Как можно ускорить этот Java-код?

Я пытаюсь оценить, насколько быстро Java может выполнить простую задачу: прочитать огромный файл в память, а затем выполнить какие-то бессмысленные вычисления с данными. Учитываются все типы оптимизации. Будь то переписывание кода по-другому или использование другой JVM, обман JIT..

Входной файл представляет собой список длиной 500 миллионов пар 32-битных целых чисел, разделенных запятой. Так:

44439,5023
33140,22257
...

Этот файл занимает 5,5 ГБ на моем компьютере. Программа не может использовать более 8 ГБ оперативной памяти и может использовать только один поток.

package speedracer;

import java.io.FileInputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class Main
{
    public static void main(String[] args)
    {
        int[] list = new int[1000000000];

        long start1 = System.nanoTime();
        parse(list);
        long end1 = System.nanoTime();

        System.out.println("Parsing took: " + (end1 - start1) / 1000000000.0);

        int rs = 0;
        long start2 = System.nanoTime();

        for (int k = 0; k < list.length; k++) {
            rs = calc(list[k++], list[k++], list[k++], list[k]);
        }

        long end2 = System.nanoTime();

        System.out.println(rs);
        System.out.println("Calculations took: " + (end2 - start2) / 1000000000.0);
    }

    public static int calc(final int a1, final int a2, final int b1, final int b2)
    {
        int c1 = (a1 + a2) ^ a2;
        int c2 = (b1 - b2) << 4;

        for (int z = 0; z < 100; z++) {
            c1 ^= z + c2;
        }

        return c1;
    }

    public static void parse(int[] list)
    {
        FileChannel fc = null;
        int i = 0;

        MappedByteBuffer byteBuffer;

        try {
            fc = new FileInputStream("in.txt").getChannel();

            long size = fc.size();
            long allocated = 0;
            long allocate = 0;

            while (size > allocated) {

               if ((size - allocated) > Integer.MAX_VALUE) {
                   allocate = Integer.MAX_VALUE;
               } else {
                   allocate = size - allocated;
               }

               byteBuffer = fc.map(FileChannel.MapMode.READ_ONLY, allocated, allocate);
               byteBuffer.clear();

               allocated += allocate;

               int number = 0;

               while (byteBuffer.hasRemaining()) {
                   char val = (char) byteBuffer.get();
                   if (val == '\n' || val == ',') {
                        list[i] = number;

                        number = 0;
                        i++;
                   } else {
                       number = number * 10 + (val - '0');
                   }
                }
            }

            fc.close();

        } catch (Exception e) {
            System.err.println("Parsing error: " + e);
        }
    }
}

Я перепробовал все, что мог придумать. Пробовал разные читалки, пробовал openjdk6, sunjdk6, sunjdk7. Пробовал разные читалки. Пришлось сделать какой-то уродливый синтаксический анализ, поскольку MappedByteBuffer не может одновременно отображать более 2 ГБ памяти. Я бегу:

   Linux AS292 2.6.38-11-generic #48-Ubuntu SMP 
   Fri Jul 29 19:02:55 UTC 2011 
   x86_64 GNU/Linux. Ubuntu 11.04. 
   CPU: is Intel(R) Core(TM) i5-2410M CPU @ 2.30GHz.

В настоящее время мои результаты для разбора: 26,50 с, расчеты: 11,27 с. Я конкурирую с аналогичным тестом C++, который выполняет ввод-вывод примерно за то же время, но вычисления занимают всего 4,5 секунды. Моя главная цель - сократить время расчета любыми возможными способами. Есть идеи?

Обновление: кажется, что основное улучшение скорости может быть связано с тем, что называется Автоматическая векторизация. Мне удалось найти некоторые намеки на то, что текущий JIT Sun выполняет только «некоторую векторизацию», однако я не могу это подтвердить. Было бы здорово найти какую-нибудь JVM или JIT с лучшей поддержкой оптимизации автовекторизации.


