¿El estándar C ++ exige un rendimiento deficiente para iostreams, o solo estoy tratando con una implementación deficiente?

197

Cada vez que menciono el lento rendimiento de los iostreams de la biblioteca estándar de C ++, me encuentro con una ola de incredulidad. Sin embargo, tengo resultados del generador de perfiles que muestran grandes cantidades de tiempo invertido en el código de la biblioteca iostream (optimizaciones completas del compilador), y el cambio de iostreams a API de E / S específicas del sistema operativo y la gestión personalizada del búfer da una mejora en el orden de magnitud.

¿Qué trabajo adicional está haciendo la biblioteca estándar de C ++, es requerido por el estándar y es útil en la práctica? ¿O algunos compiladores proporcionan implementaciones de iostreams que son competitivas con la gestión manual del búfer?

Puntos de referencia

Para que las cosas se muevan, he escrito un par de programas cortos para ejercer el búfer interno iostreams:

Tenga en cuenta que las versiones ostringstreamy stringbufejecutan menos iteraciones porque son mucho más lentas.

En ideone, ostringstreames aproximadamente 3 veces más lento que std:copy+ back_inserter+ std::vector, y aproximadamente 15 veces más lento que memcpyen un búfer sin procesar. Esto se siente consistente con el perfil de antes y después cuando cambié mi aplicación real al almacenamiento en búfer personalizado.

Todos estos son buffers en memoria, por lo que la lentitud de los iostreams no se puede atribuir a la E / S de disco lento, demasiado enjuague, sincronización con stdio o cualquiera de las otras cosas que las personas usan para excusar la lentitud observada de la biblioteca estándar de C ++ iostream

Sería bueno ver puntos de referencia en otros sistemas y comentarios sobre las cosas que hacen las implementaciones comunes (como libc ++ de gcc, Visual C ++, Intel C ++) y qué cantidad de sobrecarga es requerida por el estándar.

Justificación de esta prueba

Varias personas han señalado correctamente que los iostreams se usan más comúnmente para la salida formateada. Sin embargo, también son la única API moderna proporcionada por el estándar C ++ para el acceso a archivos binarios. Pero la verdadera razón para realizar pruebas de rendimiento en el almacenamiento en búfer interno se aplica a la E / S formateada típica: si iostreams no puede mantener el controlador de disco suministrado con datos sin procesar, ¿cómo pueden mantener el ritmo cuando también son responsables del formateo?

Tiempo de referencia

Todos estos son por iteración del kbucle externo ( ).

En ideone (gcc-4.3.4, sistema operativo y hardware desconocidos):

  • ostringstream: 53 milisegundos
  • stringbuf: 27 ms
  • vector<char>y back_inserter: 17,6 ms
  • vector<char> con iterador ordinario: 10,6 ms
  • vector<char> iterador y verificación de límites: 11.4 ms
  • char[]: 3,7 ms

En mi computadora portátil (Visual C ++ 2010 x86`` cl /Ox /EHscWindows 7 Ultimate 64-bit, Intel Core i7, 8 GB RAM):

  • ostringstream: 73,4 milisegundos, 71,6 ms
  • stringbuf: 21,7 ms, 21,3 ms
  • vector<char>y back_inserter: 34,6 ms, 34,4 ms
  • vector<char> con iterador ordinario: 1.10 ms, 1.04 ms
  • vector<char> iterador y verificación de límites: 1.11 ms, 0.87 ms, 1.12 ms, 0.89 ms, 1.02 ms, 1.14 ms
  • char[]: 1,48 ms, 1,57 ms

Visual C ++ 2010 x 86, con perfil guiada por la optimización cl /Ox /EHsc /GL /c, link /ltcg:pgi, carrera, link /ltcg:pgo, medida:

  • ostringstream: 61,2 ms, 60,5 ms
  • vector<char> con iterador ordinario: 1.04 ms, 1.03 ms

La misma computadora portátil, el mismo sistema operativo, usando cygwin gcc 4.3.4 g++ -O3:

  • ostringstream: 62,7 ms, 60,5 ms
  • stringbuf: 44,4 ms, 44,5 ms
  • vector<char>y back_inserter: 13,5 ms, 13,6 ms
  • vector<char> con iterador ordinario: 4.1 ms, 3.9 ms
  • vector<char> iterador y verificación de límites: 4.0 ms, 4.0 ms
  • char[]: 3,57 ms, 3,75 ms

