Производительность цикла кода C
У меня в приложении многократно добавлено ядро, и я хочу повысить его производительность.
Я использую Intel Core i7-960 (тактовая частота 3,2 ГГц) и уже вручную внедрил ядро, используя встроенные функции SSE:
for(int i=0; i<iterations; i+=4) {
y1 = _mm_set_ss(output[i]);
y2 = _mm_set_ss(output[i+1]);
y3 = _mm_set_ss(output[i+2]);
y4 = _mm_set_ss(output[i+3]);
for(k=0; k<ksize; k++){
for(l=0; l<ksize; l++){
w = _mm_set_ss(weight[i+k+l]);
x1 = _mm_set_ss(input[i+k+l]);
y1 = _mm_add_ss(y1,_mm_mul_ss(w,x1));
…
x4 = _mm_set_ss(input[i+k+l+3]);
y4 = _mm_add_ss(y4,_mm_mul_ss(w,x4));
}
}
_mm_store_ss(&output[i],y1);
_mm_store_ss(&output[i+1],y2);
_mm_store_ss(&output[i+2],y3);
_mm_store_ss(&output[i+3],y4);
}
Я знаю, что могу использовать упакованные векторы fp для повышения производительности, и я уже сделал это успешно, но я хочу знать, почему один скалярный код не может удовлетворить пиковую производительность процессора.
Производительность этого ядра на моей машине составляет ~1,6 FP операций за такт, в то время как максимум будет 2 FP операции за такт (поскольку FP add + FP mul может выполняться параллельно).
Если я прав, изучив сгенерированный ассемблерный код, идеальное расписание будет выглядеть следующим образом, где mov
инструкция занимает 3 цикла, задержка переключения из области загрузки в область FP для зависимых команд занимает 2 цикла, умножение FP занимает 4 цикла, а добавление FP - 3 цикла. (Обратите внимание, что зависимость от multiply -> add не требует задержки переключения, поскольку операции принадлежат одному домену).
Согласно измеренной производительности (~80% от максимальной теоретической производительности) накладные расходы составляют ~3 инструкции на 8 циклов.
Я пытаюсь либо:
- избавиться от этих накладных расходов, или
- объясните откуда это
Конечно, существует проблема с отсутствием кэша и смещением данных, которое может увеличить задержку команд перемещения, но есть ли другие факторы, которые могут сыграть здесь роль? Как зарегистрировать чтение киосков или что-то?
Я надеюсь, что моя проблема ясна, заранее спасибо за ваши ответы!
Обновление: сборка внутреннего цикла выглядит следующим образом:
...
Block 21:
movssl (%rsi,%rdi,4), %xmm4
movssl (%rcx,%rdi,4), %xmm0
movssl 0x4(%rcx,%rdi,4), %xmm1
movssl 0x8(%rcx,%rdi,4), %xmm2
movssl 0xc(%rcx,%rdi,4), %xmm3
inc %rdi
mulss %xmm4, %xmm0
cmp $0x32, %rdi
mulss %xmm4, %xmm1
mulss %xmm4, %xmm2
mulss %xmm3, %xmm4
addss %xmm0, %xmm5
addss %xmm1, %xmm6
addss %xmm2, %xmm7
addss %xmm4, %xmm8
jl 0x401b52 <Block 21>
...
3 ответа
Я заметил в комментариях, что:
- Цикл занимает 5 циклов для выполнения.
- Предполагается, что это займет 4 цикла. (так как есть 4 добавления и 4 мультипликации)
Тем не менее, ваша сборка показывает 5 SSE movssl
инструкции. Согласно таблицам Агнера Фога все инструкции по перемещению SSE с плавающей запятой имеют не менее 1 инст / цикл обратной пропускной способности для Nehalem.
Так как у вас есть 5 из них, вы не можете сделать лучше, чем 5 циклов / итерация.
Таким образом, чтобы достичь максимальной производительности, вам нужно уменьшить количество нагрузок, которые у вас есть. Как вы можете это сделать, я не могу сразу увидеть этот конкретный случай - но это может быть возможно.
Одним из распространенных подходов является использование листов. Где вы добавляете уровни вложения, чтобы улучшить местность. Хотя он в основном используется для улучшения доступа к кешу, его также можно использовать в регистрах, чтобы уменьшить количество необходимых загрузок / хранилищ.
В конечном счете, ваша цель состоит в том, чтобы уменьшить количество загрузок до меньшего, чем количество добавок / муль. Так что это может быть путь.
Большое спасибо за ваши ответы, это многое объяснило. В продолжение моего вопроса, когда я использую упакованные инструкции вместо скалярных инструкций, код с использованием встроенных функций будет выглядеть очень похоже:
for(int i=0; i<size; i+=16) {
y1 = _mm_load_ps(output[i]);
…
y4 = _mm_load_ps(output[i+12]);
for(k=0; k<ksize; k++){
for(l=0; l<ksize; l++){
w = _mm_set_ps1(weight[i+k+l]);
x1 = _mm_load_ps(input[i+k+l]);
y1 = _mm_add_ps(y1,_mm_mul_ps(w,x1));
…
x4 = _mm_load_ps(input[i+k+l+12]);
y4 = _mm_add_ps(y4,_mm_mul_ps(w,x4));
}
}
_mm_store_ps(&output[i],y1);
…
_mm_store_ps(&output[i+12],y4);
}
Измеренная производительность этого ядра составляет около 5,6 операций FP за такт, хотя я ожидаю, что она будет в 4 раза выше производительности скалярной версии, то есть 4,1,6=6,4 операций FP за такт.
С учетом изменения весового коэффициента (спасибо за указание на это), график выглядит следующим образом:
Похоже, что расписание не меняется, хотя после movss
операция, которая перемещает скалярное значение веса в регистр XMM, а затем использует shufps
скопировать это скалярное значение во всем векторе. Кажется, что весовой вектор готов к использованию для mulps
вовремя принимая во внимание задержку переключения с нагрузки на домен с плавающей запятой, так что это не должно вызывать дополнительной задержки.
movaps
(выровненный, упакованный ход),addps
& mulps
инструкции, используемые в этом ядре (проверенные с помощью ассемблерного кода), имеют такую же задержку и пропускную способность, что и их скалярные версии, поэтому это также не должно вызывать дополнительной задержки.
У кого-нибудь есть идея, на что тратится этот дополнительный цикл на 8 циклов, предполагая, что максимальная производительность, которую может получить это ядро, составляет 6,4 FP операций за такт, а скорость его работы составляет 5,6 FP операций на такт?
Еще раз спасибо за вашу помощь!
Делая это ответом из моего комментария.
В несерверном дистрибутиве Linux я полагаю, что таймер прерывания обычно установлен на 250 Гц по умолчанию, хотя это зависит от дистрибутива, он почти всегда превышает 150. Эта скорость необходима для обеспечения интерактивного графического интерфейса 30+fps. Этот таймер прерывания используется для вытеснения кода. Это означает, что 150+ раз в секунду ваш код прерывается, и код планировщика запускается и решает, что уделить больше времени. Похоже, вы отлично справляетесь, просто набирая 80% максимальной скорости, никаких проблем нет. Если вам нужно лучше установить, скажем, Ubuntu Server (100 Гц по умолчанию) и немного подправить ядро (выгрузка отключена)
РЕДАКТИРОВАТЬ: В системе с ядром 2+ это оказывает гораздо меньшее влияние, так как ваш процесс почти наверняка будет добавлен к одному ядру и более или менее оставлен, чтобы делать свое дело.