Оптимизировать эту функцию (в C ++) - PullRequest
7 голосов
/ 15 ноября 2011

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

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;
    }
}

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

Редактировать:

  • изменил имя одной переменной (itsMaximums, ошибка)
  • функция представляет собой метод класса
  • , входные и положительные значения int64_t, поэтому отрицательные и положительные
  • `(v> max) can оценить как истинное: рассмотрим ситуацию, когда фактический максимум отрицателен
  • код работает на 32-разрядном ПК (разработка) и 64-разрядном (производство)
  • N неизвестно привремя компиляции
  • Я попробовал немного SIMD, но мне не удалось повысить производительность ... (издержки перемещения переменных на _m128i, выполнения и сохранения были выше, чем прирост скорости SSE. Но я неэксперт по SSE, так что, возможно, у меня был плохой код)

Результаты:

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

  1. оригинал: 14,0 с
  2. развернутый цикл (4 итерации): 10,44 с
  3. трюк Алекса: 10,89 с
  4. 2) и 3) одновременно: 11,71 с

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 ]

15 голосов
/ 15 ноября 2011

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

Теперь: работает ли этот код на 64-битной машине? Если нет, то эти 64-битные дополнения могут немного повредить.

Этот цикл кажется очевидным кандидатом на использование SIMD-инструкций. SSE2 поддерживает ряд инструкций SIMD для целочисленной арифметики, , включая , некоторые из которых работают с двумя 64-битными значениями.

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

Для строки if убедитесь, что компилятор генерирует условное перемещение, а не ветвь.

Наконец, посмотрите, поддерживает ли ваш компилятор что-то вроде ключевого слова restrict / __restrict. Это не стандартно в C ++, но очень полезно для указания компилятору, что in и out не указывают на одинаковые адреса.

Известен ли размер (N) во время компиляции? Если это так, сделайте его параметром шаблона (а затем попробуйте передать in и out в качестве ссылок на массивы правильного размера, поскольку это также может помочь компилятору в анализе псевдонимов)

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

Редактировать

с вашим редактированием:

max &= -max >> 63;
max += t_in0;
out[i+0] = max;

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

Другими словами, все эти операции должны быть сериализованы. Вы не можете запустить один из них, пока не закончился предыдущий. Это не обязательно ускорение. Современные конвейерные вышедшие из строя процессоры любят выполнять множество вещей параллельно. Связать его с помощью одной длинной цепочки зависимых инструкций - одна из самых серьезных вещей, которые вы можете сделать. (Конечно, если это можно перемежать с другими итерациями, это может сработать лучше. Но мне кажется, что предпочтительнее было бы использовать простую инструкцию условного перемещения)

7 голосов
/ 15 ноября 2011

# Объявление см. чат

Привет, Якуб, чтоВы бы сказали, если бы я нашел версию, которая использует эвристическую оптимизацию, которая для случайных данных, распределенных равномерно, приведет к увеличению скорости в ~ 3,2 раза для int64_t (в 10,56 раза эффективнее при использовании float с)?

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


Хорошо, я опубликовал тест, основанный на ваших версиях кода, а также на моем предложенном использовании 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

Он будет ( см. выходной фрагмент здесь ):

  • выполнить 100 x 1024 итераций (т.е. 100 различных случайных начальных чисел)
  • для длины данных 1048576 (2 ^ 20).
  • Случайные входные данные равномерно распределены по всему диапазону типа данных элемента (int64_t)
  • Проверьте вывод, сгенерировав хэш-дайджест выходного массива и сравнение его с эталонной реализацией из ОП.

Результаты

Существует ряд (удивительных или неудивительных) результатов:

  1. * * * * * * * * * * * * * * * * * * * *1074* * * * * * * * * * * * * * * * * * 10 * *1074* при условии, что вы компилируете с включенными оптимизациями.,(См. Makefile ; моя арка 64-битная, Intel Core Q9550 с gcc-4.6.1)

  2. Алгоритмы не эквивалентны (выВы увидите, что хэш-суммы различаются): в частности, битовая скрипка, предложенная Алексом, не обрабатывает целочисленное переполнение совершенно одинаково (это может быть скрыто, определяя

    #define LIMITED_RANGE 1
    

    , который ограничивает входные данные, так что переполнения выигрывают 't, обратите внимание, что partial_sum_incorrect версия показывает эквивалентные C ++ небитовые _арифметические операции, которые дают одинаковые разные результаты:

    return max<0 ? v :  max + v; 
    

    Возможно, это нормально для вашей цели?)

  3. Удивительно Не дороже рассчитать оба определения алгоритма max одновременно.Вы можете видеть, что это делается внутри partial_sum_correct: он вычисляет обе «формулировки» max в одном и том же цикле;Это на самом деле не более, чем трива здесь, потому что ни один из двух методов не является значительно более быстрым ...

  4. Еще более удивительно может быть получен большой прирост производительности когда вы можете использовать float вместо int64_t.Быстрый и грязный хак может быть применен к тесту

    #define USE_FLOATS    0
    

    , показывающему, что алгоритм на основе STL (partial_sum_incorrect) работает примерно в 2,5 раза быстрее при использовании float вместо int64_t (!!!). Примечание:

    • , что именование partial_sum_incorrect относится только к целочисленному переполнению, которое не относится к числам с плавающей запятой;это видно из того факта, что хэши совпадают, поэтому на самом деле именно _partial_sum_float_correct_:)
    • текущая реализация partial_sum_correct выполняет двойную работу, из-за которой она плохо работает в режиме с плавающей запятой.См. Пул 3.
  5. (И была эта ошибка 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;
        });
5 голосов
/ 15 ноября 2011

Иногда вам нужно сделать шаг назад и снова посмотреть на него.Первый вопрос, очевидно, вам это нужно?Может ли быть альтернативный алгоритм, который работал бы лучше?

При этом, и, предположив, ради этого вопроса, что вы уже остановились на этом алгоритме, мы можем попытаться рассуждать о том, что у нас есть на самом деле.

Отказ от ответственности: метод, который я описываю, вдохновлен успешным методом, который Тим Питерс использовал для улучшения традиционной реализации интросорта, что привело к 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;
}

Вернее, упрощенная версия (исключая переполнение, поскольку она не определена):

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] становится положительным

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

Для справки: 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 &copy;
  case Type::Neutral: return &regular;
  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);
  }
}

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

3.Простое распараллеливание

Обратите внимание, что характеристика является чистой функцией ввода.Следовательно, предположим, что вы работаете в режиме «чанк на чанк», можно параллельно иметь:

  • Slice Producer: поток характеризатора, который вычисляет значение Slice::Type
  • Slice Consumer: рабочий поток, который на самом деле выполняет код

Даже если ввод является по существу случайным, при условии, что порция достаточно мала (например, строка кэша L1 ЦП), возможно, существуеткуски, для которых это работает.Синхронизация между двумя потоками может быть выполнена с помощью простой потокобезопасной очереди Slice (производитель / потребитель) и добавления атрибута bool last для прекращения потребления или путем создания Slice в векторе с типом Unknownи имея потребительский блок до тех пор, пока он не станет известен (с использованием атомики).

Примечание: поскольку характеристика чистая, она смущающе параллельна.

4.Подробнее Распараллеливание: умозрительная работа

Помните это невинное замечание: max становится положительным, как только in[i] становится положительным .

Предположим, что мы можем предположить (надежно), что Slice[j-1] даст отрицательное значение max, тогда вычисления Slice[j] не зависят от того, что им предшествовало, и мы можем начать работу прямо сейчас!

Конечно, это предположение, поэтому мы можем ошибаться ... но как только мы полностью охарактеризовали все кусочки, у нас появятся свободные ядра, так что мы могли бы также использовать их для умозрительной работы!А если мы не правы?Что ж, поток потребителя просто аккуратно сотрет нашу ошибку и заменит ее на правильное значение.

Эвристика умозрительного вычисления Slice должна быть простой и должна быть настроена.Он также может быть адаптивным ... но это может быть сложнее!

Заключение

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

4 голосов
/ 15 ноября 2011

Если значения max и in[] далеки от 64-битных мин / макс (скажем, они всегда находятся между -2 61 и + 2 61 ), вы можете попробовать цикл без условного перехода, что может привести к некоторому ухудшению производительности:

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;
}

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

1 голос
/ 15 ноября 2011

гарантирует, что метод не является виртуальным , встроенным , _ атрибут _ ((всегда_инлайн)) и - funroll-loops кажутся хорошими вариантами для изучения.

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

1 голос
/ 15 ноября 2011

Код появляется уже довольно быстро.В зависимости от природы массива in, вы можете попробовать специальный регистр, например, если вы знаете, что в конкретном вызове все входные числа положительны, out [i] будет равен кумулятивной сумме, без необходимостиветвь if.

0 голосов
/ 15 ноября 2011
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;
}
0 голосов
/ 15 ноября 2011

Единственное, что приходит в голову, что может немного помочь, это использовать указатели, а не индексы массивов в вашем цикле, что-то вроде

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++
    }
}

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

...