Mismo equipo portátil, Visual C ++ 2008 SP1, cl /Ox /EHsc:

  • ostringstream: 88,7 ms, 87,6 ms
  • stringbuf: 23,3 ms, 23,4 ms
  • vector<char>y back_inserter: 26,1 ms, 24,5 ms
  • vector<char> con iterador ordinario: 3.13 ms, 2.48 ms
  • vector<char> iterador y verificación de límites: 2.97 ms, 2.53 ms
  • char[]: 1,52 ms, 1,25 ms

Mismo portátil, compilador de Visual C ++ 2010 de 64 bits:

  • ostringstream: 48,6 ms, 45,0 ms
  • stringbuf: 16,2 ms, 16,0 ms
  • vector<char>y back_inserter: 26,3 ms, 26,5 ms
  • vector<char> con iterador ordinario: 0,87 ms, 0,89 ms
  • vector<char> iterador y verificación de límites: 0,99 ms, 0,99 ms
  • char[]: 1,25 ms, 1,24 ms

EDITAR: corrió todo dos veces para ver qué tan consistentes fueron los resultados. OMI bastante consistente.

NOTA: en mi computadora portátil, dado que puedo ahorrar más tiempo de CPU de lo que permite ideone, configuro el número de iteraciones en 1000 para todos los métodos. Esto significa que ostringstreamy la vectorreasignación, que se realiza solo en el primer pase, debería tener poco impacto en los resultados finales.

EDITAR: Vaya, se encontró un error en el vectoriterador -with-ordinario, el iterador no se estaba avanzando y, por lo tanto, había demasiados hits de caché. Me preguntaba cómo vector<char>estaba superando char[]. Sin embargo, no hizo mucha diferencia, vector<char>todavía es más rápido que char[]con VC ++ 2010.

Conclusiones

El almacenamiento en búfer de las secuencias de salida requiere tres pasos cada vez que se agregan datos:

  • Compruebe que el bloque entrante se ajusta al espacio de búfer disponible.
  • Copia el bloque entrante.
  • Actualice el puntero de fin de datos.

El último fragmento de código que publiqué, " vector<char>iterador simple más verificación de límites" no solo hace esto, sino que también asigna espacio adicional y mueve los datos existentes cuando el bloque entrante no encaja. Como señaló Clifford, el almacenamiento en búfer en una clase de E / S de archivo no tendría que hacer eso, simplemente vaciaría el búfer actual y lo reutilizaría. Por lo tanto, esto debería ser un límite superior en el costo de la producción de almacenamiento en búfer. Y es exactamente lo que se necesita para hacer un búfer en memoria que funcione.

Entonces, ¿por qué es stringbuf2.5 veces más lento en ideone, y al menos 10 veces más lento cuando lo pruebo? No se está utilizando polimórficamente en este simple micro-punto de referencia, por lo que eso no lo explica.

Ben Voigt
fuente
24
Estás escribiendo un millón de caracteres de uno en uno y te preguntas por qué es más lento que copiarlo en un búfer preasignado.
Anon
20
@Anon: estoy almacenando en memoria intermedia cuatro millones de bytes cuatro por vez, y sí, me pregunto por qué eso es lento. Si std::ostringstreamno es lo suficientemente inteligente como para aumentar exponencialmente su tamaño de búfer como lo std::vectorhace, eso es (A) estúpido y (B) algo en lo que las personas que piensan sobre el rendimiento de E / S deberían pensar. De todos modos, el búfer se reutiliza, no se reasigna cada vez. Y std::vectortambién está utilizando un búfer de crecimiento dinámico. Estoy tratando de ser justo aquí.
Ben Voigt
14
¿Qué tarea estás tratando de comparar? Si no está utilizando ninguna de las funciones de formato ostringstreamy desea un rendimiento lo más rápido posible, entonces debería considerar ir directamente a stringbuf. Se ostreamsupone que las clases unen la funcionalidad de formato compatible con la configuración regional con la opción de almacenamiento intermedio flexible (archivo, cadena, etc.) a través de rdbuf()su interfaz de función virtual. Si no está formateando, entonces ese nivel adicional de indirección ciertamente se verá proporcionalmente caro en comparación con otros enfoques.
CB Bailey
55
+1 para la verdad op. Hemos conseguido acelerar el orden o la magnitud al pasar de ofstreama fprintfcuando se genera información de registro que involucra dobles. MSVC 2008 en WinXPsp3. iostreams es simplemente perro lento.
KitsuneYMG
66
Aquí hay algunas pruebas en el sitio del comité: open-std.org/jtc1/sc22/wg21/docs/D_5.cpp
Johannes Schaub - litb

