Следующая программа 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%.
Также смотрите: stackoverflow.com/questions/504103/…
@Krease Хорошо, что ты поймал мою ошибку. Согласно новому тесту, 2 * i * i работает медленнее. Я тоже попробую побегать с Граалем.
Я смог подтвердить результат плаката: pastebin.com/KG0YRdnU При запуске этого кода опция 2 * (i * i) постоянно работает на 10-20% быстрее. Если я позволю обоим вариантам делать одно и то же, отклонение всегда будет <5%.
Во время моего бега это не так. У меня есть 2 варианта этого расчета в 2 методах calc1 () и calc2 (). В основном я вызываю calc1 (), а затем calc2 (). Затем я проверяю с помощью calc2 (), а затем calc1 (). В обоих случаях первый вызов (будь то calc1 () или calc2 ()) всегда занимает больше времени. Можешь попробовать.
@JornVernee Если это может помочь вам добавить дополнительную информацию к вашему ответу, набор инструкций JVM для iload и imul
Я не могу достоверно воспроизвести ваш результат; для меня версия в скобках работает медленнее в 95% случаев.
@nullpointer Чтобы по-настоящему выяснить, почему один из них быстрее другого, нам нужно получить дизассемблированные или идеальные графики для этих методов. Попытки разобраться в ассемблере очень утомительны, поэтому я пытаюсь получить отладочную сборку OpenJDK, которая может выводить красивые графики.
это зависит от многих факторов, таких как версия JVM и так далее. Это не всегда можно воспроизвести.
Понятно, что выполнение умножения i * i в первую очередь связано с лучшей производительностью, 2 * (i * i) кажется таким же быстрым, как и i * i * 2.
Это должна быть какая-то ошибка в JIT?
Вы можете переименовать свой вопрос в «Почему i * i * 2 быстрее, чем 2 * i * i?» для большей ясности, что проблема связана с порядком операций.
Пожалуйста, добавьте используемую версию JDK.
@ Cœur, но это не то, что происходит в байт-коде.
Я проделал тот же тест, и результаты полностью противоположны. stackoverflow.com/questions/53570864/…




