Чому повернення посилання на об’єкт Java набагато повільніше, ніж повернення примітиву


75

Ми працюємо над додатком, чутливим до затримок, і проводили мікробенчмаркінг усіх видів методів (використовуючи jmh ). Після мікробенчмаркінгу методу пошуку та задовольнившись результатами, я застосував остаточну версію, лише виявивши, що остаточна версія була в 3 рази повільнішою за ту, яку я щойно провів.

Виною тому, що реалізований метод повертав enumоб'єкт замість int. Ось спрощена версія базового коду:

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class ReturnEnumObjectVersusPrimitiveBenchmark {

    enum Category {
        CATEGORY1,
        CATEGORY2,
    }

    @Param( {"3", "2", "1" })
    String value;

    int param;

    @Setup
    public void setUp() {
        param = Integer.parseInt(value);
    }

    @Benchmark
    public int benchmarkReturnOrdinal() {
        if (param < 2) {
            return Category.CATEGORY1.ordinal();
        }
        return Category.CATEGORY2.ordinal();        
    }


    @Benchmark
    public Category benchmarkReturnReference() {
        if (param < 2) {
            return Category.CATEGORY1;
        }
        return Category.CATEGORY2;      
    }


    public static void main(String[] args) throws RunnerException {
            Options opt = new OptionsBuilder().include(ReturnEnumObjectVersusPrimitiveBenchmark.class.getName()).warmupIterations(5)
                .measurementIterations(4).forks(1).build();
        new Runner(opt).run();
    }

}

Орієнтовні результати для вище:

# VM invoker: C:\Program Files\Java\jdk1.7.0_40\jre\bin\java.exe
# VM options: -Dfile.encoding=UTF-8

Benchmark                   (value)   Mode  Samples     Score     Error   Units
benchmarkReturnOrdinal            3  thrpt        4  1059.898 ±  71.749  ops/us
benchmarkReturnOrdinal            2  thrpt        4  1051.122 ±  61.238  ops/us
benchmarkReturnOrdinal            1  thrpt        4  1064.067 ±  90.057  ops/us
benchmarkReturnReference          3  thrpt        4   353.197 ±  25.946  ops/us
benchmarkReturnReference          2  thrpt        4   350.902 ±  19.487  ops/us
benchmarkReturnReference          1  thrpt        4   339.578 ± 144.093  ops/us

Просто зміна типу повернення функції змінила продуктивність майже в 3 рази.

Я думав, що єдина різниця між поверненням об'єкта перерахування та цілим числом полягає в тому, що один повертає 64-бітове значення (посилання), а інший повертає 32-бітове значення. Один з моїх колег здогадувався, що повернення переліку додало додаткових накладних витрат через необхідність відстеження посилання на потенційний GC. (Але враховуючи те, що перелічені об’єкти є статичними кінцевими посиланнями, здається дивним, що це потрібно було б зробити).

Яке пояснення різниці в продуктивності?


ОНОВЛЕННЯ

Я поділився тут проектом maven, щоб кожен міг його клонувати та запустити еталон. Якщо хтось має час / інтерес, було б корисно подивитися, чи зможуть інші повторити ті самі результати. (Я тиражував на двох різних машинах, Windows 64 та Linux 64, обидва використовували версії JVM Oracle Java 1.7). @ZhekaKozlov каже, що не бачив різниці між методами.

Запустити: (після клонування сховища)

mvn clean install
java -jar .\target\microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1

Коментарі не призначені для розширеного обговорення; цю розмову переміщено до чату .
Flexo

Відповіді:


155

TL; DR: Ви не повинні довіряти СЛІПОМ ні до чого.

Перш за все: важливо перевірити експериментальні дані, перш ніж робити висновки з них. Просто стверджувати, що щось в 3 рази швидше / повільніше, це дивно, адже вам дійсно потрібно з’ясувати причину різниці в продуктивності, а не просто довіряти цифрам. Це особливо важливо для таких нано-тестів, як у вас.

По-друге, експериментатори повинні чітко розуміти, чим вони керують, а чим ні. У вашому конкретному прикладі ви повертаєте значення з @Benchmarkметодів, але чи можете ви бути обґрунтовано впевнені, що дзвінки, які телефонують зовні, зроблять те саме для примітиву та посилання? Якщо ви задасте собі це питання, то зрозумієте, що в основному вимірюєте інфраструктуру тестування.

До суті. На моїй машині (i5-4210U, Linux x86_64, JDK 8u40) тест дає:

