
Недавно в процессе выполнения учебного задания мне потребовалось реализовать метод конечных разностей для нахождения приближённого решения краевой задачи. По сути, я впервые столкнулся с вычислениями с плавающей точкой и не мог не попробовать запустить свою программу на Эльбрусе, зная о его больших возможностях и заточенности под вычисления такого рода. Хотите удивиться? Отправляйтесь со мной в увлекательное путешествие!
Вкратце о математике
Для понимания происходящего математическая постановка задачи не является принципиально важной, так что её подробный разбор я позволю себе пропустить и ограничусь коротким описанием.
Итак, рассматривается дифференциальное уравнение Пуассона в прямоугольной области [0; 4] x [0; 3]:
Для данного уравнения рассматривается краевая задача, причём на каждом отрезке границы заданы граничные условия третьего типа:
где — единичная внешняя нормаль к границе.
Решение будем искать с помощью разностной схемы, которая может быть представлена в виде системы линейных алгебраических уравнений
. Вычисления представляют из себя итерационный метод, на каждом шаге
вычисляется новое приближение сеточной функции
, где невязка
, а итерационный параметр
Для проверки условия остановки вычисляется норма разности между новым и старым приближением. За более подробным описанием отправляю читателя к профильной литературе, например, книге А.А. Самарского [1].
В результате основные вычисления на каждом шаге представляют из себя несколько запусков пары вложенных циклов с классической характерной структурой: несколько чтений данных из матриц, арифметические операции и запись в выходную матрицу и/или накопление результата в промежуточной переменной. С производительностью исполнения этих циклов мы и будем разбираться. Вот так выглядит один из них:
for (int i = start_i; i <= stop_i; ++i) { for (int j = start_j; j <= stop_j; ++j) { int idx = i * (run_config->domain_n + 2) + j; double wij = src[idx]; double wipj = src[(i + 1) * (run_config->domain_n + 2) + j]; double wimj = src[(i - 1) * (run_config->domain_n + 2) + j]; double wijp = src[i * (run_config->domain_n + 2) + j + 1]; double wijm = src[i * (run_config->domain_n + 2) + j - 1]; double x = (run_config->start_i + i - 1) * run_config->h1; double y = (run_config->start_j + j - 1) * run_config->h2; double laplacian = (wipj + wimj - 2 * wij) * run_config->sqinv_h1 + (wijp + wijm - 2 * wij) * run_config->sqinv_h2; dst[idx] = q(x, y) * wij - laplacian - alpha * F(x, y); } }
Стоит отметить, что постановка учебной задачи подразумевает реализацию параллельной MPI+OpenMP программы, а также (при желании) дополнение программы MPI+CUDA реализацией. Необходимость разбивки области вычислений на домены вносит небольшую специфику в организацию программы, которую я решил здесь сохранить, несмотря на то, что статья посвящена только оптимизации последовательных вычислений и не касается вопросов распараллеливания на несколько процессов.
Дедлайн был вчера: самая обычная программа
За основу для исследований возьмём реализацию, которую легко может написать студент для выполнения учебной задачи: она не самая шустрая, но укладывается в ограничение по времени работы и удовлетворяет требуемым свойствам. Примерно такой же вид будет у программы, которую писали без оглядки на производительность вычислений, ведь сложно думать об оптимизациях, когда время поджимает, а размеры данных в задаче не велики или её надо запустить всего лишь раз. Признаюсь, я сразу писал не так и мне пришлось изрядно подпортить свою программу, чтобы продемонстрировать распространённые проблемы.
Полная реализация лежит на github [3]. Основные шаги, проделанные в статье, можно отследить по истории коммитов. Например, вот начальная версия.
Для тестовых замеров будем смотреть на время выполнения 1000 шагов итерационного метода на одном ядре процессора на сетке размером 1000×1000. За эффектом от оптимизаций будем следить на трёх платформах:
-
«домашний компьютер» — Intel Core i7-9700K @ 4.8 ГГц и компилятор icc (Intel C++ Compiler Classic) 2021.5.0, базовые флаги оптимизации «-O3 -no-prec-div -ansi-alias -xHost -restrict»;
-
«целевой вычислительный кластер» — IBM POWER8 @ 4.0 ГГц и компилятор IBM XL C/C++ 13.1.6, базовые флаги оптимизации «-O5» (именно на таких процессорах требовалось запускать задачу; с удовольствием бы взял более новую систему, но доступа к свежим процессорам IBM у меня нет);
-
«главный герой» — Эльбрус-8СВ @ 1.55 ГГц и компилятор lcc 1.25.19, базовые флаги оптимизации «-O4 -ffast -ffast-math -mtune=elbrus-8c2».
Итак, для тестов на Эльбрусе мы взяли наиболее производительный на данный момент процессор (инженерные образцы 6 поколения архитектуры не рассматриваем). У данного процессора есть аппаратная поддержка циклов, векторные регистры, возможности программной конвейеризации циклов, механизм подкачки массивов (APB) и вообще 6 «двухэтажных» АЛУ для вычислений с плавающей точкой, а мы взяли достаточно свежий компилятор и хорошие опции оптимизации. Несмотря на то, что для векторизации требуется соблюдение выравнивания данных, мы надеемся получить хороший результат.
Смотрим на результаты запуска и немного огорчаемся: Эльбрус отстаёт в 5-16 раз от Intel и IBM, впрочем, последние тоже не радуют скоростью. Здесь можно отправиться читать Руководство по эффективному программированию на платформе «Эльбрус» [2], а я просто воспользуюсь опытом, полученным при реализации криптографических примитивов.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№0 — базовая |
14.1 с |
42.9 с |
223.6 с |
И всё-таки линейкой по пальцам: подстановка «горячих» функций компилятором
Об этом просто невозможно промолчать: нельзя вызывать простые функции в циклах из других единиц трансляции. Надеюсь, дорогой читатель никогда так не делает, если хочет производительности от своей программы. Да, речь сейчас о q(x, y) и F(x, y), если мы решили вычислять их явно на каждой итерации. Не рассматривая случай какой-нибудь универсальности, эти функции должны быть определены строго в том же файле, где вызываются, чтобы компилятор имел возможность подставить их (inlining) в тело цикла при обычной компиляции (без Link Time Optimization, межпроцедурного анализа в масштабах всей программы или режима -fwhole на Эльбрусе).
Давайте просто посмотрим, как изменится время выполнения программы, если перенести определения функций q(x, y) и F(x, y) из отдельного файла (куда они могли попасть из соображений удобства) поближе к месту их вызова.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№0 — базовая |
14.1 с |
42.9 с |
223.6 с |
|
№1 — возможен inline |
4.8 c |
26.2 c |
102.0 c |
Примечательно, что на Эльбрусе, который имеет фиксированные и относительно большие штрафы за вызов функций, ускорение составляет 2.2 раза, а на Intel и IBM — 2.9 и 1.5 раза, соответственно. Так что на предсказатель переходов и внеочередное исполнение надейся, а сам не плошай.
Удар ниже пояса: правильный тип для счётчика цикла
Так вышло, что использовать сейчас в качестве счётчика цикла на Эльбрусе любой тип, существенно отличающийся от long — крайне неудачная идея. Связано это с тем, что компилятору необходимо следовать стандарту языка Си, а также с рядом особенностей в реализации компилятора.
В Руководстве нет подробных комментариев, только сказано, что рекомендуется использовать тип long (знаковый). Наверное, идеологически более правильно было бы рекомендовать ssize_t, но тут надо уточнить наличие определения ssize_t на других платформах. Да и с точки зрения компилятора конкретно на Эльбрусе отличий нет: это всё знаковый 64-битный тип.
Что ж, заменяем все границы и счётчики на ssize_t (определение через my_int для удобства экспериментов) и получаем ускорение в 5.4 раза: на 1000 итераций теперь требуется 18.8 секунд вместо 102. В зависимости от ряда факторов, ускорение от подобной замены может быть как почти незаметным, так и более весомым.
Так чем плохи другие типы? Для успешного применения механизма подкачки массивов APB необходимо гарантировать линейность изменения адресов элементов массива в цикле. К сожалению, это сразу отрезает возможность эффективного использования беззнакового типа: в нём допустимо переполнение, а значит на этапе компиляции невозможно гарантировать условие i + 1 > i. Так что привычный многим беззнаковый size_t отпадает. Хотя конкретно в данном примере size_t немного выигрывает, при дальнейших оптимизациях, как правило, проявляется небольшая просадка времени выполнения на беззнаковом типе. Впрочем, разница не так велика и можно продолжать спокойно использовать size_t.
А у меня всё в int, что я делаю не так? Это пример ситуации, когда формально проблемы нет, но есть некоторые сложности в реализации компилятора. Оптимизации применяются на разных стадиях работы компилятора и не всегда успешно согласуются друг с другом. В нашем случае компилятор на более раннем этапе видит цикл и расширяет счётчик до естественного для процессора 64-битного типа. Затем при попытке применить APB внутри цикла происходит вычисление смещения с использованием другой переменной типа int — run_config->domain_n. Получается несогласованность типов переменных, необходимо преобразование, что и приводит к вопиющему снижению производительности. Интересно, что можно вынести из внутреннего цикла чтение domain_n в локальную int переменную и тоже получить ускорение, так как в этом случае расширение переменной до 64 бит происходит в более ранней фазе компиляции.
Просадка производительности с типом int на этом примере была признана недоработкой компилятора, так что в светлом будущем такого сюрприза не будет. Пока же можно дать простую рекомендацию: при возможности, основные переменные лучше дублировать в виде локальных — это даёт компилятору больше информации о независимости данных и простор для оптимизаций.
Наконец, таблица с замерами этого раздела. Хотя на Intel и IBM лучший результат показывает тип int, мы остановимся на ssize_t, чтобы не плодить платформозависимых изменений.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№1 |
4.8 c |
26.2 c |
102.0 c |
|
№1 + локальные int переменные |
4.8 c |
26.1 c |
24.0 с |
|
№1 + size_t счётчики |
5.3 с |
29.2 с |
18.4 с |
|
№2 — ssize_t счётчики |
5.3 с |
27.9 с |
18.8 с |
Галопом по Европам: несколько алгоритмических оптимизаций
Этот раздел посвящён традиционным оптимизациям, которые приходят с опытом и обычно дают положительный эффект.
Во-первых, это предвычисления значений функций q(x, y) и F(x, y). На протяжении всего итерационного процесса они не меняются, так что нет смысла тратить время на лишнюю арифметику, тем более, эти функции на практике могут быть более сложными, чем в рассматриваемом примере. Вообще говоря, может оказаться, что считать эти функции будет быстрее, чем загружать значения из памяти, но я не уверен, что такое поведение сохранится после применения дальнейших оптимизаций.
Во-вторых, это уменьшение количества проходов по массиву. В данном случае очевидно, что при вычислении итерационного параметра можно совместить вычисление скалярного произведения для числителя и нормы для знаменателя. Чуть менее заметно второе место: вычисление текущего значения итерационной ошибки можно делать вместе с обновлением сеточной функции. Заметим, что полагаться на компилятор здесь не получается, так как циклы попадают в разные функции, вызываемые из другой единицы трансляции.
В-третьих, это обмен указателей вместо копирования матрицы для перехода к новой итерации. Копирование матрицы вместо обмена указателей увеличивает время работы программы на всех рассматриваемых платформах на 3-8%.
В сумме указанные оптимизации дают ускорение на всех платформах от нескольких процентов до нескольких разов. Заметно выбивается Intel, на котором проявляется замедление при переходе к предвычислениям функций. Это место неплохо бы происследовать, но мы пока удовлетворимся хорошим ускорением на Эльбрусе и IBM.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№2 |
5.3 с |
27.9 с |
18.8 с |
|
+ предвычисления q, F |
5.98 с |
10.72 с |
14.23 с |
|
+ меньше проходов по массиву |
5.32 с |
8.88 с |
12.35 с |
|
+ отказ от memcpy — №3 |
4.93 с |
8.42 с |
12.03 с |
Панки грязи не боятся: читаем на языке ассемблера
Разобраться совсем не так сложно, как может показаться на первый взгляд. Работает универсальный приём: просто берём строчку компиляции нужного файла и заменяем флаг -c на -S, чтобы получить файл с ассемблерным кодом. Не забываем добавить -fverbose-asm, эта опция значительно упрощает чтение на языке ассемблера: для каждой инструкции строится привязка к строке исходного кода, а также включается статический подсчёт тактов (позволяет точно оценить время работы при отсутствии блокировок во время исполнения). Дальше дело за малым: выбираем строчку внутри интересующего нас цикла и находим её в ассемблерном коде по номеру.
Возьмём, например, последний вложенный цикл (из функции update_w_calc_partial_error). Так как компилятор делает ряд оптимизаций по конвейеризации циклов, расщеплению по условиям и создаёт участки компенсирующего кода, то нас прежде всего интересуют вхождения нашей строки в широкие команды (ШК) с меткой loop_mode. Таких оказывается 2, второй выглядит получше, но начнём с первого:
.L53534:! solve_cpu.c : 303 ! <0073> { loop_mode rbranch.L63957 ldd,0,sm%dr4, %db[3], %db[22], mas=0x4! solve_cpu.c : 305 fmuld,1,sm%dr1, %db[26], %db[27]! solve_cpu.c : 305 ldd,2%dr4, %db[17], %db[36], mas=0x3 ? %pcnt0 addd,3,sm0x8, %db[3], %db[1]! solve_cpu.c : 303 fsubd,4,sm%db[21], %db[33], %db[38]! solve_cpu.c : 306 ldd,5%dr3, %db[17], %db[25], mas=0x3 ? %pcnt0 } .L63965: ! <0074> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 303 abnabnf=1, abnt=1! solve_cpu.c : 303 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 303 fmuld,1,sm%dr0, %db[30], %db[39]! solve_cpu.c : 307 ldd,3,sm%dr3, %db[9], %db[17], mas=0x4 ? %pcnt4! solve_cpu.c : 306 fmul_addd,4,sm%db[45], %db[37], %db[20], %db[12]! solve_cpu.c : 307 staad,5%db[42], %aad0[ %aasti1 ] incr,5%aaincr0! solve_cpu.c : 306 }
Видим, что основная часть тела цикла вроде как занимает всего 2 такта, но постойте-ка: инструкция rbranch делает безусловный переход на метку .L63957, где мы видим ещё минимум 11 тактов:
.L63957: ! <0790> { nop 3 fmuld,0,sm%dr1, %db[36], %db[37]! solve_cpu.c : 305 fmuld,1,sm%dr0, %db[36], %db[45]! solve_cpu.c : 307 } ! <0794> { nop 5 fsubd,0,sm%db[25], %db[37], %db[42]! solve_cpu.c : 306 } ! <0800> { ibranch.L63965! solve_cpu.c : 307 }
В этом отдельном куске только арифметика с выдерживанием задержек от инструкций, так что вернёмся к анализу первой части. Замечаем, что загрузки из памяти делаются инструкцией ldd с тегами mas=0x4 и mas=0x3. Ага, замечательно, это у нас применился динамический механизм разрыва зависимостей с использованием аппаратной таблицы DAM (Memory Access Disambiguator). Секундочку…
Соблюдаем социальную дистанцию: разрыв зависимостей
При реализации подобных численных методов (да и вообще при решении многих других задач) обычно удобно соблюдать простое правило: участки памяти по разным указателям не пересекаются. В действительности, при написании функций я уже держал эту информацию в голове и полагался на независимость указателей. Только вот почему бы не рассказать об этом компилятору, раз мы обладаем такой информацией?
О необходимости разрыва зависимостей очень легко забыть, но нельзя недооценивать важность этой информации для эффективной оптимизации. В случае суперскалярных процессоров эффект может быть заметен не всегда, но он тоже присутствует, как будет видно из замеров. Пожалуй, основное проблемное место — это возможности автовекторизации. Действительно, как можно считать одновременно 2 соседние итерации цикла с помощью векторных регистров, если нет уверенности, что результат следующей итерации не зависит от записи на текущей?
В случае Эльбруса проявляется ещё и другая особенность: из-за статического планирования без информации о независимости итераций мы не можем начать исполнять следующую итерацию до окончания текущей. Это сразу лишает нас важнейшего способа быстрого исполнения циклов в условиях статического планирования — программной конвейеризации цикла.
Так что же делать? Начать можно с простого пути: меньше всего компилятор знает об указателях, которые приходят в качестве параметров функции, поэтому мы можем воспользоваться ключом -frestrict-params, говоря компилятору: «считай, что на всех указателях, являющихся параметрами функций, есть модификатор restrict». Только вот наш случай сложнее: часть указателей для удобства упакована в структуру и их разыменование выполняется в 2 шага (сначала читаем указатель из структуры, потом уже по нему обращаемся к массиву). В результате одного restrict на «внешнем» указателе уже недостаточно. Есть более более мощная комбинация флагов -frestrict-all -frestrict-unsafe, но её всерьёз рассматривать не будем.
В lcc 1.25 впервые появилась поддержка прагмы ivdep, предназначенной для игнорирования возможных зависимостей в цикле. Можно попробовать воспользоваться ею, но я предпочитаю вариант с копированием нужных указателей и явным добавлением модификатора restrict. На мой взгляд, такой вариант самый прозрачный и даже немного упрощает код программы. Вот так, например, будет выглядеть начало одной из 3 подправленных функций:
double update_w_calc_partial_error(const struct RunConfig *run_config, double tau) { double * restrict cur_w = run_config->cur_w; double * restrict next_w = run_config->next_w; double * restrict residual = run_config->residual; // <...> }
В результате небольшой модификации мы добились ускорения на 33-80% на всех платформах. Подчеркну, что наша оптимизация является общей, а не специфической для Эльбруса. Более того, наиболее выраженный эффект от её применения наблюдается на IBM.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№3 |
4.93 с |
8.42 с |
12.03 с |
|
№4 — используем restrict |
3.69 с |
4.69 с |
7.48 с |
И проверим, как же теперь выглядит исследуемый цикл на языке ассемблера. Отлично, все вхождения цикла стали компактными, раскрученными на 2 и спланированными в 4 такта на тело (то есть 2 такта на итерацию):
Цикл на языке ассемблера
.L38838:! solve_cpu.c : 312 ! <0237> { loop_mode fmuld,5,sm%dr7, %db[11], %db[15]! solve_cpu.c : 314 movad,0area=0, ind=0, am=0, be=0, %db[0]! solve_cpu.c : 315 movad,1area=0, ind=8, am=1, be=0, %db[1]! solve_cpu.c : 315 movad,3area=0, ind=0, am=0, be=0, %db[8]! solve_cpu.c : 314 } ! <0238> { loop_mode fmuld,3,sm%dr0, %db[11], %db[20]! solve_cpu.c : 316 fmuld,4,sm%dr7, %db[12], %db[16]! solve_cpu.c : 314 fmuld,5,sm%dr0, %db[12], %db[21]! solve_cpu.c : 316 } ! <0239> { loop_mode fmuld,3,sm%db[22], %db[17], %db[11]! solve_cpu.c : 316 fmuld,4,sm%db[23], %db[18], %db[24]! solve_cpu.c : 316 fsubd,5,sm%db[7], %db[17], %db[12]! solve_cpu.c : 315 } ! <0240> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 312 abnabnf=1, abnt=1! solve_cpu.c : 312 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 312 fsubd,1,sm%db[6], %db[18], %db[17]! solve_cpu.c : 315 staad,2%db[19], %aad2[ %aasti1 ]! solve_cpu.c : 315 faddd,3,sm%dr3, %db[13], %dr3! solve_cpu.c : 316 faddd,4,sm%dr1, %db[26], %dr1! solve_cpu.c : 316 staad,5%db[14], %aad2[ %aasti1 + _f32s,_lts0 0x8 ] incr,5%aaincr2! solve_cpu.c : 315 movad,3area=0, ind=8, am=1, be=0, %db[7]! solve_cpu.c : 314 }
От работы кони дохнут: вынос инварианта из цикла
Обратимся теперь к циклу из функции calc_tau_part:
for (my_int j = 2; j <= run_config->domain_n - 1; ++j) { my_int idx = i * (run_config->domain_n + 2) + j; num += rhox * a_residual[idx] * residual[idx]; div += rhox * a_residual[idx] * a_residual[idx]; }
Легко видеть, что здесь есть 2 умножения на величину rhox, значение которой не меняется в цикле. Её можно вынести за пределы цикла, так как мы изначально допускаем нарушение порядка арифметических операций. Конечно, это всего лишь одно умножение, но важна сама идея, так как в других примерах легко можно встретить вычисление в цикле, скажем, синуса от постоянной величины. Примечательно, что в документации к IBM XLC говорится о том, что компилятор умеет сам делать вынос инварианта. Что ж, замеры показывают, что надёжнее это сделать руками:
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№4 |
3.69 с |
4.69 с |
7.48 с |
|
№5 — вынос инварианта |
3.67 с |
4.37 с |
7.08 с |
Эффект на Эльбрусе хорошо иллюстрируется: теперь цикл спланирован в 1 такт без раскрутки вместо 4 тактов при раскрутке на 2.
Для любителей ассемблера
Было:
.L19038:! solve_cpu.c : 270 ! <0259> { loop_mode movad,2area=0, ind=8, am=1, be=0, %db[1]! solve_cpu.c : 272 movad,3area=0, ind=0, am=0, be=0, %db[0]! solve_cpu.c : 272 } ! <0260> { loop_mode fmuld,4,sm%dr5, %db[3], %db[15]! solve_cpu.c : 272 fmuld,5,sm%dr5, %db[2], %db[12]! solve_cpu.c : 272 movad,0area=0, ind=8, am=1, be=0, %db[6]! solve_cpu.c : 272 movad,1area=0, ind=0, am=0, be=0, %db[9]! solve_cpu.c : 272 } ! <0261> { loop_mode fmuld,3,sm%db[17], %db[10], %db[19]! solve_cpu.c : 272 fmuld,4,sm%db[17], %db[5], %db[16]! solve_cpu.c : 273 fmuld,5,sm%db[14], %db[13], %db[20]! solve_cpu.c : 272 } ! <0262> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 270 abnabnf=1, abnt=1! solve_cpu.c : 270 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 270 fmuld,1,sm%db[14], %db[4], %db[5]! solve_cpu.c : 273 faddd,2,sm%dr0, %db[7], %dr0! solve_cpu.c : 273 faddd,3,sm%dr1, %db[21], %dr1! solve_cpu.c : 272 faddd,4,sm%dr3, %db[18], %dr3! solve_cpu.c : 273 faddd,5,sm%dr2, %db[22], %dr2! solve_cpu.c : 272 }
Стало:
.L18685:! solve_cpu.c : 272 ! <0083> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 272 abnabnf=1, abnt=1! solve_cpu.c : 272 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 272 fmuld,1,sm%db[37], %db[36], %db[39]! solve_cpu.c : 274 faddd,2,sm%db[25], %db[47], %db[17]! solve_cpu.c : 274 fmuld,4,sm%db[37], %db[37], %db[38]! solve_cpu.c : 275 faddd,5,sm%db[24], %db[46], %db[16]! solve_cpu.c : 275 movad,1area=0, ind=0, am=1, be=0, %db[26]! solve_cpu.c : 274 movad,3area=0, ind=0, am=1, be=0, %db[27]! solve_cpu.c : 274 }
Ну, кошечка, ну ещё капельку: добиваемся векторизации
Существенное ограничение на Эльбрус-8СВ при работы с векторными регистрами — данные должны быть выровнены (по 16-байтной границе). Без этого компилятор не сможет сгенерировать код с использованием 128-битных регистров. А вот как получить векторный код при соблюдении необходимых требований — «а вот это науке ещё не известно!» Точнее, рецепт примерно стандартный: в текущей версии компилятору нужна информация о количестве итераций цикла, чтобы «решиться» на векторизацию. Эта информация может быть получена из подсказки с помощью прагмы loop count(N) или из профиля исполнения программы. Тем не менее, я пока не выработал для себя удобного способа работы, так что покажу способ для продвинутых: как с помощью intrinsic-функций гарантированно получить векторный код.
Основные усилия мы направим на цикл в функции calc_aw_b. Его особенность в том, что на каждой итерации нужны 3 подряд идущих элемента массива. Соответственно, выровненный доступ к памяти здесь просто так невозможен. Для обхода этой особенности на итерации с номером j будем формировать регистр, содержащий сдвинутое на 1 значение (как бы src[j + 1] элемент), с помощью ассемблерной инструкции qppermb из считанных выровненных 128-битных значений src[j] и src[j + 2]. То есть из src[j] мы берём старшую половину, а из src[j + 2] — младшую.
В остальном достаточно заменить обычные арифметические операции на вызов соответствующих intrinsic-функций и получится цикл такого вида:
for (my_int j = start_j; j < stop_j; j += 2) { my_int idx = i * (run_config->domain_n + 2) + j; __v2di wij = *((__v2di *) &src[idx]); __v2di wipj = *((__v2di *) &src[(i + 1) * (run_config->domain_n + 2) + j]); __v2di wimj = *((__v2di *) &src[(i - 1) * (run_config->domain_n + 2) + j]); src_0 = wij; src_1 = *((__v2di *) &src[idx + 2]); __v2di wijp = __builtin_e2k_qppermb(src_1, src_0, mix_doubles); __v2di t0 = __builtin_e2k_qpfaddd(wipj, wimj); __v2di t1 = __builtin_e2k_qpfmuld(const_2, wij); __v2di t2 = __builtin_e2k_qpfaddd(wijp, wijm); __v2di t3 = __builtin_e2k_qpfsubd(t0, t1); __v2di t4 = __builtin_e2k_qpfsubd(t2, t1); __v2di t5 = __builtin_e2k_qpfmuld(t3, sqinv_h1); __v2di t6 = __builtin_e2k_qpfmuld(t4, sqinv_h2); __v2di laplacian = __builtin_e2k_qpfaddd(t5, t6); __v2di t7 = __builtin_e2k_qpfmuld(wij, *((__v2di *) &q_mat[idx])); __v2di t8 = __builtin_e2k_qpfmuld(v2alpha, *((__v2di *) &b_mat[idx])); *((__v2di *) &dst[idx]) = __builtin_e2k_qpfsubd(__builtin_e2k_qpfsubd(t7, laplacian), t8); wijm = wijp; }
Выглядит уже довольно громоздко, а это ещё не видна обработка головы и хвоста цикла. Зато компилируется в красивую конструкцию с итоговым темпом обработки 1 такт на элемент матрицы.
.L15436:! solve_cpu.c : 234 ! <0247> { loop_mode qpfadd_rsubd,0,sm%xb[38], %xb[40], %xb[95], %xb[40]! solve_cpu.c : 248 qpfadd_rsubd,1,sm%xb[82], %xb[92], %xb[97], %xb[1]! solve_cpu.c : 247 qpfmuld,2,sm%xb[48], %xr5, %xb[56]! solve_cpu.c : 250 qpfmul_addd,4,sm%xb[13], %xr6, %xb[62], %xb[48]! solve_cpu.c : 251 qpfmul_rsubd,5,sm%xb[32], %xb[89], %xb[56], %xb[13]! solve_cpu.c : 254 movaqp,1area=1, ind=0, am=1, be=0, %xb[0]! solve_cpu.c : 242 } ! <0248> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 234 abnabnf=1, abnt=1! solve_cpu.c : 234 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 234 qppermb,1,sm%xb[6], %xb[8], %xr0, %xb[36] ? %pcnt21! solve_cpu.c : 242 qpfmuld,2,sm%xr2, %xb[4], %xb[89]! solve_cpu.c : 245 qpfmul_subd,4,sm%xr7, %xb[59], %xb[21], %xb[62]! solve_cpu.c : 254 staaqp,5%xb[72], %aad5[ %aasti1 ] incr,5%aaincr0! solve_cpu.c : 254 movaqp,0area=0, ind=0, am=1, be=0, %xb[21]! solve_cpu.c : 253 movaqp,1area=2, ind=0, am=1, be=0, %xb[72]! solve_cpu.c : 237 movaqp,2area=0, ind=0, am=1, be=0, %xb[59]! solve_cpu.c : 252 movaqp,3area=1, ind=0, am=1, be=0, %xb[82]! solve_cpu.c : 238 }
Здесь хорошо видно, что компилятор успешно объединяет последовательные инструкции в сдвоенные — это позволяет более плотно планировать их исполнение (без «второго этажа» 6 АЛУ не хватило бы, чтобы спланировать цикл в 2 такта на тело). И работает немного быстрее: удаётся улучшить результат на 14% и достичь 6.19 секунд (в таблицах этот шаг будет обозначен как «№6 — векторизация»). Заметим, что на рассматриваемых процессорах Intel и IBM нет требований к выравниванию данных, а потому компиляторы ещё на предыдущих этапах смогли справиться с векторизацией всех циклов.
Ломаем законы физики: линейная обработка против объёма вычислений
Продолжая тему неочевидных оптимизаций, можно вспомнить, что векторизация циклов даётся не бесплатно. Для Эльбруса мы сделали явную обработку начала и конца цикла из-за выравнивания. В случае двух других архитектур ситуация немного проще, но обработку хвоста цикла никто не отменял, хоть ответственность за генерацию соответствующих инструкций и легла на компилятор. На Эльбрусе есть ещё один нюанс: подготовка APB занимает некоторое количество тактов перед циклом.
Так зачем мы всё это делаем во внешнем цикле, если по факту двумерный массив представлен в памяти линейным участком? Заменяем двойной цикл (с количеством итераций не больше M для внешнего и N для вложенного) на одинарный с общим количеством итераций MN + 2M — 2 и поднимаем его в начало функции. Граничные точки будут перезаписаны ниже при обработке границ. Да, мы в этом цикле делаем больше вычислений, чем было, и даже что-то считаем для точек, которые нам вообще не нужны (используются при межпроцессных синхронизациях), но избавление от вложенного цикла даёт выигрыш. Напрашивается аналогия с работой кэша: при переходе к линейному доступу к памяти можно делать больше операций, но выигрыш всё равно останется.
После перехода к одному большому циклу на Эльбрусе можно проверить поведение программы при выборе параметра unroll для этого цикла. Обычно требуется посмотреть всего несколько значений (до 4 или 8), в данном случае оптимальным оказалась раскрутка на 3 итерации. На Intel и IBM пользы от ручного выбора параметра раскрутки не обнаружилось.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№6 — векторизация |
3.67 с |
4.37 с |
6.19 с |
|
№7 — коллапсирование гнезда циклов |
3.60 с |
4.25 с |
5.46 с |
Последний штрих: процессор быстрее памяти
Остался один очевидный шаг, который можно было бы сделать ещё на этапе алгоритмических оптимизаций, но я сознательно пропустил его в угоду унификации. В функции calc_aw_b параметр alpha принимает значения 0 или 1 и отвечает за необходимость вычитания правой части при вычислении . Эти 2 случая можно было бы сразу разделить, но я предлагаю взглянуть на проблему под другим углом.
Основные циклы на языке ассемблера сейчас выглядят так:
.L13023:! solve_cpu.c : 168 ! <0052> { loop_mode qpfadd_rsubd,1,sm%xb[61], %xb[36], %xb[95], %xb[15]! solve_cpu.c : 180 qpfmsd,3,sm%xb[15], %xb[87], %xb[91], %xb[103]! solve_cpu.c : 187 qpfnmad,4,sm%xr11, %xb[50], %xb[108], %xb[104]! solve_cpu.c : 187 qpfmuld,5,sm%xb[65], %xr0, %xb[105]! solve_cpu.c : 183 } ! <0053> { loop_mode qpfadd_rsubd,1,sm%xb[110], %xb[6], %xb[68], %xb[29]! solve_cpu.c : 181 qpfmuld,2,sm%xr10, %xb[35], %xb[16]! solve_cpu.c : 178 qpfmsd,3,sm%xb[43], %xb[29], %xb[109], %xb[106]! solve_cpu.c : 187 qppermb,4,sm%xb[5], %xb[35], %xr12, %xb[4] ? %pcnt5! solve_cpu.c : 175 } ! <0054> { loop_mode qpfadd_rsubd,1,sm%xb[67], %xb[110], %xb[93], %xb[61]! solve_cpu.c : 181 qpfmuld,2,sm%xr10, %xb[5], %xb[66]! solve_cpu.c : 178 qpfmad,3,sm%xb[66], %xr9, %xb[111], %xb[107]! solve_cpu.c : 184 qppermb,4,sm%xb[33], %xb[90], %xr12, %xb[65]! solve_cpu.c : 175 movaqp,0area=0, ind=0, am=0, be=0, %xb[43]! solve_cpu.c : 186 movaqp,1area=0, ind=16, am=1, be=0, %xb[50]! solve_cpu.c : 186 movaqp,3area=2, ind=0, am=1, be=0, %xb[36]! solve_cpu.c : 186 } ! <0055> { loop_mode qpfmuld,2,sm%xb[31], %xr0, %xg16! solve_cpu.c : 183 qpfmad,3,sm%xb[76], %xr9, %xg16, %xb[87]! solve_cpu.c : 184 qppermb,4,sm%xb[90], %xb[5], %xr12, %xb[108]! solve_cpu.c : 175 qpfmuld,5,sm%xb[75], %xr0, %xb[109]! solve_cpu.c : 183 movaqp,0area=1, ind=0, am=0, be=0, %xb[88]! solve_cpu.c : 175 movaqp,1area=1, ind=16, am=1, be=0, %xb[31]! solve_cpu.c : 175 movaqp,2area=0, ind=0, am=0, be=0, %xb[75]! solve_cpu.c : 185 movaqp,3area=0, ind=16, am=1, be=0, %xb[76]! solve_cpu.c : 185 } ! <0056> { loop_mode qpfadd_rsubd,1,sm%xb[30], %xb[24], %xb[18], %xb[62]! solve_cpu.c : 180 qpfmuld,2,sm%xr10, %xb[90], %xb[91]! solve_cpu.c : 178 qpfmad,3,sm%xb[17], %xr9, %xb[105], %xb[95]! solve_cpu.c : 184 qpfnmad,4,sm%xr11, %xb[62], %xb[100], %xb[99]! solve_cpu.c : 187 staaqp,5%xb[102], %aad5[ %aasti1 ]! solve_cpu.c : 187 movaqp,0area=3, ind=0, am=1, be=0, %xb[17]! solve_cpu.c : 185 movaqp,1area=4, ind=0, am=1, be=0, %xb[18]! solve_cpu.c : 171 movaqp,2area=4, ind=0, am=1, be=0, %xb[24]! solve_cpu.c : 170 movaqp,3area=1, ind=16, am=0, be=0, %xb[30]! solve_cpu.c : 171 } ! <0057> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 168 abnabnf=1, abnt=1! solve_cpu.c : 168 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 168 qpfadd_rsubd,0,sm%xb[72], %xb[71], %xb[68], %xb[72]! solve_cpu.c : 180 qpfadd_rsubd,1,sm%xb[4], %xb[67], %xb[16], %xb[71]! solve_cpu.c : 181 staaqp,2%xb[101], %aad5[ %aasti1 + _f32s,_lts1 0x10 ]! solve_cpu.c : 187 qpfmsd,3,sm%xb[98], %xb[86], %xb[97], %xb[98]! solve_cpu.c : 187 qpfnmad,4,sm%xr11, %xb[55], %xb[103], %xb[100]! solve_cpu.c : 187 staaqp,5%xb[104], %aad5[ %aasti1 + _f32s,_lts0 0x20 ] incr,5%aaincr2! solve_cpu.c : 187 movaqp,0area=2, ind=16, am=1, be=0, %xb[55]! solve_cpu.c : 170 movaqp,1area=2, ind=0, am=0, be=0, %xb[68]! solve_cpu.c : 170 movaqp,2area=1, ind=0, am=1, be=0, %xb[67]! solve_cpu.c : 171 movaqp,3area=3, ind=0, am=1, be=0, %xb[1]! solve_cpu.c : 175 } .L18027:! solve_cpu.c : 321 ! <0064> { loop_mode } ! <0065> { loop_mode movaqp,1area=0, ind=16, am=0, be=0, %xb[1]! solve_cpu.c : 324 movaqp,2area=0, ind=0, am=0, be=0, %xb[0]! solve_cpu.c : 323 movaqp,3area=0, ind=16, am=1, be=0, %xb[4]! solve_cpu.c : 323 } ! <0066> { loop_mode qpfmuld,3,sm%xb[2], %xb[2], %xb[12]! solve_cpu.c : 326 qpfmuld,4,sm%xb[6], %xb[3], %xb[11]! solve_cpu.c : 325 qpfmuld,5,sm%xb[6], %xb[6], %xb[8]! solve_cpu.c : 326 movaqp,1area=0, ind=0, am=1, be=0, %xb[7]! solve_cpu.c : 324 } ! <0067> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 321 abnabnf=1, abnt=1! solve_cpu.c : 321 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 321 qpfmuld,1,sm%xb[2], %xb[9], %xb[3]! solve_cpu.c : 325 qpfaddd,2,sm%xr0, %xb[5], %xr0! solve_cpu.c : 325 qpfaddd,3,sm%xr2, %xb[14], %xr2! solve_cpu.c : 326 qpfaddd,4,sm%xr3, %xb[13], %xr3! solve_cpu.c : 325 qpfaddd,5,sm%xr1, %xb[10], %xr1! solve_cpu.c : 326 } .L33648:! solve_cpu.c : 391 ! <0067> { loop_mode movaqp,2area=0, ind=0, am=0, be=0, %xb[1]! solve_cpu.c : 393 movaqp,3area=0, ind=16, am=1, be=0, %xb[0]! solve_cpu.c : 393 } ! <0068> { loop_mode qpfmuld,4,sm%xr5, %xb[3], %xb[15]! solve_cpu.c : 393 qpfmuld,5,sm%xr5, %xb[2], %xb[12]! solve_cpu.c : 393 movaqp,0area=0, ind=16, am=1, be=0, %xb[6]! solve_cpu.c : 394 movaqp,1area=0, ind=0, am=0, be=0, %xb[7]! solve_cpu.c : 394 } ! <0069> { loop_mode qpfmuld,3,sm%xb[17], %xb[17], %xb[3]! solve_cpu.c : 395 qpfmuld,4,sm%xb[14], %xb[14], %xb[2]! solve_cpu.c : 395 qpfsubd,5,sm%xb[11], %xb[17], %xb[16]! solve_cpu.c : 394 } ! <0070> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 391 abnabnf=1, abnt=1! solve_cpu.c : 391 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 391 qpfsubd,1,sm%xb[10], %xb[14], %xb[11]! solve_cpu.c : 394 staaqp,2%xb[13], %aad2[ %aasti1 + _f32s,_lts0 0x10 ]! solve_cpu.c : 394 qpfaddd,3,sm%xr3, %xb[5], %xr3! solve_cpu.c : 395 qpfaddd,4,sm%xr4, %xb[4], %xr4! solve_cpu.c : 395 staaqp,5%xb[18], %aad2[ %aasti1 ] incr,5%aaincr2! solve_cpu.c : 394 }
Видно, что каждый цикл спланирован так, что на обработку одного элемента матрицы в каждом цикле должен тратиться 1 такт. Если же посмотреть на результаты профилировки с помощью утилиты perf, то оказывается, что цикл в функции calc_aw_b исполняется примерно в 1.5 раза дольше, чем другие 2 (функция calc_aw_b вызывается в 2 раза чаще, чем остальные):
Samples: 7K of event 'task-clock:u', Event count (approx.): 7103000000 Children Self Command Shared Object Symbol + 59.75% 59.75% prog_e2kv5 prog_e2kv5 [.] calc_aw_b + 20.37% 20.37% prog_e2kv5 prog_e2kv5 [.] update_w_calc_partial_error + 18.16% 18.16% prog_e2kv5 prog_e2kv5 [.] calc_tau_part
Этот эффект объясняется тем, что данные не успевают подгружаться из оперативной памяти с достаточным для процессора темпом. Для эксперимента разделим случаи умножения на 1 и на 0 и в случае умножения на 0 будем не загружать значение матрицы B, а брать какое-нибудь из уже использованных. Планирование цикла принципиально не поменялось, это те же 6 тактов, хоть в нём и на 3 инструкции movaqp меньше.
Аналогичное планирование:
.L13226:! solve_cpu.c : 194 ! <0053> { loop_mode qpfadd_rsubd,1,sm%xb[69], %xb[79], %xb[48], %xb[28]! solve_cpu.c : 206 qpfmsd,3,sm%xb[15], %xb[28], %xb[94], %xb[94]! solve_cpu.c : 213 qpfnmad,4,sm%xr12, %xb[14], %xb[101], %xb[97]! solve_cpu.c : 213 qpfmuld,5,sm%xb[90], %xr0, %xb[98]! solve_cpu.c : 209 movaqp,0area=2, ind=0, am=1, be=0, %xb[15]! solve_cpu.c : 211 movaqp,1area=0, ind=0, am=0, be=0, %xb[16]! solve_cpu.c : 211 } ! <0054> { loop_mode qpfadd_rsubd,1,sm%xb[76], %xb[6], %xb[93], %xb[72]! solve_cpu.c : 207 qpfmuld,2,sm%xr9, %xb[45], %xb[69]! solve_cpu.c : 204 qpfmsd,3,sm%xb[53], %xb[27], %xb[102], %xb[99]! solve_cpu.c : 213 qppermb,4,sm%xb[5], %xb[45], %xr11, %xb[4] ? %pcnt5! solve_cpu.c : 201 movaqp,0area=0, ind=16, am=1, be=0, %xb[27]! solve_cpu.c : 211 movaqp,1area=3, ind=0, am=1, be=0, %xb[48]! solve_cpu.c : 197 movaqp,2area=3, ind=0, am=1, be=0, %xb[53]! solve_cpu.c : 196 movaqp,3area=1, ind=16, am=0, be=0, %xb[63]! solve_cpu.c : 196 } ! <0055> { loop_mode qpfadd_rsubd,1,sm%xb[36], %xb[76], %xb[46], %xb[86]! solve_cpu.c : 207 qpfmuld,2,sm%xr9, %xb[5], %xb[91]! solve_cpu.c : 204 qpfmad,3,sm%xb[34], %xr10, %xb[103], %xb[100]! solve_cpu.c : 210 qppermb,4,sm%xb[43], %xb[62], %xr11, %xb[34]! solve_cpu.c : 201 movaqp,0area=1, ind=16, am=1, be=0, %xb[73]! solve_cpu.c : 197 movaqp,1area=1, ind=0, am=0, be=0, %xb[79]! solve_cpu.c : 197 movaqp,2area=1, ind=0, am=1, be=0, %xb[85]! solve_cpu.c : 196 movaqp,3area=2, ind=0, am=1, be=0, %xb[1]! solve_cpu.c : 201 } ! <0056> { loop_mode qpfmuld,2,sm%xb[74], %xr0, %xg16! solve_cpu.c : 209 qpfmad,3,sm%xb[60], %xr10, %xg16, %xb[90]! solve_cpu.c : 210 qppermb,4,sm%xb[62], %xb[5], %xr11, %xb[74]! solve_cpu.c : 201 qpfmuld,5,sm%xb[41], %xr0, %xb[101]! solve_cpu.c : 209 movaqp,2area=0, ind=0, am=0, be=0, %xb[60]! solve_cpu.c : 201 movaqp,3area=0, ind=16, am=1, be=0, %xb[41]! solve_cpu.c : 201 } ! <0057> { loop_mode qpfadd_rsubd,1,sm%xb[59], %xb[54], %xb[71], %xb[30]! solve_cpu.c : 206 qpfmuld,2,sm%xr9, %xb[62], %xb[44]! solve_cpu.c : 204 qpfmad,3,sm%xb[30], %xr10, %xb[98], %xb[54]! solve_cpu.c : 210 qpfnmad,4,sm%xr12, %xb[44], %xb[95], %xb[59]! solve_cpu.c : 213 staaqp,5%xb[96], %aad4[ %aasti1 ]! solve_cpu.c : 213 } ! <0058> { loop_mode alcalcf=1, alct=1! solve_cpu.c : 194 abnabnf=1, abnt=1! solve_cpu.c : 194 ct%ctpr1 ? %NOT_LOOP_END! solve_cpu.c : 194 qpfadd_rsubd,0,sm%xb[89], %xb[83], %xb[93], %xb[56]! solve_cpu.c : 206 qpfadd_rsubd,1,sm%xb[4], %xb[36], %xb[69], %xb[37]! solve_cpu.c : 207 staaqp,2%xb[61], %aad4[ %aasti1 + _f32s,_lts1 0x10 ]! solve_cpu.c : 213 qpfmsd,3,sm%xb[70], %xb[37], %xb[56], %xb[93]! solve_cpu.c : 213 qpfnmad,4,sm%xr12, %xb[84], %xb[94], %xb[94]! solve_cpu.c : 213 staaqp,5%xb[97], %aad4[ %aasti1 + _f32s,_lts0 0x20 ] incr,5%aaincr2! solve_cpu.c : 213 }
А вот скорость вычислений выросла на 8%, что подтверждает наличие зависимости времени вычисления от скорости работы памяти. Остаётся честно избавиться от умножения на alpha в обоих случаях и получить итоговый результат. Ниже сводная таблица со всеми основными результатами.
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№0 — базовая |
14.1 с |
42.9 с |
223.6 с |
|
№1 — возможен inline |
4.8 c |
26.2 c |
102.0 c |
|
№2 — ssize_t счётчики |
5.3 с |
27.9 с |
18.8 с |
|
№3 — алгоритмические оптимизации |
4.93 с |
8.42 с |
12.03 с |
|
№4 — используем restrict |
3.69 с |
4.69 с |
7.48 с |
|
№5 — вынос инварианта |
3.67 с |
4.37 с |
7.08 с |
|
№6 — векторизация |
3.67 с |
4.37 с |
6.19 с |
|
№7 — коллапсирование гнезда циклов |
3.60 с |
4.25 с |
5.46 с |
|
№8 — уменьшаем нагрузку на память |
3.43 с |
3.80 с |
5.04 с |
А что там у -fwhole: компиляция в режиме «вся программа»
Иногда задумываешься: а нужно ли столько трудов по оптимизации? Может быть, есть волшебный ключик, при использовании которого компилятор сам выполнит все оптимизации? На роль такой опции лучше всего претендует межпроцедурный анализ в контексте целиковой программы на этапе линковки и, при возможности, профилировка. В случае xlc достаточно продублировать -O5 во флагах линковщика, для icc и lcc надо подать -ipo и -fwhole, соответственно, при компиляции и линковке. А вот результаты:
|
Реализация |
i7-9700K |
POWER8 |
Эльбрус-8СВ |
|
№0 — базовая |
5.0 с |
19.6 с |
94.5 с |
|
№1 — возможен inline |
5.0 c |
19.8 c |
95.1 c |
|
№2 — ssize_t счётчики |
5.0 с |
19.8 с |
18.7 с |
|
№3 — алгоритмические оптимизации |
4.90 с |
7.05 с |
11.01 с |
|
№4 — используем restrict |
3.70 с |
4.91 с |
7.41 с |
|
№5 — вынос инварианта |
3.68 с |
4.64 с |
7.03 с |
|
№6 — векторизация |
3.68 с |
4.64 с |
6.33 с |
|
№7 — коллапсирование гнезда циклов |
3.65 с |
3.36 с |
5.44 с |
|
№8 — уменьшаем нагрузку на память |
3.43 с |
3.33 с |
5.04 с |
В целом, всё видно из таблицы, но я подчеркну: межпроцедурный анализ даёт хорошие результаты, но не заменяет оптимизации, которые может применить разработчик.
Заключение
На примере относительно простой учебной программы мне удалось обнаружить основные сложности и продемонстрировать техники, с которыми приходится сталкиваться при оптимизации под Эльбрус. Конечно, есть ещё ряд особенностей, которые не вошли в эту статью, но их вклад я считаю менее заметным и важным на первых порах оптимизации.
С помощью нехитрых приёмов мне удалось получить на Эльбрусе впечатляющее ускорение в 44 раза и приблизить время вычисления на Эльбрусе к результату на современном Intel и IBM POWER8, причём с помощью тех же оптимизаций результат на Intel был улучшен в 4.1 раза, а на IBM — в 11.5 раза (13 раз с учётом межпроцедурного анализа).
Нельзя не отметить, что оптимизация под Эльбрус не отличается принципиально от оптимизации под другие процессоры общего назначения и следует, в основном, тем же принципам. При этом совершенно не надо бояться VLIW-ассемблера: при наличии минимальных навыков работы с любым компилятором не составляет труда разобраться в сгенерированном коде и понять, из-за чего результат не соответствует ожиданиям.
ссылка на оригинал статьи https://habr.com/ru/post/647165/
Добавить комментарий