Respuestas:

49

No responde tanto a los detalles de su pregunta como al título: el Informe técnico 2006 sobre el rendimiento de C ++ tiene una sección interesante sobre IOStreams (p.68). Lo más relevante para su pregunta se encuentra en la Sección 6.1.2 ("Velocidad de ejecución"):

Dado que ciertos aspectos del procesamiento de IOStreams se distribuyen en múltiples facetas, parece que el Estándar exige una implementación ineficiente. Pero este no es el caso: al utilizar alguna forma de preprocesamiento, se puede evitar gran parte del trabajo. Con un enlazador un poco más inteligente del que se usa normalmente, es posible eliminar algunas de estas ineficiencias. Esto se discute en §6.2.3 y §6.2.5.

Dado que el informe fue escrito en 2006, uno esperaría que muchas de las recomendaciones se hubieran incorporado a los compiladores actuales, pero quizás este no sea el caso.

Como mencionas, las facetas pueden no aparecer write()(pero no asumiría eso a ciegas). Entonces, ¿qué presenta? Ejecutar GProf en su ostringstreamcódigo compilado con GCC proporciona el siguiente desglose:

  • 44.23% en std::basic_streambuf<char>::xsputn(char const*, int)
  • 34.62% en std::ostream::write(char const*, int)
  • 12.50% en main
  • 6.73% en std::ostream::sentry::sentry(std::ostream&)
  • 0.96% en std::string::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)
  • 0.96% en std::basic_ostringstream<char>::basic_ostringstream(std::_Ios_Openmode)
  • 0.00% en std::fpos<int>::fpos(long long)

Por lo tanto, se dedica la mayor parte del tiempo xsputn, lo que finalmente requiere std::copy()después de muchas comprobaciones y actualizaciones de las posiciones del cursor y los búferes (eche un vistazo c++\bits\streambuf.tcca los detalles).

Mi opinión sobre esto es que te has centrado en la peor situación. Toda la verificación que se realice sería una pequeña fracción del trabajo total realizado si se tratara de fragmentos de datos razonablemente grandes. Pero su código está cambiando datos en cuatro bytes a la vez e incurriendo en todos los costos adicionales cada vez. Claramente, uno evitaría hacerlo en una situación de la vida real: considere cuán insignificante hubiera sido la penalización si writese hubiera llamado en una matriz de 1 millón de entradas en lugar de 1 millón de veces en un int. Y en una situación de la vida real, uno realmente apreciaría las características importantes de IOStreams, a saber, su diseño seguro para la memoria y el tipo. Tales beneficios tienen un precio, y usted ha escrito una prueba que hace que estos costos dominen el tiempo de ejecución.

