Почему 2 * (i * i) быстрее, чем 2 * i * i в Java?

Следующая программа Java запускается в среднем от 0,50 до 0,55 секунды:

public static void main(String[] args) {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
    System.out.println("n = " + n);
}

Если я заменю 2 * (i * i) на 2 * i * i, запуск займет от 0,60 до 0,65 секунды. Почему?

Каждую версию программы я запускал по 15 раз попеременно. Вот результаты:

 2*(i*i)  |  2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149  | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412  | 0.6393969
0.5466744 | 0.6608845
0.531159  | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526

Самый быстрый запуск 2 * i * i занял больше времени, чем самый медленный запуск 2 * (i * i). Если бы у них была такая же эффективность, вероятность этого была бы меньше, чем у 1/2^15 * 100% = 0.00305%.

Я получаю аналогичные результаты (немного разные числа, но определенно заметный и последовательный разрыв, определенно больше, чем ошибка выборки)

Krease 23.11.2018 21:47

Также смотрите: stackoverflow.com/questions/504103/…

lexicore 23.11.2018 21:56

@Krease Хорошо, что ты поймал мою ошибку. Согласно новому тесту, 2 * i * i работает медленнее. Я тоже попробую побегать с Граалем.

Jorn Vernee 23.11.2018 22:07

Я смог подтвердить результат плаката: pastebin.com/KG0YRdnU При запуске этого кода опция 2 * (i * i) постоянно работает на 10-20% быстрее. Если я позволю обоим вариантам делать одно и то же, отклонение всегда будет <5%.

Philipp Jahoda 23.11.2018 22:08

Во время моего бега это не так. У меня есть 2 варианта этого расчета в 2 методах calc1 () и calc2 (). В основном я вызываю calc1 (), а затем calc2 (). Затем я проверяю с помощью calc2 (), а затем calc1 (). В обоих случаях первый вызов (будь то calc1 () или calc2 ()) всегда занимает больше времени. Можешь попробовать.

elyor 23.11.2018 22:20

@JornVernee Если это может помочь вам добавить дополнительную информацию к вашему ответу, набор инструкций JVM для iload и imul

Naman 23.11.2018 22:21

Я не могу достоверно воспроизвести ваш результат; для меня версия в скобках работает медленнее в 95% случаев.

Caius Jard 23.11.2018 22:25

@nullpointer Чтобы по-настоящему выяснить, почему один из них быстрее другого, нам нужно получить дизассемблированные или идеальные графики для этих методов. Попытки разобраться в ассемблере очень утомительны, поэтому я пытаюсь получить отладочную сборку OpenJDK, которая может выводить красивые графики.

Jorn Vernee 23.11.2018 22:29

это зависит от многих факторов, таких как версия JVM и так далее. Это не всегда можно воспроизвести.

elyor 23.11.2018 22:33

Понятно, что выполнение умножения i * i в первую очередь связано с лучшей производительностью, 2 * (i * i) кажется таким же быстрым, как и i * i * 2.

Joakim Danielson 23.11.2018 22:44

Это должна быть какая-то ошибка в JIT?

JAAAY 24.11.2018 23:37

Вы можете переименовать свой вопрос в «Почему i * i * 2 быстрее, чем 2 * i * i?» для большей ясности, что проблема связана с порядком операций.

Cœur 28.11.2018 05:02

Пожалуйста, добавьте используемую версию JDK.

Mikhail Kholodkov 30.11.2018 17:51

@ Cœur, но это не то, что происходит в байт-коде.

DSchmidt 30.11.2018 17:56

Я проделал тот же тест, и результаты полностью противоположны. stackoverflow.com/questions/53570864/…

Waqas Gondal 01.12.2018 14:34
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
883
15
249 960
10
Перейти к ответу Данный вопрос помечен как решенный

Ответы 10

Два метода добавления генерируют несколько отличающийся байт-код:

  17: iconst_2
  18: iload         4
  20: iload         4
  22: imul
  23: imul
  24: iadd

Для 2 * (i * i) против:

  17: iconst_2
  18: iload         4
  20: imul
  21: iload         4
  23: imul
  24: iadd

Для 2 * i * i.

И при использовании такого теста JMH:

@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {

    @Benchmark
    public int noBrackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * i * i;
        }
        return n;
    }

    @Benchmark
    public int brackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * (i * i);
        }
        return n;
    }

}

