Java: цикл, який розгортається вручну, все ще швидший, ніж оригінальний цикл. Чому?


13

Розглянемо наступні два фрагменти коду на масиві довжиною 2:

boolean isOK(int i) {
    for (int j = 0; j < filters.length; ++j) {
        if (!filters[j].isOK(i)) {
            return false;
        }
    }
    return true;
}

і

boolean isOK(int i) {
     return filters[0].isOK(i) && filters[1].isOK(i);
}

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

Запитання: чому Java не оптимізувала мій перший фрагмент, використовуючи основну техніку розкручування циклу?
Зокрема, я хотів би зрозуміти наступне:

  1. Я можу легко створити код , який є оптимальним для випадків 2 фільтрів і по- , як і раніше може працювати в разі іншого кількості фільтрів (уявіть собі простий будівельник)
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters). Чи може JITC зробити те саме, а якщо ні, то чому?
  2. Чи може JITC виявити, що " filters.length == 2 " є найбільш частим випадком, і створити код, який є оптимальним для цього випадку після розминки? Це має бути майже таким же оптимальним, як і в ручному режимі.
  3. Чи може JITC виявити, що конкретний екземпляр використовується дуже часто, а потім створити код для цього конкретного екземпляра (для якого відомо, що кількість фільтрів завжди 2)?
    Оновлення: отримав відповідь, що JITC працює лише на рівні класу. Добре, зрозумів.

В ідеалі я хотів би отримати відповідь від когось із глибоким розумінням того, як працює JITC.

Деталі запуску еталону:

  • Випробувані на останніх версіях Java 8 OpenJDK та Oracle HotSpot, результати схожі
  • Використані прапори Java: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (отримали подібні результати і без фантазійних прапорів)
  • До речі, я отримую подібне співвідношення часу запуску, якщо просто запускаю його кілька мільярдів разів у циклі (не через JMH), тобто другий фрагмент завжди явно швидший

Типовий вихідний показник:

Бенчмарк (filterIndex) Режим Cnt Оцінка помилок Одиниці
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44,202 ± 0,224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38,347 ± 0,063 ns / op

(Перший рядок відповідає першому фрагменту, другий рядок - другому.

Повний код еталону:

public class LoopUnrollingBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkData {
        public Filter[] filters;
        @Param({"0", "1"})
        public int filterIndex;
        public int num;

        @Setup(Level.Invocation) //similar ratio with Level.TRIAL
        public void setUp() {
            filters = new Filter[]{new FilterChain1(), new FilterChain2()};
            num = new Random().nextInt();
        }
    }

    @Benchmark
    @Fork(warmups = 5, value = 20)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public int runBenchmark(BenchmarkData data) {
        Filter filter = data.filters[data.filterIndex];
        int sum = 0;
        int num = data.num;
        if (filter.isOK(num)) {
            ++sum;
        }
        if (filter.isOK(num + 1)) {
            ++sum;
        }
        if (filter.isOK(num - 1)) {
            ++sum;
        }
        if (filter.isOK(num * 2)) {
            ++sum;
        }
        if (filter.isOK(num * 3)) {
            ++sum;
        }
        if (filter.isOK(num * 5)) {
            ++sum;
        }
        return sum;
    }


    interface Filter {
        boolean isOK(int i);
    }

    static class Filter1 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 3 == 1;
        }
    }

    static class Filter2 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 7 == 3;
        }
    }

    static class FilterChain1 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            for (int j = 0; j < filters.length; ++j) {
                if (!filters[j].isOK(i)) {
                    return false;
                }
            }
            return true;
        }
    }

    static class FilterChain2 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            return filters[0].isOK(i) && filters[1].isOK(i);
        }
    }

    private static Filter[] createLeafFilters() {
        Filter[] filters = new Filter[2];
        filters[0] = new Filter1();
        filters[1] = new Filter2();
        return filters;
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

1
Компілятор не може гарантувати, що довжина масиву становить 2. Я не впевнений, що він би розкрутив його, навіть якщо міг би.
marstran

1
@Setup(Level.Invocation): не впевнений, що це допомагає (див. javadoc).
GPI

3
Оскільки ніде немає гарантії того, що масив завжди є довжиною 2, два способи не роблять те саме. Як тоді JIT міг дозволити собі змінити перше на друге?
Андреас

@Andreas Я пропоную вам відповісти на питання, але детально поясніть, чому JIT не може розкручуватися в цьому випадку, порівнявши з іншим подібним випадком, коли це може бути
Олександр

1
@Alexander JIT може бачити, що довжина масиву не може змінюватися після створення, оскільки поле є final, але JIT не бачить, що всі екземпляри класу отримають масив довжиною 2. Щоб побачити це, йому доведеться зануритися в createLeafFilters()метод і проаналізуйте код досить глибоко, щоб дізнатися, що масив завжди буде 2. Чому ви вважаєте, що оптимізатор JIT зануриться так глибоко у ваш код?
Андреас

Відповіді:


10

TL; DR Основна причина різниці в роботі тут не пов'язана з розкручуванням циклу. Це скоріше спекуляція типу та вбудовані кеші .

Розгортання стратегій

Насправді в термінології HotSpot такі петлі розглядаються як підраховані , і в деяких випадках JVM може їх розкрутити. Але не у вашому випадку.

HotSpot має дві стратегії розгортання циклу: 1) максимально розгорнути, тобто повністю вийняти цикл; або 2) склеїти кілька послідовних ітерацій разом.

