Мы недавно уже писали о новом Vectorization Advisor. О том, что это такое и зачем нужно, читайте в первой статье. Этот же пост посвящён разбору конкретного примера оптимизации приложения с помощью этого инструмента.
Приложение взято из примеров библиотеки Intel® Threading Building Blocks (Intel TBB). Оно рисует фрактал Мандельброта и распараллелено по потокам с помощью Intel TBB. Т.е. преимущества многоядерного процессора оно использует — посмотрим, как обстоят дела с векторными инструкциями.
![](//habrastorage.org/files/bf7/d45/80e/bf7d4580ee2e470c83f052b251d55f32.png)
Тестовая система:
• Ноутбук с Intel® Core i5-4300U (Haswell, 2 ядра, 4 аппаратных потока)
• IDE: Microsoft Visual Studio 2013
• Компилятор: Intel® Composer 2015 update 2
• Intel® Advisor XE 2016 Beta
• Приложение: фрактал Мандельброта, слегка модифицированное. См. <tbb_install_dir>\examples\task_priority
Итак, первый шаг: замеряем базовую версию, время работы: 4.51 секунды. Дальше запускаем Survey анализ в Intel® Advisor XE:
Колонка «Loop Type» говорит о том, что все циклы скалярные, т.е. не используют SIMD инструкции. Самый затратный цикл в файле fractal.cpp на строке 74. Он тратит больше 13 секунд процессорного времени – им и займёмся. Колонка «Why No Vectorization» содержит сообщение о том, что компилятор не смог оценить количество итераций цикла, поэтому и не векторизовал его. Двойной клик на подсвеченный цикл, смотрим код:
Становится ясно, почему количество итераций не известно во время компиляции. Прежде, чем погружаться в детали алгоритма и пробовать переписать while, попробуем пойти более простым путём. Посмотрим, откуда вызывается наш цикл – закладка Top Down:
![](//habrastorage.org/files/1cb/372/082/1cb372082f4a42d7944ecf2f76c22f7e.png)
Следующий по стеку вызовов цикл на строке 164. Это нормальный с виду for, который компилятор тоже не векторизовал (Scalar в колонке Loop Type), вероятно, не посчитав это эффективным. Диагностическое сообщение предлагает попробовать форсировать векторизацию, например, с помощью директивы SIMD:
Следуем совету:
Пересобираем программу и запускаем Survey опять. С #pragma simd цикл стал не «Scalar», а «Remainder»:
SIMD циклы, как известно, могут делиться на peel, body и remainder. Body – собственно, векторизованная часть, где за одну инструкцию выполняется сразу несколько итераций. Peel появляется, если данные не выровнены на длину вектора, remainder – некратные итерации, остающиеся в конце.
У нас есть только Remainder. Это значит, что цикл векторизовался, но body не исполнялось. Закладка с рекомендациями говорит о неэффективности такой ситуации – нужно, чтобы больше итераций исполнялось в body, поскольку remainder здесь – обычный последовательный цикл. Проверим, сколько там всего итераций с помощью анализа Trip Counts:
![](//habrastorage.org/files/75f/e3c/13c/75fe3c13c259438fa0ee34be1f96e0f6.png)
В нашем цикле всего 8 итераций, что довольно мало. Если бы их было больше, векторизованному body было бы что исполнять. Номер строки изменился после модификаций кода, теперь это строка 179. Посмотрим, как определяется количество итераций:
Интересующий нас цикл вызывается из параллельного цикла библиотеки Intel® Threading Building Blocks (Intel TBB). Итерации внешнего цикла распределены между разными потоками, каждый из которых получает объект типа tbb::blocked_range2d – своё локальное пространство итераций. Насколько маленьким может быть количество итераций в этом пространстве, зависит от параметра, inner_grain_size. Т.е. значение r.rows().end(), определяющее количество итераций цикла на строке 179, зависит от inner_grain_size.
Находим в коде этот самый grain size (их два для двух вложенных параллельных циклов):
Пробуем увеличить inner_grain_size до 32. Ничего плохого от этого не ожидается, просто разделение работы по потокам Intel TBB будет более крупнозернистым. Смотрим результат:
![](//habrastorage.org/files/0fd/cd8/a9b/0fdcd8a9b7e3442885abf1afc47438a9.png)
Теперь в цикле появляется векторизованное body, мы наконец добились реального использования SIMD инструкций, цикл векторизован. Но радоваться рано – производительность никак не выросла.
Смотрим рекомендации Advisor XE – одна из них говорит о вызове сериализованной (последовательной) функции. Дело в том, что если векторный цикл вызывает функцию, то для неё нужна векторизованная версия, которая может принять параметры векторами, а не обычными скалярными переменными. Если компилятор не смог сгенерировать такую векторную функцию, то используется обычная, последовательная версия. И вызывается она тоже последовательно, сводя на нет всю векторизацию.
Снова смотрим код цикла:
Благо, вызов функции здесь один: calc_one_pixel. Раз компилятор не смог сгенерировать векторный вариант, попробуем ему помочь. Но сначала давайте посмотрим на шаблоны доступа к памяти, это пригодится для явной векторизации:
![](//habrastorage.org/files/356/982/2b6/3569822b699844ebb10f621fcc2b7700.png)
Анализ Memory Access Patterns нашего цикла (вместе с вызываемой функцией) говорит нам, что все операции чтения или записи – unit stride с шагом 0. Т.е. при обращении к внешним данным из каждой итерации читается или пишется одна и та же переменная:
![](//habrastorage.org/files/fe1/cea/2b6/fe1cea2b6da849389c3c455b40fa5b37.png)
Это знание нам пригодится для ручной векторизации функции. Мы воспользуемся директивой #pragma omp simd из стандарта OpenMP 4. У неё есть опции, определяющие шаблон доступа к параметрам. Например, «linear» используется для монотонно растущих величин, в основном итерационный переменных. Нам же подойдёт uniform – для доступа к одним и тем же данным.
Добавив директиву над определением функции, мы дали возможность компилятору сгенерировать её векторную версию, которая может обрабатывать массивы данных вместо объявленных скалярных. Получаем заметный рост скорости – цикл исполняется 7.5 секунд вместо 12:
![](//habrastorage.org/files/fa6/565/fb3/fa6565fb34a54bceb2f3c342c3c40c87.png)
Попробуем побороться с другими причинами неэффективности векторного цикла. Наличие части peel говорит о том, что данные не выровнены. Добавим __declspec(align(32)) перед определением массива, в который записываются результаты calc_one_pixel:
После этого peel пропадает:
![](//habrastorage.org/files/7a6/95f/e03/7a695fe034a14ed494294412168f2b2f.png)
В таблице диагностик Advisor XE можно обратить внимание ещё на одну вещь – колонка «Transformations» говорит, что компилятор развернул цикл (unrolled) с фактором 2:
![](//habrastorage.org/files/2d0/6ca/239/2d06ca2397f04627b257b28236b20279.png)
Т.е. в целях оптимизации количество итераций сокращено вдвое. Само по себе это не плохо, но в третьем шаге мы специально старались их увеличить, и получается, что компилятор делает обратное. Попробуем отключить развёртку с помощью #pragma nounroll:
После этого количество итераций ожидаемо увеличилось вдвое:
![](//habrastorage.org/files/e5a/de5/d3d/e5ade5d3d78c4e1a9ea24620cc92fad3.png)
Манипуляции с unrolling позволили увеличить количеств итераций, но улучшения производительности не последовало. Посмотрим, что будет, если ещё сильнее увеличить grain_size, как в шаге 3. Просто эмпирически прощупываем оптимальное значение:
Получается выжать ещё немного, хоть и совсем чуть-чуть:
![](//habrastorage.org/files/8ed/ce3/42a/8edce342a279412ca2be27e0b66d8277.png)
После всех манипуляций время работы тестового приложения сократилось с 4.51 до 1.92 секунд, т.е. мы достигли ускорения примерно в 2.3 раза. Напомню, что наш вычислительный тест уже был частично оптимизирован – неплохо распараллелен по потокам на Intel TBB, достигая хорошего ускорения и масштабируемости на многоядерных процессорах. Но из-за того, что векторизация не была использована полностью, мы всё равно недополучали половину возможной производительности. Первый вывод – векторизация может иметь большое значение, не стоит ей пренебрегать.
Теперь посмотрим эффект от различных изменений:
![](//habrastorage.org/files/0b9/529/c81/0b9529c817b74540b33cd1f303aa4413.png)
Форсирование векторизации и увеличение числа итераций (шаги 2 и 3) сами по себе программу не ускорили. Самый главный прирост скорости мы получили на шаге 4 после векторизации функции. Однако это совокупный эффект от шагов 2-4. Для реальной векторизации цикла было нужно и увеличить число итераций, и форсировать векторизацию, и векторизовать функцию.
А последующие шаги особого эффекта не имели, в нашем случае их можно было совсем пропустить. Шаг 7 можно более или менее отнести к успешным, и то из-за того, что в шаге 3 мы сразу не выставили большее число итераций. Однако целью поста было показать возможности Advisor XE на конкретном примере, поэтому описаны все проделанные действия.
Приложение взято из примеров библиотеки Intel® Threading Building Blocks (Intel TBB). Оно рисует фрактал Мандельброта и распараллелено по потокам с помощью Intel TBB. Т.е. преимущества многоядерного процессора оно использует — посмотрим, как обстоят дела с векторными инструкциями.
![](http://habrastorage.org/files/bf7/d45/80e/bf7d4580ee2e470c83f052b251d55f32.png)
Тестовая система:
• Ноутбук с Intel® Core i5-4300U (Haswell, 2 ядра, 4 аппаратных потока)
• IDE: Microsoft Visual Studio 2013
• Компилятор: Intel® Composer 2015 update 2
• Intel® Advisor XE 2016 Beta
• Приложение: фрактал Мандельброта, слегка модифицированное. См. <tbb_install_dir>\examples\task_priority
1. Оцениваем ситуацию
Итак, первый шаг: замеряем базовую версию, время работы: 4.51 секунды. Дальше запускаем Survey анализ в Intel® Advisor XE:
![](http://habrastorage.org/files/948/c1b/64d/948c1b64df004821ae4c6414cc7f4d4c.png)
Колонка «Loop Type» говорит о том, что все циклы скалярные, т.е. не используют SIMD инструкции. Самый затратный цикл в файле fractal.cpp на строке 74. Он тратит больше 13 секунд процессорного времени – им и займёмся. Колонка «Why No Vectorization» содержит сообщение о том, что компилятор не смог оценить количество итераций цикла, поэтому и не векторизовал его. Двойной клик на подсвеченный цикл, смотрим код:
while ( ((x*x + y*y) <= 4) && (iter < max_iterations) ) {
xtemp = x*x - y*y + fx0;
y = 2 * x*y + fy0;
x = xtemp;
iter++;
}
Становится ясно, почему количество итераций не известно во время компиляции. Прежде, чем погружаться в детали алгоритма и пробовать переписать while, попробуем пойти более простым путём. Посмотрим, откуда вызывается наш цикл – закладка Top Down:
![](http://habrastorage.org/files/1cb/372/082/1cb372082f4a42d7944ecf2f76c22f7e.png)
2. Форсируем векторизацию
Следующий по стеку вызовов цикл на строке 164. Это нормальный с виду for, который компилятор тоже не векторизовал (Scalar в колонке Loop Type), вероятно, не посчитав это эффективным. Диагностическое сообщение предлагает попробовать форсировать векторизацию, например, с помощью директивы SIMD:
![](http://habrastorage.org/files/45b/784/eff/45b784effd7c4136bf9975adbb832ba2.png)
Следуем совету:
#pragma simd // forced vectorization
for (int y = y0; y < y1; ++y) {
fractal_data_array[x - x0][y - y0] = calc_one_pixel(x, y, tmp_max_iterations, tmp_size_x, tmp_size_y, tmp_magn, tmp_cx, tmp_cy, 255);
}
Пересобираем программу и запускаем Survey опять. С #pragma simd цикл стал не «Scalar», а «Remainder»:
![](http://habrastorage.org/files/2bf/639/a29/2bf639a29881444985eaa84eeca9b24f.png)
SIMD циклы, как известно, могут делиться на peel, body и remainder. Body – собственно, векторизованная часть, где за одну инструкцию выполняется сразу несколько итераций. Peel появляется, если данные не выровнены на длину вектора, remainder – некратные итерации, остающиеся в конце.
У нас есть только Remainder. Это значит, что цикл векторизовался, но body не исполнялось. Закладка с рекомендациями говорит о неэффективности такой ситуации – нужно, чтобы больше итераций исполнялось в body, поскольку remainder здесь – обычный последовательный цикл. Проверим, сколько там всего итераций с помощью анализа Trip Counts:
![](http://habrastorage.org/files/75f/e3c/13c/75fe3c13c259438fa0ee34be1f96e0f6.png)
3. Увеличиваем число итераций
В нашем цикле всего 8 итераций, что довольно мало. Если бы их было больше, векторизованному body было бы что исполнять. Номер строки изменился после модификаций кода, теперь это строка 179. Посмотрим, как определяется количество итераций:
tbb::parallel_for(tbb::blocked_range2d<int>(y0, y1, inner_grain_size, x0, x1, inner_grain_size),
[&] (tbb::blocked_range2d<int> &r){
int x0 = r.cols().begin(),
y0 = r.rows().begin(),
x1 = r.cols().end(),
y1 = r.rows().end();
...
for (int x = x0; x < x1; ++x) {
for (int y = y0; y < y1; ++y) { // цикл на строке 179
fractal_data_array[x - x0][y - y0] = calc_one_pixel(x, y, tmp_max_iterations, tmp_size_x, tmp_size_y, tmp_magn, tmp_cx, tmp_cy, 255);
}
}
...
Интересующий нас цикл вызывается из параллельного цикла библиотеки Intel® Threading Building Blocks (Intel TBB). Итерации внешнего цикла распределены между разными потоками, каждый из которых получает объект типа tbb::blocked_range2d – своё локальное пространство итераций. Насколько маленьким может быть количество итераций в этом пространстве, зависит от параметра, inner_grain_size. Т.е. значение r.rows().end(), определяющее количество итераций цикла на строке 179, зависит от inner_grain_size.
Находим в коде этот самый grain size (их два для двух вложенных параллельных циклов):
int grain_size = 64;
int inner_grain_size = 8;
Пробуем увеличить inner_grain_size до 32. Ничего плохого от этого не ожидается, просто разделение работы по потокам Intel TBB будет более крупнозернистым. Смотрим результат:
![](http://habrastorage.org/files/0fd/cd8/a9b/0fdcd8a9b7e3442885abf1afc47438a9.png)
Теперь в цикле появляется векторизованное body, мы наконец добились реального использования SIMD инструкций, цикл векторизован. Но радоваться рано – производительность никак не выросла.
4. Векторизуем функцию
Смотрим рекомендации Advisor XE – одна из них говорит о вызове сериализованной (последовательной) функции. Дело в том, что если векторный цикл вызывает функцию, то для неё нужна векторизованная версия, которая может принять параметры векторами, а не обычными скалярными переменными. Если компилятор не смог сгенерировать такую векторную функцию, то используется обычная, последовательная версия. И вызывается она тоже последовательно, сводя на нет всю векторизацию.
Снова смотрим код цикла:
for (int y = y0; y < y1; ++y) { // цикл на строке 179
fractal_data_array[x - x0][y - y0] = calc_one_pixel(x, y, tmp_max_iterations, tmp_size_x, tmp_size_y, tmp_magn, tmp_cx, tmp_cy, 255);
}
Благо, вызов функции здесь один: calc_one_pixel. Раз компилятор не смог сгенерировать векторный вариант, попробуем ему помочь. Но сначала давайте посмотрим на шаблоны доступа к памяти, это пригодится для явной векторизации:
![](http://habrastorage.org/files/356/982/2b6/3569822b699844ebb10f621fcc2b7700.png)
Анализ Memory Access Patterns нашего цикла (вместе с вызываемой функцией) говорит нам, что все операции чтения или записи – unit stride с шагом 0. Т.е. при обращении к внешним данным из каждой итерации читается или пишется одна и та же переменная:
![](http://habrastorage.org/files/fe1/cea/2b6/fe1cea2b6da849389c3c455b40fa5b37.png)
Это знание нам пригодится для ручной векторизации функции. Мы воспользуемся директивой #pragma omp simd из стандарта OpenMP 4. У неё есть опции, определяющие шаблон доступа к параметрам. Например, «linear» используется для монотонно растущих величин, в основном итерационный переменных. Нам же подойдёт uniform – для доступа к одним и тем же данным.
#pragma omp declare simd uniform(x0, max_iterations, size_x, size_y, magn, cx, cy, gpu)
color_t fractal::calc_one_pixel(float x0, float y0, int max_iterations, int size_x, int size_y, float magn, float cx,
Добавив директиву над определением функции, мы дали возможность компилятору сгенерировать её векторную версию, которая может обрабатывать массивы данных вместо объявленных скалярных. Получаем заметный рост скорости – цикл исполняется 7.5 секунд вместо 12:
![](http://habrastorage.org/files/fa6/565/fb3/fa6565fb34a54bceb2f3c342c3c40c87.png)
5. Выравниваем данные
Попробуем побороться с другими причинами неэффективности векторного цикла. Наличие части peel говорит о том, что данные не выровнены. Добавим __declspec(align(32)) перед определением массива, в который записываются результаты calc_one_pixel:
__declspec(align(32)) color_t fractal_data_array[delta_x][(delta_y / 16 + 1) * 16]; // aligned data
for (int x = x0; x < x1; ++x) {
#pragma simd
for (int y = y0; y < y1; ++y) {
fractal_data_array[x - x0][y - y0] = calc_one_pixel(x, y, tmp_max_iterations, tmp_size_x, tmp_size_y, tmp_magn, tmp_cx, tmp_cy, 255);
}
}
После этого peel пропадает:
![](http://habrastorage.org/files/7a6/95f/e03/7a695fe034a14ed494294412168f2b2f.png)
6. Убираем развёртку цикла (unrolling)
В таблице диагностик Advisor XE можно обратить внимание ещё на одну вещь – колонка «Transformations» говорит, что компилятор развернул цикл (unrolled) с фактором 2:
![](http://habrastorage.org/files/2d0/6ca/239/2d06ca2397f04627b257b28236b20279.png)
Т.е. в целях оптимизации количество итераций сокращено вдвое. Само по себе это не плохо, но в третьем шаге мы специально старались их увеличить, и получается, что компилятор делает обратное. Попробуем отключить развёртку с помощью #pragma nounroll:
#pragma simd
#pragma nounroll // added unrolling
for (int y = y0; y < y1; ++y) {
fractal_data_array[x - x0][y - y0] = calc_one_pixel(x, y, tmp_max_iterations, tmp_size_x, tmp_size_y, tmp_magn, tmp_cx, tmp_cy, 255);
}
После этого количество итераций ожидаемо увеличилось вдвое:
![](http://habrastorage.org/files/e5a/de5/d3d/e5ade5d3d78c4e1a9ea24620cc92fad3.png)
7. Ещё больше увеличиваем число итераций
Манипуляции с unrolling позволили увеличить количеств итераций, но улучшения производительности не последовало. Посмотрим, что будет, если ещё сильнее увеличить grain_size, как в шаге 3. Просто эмпирически прощупываем оптимальное значение:
int grain_size = 256; // increase from 64
int inner_grain_size = 64; // increase from 8
Получается выжать ещё немного, хоть и совсем чуть-чуть:
![](http://habrastorage.org/files/8ed/ce3/42a/8edce342a279412ca2be27e0b66d8277.png)
8. Результаты
После всех манипуляций время работы тестового приложения сократилось с 4.51 до 1.92 секунд, т.е. мы достигли ускорения примерно в 2.3 раза. Напомню, что наш вычислительный тест уже был частично оптимизирован – неплохо распараллелен по потокам на Intel TBB, достигая хорошего ускорения и масштабируемости на многоядерных процессорах. Но из-за того, что векторизация не была использована полностью, мы всё равно недополучали половину возможной производительности. Первый вывод – векторизация может иметь большое значение, не стоит ей пренебрегать.
Теперь посмотрим эффект от различных изменений:
![](http://habrastorage.org/files/0b9/529/c81/0b9529c817b74540b33cd1f303aa4413.png)
Форсирование векторизации и увеличение числа итераций (шаги 2 и 3) сами по себе программу не ускорили. Самый главный прирост скорости мы получили на шаге 4 после векторизации функции. Однако это совокупный эффект от шагов 2-4. Для реальной векторизации цикла было нужно и увеличить число итераций, и форсировать векторизацию, и векторизовать функцию.
А последующие шаги особого эффекта не имели, в нашем случае их можно было совсем пропустить. Шаг 7 можно более или менее отнести к успешным, и то из-за того, что в шаге 3 мы сразу не выставили большее число итераций. Однако целью поста было показать возможности Advisor XE на конкретном примере, поэтому описаны все проделанные действия.
fingoldo
Как всегда у Кирилла, очень полезный пример оптимизации! Спасибо!
krogozh Автор
Спасибо )