Разница очевидна:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  380.889 ± 58.011  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  512.464 ± 11.098  ms/op

То, что вы наблюдаете, является правильным, а не просто аномалией вашего стиля тестирования (т.е. без разминки, см. Как мне написать правильный микротест на Java?)

Опять бег с Граалем:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  335.100 ± 23.085  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  331.163 ± 50.670  ms/op

Вы видите, что результаты намного ближе, что имеет смысл, поскольку Graal в целом является более производительным и более современным компилятором.

Так что это действительно зависит от того, насколько хорошо JIT-компилятор может оптимизировать конкретный фрагмент кода, и не обязательно имеет для этого логическую причину.

Получил аналогичные результаты:

2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736

Я получил результаты ТАКОЙ ЖЕ, если оба цикла были в одной программе или каждый был в отдельном файле .java / .class, выполняемом в отдельном прогоне.

Наконец, вот декомпиляция javap -c -v <.java> каждого из них:

     3: ldc           #3                  // String 2 * (i * i):
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: iload         4
    30: imul
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

против.

     3: ldc           #3                  // String 2 * i * i:
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: imul
    29: iload         4
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

К вашему сведению -

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

Лучший ответ, и, возможно, вы сможете проголосовать за отмену удаления - stackoverflow.com/a/53452836/1746118 ... Примечание: я все равно не голосующий против.

Naman 23.11.2018 22:11

@nullpointer - согласен. Я бы определенно проголосовал за восстановление удаления, если бы мог. Я также хотел бы удвоить оценку Стефану за количественное определение понятия «значительный».

paulsm4 23.11.2018 22:14

Тот был удален самостоятельно, так как он измерял не то, что нужно - см. Комментарий автора к вопросу выше.

Krease 23.11.2018 22:16

С JIT байт-код не имеет большого значения. Покажите код JIT.

rustyx 23.11.2018 22:58

Получите отладка jre и запустите с -XX:+PrintOptoAssembly. Или просто используйте vtune или что-то подобное.

rustyx 23.11.2018 23:42

@ rustyx - Если проблема заключается в реализации JIT ... то "получение отладочной версии" СОВЕРШЕННО ДРУГОЙ JRE не обязательно поможет. Тем не менее: похоже, что вы нашли выше с дизассемблированием JIT на JRE, также объясняет поведение в JRE OP и в моем. А также объясняет, почему другие JRE ведут себя «иначе». +1: спасибо за отличную детективную работу!

paulsm4 24.11.2018 09:06

Байтовые коды: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Наблюдатель байтовых кодов: https://github.com/Konloch/bytecode-viewer

На моем JDK (Windows 10 64 бит, 1.8.0_65-b17) я могу воспроизвести и объяснить:

public static void main(String[] args) {
    int repeat = 10;
    long A = 0;
    long B = 0;
    for (int i = 0; i < repeat; i++) {
        A += test();
        B += testB();
    }

    System.out.println(A / repeat + " ms");
    System.out.println(B / repeat + " ms");
}


private static long test() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multi(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multi(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms A " + n);
    return ms;
}


private static long testB() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multiB(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multiB(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms B " + n);
    return ms;
}

private static int multiB(int i) {
    return 2 * (i * i);
}

private static int multi(int i) {
    return 2 * i * i;
}

Вывод:

...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms

Так почему? Байт-код такой:

 private static multiB(int arg0) { // 2 * (i * i)
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         iload0
         imul
         imul
         ireturn
     }
     L2 {
     }
 }

 private static multi(int arg0) { // 2 * i * i
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         imul
         iload0
         imul
         ireturn
     }
     L2 {
     }
 }

Разница в том, что: Со скобами (2 * (i * i)):

  • толкнуть стек констант
  • поместить локальный в стек
  • поместить локальный в стек
  • умножить вершину стека
  • умножить вершину стека

Без скоб (2 * i * i):

  • толкнуть стек констант
  • поместить локальный в стек
  • умножить вершину стека
  • поместить локальный в стек
  • умножить вершину стека

Загрузка всего в стек и последующая обратная работа происходит быстрее, чем переключение между помещением в стек и работой с ним.

