Уменьшение количества инструкций, удаленных после развертывания цикла

У меня есть цикл обработки изображений O (N ^ 4), и после его профилирования (с использованием Intel Vtune 2013) я вижу, что количество удаленных инструкций резко сокращается. Мне нужна помощь в понимании этого поведения на многоядерной архитектуре. (Я использую Intel Xeon x5365 - имеет 8 ядер с общим кешем L2 для каждых 2 ядер). А также резко возросло количество неправильных прогнозов ветвей!! ///////////////РЕДАКТИРОВАНИЕ/////////// Ниже показан пример моего неразвернутого кода:

for(imageNo =0; imageNo<496;imageNo++){
for (unsigned int k=0; k<256; k++)
{
    double z = O_L + (double)k * R_L;
    for (unsigned int j=0; j<256; j++)
    {
        double y = O_L + (double)j * R_L;

        for (unsigned int i=0; i<256; i++)
        {
            double x[1] = {O_L + (double)i * R_L} ;             
            double w_n =  (A_n[2] * x[0] + A_n[5] * y + A_n[8] * z + A_n[11])  ;
            double u_n =  ((A_n[0] * x[0] + A_n[3] * y + A_n[6] * z + A_n[9] ) / w_n);
            double v_n =  ((A_n[1] * x[0] + A_n[4] * y + A_n[7] * z + A_n[10]) / w_n);                      

            for(int loop=0; loop<1;loop++)
            {
                px_x[loop] = (int) floor(u_n);
                px_y[loop] = (int) floor(v_n);
                alpha[loop] = u_n - px_x[loop] ;
                beta[loop]  = v_n - px_y[loop] ;
            }
///////////////////(i,j) pixels ///////////////////////////////
                if (px_x[0]>=0 && px_x[0]<(int)threadCopy[0].S_x && px_y[0]>=0 && px_y[0]<(int)threadCopy[0].S_y)                   

                    pixel_1[0] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + px_x[0]];
                else
                    pixel_1[0] = 0.0;               

                if (px_x[0]+1>=0 && px_x[0]+1<(int)threadCopy[0].S_x && px_y[0]>=0 && px_y[0]<(int)threadCopy[0].S_y)                   

                    pixel_1[2] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + (px_x[0]+1)];
                else
                    pixel_1[2] = 0.0;                   

/////////////////// (i+1, j) pixels/////////////////////////    

                if (px_x[0]>=0 && px_x[0]<(int)threadCopy[0].S_x && px_y[0]+1>=0 && px_y[0]+1<(int)threadCopy[0].S_y)
                    pixel_1[1] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + px_x[0]];
                else
                    pixel_1[1] = 0.0;                   

                if (px_x[0]+1>=0 && px_x[0]+1<(int)threadCopy[0].S_x && px_y[0]+1>=0 && px_y[0]+1<(int)threadCopy[0].S_y)

                    pixel_1[3] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + (px_x[0]+1)];

                else 

                    pixel_1[3] = 0.0;

                pix_1 = (1.0 - alpha[0]) * (1.0 - beta[0]) * pixel_1[0] + (1.0 - alpha[0]) * beta[0]  * pixel_1[1]

                +  alpha[0]  * (1.0 - beta[0]) * pixel_1[2]   +  alpha[0]  *  beta[0]  * pixel_1[3];                                

            f_L[k * L * L + j * L + i] += (float)(1.0 / (w_n * w_n) * pix_1);
                        }
    }

}
   }

Я разворачиваю самый внутренний цикл на 4 итерации. (У вас будет общий идеал того, как я разделил цикл. В основном я создал массив Array[4] и заполнил его соответствующими значениями.) Выполняя математику, я сокращение общего количества итераций на 75%. Скажем, для каждого цикла есть 4 инструкции обработки цикла (load i, inc i, cmp i, jle loop), общее количество инструкций после развертывания должно уменьшиться на (256-64) * 4 * 256 * 256 * 496 = 24,96G. . Профилированные результаты:

Before UnRolling: Instr retired: 3.1603T      no of branch mis-predictions: 96 million
After UnRolling:  Instr retired: 2.642240T    no of branch mis-predictions: 144 million

Количество выведенных из эксплуатации сообщений уменьшилось на 518,06G. Я понятия не имею, как это происходит. Буду признателен за любую помощь по этому поводу (даже если это маловероятно). Кроме того, я хотел бы знать, почему количество неправильных прогнозов ветвей увеличивается. Заранее спасибо!


