Чому "while (i ++ <n) {}" значно повільніше, ніж "while (++ i <n) {}"


74

Очевидно, на моєму ноутбуці 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 кроки в зворотному порядку. Але це, здається, не пояснює, чому перше набагато швидше. Чи є в останньому випадку якась тимчасова копія? Або це щось за межами байт-коду (реалізація віртуальної машини, обладнання тощо), що повинно бути відповідальним за різницю в продуктивності?

Я прочитав деяку іншу дискусію щодо ++iі i++(хоча не вичерпно), але не знайшов жодної відповіді, яка стосується Java і безпосередньо пов'язана з випадком, коли ++iабо i++бере участь у порівнянні значень.


23
10 мс навряд чи достатньо для тесту - не кажучи вже про тестування Java, де у вас є ефекти розминки JVM. Чи можете ви опублікувати свій точний тестовий код? Також спробуйте змінити порядок тестів.
Містичний

3
Як сказав Mysticial, Java потребує часу на розминку. Це для компілятора Just In Time (JIT), щоб виконати свою роботу. Якщо ви розміщуєте свій код у функції і викликаєте її кілька разів перед тим, як робити вимірювання, ви можете отримати різні результати.
Thirler

12
@CaptainCodeman у такій загальній формі, що це твердження є просто дурницею. Ефективність роботи набагато більше, ніж (недосконалі) мікротести. Ми перейшли на Java для досить великого проекту з C ++ і отримали порядок продуктивності. Це залежить від проблеми, яку ви намагаєтесь вирішити, наявних у вас ресурсів та багато іншого. Завжди вибирайте мову, яка найбільше відповідає вашій проблемі, та персонал, який у вас є під рукою (серед інших факторів).
Аксель

4
@Axel Мені цікаво, для якого типу додатків перехід з C ++ на Java дав вам порядок підвищення продуктивності?
CaptainCodeman

7
@Axel Жодна скомпільована мова програмування не на порядок швидша за іншу; отже, більш вірогідним сценарієм є те, що у вас були жахливі програмісти на C ++ або ви використовували дуже повільну бібліотеку.
CaptainCodeman

Відповіді:


119

Як зазначали інші, тест багато в чому помилковий.

