java performance lambda microbenchmark

java - ¿Por qué lambda IntStream.anyMatch() es 10 más lento que la implementación ingenua?



performance microbenchmark (1)

Su punto de referencia en realidad no mide el rendimiento de anyMatch , sino más bien una sobrecarga de transmisión. Esta sobrecarga puede parecer significativa cuando se compara con una operación muy simple como una búsqueda de matriz de cinco elementos.

La desaceleración no se verá tan terrible si pasamos de números relativos a números absolutos. Vamos a medir la latencia en lugar del rendimiento para obtener una imagen más clara. He omitido el benchmark lambdaIntStream ya que funciona exactamente de la misma manera que lambdaArrayStream .

Benchmark Mode Cnt Score Error Units Contains.lambdaArrayStream avgt 5 53,242 ± 2,034 ns/op Contains.naive avgt 5 5,876 ± 0,404 ns/op

5.8 ns es aproximadamente 14 ciclos de una CPU de 2.4 GHz. La carga de trabajo es tan pequeña que cualquier ciclo adicional será notable. Entonces, ¿cuál es la sobrecarga de las operaciones de flujo?

Asignación de objetos

Ahora vuelva a ejecutar el benchmark con -prof gc profiler. Mostrará la cantidad de asignaciones de pila:

Benchmark Mode Cnt Score Error Units Contains.lambdaArrayStream:·gc.alloc.rate.norm avgt 5 152,000 ± 0,001 B/op Contains.naive:·gc.alloc.rate.norm avgt 5 ≈ 10⁻⁵ B/op

lambdaArrayStream asigna 152 bytes por iteración mientras que naive benchmark naive no asigna nada. Por supuesto, la asignación no es gratuita: hay al menos 5 objetos construidos para admitir anyMatch , y cada uno toma varios nanosegundos:

  • Lambda i -> i == val
  • IntPipeline.Head
  • Spliterators.IntArraySpliterator
  • MatchOps.MatchOp
  • MatchOps.MatchSink

Pila de llamadas

java.util.stream implementación de java.util.stream es un poco complicada ya que debe admitir todas las combinaciones de fuentes de flujo, operaciones intermedias y de terminal. Si miras la pila de anyMatch de anyMatch en tu benchmark, verás algo como eso:

at bench.Contains.lambda$lambdaArrayStream$0(Contains.java:24) at java.util.stream.MatchOps$2MatchSink.accept(MatchOps.java:119) at java.util.Spliterators$IntArraySpliterator.tryAdvance(Spliterators.java:1041) at java.util.stream.IntPipeline.forEachWithCancel(IntPipeline.java:162) at java.util.stream.AbstractPipeline.copyIntoWithCancel(AbstractPipeline.java:498) at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:485) at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471) at java.util.stream.MatchOps$MatchOp.evaluateSequential(MatchOps.java:230) at java.util.stream.MatchOps$MatchOp.evaluateSequential(MatchOps.java:196) at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) at java.util.stream.IntPipeline.anyMatch(IntPipeline.java:477) at bench.Contains.lambdaArrayStream(Contains.java:23)

No todas estas llamadas a métodos pueden estar en línea. Además, JVM limita la creación de líneas en 9 niveles, pero aquí vemos la pila de llamadas más profunda. Si -XX:MaxInlineLevel=20 el límite con -XX:MaxInlineLevel=20 la puntuación será un poco mejor:

Benchmark Mode Cnt Score Error Units Contains.lambdaArrayStream avgt 5 33,294 ± 0,367 ns/op (was 53,242) Contains.naive avgt 5 5,822 ± 0,207 ns/op

Optimizaciones de bucle

for iteración sobre una matriz es un bucle contado trivial. JVM puede aplicar una amplia gama de optimizaciones de bucle aquí: peeling de bucle, desenrollado de bucles, etc. Esto no funciona por un while : el bucle de tipo forEachWithCancel método forEachWithCancel , que se utiliza para atravesar un IntStream. El efecto de las optimizaciones de bucle se puede medir con -XX:LoopUnrollLimit=0 -XX:-UseLoopPredicate :

Benchmark Mode Cnt Score Error Units Contains.lambdaArrayStream avgt 5 33,153 ± 0,559 ns/op Contains.naive avgt 5 9,853 ± 0,150 ns/op (was 5,876)

Conclusiones

Hay una sobrecarga para construir y atravesar una secuencia, pero esto se entiende completamente y no se puede considerar un error. No diría que la sobrecarga es grande (incluso 50 ns / op no es mucho); sin embargo, en este ejemplo particular, la sobrecarga es dominante debido a una carga de trabajo extremadamente pequeña.

Hace poco estaba perfilando mi código y encontré un cuello de botella interesante en él. Aquí está el punto de referencia:

@BenchmarkMode(Mode.Throughput) @Fork(1) @State(Scope.Thread) @Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) public class Contains { private int[] ar = new int[] {1,2,3,4,5,6,7}; private int val = 5; @Benchmark public boolean naive() { return contains(ar, val); } @Benchmark public boolean lambdaArrayStreamContains() { return Arrays.stream(ar).anyMatch(i -> i == val); } @Benchmark public boolean lambdaIntStreamContains() { return IntStream.of(ar).anyMatch(i -> i == val); } private static boolean contains(int[] ar, int value) { for (int arVal : ar) { if (arVal == value) { return true; } } return false; } }

Resultado:

Benchmark Mode Cnt Score Error Units Contains.lambdaArrayStreamContains thrpt 10 22867.962 ± 1049.649 ops/s Contains.lambdaIntStreamContains thrpt 10 22983.800 ± 593.580 ops/s Contains.naive thrpt 10 228002.406 ± 8591.186 ops/s

Si muestra que Array contiene una operación a través de lambda, es 10 veces más lenta que la implementación ingenua con bucle simple. Sabía que lambdas debería ser un poco más lento. Pero 10 veces? ¿Estoy haciendo mal lambda o este es un problema con java?