Estoy trabajando en algún código Java que necesita ser altamente optimizado ya que se ejecutará en funciones activas que se invocan en muchos puntos de la lógica de mi programa principal. Parte de este código implica multiplicar double
variables por 10
elevadas a s arbitrarias no negativas int
exponent
. Una forma rápida (editar: pero no la más rápida posible, consulte la Actualización 2 a continuación) para obtener el valor multiplicado es switch
en exponent
:
double multiplyByPowerOfTen(final double d, final int exponent) {
switch (exponent) {
case 0:
return d;
case 1:
return d*10;
case 2:
return d*100;
// ... same pattern
case 9:
return d*1000000000;
case 10:
return d*10000000000L;
// ... same pattern with long literals
case 18:
return d*1000000000000000000L;
default:
throw new ParseException("Unhandled power of ten " + power, 0);
}
}
Las elipses comentadas arriba indican que las case
int
constantes continúan incrementándose en 1, por lo que realmente hay 19 case
s en el fragmento de código anterior. Ya que no estaba seguro de si iba a necesitar realmente todas las potencias de 10, en case
declaraciones 10
a través 18
, me encontré con algunos microbenchmarks que comparan el tiempo para completar 10 millones de operaciones con esta switch
declaración frente a un switch
sólo case
s 0
thru 9
(con la exponent
limitación a 9 o menos a evite romper el recortado switch
). Obtuve el resultado bastante sorprendente (¡para mí, al menos!) De que cuanto más tiempo switch
con más case
declaraciones realmente se ejecutaba más rápido.
En una alondra, intenté agregar aún más case
s que solo devolvieron valores ficticios, y descubrí que podía hacer que el interruptor se ejecutara aún más rápido con alrededor de 22-27 case
s declarados (a pesar de que esos casos ficticios nunca se golpean mientras el código se está ejecutando ) (Nuevamente, lacase
se agregaron s de manera contigua incrementando la case
constante anterior en 1
). Estas diferencias en el tiempo de ejecución no son muy significativas: para un azar exponent
entre 0
y 10
, la switch
instrucción ficticia completa termina 10 millones de ejecuciones en 1.49 segundos versus 1.54 segundos para las sin relleno versión, para un gran ahorro total de 5ns por ejecución. Entonces, no es el tipo de cosa que hace que obsesionarse con rellenar unswitch
declaración que vale la pena desde el punto de vista de la optimización. Pero todavía me parece curioso y contraintuitivo que a switch
no se vuelve más lento (o tal vez, en el mejor de los casos, mantiene un tiempo O (1) constante ) para ejecutarse a medida que case
se agregan más s.
Estos son los resultados que obtuve al ejecutar con varios límites en el generado aleatoriamente exponent
valores . No incluí los resultados hasta 1
el exponent
límite, pero la forma general de la curva sigue siendo la misma, con una cresta alrededor de la marca del caso 12-17 y un valle entre 18-28. Todas las pruebas se ejecutaron en JUnitBenchmarks utilizando contenedores compartidos para los valores aleatorios para garantizar entradas de prueba idénticas. También ejecuté las pruebas en orden, desde la switch
declaración más larga hasta la más corta, y viceversa, para tratar de eliminar la posibilidad de problemas de prueba relacionados con el pedido. He puesto mi código de prueba en un repositorio de github si alguien quiere intentar reproducir estos resultados.
Entonces, ¿qué está pasando aquí? ¿Algunos caprichos de mi arquitectura o construcción de micro-benchmark? O es el Java switch
en realidad un poco más rápido para ejecutar en el 18
que28
case
rango de lo que es de 11
hasta 17
?
prueba de github repo "interruptor-experimento"
ACTUALIZACIÓN: Limpié bastante la biblioteca de evaluación comparativa y agregué un archivo de texto en / resultados con algo de salida en un rango más amplio de exponent
valores posibles . También he añadido una opción en el código de prueba no lanzar una Exception
de default
, pero esto no parece afectar a los resultados.
ACTUALIZACIÓN 2: Encontré una discusión bastante buena sobre este tema desde 2009 en el foro xkcd aquí: http://forums.xkcd.com/viewtopic.php?f=11&t=33524 . La discusión del OP sobre el uso Array.binarySearch()
me dio la idea de una implementación simple basada en una matriz del patrón de exponenciación anterior. No hay necesidad de la búsqueda binaria ya que sé cuáles son las entradas en el array
. Parece correr aproximadamente 3 veces más rápido que el uso switch
, obviamente a expensas de parte del flujo de control que switch
ofrece. Ese código también se ha agregado al repositorio de github.
fuente
switch
declaraciones, ya que es claramente la solución más óptima. : D (No mostrar esto a mi ventaja, por favor.)lookupswitch
a atableswitch
. Desmontar su código conjavap
le mostraría seguro.Respuestas:
Como se señaló en la otra respuesta , dado que los valores de los casos son contiguos (en lugar de dispersos), el código de bytes generado para sus diversas pruebas utiliza una tabla de interruptores (instrucción de código de bytes
tableswitch
).Sin embargo, una vez que el JIT comienza su trabajo y compila el código de bytes en el ensamblaje, la
tableswitch
instrucción no siempre da como resultado una matriz de punteros: a veces, la tabla de interruptores se transforma en lo que parece unlookupswitch
(similar a una estructuraif
/else if
).La descomposición del ensamblaje generado por el JIT (punto de acceso JDK 1.7) muestra que usa una sucesión de if / else if cuando hay 17 casos o menos, una matriz de punteros cuando hay más de 18 (más eficiente).
La razón por la cual se usa este número mágico de 18 parece reducirse al valor predeterminado de la
MinJumpTableSize
bandera JVM (alrededor de la línea 352 en el código).He planteado el problema en la lista del compilador de puntos de acceso y parece ser un legado de pruebas anteriores . Tenga en cuenta que este valor predeterminado se ha eliminado en JDK 8 después de realizar más evaluaciones comparativas .
Finalmente, cuando el método se vuelve demasiado largo (> 25 casos en mis pruebas), ya no está alineado con la configuración predeterminada de JVM, esa es la causa más probable de la caída del rendimiento en ese punto.
Con 5 casos, el código descompilado se ve así (observe las instrucciones cmp / je / jg / jmp, el ensamblaje para if / goto):
Con 18 casos, el ensamblaje se ve así (observe la matriz de punteros que se usa y suprime la necesidad de todas las comparaciones:
jmp QWORD PTR [r8+r10*1]
salta directamente a la multiplicación correcta), esa es la razón probable para la mejora del rendimiento:Y finalmente, el ensamblaje con 30 casos (a continuación) se parece a 18 casos, excepto por el adicional
movapd xmm0,xmm1
que aparece hacia la mitad del código, como lo detectó @cHao ; sin embargo, la razón más probable para la caída en el rendimiento es que el método también largo para estar alineado con la configuración predeterminada de JVM:fuente
Interruptor: el caso es más rápido si los valores del caso se colocan en un rango estrecho, por ejemplo.
Porque, en este caso, el compilador puede evitar realizar una comparación para cada tramo de caso en la instrucción switch. El compilador crea una tabla de salto que contiene las direcciones de las acciones que se realizarán en diferentes tramos. El valor en el que se realiza el cambio se manipula para convertirlo en un índice en
jump table
. En esta implementación, el tiempo empleado en la instrucción switch es mucho menor que el tiempo empleado en una cascada equivalente de instrucciones if-else-if. Además, el tiempo empleado en la declaración de cambio es independiente del número de tramos de casos en la declaración de cambio.Como se indica en wikipedia sobre la declaración de cambio en la sección de compilación.
fuente
La respuesta está en el código de bytes:
SwitchTest10.java
Código de bytes correspondiente; solo se muestran las partes relevantes:
SwitchTest22.java:
Código de bytes correspondiente; de nuevo, solo se muestran las partes relevantes:
En el primer caso, con rangos estrechos, el código de bytes compilado utiliza a
tableswitch
. En el segundo caso, el bytecode compilado utiliza alookupswitch
.En
tableswitch
, el valor entero en la parte superior de la pila se usa para indexar en la tabla, para encontrar el objetivo de rama / salto. Este salto / rama se realiza inmediatamente. Por lo tanto, esta es unaO(1)
operación.A
lookupswitch
es más complicado. En este caso, el valor entero debe compararse con todas las claves de la tabla hasta que se encuentre la clave correcta. Una vez que se encuentra la clave, el objetivo de salto / rama (al que se asigna esta clave) se usa para el salto. La tabla que se usalookupswitch
se ordena y se puede usar un algoritmo de búsqueda binaria para encontrar la clave correcta. El rendimiento para una búsqueda binaria esO(log n)
, y todo el proceso también lo esO(log n)
, porque el salto todavía estáO(1)
. Entonces, la razón por la que el rendimiento es menor en el caso de rangos dispersos es que primero se debe buscar la clave correcta porque no se puede indexar directamente en la tabla.Si hay valores dispersos y solo tenía
tableswitch
que usarlos, la tabla esencialmente contendría entradas ficticias que apuntan a ladefault
opción. Por ejemplo, suponiendo que la última entradaSwitchTest10.java
fue en21
lugar de10
, obtienes:Entonces, el compilador básicamente crea esta enorme tabla que contiene entradas ficticias entre los huecos, apuntando al objetivo de la rama de la
default
instrucción. Incluso si no hay undefault
, contendrá entradas que apunten a la instrucción después del bloqueo del interruptor. Hice algunas pruebas básicas y descubrí que si la brecha entre el último índice y el anterior (9
) es mayor que35
, se usa a enlookupswitch
lugar de atableswitch
.El comportamiento de la
switch
declaración se define en la Especificación de máquina virtual Java (§3.10) :fuente
lookupswitch
?Como la pregunta ya está respondida (más o menos), aquí hay un consejo. Utilizar
Ese código usa significativamente menos IC (caché de instrucciones) y siempre estará en línea. La matriz estará en la caché de datos L1 si el código está activo. La tabla de búsqueda es casi siempre una victoria. (especialmente en microbenchmarks: D)
Editar: si desea que el método esté en línea caliente, considere que las rutas no rápidas
throw new ParseException()
son tan cortas como mínimo o muévalas a un método estático separado (por lo tanto, haga que sean cortas como mínimo). Esa esthrow new ParseException("Unhandled power of ten " + power, 0);
una idea débil porque consume una gran parte del presupuesto de línea para el código que solo se puede interpretar: la concatenación de cadenas es bastante detallada en bytecode. Más información y un caso real con ArrayListfuente