Чому, якщо (змінна1% змінна2 == 0) неефективна?


179

Я новачок в Java, і вчора ввечері працював якийсь код, і це мене дуже турбувало. Я будував просту програму для відображення всіх результатів X у циклі for, і я помітив МАСИВНЕ зниження продуктивності, коли я використовував модуль як variable % variablevs variable % 5000чи що. Чи може хтось мені пояснити, чому це так і що це викликає? Так що я можу бути кращим ...

Ось "ефективний" код (вибачте, якщо я неправильно позначив синтаксис, я зараз не на комп'ютері з кодом)

long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

Ось "неефективний код"

long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

Зауважте, у мене була змінна дата для вимірювання різниць, і як тільки вона стала достатньо довгою, перша займала 50 мс, а друга займала 12 секунд чи щось подібне. Можливо, вам доведеться збільшити stopNumабо зменшити, progressCheckякщо ваш ПК більш ефективний, ніж мій, чи ні.

Я шукав це питання в Інтернеті, але не можу знайти відповідь, можливо, я просто не ставлю його правильно.

EDIT: Я не очікував, що моє запитання буде таким популярним, я ціную всі відповіді. Я виконував орієнтир на кожну половину зайнятий час, і неефективний код зайняв значно більше часу, 1/4 секунди проти 10 секунд дають або беруть. Зрозуміло, вони використовують println, але вони обидва роблять однакову суму, тому я не думаю, що це перекосило б це сильно, тим більше, що розбіжність повторюється. Що стосується відповідей, так як я новачок у Java, я зараз дозволю голосам вирішити, яка відповідь найкраща. Я спробую вибрати один до середи.

EDIT2: Я збираюся зробити ще один тест сьогодні ввечері, де замість модуля він просто збільшує змінну, і коли вона досягне progressCheck, вона виконає одну, а потім скине цю змінну до 0. для 3-го варіанту.

EDIT3.5:

Я використовував цей код, і нижче я покажу свої результати .. Дякую ВСІМ за чудову допомогу! Я також спробував порівняти коротке значення long з 0, тому всі мої нові перевірки трапляються колись "65536" разів, роблячи це рівним у повторах.

public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
                "\nfixed = " + final1 + " ms " + "\nvariable = " + final2 + " ms " + "\nfinal variable = " + final3 + " ms " + "\nincrement = " + final4 + " ms" + "\nShort Conversion = " + final5 + " ms");
    }
}

Результати:

  • фіксовано = 874 мс (як правило, близько 1000 мс, але швидше завдяки потужності 2)
  • змінна = 8590 мс
  • остаточна змінна = 1944 мс (було ~ 1000 мс при використанні 50000)
  • приріст = 1904 мс
  • Коротке перетворення = 679 мс

Не дивно, що через відсутність поділу Коротка конверсія була на 23% швидшою, ніж "швидкий" шлях. Це цікаво зазначити. Якщо вам потрібно показувати або порівнювати щось кожні 256 разів (або приблизно там), ви можете це зробити і використовувати

if ((byte)integer == 0) {'Perform progress check code here'}

ОДНА ЗАКЛЮЧНА ПРИМІТКА, використання модуля на "Остаточній оголошеній змінній" з 65536 (не досить число) було вдвічі швидше (повільніше), ніж фіксоване значення. Де раніше це було тестування майже з однаковою швидкістю.


29
Я фактично отримав такий же результат. На моїй машині перший цикл працює приблизно за 1,5 секунди, а другий - приблизно за 9 секунд. Якщо я додаю finalперед progressCheckзмінною, обидва знову бігаються з однаковою швидкістю. Це приводить мене до думки, що компілятор або JIT вдається оптимізувати цикл, коли він знає, що progressCheckце постійне.
marstran


24
Ділення на постійну можна легко перетворити на множення на мультиплікативний зворотний . Поділ змінною не може. І 32-розрядний поділ швидший, ніж 64-бітний поділ на x86
phuclv

2
@phuclv note 32-бітний поділ тут не є проблемою, це 64-розрядна операція із залишками в обох випадках
user85421

