Статьи

Абстракция замедляет вас

Обновить

Поскольку было несколько комментариев о моем микробенчмарке, я могу дать общий ответ.

Спасибо за указание на недостатки в моей тестовой реализации. На этот раз я использовал фрагмент из Maciej Gorączka, чтобы проиллюстрировать подводные камни оптимизации и в то же время показать реальное влияние интерфейсных вызовов. Конечно, его пришлось модифицировать, поскольку он также страдал от ловушек оптимизации.

Есть несколько вещей в области оптимизации вызовов методов, которые необходимо учитывать при измерении производительности. Некоторые из них были описаны в главе, посвященной методам повышения производительности внутренних компонентов HotSpot.,
С моим исходным тестом я не учел нижеприведенное.

Виртуальные (и интерфейсные) вызовы с односторонним профилем типа компилируются с оптимистической проверкой в ​​пользу исторически общего типа (или двух типов).

Таким образом, есть много правды в том, что микробенчмарки могут быть очень хитрыми.

В качестве примера того, как заблуждение об оптимизаторе может испортить ваши тесты, приведенный ниже код:

import java.math.BigDecimal;
import java.util.Random;

public class OptimizationTest
{
    private static final long ITERATIONS = 1000 * 1000 * 1000;
    private static final int RUNS = 1;
    private static final SomeInterface[] IFACES = new SomeInterface[] {new Impl1(), new Impl2()};
    private static final Impl1[] IMPLS = new Impl1[] {new Impl1(), new Impl1()};
    private static final Random RANDOM = new Random();
    private static final int CLASS_COUNT = 2;

    public static void main(String[] args)
    {
        performTestIface(2); //warm up

        final long implTime = performTestImpl(RUNS);
        final BigDecimal runs = new BigDecimal(RUNS);
        final BigDecimal implAvg = new BigDecimal(implTime).divide(runs);
        System.out.println("Invokevirtual: " + implAvg + " ms");

        final long ifaceTime = performTestIface(RUNS);
        final BigDecimal ifaceAvg = new BigDecimal(ifaceTime).divide(runs);
        System.out.println("Invokeinterface: " + ifaceAvg + " ms");
    }

    private static long performTestIface(final long runs)
    {
        final SomeInterface iface = IFACES[RANDOM.nextInt(CLASS_COUNT)];
        long ifaceTime = 0;

        for (int run = 0; run < runs; run++)
        {
            System.gc();
            long start = System.currentTimeMillis();
            for (int i = 0; i < ITERATIONS; i++)
            {
                iface.doSomething(i);
            }
            ifaceTime += System.currentTimeMillis() - start;
        }
        return ifaceTime;
    }

    private static long performTestImpl(final long runs)
    {
        final Impl1 impl = IMPLS[RANDOM.nextInt(CLASS_COUNT)];
        long implTime = 0;

        for (int run = 0; run < runs; run++)
        {
            System.gc();
            long start = System.currentTimeMillis();
            for (int i = 0; i < ITERATIONS; i++)
            {
                impl.doSomething(i);
            }
            implTime += System.currentTimeMillis() - start;
        }
        return implTime;
    }


    static interface SomeInterface
    {
        void doSomething(int i);
    }

    static class Impl1 implements SomeInterface
    {
        private int i;

        public void doSomething(final int i)
        {
            this.i = i;
        }
    }

    static class Impl2 implements SomeInterface
    {
        private int i;

        public void doSomething(final int i)
        {
            this.i = i;
        }
    }


}

Пожалуйста, не забудьте запустить его более 10 раз . Я надеюсь, что вы получите удовольствие от результатов.

Теперь объяснение.
При запуске с флагом -XX: + PrintCompilation тест либо выдает:

    142 9 test.InvokeInterfaceTest2 $ Impl1 :: DoSomething (6 байт)
    142 1% test.InvokeInterfaceTest2 :: performTestIface @ 36 (77 байтов)
   1701 2% test.InvokeInterfaceTest2 :: performTestImpl @ 36 (75 байт)
