Страницы

Поиск по вопросам

четверг, 5 декабря 2019 г.

Методы оптимизации, основанные на эффективном использовании оборудования

#алгоритм #оптимизация


А давайте соберём здесь неалгоритмические методы оптимизации программ. Не алгоритмы
«N*log n вместо N^2», а приёмы позволяющие использовать имеющееся оборудование более
эффективно. Что приходит на ум мне:

Использование кэша процессора:


Обработка данных небольшими порциями, чтобы каждый раз все необходимые данные влезали
в кэш. 
Пример (для начала; можно придумать что-то более удачное): для quicksort эта рекомендация
выполняется, когда дело доходит до небольших блоков. Но на больших объёмах данных на
первых итерациях сортируемые и сливаемые половинки не помещаются в кэш. Следствие:
если, допустим, помимо сортировки надо с данными сделать что-то ещё, это выгодно делать
после сортировки каждого небольшого блока, пока данные ещё в кэше.


Обход латентности доступа к данным (применимо и к чтению/записи данных процессором
из памяти и к работе с диском):


Чтение/запись данных последовательно, а не в случайном порядке. Пример: внешняя сортировка
позволяет обойтись эффективными операциями чтения/записи больших последовательных блоков
данных. Если реализовать quicksort с хранением данных на диске, то при объёме данных
> объёма кэша потребуется O(n * log n) движений головки диска. 
Буферизация ввода/вывода. Пример: всё та же внешняя сортировка. Одна из операций
там – слияние данных из нескольких файлов с диска и запись результата на диск. Это
будет работать гораздо быстрее, если читать данные из каждого входного файла порциями,
скажем, по 10 МБ и писать результаты в буфер, который сбрасывать на диск тоже по мере
накопления.
Группировка нужных данных. Пример: параллельные массивы. Допустим, у нас есть большой
массив точек на плоскости и описание к каждой из них длиной 50 символов. Тогда, например,
выбор точек, попадающих в некоторый прямоугольник (допустим, нам невыгодно применять
что-то более сложное, чем простой проход по массиву) будет работать существенно быстрее,
если разбить информацию на два массива:

struct THeader {  int x, y;  };
struct TInfo {  char description[50];  };
THeader dotHeaders[10000000], dotInfos[10000000];

Другой похожий пример (вопрос Примеры оптимизации путём группировки данных в памяти).


Оптимальное использование имеющейся пропускной способности:


Сжатие данных «на лету». Пример: объёмная база данных с некоторыми  закономерностями
в данных (между полями записей или между соседними записями). Сжатие данных перед записью
на диск и расжатие при чтении может позволить достичь большей скорости записи/чтения
с диска.
Использование специальных команд процессора, содержащих дополнительные "подсказки"
по работе с памятью. Пример: intrinsic'и _mm_stream_ps и т.п. в примере http://pastebin.com/Xzq9dYww
@superhackkiller1997'a. _mm_stream_ps компилируется в команду MOVNTPS - аналог memset,
работающий в обход кэша.


Обход «неудобных» шаблонов доступа к данным:


Выравнивание данных по границам, кратным 4, 8 и более байт. Обычно делается компилятором
именно потому, что на многих процессорах доступ, например, к 4-байтному целому в памяти
по адресу, не кратному 4, намного медленнее.


Использование нескольких имеющихся устройств одновременно (несколько ядер процессора/несколько
вычислительных устройств в ядре процессора/процессор и диск):


Многопоточная обработка. Эта тема слишком обширная чтобы пытаться охватить её здесь.
Использование специальных наборов команд процессора (MMX, SSE, …).
Расчёты на графических картах.
Обработка данных в одном потоке, буферизация и сохранение в другом потоке.


Обход латентности конвейера процессора:


Уменьшение количества условных переходов в принципе.
В частности – путём разворачивания циклов. Пример: для несложной обработки большого
массива данных 


вместо

for (int i = 0; i < n; i++) 
  process(a[i]);


может быть выгодно написать

int i, stopI = n - 4;
for (i = 0; i <= stopI; i += 4) {
  process(a[i]);
  process(a[i + 1]);
  process(a[i + 2]);
  process(a[i + 3]);
}
for ( ; i < n; i++) 
  process(a[i]);


(естественно, очень желательно, чтобы функции process была inline или просто одним
оператором)


Переупорядочивание операций. Подходит и для уменьшения зависимостей по данным и для
загрузки большего количества вычислительных устройств.
Совмещение итераций цикла. Аналогично предыдущему пункту – подходит для разных случаев.
Пример (тут тоже можно придумать что-нибудь получше): для обработки большого массива
данных 


вместо

for (int i = 0; i < n; i++) {
  b[i].x += deltaX;
  if (b[i].x < 0)
    b[i].x += 100;
  b[i].y *= b[i].x * y2;
}


-

int i, stopI = n - 2;
for (i = 0; i <= stopI; i += 2) {
  b[i].x += deltaX;
  if (b[i].x < 0)
    b[i].x += 100;
  b[i + 1].x += deltaX;   // Пока  b[i].y *= b[i].x * y2; ждёт выполнения процессором
условия и, возможно, += 100, мы начинаем обрабатывать следующий элемент
  if (b[i + 1].x < 0)
    b[i + 1].x += 100;
  b[i].y *= b[i].x * y2;
  b[i + 1].y *= b[i + 1].x * y2;
}
for ( ; i < n; i++) 
  …

    


Ответы

Ответ 1



