C vs ассемблер vs неон производительность

Я работаю над приложением iPhone, которое выполняет обработку изображений в реальном времени. Одним из самых ранних шагов в конвейере является преобразование изображения BGRA в оттенки серого. Я попробовал несколько разных методов, и разница в результатах синхронизации намного больше, чем я мог себе представить. Сначала я попытался использовать C. я приближаю преобразование к светимости, добавляя B+2*G+R / 4

void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();

uchar *pOut = imByte.data;
for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{
    unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
    pOut[0] = sumA / 4;
    unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
    pOut[1] = sumB / 4;
    unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
    pOut[2] = sumC / 4;
    unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
    pOut[3] = sumD / 4;
    pOut +=4;
}       
}

этот код занимает 55 мс для преобразования изображения 352x288. Затем я нашел код ассемблера, который делает по сути то же самое

void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();

unsigned int *pOut = (unsigned int*) imByte.data;

for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{
  register unsigned int nBGRA1 asm("r4");
  register unsigned int nBGRA2 asm("r5");
  unsigned int nZero=0;
  unsigned int nSum1;
  unsigned int nSum2;
  unsigned int nPacked1;
  asm volatile(

               "ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #0]       n"   // Load in two BGRA words
               "usad8 %[nSum1], %[nBGRA1], %[nZero]  n"  // Add R+G+B+A 
               "usad8 %[nSum2], %[nBGRA2], %[nZero]  n"  // Add R+G+B+A 
               "uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8    n"   // Add G again
               "uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8    n"   // Add G again
               "mov %[nPacked1], %[nSum1], LSR #2 n"    // Init packed word   
               "mov %[nSum2], %[nSum2], LSR #2 n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum2], LSL #8 n"   // Add to packed word                 

               "ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #8]       n"   // Load in two more BGRA words
               "usad8 %[nSum1], %[nBGRA1], %[nZero]  n"  // Add R+G+B+A 
               "usad8 %[nSum2], %[nBGRA2], %[nZero]  n"  // Add R+G+B+A 
               "uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8    n"   // Add G again
               "uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8    n"   // Add G again
               "mov %[nSum1], %[nSum1], LSR #2 n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum1], LSL #16 n"   // Add to packed word
               "mov %[nSum2], %[nSum2], LSR #2 n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum2], LSL #24 n"   // Add to packed word                 

               ///////////
               ////////////

               : [pIn]"+r" (pIn), 
         [nBGRA1]"+r"(nBGRA1),
         [nBGRA2]"+r"(nBGRA2),
         [nZero]"+r"(nZero),
         [nSum1]"+r"(nSum1),
         [nSum2]"+r"(nSum2),
         [nPacked1]"+r"(nPacked1)
               :
               : "cc"  );
  *pOut = nPacked1;
  pOut++;
 }
 }

эта функция преобразует одно и то же изображение в 12 мс, почти в 5 раз быстрее! Раньше я не программировал в ассемблере, но я предполагал, что для такой простой операции он будет не намного быстрее, чем C. Вдохновленный этим успехом, я продолжил поиск и обнаружил пример неонового преобразования здесь.

void greyScaleNEON(uchar* output_data, uchar* input_data, int tot_pixels)
{
__asm__ volatile("lsr          %2, %2, #3      n"
                 "# build the three constants: n"
                 "mov         r4, #28          n" // Blue channel multiplier
                 "mov         r5, #151         n" // Green channel multiplier
                 "mov         r6, #77          n" // Red channel multiplier
                 "vdup.8      d4, r4           n"
                 "vdup.8      d5, r5           n"
                 "vdup.8      d6, r6           n"
                 "0:                           n"
                 "# load 8 pixels:             n"
                 "vld4.8      {d0-d3}, [%1]!   n"
                 "# do the weight average:     n"
                 "vmull.u8    q7, d0, d4       n"
                 "vmlal.u8    q7, d1, d5       n"
                 "vmlal.u8    q7, d2, d6       n"
                 "# shift and store:           n"
                 "vshrn.u16   d7, q7, #8       n" // Divide q3 by 256 and store in the d7
                 "vst1.8      {d7}, [%0]!      n"
                 "subs        %2, %2, #1       n" // Decrement iteration count
                 "bne         0b            n" // Repeat unil iteration count is not zero
                 :
                 :  "r"(output_data),           
                 "r"(input_data),           
                 "r"(tot_pixels)        
                 : "r4", "r5", "r6"
                 );
}

