Java: микрооптимизация управления массивами

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

Мой текущий код выглядит следующим образом (обработка ошибок и инициализация удалены):

/**
 * Simple implementation of a feedforward neural network. The network supports
 * including a bias neuron with a constant output of 1.0 and weighted synapses
 * to hidden and output layers.
 * 
 * @author Martin Wiboe
 */
public class FeedForwardNetwork {
private final int outputNeurons;    // No of neurons in output layer
private final int inputNeurons;     // No of neurons in input layer
private int largestLayerNeurons;    // No of neurons in largest layer
private final int numberLayers;     // No of layers
private final int[] neuronCounts;   // Neuron count in each layer, 0 is input
                                // layer.
private final float[][][] fWeights; // Weights between neurons.
                                    // fWeight[fromLayer][fromNeuron][toNeuron]
                                    // is the weight from fromNeuron in
                                    // fromLayer to toNeuron in layer
                                    // fromLayer+1.
private float[][] neuronOutput;     // Temporary storage of output from previous layer


public float[] compute(float[] input) {
    // Copy input values to input layer output
    for (int i = 0; i < inputNeurons; i++) {
        neuronOutput[0][i] = input[i];
    }

    // Loop through layers
    for (int layer = 1; layer < numberLayers; layer++) {

        // Loop over neurons in the layer and determine weighted input sum
        for (int neuron = 0; neuron < neuronCounts[layer]; neuron++) {
            // Bias neuron is the last neuron in the previous layer
            int biasNeuron = neuronCounts[layer - 1];

            // Get weighted input from bias neuron - output is always 1.0
            float activation = 1.0F * fWeights[layer - 1][biasNeuron][neuron];

            // Get weighted inputs from rest of neurons in previous layer
            for (int inputNeuron = 0; inputNeuron < biasNeuron; inputNeuron++) {
                activation += neuronOutput[layer-1][inputNeuron] * fWeights[layer - 1][inputNeuron][neuron];
            }

            // Store neuron output for next round of computation
            neuronOutput[layer][neuron] = sigmoid(activation);
        }
    }

    // Return output from network = output from last layer
    float[] result = new float[outputNeurons];
    for (int i = 0; i < outputNeurons; i++)
        result[i] = neuronOutput[numberLayers - 1][i];

    return result;
}

private final static float sigmoid(final float input) {
    return (float) (1.0F / (1.0F + Math.exp(-1.0F * input)));
}
}

Я запускаю JVM с параметром -server, и на данный момент мой код на 25-50% медленнее, чем аналогичный код C. Что я могу сделать, чтобы исправить эту ситуацию?

Спасибо,

Мартин Вибо

Редактировать №1: Увидев огромное количество ответов, я, вероятно, должен уточнить цифры в нашем сценарии. Во время типичного запуска метод будет вызываться около 50 000 раз с разными входными данными. Типичная сеть будет иметь numberLayers = 3 слоя с 190, 2 и 1 нейроном соответственно. Таким образом, самый внутренний цикл будет иметь около 2*191+3=385 итераций (при подсчете добавленного нейрона смещения в слоях 0 и 1).

Изменить № 1: после реализации различных предложений в этом потоке наша реализация практически такая же быстрая, как и версия C (в пределах ~ 2%). Спасибо за помощь! Все предложения были полезны, но поскольку я могу отметить только один ответ как правильный, я передам его @Durandal как за предложение оптимизации массива, так и за то, что он единственный, кто предварительно рассчитал заголовок цикла for.


person Martin Wiboe    schedule 08.06.2010    source источник
comment
Вы профилировали это? Было бы интересно узнать, где он проводит большую часть времени.   -  person Brendan Long    schedule 08.06.2010
comment
Договорились о профилировании. не смотрите на это и гадайте, что нужно улучшить.   -  person Donnie    schedule 08.06.2010
comment
Легко ли распараллелить такой код? Если это так, то написание многопоточной версии будет принадлежать однопоточной версии в разы. Я был там, переписывая правильно многопоточную быструю сортировку на Java. Приятно смотреть на 16-ядерном компьютере: stackoverflow.com/questions/2210185 (и это разрушает стандартную сортировку Java API algos большой раз). Кроме того, я вижу несколько микрооптимизаций, но я недостаточно знаю о нейронной сети, чтобы оказать большую помощь. (кстати, в последнее время стало трудно покупать машины с монопроцессором, например, я не знаю, продает ли Apple по-прежнему компьютеры Mac с монопроцессором)   -  person SyntaxT3rr0r    schedule 08.06.2010
comment
Вы уверены, что дело не только в разогреве JVM?   -  person CurtainDog    schedule 08.06.2010
comment
@CurtainDog JVM разогревается, когда я получаю лучшие измерения (на 25% -50% медленнее, чем C). @Webinator Хорошее предложение (впечатляющий алгоритм!). Я могу распараллелить общую задачу и запустить этот метод одновременно, поэтому я не уверен, что вижу преимущества разделения compute (). @Donnie and Brendan Profiling - определенно правильный путь, я просто не получил каких-либо значимых результатов от jvisualvm. Завтра попробую другой профайлер.   -  person Martin Wiboe    schedule 08.06.2010