Хорошая идея! Подождём ответов от специалистов по оптимизациям. Внесу свои 5 копеек: битовые трюки. Многие операции имеют неожиданно простую и эффективную реализацию с учётом особенностей двоичной записи чисел. Вот популярный сборник. Пример: для положительного числа v выражение (v & (v - 1)) == 0 определяет, является ли v степенью двойки. Ещё одним классическим трюком на грани между алгоритмическими и неалгоритмическими является быстрое вычисление 1/sqrt(x) из Quake.

Ответ 2



Внесу свои пять копеек. В многопоточных программах могут возникнуть проблемы с производительностью, когда потоки интенсивно выделяют/освобождают память. Это происходит из-за того, что стандартный malloc не умеет работать параллельно и просто делает lock на каждое выделение. С этим можно бороться по-разному, например выделяя большой кусок памяти для каждого потока, и используя его как пул. Но самый простой способ, это применить библиотеку tcmalloc, которая входит в gperttools. Самое приятное, что для ее использования ничего не надо менять в исходниках, нужно просто добавить флаг -ltcmalloc при линковке. На моей памяти простое добавление tcmalloc увеличивало производительность на 20-30% в одном проекте, связанном с алгоритмами на графе. UPD: Наваял демонстрацию: $ cat test.cc #include #include #include int main() { const int THREADS = 2; const int ITERATIONS = 50000000; std::vector threads; std::vector arr(THREADS, 0); for (int t = 0; t < THREADS; ++t) { threads.emplace_back([t, &arr]() { for (int i = 0; i < ITERATIONS; ++i) { std::unique_ptr ptr1(new int(i)); std::unique_ptr ptr2(new double(i)); arr[t] += *ptr1 + *ptr2; arr[t] %= ITERATIONS; } }); } for (std::thread& thread: threads) { thread.join(); } int sum = 0; for (int s: arr) { sum = (sum + s) % ITERATIONS; } return sum; } $ g++-4.7 --std=c++11 test.cc -O2 -pthread $ time ./a.out real 0m16.127s user 0m30.602s sys 0m0.172s $ g++-4.7 --std=c++11 test.cc -O2 -pthread -ltcmalloc $ time ./a.out real 0m8.743s user 0m16.417s sys 0m0.056s Как видно, даже на 2-х тредах tcmalloc ускорил программу почти в 2 раза.

Ответ 3



Вот такой примитвный. Просто транслирую с -DQUAKE и запускаю через time. avp@avp-xub11:~/src/codegoogle/smhasher$ grep CPU /proc/cpuinfo model name : Pentium(R) Dual-Core CPU E5400 @ 2.70GHz avp@avp-xub11:~/src/codegoogle/smhasher$ #include #include #include //float InvSqrt (float x); static inline float InvSqrt (float x){ float xhalf = 0.5f*x; int i = *(int*)&x; i = 0x5f3759df - (i>>1); x = *(float*)&i; x = x*(1.5f - xhalf*x*x); return x; } #if QUAKE #define WHO "QUAKE" #else #define WHO "sqrt() -lm " #endif int main() { int i; float x = 1.0; for (i = 0; i < 1000000000; i++) #if QUAKE x += InvSqrt(x); #else x += 1 / sqrt(x); #endif printf ("%s: x = %f\n", WHO, x); }

Ответ 4



К сожалению, у нас начинается флейм оптимизаторов-теоретиков и оптимизаторов-практиков (здесь и в вопросе-примере). Но т.к. я вспомнил практический пример в тему, я его всё-таки приведу :) . К вопросу о том, что всё, что не даёт лучшей оценки (O(n*log n) вместо O(n^2) и т.п.)- это баловство. Я тоже как-то, например, написал для каждой вставки в базу дополнительный select max(id)+1 ... Авто-инкремент там нельзя было сделать т.к. несколько процессов заливали данные в одну таблицу, но каждый - в своём диапазоне id, чтобы можно было по этим id в каждом диапазоне находить новые данные. Но суть не в этом. Суть в том, что когда код заработал, производительность была довольно грустной. Точно не помню, приведу очень примерные цифры ради отображения соотношений величин. Допустим, записывалось 50 элементов данных в секунду. При этом в процессе работы всех загрузчиков новых данных поступает 10 в секунду. Но когда загрузчики перезапускаются, им в сумме нужно загрузить 20000 элементов. Т.е. запас по прочности вроде как в 5 раз, но при рестарте надо ждать 6 минут. А код надо ещё тестировать и отлаживать. Было понятно, что select max(id)+1 это хрень, но избавление от него обещало прирост ну процентов 20 (что такого, быстренько в индексе пробежаться, тем более, он и так в памяти). И я точно помню, что мне казалось, что смысла нет. Тем не менее, после того, как мы немного подумали и я поправил эту и несколько подобных мелких проблем, производительность возросла раза в 3. И просто работать стало намного приятней! Не говоря о том, что я сэкономил себе много часов ожидания на имеющихся машинах (вполне нормальных по тем временам). Т.е. можно было ходить и плакаться, что мне надо машину на 1000$ дороже и сервер тоже, а можно было подумать-поработать пару дней. Upd: нашёл в одном вопросе (Книги по теме Concurrency и Parallel Programming) замечательный сборник статей. И в частности: приём переделки бинарных деревьев с тем, чтобы при спуске по дереву приходилось читать из памяти менее разбросанные данные: 1024CORES / RAM - не RAM, или Cache-Conscious Data Structures. Читается легко, всем рекомендую :)

Комментариев нет:

Отправить комментарий