Оголошення декількох масивів з 64 елементами в 1000 разів швидше, ніж оголошення масиву з 65 елементів


91

Нещодавно я помітив, що оголошення масиву, що містить 64 елементи, відбувається набагато швидше (> 1000 разів), ніж оголошення масиву того ж типу з 65 елементами.

Ось код, який я використовував для перевірки цього:

public class Tests{
    public static void main(String args[]){
        double start = System.nanoTime();
        int job = 100000000;//100 million
        for(int i = 0; i < job; i++){
            double[] test = new double[64];
        }
        double end = System.nanoTime();
        System.out.println("Total runtime = " + (end-start)/1000000 + " ms");
    }
}

Це працює приблизно за 6 мс, якщо я заміню new double[64]на new double[65]це, це займає приблизно 7 секунд. Ця проблема стає експоненціально більш серйозною, якщо робота поширюється на все більше і більше потоків, звідки і походить моя проблема.

Ця проблема також виникає з різними типами масивів, такими як int[65]або String[65]. Ця проблема не виникає з великими рядками:, String test = "many characters";але починає виникати, коли це змінюється наString test = i + "";

Мені було цікаво, чому це так, і чи можна обійти цю проблему.


3
Поза приміткою: System.nanoTime()слід віддавати перевагу порівняно System.currentTimeMillis()з бенчмаркінгом.
rocketboy

4
Мені просто цікаво? Ви під Linux? Чи змінюється поведінка з ОС?
bsd

9
Як, боже, це питання отримало проти?
Rohit Jain

2
FWIW, я бачу подібні розбіжності в продуктивності, якщо я запускаю цей код byteзамість double.
Oliver Charlesworth

3
@ThomasJungblut: То що пояснює невідповідність експерименту OP?
Oliver Charlesworth

Відповіді:


88

Ви спостерігаєте поведінку, спричинену оптимізацією, виконаною компілятором JIT вашої Java VM. Ця поведінка відтворюється, що запускається зі скалярними масивами до 64 елементів, і не запускається з масивами більше 64.

Перш ніж вдаватися до деталей, давайте детальніше розглянемо тіло циклу:

double[] test = new double[64];

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

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

  • Розігрійте компілятор JIT (і оптимізатор), виконавши еталон кілька разів.
  • Використовуйте результат кожного виразу та друкуйте його в кінці еталону.

Тепер давайте вдамося в подробиці. Не дивно, що існує оптимізація, яка запускається для скалярних масивів, розмір яких не перевищує 64 елементів. Оптимізація є частиною аналізу Escape . Він поміщає маленькі предмети та малі масиви в стек, замість того, щоб розподіляти їх по купі - або навіть краще оптимізувати їх повністю. Деякі відомості про це можна знайти в наступній статті Брайана Гетца, написаній у 2005 році:

Оптимізацію можна вимкнути за допомогою опції командного рядка -XX:-DoEscapeAnalysis. Магічне значення 64 для скалярних масивів також можна змінити в командному рядку. Якщо виконати програму наступним чином, різниці між масивами з 64 та 65 елементами не буде:

java -XX:EliminateAllocationArraySizeLimit=65 Tests

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


9
Але чому оптимізатор виявляє, що масив розміром 64 є знімним, але не 65
ug_

10
@nosid: Хоча код ОП може бути нереалістичним, він явно викликає цікаву / несподівану поведінку в JVM, що може мати наслідки в інших ситуаціях. Я вважаю, що правильно запитати, чому це відбувається.
Oliver Charlesworth

1
@ThomasJungblut Я не думаю, що цикл видаляється. Ви можете додати "int total" поза циклом і додати "total + = test [0];" до прикладу вище. Потім, надрукувавши результат, ви побачите, що загальна сума = 100 мільйонів, і вона зупиняється менше ніж за секунду.
Сіпко

1
Заміна on stack - це про заміну інтерпретованого коду компільованим на льоту, замість заміни розподілу купи у розподілі стека. EliminateAllocationArraySizeLimit - це граничний розмір масивів, які вважаються скалярно замінюваними в аналізі екранування. Отже, головне, що ефект обумовлений оптимізацією компілятора, є правильним, але це не через розподіл стека, а через те, що фаза аналізу екранування не помічає розподілу, не потрібно.
kiheru

2
@Sipko: Ви пишете, що програма не масштабується за кількістю потоків. Це свідчить про те, що проблема не пов’язана з мікрооптимізацією, про яку ви запитуєте. Я рекомендую дивитися на загальну картину, а не на дрібні частини.
nosid

2

Існує будь-яка кількість способів, за якими може бути різниця, залежно від розміру об’єкта.

Як зазначив nosid, JITC може (найімовірніше) виділяти невеликі "локальні" об'єкти в стеку, а граничний розмір для "малих" масивів може становити 64 елементи.

Розподіл по стеку значно швидший, ніж розподіл по купі, і, більш конкретно, стеку не потрібно збирати сміття, тому накладні витрати на GC значно зменшуються. (І для цього тестового випадку накладні витрати на GC, ймовірно, становлять 80-90% від загального часу виконання.)

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

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


Пізно до цієї теми. Чому розподіл по стеку відбувається швидше, ніж розподіл по купі? Згідно з кількома статтями, розподіл по купі займає ~ 12 інструкцій. Тут не так багато можливостей для вдосконалення.
Vortex

@Vortex - Виділення в стек займає 1-2 інструкції. Але це виділити весь кадр стека. Кадр стека повинен бути виділений у будь-якому випадку, щоб мати область збереження регістру для процедури, тому будь-які інші змінні, виділені одночасно, є "вільними". І як я вже казав, стек не вимагає GC. Накладні витрати на GC для елемента купи значно перевищують вартість операції розподілу купи.
Hot Licks
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.