person quantumshiv    schedule 16.03.2014    source источник
comment
@PaulA.Clayton: я отредактировал, чтобы включить неразвернутую версию моего кода. Не могли бы вы связать свое выражение с кодом? Для развернутой версии я сделал очевидные изменения, такие как: for(int loop=0; loop<4;loop++) , double w_n[4], double u_n[4] и т. д.   -  person quantumshiv    schedule 16.03.2014
comment
поскольку он помечен vtune, я предполагаю, что вы используете icc   -  person arunmoezhi    schedule 16.03.2014
comment
Нет. Я использую gcc 4.1. Я просто запускаю свой бинарный файл в Vtune.   -  person quantumshiv    schedule 16.03.2014


Ответы (1)


Непонятно, где gcc будет сокращать количество инструкций. Возможно, что повышенное давление на регистры может побудить gcc использовать инструкции load+operate (то есть такое же количество примитивных операций, но меньше инструкций). Индекс для f_L будет увеличиваться только один раз за самый внутренний цикл, но это сэкономит только 6,2 ГБ (3 * 64 * 256 * 256 * 496) инструкций. (Кстати, накладные расходы цикла должны составлять только три инструкции, поскольку i должно оставаться в регистре.)

Следующая псевдосборка (для RISC-подобной ISA) с использованием двухстороннего развертывания показывает, как можно сохранить приращение:

// the address of f_L[k * L * L + j * L + i] is in r1
// (float)(1.0 / (w_n * w_n) * pix_1) results are in f1 and f2
load-single f9 [r1];    // load float at address in r1 to register f9
add-single f9 f9 f1;    // f9 = f9 + f1
store-single [r1] f9;   // store float in f9 to address in r1
load-single f10 4[r1];  // load float at address of r1+4 to f10
add-single f10 f10 f2;  // f10 = f10 + f2
store-single 4[r1] f10; // store float in f10 to address of r1+4
add r1 r1 #8;           // increase the address by 8 bytes

Трассировка двух итераций неразвернутой версии будет выглядеть примерно так:

load-single f9 [r1];  // load float at address of r1 to f9
add-single f9 f9 f2;  // f9 = f9 + f2
store-single [r1] f9; // store float in f9 to address of r1
add r1 r1 #4;         // increase the address by 4 bytes
...
load-single f9 [r1];  // load float at address of r1 to f9
add-single f9 f9 f2;  // f9 = f9 + f2
store-single [r1] f9; // store float in f9 to address of r1
add r1 r1 #4;         // increase the address by 4 bytes

Поскольку инструкции по адресации памяти обычно включают добавление непосредственного смещения (Itanium является необычным исключением), а конвейеры обычно не реализуются для оптимизации случая, когда непосредственное значение равно нулю, использование ненулевого непосредственного смещения обычно «бесплатно». (Конечно, это уменьшает количество инструкций — 7 вместо 8 в данном случае — но в целом также повышает производительность.)

Что касается прогнозирования ветвлений, согласно Микроархитектура процессоров Intel, AMD и VIA Агнера Фога: руководство по оптимизации для программистов на ассемблере и разработчиков компиляторов(PDF) предиктор ветвлений микроархитектуры Core2 использует 8-битную глобальную историю. Это означает, что он отслеживает результаты последних 8 переходов и использует эти 8 бит (вместе с битами из адреса инструкции) для индексации таблицы. Это позволяет распознавать корреляции между соседними ветвями.

Для вашего кода ветвь, соответствующая, например, 8-й предыдущей ветви, не является одной и той же ветвью на каждой итерации (поскольку используется короткое замыкание), поэтому непросто понять, насколько хорошо будут распознаваться корреляции.

Некоторые корреляции в ветвях очевидны. Если px_x[0]>=0 истинно, то px_x[0]+1>=0 также будет истинным. Если px_x[0] <(int)threadCopy[0].S_x верно, то px_x[0]+1 <(int)threadCopy[0].S_x скорее всего будет правдой.

Если развертывание выполнено таким образом, что px_x[n] проверяется для всех четырех значений n, тогда эти корреляции будут отодвигаться дальше, чтобы результаты не использовались предсказателем ветвления.

Некоторые возможности оптимизации

Хотя вы не спрашивали о каких-либо возможностях оптимизации, я собираюсь предложить некоторые возможности для исследования.

Во-первых, для ветвей, если не подходит строгая переносимость, тест x>=0 && x<y можно упростить до (unsigned)x<(unsigned)y. Это не является строго переносимым, потому что, например, машина теоретически может представлять отрицательные числа в формате знака-величины со старшим значащим битом в качестве знака и отрицательным значением, обозначаемым нулевым битом. Для обычных представлений целых чисел со знаком такое повторное приведение будет работать до тех пор, пока y является положительным целым числом со знаком, поскольку отрицательное значение x будет иметь установленный старший бит и, следовательно, будет больше, чем y, интерпретируемое как целое число без знака.

