Почему "while (i ++ <n) {}" значительно медленнее, чем "while (++ я <n) {}"

По-видимому, на моем ноутбуке Windows 8 с HotSpot JDK 1.7.0_45 (при всех параметрах компилятора/виртуальной машины по умолчанию) нижний цикл

final int n = Integer.MAX_VALUE;
int i = 0;
while (++i < n) {
}

не менее чем на 2 порядка быстрее (~ 10 мс против ~ 5000 мс), чем:

final int n = Integer.MAX_VALUE;
int i = 0;
while (i++ < n) {
}

Мне приходилось замечать эту проблему при написании цикла для оценки другой нерелевантной проблемы производительности. И разница между ++i < n и i++ < n была достаточно огромной, чтобы существенно повлиять на результат.

Если мы посмотрим на байт-код, тело цикла более быстрой версии:

iinc
iload
ldc
if_icmplt

И для более медленной версии:

iload
iinc
ldc
if_icmplt

Итак, для ++i < n он сначала увеличивает локальную переменную i на 1, а затем нажимает ее на стек операнда, а i++ < n выполняет эти 2 шага в обратном порядке. Но это, похоже, не объясняет, почему первое намного быстрее. Есть ли временная копия в последнем случае? Или это что-то помимо байт-кода (внедрение VM, аппаратное обеспечение и т.д.), Которые должны отвечать за разницу в производительности?

Я прочитал некоторое другое обсуждение относительно ++i и i++ (но не исчерпывающе), но не нашел ответа, специфичного для Java, и непосредственно связанного с случаем, когда ++i или i++ участвует в сравнении значений.

Ответ 1

Как отмечали другие, тест имеет недостатки во многих отношениях.

Вы точно не сказали нам, как вы это сделали. Тем не менее, я попытался реализовать "наивный" тест (без обид):

class PrePostIncrement
{
    public static void main(String args[])
    {
        for (int j=0; j<3; j++)
        {
            for (int i=0; i<5; i++)
            {
                long before = System.nanoTime();
                runPreIncrement();
                long after = System.nanoTime();
                System.out.println("pre  : "+(after-before)/1e6);
            }
            for (int i=0; i<5; i++)
            {
                long before = System.nanoTime();
                runPostIncrement();
                long after = System.nanoTime();
                System.out.println("post : "+(after-before)/1e6);
            }
        }
    }

    private static void runPreIncrement()
    {
        final int n = Integer.MAX_VALUE;
        int i = 0;
        while (++i < n) {}
    }

    private static void runPostIncrement()
    {
        final int n = Integer.MAX_VALUE;
        int i = 0;
        while (i++ < n) {}
    }
}

При запуске с настройками по умолчанию, похоже, небольшая разница. Но недостаток реального теста становится очевидным, когда вы запускаете его с помощью флага -server. Результаты в моем случае затем следуют примерно как

...
pre  : 6.96E-4
pre  : 6.96E-4
pre  : 0.001044
pre  : 3.48E-4
pre  : 3.48E-4
post : 1279.734543
post : 1295.989086
post : 1284.654267
post : 1282.349093
post : 1275.204583

Очевидно, что пре-инкрементная версия полностью оптимизирована. Причина довольно проста: результат не используется. Не имеет значения, выполняется ли цикл или нет, поэтому JIT просто удаляет его.

Это подтверждается просмотром разборки хот-спота: версия предварительного инкремента приводит к этому коду:

[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x0000000055060500} &apos;runPreIncrement&apos; &apos;()V&apos; in &apos;PrePostIncrement&apos;
  #           [sp+0x20]  (sp of caller)
  0x000000000286fd80: sub    $0x18,%rsp
  0x000000000286fd87: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - PrePostIncrement::[email protected] (line 28)

  0x000000000286fd8c: add    $0x10,%rsp
  0x000000000286fd90: pop    %rbp
  0x000000000286fd91: test   %eax,-0x243fd97(%rip)        # 0x0000000000430000
                                                ;   {poll_return}
  0x000000000286fd97: retq   
  0x000000000286fd98: hlt    
  0x000000000286fd99: hlt    
  0x000000000286fd9a: hlt    
  0x000000000286fd9b: hlt    
  0x000000000286fd9c: hlt    
  0x000000000286fd9d: hlt    
  0x000000000286fd9e: hlt    
  0x000000000286fd9f: hlt    

Версия после инкремента приводит к этому коду:

[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x00000000550605b8} &apos;runPostIncrement&apos; &apos;()V&apos; in &apos;PrePostIncrement&apos;
  #           [sp+0x20]  (sp of caller)
  0x000000000286d0c0: sub    $0x18,%rsp
  0x000000000286d0c7: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - PrePostIncrement::[email protected] (line 35)

  0x000000000286d0cc: mov    $0x1,%r11d
  0x000000000286d0d2: jmp    0x000000000286d0e3
  0x000000000286d0d4: nopl   0x0(%rax,%rax,1)
  0x000000000286d0dc: data32 data32 xchg %ax,%ax
  0x000000000286d0e0: inc    %r11d              ; OopMap{off=35}
                                                ;*goto
                                                ; - PrePostIncrement::[email protected] (line 36)

  0x000000000286d0e3: test   %eax,-0x243d0e9(%rip)        # 0x0000000000430000
                                                ;*goto
                                                ; - PrePostIncrement::[email protected] (line 36)
                                                ;   {poll}
  0x000000000286d0e9: cmp    $0x7fffffff,%r11d
  0x000000000286d0f0: jl     0x000000000286d0e0  ;*if_icmpge
                                                ; - PrePostIncrement::[email protected] (line 36)

  0x000000000286d0f2: add    $0x10,%rsp
  0x000000000286d0f6: pop    %rbp
  0x000000000286d0f7: test   %eax,-0x243d0fd(%rip)        # 0x0000000000430000
                                                ;   {poll_return}
  0x000000000286d0fd: retq   
  0x000000000286d0fe: hlt    
  0x000000000286d0ff: hlt    