beldaz
fuente
Suena como una gran información para una futura pregunta sobre el rendimiento de la inserción / extracción formateada de iostreams que probablemente preguntaré pronto. Pero no creo que haya facetas involucradas ostream::write().
Ben Voigt
44
+1 para la creación de perfiles (supongo que es una máquina Linux). Sin embargo, en realidad estoy agregando cuatro bytes a la vez (en realidad sizeof i, pero todos los compiladores con los que estoy probando tienen 4 bytes int). Y eso no me parece tan poco realista, ¿a qué tamaño crees que se pasa en cada llamada xsputnen un código típico stream << "VAR: " << var.x << ", " << var.y << endl;?
Ben Voigt
39
@beldaz: Ese ejemplo de código "típico" que solo llama xsputncinco veces podría muy bien estar dentro de un bucle que escribe un archivo de 10 millones de líneas. Pasar datos a iostreams en grandes fragmentos es mucho menos un escenario de la vida real que mi código de referencia. ¿Por qué debería tener que escribir en una secuencia almacenada con el número mínimo de llamadas? Si tengo que hacer mi propio almacenamiento en búfer, ¿cuál es el punto de los iostreams de todos modos? Y con los datos binarios, tengo la opción de almacenarlo en el búfer, cuando escribo millones de números en un archivo de texto, la opción masiva simplemente no existe, DEBO llamar operator <<a cada uno.
Ben Voigt
1
@beldaz: Uno puede estimar cuando la E / S comienza a dominar con un cálculo simple. A una velocidad de escritura promedio de 90 MB / s, que es típica de los discos duros de grado actual para el consumidor, la descarga del búfer de 4 MB toma <45 ms (el rendimiento, la latencia no es importante debido a la caché de escritura del sistema operativo). Si ejecutar el bucle interno tarda más que eso en llenar el búfer, la CPU será el factor limitante. Si el bucle interno se ejecuta más rápido, la E / S será el factor limitante, o al menos queda algo de tiempo de CPU para hacer el trabajo real.
Ben Voigt
55
Por supuesto, eso no significa que usar iostreams necesariamente signifique un programa lento. Si E / S es una parte muy pequeña del programa, el uso de una biblioteca de E / S con bajo rendimiento no tendrá un gran impacto general. Pero no ser llamado con la frecuencia suficiente para importar no es lo mismo que un buen rendimiento, y en aplicaciones pesadas de E / S, sí importa.
Ben Voigt
27

Estoy bastante decepcionado con los usuarios de Visual Studio, que prefieren darme una idea de esto:

  • En la implementación de Visual Studio de ostream, el sentryobjeto (que es requerido por el estándar) entra en una sección crítica que protege el streambuf(que no es requerido). Esto no parece ser opcional, por lo que paga el costo de la sincronización de subprocesos incluso para una secuencia local utilizada por un solo subproceso, que no tiene necesidad de sincronización.

Esto perjudica el código que se usa ostringstreampara formatear mensajes con bastante severidad. Usar stringbufdirectamente evita el uso de sentry, pero los operadores de inserción formateados no pueden funcionar directamente en streambufs. Para Visual C ++ 2010, la sección crítica se ralentiza en ostringstream::writeun factor de tres frente a la stringbuf::sputnllamada subyacente .

Mirando los datos del perfil de beldaz en newlib , parece claro que los gcc sentryno hacen nada loco como este. ostringstream::writebajo gcc solo toma aproximadamente un 50% más de tiempo stringbuf::sputn, pero en stringbufsí mismo es mucho más lento que bajo VC ++. Y ambos todavía se comparan muy desfavorablemente con el uso de un vector<char>búfer de E / S, aunque no por el mismo margen que en VC ++.

Ben Voigt
fuente
¿Esta información aún está actualizada? AFAIK, la implementación de C ++ 11 incluida con GCC realiza este bloqueo 'loco'. Ciertamente, VS2010 todavía lo hace también. ¿Alguien podría aclarar este comportamiento y si 'lo que no se requiere' aún se mantiene en C ++ 11?
mloskot
2
@mloskot: No veo ningún requisito de seguridad de subprocesos en sentry... "El centinela de clase define una clase que es responsable de realizar operaciones de sufijo y sufijo de excepción segura". y una nota "El constructor y destructor centinela también pueden realizar operaciones adicionales dependientes de la implementación". También se puede deducir del principio de C ++ de "no paga por lo que no usa" que el comité de C ++ nunca aprobaría un requisito tan inútil. Pero siéntase libre de hacer una pregunta sobre la seguridad del hilo iostream.
Ben Voigt
8

El problema que ves está en los gastos generales alrededor de cada llamada a write (). Cada nivel de abstracción que agregue (char [] -> vector -> string -> ostringstream) agrega algunas llamadas / devoluciones de funciones más y otras características de limpieza que, si lo llama un millón de veces, se suman.

Modifiqué dos de los ejemplos de ideone para escribir diez entradas a la vez. El tiempo de ostringstream pasó de 53 a 6 ms (casi 10 veces mejoría) mientras que el ciclo de char mejoró (3.7 a 1.5), útil, pero solo por un factor de dos.

Si le preocupa tanto el rendimiento, debe elegir la herramienta adecuada para el trabajo. ostringstream es útil y flexible, pero hay una penalización por usarlo de la manera que lo intentas. char [] es un trabajo más duro, pero las ganancias de rendimiento pueden ser excelentes (recuerde que el gcc probablemente también incluirá los memcpys para usted).

