Как я могу кодировать Java, чтобы разрешить использование SSE и исключение проверки границ (или другие дополнительные оптимизации)? - PullRequest
39 голосов
/ 30 августа 2009

Ситуация:

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

Вопросы:

  • Как мне написать свой код, чтобы он мог JIT-компилироваться в форму с использованием более быстрых операций SSE?
  • Как я могу структурировать его так, чтобы компилятор мог легко устранить проверки границ массива?
  • Существуют ли какие-либо широкие ссылки относительно относительной скорости определенных математических операций (сколько приращений / уменьшений требуется, чтобы равняться нормальному сложению / вычитанию, насколько быстро происходит сдвиг или выборка массива)?
  • Как мне работать над оптимизацией ветвления - лучше ли иметь множество условных операторов с короткими телами или несколько длинных или коротких с вложенными условиями?
  • С текущей 1,6 JVM, сколько элементов должно быть скопировано, прежде чем System.arraycopy превзойдет цикл копирования?

Что я уже сделал:

До того, как на меня напали за преждевременную оптимизацию: базовый алгоритм уже превосходен, но реализация Java меньше, чем 2/3 скорости эквивалентного C. Я уже заменил циклы копирования на System.arraycopy, работал над оптимизацией циклов и устранены ненужные операции.

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

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

Требования для ХОРОШЕГО (принятого) ответа:

  • Недопустимые ответы: «это быстрее» без объяснения того, сколько И почему, ИЛИ не было протестировано с JIT-компилятором.
  • Пограничные ответы: не тестировались ни с чем до Hotspot 1.4
  • Основные ответы: предоставит общее правило и объяснение того, почему он быстрее на уровне компилятора и примерно насколько быстрее
  • Хорошие ответы: включает пару примеров кода для демонстрации
  • Отличные ответы: есть тесты с JRE 1.5 и 1.6
  • ИДЕАЛЬНЫЙ ответ: Это кто-то, кто работал над компилятором HotSpot и может полностью объяснить или ссылаться на условия для оптимизации, а также на то, насколько она обычно быстрее. Может включать код Java и пример кода сборки, сгенерированного HotSpot.

Кроме того: если у кого-то есть ссылки с подробным описанием оптимизации Hotspot и производительности ветвления, это приветствуется. Я знаю достаточно о байт-коде, чтобы сайт, анализирующий производительность на уровне байт-кода, а не на уровне исходного кода, был бы полезен.

(Правка) Частичный ответ: Ограничение проверки границ:

Это взято из предоставленной ссылки на внутреннюю вики HotSpot по адресу: https://wikis.oracle.com/display/HotSpotInternals/RangeCheckElimination

HotSpot устраняет проверки границ во всех циклах со следующими условиями:

  • Массив является инвариантом цикла (не перераспределяется внутри цикла)
  • Индексная переменная имеет постоянный шаг (увеличивается / уменьшается на постоянную величину, если возможно, только в одном месте)
  • Массив индексируется линейной функцией переменной.

Пример: int val = array[index*2 + 5]

ИЛИ: int val = array[index+9]

НЕ: int val = array[Math.min(var,index)+7]


Ранняя версия кода:

Это примерная версия. Не крадите его, потому что это неизданная версия кода для проекта базы данных H2. Финальная версия будет с открытым исходным кодом. Оптимизация кода здесь: Код H2 CompressLZF

Логически, это идентично версии для разработки, но в ней используется цикл for (...) для пошагового ввода и цикл if / else для различной логики между литеральным и обратным ссылками. Это уменьшает доступ к массиву и проверяет между режимами.

