Производительность кода кода 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 не несет никакой задержки на переключение, поскольку операции принадлежат одному домену).

schedule

В соответствии с измеренной производительностью (~ 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> 
...

Ответ 1

Я заметил в комментариях, что:

  • Для выполнения цикла требуется 5 циклов.
  • "Предполагается" принимать 4 цикла. (так как там добавлено 4 и 4 мулитипа)

Однако ваша сборка показывает инструкции 5 SSE movssl. Согласно таблицам Agner Fog, все инструкции перемещения SSE с плавающей запятой не менее 1 inst/cycle обратная пропускная способность для Nehalem.

Поскольку у вас есть 5 из них, , вы не можете сделать лучше, чем 5 циклов/итераций.


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

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

В конечном счете, ваша цель состоит в том, чтобы уменьшить количество нагрузок, чтобы они были меньше, чем числа add/muls. Так что это может быть путь.

Ответ 2

Большое спасибо за ваши ответы, это объяснило многое. Продолжая мой вопрос, когда я использую упакованные инструкции вместо скалярных инструкций, код, использующий intrinsics, будет выглядеть очень похоже:

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 ops за цикл.

Принимая во внимание ход весового коэффициента (спасибо, что указали это), график выглядит следующим образом:

schedule

Похоже, что расписание не изменяется, хотя после операции movss есть дополнительная инструкция, которая перемещает значение скалярного веса в регистр XMM, а затем использует shufps для копирования этого скалярного значения во всем векторе, Кажется, что вектор веса готов к использованию для mulps во времени, принимая во внимание задержку переключения от нагрузки до домена с плавающей запятой, поэтому это не должно вызывать каких-либо дополнительных латентностей.

Команды movaps (выравнивание, упакованное перемещение), addps и mulps, которые используются в этом ядре (с кодом сборки), имеют такую ​​же задержку и пропускную способность, что и их скалярные версии, поэтому это не должно нести дополнительную задержку.

Есть ли у кого-нибудь идея, где этот дополнительный цикл на 8 циклов расходуется, если предположить, что максимальная производительность, которую может получить это ядро, составляет 6.4 FP ops за цикл и работает на 5.6 FP ops за такт?

Еще раз спасибо за вашу помощь!

Ответ 3

Сделав это ответом из моего комментария.

На не-серверном дистрибутиве Linux я полагаю, что таймер прерывания обычно устанавливается на 250 Гц по умолчанию, хотя он зависит от дистрибутива почти всегда более 150. Эта скорость необходима для обеспечения интерактивного GUI 30 + fps. Этот таймер прерывания используется для упреждения кода. Это означает, что 150 раз в секунду ваш код прерывается, а код планировщика запускается и решает, на что нужно больше времени. Похоже, вы отлично справляетесь, чтобы получить 80% максимальной скорости, без проблем. Если вам нужна более эффективная установка, скажите, Ubuntu Server (по умолчанию 100 Гц) и немного отключите ядро ​​(preemption off)

EDIT: в 2+ базовой системе это имеет гораздо меньшее влияние, так как ваш процесс почти наверняка будет ударяться по одному ядру и больше или меньше, чтобы сделать свое дело.