person Zilvinas    schedule 16.09.2011    source источник
comment
Запускалось ли приложение C++ на той же машине, что и ваше приложение Java? Потому что, если бы это было на другой машине, это могло бы легко означать другие характеристики производительности.   -  person Drizzt321    schedule 17.09.2011
comment
Вы получите исключение arrayoutofbounds с параметрами calc. Вы перераспределили список. Кроме того, просто удалите весь метод calc. Он ничего не делает с результатом или исходными данными и не сохраняет результат каким-либо образом.   -  person monksy    schedule 17.09.2011
comment
вы уже пробовали с ключом -server? Серверная виртуальная машина должна быть немного быстрее, чем клиентская по умолчанию. См. stackoverflow.com /вопросы/198577/   -  person fvu    schedule 17.09.2011
comment
@Drizt321: Да, приложение C++ работало на той же машине.   -  person Zilvinas    schedule 17.09.2011
comment
@monksy: забыл упомянуть, что я запускаю программу с -Xmx6048m. Метод calc является частью задачи, чтобы увидеть, насколько быстро Java может выполнять эти операции.   -  person Zilvinas    schedule 17.09.2011
comment
Убедитесь, что вы используете 64-битный сервер, а не 32-битный клиент, и что у вас установлена ​​Java 7 (она немного быстрее, чем Java 6).   -  person Luigi Plinge    schedule 17.09.2011
comment
@fvu: Пробовал раньше. Я думаю, это автоматически сервер? Не имеет значения, если я передам это явно.   -  person Zilvinas    schedule 17.09.2011
comment
Одна вещь, которая может сработать, — объявить методы окончательными. Это позволяет избежать некоторых накладных расходов на идентификацию типа во время выполнения, хотя я не уверен, как это влияет на статические методы.   -  person Jems    schedule 17.09.2011
comment
Также вы должны следить за использованием вашего процессора. Если это всего около 50%, вы, вероятно, можете повысить производительность, выполнив половину вычислений в другом потоке.   -  person Luigi Plinge    schedule 17.09.2011
comment
@Luigi: я запускаю zilvinas@AS292:~$ java -version java версии 1.7.0 Java(TM) SE Runtime Environment (сборка 1.7.0-b147) Java HotSpot(TM) 64-битный сервер VM (сборка 21.0- b17, смешанный режим). Нет. Он должен быть на одном ядре ;) Должен упомянуть об этом в ограничениях.   -  person Zilvinas    schedule 17.09.2011
comment
@fvu: в 64-битных системах -сервер по умолчанию.   -  person Zilvinas    schedule 17.09.2011
comment
@Джемс: Пробовал. Не имеет значения. Netbeans предлагает удалить последний флаг из методов, объявленных статическими.   -  person Zilvinas    schedule 17.09.2011
comment
Вам действительно нужен текстовый файл? Вы не можете сохранить int как необработанные типы? Ваш файл будет намного меньше, и он может работать быстрее. Могут быть некоторые проблемы с прямым/обратным порядком байтов, если вы работаете на разных платформах.   -  person toto2    schedule 17.09.2011
comment
@ toto2: это часть ограничений теста. Входной файл должен оставаться таким, какой он есть.   -  person Zilvinas    schedule 17.09.2011
comment
Есть идеи, как была скомпилирована версия C++? Какой компилятор? Версия? Уровень оптимизации? Расширенные наборы инструкций? Мне интересно, возможно ли вообще превзойти версию C++   -  person Mysticial    schedule 17.09.2011
comment
Я спрашиваю об этом, потому что это бессмысленное вычисление может быть сверхоптимизировано до чего-то чрезвычайно эффективного... Возможно, компилятор C++ может это сделать, но не компилятор Java или JIT.   -  person Mysticial    schedule 17.09.2011
comment
@Mystical: он был скомпилирован с использованием g++ -O3 -Wall -c -fmessage-length=0 -MMD -MP -MF"main.d" -MT"main.d" -o "main.o" "../main.cpp" g++ -o "perf" ./main.o -lboost_program_options версии компилятора g++ (Ubuntu/Linaro 4.5.2-8ubuntu4) 4.5.2. Насколько мне известно, SSE2 не использовался.   -  person Zilvinas    schedule 17.09.2011
comment
Я собираюсь добавить ответ через мгновение. Я включу свою оптимизированную версию в этот цикл.   -  person Mysticial    schedule 17.09.2011


Ответы (7)


Прежде всего, -O3 позволяет:

-finline-functions
-ftree-vectorize

среди прочих...

Так что похоже, что это действительно может быть векторизация.

РЕДАКТИРОВАТЬ: это было подтверждено. (см. комментарии) Версия C++ действительно векторизуется компилятором. С отключенной векторизацией версия C++ на самом деле работает немного медленнее, чем версия Java.

Предполагая, что JIT не векторизует цикл, для версии Java может быть трудно/невозможно соответствовать скорости версии C++.


Теперь, если бы я был умным компилятором C/C++, вот как бы я организовал этот цикл (на x64):

int c1 = (a1 + a2) ^ a2;
int c2 = (b1 - b2) << 4;

int tmp0 = c1;
int tmp1 = 0;
int tmp2 = 0;
int tmp3 = 0;

int z0 = 0;
int z1 = 1;
int z2 = 2;
int z3 = 3;

do{
    tmp0 ^= z0 + c2;
    tmp1 ^= z1 + c2;
    tmp2 ^= z2 + c2;
    tmp3 ^= z3 + c2;
    z0 += 4;
    z1 += 4;
    z2 += 4;
    z3 += 4;
}while (z0 < 100);

