Pregunta ¿Por qué StringBuilder # append (int) es más rápido en Java 7 que en Java 8?


Mientras investigaba por un pequeño debate w.r.t. utilizando "" + n y Integer.toString(int) para convertir una primitiva entera en una cadena, escribí esto JMH microbenchmark:

@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class IntStr {
    protected int counter;


    @GenerateMicroBenchmark
    public String integerToString() {
        return Integer.toString(this.counter++);
    }

    @GenerateMicroBenchmark
    public String stringBuilder0() {
        return new StringBuilder().append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder1() {
        return new StringBuilder().append("").append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder2() {
        return new StringBuilder().append("").append(Integer.toString(this.counter++)).toString();
    }

    @GenerateMicroBenchmark
    public String stringFormat() {
        return String.format("%d", this.counter++);
    }

    @Setup(Level.Iteration)
    public void prepareIteration() {
        this.counter = 0;
    }
}

Lo ejecuté con las opciones predeterminadas de JMH con ambas máquinas virtuales Java que existen en mi máquina Linux (actualizado Mageia 4 de 64 bits, CPU Intel i7-3770, 32 GB de RAM). La primera JVM fue la suministrada con Oracle JDK 8u5 de 64 bits:

java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)

Con esta JVM obtuve casi lo que esperaba:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32317.048      698.703   ops/ms
b.IntStr.stringBuilder0     thrpt        20    28129.499      421.520   ops/ms
b.IntStr.stringBuilder1     thrpt        20    28106.692     1117.958   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20066.939     1052.937   ops/ms
b.IntStr.stringFormat       thrpt        20     2346.452       37.422   ops/ms

Es decir. utilizando el StringBuilder clase es más lenta debido a la sobrecarga adicional de crear el StringBuilder objeto y anexando una cadena vacía. Utilizando String.format(String, ...) es aún más lento, en un orden de magnitud más o menos.

El compilador proporcionado por la distribución, por otro lado, se basa en OpenJDK 1.7:

java version "1.7.0_55"
OpenJDK Runtime Environment (mageia-2.4.7.1.mga4-x86_64 u55-b13)
OpenJDK 64-Bit Server VM (build 24.51-b03, mixed mode)

Los resultados aquí fueron interesante:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    31249.306      881.125   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39486.857      663.766   ops/ms
b.IntStr.stringBuilder1     thrpt        20    41072.058      484.353   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20513.913      466.130   ops/ms
b.IntStr.stringFormat       thrpt        20     2068.471       44.964   ops/ms

Por que StringBuilder.append(int) aparece mucho más rápido con esta JVM? Mirando a la StringBuilder El código fuente de la clase no reveló nada particularmente interesante: el método en cuestión es casi idéntico a Integer#toString(int). Curiosamente, se agrega el resultado de Integer.toString(int) (el stringBuilder2 microbenchmark) no parece ser más rápido.

¿Es esta discrepancia de rendimiento un problema con el arnés de prueba? ¿O mi OpenJDK JVM contiene optimizaciones que afectarían a este código (anti) -pattern en particular?

EDITAR:

Para una comparación más directa, instalé Oracle JDK 1.7u55:

java version "1.7.0_55"
Java(TM) SE Runtime Environment (build 1.7.0_55-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.55-b03, mixed mode)

Los resultados son similares a los de OpenJDK:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32502.493      501.928   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39592.174      428.967   ops/ms
b.IntStr.stringBuilder1     thrpt        20    40978.633      544.236   ops/ms

Parece que este es un problema más general de Java 7 vs Java 8. ¿Quizás Java 7 tenía optimizaciones de cadenas más agresivas?

EDIT 2:

Para completar, estas son las opciones de VM relacionadas con cadenas para estas dos JVM:

Para Oracle JDK 8u5:

$ /usr/java/default/bin/java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}
     intx PerfMaxStringConstLength                  = 1024            {product}
     bool PrintStringTableStatistics                = false           {product}
    uintx StringTableSize                           = 60013           {product}

Para OpenJDK 1.7:

$ java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}        
     intx PerfMaxStringConstLength                  = 1024            {product}           
     bool PrintStringTableStatistics                = false           {product}           
    uintx StringTableSize                           = 60013           {product}           
     bool UseStringCache                            = false           {product}   