En resumen, ostringstream no está roto, pero cuanto más te acerques al metal, más rápido se ejecutará tu código. Assembler todavía tiene ventajas para algunas personas.

Roddy
fuente
8
¿Qué ostringstream::write()tiene que hacer que vector::push_back()no? En todo caso, debería ser más rápido ya que se entrega un bloque en lugar de cuatro elementos individuales. Si ostringstreames más lento que std::vectorsin proporcionar ninguna característica adicional, entonces sí lo llamaría roto.
Ben Voigt
1
@Ben Voigt: Por el contrario, es algo que el vector tiene que hacer que el flujo ostring NO tiene que hacer para que el vector sea más eficiente en este caso. Se garantiza que Vector es contiguo en la memoria, mientras que ostringstream no lo es. Vector es una de las clases diseñadas para ser eficaz, mientras que ostringstream no lo es.
Dragontamer5788
2
@Ben Voigt: el uso stringbufdirecto no eliminará todas las llamadas a funciones, ya que stringbufla interfaz pública consiste en funciones públicas no virtuales en la clase base que luego se envían a la función virtual protegida en la clase derivada.
CB Bailey
2
@Charles: en cualquier compilador decente debería, ya que la llamada a la función pública se integrará en un contexto donde el compilador conoce el tipo dinámico, puede eliminar la indirección e incluso alinear esas llamadas.
Ben Voigt
66
@ Roddy: Debería pensar que todo esto es código de plantilla en línea, visible en cada unidad de compilación. Pero supongo que eso podría variar según la implementación. Ciertamente, esperaría que la llamada en discusión, la sputnfunción pública que llama a lo virtual protegido xsputn, esté en línea. Incluso si xsputnno está en línea, el compilador puede, mientras está en línea sputn, determinar la xsputnanulación exacta necesaria y generar una llamada directa sin pasar por la tabla.
Ben Voigt
1

Para obtener un mejor rendimiento, debe comprender cómo funcionan los contenedores que está utilizando. En su ejemplo de matriz char [], la matriz del tamaño requerido se asigna por adelantado. En su ejemplo de vector y ostringstream, está obligando a los objetos a asignar y reasignar repetidamente y posiblemente copiar datos muchas veces a medida que el objeto crece.

Con std :: vector, esto se resuelve fácilmente inicializando el tamaño del vector al tamaño final como lo hizo con la matriz de caracteres; ¡en lugar de eso, paraliza injustamente el rendimiento cambiando el tamaño a cero! Esa no es una comparación justa.

Con respecto al ostringstream, no es posible preasignar el espacio, sugeriría que es un uso inapropiado. La clase tiene una utilidad mucho mayor que una simple matriz de caracteres, pero si no necesita esa utilidad, no la use, porque en cualquier caso pagará los gastos generales. En su lugar, debe usarse para lo que es bueno: formatear datos en una cadena. C ++ proporciona una amplia gama de contenedores y un ostringstram es uno de los menos apropiados para este propósito.

En el caso del vector y el ostringstream, obtienes protección contra el desbordamiento del búfer, no lo obtienes con una matriz de caracteres, y esa protección no es gratuita.

Clifford
fuente
1
La asignación no parece ser el problema para Ostringstream. Simplemente busca volver a cero para las iteraciones posteriores. Sin truncamiento También lo intenté ostringstream.str.reserve(4000000)y no hizo ninguna diferencia.
Roddy
Creo que con ostringstream, podría "reservar" pasando una cadena ficticia, es decir: ostringstream str(string(1000000 * sizeof(int), '\0'));con vector, resizeno desasigna ningún espacio, solo se expande si es necesario.
Nim
1
"vector ... protección contra el desbordamiento del búfer". Una idea falsa común: el vector[]operador generalmente NO se verifica por errores de límites de forma predeterminada. vector.at()es sin embargo.
Roddy
2
vector<T>::resize(0)no suele reasignar la memoria
Niki Yoshiuchi
2
@Roddy: no está utilizando operator[], pero push_back()(a modo de back_inserter), lo que definitivamente prueba el desbordamiento. Se agregó otra versión que no usa push_back.
Ben Voigt