tmp0 ^= tmp1;
tmp2 ^= tmp3;

tmp0 ^= tmp2;

return tmp0;

Обратите внимание, что этот цикл полностью векторизуем.

Еще лучше, я бы полностью развернул эту петлю. Это то, что сделает компилятор C/C++. Но теперь вопрос, сделает ли это JIT?

person Mysticial    schedule 17.09.2011
comment
Удивительная попытка, однако результат по-прежнему 11 секунд. Я попробую O2 на C++, посмотрю, что получится, и дам вам знать через минуту. - person Zilvinas; 17.09.2011
comment
Версия O2 занимает 13 секунд. Так что именно O3 делает версию C++ невероятно быстрой. - person Zilvinas; 17.09.2011
comment
Я предполагаю, что версия C++ имеет такой же идентичный цикл? (синтаксис такой же для базовых циклов, подобных этому) Просто любопытно, что произойдет, если вы скопируете этот цикл в версию C++. Интересно, сделает ли это версию C++ еще быстрее... лол - person Mysticial; 17.09.2011
comment
Ах... только что увидел ваш комментарий... Так что -O3 имел значение. Может быть, это действительно векторизация его. Единственный способ узнать это посмотреть дамп сборки. :( - person Mysticial; 17.09.2011
comment
Да, ты был полностью прав. Все дело в ftree-vectorize. Я скомпилировал его с помощью -O2 -ftree-vectorize, и это увеличило его с 13 до 4,8 с. - person Zilvinas; 17.09.2011
comment
О, ничего себе, не понял, что я был прав! Итак, я предполагаю, что вывод таков: версия Java не станет намного быстрее, чем 11, если мы не сможем сделать ее векторизованной. - person Mysticial; 17.09.2011
comment
Я одобрю ваш ответ в течение нескольких дней, если никто не предложит волшебное решение или реализацию JVM, которая поддерживает автоматическую векторизацию, такую ​​​​как gcc;) - person Zilvinas; 17.09.2011

Используйте JVM Hotspot в режиме сервера и убедитесь, что подогрейте. Также дайте алгоритмам сборки мусора достаточно времени, чтобы установить стабильный темп, если сборка является основной частью вашего теста. Я не вижу ничего с первого взгляда, что заставляет меня думать, что это было бы...

person Ryan Stewart    schedule 16.09.2011
comment
Хорошая мысль, но я уже пробовал. 64-битная JVM по умолчанию работает в режиме -server. Кроме того, метод calc выполняется 250 миллионов раз, поэтому HotSpot идентифицирует его довольно быстро. Однако я все еще пытался запустить метод calc снаружи, а затем еще раз, измеряя время выполнения, и это не имело никакого значения. - person Zilvinas; 17.09.2011

Интересный вопрос. :-) Это, вероятно, больше комментарий, так как я не буду отвечать на ваш вопрос, но он слишком длинный для поля для комментариев.

Микротестирование в Java сложно, потому что JIT может сойти с ума от оптимизации. Но этот конкретный код обманывает JIT таким образом, что он почему-то не может выполнить свою обычную оптимизацию.

Обычно этот код выполняется за время O(1), потому что ваш основной цикл ни на что не влияет:

    for (int k = 0; k < list.length; k++) {
        rs = calc(list[k++], list[k++], list[k++], list[k]);
    }

Обратите внимание, что окончательный результат rs на самом деле не зависит от выполнения всех итераций цикла; только последний. Вы можете рассчитать окончательное значение «k» для цикла без необходимости запуска цикла. Обычно JIT замечает это и превращает ваш цикл в одно задание, если он может определить, что вызываемая функция (calc) не имеет побочных эффектов (которых нет).

Но каким-то образом этот оператор в функции calc() портит JIT:

        c1 ^= z + c2;

Каким-то образом это добавляет слишком много сложности для JIT, чтобы решить, что весь этот код в конце концов ничего не меняет и что исходный цикл можно оптимизировать.

Если вы измените это конкретное утверждение на что-то еще более бессмысленное, например:

        c1 = z + c2;

Затем JIT подбирает вещи и оптимизирует ваши циклы. Попробуйте. :-)

Я попробовал локально с гораздо меньшим набором данных, и с версией «^=» расчеты заняли ~ 1,6 с, а с версией «=» они заняли 0,007 секунды (или, другими словами, это оптимизировало цикл).

Как я уже сказал, не совсем ответ, но я подумал, что это может быть интересно.

