Статьи

Вызовите оптимизацию интерфейса


Меня часто спрашивают о различиях в производительности между Java, C и C ++, и что лучше. Как и в большинстве вещей в жизни, нет черно-белого ответа. Часто говорят о том, что языки на основе управляемой среды выполнения предлагают меньшую производительность, чем их статически скомпилированные соотечественники. Однако есть несколько приемов, доступных для управляемых сред выполнения, которые могут предоставить возможности оптимизации, недоступные для статически оптимизированных языков.

Одной из таких оптимизаций, доступных для среды выполнения, является динамическое подключение метода к сайту вызова. Многие скажут, что встраивание является основной оптимизацией динамических языков. Это подход, при котором можно избежать затрат на вызов функции / метода и включить дальнейшую оптимизацию. Инлайн можно легко сделать во время компиляции или запуска
статические или
приватные методы класса, потому что они не могут быть переопределены. Это также может быть сделано Hotspot во время выполнения, что намного интереснее. В байт-коде среда выполнения увидит
коды
операций invokestatic и
invokespecial
для
статических и
приватных методов соответственно. Методы, связанные с поздним связыванием, такие как реализации интерфейса и переопределение методов, отображаются как
коды операций
invokeinterface
и
invokevirtual соответственно.

Во время компиляции невозможно определить, сколько реализаций будет для интерфейса или сколько классов переопределит базовый метод. Компилятор может иметь некоторую осведомленность, но как вы справляетесь с динамически загружаемыми классами через
Class.forName («x»). NewInstance () ? Время выполнения Hotspot очень умное. Он может отслеживать все классы по мере их загрузки и применять соответствующие оптимизации для обеспечения максимальной производительности нашего кода. Одним из таких подходов является динамическое встраивание в сайт вызова, который мы рассмотрим.

Код

public interface Operation
{
    int map(int value);
}

public class IncOperation implements Operation
{
    public int map(final int value)
    {
        return value + 1;
    }
}

public class DecOperation implements Operation
{
    public int map(final int value)
    {
        return value - 1;
    }
}

public class StepIncOperation implements Operation
{
    public int map(final int value)
    {
        return value + 7;
    }
}

public class StepDecOperation implements Operation
{
    public int map(final int value)
    {
        return value - 3;
    }
}

public final class OperationPerfTest
{
    private static final int ITERATIONS = 50 * 1000 * 1000;

    public static void main(final String[] args)
        throws Exception
    {
        final Operation[] operations = new Operation[4];
        int index = 0;
        operations[index++] = new StepIncOperation();
        operations[index++] = new StepDecOperation();
        operations[index++] = new IncOperation();
        operations[index++] = new DecOperation();

        int value = 777;
        for (int i = 0; i < 3; i++)
        {
            System.out.println("*** Run each method in turn: loop " + i);

            for (final Operation operation : operations)
            {
                System.out.println(operation.getClass().getName());

                value = runTests(operation, value);
            }
        }

        System.out.println("value = " + value);
    }

    private static int runTests(final Operation operation, int value)
    {
        for (int i = 0; i < 10; i++)
        {
            final long start = System.nanoTime();

            value += opRun(operation, value);

            final long duration = System.nanoTime() - start;
            final long opsPerSec = 
                (ITERATIONS * 1000L * 1000L * 1000L) / duration;
            System.out.printf("    %,d ops/sec\n", opsPerSec);
        }


        return value;
    }

    private static int opRun(final Operation operation, int value)
    {
        for (int i = 0; i < ITERATIONS; i++)
        {
            value += operation.map(value);
        }

        return value;
    }
}

Полученные результаты

Следующие результаты приведены для работы на ядре Linux 3.3.2 с серверной JVM Oracle 1.7.0_02 на процессоре Intel Sandy Bridge 2,4 ГГц.

*** Run each method in turn: loop 0
StepIncOperation
    2,256,816,714 ops/sec
    2,245,800,936 ops/sec
    3,161,643,847 ops/sec
    3,100,375,269 ops/sec
    3,144,364,173 ops/sec
    3,091,009,138 ops/sec
    3,089,241,641 ops/sec
    3,153,922,056 ops/sec
    3,147,331,497 ops/sec
    3,076,211,099 ops/sec
StepDecOperation
    623,131,120 ops/sec
    659,686,236 ops/sec
    1,029,231,089 ops/sec
    1,021,060,933 ops/sec
    999,287,607 ops/sec
    1,015,432,172 ops/sec
    1,023,581,307 ops/sec
    1,019,266,750 ops/sec
    1,022,726,580 ops/sec
    1,004,237,016 ops/sec
IncOperation
    301,419,319 ops/sec
    304,712,250 ops/sec
    307,269,912 ops/sec
    308,519,923 ops/sec
    307,372,436 ops/sec
    306,230,247 ops/sec
    307,964,022 ops/sec
    306,243,292 ops/sec
    308,689,942 ops/sec
    365,152,716 ops/sec
DecOperation
    236,804,700 ops/sec
    237,912,786 ops/sec
    238,672,489 ops/sec
    278,745,901 ops/sec
    278,169,934 ops/sec
    277,979,158 ops/sec
    276,620,509 ops/sec
    278,349,766 ops/sec
    276,159,225 ops/sec
    278,578,373 ops/sec
*** Run each method in turn: loop 1
StepIncOperation
    276,054,944 ops/sec
    276,683,805 ops/sec
    276,551,970 ops/sec
    279,861,144 ops/sec
    275,543,192 ops/sec
    278,451,092 ops/sec
    275,399,262 ops/sec
    277,340,411 ops/sec
    274,529,616 ops/sec
    277,091,930 ops/sec