результаты было трудно поверить. Он преобразует одно и то же изображение в 1 мс. 12x быстрее, чем ассемблер и поразительный 55X быстрее, чем C. Я понятия не имел, что такое повышение производительности возможно. В свете этого у меня есть несколько вопросов. Во-первых, я делаю что-то ужасно неправильное в коде C? Мне все еще трудно поверить, что это так медленно. Во-вторых, если эти результаты вообще точны, в каких ситуациях я могу ожидать увидеть эти выгоды? Вы, вероятно, можете себе представить, как я взволнован перспективой сделать другие части моего трубопровода работать 55X быстрее. Должен ли я учиться? ассемблер / неон и использование их внутри любого цикла, который занимает заметное количество времени?

Update 1: я опубликовал вывод ассемблера из моей функции C в текстовом файле по адресу http://temp-share.com/show/f3Yg87jQn он был слишком велик, чтобы включать его прямо здесь.

синхронизация выполняется с помощью функций OpenCV.

double duration = static_cast<double>(cv::getTickCount()); 
//function call 
duration = static_cast<double>(cv::getTickCount())-duration;
duration /= cv::getTickFrequency();
//duration should now be elapsed time in ms

результаты

я протестировал несколько предложенных улучшений. Во-первых, по рекомендации Виктора я переупорядочил внутренний петли в первую очередь все получает. Внутренняя петля тогда выглядела так.

for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{     
  //Jul 16, 2012 MR: Read and writes collected
  sumA = pIn[0] + 2 * pIn[1] + pIn[2];
  sumB = pIn[4] + 2 * pIn[5] + pIn[6];
  sumC = pIn[8] + 2 * pIn[9] + pIn[10];
  sumD = pIn[12] + 2 * pIn[13] + pIn[14];
  pOut +=4;
  pOut[0] = sumA / 4;
  pOut[1] = sumB / 4;
  pOut[2] = sumC / 4;
  pOut[3] = sumD / 4;
}

это изменение принесло время обработки до 53ms улучшение 2ms. Затем, как рекомендовал Виктор, я изменил свою функцию, чтобы получить как uint. Внутренняя петля тогда выглядела как

unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
uchar* out = temp.data;

for(; in_int < end; in_int+=4)   // Does four pixels at a time
{
    unsigned int pixelA = in_int[0];
    unsigned int pixelB = in_int[1];
    unsigned int pixelC = in_int[2];
    unsigned int pixelD = in_int[3];

    uchar* byteA = (uchar*)&pixelA;
    uchar* byteB = (uchar*)&pixelB;
    uchar* byteC = (uchar*)&pixelC;
    uchar* byteD = (uchar*)&pixelD;         

    unsigned int sumA = byteA[0] + 2 * byteA[1] + byteA[2];
    unsigned int sumB = byteB[0] + 2 * byteB[1] + byteB[2];
    unsigned int sumC = byteC[0] + 2 * byteC[1] + byteC[2];
    unsigned int sumD = byteD[0] + 2 * byteD[1] + byteD[2];

    out[0] = sumA / 4;
    out[1] = sumB / 4;
    out[2] = sumC / 4;
    out[3] = sumD / 4;
    out +=4;
    }

эта модификация имела драматический эффект, снизив время обработки до 14 мс, падение 39 МС (75%). Этот последний результат очень близок к производительности ассемблера 11ms. Окончательная оптимизация как Роб рекомендовал включить ключевое слово _ _ restrict. Я добавил его перед каждым объявлением указателя, изменив следующие строки

__restrict unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
__restrict uchar* out = temp.data;  
...
__restrict uchar* byteA = (uchar*)&pixelA;
__restrict uchar* byteB = (uchar*)&pixelB;
__restrict uchar* byteC = (uchar*)&pixelC;
__restrict uchar* byteD = (uchar*)&pixelD;  
...     

эти изменения не оказали ощутимого влияния на время обработки. Спасибо за вашу помощь, я буду уделять гораздо больше внимания управлению памятью в будущем.

4 ответов


