¿La "magia" de la JVM obstaculiza la influencia que tiene un programador sobre las micro optimizaciones en Java? Recientemente leí en C ++ que a veces la ordenación de los miembros de datos puede proporcionar optimizaciones (concedidas, en el entorno de microsegundos) y supuse que las manos de un programador están atadas cuando se trata de exprimir el rendimiento de Java.
Aprecio que un algoritmo decente proporcione mayores ganancias de velocidad, pero una vez que tiene el algoritmo correcto, ¿es más difícil ajustar Java debido al control JVM?
De lo contrario, ¿podrían las personas dar ejemplos de los trucos que puede usar en Java (además de simples indicadores de compilación).
java
c++
performance
latency
usuario997112
fuente
fuente
Respuestas:
Claro, en el nivel de microoptimización, la JVM hará algunas cosas sobre las que tendrá poco control en comparación con C y C ++ especialmente.
Por otro lado, la variedad de comportamientos del compilador con C y C ++ especialmente tendrá un impacto negativo mucho mayor en su capacidad para realizar microoptimizaciones de cualquier manera vagamente portátil (incluso en las revisiones del compilador).
Depende de qué tipo de proyecto esté ajustando, a qué entornos se dirija, etc. Y al final, realmente no importa, ya que está obteniendo unos pocos órdenes de magnitud mejores resultados de las optimizaciones algorítmicas / de estructura de datos / diseño de programas de todos modos.
fuente
Las micro optimizaciones casi nunca valen la pena, y los compiladores y los tiempos de ejecución realizan casi todas las tareas fáciles automáticamente.
Sin embargo, hay un área importante de optimización en la que C ++ y Java son fundamentalmente diferentes, y es el acceso a memoria masiva. C ++ tiene administración de memoria manual, lo que significa que puede optimizar el diseño de datos de la aplicación y los patrones de acceso para hacer un uso completo de los cachés. Esto es bastante difícil, algo específico para el hardware en el que se está ejecutando (por lo que las ganancias de rendimiento pueden desaparecer en hardware diferente), pero si se hace correctamente, puede conducir a un rendimiento absolutamente impresionante. Por supuesto, pagas por ello con la posibilidad de todo tipo de errores horribles.
Con un lenguaje recolectado como basura, Java, este tipo de optimizaciones no se pueden hacer en el código. Algunos se pueden hacer en tiempo de ejecución (automáticamente o mediante la configuración, ver más abajo), y otros simplemente no son posibles (el precio que paga por estar protegido contra errores de administración de memoria).
Los indicadores del compilador son irrelevantes en Java porque el compilador de Java casi no optimiza; el tiempo de ejecución lo hace.
Y, de hecho, los tiempos de ejecución de Java tienen una multitud de parámetros que se pueden ajustar, especialmente en relación con el recolector de basura. No hay nada "simple" en esas opciones: los valores predeterminados son buenos para la mayoría de las aplicaciones, y para obtener un mejor rendimiento es necesario que comprenda exactamente qué hacen las opciones y cómo se comporta su aplicación.
fuente
Los microsegundos se suman si estamos pasando de millones a miles de millones de cosas. Una sesión personal de vtune / micro-optimización de C ++ (sin mejoras algorítmicas):
Todo, además de "multihilo", "SIMD" (escrito a mano para vencer al compilador) y la optimización del parche de 4 valencia, eran optimizaciones de memoria a nivel micro. Además, el código original a partir de los tiempos iniciales de 32 segundos ya estaba bastante optimizado (complejidad algorítmica teóricamente óptima) y esta es una sesión reciente. La versión original mucho antes de esta sesión reciente tardó más de 5 minutos en procesarse.
La optimización de la eficiencia de la memoria puede ayudar a menudo desde varias veces hasta órdenes de magnitud en un contexto de subproceso único y más en contextos multiproceso (los beneficios de un representante de memoria eficiente a menudo se multiplican con múltiples subprocesos en la mezcla).
Sobre la importancia de la microoptimización
Me inquieta un poco esta idea de que las micro optimizaciones son una pérdida de tiempo. Estoy de acuerdo en que es un buen consejo general, pero no todos lo hacen incorrectamente en base a corazonadas y supersticiones en lugar de mediciones. Hecho correctamente, no necesariamente produce un micro impacto. Si tomamos el propio Embree (núcleo de trazado de rayos) de Intel y probamos solo el BVH escalar simple que han escrito (no el paquete de rayos que es exponencialmente más difícil de superar), y luego intentamos superar el rendimiento de esa estructura de datos, puede ser muy útil. experiencia humilde incluso para un veterano acostumbrado a perfilar y ajustar el código durante décadas. Y todo se debe a las micro optimizaciones aplicadas. Su solución puede procesar más de cien millones de rayos por segundo cuando he visto profesionales industriales trabajando en trazado de rayos que pueden '
No hay forma de llevar a cabo una implementación directa de un BVH con solo un enfoque algorítmico y obtener más de cien millones de intersecciones de rayos primarios por segundo con cualquier compilador optimizador (incluso el propio ICC de Intel). Una sencilla a menudo ni siquiera recibe un millón de rayos por segundo. Se necesitan soluciones de calidad profesional para obtener incluso algunos millones de rayos por segundo. Se necesita micro-optimización de nivel Intel para obtener más de cien millones de rayos por segundo.
Algoritmos
Creo que la microoptimización no es importante siempre que el rendimiento no sea importante a nivel de minutos a segundos, por ejemplo, u horas a minutos. Si tomamos un algoritmo horrible como el ordenamiento de burbujas y lo usamos sobre una entrada masiva como ejemplo, y luego lo comparamos incluso con una implementación básica de ordenamiento por fusión, el primero puede tardar meses en procesarse, el último tal vez 12 minutos, como resultado de complejidad cuadrática vs linealitmica.
La diferencia entre meses y minutos probablemente hará que la mayoría de las personas, incluso aquellas que no trabajan en campos críticos para el rendimiento, consideren que el tiempo de ejecución es inaceptable si requiere que los usuarios esperen meses para obtener un resultado.
Mientras tanto, si comparamos la ordenación de fusión directa no micro-optimizada con la ordenación rápida (que no es en absoluto algorítmicamente superior a la ordenación de fusión, y solo ofrece mejoras a nivel micro para la localidad de referencia), la ordenación rápida micro-optimizada podría terminar en 15 segundos en lugar de 12 minutos. Hacer que los usuarios esperen 12 minutos puede ser perfectamente aceptable (tiempo de descanso para tomar café).
Creo que esta diferencia es probablemente insignificante para la mayoría de las personas entre, digamos, 12 minutos y 15 segundos, y es por eso que la micro-optimización a menudo se considera inútil, ya que a menudo solo es como la diferencia entre minutos y segundos, y no minutos y meses. La otra razón por la que creo que se considera inútil es que a menudo se aplica a áreas que no importan: alguna pequeña área que ni siquiera es irregular y crítica que produce una diferencia cuestionable del 1% (que muy bien podría ser solo ruido). Pero para las personas que se preocupan por este tipo de diferencias de tiempo y están dispuestas a medir y hacerlo bien, creo que vale la pena prestar atención al menos a los conceptos básicos de la jerarquía de memoria (específicamente los niveles superiores relacionados con fallas de página y errores de caché) .
Java deja mucho espacio para buenas micro optimizaciones
Uf, lo siento, con ese tipo de despotricar a un lado:
Un poco, pero no tanto como la gente podría pensar si lo haces bien. Por ejemplo, si está procesando imágenes, en código nativo con SIMD manuscrita, multiprocesamiento y optimizaciones de memoria (patrones de acceso y posiblemente incluso representación dependiendo del algoritmo de procesamiento de imágenes), es fácil procesar cientos de millones de píxeles por segundo durante 32- Píxeles RGBA (canales de color de 8 bits) y, a veces, incluso miles de millones por segundo.
Es imposible acercarse a Java si dice que hizo un
Pixel
objeto (esto solo inflaría el tamaño de un píxel de 4 bytes a 16 en 64 bits).Pero es posible que pueda acercarse mucho más si evita el
Pixel
objeto, utiliza una matriz de bytes y modela unImage
objeto. Java sigue siendo bastante competente allí si comienzas a usar matrices de datos antiguos simples. He intentado este tipo de cosas antes en Java y me impresionó bastante, siempre y cuando no crees un montón de pequeños objetos en todas partes que sean 4 veces más grandes de lo normal (por ejemplo: use enint
lugar deInteger
) y comience a modelar interfaces masivas como unImage
interfaz, noPixel
interfaz. Incluso me atrevería a decir que Java puede competir con el rendimiento de C ++ si está recorriendo datos antiguos y no objetos (grandes matrices defloat
, por ejemplo, noFloat
).Quizás aún más importante que los tamaños de memoria es que una serie de
int
garantías garantiza una representación contigua. Una serie deInteger
no. La contigüidad es a menudo esencial para la localidad de referencia, ya que significa que múltiples elementos (ej .: 16ints
) pueden caber en una sola línea de caché y potencialmente acceder a ellos juntos antes del desalojo con patrones eficientes de acceso a la memoria. Mientras tanto, un soloInteger
puede quedar varado en algún lugar de la memoria, ya que la memoria circundante es irrelevante, solo para que esa región de memoria se cargue en una línea de caché solo para usar un solo entero antes del desalojo en lugar de 16 enteros. Incluso si tenemos una suerte maravillosa y nos rodeanIntegers
estaban bien uno al lado del otro en la memoria, solo podemos caber 4 en una línea de caché a la que se puede acceder antes del desalojo como resultado deInteger
ser 4 veces más grande, y eso es en el mejor de los casos.Y hay muchas micro optimizaciones que se pueden tener allí ya que estamos unificados bajo la misma arquitectura / jerarquía de memoria. Los patrones de acceso a la memoria no importan, sin importar el lenguaje que use, los conceptos como el mosaico / bloqueo de bucles generalmente se aplican con mucha más frecuencia en C o C ++, pero benefician a Java de la misma manera.
El orden de los miembros de datos generalmente no importa en Java, pero eso es principalmente algo bueno. En C y C ++, preservar el orden de los miembros de datos a menudo es importante por razones ABI, por lo que los compiladores no se meten con eso. Los desarrolladores humanos que trabajan allí deben tener cuidado de hacer cosas como organizar sus miembros de datos en orden descendente (de mayor a menor) para evitar desperdiciar memoria en el relleno. Con Java, aparentemente el JIT puede reordenar los miembros sobre la marcha para garantizar una alineación adecuada mientras minimiza el relleno, por lo que, siempre que sea así, automatiza algo que los programadores promedio de C y C ++ a menudo pueden hacer mal y terminan desperdiciando memoria de esa manera ( que no solo es desperdiciar memoria, sino que a menudo desperdicia velocidad aumentando el paso entre las estructuras de AoS innecesariamente y causando más errores de caché). Eso' Es muy robótico reorganizar los campos para minimizar el relleno, por lo que idealmente los humanos no se ocupan de eso. El único momento en que la disposición de los campos puede ser importante de una manera que requiera que un humano conozca la disposición óptima es si el objeto es mayor que 64 bytes y estamos organizando los campos según el patrón de acceso (no el relleno óptimo), en cuyo caso podría ser un esfuerzo más humano (requiere comprender rutas críticas, parte de la cual es información que un compilador no puede anticipar sin saber qué harán los usuarios con el software).
La mayor diferencia para mí en términos de una mentalidad optimizadora entre Java y C ++ es que C ++ podría permitirle usar objetos un poco (más) un poco más que Java en un escenario de rendimiento crítico. Por ejemplo, C ++ puede ajustar un número entero a una clase sin ningún tipo de sobrecarga (referencia en todo el lugar). Java debe tener esa metadata estilo puntero + relleno de alineación sobrecarga por objeto, por eso
Boolean
es más grande queboolean
(pero a cambio proporciona beneficios uniformes de reflexión y la capacidad de anular cualquier función que no esté marcada comofinal
para cada UDT individual).Es un poco más fácil en C ++ controlar la contigüidad de los diseños de memoria en campos no homogéneos (por ejemplo, entrelazar flotadores e ints en una matriz a través de una estructura / clase), ya que la localidad espacial a menudo se pierde (o al menos se pierde el control) en Java al asignar objetos a través del GC.
... pero a menudo las soluciones de mayor rendimiento a menudo las dividirán de todos modos y usarán un patrón de acceso SoA sobre matrices contiguas de datos antiguos simples. Por lo tanto, para las áreas que necesitan un rendimiento máximo, las estrategias para optimizar el diseño de la memoria entre Java y C ++ son a menudo las mismas, y a menudo lo harán demoler esas pequeñas interfaces orientadas a objetos en favor de las interfaces de estilo de colección que pueden hacer cosas como hot / división en campo frío, repeticiones de SoA, etc. Las repeticiones de AoSoA no homogéneas parecen un poco imposibles en Java (a menos que haya utilizado una matriz cruda de bytes o algo así), pero esos son para casos raros donde amboslos patrones de acceso secuencial y aleatorio deben ser rápidos y, al mismo tiempo, tener una combinación de tipos de campo para campos calientes. Para mí, la mayor parte de la diferencia en la estrategia de optimización (en el tipo general de nivel) entre estos dos es discutible si está alcanzando el máximo rendimiento.
Las diferencias varían un poco más si simplemente está buscando un "buen" rendimiento: no poder hacer tanto con objetos pequeños como
Integer
vs.int
puede ser un poco más PITA, especialmente con la forma en que interactúa con los genéricos . Es un poco más difícil Sólo construir una estructura de datos genérica como objetivo la optimización central en Java que funciona paraint
,float
, etc., evitando aquellas UDT más grandes y caros, pero a menudo las zonas más críticas para el desempeño requerirá mano de laminación en sus propias estructuras de datos sintonizado para un propósito muy específico de todos modos, por lo que solo es molesto para el código que se esfuerza por obtener un buen rendimiento pero no un rendimiento máximo.Objeto de arriba
Tenga en cuenta que la sobrecarga de objetos Java (metadatos y pérdida de localidad espacial y pérdida temporal de localidad temporal después de un ciclo inicial de GC) a menudo es grande para cosas que son realmente pequeñas (como
int
vs.Integer
) que están siendo almacenadas por millones en alguna estructura de datos que es en gran parte contigua y se accede en bucles muy apretados. Parece haber mucha sensibilidad sobre este tema, por lo que debo aclarar que no debe preocuparse por la sobrecarga de objetos para objetos grandes como imágenes, solo objetos realmente minúsculos como un solo píxel.Si alguien se siente dudoso sobre esta parte, sugeriría hacer un punto de referencia entre sumar un millón aleatorio
ints
versus un millón aleatorioIntegers
y hacer esto repetidamente (Integers
se reorganizará en la memoria después de un ciclo GC inicial).Último truco: diseños de interfaz que dejan espacio para optimizar
Entonces, el mejor truco de Java, según lo veo, si se trata de un lugar que maneja una carga pesada sobre objetos pequeños (por ejemplo: a
Pixel
, un vector 4, una matriz 4x4, aParticle
, posiblemente inclusoAccount
si solo tiene unos pocos campos) es evitar el uso de objetos para estas cosas pequeñas y usar matrices (posiblemente encadenados) de datos antiguos simples. Los objetos se convierten entonces en las interfaces de colección comoImage
,ParticleSystem
,Accounts
, una colección de matrices o vectores, etc. las individuales se puede acceder mediante un índice, por ejemplo, Este es también uno de los últimos trucos de diseño en C y C ++, ya que incluso sin que los gastos generales objeto básico y memoria desarticulada, modelar la interfaz al nivel de una sola partícula impide las soluciones más eficientes.fuente
user204677
fue. Qué gran respuesta.Hay un área intermedia entre la microoptimización, por un lado, y la buena elección del algoritmo, por el otro.
Es el área de aceleraciones de factor constante, y puede producir órdenes de magnitud.
La forma en que lo hace es cortando fracciones enteras del tiempo de ejecución, como primero el 30%, luego el 20% de lo que queda, luego el 50% de eso, y así sucesivamente durante varias iteraciones, hasta que casi no quede nada.
No ves esto en pequeños programas de estilo demo. Donde lo ve es en grandes programas serios con muchas estructuras de datos de clase, donde la pila de llamadas suele tener muchas capas de profundidad. Una buena manera de encontrar las oportunidades de aceleración es examinando muestras de tiempo aleatorio del estado del programa.
En general, las aceleraciones consisten en cosas como:
minimizando las llamadas
new
agrupando y reutilizando objetos antiguos,Reconociendo las cosas que se están haciendo allí, por el bien de la generalidad, en lugar de ser realmente necesarias,
revisando la estructura de datos mediante el uso de diferentes clases de recopilación que tienen el mismo comportamiento big-O pero aprovechan los patrones de acceso realmente utilizados,
guardar datos que han sido adquiridos por llamadas a funciones en lugar de volver a llamar a la función (es una tendencia natural y divertida de los programadores suponer que las funciones que tienen nombres más cortos se ejecutan más rápido).
tolerar una cierta cantidad de inconsistencia entre las estructuras de datos redundantes, en lugar de tratar de mantenerlas completamente consistentes con los eventos de notificación,
etcétera etcétera.
Pero, por supuesto, ninguna de estas cosas debe hacerse sin antes demostrar que se trata de problemas al tomar muestras.
fuente
Java (hasta donde yo sé) no le da control sobre las ubicaciones variables en la memoria, por lo que le resulta más difícil evitar cosas como el intercambio falso y la alineación de variables (puede rellenar una clase con varios miembros no utilizados). Otra cosa que no creo que pueda aprovechar son instrucciones como
mmpause
, pero estas cosas son específicas de la CPU, por lo que si cree que lo necesita, Java puede no ser el lenguaje para usar.Existe la clase insegura que le brinda flexibilidad de C / C ++ pero también con el peligro de C / C ++.
Podría ayudarlo a mirar el código de ensamblaje que genera la JVM para su código
Para leer sobre una aplicación Java que analiza este tipo de detalles, consulte el código de disruptor publicado por LMAX
fuente
Esta pregunta es muy difícil de responder, porque depende de las implementaciones de lenguaje.
En general, hay muy poco espacio para tales "micro optimizaciones" en estos días. La razón principal es que los compiladores aprovechan tales optimizaciones durante la compilación. Por ejemplo, no hay diferencia de rendimiento entre los operadores anteriores y posteriores al incremento en situaciones donde su semántica es idéntica. Otro ejemplo sería, por ejemplo, un ciclo como este
for(int i=0; i<vec.size(); i++)
donde uno podría argumentar que en lugar de llamar alsize()
función de miembro durante cada iteración, sería mejor obtener el tamaño del vector antes del bucle y luego compararlo con esa única variable y así evitar la función de una llamada por iteración. Sin embargo, hay casos en los que un compilador detectará este caso tonto y almacenará en caché el resultado. Sin embargo, esto solo es posible cuando la función no tiene efectos secundarios y el compilador puede estar seguro de que el tamaño del vector permanece constante durante el ciclo, por lo que simplemente se aplica a casos bastante triviales.fuente
const
métodos en este vector, estoy bastante seguro de que muchos compiladores de optimización lo resolverán.Además de las mejoras de los algoritmos, asegúrese de considerar la jerarquía de la memoria y cómo la utiliza el procesador. Hay grandes beneficios en la reducción de las latencias de acceso a la memoria, una vez que comprende cómo el lenguaje en cuestión asigna memoria a sus tipos de datos y objetos.
Ejemplo de Java para acceder a una matriz de 1000x1000 ints
Considere el siguiente código de muestra: accede a la misma área de memoria (una matriz de entradas de 1000x1000), pero en un orden diferente. En mi Mac mini (Core i7, 2.7 GHz) la salida es la siguiente, que muestra que atravesar la matriz por filas más que duplica el rendimiento (promedio de más de 100 rondas cada una).
Esto se debe a que la matriz se almacena de manera que las columnas consecutivas (es decir, los valores int) se colocan adyacentes en la memoria, mientras que las filas consecutivas no. Para que el procesador realmente use los datos, debe transferirse a sus cachés. La transferencia de memoria se realiza mediante un bloque de bytes, llamado línea de caché ; cargar una línea de caché directamente desde la memoria introduce latencias y, por lo tanto, disminuye el rendimiento de un programa.
Para el Core i7 (puente arenoso), una línea de caché contiene 64 bytes, por lo que cada acceso a la memoria recupera 64 bytes. Debido a que la primera prueba accede a la memoria en una secuencia predecible, el procesador buscará previamente los datos antes de que el programa los consuma realmente. En general, esto da como resultado una menor latencia en los accesos a la memoria y, por lo tanto, mejora el rendimiento.
Código de muestra:
fuente
El JVM puede interferir, y a menudo lo hace, y el compilador JIT puede cambiar significativamente entre versiones. Algunas micro optimizaciones son imposibles en Java debido a limitaciones de lenguaje, como ser amigable con el subproceso o la última colección SIMD de los procesadores Intel.
Se recomienda leer un blog altamente informativo sobre el tema de uno de los autores de Disruptor :
Siempre hay que preguntarse por qué molestarse en usar Java si desea micro optimizaciones, existen muchos métodos alternativos para la aceleración de una función, como usar JNA o JNI para pasar a una biblioteca nativa.
fuente