Но почему нажимайте-нажимайте-умножайте-умножайте быстрее, чем нажимайте-умножайте-нажимайте-умножайте?

m0skit0 01.12.2018 13:04

@ m0skit0: Действительно, ответ объясняется не байт-кодом, а только рассмотрением фактического JITed x86-64 asm. JIT с тем же 16-кратным развертыванием для машины с большим количеством регистров (например, AArch64 или PowerPC), вероятно, не покажет никакой разницы на этих других ISA, в отличие от x86-64 или, вероятно, 32-разрядной ARM. По сути, не так уж и быстрее сдвинуть все и работать с байт-кодом Java, или, по крайней мере, эти вопросы и ответы не доказывают этого. Это происходит медленнее в этом случае, когда JIT-компилятор в одном случае отключает себя хуже, чем в другом.

Peter Cordes 03.07.2020 10:24

(Примечание редактора: этот ответ противоречит данным, полученным при просмотре asm, как показано в другом ответе. Это было предположение, подтвержденное некоторыми экспериментами, но оно оказалось неверным.)


Когда умножение равно 2 * (i * i), JVM может вычесть умножение на 2 из цикла, в результате чего получится эквивалентный, но более эффективный код:

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

но когда умножение равно (2 * i) * i, JVM не оптимизирует его, поскольку умножение на константу больше не выполняется непосредственно перед сложением n +=.

Вот несколько причин, по которым я думаю, что это так:

  • Добавление оператора if (n == 0) n = 1 в начале цикла приводит к тому, что обе версии будут столь же эффективными, поскольку факторинг умножения больше не гарантирует, что результат будет одинаковым.
  • Оптимизированная версия (с учетом умножения на 2) работает точно так же быстро, как и версия 2 * (i * i).

Вот тестовый код, который я использовал, чтобы сделать эти выводы:

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

И вот результаты:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

Думаю на optimizedVersion должен быть n *= 2000000000;

StefansArya 24.11.2018 02:19

@StefansArya - Нет. Рассмотрим случай, когда предел равен 4, и мы пытаемся вычислить 2*1*1 + 2*2*2 + 2*3*3. Очевидно, что вычисление 1*1 + 2*2 + 3*3 и умножение на 2 правильно, тогда как умножение на 8 - нет.

Martin Bonner supports Monica 26.11.2018 16:57

@MartinBonner - Согласен, я думаю, кто-то объяснял это несколько дней назад, но теперь его комментарий пропал. В любом случае спасибо за объяснение.

StefansArya 26.11.2018 18:12

Математическое уравнение было таким же, как у этого 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Это было очень просто, и я просто забыл об этом, потому что цикл увеличивался.

StefansArya 26.11.2018 18:22

Если вы распечатываете сборку с помощью jvm отладки, это не кажется правильным. Вы увидите группу sall ..., # 1, которые умножаются на 2 в цикле. Интересно, что версия помедленнее, похоже, не имеет умножений в цикле.

Daniel Berlin 01.12.2018 05:53

Почему JVM может исключить 2 из 2 * (i * i), но не из (2 * i) * i? Я бы подумал, что они эквивалентны (это может быть моим неверным предположением). Если да, то разве JVM не канонизирует выражение перед оптимизацией?

RedSpikeyThing 01.12.2018 15:50

Я попробовал JMH, используя архетип по умолчанию: я также добавил оптимизированную версию, основанную на Объяснение рунеморо.

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

Результат здесь:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

На моем ПК (Core i7 860 - ничего особенного не делает, кроме чтения на моем смартфоне):

  • n += i*i, затем сначала n*2
  • 2 * (i * i) - второй.

JVM явно не оптимизируется так же, как человек (на основе ответа Рунеморо).

Итак, читаем байт-код: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Я не являюсь экспертом по байт-коду, но мы iload_2 до imul: вероятно, в этом вы видите разницу: я могу предположить, что JVM оптимизирует чтение i дважды (i уже здесь, и нет необходимости загружать его снова), пока 2*i*i не может.

Байт-код AFAICT не имеет большого значения для производительности, и я бы не стал пытаться оценивать, что быстрее, на его основе. Это всего лишь исходный код для JIT-компилятора ... конечно, сохраняющий смысл переупорядочивание строк исходного кода может изменить результирующий код и его эффективность, но все это довольно непредсказуемо.

