Оптимизации этой функции (в C++)
у меня есть код, потребляющий процессор, где выполняется некоторая функция с циклом много раза. Каждая оптимизация в этом цикле приносит заметный прирост производительности. Вопрос:как бы вы оптимизировали этот цикл (хотя оптимизировать больше нечего...)?
void theloop(int64_t in[], int64_t out[], size_t N)
{
for(uint32_t i = 0; i < N; i++) {
int64_t v = in[i];
max += v;
if (v > max) max = v;
out[i] = max;
}
}
я попробовал несколько вещей, например, Я заменил массивы указателями, которые были увеличены в каждом цикле, но (удивительно) я проиграл некоторые работы вместо получение...
Edit:
- изменить имя одной переменной (
itsMaximums
ошибка) - функция является методом класса
- и являются
int64_t
, так и отрицательные и положительные - `(в > макс) can оценить в true: рассмотрим ситуацию, когда фактический Макс отрицательный
- код работает на 32-разрядных ПК (разработка) и 64-разрядных (производство)
-
N
неизвестно на этапе компиляции время - я попробовал SIMD, но мне не удалось увеличить производительность... (накладные расходы на перемещение переменных в
_m128i
, выполнение и сохранение назад было выше, чем увеличение скорости SSE. Но я не эксперт по SSE, поэтому, возможно, у меня был плохой код)
результаты:
я добавил некоторые разворачиваются петли, и хороший хак с поста Алекса. Ниже я вставляю некоторые результаты:
- оригинал: 14.0 s
- развернутая петля (4 итерации): 10.44 s
- alex'ES трюк: 10.89 s
- 2) и 3) сразу: 11.71 s
strage, что 4) не быстрее, чем 3) и 4). Ниже код 4):
for(size_t i = 1; i < N; i+=CHUNK) {
int64_t t_in0 = in[i+0];
int64_t t_in1 = in[i+1];
int64_t t_in2 = in[i+2];
int64_t t_in3 = in[i+3];
max &= -max >> 63;
max += t_in0;
out[i+0] = max;
max &= -max >> 63;
max += t_in1;
out[i+1] = max;
max &= -max >> 63;
max += t_in2;
out[i+2] = max;
max &= -max >> 63;
max += t_in3;
out[i+3] = max;
}
8 ответов
#объявление посмотреть чатПривет Якуб, что бы вы сказали, если бы я нашел версию, которая использует эвристическую оптимизацию, которая для случайных данных, распределенных равномерно, приведет к увеличению скорости ~3.2 x для
int64_t
(10.56 х эффективное использованиеfloat
s)?мне еще нужно найти время, чтобы обновить сообщение, но объяснение и код можно найти через чат.
Я использовал тот же код тестового стенда (ниже), чтобы убедиться, что результаты верны и точно соответствуют исходной реализации из вашего OP редактировать: забавно... у этого тестового стенда был фатальный недостаток, который сделал результаты недействительными: эвристическая версия фактически пропускала части ввода, но поскольку существующий вывод не очищался, он, казалось, имел правильный вывод... (еще редактирование...)
хорошо, я опубликовал тест на основе ваших версий кода, а также мое предлагаемое использование partial_sum
.
здесь кодhttps://gist.github.com/1368992#file_test.cpp
особенности
для конфигурации по умолчанию
#define MAGNITUDE 20
#define ITERATIONS 1024
#define VERIFICATION 1
#define VERBOSE 0
#define LIMITED_RANGE 0 // hide difference in output due to absense of overflows
#define USE_FLOATS 0
он (посмотреть фрагмент вывода вот!--34-->):
- выполнить 100 x 1024 итерации (т. е. 100 различных случайных семян)
- для длины данных 1048576 (2^20).
- случайные входные данные равномерно распределенной во всем диапазоне типа данных элемента (
int64_t
) - Проверьте вывод, создав хэш-дайджест выходного массива и сравнение его с эталонной реализации от OP.
результаты
есть ряд (неожиданных или неудивительных) результатов:
здесь без существенных разница в производительности между любым из алгоритмов вообще (для целочисленных данных), при компиляции с включенной оптимизацией. (См.make-файл; моя арка 64bit, Intel Core Q9550 с gcc-4.6.1)
-
алгоритмы не эквивалентно (вы увидите, что хэш-суммы отличаются): в частности, бит-скрипка, предложенная Алексом, не обрабатывает переполнение целых чисел совершенно одинаково (это может быть скрыто, определяя
#define LIMITED_RANGE 1
который ограничивает входные данные, поэтому переполнения не произойдет; обратите внимание, что
partial_sum_incorrect
версия показывает эквивалентные C++ не побитовые _arithmetic операции, которые дают то же самое разные результаты:return max<0 ? v : max + v;
возможно, это нормально для вашего цель?)
удивительно не дороже вычислить оба определения алгоритма max сразу. Вы можете видеть, что это делается внутри
partial_sum_correct
: он вычисляет обе "формулировки" max в одном цикле; это действительно не больше, чем ТРИВА здесь, потому что ни один из двух методов не является значительно быстрее...-
еще более удивительно большой прирост производительности можно иметь, когда вы возможность использования
float
вместоint64_t
. Быстрый и грязный хак может быть применен для ориентира#define USE_FLOATS 0
показывая, что алгоритм на основе STL (
partial_sum_incorrect
), работает приблизительно 2.5 x быстрее при использованииfloat
вместоint64_t
(!!!).
Примечание:- что именование
partial_sum_incorrect
относится только к целочисленному переполнению, которое не применяется к поплавкам; это видно из того, что хэши совпадают, поэтому на самом деле это _partial_sum_float_correct_ :) - что текущая реализация
partial_sum_correct
делает двойную работу, которая заставляет его плохо работать в режиме с плавающей запятой. Смотрите bullet 3.
- что именование
(и была эта ошибка off-by-1 в развернутой версии цикла из OP, о которой я упоминал ранее)
частичной сумме
для вашего интереса приложение с частичной суммой выглядит так: C++11:
std::partial_sum(data.begin(), data.end(), output.begin(),
[](int64_t max, int64_t v) -> int64_t
{
max += v;
if (v > max) max = v;
return max;
});
во-первых, вам нужно посмотреть на сгенерированную сборку. В противном случае вы не можете знать, что на самом деле происходит при выполнении этого цикла.
теперь: этот код работает на 64-разрядной машине? Если нет, эти 64-битные дополнения могут немного повредить.
этот цикл кажется очевидным кандидатом на использование инструкций SIMD. SSE2 поддерживает ряд инструкций SIMD для целочисленной арифметики,в том числе некоторые, которые работают на двух 64-разрядных ценности.
кроме этого, посмотрите, правильно ли компилятор разворачивает цикл, а если нет, сделайте это сами. Разверните пару итераций цикла, а затем измените порядок из него. Поместите все нагрузки памяти в верхней части цикла, чтобы их можно было запустить как можно раньше.
на if
line, убедитесь, что компилятор генерирует условное перемещение, а не ветвь.
наконец, посмотрите, поддерживает ли ваш компилятор что-то вроде restrict
/__restrict
ключевое слово. Это не стандарт на C++, но это очень полезно для указания компилятору, что in
и out
не указывайте на одни и те же адреса.
- размер (N
) известен во время компиляции? Если это так, сделайте его параметром шаблона (а затем попробуйте передать in
и out
как ссылки на массивы правильного размера, так как это также может помочь компилятору в анализе псевдонимов)
просто некоторые мысли с моей головы. Но опять же, изучите разборку. Вы должны знать, что компилятор делает для вас, и особенно, что он не сделать для вас.
редактировать
С изменить:
max &= -max >> 63;
max += t_in0;
out[i+0] = max;
что меня поражает, так это то, что вы добавили огромный цепи зависимостей. Прежде чем результат может быть вычислен, max должен быть отрицан, результат должен быть сдвинут, результат это должно быть иЭд вместе со своим исходное значение и результат это необходимо добавить в другую переменную.
иными словами, все эти операции должны быть сериализованы. Вы не можете начать один из них до того, как предыдущий закончит. Это не обязательно ускорение. Современные конвейерные процессоры, вышедшие из строя, любят выполнять множество вещей параллельно. Связать его с одной длинной цепочкой зависимых инструкций - одна из самых калечащих вещей, которые вы можете сделать. (Конечно, это если можно чередовать с другие итерации, это может сработать лучше. Но мое чутье подсказывает, что простая условная инструкция перемещения была бы предпочтительнее)
иногда вам нужно отступить назад и снова просмотреть его. Первый вопрос, очевидно, вам это надо ? Может ли быть альтернативный алгоритм, который будет работать лучше ?
тем не менее, и предположим ради этого вопроса, Что вы уже решили этот алгоритм, мы можем попытаться рассуждать о том, что у нас на самом деле есть.
отказ от ответственности: метод, который я описываю, вдохновлен успешным методом, используемым Тимом Питерсом для улучшения традиционная реализация introsort, ведущей к TimSort. Так что, пожалуйста, потерпите со мной;)
1. Извлечение Свойств
основная проблема, которую я вижу, - это зависимость между итерациями, которая предотвратит большую часть возможных оптимизаций и предотвратит многие попытки распараллеливания.
int64_t v = in[i];
max += v;
if (v > max) max = v;
out[i] = max;
давайте переработаем этот код в функциональный мода:
max = calc(in[i], max);
out[i] = max;
где:
int64_t calc(int64_t const in, int64_t const max) {
int64_t const bumped = max + in;
return in > bumped ? in : bumped;
}
или, скорее, упрощенная версия (переполнение baring, поскольку оно не определено):
int64_t calc(int64_t const in, int64_t const max) {
return 0 > max ? in : max + in;
}
вы замечаете острия ? Поведение изменяется в зависимости от того, является ли плохо названный (*)max
положительным или отрицательным.
этот переломный момент делает интересным наблюдать значения в in
более близко, особенно согласно влиянию они могли иметь на max
:
-
max < 0
иin[i] < 0
затемout[i] = in[i] < 0
-
max < 0
иin[i] > 0
затемout[i] = in[i] > 0
-
max > 0
иin[i] < 0
затемout[i] = (max + in[i]) ?? 0
-
max > 0
иin[i] > 0
затемout[i] = (max + in[i]) > 0
(*) плохо назван, потому что это также аккумулятор, который скрывает имя. Но лучшего предложения у меня нет.
2. Оптимизация операций
это приводит нас к открытию интересные случаи:
- если у нас есть кусок
[i, j)
массива, содержащего только отрицательные значения (которые мы называем отрицательным срезом), тогда мы могли бы сделатьstd::copy(in + i, in + j, out + i)
иmax = out[j-1]
- если у нас есть кусок
[i, j)
массива, содержащего только положительные значения, то это чистый код накопления (который можно легко развернуть) -
max
получает положительный, как толькоin[i]
положительное
поэтому это может быть интересно (но, возможно, нет, я не обещаю), чтобы установить профиль ввода, прежде чем фактически работать с ним. Обратите внимание, что профиль может быть сделан chunk за chunk для больших входных данных, например, настройка размера chunk на основе размера строки кэша.
для ссылок, 3 подпрограмм:
void copy(int64_t const in[], int64_t out[],
size_t const begin, size_t const end)
{
std::copy(in + begin, in + end, out + begin);
} // copy
void accumulate(int64_t const in[], int64_t out[],
size_t const begin, size_t const end)
{
assert(begin != 0);
int64_t max = out[begin-1];
for (size_t i = begin; i != end; ++i) {
max += in[i];
out[i] = max;
}
} // accumulate
void regular(int64_t const in[], int64_t out[],
size_t const begin, size_t const end)
{
assert(begin != 0);
int64_t max = out[begin - 1];
for (size_t i = begin; i != end; ++i)
{
max = 0 > max ? in[i] : max + in[i];
out[i] = max;
}
}
теперь, предположим, что мы можем как-то охарактеризовать входные, используя простую структуру:
struct Slice {
enum class Type { Negative, Neutral, Positive };
Type type;
size_t begin;
size_t end;
};
typedef void (*Func)(int64_t const[], int64_t[], size_t, size_t);
Func select(Type t) {
switch(t) {
case Type::Negative: return ©
case Type::Neutral: return ®ular;
case Type::Positive: return &accumulate;
}
}
void theLoop(std::vector<Slice> const& slices, int64_t const in[], int64_t out[]) {
for (Slice const& slice: slices) {
Func const f = select(slice.type);
(*f)(in, out, slice.begin, slice.end);
}
}
теперь, если introsort работа в цикле минимальна, поэтому вычисление характеристики могут быть слишком дорогостоящими... однако это приводит себя хорошо к распараллеливание.
3. Простое распараллеливание
обратите внимание,что характеристика является чистой функцией ввода. Поэтому, предположим, что вы работаете в куске за куском, можно было бы параллельно иметь:
- Slice Producer: поток символов, который вычисляет
Slice::Type
стоимостью - срез Потребитель: рабочий поток, который фактически выполняет код
даже если вход по существу случайный, при условии, что кусок достаточно мал (например, строка кэша CPU L1), могут быть куски, для которых он работает. Синхронизация между двумя потоками может быть выполнена с помощью простой потокобезопасной очереди Slice
(поставщик/потребитель) и добавить bool last
атрибут, чтобы остановить потребление или путем создания Slice
в направлении Unknown
тип, и имея потребительский блок, пока он не известен (с использованием atomics).
Примечание: поскольку характеристика чиста, она смущающе параллельна.
4. Больше распараллеливания: спекулятивная работа
помните это невинное замечание:max
получает положительный, как только in[i]
положительное.
предположим, что мы можем догадаться (надежно), что Slice[j-1]
будет производить max
значение, которое отрицательно, то вычисление на Slice[j]
независимо от того, что им предшествовало, и мы можем начать работу прямо сейчас!
конечно, это предположение, поэтому мы можем ошибаться... но как только мы полностью охарактеризовали все срезы, у нас есть простаивающие ядра, поэтому мы можем также использовать их для спекулятивной работы! А если мы ошибаемся ? Ну, потребительская нить просто мягко сотрет нашу ошибку и заменит ее правильным значением.
эвристика для спекулятивного вычисления Slice
должно быть просто, и это должно быть настроиться. Он может быть адаптивным... но это может быть сложнее!
вывод
проанализируйте свой набор данных и попробуйте найти, можно ли разбить зависимости. Если это так, вы, вероятно, можете воспользоваться этим, даже не идя многопоточным.
Если значения max
и in[]
далеки от 64-битного min / max (скажем, они всегда находятся между -261 а +261), вы можете попробовать цикл без условной ветви, что может вызвать некоторое ухудшение perf:
for(uint32_t i = 1; i < N; i++) {
max &= -max >> 63; // assuming >> would do arithmetic shift with sign extension
max += in[i];
out[i] = max;
}
теоретически компилятор может сделать аналогичный трюк, но, не видя разборки, трудно сказать, делает ли он это.
код появляется уже довольно быстро. В зависимости от характера массива in вы можете попробовать специальный корпус, например, если вы знаете, что в конкретном вызове все входные номера положительны, out[i] будет равен кумулятивной сумме, без необходимости ветви if.
сделать метод не виртуальные, inline, _атрибут_((always_inline)) и - funroll-loops Кажется, хорошие варианты для изучения.
только путем бенчмаркинга мы можем определить, были ли они стоящими оптимизациями в вашей большей программе.
единственное, что приходит на ум, что может помощь немного использовать указатели, а не индексы массива в вашем цикле, что-то вроде
void theloop(int64_t in[], int64_t out[], size_t N)
{
int64_t max = in[0];
out[0] = max;
int64_t *ip = in + 1,*op = out+1;
for(uint32_t i = 1; i < N; i++) {
int64_t v = *ip;
ip++;
max += v;
if (v > max) max = v;
*op = max;
op++
}
}
мышление здесь заключается в том, что индекс в массиве может компилироваться как принимающий базовый адрес массива, умножающий размер элемента на индекс и добавляющий результат для получения адреса элемента. Управления указателями избегает этого. Я предполагаю, что хороший оптимизирующий компилятор сделает это уже, поэтому вам нужно изучить текущий вывод ассемблера.
int64_t max = 0, i;
for(i=N-1; i > 0; --i) /* Comparing with 0 is faster */
{
max = in[i] > 0 ? max+in[i] : in[i];
out[i] = max;
--i; /* Will reduce checking of i>=0 by N/2 times */
max = in[i] > 0 ? max+in[i] : in[i]; /* Reduce operations v=in[i], max+=v by N times */
out[i] = max;
}
if(0 == i) /* When N is odd */
{
max = in[i] > 0 ? max+in[i] : in[i];
out[i] = max;
}