public int compressNewer(final byte[] in, final int inLen, final byte[] out, int outPos){
        int inPos = 0;
        // initialize the hash table
        if (cachedHashTable == null) {
            cachedHashTable = new int[HASH_SIZE];
        } else {
            System.arraycopy(EMPTY, 0, cachedHashTable, 0, HASH_SIZE);
        }
        int[] hashTab = cachedHashTable;
        // number of literals in current run
        int literals = 0;
        int future = first(in, inPos);
        final int endPos = inLen-4;

        // Loop through data until all of it has been compressed
        while (inPos < endPos) {
                future = (future << 8) | in[inPos+2] & 255;
//                hash = next(hash,in,inPos);
                int off = hash(future);
                // ref = possible index of matching group in data
                int ref = hashTab[off];
                hashTab[off] = inPos;
                off = inPos - ref - 1; //dropped for speed

                // has match if bytes at ref match bytes in future, etc
                // note: using ref++ rather than ref+1, ref+2, etc is about 15% faster
                boolean hasMatch = (ref > 0 && off <= MAX_OFF && (in[ref++] == (byte) (future >> 16) && in[ref++] == (byte)(future >> 8) && in[ref] == (byte)future));

                ref -=2; // ...EVEN when I have to recover it
                // write out literals, if max literals reached, OR has a match
                if ((hasMatch && literals != 0) || (literals == MAX_LITERAL)) {
                    out[outPos++] = (byte) (literals - 1);
                    System.arraycopy(in, inPos - literals, out, outPos, literals);
                    outPos += literals;
                    literals = 0;
                }

                //literal copying split because this improved performance by 5%

                if (hasMatch) { // grow match as much as possible
                    int maxLen = inLen - inPos - 2;
                    maxLen = maxLen > MAX_REF ? MAX_REF : maxLen;
                    int len = 3;
                    // grow match length as possible...
                    while (len < maxLen && in[ref + len] == in[inPos + len]) {
                        len++;
                    }
                    len -= 2;

                    // short matches write length to first byte, longer write to 2nd too
                    if (len < 7) {
                        out[outPos++] = (byte) ((off >> 8) + (len << 5));
                    } else {
                        out[outPos++] = (byte) ((off >> 8) + (7 << 5));
                        out[outPos++] = (byte) (len - 7);
                    }
                    out[outPos++] = (byte) off;
                    inPos += len;

                    //OPTIMIZATION: don't store hashtable entry for last byte of match and next byte
                    // rebuild neighborhood for hashing, but don't store location for this 3-byte group
                    // improves compress performance by ~10% or more, sacrificing ~2% compression...
                    future = ((in[inPos+1] & 255) << 16) | ((in[inPos + 2] & 255) << 8) | (in[inPos + 3] & 255);
                    inPos += 2;
                } else { //grow literals
                    literals++;
                    inPos++;
                } 
        }

        // write out remaining literals
        literals += inLen-inPos;
        inPos = inLen-literals;
        if(literals >= MAX_LITERAL){
            out[outPos++] = (byte)(MAX_LITERAL-1);
            System.arraycopy(in, inPos, out, outPos, MAX_LITERAL);
            outPos += MAX_LITERAL;
            inPos += MAX_LITERAL;
            literals -= MAX_LITERAL;
        }
        if (literals != 0) {
            out[outPos++] = (byte) (literals - 1);
            System.arraycopy(in, inPos, out, outPos, literals);
            outPos += literals;
        }
        return outPos; 
    }

Окончательное редактирование:

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

Ответы [ 4 ]

18 голосов
/ 12 сентября 2009

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

Знай своего врага

Вы нацелены на комбинацию JVM (по сути JIT) и базовой подсистемы CPU / Memory. Таким образом, «это быстрее на JVM X», вероятно, не будет действительным во всех случаях, когда вы переходите к более агрессивной оптимизации.

Если ваш целевой рынок / приложение будет в основном работать на определенной архитектуре, вам следует подумать об инвестировании в инструменты, специфичные для него. * Если ваша производительность на x86 является критическим фактором, то Intel VTune отлично подходит для детального анализа типа jit output , который вы описываете. * Различия между 64-битными и 32-битными JIT могут быть значительными, особенно на платформах x86, где могут меняться соглашения о вызовах и регистрация возможностей очень разные.

Получите нужные инструменты

Вы, вероятно, захотите получить профилировщик выборки. Затраты на инструментарий (и связанный с этим удар по таким вещам, как встраивание, загрязнение кэша и инфляция размера кода) для ваших конкретных потребностей были бы слишком велики. Анализатор Intel VTune действительно можно использовать для Java, хотя интеграция не такая тесная, как у других.
Если вы используете Sun JVM и рады только зная, что делает последняя / лучшая версия, то опции, доступные для , позволяют исследовать вывод JIT , если вы немного разбираетесь в сборке. В этой статье подробно описан интересный анализ с использованием этой функциональности

Учитесь у других реализаций

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

Некоторые особенности

Поскольку LZF в эффективной неуправляемой реализации на современном настольном компьютере CPUS в значительной степени ограничен пропускной способностью памяти (следовательно, он конкурирует со скоростью неоптимизированной memcpy), вам потребуется код, чтобы он полностью оставался в кэше уровня 1.
Таким образом, любые статические поля, которые вы не можете преобразовать в константы, должны быть помещены в один и тот же класс, поскольку эти значения часто помещаются в одну и ту же область памяти, выделенную для виртуальных таблиц и метаданных, связанных с классами.

Следует избегать размещения объектов, которые нельзя отследить с помощью Escape Analysis (только в версии 1.6 и далее).