Ответы (8)


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

final int[] neuronOutputSlice = neuronOutput[layer - 1];
final int[][] fWeightSlice = fWeights[layer - 1];
for (int inputNeuron = 0; inputNeuron < biasNeuron; inputNeuron++) {
    activation += neuronOutputSlice[inputNeuron] * fWeightsSlice[inputNeuron][neuron];
}

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

for (int neuron = 0; neuron < neuronCounts[layer]; neuron++) { ... }
// transform to precalculated exit condition (move invariant array access outside loop)
for (int neuron = 0, neuronCount = neuronCounts[layer]; neuron < neuronCount; neuron++) { ... }

Опять же, JIT может уже сделать это за вас, поэтому профилируйте, если это поможет.

Есть ли смысл в умножении на 1.0F, который ускользает от меня здесь ?:

float activation = 1.0F * fWeights[layer - 1][biasNeuron][neuron];

Другие вещи, которые потенциально могут повысить скорость за счет удобочитаемости: встроенная функция sigmoid () вручную (JIT имеет очень жесткие ограничения для встраивания, и функция может быть больше). Может быть немного быстрее запустить цикл в обратном направлении (где это, конечно, не меняет результат), поскольку проверка индекса цикла на ноль немного дешевле, чем проверка на локальную переменную (самый внутренний цикл снова является потенциальным кандидатом, но не ожидайте, что вывод будет на 100% идентичным во всех случаях, поскольку добавление чисел с плавающей запятой a + b + c потенциально не то же самое, что a + c + b).

person Durandal    schedule 08.06.2010
comment
Массивы и предварительный расчет, похоже, улучшили общее время работы на 25% :) Спасибо. - person Martin Wiboe; 08.06.2010

Несколько советов.

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

активация + = NeuronOutput [слой-1] [inputNeuron] * fWeights [слой-1] [нейрон] [inputNeuron];

  • не выполняйте работу внутри цикла (каждый раз), которую можно выполнить вне цикла (один раз). Не выполняйте поиск [слой -1] каждый раз, когда вы можете поместить его в локальную переменную. Ваша IDE должна легко реорганизовать это.

  • многомерные массивы в Java не так эффективны, как в C. На самом деле они представляют собой несколько слоев одномерных массивов. Вы можете реструктурировать код, чтобы использовать только одномерный массив.

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

  • вместо того, чтобы формировать слой-1 повсюду, почему бы не использовать layer1 как layer-1 и не использовать layer1 + 1 вместо слоя.

person Peter Lawrey    schedule 08.06.2010
comment
Вау - оптимизация доступа к массиву сократила время работы на 20%. Спасибо. - person Martin Wiboe; 08.06.2010
comment
замена двух последних индексов fWeights также позволит циклу activation += neuronOutput[layer-1][inputNeuron] * fWeights[layer - 1][inputNeuron][neuron]; над inputNeuron векторизовать с помощью SSE2 или AVX (и даже FMA), если Java (или ваша конкретная JVM) имеет какой-либо вариант -ffast-math. Превращение последовательного доступа в непрерывный - огромное преимущество с SIMD. - person Peter Cordes; 20.11.2016

Для начала не делайте этого:

// Copy input values to input layer output
for (int i = 0; i < inputNeurons; i++) {
    neuronOutput[0][i] = input[i];
}

Но это:

System.arraycopy( input, 0, neuronOutput[0], 0, inputNeurons );
person SyntaxT3rr0r    schedule 08.06.2010
comment
Конечно, но разве в этом алгоритме не копируются только два массива: один для копирования входных данных, а другой - для копирования результатов? Реальные затраты более вероятны внутри вложенных циклов for. - person Jim Ferrans; 08.06.2010
comment
@W и V - Верно, но проблема не в этом. - person CurtainDog; 08.06.2010
comment
Хорошее предложение - подойдет :) Но время работы метода определяется внутренними циклами, так что, к сожалению, положение не спасет. (inputNeurons составляет ~ 200, так что это не должно иметь большого значения) - person Martin Wiboe; 08.06.2010

Первое, на что я хотел бы обратить внимание, это посмотреть, не тормозит ли вас Math.exp. См. этот пост о приближении Math.exp для получения альтернативы.