здесь есть объяснение относительно некоторых причин "успеха" NEON:http://hilbert-space.de/?p=22

попробуйте скомпилировать код C с помощью переключателей"- S-O3", чтобы увидеть оптимизированный вывод компилятора GCC.

IMHO, ключом к успеху является оптимизированный шаблон чтения / записи, используемый обеими версиями сборки. И NEON / MMX / другие векторные двигатели также поддерживают насыщенность (зажим результатов до 0..255 без необходимости использовать ' unsigned ints').

эти строки в цикле:

unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
pOut[1] = sumB / 4;
unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
pOut[2] = sumC / 4;
unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut[3] = sumD / 4;
pOut +=4;

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

// and the pIn reads can be combined into a single 4-byte fetch
sumA = pIn[0] + 2 * pIn[1] + pIn[2];
sumB = pIn[4] + 2 * pIn[5] + pIn[6];
sumC = pIn[8] + 2 * pIn[9] + pIn[10];
sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut +=4;
pOut[0] = sumA / 4;
pOut[1] = sumB / 4;
pOut[2] = sumC / 4;
pOut[3] = sumD / 4;

имейте в виду, что строка" unsigned in sumA " здесь действительно может означать вызов alloca () (выделение в стеке), поэтому вы тратите много циклов на временные выделения var (вызов функции 4 раза).

кроме того, индексирование pIn[i] выполняет только однобайтовую выборку из памяти. Лучший способ сделать это-прочитать int, а затем извлечь отдельные байты. Чтобы сделать все быстрее, используйте "unsgined int*" для чтения 4 байтов (pIn[i * 4 + 0], pIn[i * 4 + 1], pIn[i * 4 + 2], pIn[i * 4 + 3]).

неоновая версия явно превосходит: линии

             "# load 8 pixels:             \n"
             "vld4.8      {d0-d3}, [%1]!   \n"

и

             "#save everything in one shot   \n"
             "vst1.8      {d7}, [%0]!      \n"

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


Если производительность критически важна (как это обычно бывает при обработке изображений в реальном времени), вам нужно обратить внимание на машинный код. Как вы обнаружили, может быть особенно важно использовать векторные инструкции (которые предназначены для таких вещей, как обработка изображений в реальном времени), и компиляторам трудно автоматически эффективно использовать векторные инструкции.

то, что вы должны попробовать, прежде чем совершать ассамблеи, используя компилятор встроенные функции. Встроенные компоненты компилятора не более портативны, чем сборка, но они должны быть проще для чтения и записи и проще для компилятора работать. Помимо проблем с ремонтопригодностью, проблема производительности сборки заключается в том, что она эффективно отключает оптимизатор (вы использовали соответствующий флаг компилятора, чтобы включить его, верно?). То есть: с встроенной сборкой компилятор не может настроить назначение регистра и т. д., Поэтому, если вы не пишете всю свою внутреннюю цикл в сборке, он все еще может быть не так эффективен, как мог бы быть.

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

Если вы пробовали выше и все еще не можете спровоцировать компилятор на правильные действия, продолжайте писать свой внутренний цикл в сборке (и снова проверьте, действительно ли результат быстрее). По причинам, описанным выше, обязательно получите весь внутренняя петля, включая ветвь петли.

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


ответ Виктора Латыпова имеет много хорошей информации, но я хочу отметить еще одну вещь: в вашей оригинальной функции C компилятор не может сказать, что pIn и pOut укажите на неперекрывающиеся области памяти. Теперь взгляните на эти строки:--13-->

pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];

компилятор должен предположить, что pOut[0] может быть то же самое, что pIn[4] или pIn[5] или pIn[6] (или любой другой pIn[x]). Поэтому он в основном не может переупорядочить любой код петля.

вы можете сказать компилятору, что pIn и pOut не перекрывайте, объявляя их __restrict:

__restrict uchar *pIn = (uchar*) imBGRA.data;
__restrict uchar *pOut = imByte.data;

это может немного ускорить вашу оригинальную версию C.


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

также глядя на сборку, они не используют деление, а только умножения, поэтому посмотрите, что для вашего кода C. Другой пример-это оптимизирует ваше умножение на 2 из двух дополнений. Это снова может быть еще один трюк, так как умножение может быть медленнее в приложении iPhone, чем добавление.