Максимальне розгортання можна здійснити, лише якщо відома точна кількість ітерацій .

  if (!cl->has_exact_trip_count()) {
    // Trip count is not exact.
    return false;
  }

Однак у вашому випадку функція може повернутися рано після першої ітерації.

Часткова розгортання може бути застосована, але наступна умова перериває розгортання:

  // Don't unroll if the next round of unrolling would push us
  // over the expected trip count of the loop.  One is subtracted
  // from the expected trip count because the pre-loop normally
  // executes 1 iteration.
  if (UnrollLimitForProfileCheck > 0 &&
      cl->profile_trip_cnt() != COUNT_UNKNOWN &&
      future_unroll_ct        > UnrollLimitForProfileCheck &&
      (float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
    return false;
  }

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

Тип спекуляції

У вашій розкрученій версії є два різних invokeinterfaceбайт-коди. Ці сайти мають два профілі різних типів. Перший приймач - завжди Filter1, а другий - завжди Filter2. Отже, у вас в основному є два мономорфних сайти викликів, і HotSpot може ідеально вбудовувати обидва виклики - так званий "вбудований кеш", який у цьому випадку має 100% показника звернення.

За допомогою циклу існує лише один invokeinterfaceбайт-код, і збирається лише один тип профілю. HotSpot JVM бачить, що filters[j].isOK()викликається 86% разів із Filter1приймачем та 14% разів із Filter2приймачем. Це буде біморфний дзвінок. На щастя, HotSpot також може спекулятивно вбудовувати біморфні дзвінки. Він накреслює обидві цілі за допомогою умовної гілки. Однак у цьому випадку коефіцієнт показів становитиме не більше 86%, а продуктивність постраждає від відповідних непередбачуваних гілок на рівні архітектури.

Все буде ще гірше, якщо у вас є 3 і більше різних фільтра. У цьому випадку isOK()буде мегаморфний дзвінок, який HotSpot взагалі не може вбудувати. Отже, складений код буде містити справжній інтерфейсний виклик, який має більший вплив на продуктивність.

Більше про спекулятивне вкраплення у статті "Чорна магія" ("Java") .

Висновок

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

Для найкращого оптимізації віртуальних викликів вам потрібно буде вручну розділити цикл, в першу чергу для розбиття профілів типу. HotSpot поки що не може зробити це автоматично.


дякую за чудову відповідь. Просто для повноти: чи знаєте ви про будь-які методи JITC, які можуть створювати код для конкретного примірника?
Олександр

@Alexander HotSpot не оптимізує код для конкретного примірника. Він використовує статистику виконання, яка включає лічильники за байтовим кодом, профіль типу, ймовірності цільової гілки тощо. Якщо ви хочете оптимізувати код для конкретного випадку, створіть для нього окремий клас, вручну або з динамічним генеруванням байт-коду.
apangin

13

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

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

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

Це відобразило б ближчу механіку сучасного розгортання і, мабуть, ще ніде не було, як би виглядав розкручений машинний код:

if (! filters[0].isOK(i))
{
   return false;
} 
if(! filters[1].isOK(i))
{
   return false;
}
return true;

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

Якщо ви хочете отримати більшу впевненість, є аналізатор / візуалізатор jitwatch фактичних операцій Jit, включаючи машинний код (github) (слайди презентації) . Якщо в кінцевому підсумку є що подивитися, я б більше довіряв власним очам, ніж будь-яка думка щодо того, що може чи не може зробити ЗІТ взагалі, оскільки кожен випадок має свою специфіку. Тут вони стурбовані труднощами скласти загальні заяви щодо конкретних випадків, що стосуються JIT, та надають цікаві посилання.

Оскільки ваша мета - мінімальний час виконання, a && b && c ...форма, ймовірно, є найбільш ефективною, якщо ви не хочете залежати від надії на розмотування циклу, принаймні більш ефективною, ніж усе, що подано досі. Але ти не можеш мати це в загальному вигляді. З функціональним складом java.util.Function знову величезні накладні витрати (кожна функція - клас, кожен виклик - це віртуальний метод, який потребує відправлення). Можливо, у такому сценарії може бути доцільним підривати рівень мови та генерувати власний байт-код під час виконання. З іншого боку, &&логіка також вимагає розгалуження на рівні байтового коду і може бути еквівалентною if / return (що також не можна генерувати без накладних витрат).


лише невеликий адендум: підрахований цикл у світі JVM - це будь-який цикл, який "працює" над int i = ....; i < ...; ++iбудь-яким іншим циклом.
Євген
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.