maaartinus 26.11.2018 03:33
Ответ принят как подходящий

Есть небольшая разница в порядке байт-кода.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

против 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

На первый взгляд, это не должно иметь значения; во всяком случае, вторая версия более оптимальна, поскольку использует на один слот меньше.

Поэтому нам нужно глубже изучить нижний уровень (JIT) 1.

Помните, что JIT имеет тенденцию очень агрессивно развертывать небольшие петли. Действительно, мы наблюдаем 16-кратное развертывание корпуса 2 * (i * i):

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

Мы видим, что на стек «проливается» 1 регистр.

А для версии 2 * i * i:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

Здесь мы наблюдаем гораздо больше «проливов» и большего количества обращений к стеку [RSP + ...] из-за большего количества промежуточных результатов, которые необходимо сохранить.

Таким образом, ответ на вопрос прост: 2 * (i * i) быстрее, чем 2 * i * i, потому что JIT генерирует более оптимальный ассемблерный код для первого случая.


Но, конечно, очевидно, что ни первая, ни вторая версия никуда не годятся; цикл может действительно выиграть от векторизации, поскольку любой процессор x86-64 имеет хотя бы поддержку SSE2.

Так что это проблема оптимизатора; как это часто бывает, он слишком агрессивно разворачивается и стреляет себе в ногу, упуская при этом различные другие возможности.

Фактически, современные процессоры x86-64 разбивают инструкции на микрооперации (µop), а с такими функциями, как переименование регистров, µop-кеши и буферы циклов, оптимизация цикла требует гораздо больше изящества, чем простое развертывание для достижения оптимальной производительности. Согласно руководству по оптимизации Агнера Фога:

The gain in performance due to the µop cache can be quite considerable if the average instruction length is more than 4 bytes. The following methods of optimizing the use of the µop cache may be considered:

  • Make sure that critical loops are small enough to fit into the µop cache.
  • Align the most critical loop entries and function entries by 32.
  • Avoid unnecessary loop unrolling.
  • Avoid instructions that have extra load time
    . . .

Что касается времени загрузки - даже самый быстрый результат L1D стоит 4 цикла, дополнительный регистр и µop, так что да, даже несколько обращений к памяти снизят производительность в жестких циклах.

Но вернемся к возможности векторизации - чтобы увидеть, насколько быстро это может быть, мы можем скомпилировать аналогичное приложение C с помощью GCC, который полностью векторизует его (показан AVX2, SSE2 аналогичен) 2:

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

Со временем работы:

  • SSE: 0,24 с, или в 2 раза быстрее.
  • AVX: 0,15 с, или в 3 раза быстрее.
  • AVX2: 0,08 с, или в 5 раз быстрее.

1To get JIT generated assembly output, get a debug JVM and run with -XX:+PrintOptoAssembly

2The C version is compiled with the -fwrapv flag, which enables GCC to treat signed integer overflow as a two's-complement wrap-around.

4c Задержка использования нагрузки L1d здесь не играет роли. RSP постоянен все время, поэтому выполнение вне очереди может запустить загрузку достаточно рано, чтобы данные были готовы. Стоимость разлива / перезарядки зависит от дополнительных затрат. Задержка переадресации хранилища / перезагрузки (от 3 до 5 циклов) отличается от задержки попадания в кэш L1d и является возможной проблемой, но я не думаю, что здесь происходит. Цикл занимает более 5 циклов на итерацию, поэтому это не узкое место. И я не думаю, что пропускная способность магазина является узким местом.

Peter Cordes 25.11.2018 23:32

Вероятно, это просто узкое место в пропускной способности интерфейса пользователя из-за неэффективной генерации кода. Он даже не использует LEA как глазок для mov / add-immediate. например movl RBX, R9 / addl RBX, #8 должен быть leal ebx, [r9 + 8], 1 мкоп для копирования и добавления. Или leal ebx, [r9 + r9 + 16] делать ebx = 2*(r9+8). Так что да, разворачивание до точки разлива - это глупо, как и наивный тупой кодогенератор, который не использует преимущества целочисленных идентичностей и ассоциативной целочисленной математики.

Peter Cordes 25.11.2018 23:38

@kasperd - да и для этой версии.

rustyx 26.11.2018 17:45

