Declarar múltiples matrices con 64 elementos 1000 veces más rápido que declarar una matriz de 65 elementos

91

Recientemente, noté que declarar una matriz que contiene 64 elementos es mucho más rápido (> 1000 veces) que declarar el mismo tipo de matriz con 65 elementos.

Aquí está el código que usé para probar esto:

public class Tests{
    public static void main(String args[]){
        double start = System.nanoTime();
        int job = 100000000;//100 million
        for(int i = 0; i < job; i++){
            double[] test = new double[64];
        }
        double end = System.nanoTime();
        System.out.println("Total runtime = " + (end-start)/1000000 + " ms");
    }
}

Esto se ejecuta en aproximadamente 6 ms, si reemplazo new double[64]con new double[65]que tarda aproximadamente 7 segundos. Este problema se vuelve exponencialmente más grave si el trabajo se distribuye en más y más subprocesos, que es donde se origina mi problema.

Este problema también ocurre con diferentes tipos de matrices como int[65]o String[65]. Este problema no ocurre con cadenas grandes:, String test = "many characters";pero comienza a ocurrir cuando se cambia aString test = i + "";

Me preguntaba por qué es así y si es posible eludir este problema.

Sipko
fuente
3
Fuera de nota: System.nanoTime()debería preferirse System.currentTimeMillis()a la evaluación comparativa.
rocketboy
4
Tengo curiosidad ? ¿Estás bajo Linux? ¿El comportamiento cambia con el sistema operativo?
bsd
9
¿Cómo diablos consiguió esta pregunta un voto negativo?
Rohit Jain
2
FWIW, veo discrepancias de rendimiento similares si ejecuto este código con en bytelugar de double.
Oliver Charlesworth
3
@ThomasJungblut: Entonces, ¿qué explica la discrepancia en el experimento del OP?
Oliver Charlesworth

Respuestas:

88

Está observando un comportamiento causado por las optimizaciones realizadas por el compilador JIT de su máquina virtual Java. Este comportamiento se puede reproducir con matrices escalares de hasta 64 elementos y no se activa con matrices de más de 64.

Antes de entrar en detalles, echemos un vistazo más de cerca al cuerpo del bucle:

double[] test = new double[64];

El cuerpo no tiene ningún efecto (comportamiento observable) . Eso significa que no hay diferencia fuera de la ejecución del programa si esta declaración se ejecuta o no. Lo mismo es cierto para todo el ciclo. Por lo tanto, podría suceder que el optimizador de código traduzca el bucle en algo (o nada) con el mismo comportamiento funcional y de tiempo diferente.

Para los puntos de referencia, al menos debe cumplir con las siguientes dos pautas. Si lo hubiera hecho, la diferencia habría sido significativamente menor.

  • Calienta el compilador (y el optimizador) JIT ejecutando el punto de referencia varias veces.
  • Utilice el resultado de cada expresión e imprímalo al final del punto de referencia.

Ahora entremos en detalles. No es sorprendente que exista una optimización que se active para matrices escalares que no superen los 64 elementos. La optimización es parte del análisis de Escape . Coloca objetos pequeños y arreglos pequeños en la pila en lugar de asignarlos al montón, o incluso mejor, optimizarlos por completo. Puedes encontrar algo de información al respecto en el siguiente artículo de Brian Goetz escrito en 2005:

La optimización se puede desactivar con la opción de línea de comando -XX:-DoEscapeAnalysis. El valor mágico 64 para matrices escalares también se puede cambiar en la línea de comandos. Si ejecuta su programa de la siguiente manera, no habrá diferencia entre matrices con 64 y 65 elementos:

java -XX:EliminateAllocationArraySizeLimit=65 Tests

Habiendo dicho eso, desaconsejo enfáticamente el uso de tales opciones de línea de comando. Dudo que haga una gran diferencia en una aplicación realista. Solo lo usaría si estuviera absolutamente convencido de la necesidad, y no basándome en los resultados de algunos pseudo benchmarks.

nosid
fuente
9
Pero, ¿por qué el optimizador detecta que la matriz de tamaño 64 es extraíble pero no 65
Ug_
10
@nosid: Si bien el código del OP puede no ser realista, claramente está desencadenando un comportamiento interesante / inesperado en la JVM, que puede tener implicaciones en otras situaciones. Creo que es válido preguntar por qué está sucediendo esto.
Oliver Charlesworth
1
@ThomasJungblut No creo que se elimine el bucle. Puede agregar "int total" fuera del ciclo y agregar "total + = test [0];" al ejemplo anterior. Luego, al imprimir el resultado, verá que el total = 100 millones y se ejecuta en menos de un segundo.
Sipko
1
El reemplazo en la pila consiste en reemplazar el código interpretado con compilado sobre la marcha, en lugar de reemplazar la asignación de pila con la asignación de pila. EliminateAllocationArraySizeLimit es el tamaño límite de las matrices que se consideran escalares reemplazables en el análisis de escape. Entonces, el punto principal de que el efecto se debe a la optimización del compilador es correcto, pero no se debe a la asignación de la pila, sino a que la fase de análisis de escape no se da cuenta de que la asignación no es necesaria.
kiheru
2
@Sipko: Estás escribiendo que la aplicación no se escala con el número de subprocesos. Eso es una indicación de que el problema no está relacionado con las micro optimizaciones por las que está preguntando. Recomiendo mirar el panorama general en lugar de las partes pequeñas.
nosid
2

Hay muchas formas en las que puede haber una diferencia, según el tamaño de un objeto.

Como dijo Nosid, el JITC puede estar (muy probablemente) asignando pequeños objetos "locales" en la pila, y el tamaño de corte para las matrices "pequeñas" puede ser de 64 elementos.

La asignación en la pila es significativamente más rápida que la asignación en el montón y, más concretamente, la pila no necesita ser recolectada como basura, por lo que la sobrecarga de GC se reduce considerablemente. (Y para este caso de prueba, la sobrecarga de GC es probablemente del 80 al 90% del tiempo total de ejecución).

Además, una vez que se asigna el valor a la pila, el JITC puede realizar una "eliminación de código muerto", determinar que el resultado del newnunca se utiliza en ningún lugar y, después de asegurarse de que no hay efectos secundarios que se perderían, eliminar toda la newoperación. y luego el bucle (ahora vacío) en sí.

Incluso si el JITC no realiza la asignación de pila, es completamente posible que los objetos más pequeños que un cierto tamaño se asignen en un montón de manera diferente (por ejemplo, desde un "espacio" diferente) que los objetos más grandes. (Sin embargo, normalmente esto no produciría diferencias de tiempo tan dramáticas).

Lamidas calientes
fuente
Tarde en este hilo. ¿Por qué la asignación en la pila es más rápida que la asignación en el montón? Según algunos artículos, la asignación en el montón requiere aproximadamente 12 instrucciones. No hay mucho margen de mejora.
Vortex
@Vortex: la asignación a la pila requiere de 1 a 2 instrucciones. Pero eso es para asignar un marco de pila completo. El marco de pila debe asignarse de todos modos para tener un área de guardado de registro para la rutina, por lo que cualquier otra variable asignada al mismo tiempo es "libre". Y como dije, la pila no requiere GC. La sobrecarga de GC para un elemento de montón es mucho mayor que el costo de la operación de asignación de montón.
Hot Licks