person nivekastoreth    schedule 08.06.2010
comment
Я думал, что таблица поиска для всей функции sigmoid() может быть полезной, но трудно сказать, не зная, сколько времени тратится на эту функцию. - person Brendan Long; 08.06.2010
comment
Почти наверняка таблица поиска значительно увеличит скорость этой функции и, возможно, поможет вам восстановить 25% -ную потерю от C к Java. Если вы сомневаетесь, сколько времени там потрачено, воспользуйтесь некоторыми инструментами профилирования, чтобы определить, что занимает так много времени. Но поскольку это, по крайней мере, рассчитывается время нейронов слоя *, есть большая вероятность, что это узкое место, которое можно легко устранить. - person drharris; 08.06.2010
comment
Я пробовал использовать это приближение, но, к сожалению, результаты слишком неточные. Знаете ли вы, как повысить точность, жертвуя скоростью? @Brendan Long и таблица поиска drharris вполне могут быть вариантом - я буду выполнять миллионы вычислений. Как можно реализовать поточно-ориентированную таблицу поиска, в которой в качестве ключа используются числа с плавающей запятой? - person Martin Wiboe; 08.06.2010
comment
Что ж, просмотрите этот старый пост, чтобы увидеть множество примеров того, как улучшить типичную сигмовидную функцию: stackoverflow.com/ questions / 412019 / math-optimisation-in-c - person drharris; 08.06.2010
comment
Если у вас есть вопросы, как заставить этот код работать быстрее, прежде чем углубляться в оптимизацию компромисса между скоростью и точностью, мне любопытно, подтолкнул ли неточный (но более быстрый) метод вас ближе к приемлемому времени выполнения? Если нет, возможно, мы ищем не в том месте. - person nivekastoreth; 14.06.2010

Замените дорогостоящую сигмовидную передаточную функцию с плавающей запятой на целочисленную ступенчатую передаточную функцию.

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

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

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

Вместо того, чтобы моделировать модель, замените дорогостоящую реализацию органической сигмовидной передаточной функции с плавающей запятой прямой цифровой реализацией ступенчатой ​​функции (меньше нуля = -1, больше нуля = +1).

введите описание изображения здесь Мозг не может этого сделать, но обратное распространение может!

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

Также поддерживает аргумент, что информатика по своей сути крута.

person Community    schedule 03.04.2013

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

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

person sizzzzlerz    schedule 08.06.2010
comment
Профилирование обязательно поможет. Я попробую переключить последние два индекса в fWeights [layer - 1] [inputNeuron] [neuron], так что inputNeuron, который изменяется, является третьим индексом. - person Martin Wiboe; 08.06.2010

Я предлагаю использовать систему с фиксированной запятой, а не с плавающей запятой. Почти на всех процессорах использование int быстрее, чем float. Самый простой способ сделать это - просто сдвинуть все влево на определенную величину (4 или 5 - хорошие отправные точки) и рассматривать нижние 4 бита как десятичные.

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

person Daniel    schedule 08.06.2010
comment
В общем, хороший момент (на самом деле многие системы, которые действительно требуют фиксированной точности, ошибочны, потому что они наивно используют FP). Однако в данном случае я не думаю, что сигмовидная функция хорошо поддается этой технике. - person CurtainDog; 08.06.2010
comment
На современном оборудовании одна инструкция FP работает быстрее, чем несколько целочисленных инструкций, необходимых для выполнения того же действия с фиксированной точкой. (особенно для умножения, где вам нужно смещение, чтобы поставить точку в нужном месте; добавление / добавление дешевле.) - person Peter Cordes; 20.11.2016
comment
Целое число отлично подходит для обработки пикселей, которые изначально были целыми числами, поскольку часто достаточно 16 бит на элемент. Таким образом, вы можете получить вдвое больше элементов на вектор SIMD по сравнению с float, и есть некоторые инструкции SSE, специально разработанные для вещей, которые часто необходимы для пикселей. Поэтому работа с целыми числами полезна, когда у вас есть несколько 16-битных элементов, которые нужно выполнять параллельно, особенно. если он сохраняет преобразование в / из float. В других случаях это часто не стоит. - person Peter Cordes; 20.11.2016
comment
Последние процессоры даже поддерживают вектор FP слияния-умножения-сложения с одной инструкцией, но не целочисленным. Таким образом, вы можете делать больше за такт с помощью FP на новейших процессорах Intel / AMD. (Инструкции FMA были введены после того, как этот ответ был написан, но они могут быть полезны для JVM, выполняющей этот цикл суммирования.) - person Peter Cordes; 20.11.2016

Ключ к оптимизации - сначала измерить, на что тратится время. Окружите различные части вашего алгоритма вызовами System.nanoTime ():

long start_time = System.nanoTime();
doStuff();
long time_taken = System.nanoTime() - start_time;

Я предполагаю, что хотя использование System.arraycopy () может немного помочь, вы найдете свои реальные затраты во внутреннем цикле.

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

person Jim Ferrans    schedule 08.06.2010