Два метода добавления генерируют несколько отличающийся байт-код:
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 ... Примечание: я все равно не голосующий против.
@nullpointer - согласен. Я бы определенно проголосовал за восстановление удаления, если бы мог. Я также хотел бы удвоить оценку Стефану за количественное определение понятия «значительный».
Тот был удален самостоятельно, так как он измерял не то, что нужно - см. Комментарий автора к вопросу выше.
С JIT байт-код не имеет большого значения. Покажите код JIT.
Получите отладка jre и запустите с -XX:+PrintOptoAssembly. Или просто используйте vtune или что-то подобное.
@ rustyx - Если проблема заключается в реализации JIT ... то "получение отладочной версии" СОВЕРШЕННО ДРУГОЙ JRE не обязательно поможет. Тем не менее: похоже, что вы нашли выше с дизассемблированием JIT на JRE, также объясняет поведение в JRE OP и в моем. А также объясняет, почему другие JRE ведут себя «иначе». +1: спасибо за отличную детективную работу!
Байтовые коды: 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: Действительно, ответ объясняется не байт-кодом, а только рассмотрением фактического JITed x86-64 asm. JIT с тем же 16-кратным развертыванием для машины с большим количеством регистров (например, AArch64 или PowerPC), вероятно, не покажет никакой разницы на этих других ISA, в отличие от x86-64 или, вероятно, 32-разрядной ARM. По сути, не так уж и быстрее сдвинуть все и работать с байт-кодом Java, или, по крайней мере, эти вопросы и ответы не доказывают этого. Это происходит медленнее в этом случае, когда JIT-компилятор в одном случае отключает себя хуже, чем в другом.
(Примечание редактора: этот ответ противоречит данным, полученным при просмотре 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 * (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 - Нет. Рассмотрим случай, когда предел равен 4, и мы пытаемся вычислить 2*1*1 + 2*2*2 + 2*3*3. Очевидно, что вычисление 1*1 + 2*2 + 3*3 и умножение на 2 правильно, тогда как умножение на 8 - нет.
@MartinBonner - Согласен, я думаю, кто-то объяснял это несколько дней назад, но теперь его комментарий пропал. В любом случае спасибо за объяснение.
Математическое уравнение было таким же, как у этого 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Это было очень просто, и я просто забыл об этом, потому что цикл увеличивался.
Если вы распечатываете сборку с помощью jvm отладки, это не кажется правильным. Вы увидите группу sall ..., # 1, которые умножаются на 2 в цикле. Интересно, что версия помедленнее, похоже, не имеет умножений в цикле.
Почему JVM может исключить 2 из 2 * (i * i), но не из (2 * i) * i? Я бы подумал, что они эквивалентны (это может быть моим неверным предположением). Если да, то разве JVM не канонизирует выражение перед оптимизацией?
Я попробовал 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*22 * (i * i) - второй.JVM явно не оптимизируется так же, как человек (на основе ответа Рунеморо).
Итак, читаем байт-код: javap -c -v ./target/classes/org/sample/MyBenchmark.class
Я не являюсь экспертом по байт-коду, но мы iload_2 до imul: вероятно, в этом вы видите разницу: я могу предположить, что JVM оптимизирует чтение i дважды (i уже здесь, и нет необходимости загружать его снова), пока 2*i*i не может.
Байт-код AFAICT не имеет большого значения для производительности, и я бы не стал пытаться оценивать, что быстрее, на его основе. Это всего лишь исходный код для JIT-компилятора ... конечно, сохраняющий смысл переупорядочивание строк исходного кода может изменить результирующий код и его эффективность, но все это довольно непредсказуемо.
Есть небольшая разница в порядке байт-кода.
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
Со временем работы:
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 циклов на итерацию, поэтому это не узкое место. И я не думаю, что пропускная способность магазина является узким местом.
Вероятно, это просто узкое место в пропускной способности интерфейса пользователя из-за неэффективной генерации кода. Он даже не использует LEA как глазок для mov / add-immediate. например movl RBX, R9 / addl RBX, #8 должен быть leal ebx, [r9 + 8], 1 мкоп для копирования и добавления. Или leal ebx, [r9 + r9 + 16] делать ebx = 2*(r9+8). Так что да, разворачивание до точки разлива - это глупо, как и наивный тупой кодогенератор, который не использует преимущества целочисленных идентичностей и ассоциативной целочисленной математики.
@kasperd - да и для этой версии.
Векторизация для последовательного сокращения была отключена в C2 (bugs.openjdk.java.net/browse/JDK-8078563), но сейчас рассматривается возможность повторного включения (bugs.openjdk.java.net/browse/JDK-8188313).
У меня после выключения разворачивания цикла вообще получился какой-то интересный Результаты.
Хуже всего то, что ни один из этих компонентов не осознает, что умножение на 2 может быть выполнено намного быстрее, если выполнить left-shift. Я скопировал пример из вопроса и выполнил все 3 версии сразу, и из моего тестирования ясно, что 2 * i * i работает быстрее всего (в отличие от исходного тестового примера) и что в случае (i * i) << 1 запускается самый медленный, что, честно говоря, меня шокирует . Я использую OpenJDK 11 на MacOS. Я не анализировал вывод JIT.
Я думаю, что версия 2*i*i каким-то образом использует add, потому что там все еще "всего" 16 инструкций imul и нет sal или shl. Не думаю, что это выводит 2* из строя.
Только что заметил, что на 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.)
Касперд спросил в комментарии принятого ответа:
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).
Хотя это не связано напрямую со средой вопроса, просто из любопытства я провел тот же тест на .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 * (я * я)
2 * я * я
Хотя это не ответ на вопрос, он добавляет ценности. При этом, если что-то жизненно важно для вашего сообщения, пожалуйста, вставьте это в сообщение, а не ссылка на сторонний ресурс. Ссылки мертвы.
@JaredSmith Спасибо за отзыв. Учитывая, что ссылка, которую вы упомянули, является ссылкой "результат", это изображение не является источником вне сайта. Я загрузил его в stackoverflow через отдельную панель.
Это ссылка на imgur, так что да, неважно, как вы добавили ссылку. Я не понимаю, что такого сложного в копировании и вставке некоторого вывода консоли.
@JaredSmith Готово.
Но это наоборот
Выше он использует lea для случая 1 и shl для случая 2 для умножения.
@JaredSmith: но ... ссылки stack.imgur.com относятся к собственному частному экземпляру imgur Stack Exchange / Stack Overflow, который должен существовать как минимум столько же, сколько и сам Stack Overflow, поскольку именно туда загрузчик изображений помещает все изображения. (Естественно, что не делает его подходящей заменой для текста. Только то, что «ссылки становятся мертвыми», кажется неприменимым.)
@SamB он все еще находится в домене imgur.com, что означает, что он будет существовать только до тех пор, пока imgur.
@ p91paul: Не совсем. Stack Overflow / Stack Exchange уже один раз перенесли все изображения imgur (из основного экземпляра imgur в их собственный, эксклюзивный для SO / SE экземпляр), и они могут сделать это снова, если в этом возникнет необходимость. (Я имею в виду, может возникнуть проблема, если каким-то образом все серверы stack.imgur.com взорвутся одновременно, но это, вероятно, не так уж и вероятно.) На самом деле они не считаются «внешними» ссылками.
Скорее приложение. Я воспроизвел эксперимент, используя последнюю версию 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 стоило ему много мопов, как и оба цикла здесь.
lea eax, [rax + r11 * 2] заменил бы 2 инструкции (в обоих циклах), если бы у JIT-компилятора было время искать эту оптимизацию в длительных циклах. Любой достойный опережающий компилятор его найдет. (Если, возможно, настройка только для AMD, где LEA с масштабированным индексом имеет задержку в 2 цикла, поэтому, возможно, оно того не стоит.)
Я получаю аналогичные результаты (немного разные числа, но определенно заметный и последовательный разрыв, определенно больше, чем ошибка выборки)