Benchmark                    (value)   Mode  Samples  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt        5  0.876 ± 0.023  ops/ns
...benchmarkReturnOrdinal          2  thrpt        5  0.876 ± 0.009  ops/ns
...benchmarkReturnOrdinal          1  thrpt        5  0.832 ± 0.048  ops/ns
...benchmarkReturnReference        3  thrpt        5  0.292 ± 0.006  ops/ns
...benchmarkReturnReference        2  thrpt        5  0.286 ± 0.024  ops/ns
...benchmarkReturnReference        1  thrpt        5  0.293 ± 0.008  ops/ns

Гаразд, тому контрольні тести здаються втричі повільнішими. Але почекайте, він використовує старий JMH (1.1.1), давайте оновимось до останнього (1.7.1):

Benchmark                    (value)   Mode  Cnt  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt    5  0.326 ± 0.010  ops/ns
...benchmarkReturnOrdinal          2  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnOrdinal          1  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnReference        3  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        2  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        1  thrpt    5  0.288 ± 0.002  ops/ns

На жаль, зараз вони лише трохи повільніші. До речі, це також говорить нам, що тест пов'язаний з інфраструктурою. Гаразд, ми можемо подивитися, що насправді відбувається?

Якщо ви побудуєте тести і оглянете, що саме викликає ваші @Benchmarkметоди, ви побачите щось на зразок:

public void benchmarkReturnOrdinal_thrpt_jmhStub(InfraControl control, RawResults result, ReturnEnumObjectVersusPrimitiveBenchmark_jmh l_returnenumobjectversusprimitivebenchmark0_0, Blackhole_jmh l_blackhole1_1) throws Throwable {
    long operations = 0;
    long realTime = 0;
    result.startTime = System.nanoTime();
    do {
        l_blackhole1_1.consume(l_longname.benchmarkReturnOrdinal());
        operations++;
    } while(!control.isDone);
    result.stopTime = System.nanoTime();
    result.realTime = realTime;
    result.measuredOps = operations;
}

У цьому l_blackhole1_1є consumeметод, який "споживає" значення (див. BlackholeОбґрунтування). Blackhole.consumeмає перевантаження для посилань та примітивів , і цього достатньо, щоб виправдати різницю в продуктивності.

Існує обгрунтування, чому ці методи виглядають по-іншому: вони намагаються бути якомога швидшими для своїх типів аргументів. Вони не обов'язково демонструють однакові експлуатаційні характеристики, хоча ми намагаємось їх поєднати, отже, більш симетричний результат із новішими JMH. Тепер ви навіть можете зайти на сторінку, -prof perfasmщоб побачити згенерований код для ваших тестів і зрозуміти, чому продуктивність відрізняється, але це не в цьому.

Якщо ви дійсно хочете зрозуміти, як повернення примітиву та / або посилання відрізняється від продуктивності, вам потрібно буде ввести велику страшну сіру зону нюансованого тестування продуктивності. Наприклад, щось на зразок цього тесту:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(5)
public class PrimVsRef {

    @Benchmark
    public void prim() {
        doPrim();
    }

