Pull to refresh

Comments 23

К сожалению, подсветки кода на С на Хабре нет, поэтому без неё( Сами были удивлены.

C++? Хоть и не под язык, но будет точно лучше отсутствия.

Рекомендую следующий алгоритм использования:

if( data_volume < cpu_cache_size )
   	storeu
 else {
   	stream

Для стерильных условий понятно, а как быть в реальных, когда потоков может быть очень много, на виртуализированных серверах, где программист не знает сколько ему достанется кеша в каждый конкретный тик.

Да, запуск предполагается в штатных условиях и это обучающая статья, для знакомства с кэшем, в том числе с non-temporal инструкциями.

А почему вы везде используете _mm***_loadu_si***, которые вообще говоря предназначены для загрузки из невыровненных данных, вместо операторов, которые специально заточены под выровненные данные (если вы данные в любом случае выравниваете)?

Да, Вы правы, но в современных процессорах *a и *u инструкции работают практически одинаково, это избавляет от необходимости делать 2 ветви кода, да и компилятор может неявно вставить *u, вместо *a интринсика.

  • В статье ни слова про restrict.

  • Векторизованные функции не эквивалентны изначальному скалярному варианту так как будет выход за границы буфера если его размер не кратен 32/64 и т.д.

Да, и restrict тоже бывает эффективен для векторизации простых циклов, но все методики просто не поместятся в одну статью. Все зависит от интереса к теме у аудитории.

По-моему в части примеров кода они больше "псевдокод", типа переменная не определена, какой-то цикл &(len~63), а потом за ним опять цикл по всему len (то есть делаем то же самое, что в "неоптимизированном" варианте, но сначала что-то с интринсиками)

Простите за тупой вопрос, но разве такие оптимизации - это задача программиста, а не компилятора? Я, правда, пишу не на Си, но вроде бы логично предположить, что увидев примерно

такой код

subroutine compute_result(result,x,z)
real :: result(1), x(1), z(1)
...
where (isNan(x)); result=z+42
else where (x /= 0.); result=z/x
else; result=result+z
end where
...

и ключ компиляции /arch:AVX, компилятор должен сам сделать все остальное? Тем более, что на этапе компиляции я еще не знаю фактическое число элементов в массивах result, x и z, да и вообще не царское это дело - оптимизировать код под конкретную архитектуру вручную?

Что касается моего древнего языка, то, например, Интел-компилятор такую оптимизацию

делает

если лень (или не умеешь) залезать в ассемблерный листинг, это видно просто из сравнения скорости тестовых программ, собранных с разными "архитектурными" расширениями

По идее, в современных языках все должно быть аналогично? А если даже какие-то нюансы пока не реализованы, то, учитывая тенденцию к конвергенции полезных фич в разных языках, это только вопрос времени.

Но тогда зачем это делать вручную? Может быть, правильнее учиться приемам высокоуровневого кодирования, которые помогут компилятору оптимизировать все именно так, как Вам хочется?

P.S. Замечу, что это вовсе не отменяет полезности статей, подобных этой (наоборот, два плюса этому автору!) Но только читать их мы будем не для того, чтобы воспроизвести такие решения в своем коде, а чтобы лучше понимать: во что на самом деле превращает вашу программу оптимизирующий компилятор. И писать свой код так, чтобы он мог все это сделать за программиста. То есть заменять циклы массивными операциями и т.д.

в идеальном мире хотелось бы, чтобы компиляторы были такими умными

в реальном мире -- куча кейсов, когда компилятор не справляется

Оптимизирующие компиляторы значительно хуже умеют оптимизировать обращения к памяти, чем вычисления на регистрах.

for(i=0;i<len;i+=64){ __m512i x0=_mm512_loadu_si512(src+i); _mm512_storeu_si512(dst+i, x0); }

А у вас что - процессоры без поддержки конвеера?

То есть уже давно было придумано (ещё в прошлом веке), что последовательно выполнение команд (без джампов) позволяет улучшить их среднее время выполнения (вплоть до одного такта) за счёт прогнозной предобработки

в этом случае код обычно выглядит так

switch(len) {

...

case 256: x0=_mm512_loadu_si512(src+192); _mm512_storeu_si512(dst+192, x0);
case 192:x0=_mm512_loadu_si512(src+128); _mm512_storeu_si512(dst+128, x0);
case 128: x0=_mm512_loadu_si512(src+64); _mm512_storeu_si512(dst+64, x0);
case 64:x0=_mm512_loadu_si512(src+0); _mm512_storeu_si512(dst+, x0);

...

}

Ну а Intel (в отличии от AMD) любят делать процессоры с конвеерной обработкой команд.

И Motorola тоже использовала такой подход когда-то для своих RISC процессоров

Ну а Intel (в отличии от AMD) любят делать процессоры с конвеерной обработкой команд.

Откуда такие странные умозаключения? Даже 6502 был конвейеризирован (частично).

Это наверное какой-то запоздалый укор АМД из-за неконвейеризирванного FPU K6?

Любые современные процессоры, что Intel, что AMD, что ARM - конвейеризированные. Более того, все "большие" процессоры реализуют механизм Out of Order выполнения и разбивают сложные операции в микрооперации.

Простого цикла load/store достаточно, чтобы многократно превзойти ПСП, и не нужно тут duff's device лепить.

Тем не менее компилятор освобождает вас от обезьяней работы и раскрывает цикл.

https://gcc.godbolt.org/z/E74nGs1Gs

.LBB0_8:                                # =>This Inner Loop Header: Depth=1
        vmovups zmm0, zmmword ptr [rdi + rdx]
        vmovups zmmword ptr [rsi + rdx], zmm0
        vmovups zmm0, zmmword ptr [rdi + rdx + 64]
        vmovups zmmword ptr [rsi + rdx + 64], zmm0
        vmovups zmm0, zmmword ptr [rdi + rdx + 128]
        vmovups zmmword ptr [rsi + rdx + 128], zmm0
        vmovups zmm0, zmmword ptr [rdi + rdx + 192]
        vmovups zmmword ptr [rsi + rdx + 192], zmm0
        add     rdx, 256
        add     rcx, -4
        jne     .LBB0_8

Тогда звиняйте - мои знания устарели. Я на asm, C, C++ в прошлом веке писал.В этом веке только немного под CUDA на C/C++.

Поможет ли табличная замена оптимизировать следующую простую функцию:

void video_converter_matrix8_table(MatrixData *data, gpointer pixels) {
    gint i, width = data->width * 4;
    guint8 r, g, b;
    gint64 c = data->t_c;  // 0x0000100080008000
    guint8 *p = pixels;
    gint64 x;

    for (i = 0; i < width; i += 4) {
        r = p[i + 1];
        g = p[i + 2];
        b = p[i + 3];

        x = data->t_r[r] + data->t_g[g] + data->t_b[b] + c;

        p[i + 1] = x >> (32 + SCALE);
        p[i + 2] = x >> (16 + SCALE);
        p[i + 3] = x >> (0 + SCALE);
    }
}

https://github.com/GStreamer/gst-plugins-base/blob/ce937bcb21412d7b3539a2da0509cc96260562f8/gst-libs/gst/video/video-converter.c#L1190
Как это эффективнее всего переписать используя AVX-регистры?

  1. Загружаем в регистр сразу несколько пикселей, можно начать с 4-х пикселей(16 байт) в 128-битный регистр (лучше конечно, 32 байта в 256-битный, но код будет существенно длиннее). 4-й байт для каждого пикселя тоже загружаем

  2. Теперь строим отдельно 3 регистра, содержащих только r-компоненты, g-компоненты, b-компоненты соответственно. Строить проще всего через shuffle-инструкцию - выделяем нужные байты из исходного регистра, и располагаем их в требуемых местах в выходном регистре так, чтобы каждая компонента занимала 4 байта. Это требуется для последующего вызова gather-инструкций

  3. В 256-битные регистры загружаем 64-битные элементы массивов tr_r, t_g, t_b через gather-инструкции, используя регистры с этапа 2 как индексы

  4. Складываем их между собой, прибавляя вектор констант с (инициализированный вне цикла), получаем 4 64-битных числа x в одном регистре

  5. Поскольку SCALE == 8, то последняя итерация заключается в выделении определенных байтов из результата 4, выполняем его снова через shuffle, получаем 4 пикселя в 128-битном регистре.

  6. Дополнительно нужно взять alpha-компоненту с исходного регистра 1, чтобы не потерять ее при выгрузке. Для выбора из 2-х регистров по маске тоже есть соответствующая инструкция

  7. Выгружаем 128-битный регистр как единое целое

    Как-то так. Ну и отдельно нужно обработать хвост, не кратный 4

    И разумеется, проверить, что результат выполнения соответствует оригинальному коду :)

Спасибо за статью.

Как реализовать замену байт? Каждый байт имеет всего 2^8 = 256 возможных значений. Создадим специальную lookup-таблицу (LUT).
Ускорение: 619 мс / 34 мс = 18 раз.

Интересно бы сравнить эти результаты со старыми друзьями lodsb/ror/stosb, если говорим про смену местами 4х бит
Да это будет х86-lock. Но думаю и в других процах что-то подобное есть.

В ARM NEONe есть, весьма удобно.

Спасибо за статью. Я бы добавил небольшой раздел про то, как определить наличие этого самого расширения AVX-512VBMI (далеко не каждый сервер его поддерживает, не говоря уже про десктоп). А так же что, делать если такого расширения нет.

Если опустить всякие мелкие нюансы, есть один фундаментальный вопрос - а каким образом копирование элементов, которое, вообще говоря, не переиспользует данные, зависит от каких-то там размеров кэша?

Если алгоритм префетча не имеет каких-то особенностей, то любое обращение в память при копировании, не инициирующее Page fault/TLB miss должно ВСЕГДа попадать в L1/L2 кэш (опять таки, в зависимости от логики префетча, куда он данные таскает). Поэтому все приведённые в статье замеры выглядят крайне подозрительно.

Sign up to leave a comment.