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


Mientras investigaba para un pequeño debate w. r. t. usando "" + n y Integer.toString(int) para convertir un entero primitivo a 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 (Mageia 4 de 64 bits actualizada, CPU Intel i7-3770, 32 GB de RAM). La primera JVM fue la suministrada con Oracle JDK 8u5 64-bit:

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 tengo más o menos lo que esperado:

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, usar la clase StringBuilder es más lento debido a la sobrecarga adicional de crear el objeto StringBuilder y agregar una cadena vacía. Usar String.format(String, ...) es aún más lento, por 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 interesantes :

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 qué StringBuilder.append(int) aparece mucho más rápido con esta JVM? Mirando el código fuente de la clase StringBuilder no reveló nada particularmente interesante-el método en cuestión es casi idéntico a Integer#toString(int). Curiosamente, agregar 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 JVM OpenJDK contiene optimizaciones que afectarían a este código en particular (anti)-patrón?

EDITAR:

Para una comparación más sencilla, 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. Tal vez Java 7 tenía optimizaciones de cadenas más agresivas?

EDITAR 2:

Para completar, aquí están las opciones de VM relacionadas con cadenas para ambas 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}   

La opción UseStringCache se eliminó en Java 8 sin reemplazo, por lo que duda que hace alguna diferencia. El resto de las opciones parecen tener la misma configuración.

EDITAR 3:

Una comparación lado a lado del código fuente de la AbstractStringBuilder, StringBuilder y Integer clases del archivo src.zip de no revela nada noteworty. Aparte de una gran cantidad de cambios cosméticos y de documentación, Integer ahora tiene 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 la rutas de código utilizadas por StringBuilder#append(int), aunque puede que me haya perdido algo.

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

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 - pude reconocer fácilmente el mismo diseño. Por el contrario, el código generado por OpenJDK 7 era casi irreconocible para el ojo inexperto (como el mío). La llamada new StringBuilder() fue aparentemente eliminada, al igual que la creación del array en el constructor StringBuilder. Además, el complemento desensamblador no fue capaz de proporcionar tantas referencias al código fuente como lo hizo en JDK 8.

Asumo 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 ciertas operaciones StringBuilder. No estoy seguro de por qué esta optimización no ocurre en mi implementación de JVM 8 o por qué las mismas optimizaciones no se implementaron 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 a estas preguntas...

Author: Community, 2014-05-20

2 answers

TL; DR: Los efectos secundarios en append aparentemente rompen las optimizaciones de StringConcat.

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

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

  • Ver a través de la -XX:+PrintInlining para ambos 7u55 y 8u5. En 7u55, verás 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)
    

    Es posible que note que la versión 7u55 es menos profunda, y parece que no se llama a nada después de los métodos StringBuilder this esta es una buena indicación de que las optimizaciones de cadenas están en efecto. De hecho, si ejecuta 7u55 con -XX:-OptimizeStringConcat, las subcalls reaparecerán y el rendimiento se reducirá a niveles 8u5.

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

  • hg log src/share/vm/opto/stringopts.cpp para averiguar 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 (bastante fácil de Google para el resumen de conjuntos de cambios): http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2013-October/012084.html

  • Spot " String concat optimization optimization colapsa el patrón [...] en una sola asignación de una cadena y formando el resultado directamente. Todas las deopts posibles 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 todo el patrón debe liberarme de efectos secundarios." Eureka?

  • Escriba el punto de referencia contrastante:

    @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 el lado en línea / empalmado efectos:

    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 de línea:

    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
    
  • Enviar el informe de error ( https://bugs.openjdk.java.net/browse/JDK-8043677 ) para discutir este comportamiento con los chicos de VM. La justificación de la solución original es sólida como una roca, es interesante, sin embargo, si podemos / debemos recuperar esta optimización en algunos casos triviales como estos.

  • ???

  • BENEFICIO.

Y sí, debería publicar los resultados para el benchmark que mueve el incremento de la cadena StringBuilder, haciéndolo antes de toda la cadena. También, cambiado a tiempo promedio, y ns / op. Esto 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: {[19]]}

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

stringFormat en realidad es un poco más rápido en 8u5, y todas las otras pruebas son las mismas. Esto solidifica la hipótesis el efecto secundario rotura de cadenas de SB en el mayor culpable en la pregunta original.

 94
Author: Aleksey Shipilev,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-05-21 20:37:23

Creo que esto tiene que ver con la bandera CompileThreshold que controla cuando el código de bytes es compilado en código máquina por JIT.

El Oracle JDK tiene un recuento predeterminado de 10.000 como documento en http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html .

Donde OpenJDK no pude encontrar un ú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

También, intente activar / desactivar las banderas JDK de Oracle como -XX:+UseCompressedStrings y -XX:+OptimizeStringConcat. Sin embargo, no estoy seguro de si esos indicadores están activados por defecto en OpenJDK. ¿Podría alguien sugerir?

Una experiencia que se puede hacer, es en primer lugar ejecutar el programa por un montón de veces, por ejemplo, 30.000 bucles, hacer un Sistema.gc () y luego tratar de ver el rendimiento. Creo que darían lo mismo.

Y Yo suponga que su configuración de GC también es la misma. De lo contrario, está asignando muchos objetos y el GC podría ser la mayor parte de su tiempo de ejecución.

 5
Author: Alex Suo,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-05-20 10:36:16