C vs assembler vs Neon performance

Pracuję nad aplikacją na iPhone ' a, która przetwarza obraz w czasie rzeczywistym. Jednym z najwcześniejszych kroków w jego potoku jest konwersja obrazu BGRA do skali szarości. Wypróbowałem kilka różnych metod, a różnica w wynikach czasowych jest znacznie większa, niż sobie wyobrażałem. Najpierw próbowałem użyć C. przybliżam konwersję do jasności dodają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;
}       
}

Ten kod zajmuje 55 ms, aby przekonwertować obraz o wymiarach 352x288. Następnie znalazłem jakiś kod asemblera, który zasadniczo to samo

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++;
 }
 }

Ta funkcja konwertuje ten sam obraz w 12ms, prawie 5X szybciej! Nie zaprogramowałem wcześniej w asemblerze, ale założyłem, że nie będzie to dużo szybsze niż C przy tak prostej operacji. Zainspirowany tym sukcesem kontynuowałem poszukiwania i odkryłem przykład konwersji neonów tutaj.

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"
                 );
}
Trudno było w to uwierzyć. Konwertuje ten sam obraz w 1 ms. 12x szybciej niż asembler i 55x szybciej niż C. I nie miałem pojęcia, że takie zyski wydajności są możliwe. W związku z tym mam kilka pytań. Po pierwsze, czy robię coś strasznie złego w kodzie C? Wciąż trudno mi uwierzyć, że jest tak wolno. Po drugie, jeśli te wyniki są w ogóle dokładne, w jakich sytuacjach mogę spodziewać się tych zysków? Możesz sobie wyobrazić, jak bardzo jestem podekscytowany perspektywą przyspieszenia działania innych części mojego rurociągu 55X. Czy powinienem uczyć się asemblera/neonu i używać ich wewnątrz jakiegokolwiek pętla, która zajmuje znaczną ilość czasu?

Update 1: umieściłem wyjście asemblera z mojej funkcji C w pliku tekstowym na http://temp-share.com/show/f3Yg87jQn była zbyt duża, by ją tu umieścić.

Synchronizacja odbywa się za pomocą funkcji 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

Wyniki

Przetestowałem kilka sugerowanych ulepszeń. Po pierwsze, zgodnie z zaleceniem Viktora zmieniłem kolejność wewnętrznej pętli, aby najpierw umieścić wszystkie pobieranie. Wewnętrzna pętla wyglądała wtedy na przykład.

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;
}

Ta zmiana zmniejszyła czas przetwarzania do 53ms i poprawiła o 2ms. następnie zgodnie z zaleceniami Victora zmieniłem swoją funkcję na fetch as uint. Wewnętrzna pętla wyglądała wtedy tak:

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;
    }

Ta modyfikacja miała dramatyczny efekt, skracając czas przetwarzania do 14ms, spadek o 39ms (75%). Ten ostatni wynik jest bardzo zbliżony do wydajności asemblera 11ms. ostateczną optymalizacją zalecaną przez Roba było włączenie słowa kluczowego _ _ restrict. Dodałem go przed każda deklaracja wskaźnika zmieniająca następujące linie

__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;  
...     

Zmiany te nie miały wymiernego wpływu na czas przetwarzania. Dziękuję za wszelką pomoc, będę zwracać znacznie większą uwagę na zarządzanie pamięcią w przyszłości.

Author: Hammer, 2012-07-16

4 answers

Jest tutaj wyjaśnienie dotyczące niektórych powodów "sukcesu" Neona: http://hilbert-space.de/?p=22

Spróbuj skompilować kod C za pomocą przełączników "- S-O3", aby zobaczyć zoptymalizowane wyjście kompilatora GCC.

IMHO, kluczem do sukcesu jest zoptymalizowany wzorzec odczytu/zapisu stosowany w obu wersjach montażowych. I NEON / MMX / inne silniki wektorowe również obsługują nasycenie (mocowanie wyników do 0..255 bez konieczności używania "unsigned ints").

Zobacz te linie w pętli:

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;

Odczyty i zapisy są naprawdę mieszane. Nieco lepszą wersją cyklu pętli byłoby

// 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;

Należy pamiętać, że linia "unsigned in sumA" tutaj może naprawdę oznaczać wywołanie alloca () (alokacja na stosie), więc marnujesz dużo cykli na tymczasowe alokacje var (wywołanie funkcji 4 razy).

Indeksowanie pIn [i] wykonuje tylko jednobajtowy Pobór z pamięci. Lepszym sposobem na to jest przeczytanie int, a następnie wyodrębnij pojedyncze bajty. Aby przyspieszyć działanie, użyj " unsgined int*", aby odczytać 4 bajty (pIn[i * 4 + 0], pIn[i * 4 + 1], pIn[i * 4 + 2], pIn[i * 4 + 3]).

Wersja neonowa jest wyraźnie lepsza: linie

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

I

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