Код c активно использует развертывание циклов. Однако производительность этого на более старых (1.4 эпохи) виртуальных машинах сильно зависит от режима, в котором JVM находится в . По-видимому, последние версии Sun jvm более агрессивны при встраивании и развертывании, особенно в режиме сервера.

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

"Это идет прямо к нам"

Ваша цель движется и будет продолжать. Опять предыдущий опыт Марка Лемана:

по умолчанию размер HLOG теперь 15 (кэш процессоров увеличился)

Даже незначительные обновления jvm могут включать значительные изменения компилятора

6544668 Не проверяйте операции с массивами, которые нельзя выровнять во время выполнения. 6536652 Реализация некоторых оптимизаций суперслов (SIMD) 6531696 не использует непосредственное 16-битное хранилище значений в памяти на процессоре Intel 6468290 Разделяй и распределяй вне eden для каждого процессора

Капитан Очевидность

Измерить, измерить, измерить. ЕСЛИ вы можете заставить свою библиотеку включать (в отдельную dll) простой и легкий для выполнения тест, который регистрирует соответствующую информацию (версия vm, процессор, ОС, параметры командной строки и т. Д.) И упрощает отправку обратно вам, увеличьте ваше покрытие, лучше всего вы покроете тех людей, которые его используют.

7 голосов
/ 14 сентября 2009

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

  • V.Михеев, С. Федосеев, В. Сухарев, Н. Липский.2002 Эффективное улучшение контроля версий в Java . Link .Эта статья принадлежит ребятам из Excelsior, которые внедрили эту технику в их Jet JVM.
  • Würthinger, Thomas, Christian Wimmer и Hanspeter Mössenböck2007. Устранение проверки границ массива для клиентского компилятора HotSpot .PPPJ. Ссылка .Слегка основываясь на вышеприведенном документе, я полагаю, что эта реализация будет включена в следующую JDK .Также представлены достигнутые ускорения .

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

Надеюсь, это поможет.

2 голосов
/ 08 декабря 2009

Много ответов пока, но пара дополнительных вещей:

  • Мера, мера, мера. Столько, сколько большинство разработчиков Java предостерегают от микробанчмаркинга, обязательно сравнивайте производительность между изменениями. Оптимизации, которые не приводят к измеримым улучшениям, как правило, не стоит оставлять (конечно, иногда это комбинация вещей, и это становится сложнее)
  • Плотные циклы имеют значение для Java так же, как и для C (и то же самое с распределением переменных - хотя вы не управляете им напрямую, HotSpot в конечном итоге должен будет это сделать). Мне удается практически удвоить скорость декодирования UTF-8, переставив тугой цикл для обработки однобайтового случая (7-битный ASCII) как тугой (эр) внутренний цикл, исключив другие случаи.
  • Не стоит недооценивать затраты на выделение и / или очистку больших массивов - если вы хотите, чтобы кодирование / декодирование lzf было более быстрым для небольших / средних кусков (не только размером в мегабайт), имейте в виду, что ВСЕ выделения байтов [] / int [] несколько дорогостоящие; не из-за GC, а потому, что JVM ДОЛЖНА очистить пространство.

Реализация H2 также немного оптимизирована (например: она больше не очищает хеш-массив, это часто имеет смысл); и я на самом деле помог изменить его для использования в другом проекте Java. Мой вклад в основном заключался в том, чтобы просто изменить его, чтобы он был более оптимальным для не потокового случая, но это не коснулось узких циклов кодирования / декодирования.

2 голосов
/ 15 сентября 2009

Маловероятно, что вам нужно сильно помогать JIT-компилятору в оптимизации простого алгоритма вычисления чисел, такого как LZW. ShuggyCoUk упомянул об этом, но я думаю, что это заслуживает дополнительного внимания:

Кэш-дружественность вашего кода будет иметь большое значение.

Вы должны уменьшить размер своего рабочего режима и максимально улучшить доступ к данным. Вы упоминаете «упаковку байтов в целые числа для производительности» Это похоже на использование ints для хранения байтовых значений, чтобы выровнять их по словам. Не делай этого! Увеличенный размер набора данных перевесит любой выигрыш (однажды я преобразовал некоторый код обработки чисел ECC из int [] в byte [] и получил ускорение в 2 раза).

Если есть вероятность, что вы этого не знаете: если вам нужно обрабатывать некоторые данные как байты и целые, вам не нужно сдвигать и | -маскировать их - используйте ByteBuffer.asIntBuffer() и родственные методы.

С текущей 1,6 JVM, сколько элементы должны быть скопированы до System.arraycopy превосходит цикл копирования?

Лучше сделайте тест самостоятельно. Когда я делал это в то время, когда в Java 1,3 раза, это было где-то около 2000 элементов.

...