los UseStringCache La opción fue eliminada en Java 8 sin reemplazo, así que dudo que haga la diferencia. El resto de las opciones parecen tener la misma configuración.

EDIT 3:

Una comparación lado a lado del código fuente de la AbstractStringBuilder, StringBuilder y Integer clases de la src.zip archivo de revela nada noteworty. Además de una gran cantidad de cambios cosméticos y de documentación, Integer ahora tiene algo de soporte para enteros sin signo y StringBuilder ha sido ligeramente refactorizado para compartir más código con StringBuffer. Ninguno de estos cambios parece afectar las rutas de código utilizadas por StringBuilder#append(int), aunque puede haberme perdido algo.

Una comparación del código de ensamblado generado para IntStr#integerToString() y IntStr#stringBuilder0() es mucho más interesante. El diseño básico del código generado para IntStr#integerToString() fue similar para ambas JVM, aunque Oracle JDK 8u5 parecía ser más agresivo w.r.t. destacando algunas llamadas dentro del Integer#toString(int) código. Hubo una correspondencia clara con el código fuente de Java, incluso para alguien con una experiencia de ensamblaje mínima.

El código de ensamblaje para IntStr#stringBuilder0(), sin embargo, era radicalmente diferente. El código generado por Oracle JDK 8u5 estaba una vez más directamente relacionado con el código fuente de Java; podía reconocer fácilmente el mismo diseño. Por el contrario, el código generado por OpenJDK 7 era casi irreconocible para el ojo no entrenado (como el mío). los new StringBuilder()llamada fue aparentemente eliminada, como fue la creación de la matriz en el StringBuilder constructor. Además, el complemento desensamblador no fue capaz de proporcionar tantas referencias al código fuente como lo hizo en JDK 8.

Supongo que esto es el resultado de un pase de optimización mucho más agresivo en OpenJDK 7, o más probablemente el resultado de insertar código de bajo nivel escrito a mano para ciertos StringBuilder operaciones. No estoy seguro de por qué esta optimización no ocurre en mi implementación de JVM 8 o por qué no se implementaron las mismas optimizaciones para Integer#toString(int) en JVM 7. Supongo que alguien familiarizado con las partes relacionadas del código fuente de JRE tendría que responder estas preguntas ...


76
2018-05-20 10:13


origen


Respuestas:


TL; DR: Efectos secundarios en append aparentemente rompe las optimizaciones de StringConcat.

Muy buen análisis en la pregunta original y actualizaciones!

