Целью данной работы является обозначение еще одной техники оптимизации циклов. При этом нет задачи ориентироваться на какую-либо существующую архитектуру, а, наоборот, будем стараться действовать по возможности абстрактно, опираясь преимущественно на здравый смысл.
Автор назвал эту технику “loops fracking” по аналогии с, например, “loops unrolling” или “loops nesting”. Тем более, что термин отражает смысл и не занят.
Циклы являются основным объектом для оптимизации, именно в циклах большинство программ проводят основное время. Существует достаточное количество техник оптимизации, ознакомиться с ними можно здесь.
Основные ресурсы для оптимизации
- Экономия на логике окончания цикла. Проверка критерия окончания цикла приводит к ветвлению, ветвление “ломает конвейер”, давайте проверять реже. В результате появляются прелестные образцы кода, например, Duf’s device.
void send(int *to, int *from, int count) { int n = (count + 7) / 8; switch (count % 8) { case 0: do { *to = *from++; case 7: *to = *from++; case 6: *to = *from++; case 5: *to = *from++; case 4: *to = *from++; case 3: *to = *from++; case 2: *to = *from++; case 1: *to = *from++; } while (--n > 0); } }
На данный момент предсказатели переходов (там, где они есть) в процессорах сделали подобную оптимизацию малоэффективной.
- Вынос инвариантов цикла за скобки (hoisting).
- Оптимизация работы с памятью для более эффективной работы кэша памяти. Если в цикле есть обращения к количеству памяти, заведомо превышающему объем кэш-памяти, становится важным, в каком порядке идут эти обращения. Компилятору сложно кроме очевидных случаев разбираться с этим, иногда для достижения эффекта фактически пишется другой алгоритм. Поэтому данная оптимизация ложится на плечи прикладных программистов. А компилятор / профилировщик могут обеспечить статистикой, дать подсказки … обратную связь.
- Использование (явного или неявного) параллелизма процессора. Современные процессоры умеют исполнять код параллельно.
В случае явно параллельной архитектуры (EPIC, VLIW) одна инструкция может содержать несколько команд (которые будут выполняться параллельно), затрагивающих разные функциональные блоки.
Суперскалярные процессоры самостоятельно разбирают поток инструкций, выискивают параллелизм и по возможности его используют.
Принципиальная схема суперскалярного исполнения команд
Еще один вариант — векторные операции, SIMD.
Сейчас мы как раз и займемся поиском способа максимального использования параллелизма процессора(ов).
Что мы имеем
Для начала разберем несколько простых примеров, используя для опытов MSVS-2013(x64) на процессоре Intel Core-i7 2600. К слову, GCC умеет всё то же и не хуже, во всяком случае на таких простых примерах.
Простейший цикл — вычисление суммы целочисленного массива.
int64_t data[100000];
…
int64_t sum = 0;
for (int64_t val : data) {
sum += val;
}
Вот что делает из этого компилятор:
lea rsi,[data]
mov ebp,186A0h ;100 000
mov r14d,ebp
...
xor edi,edi
mov edx,edi
nop dword ptr [rax+rax] ; выравнивание
loop_start:
add rdx,qword ptr [rsi]
lea rsi,[rsi+8]
dec rbp
jne loop_start
То же, но с double’ами (AVX, /fp:precise & /fp:strict — ANSI совместимость):
vxorps xmm1,xmm1,xmm1
lea rax,[data]
mov ecx,186A0h
nop dword ptr [rax+rax]
loop_start:
vaddsd xmm1,xmm1,mmword ptr [rax]
lea rax,[rax+8]
dec rcx
jne loop_start
Миллион раз этот код выполняется за 85 сек.
Не видно здесь никакой работы компилятора по выявлению параллельности, хотя, казалось бы, в задаче она налицо. Компилятор обнаружил зависимость в данных и не смог ее обойти.
То же, но (AVX, /fp:fast — без ANSI — совместимости):
vxorps ymm2,ymm0,ymm0
lea rax,[data]
mov ecx,30D4h ; 12500, 1/8
vmovupd ymm3,ymm2
loop_start:
vaddpd ymm1,ymm3,ymmword ptr [rax+20h] ; SIMD
vaddpd ymm2,ymm2,ymmword ptr [rax]
lea rax,[rax+40h]
vmovupd ymm3,ymm1
dec rcx
jne loop_start
vaddpd ymm0,ymm1,ymm2
vhaddpd ymm2,ymm0,ymm0
vmovupd ymm0,ymm2
vextractf128 xmm4,ymm2,1
vaddpd xmm0,xmm4,xmm0
vzeroupper
Выполняется 26 сек, используются векторные операции.
Тот же цикл, но в традиционном С стиле:
for (i = 0; i < 100000; i ++) {
sum += data[i];
}
Получаем неожиданно для (/fp:precise):
vxorps xmm4,xmm4,xmm4
lea rax,[data+8h]
lea rcx,[piecewise_construct+2h]
vmovups xmm0,xmm4
nop word ptr [rax+rax]
loop_start:
vaddsd xmm0,xmm0,mmword ptr [rax-8]
add rax,50h
vaddsd xmm1,xmm0,mmword ptr [rax-50h]
vaddsd xmm2,xmm1,mmword ptr [rax-48h]
vaddsd xmm3,xmm2,mmword ptr [rax-40h]
vaddsd xmm0,xmm3,mmword ptr [rax-38h]
vaddsd xmm1,xmm0,mmword ptr [rax-30h]
vaddsd xmm2,xmm1,mmword ptr [rax-28h]
vaddsd xmm3,xmm2,mmword ptr [rax-20h]
vaddsd xmm0,xmm3,mmword ptr [rax-18h]
vaddsd xmm0,xmm0,mmword ptr [rax-10h]
cmp rax,rcx
jl loop_start
Здесь нет параллельности, всего лишь попытка сэкономить на обслуживании цикла. Выполняется этот код 87 сек. Для /fp:fast код не изменился.
Подскажем компилятору, используя loop nesting.
double data[100000];
…
double sum = 0, sum1 = 0, sum2 = 0;
for (int ix = 0; i < 100000; i+=2)
{
sum1 += data[i];
sum2 += data[i+1];
}
sum = sum1 + sum2;
Получаем ровно то, что просили, причем код одинаков для опций /fp:fast & /fp:precise. Операции vaddsd на некоторых процессорах (Бульдозер от АМД) могут выполняться параллельно.
vxorps xmm0,xmm0,xmm0
vmovups xmm1,xmm0
lea rax,[data+8h]
lea rcx,[piecewise_construct+2h]
nop dword ptr [rax]
nop word ptr [rax+rax]
loop_start:
vaddsd xmm0,xmm0,mmword ptr [rax-8]
vaddsd xmm1,xmm1,mmword ptr [rax]
add rax,10h
cmp rax,rcx
jl loop_start
Миллион раз этот код выполняется за 43 сек, вдвое быстрее чем “наивный и точный” подход.
При шаге в 4 элемента код выглядит так (опять же, одинаково для опций компилятора /fp:fast & /fp:precise)
vxorps xmm0,xmm0,xmm0
vmovups xmm1,xmm0
vmovups xmm2,xmm0
vmovups xmm3,xmm0
lea rax,[data+8h]
lea rcx,[piecewise_construct+2h]
nop dword ptr [rax]
loop_start:
vaddsd xmm0,xmm0,mmword ptr [rax-8]
vaddsd xmm1,xmm1,mmword ptr [rax]
vaddsd xmm2,xmm2,mmword ptr [rax+8]
vaddsd xmm3,xmm3,mmword ptr [rax+10h]
add rax,20h
cmp rax,rcx
jl loop_start
vaddsd xmm0,xmm1,xmm0
vaddsd xmm1,xmm0,xmm2
vaddsd xmm1,xmm1,xmm3
Этот код миллион раз выполняется за 34 сек. Для того же, чтобы гарантировать векторные вычисления, придется прибегать к разным трюкам, как то:
- писать подсказки компилятору в виде прагм: #pragma ivdep (#pragma loop(ivdep), #pragma GCC ivdep), #pragma vector always, #pragma omp simd … и надеяться что компилятор не проигнорирует их
- использовать intrinsic’и — указания компилятору, какие инструкции использовать, например, суммирование двух массивов может выглядеть так:
Как то всё это не очень вяжется со светлым образом “языка высокого уровня”.
С одной стороны, при необходимости получить результат, указанные оптимизации совсем не в тягость. С другой, возникает проблема переносимости. Допустим, программа писалась и отлаживалась для процессора с четырьмя сумматорами. Тогда при попытке исполнить ее на версии процессора с 6-ю сумматорами, мы не получим ожидаемого выигрыша.
А на версии с тремя — получим замедление в 2 раза, а не на четверть, как хотелось бы.
Напоследок вычислим сумму квадратов (/fp:precise):
vxorps xmm2,xmm2,xmm2
lea rax,[data+8h] ; pdata = &data[1]
mov ecx,2710h ; 10 000
nop dword ptr [rax+rax]
loop_start:
vmovsd xmm0,qword ptr [rax-8] ; xmm0 = pdata[-1]
vmulsd xmm1,xmm0,xmm0 ; xmm1 = pdata[-1] ** 2
vaddsd xmm3,xmm2,xmm1 ; xmm3 = 0 + pdata[-1] ** 2 ; sum
vmovsd xmm2,qword ptr [rax] ; xmm2 = pdata[0]
vmulsd xmm0,xmm2,xmm2 ; xmm0 = pdata[0] ** 2
vaddsd xmm4,xmm3,xmm0 ; xmm4 = sum + pdata[0] ** 2 ; sum
vmovsd xmm1,qword ptr [rax+8] ; xmm1 = pdata[1]
vmulsd xmm2,xmm1,xmm1 ; xmm2 = pdata[1] ** 2
vaddsd xmm3,xmm4,xmm2 ; xmm3 = sum + pdata[1] ** 2 ; sum
vmovsd xmm0,qword ptr [rax+10h] ; ...
vmulsd xmm1,xmm0,xmm0
vaddsd xmm4,xmm3,xmm1
vmovsd xmm2,qword ptr [rax+18h]
vmulsd xmm0,xmm2,xmm2
vaddsd xmm3,xmm4,xmm0
vmovsd xmm1,qword ptr [rax+20h]
vmulsd xmm2,xmm1,xmm1
vaddsd xmm4,xmm3,xmm2
vmovsd xmm0,qword ptr [rax+28h]
vmulsd xmm1,xmm0,xmm0
vaddsd xmm3,xmm4,xmm1
vmovsd xmm2,qword ptr [rax+30h]
vmulsd xmm0,xmm2,xmm2
vaddsd xmm4,xmm3,xmm0
vmovsd xmm1,qword ptr [rax+38h]
vmulsd xmm2,xmm1,xmm1
vaddsd xmm3,xmm4,xmm2
vmovsd xmm0,qword ptr [rax+40h]
vmulsd xmm1,xmm0,xmm0
vaddsd xmm2,xmm3,xmm1 ; xmm2 = sum;
lea rax,[rax+50h]
dec rcx
jne loop_start
Компилятор опять пилит цикл на куски по 10 элементов с целью экономии на логике цикла, при этом обходится 5 регистрами — один под сумму и по паре на каждую из двух параллельных веток умножений.
Или вот так для /fp:fast:
vxorps ymm4,ymm0,ymm0
lea rax,[data]
mov ecx,30D4h ;12500 1/8
loop_start:
vmovupd ymm0,ymmword ptr [rax]
lea rax,[rax+40h]
vmulpd ymm2,ymm0,ymm0 ; SIMD
vmovupd ymm0,ymmword ptr [rax-20h]
vaddpd ymm4,ymm2,ymm4
vmulpd ymm2,ymm0,ymm0
vaddpd ymm3,ymm2,ymm5
vmovupd ymm5,ymm3
dec rcx
jne loop_start
vaddpd ymm0,ymm3,ymm4
vhaddpd ymm2,ymm0,ymm0
vmovupd ymm0,ymm2
vextractf128 xmm4,ymm2,1
vaddpd xmm0,xmm4,xmm0
vzeroupper
Сводная таблица:
MSVC,/fp:strict, /fp:precise, сек | MSVC,/fp:fast, сек | |
foreach | 85 | 26 |
C style цикл | 87 | 26 |
C style nesting X2 | 43 | 43 |
C style nesting X4 | 34 | 34 |
Как объяснить эти числа?
Стоит отметить, что истинную картину происходящего знают только разработчики процессора, мы же можем только делать догадки.
Первая мысль, что ускорение происходит за счет нескольких независимых сумматоров, по всей видимости ошибочна. Процессор i7-2600 имеет один векторный сумматор, который не умеет выполнять независимые скалярные операции.
Тактовая частота процессора — до 3.8 гГц. 85 сек простого цикла дают нам (миллион раз по 100 000 сложений) ~3 такта на итерацию. Это хорошо согласуется с данными (1, 2) о 3 тактах на исполнение векторной инструкции vaddpd (пусть даже мы складываем скаляры). Поскольку есть зависимость по данным, быстрее 3 тактов итерацию выполнить невозможно.
В случае нестинга (Х2) отсутствует зависимость по данным внутри итерации и есть возможность загрузить конвейер сумматора с разницей в такт. Но в следующей итерации зависимости по данным возникают так же с разницей в такт, в результате имеем ускорение в 2 раза.
В случае нестинга (Х4) конвейер сумматора загружается также с шагом в такт, но ускорения в 3 раза (из-за длины конвейера) не происходит, вмешивается дополнительный фактор. Например, итерация цикла перестала умещаться в строке кэша L0m и получает штрафной такт(ы).
Итак:
- с моделью компиляции /fp:fast самый простой исходный текст дает самый быстрый вариант кода, что логично т.к. мы имеем дело с языком высокого уровня.
- ручные оптимизации в духе nesting’а дают хороший результат для модели /fp:precise, но только мешают компилятору при использовании /fp:fast
- ручные оптимизации переносимы в большей степени, нежели векторизованный код
Чуть чуть про компиляторы
Регистровые архитектуры дают простой и универсальный способ получить приемлемый код из переносимого текста на языках высокого уровня. Компиляцию условно можно разбить на несколько шагов:
- Синтаксический анализ. На этом этапе осуществляется синтаксически управляемая трансляция, производятся статические проверки. На выходе мы имеем дерево (DAG) синтаксического разбора.
- Генерация промежуточного кода. При желании, генерация промежуточного кода может быть объединена с синтаксическим анализом.
Тем более, что при использовании в качестве промежуточного кода трехадресных инструкций, данный шаг становится тривиальным ибо "трехадресный код является линеаризованным представлением синтаксического дерева или дага, в котором внутренним узлам графа соответствуют явные имена".
В сущности, трехадресный код предназначен для виртуального процессора с бесконечным количеством регистров.
- Генерация кода. Результатом данного шага является программа для целевой архитектуры. Поскольку реальное количество регистров ограничено и невелико, на этом этапе мы должны определить, какие временные переменные в каждый момент будут находиться в регистрах, и распределить их по конкретным регистрам. Даже в чистом виде эта задача является NP-полной, кроме того, дело усложняется тем, что обычно существуют разнообразные ограничения по использованию регистров. Тем не менее, для решения данной задачи разработаны приемлемые эвристики. Кроме того, трехадресный (или его эквиваленты) код дает нам формальный аппарат для анализа потоков данных, оптимизации, удаления бесполезного кода,…
Вырисовываются проблемы:
- Для решения NP-полной задачи размещения регистров используют эвристики и это даёт код приемлемого качества. Эти эвристики не любят дополнительных ограничений на использование памяти или регистров. Например, расщепленной (interlaced) памяти, неявного использования регистров инструкциями, векторных операций, регистровых колец … Не любят вплоть до того, что эвристика может перестать работать и построение близкого к оптимальному кода перестает быть задачей, решаемой универсальным образом.
В результате (векторные?) возможности процессора могут быть задействованы лишь тогда, когда компилятор распознал типовую ситуацию из набора, которому его обучили.
- Проблема масштабирования. Размещение (allocation) регистров делается статически, если мы попытаемся исполнить скомпилированный код на процессоре с той же системой команд, но большим количеством регистров, никакого выигрыша не получится.
Это касается и SPARC с его стеком регистровых окон, для которого большее количество регистров приводит лишь к тому, что большее количество фреймов вызовов умещается в пуле регистров, что уменьшает частоту обращений к памяти.
EPIC — сделана попытка в направлении масштабирования — “Каждая группа из нескольких инструкций называется bundle. Каждый bundle может иметь стоповый бит, обозначающий, что следующая группа зависит от результатов работы данной. Такой бит позволяет создавать будущие поколения архитектуры с возможностью параллельного запуска нескольких bundles. Информация о зависимостях вычисляется компилятором, и поэтому аппаратуре не придётся проводить дополнительную проверку независимости операндов.” Предполагалось, что независимые bundles могут исполняться параллельно, причем, чем больше исполняющих устройств в системе, тем шире может использоваться внутренний параллелизм программы. С другой стороны, далеко не всегда подобные особенности дают выигрыш, во всяком случае, для суммирования массива это видится автору бесполезным.
Суперскалярные процессоры решают проблему, вводя “регистры для нас” и “регистры в себе”. На число первых ориентируется компилятор, когда расписывает(аллоцирует) регистры. Число вторых может быть любым, обычно их в разы больше, чем первых. В процессе декодирования суперскалярный процессор заново расписывает регистры исходя из их фактического количества в пределах окна в теле программы. Размер окна определяется сложностью логики, которую процессор может себе позволить. Кроме числа регистров, конечно, масштабированию подлежат и функциональные устройства.
- Проблема совместимости. Особо отметим X84-64 и линейку технологий — SSE — SSE2 — SSE3 — SSSE3 — SSE4 — AVX — AVX2 — AVX512 — …
Совместимость сверху вниз (т.е. код скомпилирован под старшую технологию, а хочется исполнять на младших процессорах) может быть обеспечена одним способом — генерацией кода под каждую упомянутую технологию и выбор в runtime нужной ветки для исполнения. Звучит не очень привлекательно.
Совместимость снизу вверх обеспечивает процессор. Эта совместимость гарантирует исполнимость кода, но не обещает его эффективного исполнения. Например, если код скомпилирован под технологию с двумя независимыми сумматорами, а исполняется на процессоре с 4-мя, фактически использованы будут только два из них. Генерация нескольких веток кода под разные технологии не решает проблемы будущих технологий, планируемых или нет.
Взгляд на циклы
Рассмотрим ту же задачу, суммирование массива. Представим это суммирование как вычисление одного выражения. Поскольку мы используем бинарное сложение, выражение может быть представлено в виде бинарного дерева, причем из за ассоциативности суммирования, таких деревьев много.
Вычисление производится при обходе дерева вглубь слева направо. Обычное суммирование выглядит как лево-растущее вырожденное до списка дерево:
double data[N];
…
double sum = 0;
for (int i = 0; i < N; i++)
{
sum += data[i];
}
Максимальная глубина стека (обход в глубину подразумевает постфиксное суммирование, т.е. стек), которая может здесь потребоваться — 2 элемента. Никакой параллельности не предполагается, каждое суммирование (кроме первого) должно дождаться результатов предыдущего суммирования, т.е. налицо зависимость по данным.
Зато нам достаточно 3 регистров (сумма и два для эмуляции верхушки стека) чтобы просуммировать массив любого размера.
Нестинг цикла в два ручейка выглядит так:
double data[N];
…
double sum = 0;
double sum1 = 0, sum2 = 0;
for (int i = 0; i < N/2; i+=2)
{
sum1 += data[i];
sum2 += data[i + 1];
}
sum = sum1 + sum2;
Для вычислений нужно вдвое больше ресурсов, 5 регистров на всё, но теперь часть суммирований можно делать параллельно.
Самый ужасный с вычислительной точки зрения вариант — право-растущее, вырожденное до списка дерево, для его вычисления нужен стек размером с массив при отсутствии параллелизма.
Какой вариант дерева даст максимальный параллелизм? Очевидно, сбалансированное (в пределах возможного) дерево, где обращения к исходным данным есть только в листовых суммирующих узлах.
// Алгоритм вычисления суммы таков:
double data[N];
for (unsigned ix = 0; ix < N; ix++) {
unsigned lix = ix;
push(data[ix]);
while (lix & 1) {
popadd();
lix >>= 1;
}
}
for (unsigned ix = 1; ix < bit_count(N+1); ix++) {
popadd();
}
В этом псевдо-коде использованы следующие функции:
- push(val) — помещает значение на вершину стека, увеличивает стек. Предполагается что стек организован на пуле регистров.
- popadd() — суммирует два элемента на вершине стека, результат помещает во второй сверху, удаляет верхний элемент.
- bit_count(val) — подсчитывает число бит в целочисленном значении
Как это работает? Обратим внимание, что номер элемента в бинарном представлении кодирует путь до него от вершины дерева выражения (от старших битов к младшим). При этом 0 обозначает движение налево, 1 — направо (аналогично коду Хаффмана).
Отметим, что количество непрерывно идущих взведённых младших бит равно количеству суммирований, которые надо сделать для обработки текущего элемента. А общее количество взведённых бит в номере означает количество элементов находящихся в стеке на момент до начала работы с эти элементом.
На что следует обратить внимание:
- Размер стека т.е. потребное число регистров для него есть log2 от размера данных. С одной стороны, это не очень удобно т.к. размер данных может быть величиной вычисляемой, а размер стека хотелось бы определить при компиляции. С другой стороны, никто на мешает компилятору пилить данные на тайлы размер которых определяется исходя из числа доступных регистров.
- В такой интерпретации задачи достаточно параллелизма, чтобы автоматически задействовать любое количество доступных независимых сумматоров. Загрузка элементов из массива может делаться независимо и параллельно. Суммирования одного уровня также выполняются параллельно.
- с параллелизмом есть проблемы. Пусть на момент компиляции у нас было N сумматоров. Для того, чтобы эффективно работать с отличным от N их числом (ради чего всё и затевалось), придётся задействовать аппаратную поддержку
- для архитектур с явным параллелизмом всё непросто. Помог бы пул сумматоров и разрешение исполнять параллельно несколько независимых “широких” инструкций. Для суммирования берется не конкретный сумматор, а первый в очереди. Если широкая инструкция пытается выполнить три суммирования, из пула будут истребованы три сумматора. Если такого к-ва свободных сумматоров нет, инструкция будет блокирована вплоть до их освобождения.
- для суперскалярных архитектур требуется отслеживать состояние стека. Есть унарные (ex: смена знака) и бинарные операции (Ex: popadd) со стеком. А также листовые операции без зависимостей (Ex:push). С последними проще всего, их можно ставить на исполнение в любой момент. Но если у операции есть аргумент(ы), она перед выполнением должна дождаться готовности своих аргументов.
Так, оба значения для операции popadd должны находиться на вершине стека. Но к тому моменту как подойдет очередь их суммировать, они уже могут быть совсем не на вершине стека. Может случиться, что они физически расположены и не подряд.
Выходом будет размещение (allocation) соответствующего регистра из пула стека в момент постановки инструкции на исполнение а не в момент, когда результат готов и его надо разместить.
Например push(data[i]) означает, что из памяти надо вычитать значение и разместить его в регистр. Номер этого регистра берется из очереди свободных регистров до начала чтения. Регистр удалятся из очереди.
Для операции popadd на момент постановки на исполнение номера регистров с аргументами уже известны, хотя, возможно и не вычислены. При этом берется новый регистр под результат, а при готовности аргументов popadd исполняется, регистры из под аргументов отдаются в очередь свободных.
Постановка инструкций на исполнение происходит вплоть до исчерпания пула регистров и по мере их освобождения продвигается вперед.
Таким образом достигается масштабируемость и по размеру пула регистров и по количеству функциональных устройств. Платой является поддержание состояния стека/очереди регистров и зависимостей между инструкциями. Что не выглядит непреодолимой преградой.
- В режиме ANSI совместимости из-за проблем с точностью для выражений с double’ами формально невозможны оптимизационные алгебраические преобразования. Поэтому существует режим /fp:fast, который развязывает компилятору руки. Фактически суммирование пирамидкой даже более бережно относится к точности в силу того, что происходит суммирование (более вероятно) сопоставимых чисел, а не добавление к нарастающей сумме.
- Можно ли реализовать описанную схему на существующих архитектурах, без аппаратной поддержки стека? Да, для фиксированного размера массива. Компилятор пилит цикл на тайлы размером, скажем, 64 элемента и явно расписывает стековую машину по регистрам. Заодно потребуется и код для остальных степеней 2 меньше 64 — для выхода из цикла. Это будет довольно громоздко, но сработает.
lea rax,[data] vxorps xmm6,xmm6,xmm6 ; здесь будем держать 0 vaddsd xmm0,xmm6,mmword ptr [rax] ; 0 vaddsd xmm1,xmm0,mmword ptr [rax+8] ; 1 vaddsd xmm0,xmm6,mmword ptr [rax+10h] ; 1 0 vaddsd xmm2,xmm0,mmword ptr [rax+18h] ; 1 2 vaddsd xmm0,xmm1,xmm2 ; 0 vaddsd xmm1,xmm6,mmword ptr [rax+20h] ; 0 1 vaddsd xmm2,xmm1,mmword ptr [rax+28h] ; 0 2 vaddsd xmm1,xmm6,mmword ptr [rax+30h] ; 0 2 1 vaddsd xmm3,xmm1,mmword ptr [rax+38h] ; 0 2 3 vaddsd xmm1,xmm2,xmm3 ; 0 1 vaddsd xmm0,xmm0,xmm1 ; 0 vaddsd xmm1,xmm6,mmword ptr [rax+40h] ; 0 1 vaddsd xmm2,xmm1,mmword ptr [rax+48] ; 0 2 vaddsd xmm1,xmm6,mmword ptr [rax+50h] ; 0 2 1 vaddsd xmm3,xmm1,mmword ptr [rax+58h] ; 0 2 3 vaddsd xmm1,xmm2,xmm3 ; 0 1 vaddsd xmm2,xmm6,mmword ptr [rax+60h] ; 0 1 2 vaddsd xmm3,xmm2,mmword ptr [rax+68h] ; 0 1 3 vaddsd xmm2,xmm6,mmword ptr [rax+70h] ; 0 1 3 2 vaddsd xmm4,xmm2,mmword ptr [rax+78h] ; 0 1 3 4 vaddsd xmm2,xmm4,xmm3 ; 0 1 2 vaddsd xmm3,xmm1,xmm2 ; 0 3 vaddsd xmm1,xmm0,xmm3 ; 1
Так может выглядеть код для 16 -элементной пирамиды.
Что дальше?
Мы разобрали нахождение суммы массива — очень простую задачу. Возьмем что-нибудь посложнее.
- Сумма квадратов. Находится принципиально также, различие в том, что в листовых узлах дерева выражения значение требуется возвести в квадрат.
- Скалярное произведение векторов. Аналогично, в листовом узле сначала находится произведение значений из двух массивов.
- Сложение векторов. Здесь нет зависимости по данным и естественный параллелизм может быть использован и без особых ухищрений.
- Произведение матриц. Произведение матриц AB состоит из всех возможных комбинаций скалярных произведений вектор-строк матрицы A и вектор-столбцов матрицы B.
- Наивный алгоритм оперирует скалярными произведениями, которые мы уже считать умеем. Кубичен.
- Алгоритм Штрассена рекурсивно разбивает матрицы 2Х2, затем перемножает их, используя 7 умножений вместо 8. Разбиение продолжается до тех пор, пока подматрицы не станут малого размера (< 32 ...128), дальше они перемножаются наивным образом. Имеет сложность и страдает неустойчивостью.
- Наилучший достигнутый результат — и он тоже рекурсивен. Впрочем, это чисто теоретический результат без перспектив реального применения.
- Наивный алгоритм оперирует скалярными произведениями, которые мы уже считать умеем. Кубичен.
- Наивная свёртка.
for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { result[i + j] += x[i] * h[M - 1 - j]; } }
С внутренним циклом мы работать умеем, общую оптимизацию лучше делать через ДПФ.
- ДПФ. В наивном виде не используется. Алгоритм БПФ опять же основан на рекурсии. Например, data-flow диаграмма для популярного алгоритма Кули-Тьюки (Cooley–Tukey) с основанием 2 выглядит так (16-точечное):
Здесь достаточно естественного параллелизма чтобы прибегать к особым усилиям.
Последний пример достаточно показателен. В целях оптимизации рекурсия обычно переводится в итерации, в результате типичный текст (основной цикл) выглядит так:
nn = N >> 1;
ie = N;
for (n=1; n<=LogN; n++) {
rw = Rcoef[LogN - n];
iw = Icoef[LogN - n];
if(Ft_Flag == FT_INVERSE) iw = -iw;
in = ie >> 1;
ru = 1.0;
iu = 0.0;
for (j=0; j<in; j++) {
for (i=j; i<N; i+=ie) {
io = i + in;
rtp = Rdat[i] + Rdat[io];
itp = Idat[i] + Idat[io];
rtq = Rdat[i] - Rdat[io];
itq = Idat[i] - Idat[io];
Rdat[io] = rtq * ru - itq * iu;
Idat[io] = itq * ru + rtq * iu;
Rdat[i] = rtp;
Idat[i] = itp;
}
sr = ru;
ru = ru * rw - iu * iw;
iu = iu * rw + sr * iw;
}
ie >>= 1;
}
Что можно сделать в данном случае? В духе описанной оптимизации циклов, пожалуй, что ничего. Может ли здесь быть полезным описанный аппаратный стек, вопрос интересный. Впрочем, это уже совсем другая история.
PS: отдельное спасибо Таситу Мурки (Felid) за консультации по SIMD и не только.
PPS: иллюстрация для заголовка взята из видеоряда к King Crimson — Fracture — Live in Boston 1974.
Комментарии (29)
encyclopedist
01.12.2015 12:42+2Здесь достаточно естественного параллелизма чтобы прибегать к особым усилиям.
Как раз для БПФ в библиотеке FFTW применяется поход похожий на то что вы предлагаете. Там есть набор из множества функций, вычисляющих кусочки БПФ-графа разных размеров, и из них составляется целое преобразование (при этом ещё и выбирается оптимальная для данной машины комбинация, с помощью замеров производительности). Сами кирпичики генерируются с помощью скриптов перед компиляцией.encyclopedist
01.12.2015 15:17Вот тут статья, которая описывает, как это работает www.fftw.org/fftw-paper-ieee.pdf
DmitryBabokin
01.12.2015 12:48+4Примитивизация компилятора до 1-2-3 пунктов (синтаксический анализ — генерация промежуточного кода — кодогенерация) вводит в заблуждение как читателя, так и самого автора, ибо сводит всё к проблеме распределения регистров. А это категорически не так. Между пунктами 2 и 3 ещё присутствует огромный кусок, которые по своему размеру превышает эти 1-2-3 в разы. А именно, оптимизатор, который включает в себя скалярные машинно-независимые оптимизации, межпроцедурный анализ и оптимизации, высокоуровневый оптимизатор циклов и цикловых гнёзд и непосредственно векторизатор.
В приведённом примере с частичными суммами, компилятор отлично видит все возможности для оптимизации и всё отлично оптимизирует, если позволяет семантика (в данном случае fp-model) и попытки оптимизировать это вручную не могут привести ни к чему, кроме как к тому, чтобы помешать компилятору сгенерировать оптимальный код. И делается это на уровне высокоуровневой оптимизации циклов и векторизатора, а не распределителя регистров, как создаётся впечатление из статьи.zzeng
01.12.2015 13:01Проблема, как она мне видится, не в том, что компилятор не умеет выдавать оптимальный код.
В конкретном описанном случае переплюнуть векторное суммирование принципиально невозможно.
А теперь представим, что программа скомпилирована под avx2, а исполняться будет на avx512 или avx1024.
Изначально вопрос был: а можно ли, скомпилировать один раз, а потом использовать на более новых процессорах максимально эффективно.
Похоже, что можно, пусть практическая ценность вот прямо сейчас минимальна, но важна ведь идея.
DmitryBabokin
01.12.2015 14:06+4Поздравляю, вы придумали идею Java :)
А что если код скомпилирован для ARM, а хочется исполнять на SPARC? Как быть?
> Изначально вопрос был: а можно ли, скомпилировать один раз, а потом использовать на более новых процессорах максимально эффективно.
Лично я этого вопроса в статье не увидел.
Единственный способ в современном мире писать переносимые программы — это писать их в максимально понятном компилятору виде и рассчитывать на то, что он их хорошо оптимизирует. Если хочется убедиться, что у компилятора не возникло проблем с анализом зависимостей по данным и прочими неожиданными нюансами — надо добавить прагму ivdep или ещё кое-какие. Или писать на явно параллельных языках (ispc) или расширениях (Cilk+, OpenMP, OpenCL).
Или можно скомпилоровать для нескольких архитектур одновременно (для icc это -ax<список архитектур> — аля -axSSE4.1,AVX,MIC-AVX512)
Что пытались сказать на эту тему статье — не понял, честно. Для будущих архитектур можно или перекомпилировать, или полагаться бинарную трансляцию / рантайм поддержку (виртуальную машину).Halt
01.12.2015 14:26Я бы еще добавил вариант использовать LLVM и компилировать код в IR, но да, это уже ближе к подходу джавы.
DmitryBabokin
01.12.2015 14:39+1Ну да, но это вариация на тему. Apple сейчас как раз пользуется этим подходом — требует сабмитить приложения в LLVM IR в их стор.
Правда не стоит забывать, что LLVM IR строго говоря является платформенно-зависимым, так как все ABI ловеренги происходят во фронтенде и все оптимизации происходят со знанием таргет-архитектуры (я про класс DataLayout).Halt
01.12.2015 15:11Ну тут уже видимо или шашечки или ехать. Можно задавать все типы с фиксированным размером и выравниванием, можно выбирать оптимизации не полагающиеся на DataLayout, можно тупо не оптимизировать наконец, но смысл тогда вообще во всей затее? :)
DmitryBabokin
01.12.2015 15:30+1>но смысл тогда вообще во всей затее?
Смотря в какой затее. Если вообще смысл этой «платформо-независимости» LLVM IR — то тут понятно, один оптимизатор работает для всех архитектур и все счастливы.
Если речь про то, зачем распространять приложения в LLVM IR — оптимизация под конкретную микроархитектуру (но это тоже кажется что не главное) и гораздо более простой анализ кода (что для аппстора куда более важно).
zzeng
01.12.2015 15:00Моя вина — не донес основную мысль
Эта статья не о том, как улучшить кодогенерацию под конкретный процессор еще на 1%.
А скорее интерес пощупать возможную синергию между (абстрактными) компилятором и процессором.
Вот спарк — замечательны процессор, у него масштабируемое к-во регистров, в определенных (и нетривиальных) случаях код может обходиться без обращения к памяти. Но довеском идет и ряд проблем.
В epic в добавок еще пытаются масштабировать функциональные устройства. Результат тоже пока не очень.
Регистровая архитектура — замечательная концепция, позволяющая компилятору быстро выдавать почти идеальный код.
Суперскалярный процессор — отличная идея, увы, производительность упирается во внутреннюю сложность.
Технологические возможности производителей процессоров выше способности их (возможности) переварить, что странно.
Возможности компиляторов упираются в особенности архитектур и их сложность тоже начинает расти непропорционально полезному результату.
Я утрирую, конечно, и тем не менее.
Вот у меня есть мысль и я её думаю :)
Мысль о том, как подать суперскалярному ядру уже частично размеченный зависимостями код.
Так что я пишу потихоньку простой компилятор, виртуальную машину к нему.
А данная статья побочный эффект, что-ли. Еще раз извиняюсь, что не выделил основной вопрос.
Он в том, можно ли на суперскалярном ядре написать такой код для простого суммирования массива, что
при любом (масштабируемом) числе независимых функциональных устройств они будут использованы по максимуму.DmitryBabokin
01.12.2015 15:12+1> можно ли на суперскалярном ядре…
Нельзя. Нельзя в современной парадигме, когда есть программный уровень, который полностью контролируется программистом и хардварный, который просто исполняет (пусть и с некоторыми железячными оптимизациями, как то внеочередное исполнение). В этой парадигме код обязан быть сгенерирован для данной конкретной архитектуры для максимальной производительности (банальное знание о количестве исполняющих устройств в конкретной микроархритектуре может заметно повлиять на качество кода).
Для решения вашей задачи нужен промежуточный уровень — это или бинарная трансляция (не важно встроена она в чип или находится в ОС), или виртуальная машина.zzeng
01.12.2015 15:18«Единственный способ в современном мире» избавиться от парадигм — не использовать парадигмы :)
Вот конкретно описанный метод вычисления суммы пирамидкой даёт такую формальную возможность.DmitryBabokin
01.12.2015 15:34+2Вычисление пирамидкой — это переход к другой парадигме :)) Это другой алгоритм, с другими свойствами, а не некоторое универсальное представление, которое годится для всех. Вообще, мне кажется это очень плохой пример, который абсолютно не описывает то, что вы хотели продемонстрировать.
zzeng
01.12.2015 15:49Я нигде не говорил об универсальности,
локальная задача, локальное решение.
С одной стороны. А с другой — весьма распространенный вид зависимостей по данным.
Сумма да произведение — вот и вся арифметика :)DmitryBabokin
01.12.2015 15:53+2Я так понял идея была как раз в некотором универсально представлении, которое легко можно положить на любую архитектуру за счёт знания о структуре и свойстве алгоритма.
А так, любой высокоуровневый оптимизатор циклов и векторизатор делает всё, о чём вы рассказали, и даже намного больше (именно в плане выяснения свойств алгоритма и анализа возможных путей оптимизации).zzeng
01.12.2015 16:03Про любую архитектуру я тоже не говорил :)
А так да.
Но в данной статье рассматривается лишь вопрос об устранении зависимостей при суммировании.
Зависимость в данных такая штука, что если она есть, её не объедешь ни на одной архитектуре.
А за счет алгебраических преобразований кое что сделать можно.
Да, я знаю, современные компиляторы умеют пользоваться алгебраическими преобразованиями.
Но в рамках целевой архитектуры.
А хочется универсальности.
SIMD не предлагать :)DmitryBabokin
01.12.2015 16:10+1У вас опять всё в кучу. Анализ зависимостей проводится вне зависимости от архитектуры, как и изменение структуры циклов/вычислений. Архитектура имеет значение только при принятии решения об целесообразности преобразования.
Если вы хотите универсальности — надо думать о представлении алгоритма, в котором максимально представлены все свойства необходимые для оптимизации под конкретную (произвольную) архитектуру. И это интересный вопрос.
Если вы хотите максимальной агрессивности оптимизации — надо наращивать аналитически мускулы конкретного оптимизатора.zzeng
01.12.2015 16:21А я хочу синергии и пытаюсь оптимизировать сразу пару компилятор/архитектура.
И в этой мутной водичке стараюсь сохранить (условную) простоту и того и другого.
0serg
01.12.2015 14:24Изначально вопрос был: а можно ли, скомпилировать один раз, а потом использовать на более новых процессорах максимально эффективно.
Похожая задача в значительно более острой форме стоит для GPGPU вычислений, т.к. вариабельность GPU намного выше чем вариабельность CPU. И ничего лучшего чем тупо хранить исходный код и компилировать его на месте под нужный GPU там пока не придумали. Потихоньку правда есть движение в сторону того чтобы хранить не прямо сырцы а уже распарсенное фронт-эндом синтаксическое дерево.
Ну и Java там где производительность менее критична, да :)
vladon
01.12.2015 13:52+1Clang (3.7 с -O4) сгенерировал оптимизированный код уже сразу из первого варианта (где без подсказок).
Тогда вопрос: и зачем? Don't help the compiler.zzeng
01.12.2015 14:05Оптимизированный — т.е. векторный?
А как же ANSI — совместимость?encyclopedist
01.12.2015 14:25+1Вероятно, -O4 включает -fast-math, аналог /fp:fast
zzeng
01.12.2015 15:04Компилятор от MS также выдает векторизованный код с /fp:fast.
Вопрос не в этом. Подробнее написал выше.
0serg
01.12.2015 14:13+5Уважаемый сэр, обращаю Ваше внимание на один простой факт: изменение порядка суммирования float-ов меняет конечный результат.
Режим /fp:precise гарантирует что компилятор сумму посчитает именно так как написано в коде — последовательно. Ваш «код с подсказкой для компилятора» дает другой результат и если компилятор подобное сам забабахает в режиме /fp:precise то это будет ошибкой. Отсюда и наблюдается драматическая разница. Для интереса можете повторить эксперимент на сумме int-ов :)
Подобные ньюансы кажутся мелочами ровно до первого случая когда влетаешь в проблему связанную с мааааленькими ошибочками округления из-за которых программа при включении /fp:fast внезапно начинает крэшиться из-за возникнования отрицательного числа в выражении которое ну никак казалось бы отрицательным быть не может :)zzeng
01.12.2015 15:02Я об этом честно написал, разе нет?
0serg
02.12.2015 13:37Ну у меня статья оставила впечатление разбора «как улучшить код чтобы помочь тупому компилятору который не может обойти зависимости по данным». А компилятор-то как раз здесь отнюдь не туп. Если Вы разрешаете изменять порядок суммирования — то нафига разбирать варианты с /fp:precise? Стоило бы написать в другом ключе: разобрать что такое /fp:fast vs /fp:precise, показать насколько драматичный выигрыш дает fp:fast и как можно получить тот же результат модификацией кода если по каким-то причинам fp:fast при компиляции использовать вдруг не получается (хотя мне сложно представить почему). Это то что касается первой, содержательной части статьи.
Что касается второй части — то это просто очень сырая идея и вдобавок велосипед, т.к. задача суммирования «деревом» на массивно-параллельной архитектуре является классикой программирования для GPU и очень давно подробно иследована и разобрана. Было бы любопытно увидеть результаты подобной оптимизации на суперскалярах, но как раз ее-то у Вас в статье и нетzzeng
02.12.2015 13:46Видимо, мне следовало более внятно написать о цели статьи,
а именно (уже писал выше), ответить на вопрос можно ли на суперскалярном ядре написать такой код для простого суммирования массива, что при любом (масштабируемом) числе независимых функциональных устройств они будут использованы по максимуму.
Gumanoid
Так что же такое loop fracking?
zzeng
Взгляд на сумму ряда, например, как на единое выражение а не как на цикл.