¿Una forma idiomática de evaluación del desempeño?
Estoy evaluando una carga de trabajo de red+renderizado para mi proyecto.
El programa ejecuta continuamente un bucle principal:
while (true) {
doSomething()
drawSomething()
doSomething2()
sendSomething()
}
El bucle principal se ejecuta más de 60 veces por segundo.
Quiero ver el desglose del rendimiento y cuánto tiempo lleva cada procedimiento.
Mi preocupación es que si imprimo el intervalo de tiempo para cada entrada y salida de cada trámite,
Supondría una enorme sobrecarga de rendimiento.
Tengo curiosidad por saber cuál es una forma idiomática de medir el desempeño.
¿La impresión del registro es suficientemente buena?
Generalmente: para cosas cortas repetidas, puedes cronometrar todo el ciclo de repetición. (Pero el microbenchmarking es difícil; es fácil distorsionar los resultados a menos que comprenda las implicaciones de hacerlo; para cosas muy breves, el rendimiento y la latencia son diferentes, así que mida ambos por separado haciendo que una iteración use o no el resultado de la anterior. También tenga en cuenta que La predicción de bifurcaciones y el almacenamiento en caché pueden hacer que algo parezca rápido en un microbenchmark cuando en realidad sería costoso si se hiciera uno a la vez entre otros trabajos en un programa más grande. Por ejemplo, el desarrollo de bucles y las tablas de búsqueda a menudo se ven bien porque no hay presión sobre I-cache o D-cache de cualquier otra cosa).
O si insiste en cronometrar cada iteración por separado, registre los resultados en una matriz e imprímalos más tarde; no desea invocar código de impresión pesado dentro de su bucle.
Esta pregunta es demasiado amplia para decir algo más específico.
Muchos lenguajes tienen paquetes de evaluación comparativa que lo ayudarán a escribir microbenchmarks de una sola función. Usalos, usalos a ellos . por ejemplo, para Java, JMH se asegura de que la función bajo prueba esté precalentada y completamente optimizada por el JIT, y todo ese jazz, antes de realizar ejecuciones cronometradas. Y lo ejecuta durante un intervalo específico, contando cuántas iteraciones completa. Consulte ¿Cómo escribo un microbenchmark correcto en Java? por eso y más.
Tenga cuidado con los errores comunes de los microbenchmarks
Fallo al calentar el caché de código/datos y demás: fallas de página dentro de la región cronometrada para tocar nueva memoria, o errores de caché de código/datos, eso no sería parte del funcionamiento normal. (Ejemplo de cómo notar este efecto: Rendimiento: memset ; o ejemplo de una conclusión errónea basada en este error )
La memoria nunca escrita (obtenida recién hecha del kernel) obtiene todas sus páginas de copia en escritura asignadas a la misma página física de ceros en todo el sistema (4K o 2M) si lee sin escribir, al menos en Linux . Por lo tanto, puede obtener aciertos de caché pero falla TLB. por ejemplo, una asignación grande de
new
// o una matriz inicializadacalloc
enmalloc
cero en un almacenamiento estático en.bss
. Utilice un inicializador o memset distinto de cero.No darle tiempo a la CPU para que alcance el turbo máximo: las CPU modernas bajan a velocidades de inactividad para ahorrar energía y solo aumentan después de unos pocos milisegundos. (O más dependiendo del sistema operativo/hw).
relacionado: en x86 moderno, RDTSC cuenta los ciclos de referencia, no los ciclos de reloj central , por lo que está sujeto a los mismos efectos de variación de frecuencia de CPU que el tiempo del reloj de pared.
La mayoría de las instrucciones asm aritméticas de números enteros y FP ( excepto división y raíz cuadrada que ya son más lentas que otras) tienen un rendimiento (latencia y rendimiento) que no depende de los datos reales. Excepto que el punto flotante subnormal, también conocido como denormal, es muy lento y, en algunos casos (por ejemplo, x87 heredado pero no SSE2 ), la producción de NaN o Inf también puede ser lenta.
En las CPU modernas con ejecución fuera de orden, algunas cosas son demasiado cortas para cronometrarlas de manera significativa ; consulte también esto . El rendimiento de un pequeño bloque de lenguaje ensamblador (por ejemplo, generado por un compilador para una función) no se puede caracterizar por un solo número, incluso si no se bifurca ni accede a la memoria (por lo que no hay posibilidad de una predicción errónea o un error de caché). Tiene latencia de entradas a salidas, pero el rendimiento diferente si se ejecuta repetidamente con entradas independientes es mayor. por ejemplo, una
add
instrucción en una CPU Skylake tiene un rendimiento de 4/reloj, pero una latencia de 1 ciclo. Por lo tanto,dummy = foo(x)
puede ser 4 veces más rápido quex = foo(x);
en un bucle. Las instrucciones de punto flotante tienen una latencia mayor que las de números enteros, por lo que suele ser un problema mayor. El acceso a la memoria también está canalizado en la mayoría de las CPU, por lo que recorrer una matriz (la dirección para la próxima carga es fácil de calcular) suele ser mucho más rápido que recorrer una lista vinculada (la dirección para la próxima carga no está disponible hasta que se completa la carga anterior).Obviamente, el rendimiento puede diferir entre CPU; En general, generalmente es raro que la versión A sea más rápida en Intel y la versión B sea más rápida en AMD, pero eso puede suceder fácilmente a pequeña escala. Al informar/registrar números de referencia, siempre tenga en cuenta en qué CPU realizó la prueba.
En relación con los puntos anteriores y siguientes: no se puede "comparar al
*
operador" en C en general, por ejemplo. Algunos casos de uso se compilarán de manera muy diferente a otros, por ejemplo,tmp = foo * i;
en un bucle a menudo puede convertirse entmp += foo
(reducción de fuerza), o si el multiplicador es una potencia constante de 2, el compilador simplemente usará un cambio. El mismo operador en el código fuente puede compilar con instrucciones muy diferentes, dependiendo del código circundante.Debe compilar con la optimización habilitada , pero también debe evitar que el compilador optimice el trabajo o lo saque de un bucle. Asegúrese de utilizar el resultado (por ejemplo, imprimirlo o almacenarlo en un archivo
volatile
) para que el compilador tenga que producirlo. Para una matriz,volatile double sink = output[argc];
es un truco útil: el compilador no conoce el valor deargc
por lo que tiene que generar la matriz completa, pero no es necesario leer la matriz completa ni siquiera llamar a una función RNG. (A menos que el compilador se transforme agresivamente para calcular solo la salida seleccionada porargc
, pero eso no suele ser un problema en la práctica).Para las entradas, use un número aleatorio o
argc
algo así en lugar de una constante de tiempo de compilación para que su compilador no pueda hacer una propagación constante de cosas que no serán constantes en su caso de uso real. En C, a veces puedes usar asm en línea ovolatile
para esto, por ejemplo, las cosas sobre las que se pregunta esta pregunta . Un buen paquete de evaluación comparativa como Google Benchmark incluirá funciones para esto.Si el caso de uso real de una función le permite integrarse en los llamadores donde algunas entradas son constantes, o las operaciones se pueden optimizar para otros trabajos, no es muy útil compararla por sí sola.
Funciones grandes y complicadas con manejo especial para muchos casos especiales pueden parecer rápidas en un microbenchmark cuando las ejecutas repetidamente, especialmente con la misma entrada cada vez. En casos de uso de la vida real, la predicción de bifurcaciones a menudo no estará preparada para esa función con esa entrada. Además, un bucle desenrollado masivamente puede verse bien en un microbenchmark, pero en la vida real ralentiza todo lo demás con su gran huella de caché de instrucciones que conduce a la expulsión de otro código.
En relación con el último punto: no ajuste solo las entradas grandes, si el caso de uso real de una función incluye muchas entradas pequeñas. por ejemplo, una memcpy
implementación que es excelente para insumos grandes pero que lleva demasiado tiempo para determinar qué estrategia usar para insumos pequeños podría no ser buena. Es una compensación; asegúrese de que sea lo suficientemente bueno para entradas grandes (para una definición adecuada de "suficiente"), pero también mantenga los gastos generales bajos para entradas pequeñas.
Pruebas de fuego:
Si está comparando dos funciones en un programa: si invertir el orden de las pruebas cambia los resultados, su punto de referencia no es justo . Por ejemplo, es posible que la función A solo parezca lenta porque la estás probando primero, sin un calentamiento suficiente. ejemplo: ¿ Por qué std::vector es más lento que una matriz? (No lo es, el bucle que se ejecute primero tiene que pagar por todos los errores de página y de caché; el segundo simplemente se acerca para llenar la misma memoria).
Aumentar el recuento de iteraciones de un bucle de repetición debería aumentar linealmente el tiempo total y no afectar el tiempo por llamada calculado. De lo contrario, entonces tiene una sobrecarga de medición no despreciable o su código está optimizado (por ejemplo, sacado del bucle y se ejecuta solo una vez en lugar de N veces).
Varíe otros parámetros de prueba como control de cordura.
Para C/C++, consulte también La evaluación comparativa de bucle for() simple toma el mismo tiempo con cualquier bucle enlazado, donde entré en más detalles sobre la evaluación microbenchmarking y el uso volatile
o asm
para detener la optimización de trabajos importantes con gcc/clang.