Invokevirtual: 745 мс
   2447 10 test.InvokeInterfaceTest2 :: executeTestIface (77 байт)
Invokeinterface: 722 мс

 или же:

      65 3 test.InvokeInterfaceTest2 $ Impl2 :: doSomething (6 байт)
     66 1% test.InvokeInterfaceTest2 :: executeTestIface @ 36 (77 байт)
   1523 4 test.InvokeInterfaceTest2 $ Impl1 :: doSomething (6 байт)
   1523 2% test.InvokeInterfaceTest2 : executeTestImpl @ 36 (75 байт)
Invokevirtual: 732 мс
   2255 5 test.InvokeInterfaceTest2 :: executeTestIface (77 байт)
   2262 1% выполнен не входящим test.InvokeInterfaceTest2 :: executeTestIface @ -2 (77 байт)
   2268 3% test.InvokeInterfaceTest2: : executeTestIface @ 36 (77 байт).
Интерфейс вызова: 1816 мс

Обратите внимание, что HotSpot жалуется на метод, который не является входящим, что означает, что ранее скомпилированный метод больше не действителен. Это происходит потому, что мы используем 2 разные реализации одного и того же интерфейса, как в более быстром примере, но этот временной класс, реализующий интерфейс, изменился, поэтому целевой метод пришлось деоптимизировать.

Это становится еще более понятным, когда мы посмотрим, как оптимизация была реализована компилятором. В скором времени мы получаем собственный код для executeTestIface:

126 B16: # N1 <- B2 Freq: 1.01326e-06
126 movl RSI, # -28 # int
12b movq RBP, [rsp + # 0] # разлив
12f movl [rsp + # 4],
вызов RAX # разлив 133, статическая оболочка для: uncommon_trap (reason = ‘range_check’ action = ‘make_not_entrant’)
        # OptimizationTest :: executeTestIface @ bci: 10 L [0] = RBP L [1] = _ L [2] = _ L [3] = _ L [4] = _ L [5] = _ L [6] = _ L [7] = _ L [8] = _ STK [0] = rsp + # 8 STK [1] = rsp + # 4
        # OopMap {[8] = NarrowOop off = 312}
138 int3 # ShouldNotReachHere

Код перекомпилируется после того, как некоторое количество последующих мономорфных вызовов достигнет PerMethodTrapLimit или PerBytecodeRecompilationCutoff. Таким образом, для более медленного запуска код перекомпилируется в:

14f B16: # N250 <- B9 Freq: 0,25578
14f movl RSI, # -58 # int
154 movq [rsp + # 16], RBX # разлив
159 movq [rsp + # 32], R14 # разлив
15e movq [rsp + # 40],
вызов R13 # spill 163, статическая оболочка для: uncommon_trap (reason = ‘bimorphic’ action = ‘Maybe_recompile’)
        # OptimizationTest :: executeTestIface @ bci: 49 L [0] = rsp + # 0 L [1] = _ L [2] = rsp + # 40 L [3] = rsp + # 16 L [4] = _ L [5] = rsp + # 24 L [6] = rsp + # 32 L [7] = _ L [ 8] = RBP STK [0] = rsp + # 40 STK [1] = RBP
        # OopMap {[40] = Oop off = 360}
168 int3 # ShouldNotReachHere
168
16d B17: # B3 B18 <- B2 Freq: 0,16983
16d decode_heap_oop_not_null RSI, R10
171 movq rdi, [RSI + (sizeof (oopDesc) + Klass :: second_supers_offset_in_bytes ())]
    movl rcx, [rdi + arrayOopDesc ​​:: length_offset_in_bytes ()] # длина для сканирования
    addq rdi, arrayOopDex :: base_offset_ip_OBJT (TIBT): для начала данных; набор NZ в случае подсчете равно нулю
    REPNE scasq # Scan * RDI ++ для матча с бараками в то время как cx— = 0!
    JNE, с мисс # Пропущенные: Флаги новозеландского
    MOVQ [RSI + (SizeOf (oopDesc) + Klass :: secondary_super_cache_offset_in_bytes () )], RAX # Hit: обновление кэша
    отсутствует:   
2a7 je B3 P = 0,999999 C = -1,000000

B18: # N250 <- B17 Freq: 1.6983e-07
2ad movl RSI, # -83 # int
2b2 movq [rsp + # 8], R13 # разлив
2b7 movq [rsp + # 16], RBX # разлив
2bc movq [rsp + # 32], R14 # spill
2c1 nop # 2 байта pad для циклов и вызовов
2c3 call, статическая оболочка для: uncommon_trap (reason = ‘unreached’ action = ‘reinterpret’)
        # OptimizationTest :: executeTestIface @ bci: 36 L [0 ] = rsp + # 0 L [1] = _ L [2] = rsp + # 8 L [3] = rsp + # 16 L [4] = _ L [5] = rsp + # 24 L [6] = rsp + # 32 L [7] = _ L [8] = RBP
        # OopMap {[8] = Oop off = 712}
2c8 int3 # ShouldNotReachHere

Обычно в высокопроизводительных приложениях интерфейсы, отражающие шаблоны, такие как наблюдатель или слушатель, вызываются в цикле с часто меняющимися реализациями. Вот почему я считаю, что эта проблема может иметь практические последствия.

Спасибо, господа за замечания. Я явно не смог предоставить безупречный тест, доказывающий мои аргументы с первого раза.
Надеюсь, вы найдете это солидным и интересным.

Конец обновления

 

Я думаю, что нам всем нравится писать хороший код; мы очень часто используем такие идеи, как композиция, разделение интересов и так далее. Прежде всего, мы совершенно правильно склонны вводить различные уровни абстракции с использованием интерфейсов и абстрактных классов. Этот подход идеально подходит для выдающегося количества сценариев, но может привести к снижению производительности, когда мы имеем дело с требованиями низкой задержки.

Каждый раз, когда мы вводим новый уровень абстракции, в то же время мы добавляем усложнение к процедуре, с помощью которой JVM разрешает целевой метод для вызова.
Методы в Java не вызываются напрямую из-за полиморфной природы языка. Например:

final Set set = getImpl(); // returns different implementations  
set.add(new Object());

будет иметь другие последствия, чем:

final HashSet set = getImpl(); // returns different implementations 
set.add(new Object());

Первый будет переведен на:

    ...
    NEW java/lang/Object
    DUP
    INVOKESPECIAL java/lang/Object.<init> ()V
    INVOKEINTERFACE java/util/Set.add (Ljava/lang/Object;)Z
    ...

Хотя последний будет выглядеть больше как:

    ...
    NEW java/lang/Object
    DUP
    INVOKESPECIAL java/lang/Object.<init> ()V
    INVOKEVIRTUAL java/util/HashSet.add (Ljava/lang/Object;)Z
    ...

Различие в поведении JVM для этих двух случаев может быть не столь очевидным даже после тщательного прочтения спецификации инструкций . Процедура поиска конкретного метода в таблице виртуальных методов практически идентична, но открытый символ семантики INVOKEINTERFACE делает некоторые оптимизации невозможными.

Давайте рассмотрим приведенные ниже примеры. В первом случае:

class Parent {
    public void method1() { }
    public void method2() { }
}

class Child extends Parent {
    public void method2() { } // overriden method
    public void method3() { }
}

Эта настройка приведет к созданию таблицы виртуальных методов, которая выглядит примерно так:

 родитель
 1 Parent.method1
 2 Parent.method2
ребенок
 1 Parent.method1
 2 Child.method2
 3 Child.method3

Здесь мы можем наблюдать, как таблица виртуальных методов для дочернего класса сохраняет порядок методов для родительской таблицы методов класса. Он только переопределяет ссылку / ссылку на переопределенные методы, таким образом, обеспечивая мономорфные вызовы и оптимизации, которые используют жесткую связь с целевыми методами и полностью исключают необходимость поиска таблицы методов при каждом вызове. Это, в свою очередь, предоставляет средства для встраивания методов, которые могут привести к хорошему повышению производительности.

Теперь давайте посмотрим на случай для интерфейсов:

interface SomeInterface {
    void someMethod();
}

class Parent {
    public void method1() { }
    public void method2() { }
}

class Child extends Parent implements SomeInterface {
    public void method2() { } // overridden method from Parent
    public void someMethod() { }
}

class OtherClass implements SomeInterface {
    public void method3() { }
    public void someMethod() { }
}

Таблица виртуальных методов для приведенного выше будет выглядеть так:

 родитель
 1 Parent.method1
 2 Parent.method2
 ребенок
 1 Parent.method1
 2 Child.method2
 3 SomeInterface.someMethod
 OtherClass
 1 OtherClass.method3
 2 SomeInterface.someMethod

Как мы видим, порядок индексов интерфейсного метода someMethod () не сохраняется. Для дочернего класса это будет метод # 3, но для OtherClass запись для этого метода будет найдена под # 2.

Следствием введения этих мегаморфных вызовов является дополнительная стоимость поиска в таблице виртуальных методов при каждом вызове метода. Чтобы проиллюстрировать влияние мегаморфных вызовов, мы можем запустить простой микробенчмарк:

Изменить: приведенный ниже код явно ошибочен (см. Комментарии). Используйте один из верхней части статьи.

import java.math.BigDecimal;

public class InvokeInterfaceTest
{
    private static final long ITERATIONS = 1000 * 1000 * 1000;
    private static final int RUNS = 10;
    private static long implTime;
    private static long ifaceTime;

    public static void main(String[] args)
    {
        performTest(2); //warm up
        ifaceTime = implTime = 0;
        performTest(RUNS);
        final BigDecimal implAvg = new BigDecimal(implTime).divide(new BigDecimal(RUNS));
        final BigDecimal ifaceAvg = new BigDecimal(ifaceTime).divide(new BigDecimal(RUNS));
        System.out.println("Invokevirtual: " + implAvg + " ms");
        System.out.println("Invokeinterface: " + ifaceAvg + " ms");
    }

    private static void performTest(final long runs)
    {
        final FooImpl impl = new FooImpl();
        final Foo iface = new FooImpl();

        for (int run = 0; run < runs; run++)
        {
            System.gc();
            long start = System.currentTimeMillis();
            for (int i = 0; i < ITERATIONS; i++)
            {
                impl.doSomething(i);
            }
            implTime += System.currentTimeMillis() - start;

            System.gc();
            start = System.currentTimeMillis();
            for (int i = 0; i < ITERATIONS; i++)
            {
                iface.doSomething(i);
            }
            ifaceTime += System.currentTimeMillis() - start;
        }
    }

    private static interface Foo
    {
        void doSomething(int i);
    }

    private static class FooImpl implements Foo
    {
        private int i;

        public void doSomething(final int i)
        {
            this.i = i; //avoid optimization
        }
    }
}

Который на моем ноутбуке с процессором Intel® Core ™ (i5-2410M) @ 2,30 ГГц с использованием Java 1.6.0_26-b03 для 64-битной Linux дает следующие результаты:

Invokevirtual: 735,4 мс
Invokeinterface: 1412,4 мс

Чтобы минимизировать влияние других процессов, запущенных в моей системе, я прикрепил тестовый поток к одному ядру, сделав его недоступным для других процессов и достигнув более стабильных результатов (как описано в моем предыдущем посте о потоках Java). 

Итак, если вам приходится иметь дело с огромным количеством вызовов, а задержка является решающим фактором, то вам, вероятно, придется рассмотреть возможность устранения мегаморфных вызовов за счет менее абстрактного, хитрого дизайна.