    @Benchmark
    public void ref() {
        doRef();
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private int doPrim() {
        return 42;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private Object doRef() {
        return this;
    }

}

... що дає однаковий результат для примітивів та посилань:

Benchmark       Mode  Cnt  Score   Error  Units
PrimVsRef.prim  avgt   25  2.637 ± 0.017  ns/op
PrimVsRef.ref   avgt   25  2.634 ± 0.005  ns/op

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

prim:

                  [Verified Entry Point]
 12.69%    1.81%    0x00007f5724aec100: mov    %eax,-0x14000(%rsp)
  0.90%    0.74%    0x00007f5724aec107: push   %rbp
  0.01%    0.01%    0x00007f5724aec108: sub    $0x30,%rsp         
 12.23%   16.00%    0x00007f5724aec10c: mov    $0x2a,%eax   ; load "42"
  0.95%    0.97%    0x00007f5724aec111: add    $0x30,%rsp
           0.02%    0x00007f5724aec115: pop    %rbp
 37.94%   54.70%    0x00007f5724aec116: test   %eax,0x10d1aee4(%rip)        
  0.04%    0.02%    0x00007f5724aec11c: retq  

посилання:

                  [Verified Entry Point]
 13.52%    1.45%    0x00007f1887e66700: mov    %eax,-0x14000(%rsp)
  0.60%    0.37%    0x00007f1887e66707: push   %rbp
           0.02%    0x00007f1887e66708: sub    $0x30,%rsp         
 13.63%   16.91%    0x00007f1887e6670c: mov    %rsi,%rax     ; load "this"
  0.50%    0.49%    0x00007f1887e6670f: add    $0x30,%rsp
  0.01%             0x00007f1887e66713: pop    %rbp
 39.18%   57.65%    0x00007f1887e66714: test   %eax,0xe3e78e6(%rip)
  0.02%             0x00007f1887e6671a: retq   

[сарказм] Подивіться, як це просто! [/ сарказм]

Закономірність така: чим простіше питання, тим більше вам доведеться відпрацювати, щоб дати правдоподібну та надійну відповідь.


2
Грамотна відповідь. То що ви рекомендуєте для виконання дійсного тесту? Я пропоную вбудувати цикл і неоптимізованого споживача в сам метод тестування. Таким чином продуктивність тестової основи зникає в шумі.
usr

6
Е-е, дозвольте мені спочатку відповісти загально. Я рекомендую почати з чогось, подивитися, що це нарешті робить, і виправити це, поки не зробить те, що ви хочете.
Олексій Шипілєв

2
Тепер більш конкретна відповідь. JMH генерує цикл сам, і він викликає "неоптимізований" Blackhole.consumeдля користувача. Ви можете , ймовірно , тягнути його в @Benchmarkметод, і використовувати не-inlineable метод тонути результати в, але це працює тільки , поки ви зустрічаєтеся розумнішими оптимізатор ... У той час як ми можемо переглянути то , що JMH робить під прикриттям , коли це станеться, писаки користувач буде неминуче відставати. Тоді користувачі, які більше довіряють своєму святому коду, а не святим системам порівняльних показників, згорять у пеклі!
Олексій Шипілєв

3
@usr За допомогою точки доступу ви можете використовувати -Xint, і більшість ваших проблем з тестуванням зникнуть. (Будь ласка, не) "Енергетичні компанії мене за це ненавидять". (c)
Олексій Шипілєв

4
Чудова відповідь. Особисто я б точно подивився на збірку для даних тестів, тому що подібні мікро-мікро-тести жахливо легко помилитися навіть з jmh, як це питання добре показує. Якщо вас турбує продуктивність, тоді розуміння складання - це неймовірно корисний інструмент. @usr Якщо у мене коли-небудь вистачить часу, я насправді хочу реалізувати щось на зразок jmh для .NET (я зробив близько третини), і це так просто, дуже, набагато простіше, ніж у порівнянні з Java / HotSpot;)
Voo

6

Щоб очистити помилкове уявлення про посилання та пам’ять, в які потрапили деякі (@Mzf), давайте зануримось у Специфікацію віртуальної машини Java. Але перед тим, як їхати туди, слід пояснити одне - об’єкт ніколи не можна отримати з пам'яті, лише його поля можуть . Насправді немає такого коду операцій, який би виконував таку велику операцію.

Цей документ визначає посилання як тип стека (таким чином, що це може бути результатом або аргументом інструкцій, що виконують операції над стеком) 1-ї категорії - категорії типів, що приймають одне слово стека (32 біти). Див. Таблицю 2.3 Список типів стеку Java.

Крім того, якщо виклик методу завершується нормально відповідно до специфікації, значення, яке вискакується з верхньої частини стека, висувається на стек засобу, що викликає метод (розділ 2.6.4).

Ваше питання полягає в тому, що спричиняє різницю у часі виконання. Відповіді на передмову глави 2:

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

Іншими словами, оскільки в документі з логічних причин не зазначено такого поняття, як покарання за використання посилання (це врешті-решт просто стекове слово як intабо floatє), вам залишається шукати у вихідному коді вашої реалізації або ніколи з'ясування взагалі.

По суті, нам не слід насправді завжди звинувачувати в реалізації, є деякі підказки, які ви можете взяти під час пошуку своїх відповідей. Java визначає окремі інструкції щодо маніпулювання цифрами та посиланнями. Інструкції з маніпулювання посиланнями починаються з a(наприклад astore, aloadабо areturn) і є єдиними інструкціями, дозволеними для роботи з посиланнями. Зокрема, вам може бути цікаво поглянути на areturnреалізацію.


1
Чи обов'язково посилання займають 32 біти?

2
Не одразу очевидно, як це відповідає на питання.
Олег Естехін

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