Векторизация для последовательного сокращения была отключена в C2 (bugs.openjdk.java.net/browse/JDK-8078563), но сейчас рассматривается возможность повторного включения (bugs.openjdk.java.net/browse/JDK-8188313).

pron 30.11.2018 15:31

У меня после выключения разворачивания цикла вообще получился какой-то интересный Результаты.

Oleksandr Pyrohov 04.12.2018 01:37

Хуже всего то, что ни один из этих компонентов не осознает, что умножение на 2 может быть выполнено намного быстрее, если выполнить left-shift. Я скопировал пример из вопроса и выполнил все 3 версии сразу, и из моего тестирования ясно, что 2 * i * i работает быстрее всего (в отличие от исходного тестового примера) и что в случае (i * i) << 1 запускается самый медленный, что, честно говоря, меня шокирует . Я использую OpenJDK 11 на MacOS. Я не анализировал вывод JIT.

Christopher Schultz 04.12.2018 16:01

Я думаю, что версия 2*i*i каким-то образом использует add, потому что там все еще "всего" 16 инструкций imul и нет sal или shl. Не думаю, что это выводит 2* из строя.

Peter Cordes 06.12.2018 11:23

Только что заметил, что на Haswell vpslld ymm1, ymm1, 1 может работать только на том же порту, что и vpmulld, порт 0. Сдвиг влево на 1 - это та же операция, что и для vpaddd ymm1, ymm1, ymm1 (x += 2 - это то же самое, что и x *= 2 или x <<= 1). padd работает на порте 1 или 5 на Haswell. Так что этот цикл медленнее, чем должен быть на Haswell. (Skylake может выполнять целочисленное умножение и сдвиг SIMD на большем количестве портов, поэтому, вероятно, без разницы.). Кроме того, inc eax / cmp/jcc тратит впустую uop по сравнению с dec eax/jnz, который может макросовкладывать на процессорах Intel в uop с переходом и переходом. (AMD и более старые Intel могут использовать только fuse test или cmp / jcc.)

Peter Cordes 08.12.2018 07:36

Касперд спросил в комментарии принятого ответа:

The Java and C examples use quite different register names. Are both example using the AMD64 ISA?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

У меня недостаточно репутации, чтобы ответить на этот вопрос в комментариях, но это все те же ISA. Стоит отметить, что версия GCC использует 32-битную целочисленную логику, а скомпилированная версия JVM внутренне использует 64-битную целочисленную логику.

R8 - R15 - это просто новые X86_64 регистры. От EAX до EDX - это нижние части регистров общего назначения от RAX до RDX. Важная часть ответа заключается в том, что версия GCC не развернута. Он просто выполняет один раунд цикла для каждого реального цикла машинного кода. В то время как версия JVM имеет 16 раундов цикла в одном физическом цикле (на основе ответа rustyx, я не переосмысливал сборку). Это одна из причин, по которой используется больше регистров, поскольку тело цикла на самом деле в 16 раз длиннее.

Жаль, что gcc не замечает, что он может вывести *2 из цикла. Хотя в данном случае это даже не победа, потому что с LEA это делается бесплатно. На процессорах Intel lea eax, [rax+rcx*2] имеет такую ​​же задержку 1 с, как и add eax,ecx. Однако на процессорах AMD любой масштабированный индекс увеличивает задержку LEA до 2 циклов. Таким образом, цепочка зависимостей с переносом цикла удлиняется до 2 циклов, становясь узким местом для Ryzen. (Пропускная способность imul ecx,edx составляет 1 на такт на Ryzen и на Intel).

Peter Cordes 30.11.2018 06:46

Хотя это не связано напрямую со средой вопроса, просто из любопытства я провел тот же тест на .NET Core 2.1, x64, в режиме выпуска.

Вот интересный результат, подтверждающий аналогичные явления (наоборот), происходящие на темной стороне силы. Код:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

Результат:

2 * (я * я)

  • результат: 119860736, 438 мс
  • результат: 119860736, 433 мс
  • результат: 119860736, 437 мс
  • результат: 119860736, 435 мс
  • результат: 119860736, 436 мс
  • результат: 119860736, 435 мс
  • результат: 119860736, 435 мс
  • результат: 119860736, 439 мс
  • результат: 119860736, 436 мс
  • результат: 119860736, 437 мс

