среда, 30 мая 2007 г.

SSE из управляемого кода

Ещё с 90х годов процессоры Intel и AMD поддерживают в некоторой степени SIMD параллелизм в форме MMX и SSE. Несмотря на то, что большинство наших программ ориентированы на MIMD вычисления, исследования SIMD параллелизма ведутся ещё с начала 80 х. Вообще это направление выглядит весьма перспективным, хотя и не настолько разрекламированным как многоядерные процессоры. В частности идеи векторизации весьма популярны среди суперкомпьютерного сообщества и сообщества FORTRAN программистов. Интерес к этой теме подогревается также GPGPU сообществом (см., например, здесь и здесь), что в сочетании с многоядерной чехардой, дает интересную почву для размышлений.

Сегодня мы можем использовать SSE из управляемого кода, при этом правда требуется некоторая ловкость рук и интероп, который нехило уменьшает производительность. Вот простой пример – перемножение двух массивов.

Поскольку мы не можем прямо исползовать SEE инструкции в управляемом коде, нам придется вначале создать небольшую DLL. Назовем её “vecthelp.dll” и экспортнем из неё всего одну функцию:

#include 
const int c_vectorStride = 4;
extern "C" __declspec(dllexport)
void VectMult(float * src1, float * src2, float * dest, int length) {
for (int i = 0; i < length; i += c_vectorStride) {
// Vector load, multiply, store.
__m128 v1 = _mm_load_ps(src1 + i); // MOVAPS
__m128 v2 = _mm_load_ps(src2 + i); // MOVAPS
__m128 vresult = _mm_mul_ps(v1, v2); // MULPS
_mm_store_ps(dest + i, vresult); // MOVAPS
}
}

“VectMult” принимает два указателя на массивы float-ов – “src1” и “src2” – каждый из которых имеет размер “length” и затем сохраняет результат их перемножения в массив “dest”. Обратите внимание мы перемещаемся по массиву с шагом 4 (c_vectorStride = 4). На каждой итерации мы загружаем 4 смежных элемента массивов “src1” и “src2” в XMMx регистры. Именно это делают SSE-инструкции “_mm_load_ps” . Затем с помощью “_mm_mul_ps” мы перемножаем эти 4 элемента параллельно. После этого результат сохраняется в массиве “dest”. Предполагается, что длина массивов кратна 4.

Теперь чтобы использовать этот параллельный умножитель из управляемого кода мы вынуждены использовать P/Invoke. Да при этом ещё нельзя забывать о том, что SSE работает с 16 байтным выравниванием(alignment), поэтому нам придется немного поколдовать, чтобы заставить CLR выдать нам его. Я размещают массив в стэке, чтобы не делать pinning массива(запрет на автоматическое перемещение переменной в памяти, без пиннинга сборщик мусора при устранении фрагментации памяти может произвольно менять адрес переменной, разумеется он автоматически перенастраивает все ссылки на новое местоположение переменной, но при интеропе это не допустимо и нужно либо делать пиннинг, либо размещать переменную в стэке). При больших массивах правда может возникнуть переполнение стэка. Короче это просто пример:

using System;
unsafe class Program {
[System.Runtime.InteropServices.DllImport("vecthelp.dll")]
private extern static void VectMult(float * src1, float * src2, float * dest, int length);

public static void Main() {
const int vecsize = 1024 * 16; // 16KB of floats.
float* a = stackalloc float[vecsize + (16 / sizeof(float)) -1];
float* b = stackalloc float[vecsize + (16 / sizeof(float)) -1];
float* c = stackalloc float[vecsize + (16 / sizeof(float)) -1];
// To use SSE, we must ensure 16 byte alignment.
a = (float *)AlignUp(a, 16);
b = (float *)AlignUp(b, 16);
c = (float *)AlignUp(c, 16);
// Initialize 'a' and 'b':
for (int i = 0; i < vecsize; i++) {
a[i] = i;
b[i] = vecsize - i;
}
// Now perform the multiplication.
VectMult(a, b, c, vecsize);
... do something with c ...
}
private static void * AlignUp(void * p, ulong alignBytes) {
ulong addr = (ulong)p;
ulong newAddr = (addr + alignBytes - 1) & ~(alignBytes - 1);
return (void *)newAddr;
}
}

Хотелось бы конечно, чтобы при этом результирующая производительность возросла в 4 раза. К сожалению P/Invoke делает свое черное дело и прирост производительности будет заметен только на больших массивах. Интересно многим ли из вас нужно вычислять произведение 16MB массивов. Кто-то безусловно занимается этим, но не думаю, что таких людей много. Однако даже за вычетом издержек на P\Invoke в итоге мы получаем 2 кратный прирост производительности на небольших массивах, 3х кратное увеличение на массивах большого размера.
Очевидно, что в будущем поддержка векторных операций в процессорах будет только расти и следовательно эти цифры изменяться в большую сторону. Возможно, даже когда-нибудь мы сможем пользоваться SSE, не прибегая к интеропу. Представьте – на 32х ядерной машине, если на каждом ядре будет стоять 16-ти позиционное векторное АЛУ, то мы в итоге получим 32х16 (512) потенциал для параллельного исполнения кода…, конечно, если мы сможем разбить нашу задачу на 512 независимых подзадач. Популярность GPU отчасти обусловлена более «широким» АЛУ, которыми они снабжены. Может быть как-нибудь я расскажу как можно сложить два массива параллельно с использованием пиксельных шейдеров и DirectX.

(с)Джо Даффи. Перевод мой.

Источник - Блог Джо Даффи (Joe Duffy)