person vanza    schedule 17.09.2011
comment
Как уже указывал @Mysticial, я добавил оператор печати, чтобы заставить JIT запустить цикл. Если вы удалите XOR, это все равно займет 0,007 секунды, что, я думаю, означает, что это намного быстрее, но весь цикл все еще выполняется. - person Zilvinas; 17.09.2011
comment
@Zilvinas: Какое заявление о печати? Если вы имеете в виду тот, который печатает rs, это не влияет на цикл, потому что, как я указал, вы можете вычислить значение rs, не запуская цикл. Исключение XOR заставляет JIT понять, что этот конкретный цикл можно оптимизировать, что затем заставляет его понять, что цикл в main() также может быть оптимизирован. Я просто не знаю, почему это не делается с XOR. Я также получаю время выполнения 0,007 с, если удаляю назначение rs = с сайта вызова calc(), что означает, что в этих случаях цикл просто не запускается. - person vanza; 17.09.2011
comment
если я закомментирую System.out.println(rs);, цикл будет работать 0,007 с. - person Zilvinas; 17.09.2011
comment
Точно моя точка зрения. Удаление XOR имеет тот же эффект, заставляет JIT понять, что цикл бессмысленен, и просто не запускает его. Удаление печати имеет тот же эффект, поскольку цикл изменяет rs и k, и теперь ни одна переменная не будет использоваться после цикла. Как я уже сказал, это не объясняет, как сделать это быстрее, но намекает на то, почему JIT не более полезен. - person vanza; 17.09.2011

Вы пытались «встроить» parse() и calc(), т.е. поместить весь код в main()?

person mbatchkarov    schedule 17.09.2011
comment
Да, я сделал. Это была первая версия моего кода. Я считаю, что он очень быстро получает HotSpotted, JiTed и встраивается автоматически. - person Zilvinas; 17.09.2011

Какова будет оценка, если вы переместите несколько строк функции calc внутрь итерации списка?
Я знаю, что это не очень чисто, но вы выиграете в стеке вызовов.

[...]
    for (int k = 0; k < list.length; k++) {
        int a1 = list[k++];
        int a2 = list[k++];
        int b1 = list[k++];
        int b2 = list[k];

        int c1 = (a1 + a2) ^ a2;
        int c2 = (b1 - b2) << 4;

        for (int z = 0; z < 100; z++) {
            c1 ^= z + c2;
        }

        rs = c1;
    }
person Destroyica    schedule 17.09.2011
comment
Не имеет значения. Я думаю, что HotSpot очень быстро идентифицирует «горячую точку» JIT «алгоритм» и встраивает его. - person Zilvinas; 17.09.2011

MappedByteBuffer вносит только около 20% в производительность ввода-вывода и требует огромных затрат памяти — если он вызывает подкачку, лекарство хуже, чем болезнь.

Я бы использовал BufferedReader вокруг FileReader и, возможно, Scanner вокруг него, чтобы получить целые числа, или, по крайней мере, Integer.parseInt(), который, скорее всего, будет разогрет HotSpot, чем ваш собственный код преобразования системы счисления.

person user207421    schedule 17.09.2011
comment
Хорошо, справедливое замечание о стоимости памяти, однако только для этого теста я могу сэкономить память. BuferedReader, util.split и Integer.parseInt были моим первым выбором. На самом деле чтение файла в память заняло 8 минут. Просто в качестве эксперимента я просто запустил этот код: BufferedReader in = new BufferedReader(new FileReader("in.txt")); String line; int i = 0; while ((line = in.readLine()) != null) { list[i++] = 1; list[i++] = 2; }, и это заняло 47 секунд. - person Zilvinas; 17.09.2011

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

Если задача заключается в выполнении бессмысленных вычислений, то лучшая оптимизация — не выполнять вычисления.

Если то, что вы действительно пытаетесь сделать здесь, это выяснить, существует ли общий метод ускорения вычислений, то я думаю, что вы лаете не по тому дереву. Нет такой техники. То, что вы узнаете об оптимизации бессмысленных вычислений, вряд ли применимо к другим (надеюсь, содержательным) вычислениям.

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

  • Текущий (Java) - 26,50 с + 11,27 с = ~ 38 секунд
  • Цель (C++) - ~ 26,5 с + 4,50 = ~ 31 секунда
  • Процентное ускорение - менее 20%.

Ускорение менее чем на 20% для ~40-секундного вычисления, вероятно, не стоит затраченных усилий. Дешевле заставить пользователя крутить пальцами в течение этих дополнительных 7 секунд.


Это тоже говорит вам кое-что интересное. Что в этом сценарии не имеет большого значения в относительном выражении, используете ли вы C++ или Java. В общей производительности программы преобладает фаза, в которой C++ и Java сопоставимы.

person Stephen C    schedule 17.09.2011
comment
Хотя я согласен, что это бессмысленное занятие, я ищу идеи от энтузиастов, которым нравится возиться из простого любопытства, чтобы увидеть предельные пределы (в очень узком смысле) JVM. - person Zilvinas; 17.09.2011