company_banner

Intel® Graphics Technology. Часть III: эффективные вычисления на графике

    image

    В комментариях к прошлому посту был поднят весьма важный вопрос – а будет ли вообще выигрыш в производительности от выгрузки вычислений на интегрированную графику, по сравнению с выполнением только на CPU? Конечно, он будет, но нужно соблюдать определенные правила программирования для эффективных вычислений на GFX+CPU.
    В подтверждение моих слов, сразу представлю график ускорения, получаемого при выполнении вычислений на интегрированной графике, для различных алгоритмов и с разной долей вовлеченности CPU. На КДПВ мы видим, что выигрыш более чем весомый.

    Многие скажут, что тут вообще непонятно, что это за алгоритмы и что там с этим кодом делали, чтобы получить такие результаты.
    Поэтому рассмотрим, как добиться таких впечатляющих результатов для эффективного выполнения на GFX.
    Для начала, попробуем собрать все особенности и способы вместе, учитывая наши знания о специфике самой железки, а затем перейдем к реализации на конкретном примере с помощью Intel Graphics Technology. Итак, что делать, чтобы получить высокую производительность:
    • Увеличиваем итерационное пространство за счет использования вложенных cilk_for. В результате имеем больший ресурс для параллелизма и большее количество потоков на GPU может быть занято.
    • Для векторизации кода (да, для GPU она так же очень важна, как и для CPU), который будет оффлоадиться, используем директиву pragma simd или нотацию массива Intel®Cilk™ Plus.
    • Используем ключевое слово __restrict__ и __assume_aligned() для того, чтобы компилятор не создавал различные версии кода (code paths). Например, он может генерировать две версии для работы с выровненной и не выравненной памятью, что будет проверяться в рантайме и исполнение пойдёт по нужной «ветке».
    • Не забываем, что pin в директиве pragma offload позволяет избегать оверхеда от копирования данных между DRAM и памятью карты и позволяет использовать общую память для CPU и GPU.
    • Отдаем предпочтение работе с 4-байтными элементами, чем 1- или 2-байтными, потому что операции gather/scatter с таким размером намного эффективнее. Для случая с 1,2 байтами они намного медленнее.
    • Ещё лучше – это избегать gather/scatter инструкций. Для этого, используем структуру данных SoA (Structure of Arrays) вместо AoS (Array of Structures). Здесь всё предельно просто – данные лучше хранить в массивах, тогда доступ к памяти будет последовательным и эффективным.
    • Одна из наиболее сильных сторон GFX – это свои 4 KB регистрового файла у каждого потока. Если локальные переменные будут превышать этот размер – придется работать с гораздо более медленной памятью.
    • При работе с массивом int buf[2048], выделенного в регистровом файле (GRF), в цикле вида for(i=0,2048) {… buf[i] … } будет осуществляться индексированный доступ к регистру. Для того, чтобы работать с прямой адресацией, делаем развертку цикла (loop unrolling) с помощью директивы pragma unroll.

    Теперь давайте посмотрим как всё это работает. Не стал брать самый простой пример умножения матриц, а немного его модифицировал, используя нотацию массива Cilk Plus для векторизации, и оптимизацию сache blocking.
    Решил честно изменять код и смотреть, как меняется производительность.
    void matmul_tiled(float A[][K], float B[][N], float C[][N])
    {
    	for (int m = 0; m < M; m += TILE_M) {                 // iterate tile rows in the result matrix
    		for (int n = 0; n < N; n += TILE_N) {             // iterate tile columns in the result matrix
    			// (c) Allocate current tiles for each matrix:
    			float atile[TILE_M][TILE_K], btile[TILE_N], ctile[TILE_M][TILE_N];
    			ctile[:][:] = 0.0;                            // initialize result tile
    
    			for (int k = 0; k < K; k += TILE_K) {         // calculate 'dot product' of the tiles
    				atile[:][:] = A[m:TILE_M][k:TILE_K];      // cache atile in registers;
    				for (int tk = 0; tk < TILE_K; tk++) {     // multiply the tiles
    					btile[:] = B[k + tk][n:TILE_N];       // cache a row of matrix B tile 
    					for (int tm = 0; tm < TILE_M; tm++) { // do the multiply-add
    						ctile[tm][:] += atile[tm][tk] * btile[:];
    					}
    				}
    			}
    			C[m:TILE_M][n:TILE_N] = ctile[:][:];  // write the calculated tile to back memory
    		}
    	}
    }
    

    Плюсы подобного алгоритма с блочной работой с матрицами понятен — мы пытаемся избежать проблемы с кэшем, и для этого изменяем размеры TILE_N, TILE_M и TILE_K. Собрав этот пример компилятором Intel c оптимизацией и размерами матриц M и K равными 2048, а N — 4096, запускаю приложение. Время просчета составляет 5.12 секунд. В этом случае мы использовали только векторизацию средствами Cilk'а (причем, набор SSE инструкций по дефолту). Нам нужно реализовать и параллелизм по задачам. Для этого можно воспользоваться cilk_for:
    cilk_for(int m = 0; m < M; m += TILE_M)
    ...
    

    Пересобираем код и снова запускаем на выполнение. Ожидаемо, получаем почти линейное ускорение. На моей системе с 2 ядерным процессором, время составило 2.689 секунд. Пришло время задействовать оффлоад на графику и посмотреть, что мы можем выиграть в производительности. Итак, используя директиву pragma offload и добавляя вложенный цикл cilk_for, получаем:
    #pragma offload target(gfx) pin(A:length(M)) pin(B:length(K)) pin(C:length(M))
    	cilk_for(int m = 0; m < M; m += TILE_M) {                 
    		cilk_for(int n = 0; n < N; n += TILE_N) {            
    ...
    

    Приложение с оффлоадом выполнялось 0.439 секунды, что весьма неплохо. В моем случае, дополнительные модификации с unroll'ингом циклов не показали серьёзного прироста в производительности. А вот ключевую роль сыграл алгоритм работы с матрицами. Размеры TILE_M и TILE_K были выбраны равными 16, а TILE_N — 32. Таким образом sizeof(atile) составил 1 KB, sizeof(btile) — 128 B, а sizeof(ctile) — 2 KB. Я думаю, понятно, к чему я всё это сделал. Правильно, общий размер 1 KB + 2 KB + 128 B оказался меньше 4 KB, а значит мы работали с самой быстрой памятью (регистровом файлом), доступной каждому потоку на GFX.
    Кстати, обычный алгоритм работал намного дольше (порядка 1.6 секунд).
    Ради эксперимента, я включил генерацию AVX инструкций и ещё несколько ускорил выполнение только на CPU до 4.098 секунд, а версии с Cilk'ом по задачам — до 1.784. Тем не менее, именно оффлоад на GFX позволил существенно увеличить производительность.
    Я не поленился, и решил посмотреть, что может отпрофилировать VTune Amplfier XE в подобном приложении.
    Собрал код с дебаг информацией и запустил Basic Hotspot анализ с галочкой 'Analyze GPU usage':
    image

    Интересно, что для OpenCL там есть отдельная опция. Собрав профиль и полазив по вкладкам Vtune'а, нашёл такую информацию:

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

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

    Comments 4

      0
      Как то мне кажется это всё не очень прозрачно. Я бы просто напрямую OpenCL использовал.
        0
        Ну в определенном смысле в «непрозрачности» и есть идея — детали скрыты, конструкции для оффлоада простые.
        OpenCL более универсальное средство, но там нужно гораздо больше всего делать ручками.
          0
          Здесь 2 основных преимущества над OpenCL:
          1. «Single source» — не надо специальные исходные файлы создавать с кодом для устройств.
          2. нативная поддержка C++.

          «не очень прозрачно» в этой статье можно сказать про примеры кода с тайлингом. Но в этом плане, если оптимизировать OpenCL код под какое-то конкретное устройство, то и тайлинг будет и лишние буфера, чтобы шина не вставала, когда разные устройства обращаются в память.
          0
          Было бы неплохо, если бы это добавили в Intel Media SDK. К примеру, можно было бы в коде выбирать backend — HW encode/decode и ускорение на ядрах графического процессора (аппаратный энкодер/декодер сейчас на отдельном чипе работает). Тогда, можно было бы ускорить операции с медиаданными в нескольких потоках.

          Only users with full accounts can post comments. Log in, please.