Это не совсем понятно для меня, почему это, по-видимому, не удаляет версию после инкремента. (На самом деле, я рассматриваю вопрос как отдельный вопрос). Но, по крайней мере, это объясняет, почему вы можете видеть различия с "порядком величины"...


EDIT: Интересно, что при изменении верхнего предела цикла от Integer.MAX_VALUE до Integer.MAX_VALUE-1, обе версии оптимизируются и требуют "нулевого" времени. Как-то этот предел (который все еще появляется как 0x7fffffff в сборке) предотвращает оптимизацию. Предположительно, это имеет какое-то отношение к сопоставлению, сопоставленному с инструкцией (singed!) cmp, но я не могу дать более глубокую причину помимо этого. JIT работает загадочно...

Ответ 2

Разница между ++ я и я ++ заключается в том, что ++ я эффективно увеличивает эту переменную и возвращает ее новому значению. я ++, с другой стороны, эффективно создает временную переменную для хранения текущего значения в i, а затем увеличивает значение переменной, возвращающее значение переменной temp. Здесь возникают дополнительные накладные расходы.

// i++ evaluates to something like this
// Imagine though that somehow i was passed by reference
int temp = i;
i = i + 1;
return temp;

// ++i evaluates to
i = i + 1;
return i;

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

for( int i = 0; i < Integer.MAX_VALUE; i++ ) {}

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

Ответ 3

РЕДАКТИРОВАТЬ 2

Вы действительно должны посмотреть здесь:

http://hg.openjdk.java.net/code-tools/jmh/file/f90aef7f1d2c/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_11_Loops.java

ИЗМЕНИТЬ Чем больше я думаю об этом, я понимаю, что этот тест как-то не так, цикл будет серьезно оптимизирован JVM.

Я думаю, что вы должны просто отбросить @Param и n=2.

Таким образом вы проверите производительность самого while. Результаты, полученные в этом случае:

o.m.t.WhileTest.testFirst      avgt         5        0.787        0.086    ns/op
o.m.t.WhileTest.testSecond     avgt         5        0.782        0.087    ns/op

Разница почти не отличается

Самый первый вопрос, который вы должны задать себе, - это , как вы тестируете и измеряете этот. Это микро-бенчмаркинг, а на Java это искусство, и почти всегда простой пользователь (например, я) ошибуется в результатах. Вы должны полагаться на тестовый тест и очень хороший инструмент для этого. Я использовал JMH, чтобы проверить это:

    @Measurement(iterations=5, time=1, timeUnit=TimeUnit.MILLISECONDS)
@Fork(1)
@Warmup(iterations=5, time=1, timeUnit=TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Benchmark)
public class WhileTest {
    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(".*" + WhileTest.class.getSimpleName() + ".*")
            .threads(1)
            .build();

        new Runner(opt).run();
    }


    @Param({"100", "10000", "100000", "1000000"})
    private int n;

    /*
    @State(Scope.Benchmark)
    public static class HOLDER_I {
        int x;
    }
    */


    @Benchmark
    public int testFirst(){
        int i = 0;
        while (++i < n) {
        }
        return i;
    }

    @Benchmark
    public int testSecond(){
        int i = 0;
        while (i++ < n) {
        }
        return i;
    }
}

Кто-то, более опытный в JMH, может исправить эти результаты (я действительно надеюсь, что так!), поскольку я еще не настолько универсален в JMH), но результаты показывают, что разница довольно маленькая:

Benchmark                        (n)   Mode   Samples        Score  Score error    Units
o.m.t.WhileTest.testFirst        100   avgt         5        1.271        0.096    ns/op
o.m.t.WhileTest.testFirst      10000   avgt         5        1.319        0.125    ns/op
o.m.t.WhileTest.testFirst     100000   avgt         5        1.327        0.241    ns/op
o.m.t.WhileTest.testFirst    1000000   avgt         5        1.311        0.136    ns/op
o.m.t.WhileTest.testSecond       100   avgt         5        1.450        0.525    ns/op
o.m.t.WhileTest.testSecond     10000   avgt         5        1.563        0.479    ns/op
o.m.t.WhileTest.testSecond    100000   avgt         5        1.418        0.428    ns/op
o.m.t.WhileTest.testSecond   1000000   avgt         5        1.344        0.120    ns/op

Поле Score - это то, которое вас интересует.

Ответ 4

Вероятно, этого теста недостаточно, чтобы сделать выводы, но я бы сказал, что если это так, JVM может оптимизировать это выражение, изменив я ++ на ++ i, поскольку сохраненное значение я ++ (pre value) никогда не используется в этом цикл.

Ответ 5

Я предлагаю вам (по возможности) использовать ++c, а не c++, поскольку первый никогда не будет медленнее, так как, в конечном счете, необходимо сделать глубокую копию c в последнем случае, чтобы вернуть предыдущее значение.

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

Многие люди не согласны с этим: они рассматривают его как микро-оптимизацию.