2 * я * я

  • результат: 119860736, 417 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 418 мс
  • результат: 119860736, 418 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 418 мс
  • результат: 119860736, 416 мс
  • результат: 119860736, 417 мс
  • результат: 119860736, 418 мс

Хотя это не ответ на вопрос, он добавляет ценности. При этом, если что-то жизненно важно для вашего сообщения, пожалуйста, вставьте это в сообщение, а не ссылка на сторонний ресурс. Ссылки мертвы.

Jared Smith 28.11.2018 14:54

@JaredSmith Спасибо за отзыв. Учитывая, что ссылка, которую вы упомянули, является ссылкой "результат", это изображение не является источником вне сайта. Я загрузил его в stackoverflow через отдельную панель.

Ünsal Ersöz 28.11.2018 15:32

Это ссылка на imgur, так что да, неважно, как вы добавили ссылку. Я не понимаю, что такого сложного в копировании и вставке некоторого вывода консоли.

Jared Smith 28.11.2018 15:49

@JaredSmith Готово.

Ünsal Ersöz 28.11.2018 16:02

Но это наоборот

leppie 30.11.2018 15:55

Выше он использует lea для случая 1 и shl для случая 2 для умножения.

Onur Gumus 01.12.2018 05:51

@JaredSmith: но ... ссылки stack.imgur.com относятся к собственному частному экземпляру imgur Stack Exchange / Stack Overflow, который должен существовать как минимум столько же, сколько и сам Stack Overflow, поскольку именно туда загрузчик изображений помещает все изображения. (Естественно, что не делает его подходящей заменой для текста. Только то, что «ссылки становятся мертвыми», кажется неприменимым.)

SamB 01.12.2018 07:11

@SamB он все еще находится в домене imgur.com, что означает, что он будет существовать только до тех пор, пока imgur.

p91paul 01.12.2018 11:22

@ p91paul: Не совсем. Stack Overflow / Stack Exchange уже один раз перенесли все изображения imgur (из основного экземпляра imgur в их собственный, эксклюзивный для SO / SE экземпляр), и они могут сделать это снова, если в этом возникнет необходимость. (Я имею в виду, может возникнуть проблема, если каким-то образом все серверы stack.imgur.com взорвутся одновременно, но это, вероятно, не так уж и вероятно.) На самом деле они не считаются «внешними» ссылками.

SamB 14.12.2018 03:14

Скорее приложение. Я воспроизвел эксперимент, используя последнюю версию Java 8 JVM от IBM:

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

И это показывает очень похожие результаты:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(второй результат с использованием 2 * i * i).

Интересно, что при работе на той же машине, но с использованием Oracle Java:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

результаты в среднем немного медленнее:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Короче говоря: здесь имеет значение даже второстепенный номер версии HotSpot, поскольку тонкие различия в реализации JIT могут иметь заметные последствия.

Интересное наблюдение с использованием Java 11 и отключением разворачивания цикла со следующей опцией ВМ:

-XX:LoopUnrollLimit=0

Цикл с выражением 2 * (i * i) дает более компактный нативный код 1:

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

по сравнению с версией 2 * i * i:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

Версия Java:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

Результаты тестов:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

Исходный код теста:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1 - VM options used: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0

Вау, это какая-то тупица. Вместо увеличения iдо, копируя его для вычисления 2*i, он делает это после, поэтому ему нужна дополнительная инструкция add r11d,2. (К тому же он пропускает глазок add same,same вместо shl на 1 (добавление запускается на большем количестве портов). Он также пропускает глазок LEA для x*2 + 2 (lea r11d, [r8*2 + 2]), если он действительно хочет делать что-то в этом порядке по какой-то безумной причине планирования инструкций. Мы Из развернутой версии уже было видно, что отсутствие LEA стоило ему много мопов, как и оба цикла здесь.

Peter Cordes 02.12.2018 03:50
lea eax, [rax + r11 * 2] заменил бы 2 инструкции (в обоих циклах), если бы у JIT-компилятора было время искать эту оптимизацию в длительных циклах. Любой достойный опережающий компилятор его найдет. (Если, возможно, настройка только для AMD, где LEA с масштабированным индексом имеет задержку в 2 цикла, поэтому, возможно, оно того не стоит.)
Peter Cordes 02.12.2018 03:51

Другие вопросы по теме