Производительность цикла кода 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+ это оказывает гораздо меньшее влияние, так как ваш процесс почти наверняка будет добавлен к одному ядру и более или менее оставлен, чтобы делать свое дело.

Другие вопросы по тегам