4
@RobertCotterman, якщо ви визначите змінну як остаточну, компілятор створює той самий байт-код, що і за допомогою константи (eclipse / Java 11) ((незважаючи на використання ще однієї слоти пам'яті для змінної))
user85421

Відповіді:


139

Ви вимірюєте заглушку OSR (заміну на стек) .

Заглушка OSR - це спеціальна версія скомпільованого методу, призначена спеціально для передачі виконання з інтерпретованого режиму до компільованого коду під час роботи методу.

Заглушки OSR не настільки оптимізовані, як звичайні методи, тому що їм потрібен макет кадру, сумісний з інтерпретованим кадром. Я показав це вже в таких відповідях: 1 , 2 , 3 .

Подібне відбувається і тут. Поки "неефективний код" працює з довгим циклом, метод збирається спеціально для заміни на стеці прямо всередині циклу. Стан передається з інтерпретованого кадру методом, складеному OSR, і цей стан включає progressCheckлокальну змінну. На даний момент JIT не може замінити змінну постійною і, отже, не може застосувати певні оптимізації, наприклад зниження сили .

Зокрема, це означає, що JIT не замінює ціле ділення на множення . (Див. Чому GCC використовує множення на дивне число при здійсненні цілого поділу? Для трюку asm від компілятора випереджаючого часу, коли значення є константою часу компіляції після вбудовування / постійного поширення, якщо ці оптимізації включені Ціле буквальне право в %виразі також оптимізується за допомогоюgcc -O0 , подібно до того, де його оптимізує JITer навіть в заглушці OSR.)

Однак якщо запустити один і той же метод кілька разів, другий і наступні запуски виконають звичайний (не OSR) код, який повністю оптимізований. Ось орієнтир для доведення теорії ( орієнтир за допомогою JMH ):

@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

І результати:

# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

Сама перша ітерація divVarдійсно набагато повільніше, через неефективно складену заглушку OSR. Але як тільки метод відновлюється з початку, виконується нова необмежена версія, яка використовує всі доступні оптимізації компілятора.


5
Я вагаюся з цього приводу. З одного боку, це звучить як продуманий спосіб сказати "Ви зіпсували свій орієнтир, прочитайте щось про JIT". З іншого боку, мені цікаво, чому ви, здається, настільки впевнені, що OSR був тут головним релевантним моментом. Я маю в виду, роблячи (мікро) тест , який включає в себе System.out.printlnбуде майже завжди дають результати для сміття, а також той факт , що обидві версії однаково швидко не потрібно нічого робити з ОСР в зокрема , наскільки я можу сказати ..
Marco13

2
(Мені цікаво і люблю це розуміти. Я сподіваюся, що коментарі не турбують. Можливо, видалять їх пізніше, але:) Посилання 1трохи сумнівне - порожній цикл також можна повністю оптимізувати. Другий більше схожий на той. Але знову ж , це не зрозуміло , чому ви приписуєте різницю в OSR конкретно . Я просто сказав: У якийсь момент метод стає JITED і стає швидшим. Наскільки я розумію, OSR лише спричиняє використання остаточного оптимізованого коду (приблизно) ~ "відкладеним до наступного проходу оптимізації". (продовження ...)
Marco13

1
(продовження :) Якщо ви спеціально не аналізуєте журнали точки гарячої точки, ви не можете сказати, різниця викликана порівнянням JITed і un-JITed коду, або порівнянням JITed і OSR-stub-коду. І ви точно не можете цього сказати точно, коли питання не містить реального коду або повного еталону JMH. Так стверджуючи, що різниця викликана звуками OSR, для мене невідповідно специфічними (і "невиправданими") порівняно з твердженням, що це викликано JIT загалом. (Без образи - мені просто цікаво ...)
Marco13

4
@ Marco13 є проста евристика: без активності JIT кожна %операція мала б однакову вагу, оскільки оптимізоване виконання можливе лише в тому випадку, якщо оптимізатор справді працював. Тож той факт, що один варіант циклу значно швидше, ніж інший, підтверджує наявність оптимізатора та ще більше підтверджує, що він не зміг оптимізувати одну з циклів у тій же мірі, що й іншу (в межах одного методу!). Оскільки ця відповідь доводить можливість оптимізації обох циклів в однаковій мірі, повинно бути щось, що перешкоджає оптимізації. І це ОСЗ у 99,9% усіх випадків
Холгер

4
@ Marco13 Це була "освічена здогадка", заснована на знаннях HotSpot Runtime та досвіді аналізу подібних проблем раніше. Такий довгий цикл навряд чи може бути складений іншим способом, ніж OSR, особливо в простому ручному орієнтирі. Тепер, коли ОП опублікував повний код, я можу лише ще раз підтвердити міркування, запустивши код -XX:+PrintCompilation -XX:+TraceNMethodInstalls.
apangin

42

Під час подання коментаря @phuclv я перевірив код, сформований JIT 1 , результати такі:

для variable % 5000(поділ на постійні):

mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

для variable % variable:

mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

Оскільки ділення завжди займає більше часу, ніж множення, останній фрагмент коду є менш ефективним.

Версія 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)

1 - використовувані варіанти VM: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main


14
Надати порядок на «повільніше», для x86_64: imulце 3 цикли, idivце від 30 до 90 циклів. Таким чином, ціле ділення на 10х і 30х повільніше, ніж ціле множення.
Матьє М.

2
Чи не могли б ви пояснити, що все це означає для зацікавлених читачів, але не розмовляєте з асемблером?
Ніко Хаазе

7
@NicoHaase Дві коментовані лінії є єдиними важливими. У першому розділі код виконує ціле множення, тоді як у другому розділі код виконує ціле ділення. Якщо ви думаєте робити множення та ділення вручну, при множенні ви зазвичай робите купу невеликих множин, а потім один великий набір додавань, але ділення - це невелике ділення, невелике множення, віднімання та повторення. Ділення повільне, тому що ви, по суті, робите купу множин.
MBraedley

4
@MBraedley дякую за ваш внесок, але таке пояснення слід додати до самої відповіді і не ховати в розділі коментарів
Ніко Хааз

6
@MBraedley: Більш того, множення в сучасному процесорі швидке, оскільки часткові продукти є незалежними і, таким чином, можуть бути обчислені окремо, тоді як кожен етап поділу залежить від попередніх етапів.
Supercat

26

Як зазначали інші, для загального функціонування модуля потрібно зробити поділ. У деяких випадках ділення можна замінити (компілятором) множенням. Але обидва можуть бути повільними порівняно з додаванням / відніманням. Отже, найкращого результату можна очікувати, якщо ви хочете:

long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(Як незначна спроба оптимізації ми тут використовуємо попередній декремент вниз, оскільки для багатьох архітектур порівняно з 0 одразу після арифметичної операції, коштує рівно 0 інструкцій / циклів процесора, оскільки прапори ALU вже встановлені належним чином попередньою операцією. Гідне оптимізація компілятор, однак, зробить цю оптимізацію автоматично, навіть якщо ви пишетеif (counter++ == 50000) { ... counter = 0; } .)

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

Ще одна «хитрість» - використовувати значення / обмеження потужності, наприклад progressCheck = 1024;. Модуль потужністю два можна швидко обчислити за допомогою побітових передач and, тобто if ( (i & (1024-1)) == 0 ) {...}. Це також має бути досить швидким, і, можливо, деякі архітектури перевершують явне counterвище.


3
Розумний компілятор перетворив би петлі сюди. Або ви могли це зробити у джерелі. if()Тіло стає тілом зовнішнього контуру, а матеріал зовні if()стає внутрішнім тілом циклу , який виконується для min(progressCheck, stopNum-i)ітерацій. Отже, на початку, і кожного разу counter, коли ви досягаєте 0, ви робите long next_stop = i + min(progressCheck, stopNum-i);налаштування для for(; i< next_stop; i++) {}циклу. У цьому випадку внутрішній цикл порожній і, сподіваємось, повністю його оптимізувати, ви можете зробити це у джерелі та спростити JITer, зменшивши цикл до i + = 50k.
Пітер Кордес

2
Але так, загалом, лічильник - це хороша ефективна техніка для матеріалів типу fizzbuzz / progresscheck.
Пітер Кордес

Я додав до свого запитання, і зробив приріст, --counterце так само швидко, як і моя інкрементна версія, але менше code.also, це було на 1 нижче, ніж повинно бути, мені цікаво, чи варто counter--отримати точну кількість, яку ви хочете не те, що це велика різниця
Роберт Коттерман

@PeterCordes Розумний компілятор просто надрукував цифри, без циклу. (Я думаю, що деякі лише трохи тривіальніші орієнтири почали провалюватися таким чином, можливо, 10 років тому.)
Пітер -

2
@RobertCotterman Так, --counterвимкнено один. counter--дасть вам точно progressCheckкількість ітерацій (або ви можете встановити progressCheck = 50001;звичайно).
JimmyB

4

Я також здивований, побачивши ефективність наведених вище кодів. Це все про час, витрачений компілятором на виконання програми відповідно до заявленої змінної. У другому (неефективному) прикладі:

for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

Ви виконуєте операцію з модулем між двома змінними. Тут компілятор повинен перевірити значенняstopNum іprogressCheck переходити до конкретного блоку пам'яті, розташованого для цих змінних, кожного разу після кожної ітерації, оскільки це змінна, і її значення може бути змінено.

Ось чому після кожного ітераційного компілятора перейшов до місця пам'яті, щоб перевірити останнє значення змінних. Тому під час компіляції компілятор не зміг створити ефективний байт-код.

У першому прикладі коду ви виконуєте оператор модуля між змінною та постійним числовим значенням, яке не збирається змінюватись у процесі виконання та компілятором, не потрібно перевіряти значення цього числового значення з місця пам'яті. Ось чому компілятор зміг створити ефективний байт-код. Якщо ви оголошуєте progressCheckяк finalабо в якості final staticзмінної , то в момент часу виконання / компіляції компілятор знаю , що це кінцева величина і її значення не зміниться , то компілятор замінить progressCheckз 50000в коді:

for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

Тепер ви можете бачити, що цей код також виглядає як перший (ефективний) приклад коду. Виконання першого коду та, як ми вже згадували вище, обидва коди працюватимуть ефективно. Не буде великої різниці у часі виконання будь-якого прикладу коду.


1
Є велика різниця, хоча я робив цю операцію в трильйон разів, тому понад 1 трлн операцій це заощадило 89% часу на виконання "ефективного" коду. зауважте, якщо ви робите це лише кілька тисяч разів, говорили про таку крихітну різницю, це, мабуть, не велика справа. я маю на увазі понад 1000 операцій, це дозволить вам заощадити 1 мільйонну частину 7 секунд.
Роберт Коттерман

1
@ Бішаль Дюбі "Не буде великої різниці у часі виконання обох кодів." Ви читали питання?
Грант Фостер

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