Во-вторых, количество ветвей можно значительно уменьшить, используя 100% корреляции для px_x или px_y:

if ((unsigned) px_y[0]<(unsigned int)threadCopy[0].S_y)
{
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pixel_1[0] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + px_x[0]];
    else
        pixel_1[0] = 0.0;
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pixel_1[2] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + (px_x[0]+1)];
    else
        pixel_1[2] = 0.0;
}
if ((unsigned)px_y[0]+1<(unsigned int)threadCopy[0].S_y)
{
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pixel_1[1] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + px_x[0]];
    else
        pixel_1[1] = 0.0;
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pixel_1[3] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + (px_x[0]+1)];
    else
        pixel_1[3] = 0.0;
}

(Если приведенный выше фрагмент кода реплицируется для развертывания, его, вероятно, следует реплицировать как блок, а не чередовать тесты для разных значений px_x и px_y, чтобы ветвь px_y находилась рядом с ветвью px_y+1, а первая ветвь px_x была рядом с ветвью px_x. другой филиал px_x и филиалы px_x+1.)

Другая возможная оптимизация — изменение вычисления w_n на вычисление его обратной величины. Это изменит умножение и три деления на четыре умножения и одно деление. Деление намного дороже умножения. Кроме того, вычисление приблизительной обратной величины намного более удобно для SIMD, поскольку обычно существуют инструкции по обратной оценке, которые обеспечивают отправную точку, которую можно уточнить с помощью метода Ньютона-Рафсона.

Если приемлема даже худшая обфускация кода, вы можете рассмотреть возможность изменения кода, такого как double y = O_L + (double)j * R_L;, на double y = O_L; ... y += R_L;. (Я провел тест, и gcc, похоже, не распознал эту оптимизацию, вероятно, из-за использования плавающей запятой и приведения к двойному.) Таким образом:

for(int imageNo =0; imageNo<496;imageNo++){

double z = O_L;
for (unsigned int k=0; k<256; k++)
{

    double y = O_L;
    for (unsigned int j=0; j<256; j++)
    {
        double x[1]; x[0] = O_L;
        for (unsigned int i=0; i<256; i++)
        {
            ...
            x[0] +=  R_L ;
        } // end of i loop
        y += R_L;
    }  // end of j loop
    z += R_L;
} // end of k loop
    } // end of imageNo loop

Я предполагаю, что это лишь незначительно улучшит производительность, поэтому стоимость запутывания будет выше по сравнению с выгодой.

Еще одно изменение, которое, возможно, стоит попробовать, — это включение некоторых вычислений pix_1 в разделы, устанавливающие условные значения pixel_1[]. Это значительно запутало бы код и не принесло бы большой пользы. Кроме того, это может затруднить автовекторизацию компилятором. (При условной установке значений на соответствующий I_n или на ноль сравнение SIMD может установить каждый элемент на -1 или 0, а простое and со значением I_n даст правильное значение. В этом случае накладные расходы на формирование I_n vector, вероятно, не будет иметь смысла, учитывая, что Core2 поддерживает только SIMD двойной точности с двойной точностью, но с поддержкой сбора или даже просто с более длинным вектором компромиссы могут измениться.)

Однако это изменение увеличило бы размер базовых блоков и уменьшило объем вычислений, когда какие-либо из px_x и px_y выходят за пределы допустимого диапазона (я предполагаю, что это редкость, поэтому преимущество будет очень небольшим при Лучший).

double pix_1 = 0.0;
double alpha_diff = 1.0 - alpha;
if ((unsigned) px_y[0]<(unsigned int)threadCopy[0].S_y)
{
    double beta_diff = 1.0 - beta;
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha_diff * beta_diff 
             * threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + px_x[0]];
    // no need for else statement since pix1 is already zeroed and not 
    // adding the pixel_1[0] factor is the same as zeroing pixel_1[0]
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha * beta_diff 
             * threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + (px_x[0]+1)];
}
if ((unsigned)px_y[0]+1<(unsigned int)threadCopy[0].S_y)
{
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha_diff * beta 
             * threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + px_x[0]];
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha * beta 
             * threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + (px_x[0]+1)];
}

В идеале такой код, как ваш, должен быть векторизован, но я не знаю, как заставить gcc распознавать возможности, как выражать возможности с помощью встроенных функций, а также стоит ли прилагать значительные усилия по ручной векторизации этого кода при ширине SIMD, равной всего двум. .