Zapisz większość czasu na dostęp do pamięci.

 5
Author: Viktor Latypov,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-07-16 16:46:41

Jeśli wydajność jest krytycznie ważna (jak zwykle w przypadku przetwarzania obrazu w czasie rzeczywistym), musisz zwrócić uwagę na kod maszynowy. Jak już odkryłeś, szczególnie ważne może być używanie instrukcji wektorowych (które są przeznaczone do takich rzeczy, jak przetwarzanie obrazów w czasie rzeczywistym)-i trudno jest kompilatorom automatycznie efektywnie używać instrukcji wektorowych.

Przed przystąpieniem do assembly powinieneś spróbować użyć kompilatora intrinsics . Wewnętrzne elementy kompilatora nie są bardziej przenośne niż assembly, ale powinny być łatwiejsze do odczytu i zapisu oraz łatwiejsze do pracy z kompilatorem. Oprócz problemów z konserwacją, problem wydajności z assembly polega na tym, że skutecznie wyłącza optymalizator (użyłeś odpowiedniej flagi kompilatora, aby go włączyć, prawda?). To znaczy: z wbudowanym assembly, kompilator nie jest w stanie dostosować przypisania rejestru itd., więc jeśli nie zapiszesz całej wewnętrznej pętli w assembly, to może nadal nie być tak wydajny, jak może być.

Jednak nadal będziesz w stanie wykorzystać swoją nowo odkrytą wiedzę na temat montażu z dobrym skutkiem-ponieważ możesz teraz sprawdzić montaż wytwarzany przez kompilator i dowiedzieć się, czy jest głupi. Jeśli tak, możesz zmodyfikować kod C (być może ręcznie wykonując pipelining, Jeśli kompilator nie radzi sobie z tym), przekompilować go, spojrzeć na wyjście złożenia, aby zobaczyć, czy kompilator robi teraz to, co chcesz, a następnie sprawdzić, czy właściwie biegam szybciej...

Jeśli wypróbowałeś powyższe i nadal nie możesz sprowokować kompilatora do zrobienia właściwej rzeczy, napisz swoją wewnętrzną pętlę w assembly(i ponownie sprawdź, czy wynik jest rzeczywiście szybszy). Z powodów opisanych powyżej, upewnij się, że uzyskasz całą wewnętrzną pętlę , w tym gałąź pętli.

Na koniec, jak wspominali inni, poświęć trochę czasu, aby spróbować dowiedzieć się, co to jest "właściwa rzecz". Kolejna zaleta nauki maszyny Architektura polega na tym, że daje Ci mentalny model działania rzeczy - więc będziesz miał większą szansę zrozumienia, jak ułożyć efektywny kod.

 4
Author: comingstorm,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-07-16 21:10:50

Odpowiedź Viktora Latypova zawiera wiele dobrych informacji, ale chcę zwrócić uwagę na jeszcze jedną rzecz: w oryginalnej funkcji C, kompilator nie może powiedzieć, że pIn i pOut wskazują na nie nakładające się regiony pamięci. Teraz spójrz na te linie:

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

Kompilator musi zakładać, że {[4] } może być taki sam jak pIn[4] lub pIn[5] lub pIn[6] (lub jakikolwiek inny pIn[x]). Więc w zasadzie nie można zmienić kolejności żadnego kodu w Twojej pętli.

Możesz powiedzieć kompilatorowi, że pIn i pOut nie nakładaj się deklarując je __restrict:

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

To może nieco przyspieszyć oryginalną wersję C.

 3
Author: rob mayoff,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-07-16 17:46:49

Jest to rodzaj przegięcia między wydajnością a konserwacją. Zazwyczaj ładowanie aplikacji i szybkie działanie jest bardzo miłe dla użytkownika, ale nie ma kompromisu. Teraz Twoja aplikacja jest dość trudna do utrzymania, a wzrost prędkości może być nieuzasadniony. Jeśli użytkownicy aplikacji narzekali, że czuł się powolny, to te optymalizacje są warte wysiłku i braku konserwacji, ale jeśli wynikało to z potrzeby przyspieszenia aplikacji, nie powinieneś iść tak daleko w optymalizacja. Jeśli robisz te obrazy konwersji przy starcie aplikacji to szybkość nie ma znaczenia, ale jeśli robisz je stale (i robi wiele z nich ) podczas gdy aplikacja jest uruchomiona, to mają one więcej sensu. Zoptymalizuj tylko te części aplikacji, w których użytkownik spędza czas i faktycznie odczuwa spowolnienie.

Również patrząc na assembly nie używają podziału, a raczej tylko mnożenia, więc spójrz na to w kodzie C. Innym przykładem jest to, że optymalizuje się Twoje mnożenie przez 2 na dwa dodatki. To znowu może być kolejna sztuczka, ponieważ mnożenie może być wolniejsze w aplikacji iPhone niż dodawanie.

 0
Author: sean,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-07-16 16:14:43