Para completar, a continuación hay algunos pasos que faltan:

  • Ver a través de -XX:+PrintInlining para ambos 7u55 y 8u5. En 7u55, verá algo como esto:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
       @ 18   java.lang.StringBuilder::append (8 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
    

    ... y en 8u5:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
         @ 3   java.lang.AbstractStringBuilder::<init> (12 bytes)   inline (hot)
           @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
       @ 18   java.lang.StringBuilder::append (8 bytes)   inline (hot)
         @ 2   java.lang.AbstractStringBuilder::append (62 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
         @ 13   java.lang.String::<init> (62 bytes)   inline (hot)
           @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
           @ 55   java.util.Arrays::copyOfRange (63 bytes)   inline (hot)
             @ 54   java.lang.Math::min (11 bytes)   (intrinsic)
             @ 57   java.lang.System::arraycopy (0 bytes)   (intrinsic)
    

    Puede observar que la versión 7u55 es menos profunda, y parece que no se llama nada después StringBuilder métodos: esta es una buena indicación de que las optimizaciones de cadenas están en vigor. De hecho, si ejecuta 7u55 con -XX:-OptimizeStringConcat, las llamadas secundarias reaparecerán y el rendimiento bajará a niveles de 8u5.

  • OK, entonces tenemos que descubrir por qué 8u5 no hace la misma optimización. Grep http://hg.openjdk.java.net/jdk9/jdk9/hotspot para "StringBuilder" para averiguar dónde maneja VM la optimización de StringConcat; esto te llevará a src/share/vm/opto/stringopts.cpp

  • hg log src/share/vm/opto/stringopts.cpp para descubrir los últimos cambios allí. Uno de los candidatos sería:

    changeset:   5493:90abdd727e64
    user:        iveresov
    date:        Wed Oct 16 11:13:15 2013 -0700
    summary:     8009303: Tiered: incorrect results in VM tests stringconcat...
    
  • Busque los hilos de revisión en las listas de correo de OpenJDK (lo suficientemente fácil para buscar en Google el resumen del conjunto de cambios): http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2013-October/012084.html

  • Spot "Optimización de optimización de concat de String colapsa el patrón en una sola asignación de una cadena y forma el resultado directamente. Todos los posibles deopts que pueden ocurrir en el código optimizado reinician este patrón desde el principio (a partir de la asignación de StringBuffer) . Eso significa que el patrón completo debe ser libre de efectos secundarios."Eureka?

  • Escriba la referencia de contraste:

    @Fork(5)
    @Warmup(iterations = 5)
    @Measurement(iterations = 5)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @State(Scope.Benchmark)
    public class IntStr {
        private int counter;
    
        @GenerateMicroBenchmark
        public String inlineSideEffect() {
            return new StringBuilder().append(counter++).toString();
        }
    
        @GenerateMicroBenchmark
        public String spliceSideEffect() {
            int cnt = counter++;
            return new StringBuilder().append(cnt).toString();
        }
    }
    
  • Mídalo en JDK 7u55, viendo el mismo rendimiento para los efectos secundarios en línea / empalmados:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       65.460        1.747    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       64.414        1.323    ns/op
    
  • Mídalo en JDK 8u5, viendo la degradación del rendimiento con el efecto integrado:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       84.953        2.274    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       65.386        1.194    ns/op
    
  • Presentar el informe de error (https://bugs.openjdk.java.net/browse/JDK-8043677) para discutir este comportamiento con los chicos de VM. El fundamento de la corrección original es sólido como una roca, pero es interesante si podemos / debemos recuperar esta optimización en algunos casos triviales como estos.

  • ???

  • LUCRO.

Y sí, debería publicar los resultados para el punto de referencia que mueve el incremento desde el StringBuilder cadena, haciéndolo antes de toda la cadena. Además, cambió al tiempo promedio y ns / op. Este es JDK 7u55:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.805        1.093    ns/op
o.s.IntStr.stringBuilder0      avgt        25      128.284        6.797    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.524        3.116    ns/op
o.s.IntStr.stringBuilder2      avgt        25      254.384        9.204    ns/op
o.s.IntStr.stringFormat        avgt        25     2302.501      103.032    ns/op

Y esto es 8u5:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.032        3.295    ns/op
o.s.IntStr.stringBuilder0      avgt        25      127.796        1.158    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.585        1.137    ns/op
o.s.IntStr.stringBuilder2      avgt        25      250.980        2.773    ns/op
o.s.IntStr.stringFormat        avgt        25     2123.706       25.105    ns/op

stringFormates en realidad un poco más rápido en 8u5, y todas las demás pruebas son iguales. Esto solidifica la hipótesis de la rotura de los efectos secundarios en las cadenas SB en el principal culpable en la pregunta original.


94
2018-05-21 19:23



Creo que esto tiene que ver con el CompileThreshold indicador que controla cuando el código de byte se compila en código máquina por JIT.

Oracle JDK tiene un conteo predeterminado de 10,000 como documento en http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html.

Donde OpenJDK no pude encontrar el último documento en esta bandera; pero algunos hilos de correo sugieren un umbral mucho más bajo: http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2010-November/004239.html

Además, intente encender / apagar las banderas Oracle JDK como -XX:+UseCompressedStrings y -XX:+OptimizeStringConcat. No estoy seguro si esas banderas están activadas por defecto en OpenJDK. ¿Podría alguien sugerirme?

Un experimento que puede hacer es ejecutar el programa varias veces, por ejemplo, 30,000 bucles, hacer un System.gc () y luego tratar de observar el rendimiento. Creo que rendirían lo mismo.

Y asumo que la configuración de tu GC es la misma también. De lo contrario, está asignando una gran cantidad de objetos y el GC podría ser la parte más importante de su tiempo de ejecución.


5
2018-05-20 10:36