Ви не сказали нам, як саме ви пройшли цей тест. Однак я спробував здійснити такий "наївний" тест (без образ), такий:

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::runPreIncrement@-1 (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::runPostIncrement@-1 (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::runPostIncrement@11 (line 36)

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

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

Для мене не зовсім зрозуміло, чому це здається не видаляє версію після збільшення. (Насправді, я розглядаю це питання як окреме питання). Але, принаймні, це пояснює, чому ви можете бачити відмінності на "порядок величини" ...


РЕДАКТУВАТИ: Цікаво, що при зміні верхньої межі циклу з Integer.MAX_VALUEна Integer.MAX_VALUE-1, тоді обидві версії оптимізовані та вимагають "нульового" часу. Якось ця межа (яка все ще з'являється, як 0x7fffffffу збірці) перешкоджає оптимізації. Імовірно, це має щось спільне з порівнянням, яке відображається на (виокремлену!) cmpІнструкцію, але я не можу навести глибоку причину, окрім цього. JIT працює загадково ...


2
Я не хлопець з Java, але справді захоплююсь захопленням механікою компіляторів. Якщо ви (або хтось) ставите своє подальше запитання в окремому дописі, надішліть посилання. Дякую!
RLH

26
Власне це було перше, що мені спало на думку: коли while (i++ < Integer.MAX_VALUE)виходить із циклу, вже сталося переповнення i. Довести правильність перетворення коду набагато складніше, коли може відбутися переповнення, і врешті-решт, цикли з переповненнями не є загальним випадком, так чому ж точка доступу повинна турбуватися їх оптимізацією ...
Холгер

5
@RLH я відправив наступне запитання на stackoverflow.com/questions/25326377 / ...
Marco13

@Holger: Так, це звучить як спосіб уникнути проблем з оптимізацією, що порушує обмеження безпеки - це трапляється не часто, тому не варто перевіряти всі речі, які можуть піти не так (наприклад, переповнення буфера).
Луаан,

@Holger, але як ти поясниш, що якщо обмеження зменшено з Integer.MAX_VALUE до Integer.MAX_VALUE-1, обидва оптимізовані, тож із випадком i ++ переповнення випадків все одно трапляється, але одночасно оптимізовано !!!
Суміт Кумар Саха

19

Різниця між ++ i та i ++ полягає в тому, що ++ i ефективно збільшує змінну та «повертає» це нове значення. i ++, з іншого боку, ефективно створює тимчасову змінну для утримання поточного значення в 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, так і i ++ з такою ж продуктивністю, як якщо б ви використовували ++ i.


Може бути дещо зрозуміліше, коли компілятор точки доступу буде згадуватися явно.
Joop Eggen

10
Як згадано в OP, обидві версії дають однакову кількість інструкцій байт-коду. Де там накладні витрати, про які ви говорите? І які оптимізації JVM, про які ви говорите, можливі для ++iверсії, а не для іншої?
arne.b

Цікаво, як працює iload ... Чи насправді він копіює змінну з локальної таблиці змінних у стек операндів? Якщо так, для i ++ спочатку i виштовхується (копіюється) у стек операндів, і iinc збільшує вихідний i у таблиці локальних змінних. ++ i робить точно те ж саме у зворотному порядку. В обох випадках додаткової змінної temp немає. Але я можу абсолютно помилитися :)
sikan

Якщо ви подивитесь на відповідь Євгена з його доданими тестами, ви побачите, що різниця мінімальна, якщо взагалі не існує. JVM може оптимізувати, здебільшого, i ++ до ++ i. Таким чином, він видалить змінну temp і просто зробить збільшення змінної. Єдиним припущенням є те, що, використовуючи i ++ у порівнянні, це те, що коли байт-код компілюється до машинного коду, JVM виділяє додатковий регістр для використання з циклом.
Smith_61

18

РЕДАГУВАТИ 2

Вам справді слід заглянути сюди:

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

EDIT Чим більше я думаю про це, я усвідомлюю, що цей тест якось неправильний, 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

Поле оцінки - це те, що вас цікавить.


З того, що я можу сказати і виправити мене, якщо я помиляюся, JVM, здається, не оптимізує i ++ в ++ i, коли використовується результат. Або це просто тому, що i ++ повторює додатковий час?
Smith_61

0

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


-3

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

Дійсно, багато оптимізаторів оптимізують непотрібну глибоку копію, але їм нелегко це зробити, якщо ви використовуєте значення виразу. І ви робите саме це у вашому випадку.

Багато людей не згодні: вони розглядають це як мікрооптимізацію.


6
Це може бути правдою у світі нетривіальних ітераторів С ++, але не для примітивних типів ...
Містичний

3
@Bathsheba Я згоден з тим, що ви повинні розуміти свій компілятор і які оптимізації він зробить для вас. У обмежених випадках вам доведеться робити такі види оптимізації самостійно. Якщо ви використовуєте компілятор, який не буде робити це за вас, ви, мабуть, будете знати. Оскільки більшість цих компіляторів призначені для вбудованих систем або мають меншу кількість користувачів.
Smith_61

4
Я на стороні @Bathsheba. Я знаю, що в 99% випадків (особливо в Java) це не робить різниці в написанні ++ i та i ++. Однак я волів би взяти за звичку писати ++ i, оскільки є нетривіальний випадок, коли це має значення (особливо в C ++ тощо). Враховуючи, що ++ i читати нічого складніше, ніж i ++, чому б не написати потенційно безпечнішу форму? Так само, як ми пишемо такі речі, як if (CONSTANT == var), іif (CONSTANT.equals(var))
Адріан Шум

5
Проголосуйте проти дезінформації. Неможливо "глибоко копіювати" що-небудь, на чому можна використовувати оператори "++" у Java, і твердження, що оптимізатори не можуть оптимізувати операцію, коли вона використовується для порівняння, також є помилковою інформацією.
Score_Under

4
У ситуації, коли використовується результат оператора збільшення, слід використовувати той оператор, який краще відповідає семантиці того, що робиться, оскільки будь-яка різниця в продуктивності може бути компенсована змінами коду в інших місцях, що виникають внаслідок вибору. Якщо результат оператора не використовується, я віддаю перевагу постоператорам, оскільки це більше відповідає шаблону іменника-дієслова, який використовується в інших місцях.
supercat
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.