StepDecOperation
    279,729,066 ops/sec
    279,812,269 ops/sec
    276,478,587 ops/sec
    277,660,649 ops/sec
    276,844,441 ops/sec
    278,684,313 ops/sec
    277,791,665 ops/sec
    277,617,484 ops/sec
    278,575,241 ops/sec
    278,228,274 ops/sec
IncOperation
    277,724,770 ops/sec
    278,234,042 ops/sec
    276,798,434 ops/sec
    277,926,962 ops/sec
    277,786,824 ops/sec
    278,739,590 ops/sec
    275,286,293 ops/sec
    279,062,831 ops/sec
    276,672,019 ops/sec
    277,248,956 ops/sec
DecOperation
    277,303,150 ops/sec
    277,746,139 ops/sec
    276,245,511 ops/sec
    278,559,202 ops/sec
    274,683,406 ops/sec
    279,280,730 ops/sec
    276,174,620 ops/sec
    276,374,159 ops/sec
    275,943,446 ops/sec
    277,765,688 ops/sec
*** Run each method in turn: loop 2
StepIncOperation
    278,405,907 ops/sec
    278,713,953 ops/sec
    276,841,096 ops/sec
    277,891,660 ops/sec
    275,716,314 ops/sec
    277,474,242 ops/sec
    277,715,270 ops/sec
    277,857,014 ops/sec
    275,956,486 ops/sec
    277,675,378 ops/sec
StepDecOperation
    277,273,039 ops/sec
    278,101,972 ops/sec
    275,694,572 ops/sec
    276,312,449 ops/sec
    275,964,418 ops/sec
    278,423,621 ops/sec
    276,498,569 ops/sec
    276,593,475 ops/sec
    276,238,451 ops/sec
    277,057,568 ops/sec
IncOperation
    275,700,451 ops/sec
    277,463,507 ops/sec
    275,886,477 ops/sec
    277,546,096 ops/sec
    275,019,816 ops/sec
    278,242,287 ops/sec
    277,317,964 ops/sec
    277,252,014 ops/sec
    276,893,038 ops/sec
    277,601,325 ops/sec
DecOperation
    275,580,894 ops/sec
    280,146,646 ops/sec
    276,901,134 ops/sec
    276,672,567 ops/sec
    276,879,422 ops/sec
    278,674,196 ops/sec
    275,606,174 ops/sec
    278,132,534 ops/sec
    275,858,358 ops/sec
    279,444,112 ops/sec

Что здесь происходит?

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

Если мы запустим JVM с
-XX: + PrintCompilation, мы увидим, что Hotspot выбирает компиляцию методов, а затем де-оптимизирует существующие оптимизации по мере загрузки новых реализаций.

 52    1             java.lang.String::hashCode (67 bytes)
     54    2             StepIncOperation::map (5 bytes)
     55    1 %           OperationPerfTest::opRun @ 2 (26 bytes)
     76    3             OperationPerfTest::opRun (26 bytes)
    223    3             OperationPerfTest::opRun (26 bytes)   made not entrant
    223    1 %           OperationPerfTest::opRun @ -2 (26 bytes)   made not entrant
    224    2 %           OperationPerfTest::opRun @ 2 (26 bytes)
    224    4             StepDecOperation::map (4 bytes)
    306    5             OperationPerfTest::opRun (26 bytes)
    772    2 %           OperationPerfTest::opRun @ -2 (26 bytes)   made not entrant
    772    3 %           OperationPerfTest::opRun @ 2 (26 bytes)
    773    6             IncOperation::map (4 bytes)
    930    5             OperationPerfTest::opRun (26 bytes)   made not entrant
   1995    7             OperationPerfTest::opRun (26 bytes)
   2293    8             DecOperation::map (4 bytes)
  11339    9             java.lang.String::indexOf (87 bytes)
  15017   10             java.lang.String::charAt (33 bytes)


Вывод выше показывает решения, принятые Hotspot при компиляции кода. Когда третий столбец содержит символ «%», он выполняет
OSR (замену стека) метода. За этим 4 раза следует метод «не входящий», поскольку он де-оптимизируется, когда Hotspot обнаруживает новые реализации. 3 раза метод делается не входящим для вновь обнаруженных классов и один раз для удаления версии OSR, которая будет заменена обычной версией JIT, не относящейся к OSR, когда окончательная реализация установлена. Еще больше деталей можно увидеть, заменив 
-XX: + PrintCompilation  на

XX: + UnlockDiagnosticVMOptions -XX: + LogCompilation .

В случае одного мономорфного варианта реализации Hotspot может просто встроить метод и поместить ловушку в код для запуска, если будут загружены будущие реализации. Это дает производительность, очень похожую на отсутствие служебных вызовов. Для второй биморфной реализации Hotspot может встроить оба метода и выбрать реализацию на основе условия ветвления. Помимо этого, все становится сложнее, и таблицы переходов требуются для разрешения метода во время выполнения, что делает код полиморфным или мегаморфным. Сгенерированный код сборки можно просмотреть с помощью
XX: + UnlockDiagnosticVMOptions -XX: CompileCommand = print, OperationPerfTest.doRun Параметры JVM для Java 7. В выходных данных показаны этапы компиляции, при которых не только оптимизируется метод inlining inline, но и Hotspot больше не выполняет развертывание цикла для этого метода.

Выводы

Мы можем видеть, что если интерфейсный метод имеет только одну или две реализации, то Hotspot может динамически встроить метод, избегая накладных расходов на вызов функции. Это было бы возможно только с помощью
профильной оптимизации для такого языка, как C или C ++. Мы также видим, что вызовы методов относительно дешевы в современной JVM, порядка 12 циклов, даже если мы не можем их избежать. Следует отметить, что стоимость вызовов методов увеличивается на несколько циклов для каждого переданного дополнительного аргумента.

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