Я не программист (просто тот, кто любит изучать и думать об архитектуре компьютера), и у меня есть значительная склонность к микрооптимизации (как ясно из вышеизложенного), поэтому приведенные выше предложения следует рассматривать в этом свете.

person Paul A. Clayton    schedule 20.03.2014
comment
Большое спасибо за ответ на мой вопрос, а также за предложение нескольких методов оптимизации. Я думаю, вы были правы насчет последней техники, которая усложняла авто-векоризацию. Кроме того, мне потребовалось больше тактов, когда я использовал (unsigned)x . Вы также сказали, что индекс для f_L будет увеличиваться только один раз для каждого внутреннего цикла, но, насколько я понимаю, его необходимо обновлять для каждого цикла с раздеванием. Например: f_L[k * L * L + j * L + i] для цикла i, f_L[k * L * L + j * L + (i+1)] для цикла (i+1) и так далее. Пожалуйста, поправьте меня, если я ошибаюсь. - person quantumshiv; 24.03.2014
comment
Я развернул j-loop, и он работает лучше, чем i-loop. Почти на 45% меньше ошибочных прогнозов ветвей. Но количество промахов I-Cache увеличилось. Не могли бы вы также объяснить такое поведение? В основном я хочу знать, насколько лучше развертывание внешних циклов с точки зрения кеша инструкций. - person quantumshiv; 24.03.2014
comment
@Tiro_Coder Инструкция сохранения f_L может включать +0 и +4 (число с плавающей запятой равно 4 байтам) с использованием адресации по основанию + смещению (хранилище включает бесплатное добавление при генерации адреса), предполагая, что индекс f_L был сохранен и скорректирован в конце внутренняя петля. В.р.т. неправильные предсказания ветвления для развертывания j, я предполагаю, что члены A_n[N]*y меньше, поэтому корреляция между px_x и px_x+1 и px_y и px_y+1 больше. (Трюк без знака уменьшает количество ветвлений, но удаляет корреляционную информацию.) Я даже не могу предположить, откуда берутся лишние промахи Icache. - person Paul A. Clayton; 24.03.2014
comment
@Tiro_Coder Также спасибо за отчет о проделанной работе! Я удивлен тем, что gcc удалось выполнить автовекторизацию (у gcc плохая репутация по сравнению с icc в этой области, и даже icc не так умен, как опытный программист). Я немного разочарован тем, что мои предложения не были более полезными с вашей проблемой, но, возможно, они имели по крайней мере некоторую общеобразовательную ценность. Из любопытства не пробовали ли вы развернуть цикл k, чтобы посмотреть, уменьшит ли это еще больше количество ошибочных прогнозов ветвлений? Еще раз спасибо за отчет о проделанной работе и указание на то, что ответ был полезен. - person Paul A. Clayton; 24.03.2014
comment
Я все еще пытаюсь понять операцию f_Lupdate. Итак, f_L[k * L * L + j * L + i] += (float)(1.0 / (w_n[0] * w_n[0]) * pix_1) и f_L[k * L * L + (j+1) * L + i] += (float)(1.0 / (w_n[1] * w_n[1]) * pix_2 ); блок инструкций будут объединены только в один блок, что делает его только одной операцией обновления во внутреннем блоке? С учетом двухстороннего развертывания. (Я пытаюсь понять, как это всего лишь одна операция обновления вместо B (B способ развернуть) раз)? - person quantumshiv; 24.03.2014
comment
Я развернул цикл k и получил производительность, аналогичную циклу j. Пытаясь понять поведение этого внешнего цикла, я последовал ответу на этот вопрос - [Ссылка] (stackoverflow.com/questions/11007134/). Но мне все равно непонятно, так как double y[2] = {O_L + (double)j * R_L, O_L + (double)(j+1) * R_L}; будет обновляться столько же раз, сколько x[2] во внутреннем цикле! Большое спасибо за помощь. Ваш вклад действительно помогает мне визуализировать архитектуру. - person quantumshiv; 24.03.2014
comment
Поясняет ли редактирование, как развертывание может снизить накладные расходы? (Понимание поведения программы сложно, потому что компиляторы сложны, а современные высокопроизводительные аппаратные реализации сложны — поэтому сложность в квадрате! Небольшие изменения могут помочь или помешать компилятору распознать возможности оптимизации; аналогичным образом, кажущиеся незначительными изменения могут повлиять на предсказатель ветвления и поведение кэша.) - person Paul A. Clayton; 24.03.2014
comment
да. Большое спасибо за редактирование. Я искал VTune, если это то, что происходит, но слишком много информации, чтобы понять это. Не могли бы вы дать общий комментарий, почему развертывание внешнего цикла будет лучше, чем внутреннего? - person quantumshiv; 25.03.2014