Czego brakuje/nie jest optymalna w tej implementacji memcpy?

Zainteresowało mnie napisanie memcpy() jako ćwiczenie edukacyjne. Nie będę pisał całego Traktatu o tym, co zrobiłem i o czym nie myślałem, ale oto implementacja jakiegoś faceta :

__forceinline   // Since Size is usually known,
                // most useless code will be optimized out
                // if the function is inlined.

void* myMemcpy(char* Dst, const char* Src, size_t Size)
{
        void* start = Dst;
        for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
        {
                __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
                _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
        }

#define CPY_1B *((uint8_t * &)Dst)++ = *((const uint8_t * &)Src)++
#define CPY_2B *((uint16_t* &)Dst)++ = *((const uint16_t* &)Src)++
#define CPY_4B *((uint32_t* &)Dst)++ = *((const uint32_t* &)Src)++
#if defined _M_X64 || defined _M_IA64 || defined __amd64
#define CPY_8B *((uint64_t* &)Dst)++ = *((const uint64_t* &)Src)++
#else
#define CPY_8B _mm_storel_epi64((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const uint64_t* &)Src, ++(uint64_t* &)Dst
#endif
#define CPY16B _mm_storeu_si128((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const __m128i* &)Src, ++(__m128i* &)Dst

    switch (Size) {
    case 0x00:                                                      break;
    case 0x01:      CPY_1B;                                         break;
    case 0x02:              CPY_2B;                                 break;
    case 0x03:      CPY_1B; CPY_2B;                                 break;
    case 0x04:                      CPY_4B;                         break;
    case 0x05:      CPY_1B;         CPY_4B;                         break;
    case 0x06:              CPY_2B; CPY_4B;                         break;
    case 0x07:      CPY_1B; CPY_2B; CPY_4B;                         break;
    case 0x08:                              CPY_8B;                 break;
    case 0x09:      CPY_1B;                 CPY_8B;                 break;
    case 0x0A:              CPY_2B;         CPY_8B;                 break;
    case 0x0B:      CPY_1B; CPY_2B;         CPY_8B;                 break;
    case 0x0C:                      CPY_4B; CPY_8B;                 break;
    case 0x0D:      CPY_1B;         CPY_4B; CPY_8B;                 break;
    case 0x0E:              CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x0F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x10:                                      CPY16B;         break;
    case 0x11:      CPY_1B;                         CPY16B;         break;
    case 0x12:              CPY_2B;                 CPY16B;         break;
    case 0x13:      CPY_1B; CPY_2B;                 CPY16B;         break;
    case 0x14:                      CPY_4B;         CPY16B;         break;
    case 0x15:      CPY_1B;         CPY_4B;         CPY16B;         break;
    case 0x16:              CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x17:      CPY_1B; CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x18:                              CPY_8B; CPY16B;         break;
    case 0x19:      CPY_1B;                 CPY_8B; CPY16B;         break;
    case 0x1A:              CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1B:      CPY_1B; CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1C:                      CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1D:      CPY_1B;         CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1E:              CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    }
#undef CPY_1B
#undef CPY_2B
#undef CPY_4B
#undef CPY_8B
#undef CPY16B
        return start;
}

Komentarz tłumaczy się jako "Size is usually known as the compiler can optimized the code inline out most useless".

Chciałbym poprawić, jeśli to możliwe, w tej implementacji - ale może nie ma wiele do poprawy. Widze ze uzywa SSE / AVX dla wiekszych kawałki pamięci, a następnie zamiast pętli przez ostatnie

  • dlaczego rozwijamy pętlę dla kilku ostatnich bajtów, a nie częściowo rozwijamy pierwszą (a teraz pojedynczą) pętlę?
  • a co z problemami z wyrównaniem? Czy oni nie są ważni? Czy powinienem obsłużyć kilka pierwszych bajtów do jakiegoś wyrównania kwantowego inaczej, a następnie wykonać 256-bitowe operacje na wyrównanych sekwencjach bajtów? A jeśli tak, jak określić odpowiednie dopasowanie?
  • Jaka jest najważniejsza brakująca funkcja w tej implementacji (jeśli w ogóle istnieje)?

Cechy / zasady wymienione w odpowiedziach do tej pory

  • powinieneś __restrict__ swoje parametry. (@chux)
  • przepustowość pamięci jest czynnikiem ograniczającym; zmierz swoją implementację przeciwko niej.(@Zboson)
  • dla małych tablic można oczekiwać zbliżania się do przepustowości pamięci; dla większych tablic-nie tak bardzo. (@Zboson)
  • wiele wątków (może być | są) niezbędnych do nasycenia przepustowości PAMIĘCI. (@Zboson)
  • prawdopodobnie mądrze jest zoptymalizować inaczej dla dużych i małych rozmiarów kopii. (@Zboson)
  • (wyrównanie czy jest ważne? Nie jest wyraźnie zaadresowane!)
  • kompilator powinien być bardziej świadomy "oczywistych faktów", których może użyć do optymalizacji (takich jak fakt, że rozmiar
  • istnieją argumenty za Rozwiń swoje połączenia SSE / AVX (@BenJackson, tutaj ) i argumenty przeciwko temu (@PaulR)
  • nie-czasowe transfery (za pomocą których można powiedzieć procesorowi, że nie jest potrzebny do buforowania docelowej lokalizacji) powinny być przydatne do kopiowania większych buforów. (@Zboson)
Author: L. F., 2014-10-07

4 answers

Studiowałem pomiar przepustowości pamięci dla procesorów Intela z różnymi operacjami i jedną z nich jest memcpy. Robiłem to na Core2, Ivy Bridge i Haswell. Większość testów wykonałem używając c / c++ z intrinsics (patrz kod poniżej - ale obecnie przepisuję testy w assembly).

Aby napisać własną wydajną funkcję memcpy Ważne jest, aby wiedzieć, jaka jest absolutna najlepsza możliwa przepustowość. Ta przepustowość jest funkcją wielkości tablic, która będzie kopiowane i dlatego wydajna funkcja memcpy musi inaczej optymalizować dla małych i dużych (i być może pomiędzy). Aby wszystko było proste, zoptymalizowałem dla małych tablic 8192 bajtów i dużych tablic 1 GB.

Dla małych tablic maksymalna przepustowość odczytu i zapisu dla każdego rdzenia wynosi:

Core2-Ivy Bridge             32 bytes/cycle
Haswell                      64 bytes/cycle

To jest benchmark, który powinieneś celować dla małych tablic. Dla moich testów zakładam, że tablice są wyrównane do 64 bajtów i że rozmiar tablicy jest wielokrotnością 8*sizeof(float)*unroll_factor. Oto moje obecnie Wyniki memcpy dla rozmiaru 8192 bajtów (Ubuntu 14.04, GCC 4.9, EGLIBC 2.19):

                             GB/s     efficiency
    Core2 ([email protected] GHz)  
        builtin               35.2    41.3%
        eglibc                39.2    46.0%
        asmlib:               76.0    89.3%
        copy_unroll1:         39.1    46.0%
        copy_unroll8:         73.6    86.5%
    Ivy Bridge ([email protected] GHz)                        
        builtin              102.2    88.7%
        eglibc:              107.0    92.9%
        asmlib:              107.6    93.4%
        copy_unroll1:        106.9    92.8%
        copy_unroll8:        111.3    96.6%
    Haswell ([email protected] GHz)
        builtin:              68.4    82.2%     
        eglibc:               39.7    47.7%
        asmlib:               73.2    87.6%
        copy_unroll1:         39.6    47.6%
        copy_unroll8:         81.9    98.4%

asmlib jest asmlib Agnera mgły . Funkcje copy_unroll1 i copy_unroll8 są zdefiniowane poniżej.

Z tej tabeli widać, że wbudowany GCC memcpy nie działa dobrze na Core2, a memcpy W EGLIBC nie działa dobrze na Core2 czy Haswell. Sprawdziłem ostatnio wersję GLOWNĄ GLIBC i wydajność była znacznie lepsza na Haswell. We wszystkich przypadkach rozwijanie jest najlepsze wynik.

void copy_unroll1(const float *x, float *y, const int n) {
    for(int i=0; i<n/JUMP; i++) {
        VECNF().LOAD(&x[JUMP*(i+0)]).STORE(&y[JUMP*(i+0)]);
    }
}

void copy_unroll8(const float *x, float *y, const int n) {
for(int i=0; i<n/JUMP; i+=8) {
    VECNF().LOAD(&x[JUMP*(i+0)]).STORE(&y[JUMP*(i+0)]);
    VECNF().LOAD(&x[JUMP*(i+1)]).STORE(&y[JUMP*(i+1)]);
    VECNF().LOAD(&x[JUMP*(i+2)]).STORE(&y[JUMP*(i+2)]);
    VECNF().LOAD(&x[JUMP*(i+3)]).STORE(&y[JUMP*(i+3)]);
    VECNF().LOAD(&x[JUMP*(i+4)]).STORE(&y[JUMP*(i+4)]);
    VECNF().LOAD(&x[JUMP*(i+5)]).STORE(&y[JUMP*(i+5)]);
    VECNF().LOAD(&x[JUMP*(i+6)]).STORE(&y[JUMP*(i+6)]);
    VECNF().LOAD(&x[JUMP*(i+7)]).STORE(&y[JUMP*(i+7)]);
}

}

Gdzie VECNF().LOADjest _mm_load_ps() dla SSE lub _mm256_load_ps() dla AVX, VECNF().STORE jest _mm_store_ps() dla SSE lub _mm256_store_ps() dla AVX, a skok wynosi 4 dla SSE lub 8 dla AVX.

W przypadku dużych rozmiarów najlepszy wynik uzyskuje się za pomocą instrukcji nie-temporalnych oraz za pomocą wielu wątków. W przeciwieństwie do tego, co wielu ludzi może wierzyć pojedynczy wątek zwykle nie nasyca przepustowości PAMIĘCI .

void copy_stream(const float *x, float *y, const int n) {
    #pragma omp parallel for        
    for(int i=0; i<n/JUMP; i++) {
        VECNF v = VECNF().load_a(&x[JUMP*i]);
        stream(&y[JUMP*i], v);
    }
}

Gdzie stream jest _mm_stream_ps() dla SSE lub _mm256_stream_ps() dla AVX

Oto memcpy wyniki na mojej [email protected] GHz z czterema wątkami dla 1 GB z maksymalną przepustowością pamięci głównej 51,2 GB / s.

                         GB/s     efficiency
    eglibc:              23.6     46%
    asmlib:              36.7     72%
    copy_stream:         36.7     72%
Po raz kolejny EGLIBC wypada słabo. Dzieje się tak dlatego, że nie używa magazynów pozaziemskich.

Zmodyfikowałem eglibc i asmlib memcpy Funkcje uruchamiane równolegle w ten sposób

void COPY(const float * __restrict x, float * __restrict y, const int n) {
    #pragma omp parallel
    {
        size_t my_start, my_size;
        int id = omp_get_thread_num();
        int num = omp_get_num_threads();
        my_start = (id*n)/num;
        my_size = ((id+1)*n)/num - my_start;
        memcpy(y+my_start, x+my_start, sizeof(float)*my_size);
    }
}

Ogólna funkcja memcpy musi uwzględniać tablice, które nie są wyrównane do 64 bajtów (lub nawet do 32 lub do 16 bajtów) i gdzie rozmiar nie jest wielokrotnością 32 bajtów ani współczynnikiem rozwinięcia. Dodatkowo, należy podjąć decyzję, kiedy korzystać z magazynów nie-czasowych. Ogólna zasada polega na używaniu magazynów nieokresowych tylko dla rozmiarów większych niż połowa największego poziomu pamięci podręcznej (Zwykle L3). Ale tezy są" drugiego rzędu " szczegóły, które myślę, że należy zająć się po optymalizacji dla idealnych przypadkach dużych i małych. Nie ma sensu martwić się o korygowanie niewspółosiowości lub nieposiadających idealnego rozmiaru wielokrotności, jeśli idealny przypadek działa również słabo.

Update

Bazując na komentarzach Stephena Canona dowiedziałem się, że na Ivy Bridge i Haswell jest bardziej wydajne w użyciu rep movsb niż movntdqa (nie-czasowa Instrukcja przechowywania). Intel nazywa to enhanced rep movsb (ERMSB) . Jest to opisane w Intel Optimization manuals w sekcji 3.7.6 Enhanced REP MOVSB and STOSB operation (ERMSB).

Dodatkowo, w Agner Fog ' s Optymalizacja podprogramów w Assembly instrukcja w sekcji 17.9 przenoszenie bloków danych (wszystkich procesorów) pisze:

" istnieje kilka sposobów przenoszenia dużych bloków danych. Najczęstsze metody to:

  1. Instrukcja REP MOVS.
  2. jeśli dane są wyrównane: Odczyt i zapis w pętli o największym dostępnym rozmiarze rejestru.
  3. jeśli rozmiar jest stały: instrukcje ruchu inline.
  4. jeśli dane są źle dopasowane: najpierw przenieś tyle bajtów, ile wymagane do dokonania przeznaczenia / align = "left" / Następnie odczyt bez wyrównania i zapis wyrównany w pętli z największym dostępnym rozmiar rejestru.
  5. jeśli dane są źle dopasowane: odczyt wyrównany, przesunięcie w celu kompensacji niewspółosiowości i zapis / align = "left" /
  6. jeśli rozmiar danych jest zbyt duży do buforowania, użyj zapisu nie-czasowego, aby ominąć pamięć podręczną. Przesunięcie w celu zrekompensowania niewspółosiowości, jeśli to konieczne."

Ogólny memcpy powinien rozważyć każdy z tych punktów. Dodatkowo z Ivy Bridge i Haswell wydaje się, że punkt 1 jest lepszy niż punkt 6 dla dużych tablic. Różne techniki są niezbędne dla Intela i AMD oraz dla każdej iteracji technologii. Myślę, że jest jasne, że pisanie własnej ogólnej efektywnej funkcji memcpy może być dość skomplikowane. Ale w szczególnych przypadkach, które przyjrzałem się już udało mi się zrobić lepiej niż GCC builtin memcpy lub ten w EGLIBC, więc założenie, że nie można zrobić lepiej niż standardowe biblioteki jest błędne.

 37
Author: Z boson,
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
2017-05-23 12:02:38

Na pytanie nie można dokładnie odpowiedzieć bez dodatkowych szczegółów, takich jak:

  • Jaka jest docelowa Platforma (architektura procesora, większość, ale konfiguracja pamięci również odgrywa rolę)?
  • jaki jest rozkład i przewidywalność1 długości kopii (a w mniejszym stopniu rozkład i przewidywalność wyrównań)?
  • czy rozmiar kopii będzie kiedykolwiek statycznie znany podczas kompilacji?

Mimo to, mogę wskazać kilka rzeczy, które mogą być nieoptymalne przynajmniej dla niektórych kombinacji powyższych parametrów.

32-case switch Statement

Instrukcja 32-case switch jest ładnym sposobem obsługi końcowych 0 do 31 bajtów i prawdopodobnie benchmarki bardzo dobrze - ale może działać źle w świecie rzeczywistym z powodu co najmniej dwóch czynników.

Rozmiar Kodu

Samo polecenie switch zajmuje kilkaset bajtów kodu dla ciała, oprócz 32-wpisowej tabeli wyszukiwania potrzebne, aby przejść do właściwej lokalizacji dla każdej długości. Koszt tego nie pojawi się w skoncentrowanym benchmarku memcpy na pełnowymiarowym CPU, ponieważ wszystko nadal mieści się w najszybszym poziomie pamięci podręcznej: ale w prawdziwym świecie wykonujesz również inny kod i istnieje spór o pamięć podręczną uop i pamięć podręczną danych L1 i instrukcji.

Że wiele instrukcji może zająć w pełni 20% efektywnego rozmiaru pamięci podręcznej uop3, i UOP Cache misses (i odpowiednie cykle przejścia cache-to-legacy encoder) mogą łatwo wymazać niewielką korzyść, jaką daje ten skomplikowany przełącznik.

Ponadto przełącznik wymaga 32-wpisowej, 256-bajtowej tabeli wyszukiwania dla celów skoku4. Jeśli kiedykolwiek dostaniesz miss do DRAM na tym lookup, mówisz kary 150 + cykli: ile nie-miss trzeba wtedy, aby switch warto, biorąc pod uwagę, że to prawdopodobnie oszczędność kilka lub dwa co najwyżej? Ponownie, że nie pojawi się w microbenchmark.

Na ile to ma wartość, to memcpy nie jest niczym niezwykłym: tego rodzaju "wyczerpujące wyliczanie przypadków" jest powszechne nawet w zoptymalizowanych bibliotekach. Mogę wywnioskować, że albo ich rozwój był napędzany głównie przez mikrobenchmarks, albo że nadal jest warto dla dużego kawałka kodu ogólnego przeznaczenia, pomimo wad. To powiedziawszy, z pewnością istnieją scenariusze (instrukcja i / lub ciśnienie pamięci podręcznej danych), w których jest to nieoptymalne.

Przewidywanie Gałęzi

The polecenie switch opiera się na pojedynczej gałęzi pośredniej do wyboru spośród alternatyw. Będzie to skuteczne do tego stopnia, że predyktor gałęzi może przewidzieć tę pośrednią gałąź, co zasadniczo oznacza, że sekwencja obserwowanych długości musi być przewidywalna.

Ponieważ jest to oddział pośredni, istnieje więcej ograniczeń przewidywalności oddziału niż oddziału warunkowego, ponieważ istnieje ograniczona liczba wpisów BTB. Ostatnie Procesory poczyniły postępy tutaj, ale można śmiało powiedzieć, że jeśli seria długości podawana do memcpy nie będzie zgodna z prostym powtarzającym się schematem krótkiego okresu (tak krótkim jak 1 lub 2 na starszych procesorach), to przy każdym wywołaniu pojawi się błąd branch-mispredict.

Ten problem jest szczególnie podstępny, ponieważ prawdopodobnie najbardziej zaszkodzi ci w prawdziwym świecie, dokładnie w sytuacjach, w których znak mikrobenchmark pokazuje switch jako najlepszy: krótkie długości. Dla bardzo długich długości zachowanie na kończących się 31 bajtach nie jest zbyt ważne ponieważ jest zdominowany przez kopię zbiorczą. W przypadku krótkich długości, switch jest najważniejsze (rzeczywiście, dla kopii 31 bajtów lub mniej to wszystkie są wykonywane)!

Dla tych krótkich długości, przewidywalna seria długości działa bardzo dobrze dla switch, ponieważ skok pośredni jest w zasadzie wolny. W szczególności typowy benchmark memcpy "zamiata" serię długości, używając tej samej długości wielokrotnie dla każdego pod-testu, aby przedstawić wyniki dla łatwego wykresu " czas vs długość" wykresy. switch świetnie sprawdza się w tych testach, często raportując wyniki takie jak 2 lub 3 cykle dla małych długości kilku bajtów.

W prawdziwym świecie, twoje długości mogą być małe, ale nieprzewidywalne. W takim przypadku oddział pośredni będzie często błędnie interpretował5, z karą ~20 cykli na nowoczesnych procesorach. W porównaniu do najlepszego przypadku kilku cykli jest o rząd wielkości gorszy. Tak więc szklana szczęka tutaj może być bardzo poważna (tzn. zachowanie switch w tym typowym przypadek może być o rząd wielkości gorszy niż najlepszy, podczas gdy na dłuższych dystansach Zwykle obserwujesz różnicę co najwyżej 50% między różnymi strategiami).

Rozwiązania

Więc jak można zrobić lepiej niż wyżej, przynajmniej w warunkach, w których switch rozpada?

Użyj urządzenia Duffa

Jednym z rozwiązań problemu z rozmiarem kodu jest połączenie skrzynek przełączników ze sobą, urządzenie Duffa-style.

Na przykład zmontowane kod dla długości 1, 3 i 7 przypadków wygląda następująco:

Długość 1

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    ret

Długość 3

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    movzx   edx, WORD PTR [rsi+1]
    mov     WORD PTR [rcx+1], dx

Długość 7

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    movzx   edx, WORD PTR [rsi+1]
    mov     WORD PTR [rcx+1], dx
    mov     edx, DWORD PTR [rsi+3]
    mov     DWORD PTR [rcx+3], edx
    ret
Można to połączyć w jeden przypadek, z różnymi skokami:]}
    len7:
    mov     edx, DWORD PTR [rsi-6]
    mov     DWORD PTR [rcx-6], edx
    len3:
    movzx   edx, WORD PTR [rsi-2]
    mov     WORD PTR [rcx-2], dx
    len1:
    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    ret

Etykiety nic nie kosztują, i łączą walizki razem i usuwają dwie z 3 ret instrukcji. Zauważ, że podstawy dla rsi i rcx zmieniły się tutaj: wskazują one na ostatni bajt do skopiowania Z / do, a nie niż pierwszy. Ta zmiana jest darmowa lub bardzo tania w zależności od kodu przed skokiem.

Można go rozszerzyć na dłuższe długości (np. można dołączyć długości 15 i 31 do łańcucha powyżej) i użyć innych łańcuchów do brakujących długości. Pełne ćwiczenie pozostawiamy czytelnikowi. Prawdopodobnie możesz uzyskać zmniejszenie rozmiaru o 50% samodzielnie z tego podejścia, a znacznie lepiej, jeśli połączysz go z czymś innym, aby zwinąć rozmiary od 16 - 31.

Takie podejście pomaga tylko w kodowaniu rozmiar (i ewentualnie rozmiar tabeli skoku, jeśli zmniejszysz rozmiar zgodnie z opisem w 4 i dostajesz poniżej 256 bajtów, pozwalając na tabelę wyszukiwania O rozmiarze bajtów. To nic nie robi dla przewidywalności.

Nakładające Się Sklepy

Jedna sztuczka, która pomaga zarówno dla rozmiaru kodu, jak i przewidywalności, to użycie nakładających się sklepów. Oznacza to, że memcpy z 8 do 15 bajtów może być wykonane w sposób wolny od gałęzi z dwoma 8-bajtowymi magazynami, przy czym drugi magazyn częściowo pokrywa się z pierwszym. Na przykład do skopiuj 11 bajtów, wykonasz 8-bajtową kopię w pozycji względnej 0 i 11 - 8 == 3. Niektóre bajty w środku byłyby" kopiowane dwa razy", ale w praktyce jest to w porządku, ponieważ 8-bajtowa Kopia ma taką samą prędkość jak 1, 2 lub 4-bajtowa.

Kod C wygląda następująco:

  if (Size >= 8) {
    *((uint64_t*)Dst) = *((const uint64_t*)Src);
    size_t offset = Size & 0x7;
    *(uint64_t *)(Dst + offset) = *(const uint64_t *)(Src + offset);
  }

... i odpowiednie zgromadzenie nie jest problematyczne:

    cmp     rdx, 7
    jbe     .L8
    mov     rcx, QWORD PTR [rsi]
    and     edx, 7
    mov     QWORD PTR [rdi], rcx
    mov     rcx, QWORD PTR [rsi+rdx]
    mov     QWORD PTR [rdi+rdx], rcx

W szczególności zwróć uwagę, że otrzymujesz dokładnie dwa ładunki, dwa sklepy i jeden and (oprócz cmp i jmp, których istnienie zależy od tego, jak zorganizujesz otaczający kod). Jest to już powiązane lub lepsze niż większość metod generowanych przez kompilator dla 8-15 bajtów, które mogą używać do 4 par load/store.

[51]}starsze procesory poniosły pewną karę za takie "nakładające się sklepy", ale nowsze architektury (przynajmniej w ostatniej dekadzie) wydają się radzić sobie z nimi bez kary6. Ma to dwie główne zalety:
  1. Zachowanie jest wolne od gałęzi dla różnych rozmiarów. Skutecznie, to kwantyzuje rozgałęzienia tak, że wiele wartości podąża tą samą ścieżką. Wszystkie rozmiary od 8 do 15 (lub 8 do 16, jeśli chcesz) podążają tą samą ścieżką i nie cierpią na błędne ciśnienie.

  2. Co najmniej 8 lub 9 różnych przypadków z switch jest łączonych w jeden przypadek z ułamkiem całkowitej wielkości kodu.

To podejście może być połączone z podejściem switch, ale przy użyciu tylko kilku przypadków, lub może być rozszerzone na większe rozmiary z warunkowym ruchy, które mogą wykonywać na przykład wszystkie ruchy od 8 do 31 bajtów bez gałęzi.

To, co działa najlepiej, zależy od rozkładu gałęzi, ale ogólnie ta technika "nakładania się" działa bardzo dobrze.

/ Align = "left" / ]}

Istniejący kod nie adresuje wyrównania.

W rzeczywistości nie jest to, ogólnie, legalne lub C lub C++, ponieważ wskaźniki char * są po prostu rzucane na większe typy i dereferowane, co nie jest legalne-chociaż w praktyce generuje kody to działa na dzisiejszych kompilatorach x86 (ale w rzeczywistości zawiodłoby na platformie o bardziej rygorystycznych wymaganiach dotyczących dopasowania).

Poza tym często lepiej jest zająć się wyrównaniem. Istnieją trzy główne przypadki:

  1. źródło i cel są już wyrównane. Nawet oryginalny algorytm będzie tutaj działał dobrze.
  2. źródło i cel sąrelatywnie wyrównane, ale całkowicie źle dopasowane. Oznacza to, że istnieje wartość A, którą można dodać do zarówno źródło, jak i miejsce docelowe są wyrównane.
  3. źródło i miejsce przeznaczenia są całkowicie niewspółosiowe (tzn. nie są faktycznie wyrównane, a case (2) nie ma zastosowania).

Istniejący algorytm będzie działał poprawnie w przypadku (1). Potencjalnie brak jest dużej optymalizacji, jak w przypadku (2), ponieważ mała pętla intro może zmienić niepodpisaną kopię w wyrównaną.

Jest również prawdopodobne, że działa słabo w przypadku (3), ponieważ ogólnie w całkowicie niewspółosiowym przypadek możesz wybrać wyrównanie miejsca docelowego lub źródła, a następnie przejść do pozycji "semi-aligned".

Kary wyrównania były coraz mniejsze w czasie i na najnowszych chipach są skromne dla kodu ogólnego przeznaczenia, ale nadal mogą być poważne dla kodu z wielu obciążeń i sklepów. W przypadku dużych kopii prawdopodobnie nie ma to większego znaczenia, ponieważ przepustowość pamięci DRAM będzie ograniczona, ale w przypadku mniejszych kopii niewspółosiowość może zmniejszyć przepustowość o 50% lub więcej.

Jeśli używasz NT stores, wyrównanie może być również ważne, ponieważ wiele instrukcji NT store działa źle z błędnie ustawionymi argumentami.

Brak rozwinięcia

Kod nie jest rozwijany, a Kompilatory rozwijane są domyślnie w różnych ilościach. Oczywiście jest to nieoptymalne, ponieważ wśród dwóch kompilatorów o różnych strategiach rozwijania, co najwyżej jeden będzie najlepszy.

Najlepszym podejściem (przynajmniej dla znanych celów platformy) jest określenie, który współczynnik unroll jest najlepszy, a następnie zastosowanie go w kod.

Co więcej, rozwijanie może być często połączone w inteligentny sposób z" intro "naszego kodu" outro", wykonując lepszą pracę niż kompilator może.

Znane rozmiary

Głównym powodem, dla którego trudno jest pokonać" wbudowaną " procedurę memcpy w nowoczesnych kompilatorach, jest to, że Kompilatory nie wywołują biblioteki memcpy za każdym razem, gdy memcpy pojawia się w źródle. Znają umowę memcpy i mogą ją dowolnie realizować za pomocą pojedynczej instrukcji inlined, a nawet mniej7, we właściwym scenariuszu.

Jest to szczególnie oczywiste przy znanych długościach w memcpy. W takim przypadku, jeśli długość jest mała, Kompilatory po prostu wstawią kilka instrukcji, aby wykonać kopię efektywnie i na miejscu. Pozwala to nie tylko uniknąć narzutu wywołania funkcji, ale także wszystkich kontroli rozmiaru i tak dalej-a także generuje w czasie kompilacji efektywny kod dla kopii, podobnie jak duży switch w powyższej implementacji-ale bez kosztów switch.

Podobnie, kompilator wie wiele o wyrównywaniu struktur w kodzie wywołującym i może tworzyć kod, który efektywnie zajmuje się wyrównywaniem.

Jeśli zaimplementujesz memcpy2 jako funkcję biblioteczną, trudno to odtworzyć. Możesz uzyskać część sposobu, w jaki dzielę metodę na small ibig część: small część pojawia się w pliku nagłówkowym i sprawdza rozmiar i potencjalnie wywołuje istniejący plik memcpy Jeśli rozmiar jest mały lub deleguje do procedury biblioteki, jeśli jest duża. Dzięki magii inliningu możesz dostać się do tego samego miejsca, co builtin memcpy.

Wreszcie, możesz również wypróbować triki z __builtin_constant_p lub odpowiednikami, aby efektywnie obsłużyć małą, znaną sprawę.


1 zauważ, że rysuję tutaj rozróżnienie między "dystrybucją" rozmiarów - np. można powiedzieć, że _uniformalnie dystrybuowane między 8 a 24 bajtami-a "przewidywalnością" rzeczywista Sekwencja rozmiarów (np. czy rozmiary mają przewidywalny wzór)? Kwestia przewidywalności jest nieco subtelna, ponieważ zależy od implementacji, ponieważ jak opisano powyżej, niektóre implementacje są z natury bardziej przewidywalne.

2 w szczególności, ~750 bajtów instrukcji w clang i ~600 bajtów w gcc dla samego ciała, na górze 256-bajtowej tabeli wyszukiwania skoków dla ciała przełącznika, która miała odpowiednio 180 - 250 instrukcji (gcc i clang). [233]} Godbolt link.

3 zasadniczo 200 połączonych uops z efektywnego rozmiaru pamięci podręcznej UOP 1000 instrukcji. Podczas gdy ostatni x86 miał rozmiar pamięci podręcznej UOP około ~1500 uops, nie można go używać poza wyjątkowo dedykowanym wypełnieniem bazy kodu ze względu na restrykcyjne reguły przypisywania kodu do pamięci podręcznej.

4 obudowy przełączników mają różną długość, więc skok nie może być bezpośrednio obliczony. Jeśli to coś warte, to można było to zrobić. inaczej: mogli użyć 16-bitowej wartości w tabeli wyszukiwania, kosztem Nie użycia źródła pamięci dla jmp, zmniejszając jej rozmiar o 75%.

5 W przeciwieństwie do warunkowego przewidywania gałęzi, które ma typowy najgorszy scenariusz przewidywania wynoszący ~50% (dla całkowicie losowych gałęzi), trudna do przewidzenia gałąź pośrednia może łatwo zbliżyć się do 100%, ponieważ nie rzucasz monetą, wybierasz prawie nieskończony zestaw celów dla gałęzi. Dzieje się tak w świecie rzeczywistym: if memcpy jest używany do kopiowania małych ciągów o długościach równomiernie rozłożonych między 0 a 30, kod switch będzie błędnie interpretowany ~97% czasu.

6 oczywiście, mogą być kary za niewłaściwie dopasowane sklepy, ale są one również na ogół małe i stają się coraz mniejsze.

7 na przykład, memcpy do stosu, po którym następuje jakaś manipulacja i kopia w innym miejscu może zostać całkowicie wyeliminowana, bezpośrednio przenosząc oryginalne dane do ostatecznego miejsce. Nawet takie rzeczy jak malloc, po których następuje memcpy, można całkowicie wyeliminować.

 6
Author: BeeOnRope,
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
2019-01-29 01:39:43

Po pierwsze, pętla główna używa nieprzypisanego wektora AVX do kopiowania 32 bajtów na raz, dopóki nie zostanie

    for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
    {
        __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
        _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
    }

Wtedy ostateczna Instrukcja switch obsługuje Pozostałe 0..31 bajtów w możliwie najefektywniejszy sposób, używając kombinacji kopii 8/4/2/1 bajtów. Zauważ, że nie jest to rozwijana pętla - to tylko 32 różne zoptymalizowane ścieżki kodu, które obsługują Pozostałe bajty przy użyciu minimalnej liczby obciążeń i magazynów.

Jak dla dlaczego główna 32-bajtowa pętla AVX nie jest ręcznie rozwijana - istnieje kilka możliwych powodów:

  • większość kompilatorów automatycznie rozwija małe pętle (w zależności od rozmiaru pętli i przełączników optymalizacji)
  • LSD nie jest w stanie odczytać danych z pamięci podręcznej LSD (zwykle tylko 28 zdekodowanych µops).]}
  • na obecnych procesorach Core iX możesz wystawić tylko dwa równoległe ładunki / magazyny przed przeciągnięciem [ * ]
  • zazwyczaj nawet nie rozwijana pętla AVX jak ta może nasycić dostępną przepustowość DRAM [ * ]

[ * ] zauważ, że dwa ostatnie komentarze powyżej odnoszą się do przypadków, w których źródło i/lub miejsce docelowe nie znajdują się w pamięci podręcznej (tj. zapis/Odczyt do/z pamięci DRAM), a zatem opóźnienie ładowania/przechowywania jest wysokie.

 4
Author: Paul R,
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
2014-10-08 12:13:59

Korzystanie z ERMSB]}

Proszę również rozważyć użycie REP MOVSB dla większych bloków.

Jak wiecie, od czasu wyprodukowania pierwszego procesora Pentium w 1993 roku, Intel zaczął wykonywać proste polecenia szybciej, a złożone (jak REP MOVSB) wolniej. Tak więc REP MOVSB stał się bardzo powolny i nie było już powodu, aby go używać. W 2013 roku Intel zdecydował się na rewizję REP MOVSB. Jeśli procesor posiada bit CPUID ERMSB (Enhanced REP MOVSB), to polecenia REP MOVSB są wykonywane inaczej niż na starszych procesory i mają być szybkie. W praktyce jest szybki tylko dla dużych bloków, 256 bajtów i większych, i tylko wtedy, gdy spełnione są pewne warunki:

  • zarówno adresy źródłowe, jak i docelowe muszą być wyrównane do 16-bajtowej granicy;
  • region źródłowy nie powinien pokrywać się z regionem docelowym;
  • Długość musi być wielokrotnością 64, aby uzyskać wyższą wydajność;
  • Kierunek musi być do przodu (CLD).

Zobacz instrukcję Intela w sprawie optymalizacji, sekcja 3.7.6 Enhanced REP movsb and STOSB operation (ERMSB)http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

Intel zaleca używanie AVX dla bloków mniejszych niż 2048 bajtów. W przypadku większych bloków Intel zaleca stosowanie REP MOVSB. Dzieje się tak dlatego, że wysokie początkowe koszty rozruchu REP MOVSB(około 35 cykli).

Zrobiłem testy prędkości, i dla bloków powyżej 2048 bajtów i wyższych, wydajność REP MOVSB jest nie do pobicia. Jednak dla bloków mniejszych niż 256 bajtów, REP MOVSB jest bardzo wolny, nawet wolniejszy niż zwykły MOV RAX w pętli.

Proszę nie, ERMSB wpływa tylko na MOVSB, nie MOVSD( MOVSQ), więc MOVSB jest trochę szybszy niż MOVSD (MOVSQ).

Więc możesz użyć AVX dla swojej implementacji memcpy (), a jeśli blok jest większy niż 2048 bajtów i wszystkie warunki są spełnione, wywołaj REP MOVSB - więc twoja implementacja memcpy() będzie nie do pobicia.

Czerpanie korzyści z Out-of-Order Execution Engine

Możesz również przeczytać o silniku realizacji poza zamówieniem w podręczniku " Intel® 64 i IA-32 Architectures Optimization Reference Manual" http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf sekcja 2.1.2 i czerpać z niej korzyści.

Na przykład, w serii procesorów Intel SkyLake (wprowadzonej na rynek w 2015 roku), ma:
  • 4 jednostki wykonawcze dla jednostki arytmetyczno-logicznej (ALU) (add, and, CMP, or, test, xor, movzx, movsx, mov, (v)movdqu, (v)movdqa, (v)movap*, (v) movup),
  • 3 jednostki wykonawcze dla wektora ALU ( (V)pand, (V)por, (V)pxor, (V)movq, (V)movq, (V)movap*, (v)movup*, (V)ANDP*, (V)orp*, (V)paddb/w/d/q, (v)blendv*, (V)blendp*, (v)pblendd)

Możemy więc zajmować powyższe jednostki (3+4) równolegle, jeśli używamy operacji tylko rejestrujących. Nie możemy używać 3+4 instrukcji równolegle do kopiowania pamięci. Możemy używaj jednocześnie maksymalnie dwóch 32-bajtowych instrukcji do ładowania z pamięci i jednej 32-bajtowej instrukcji do przechowywania z pamięci, nawet jeśli pracujemy z buforem poziomu 1.

Proszę ponownie zapoznać się z instrukcją Intela, aby zrozumieć, jak wykonać najszybszą implementację memcpy: http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

Sekcja 2.2.2 (silnik poza zamówieniem Haswelll microarchitecture): "Scheduler kontroluje wysyłanie mikroprocesorów do portów dyspozytorskich. Istnieje osiem portów wysyłkowych obsługujących rdzeń realizacji poza zamówieniem. Cztery z ośmiu portów zapewniały zasoby wykonawcze dla operacji obliczeniowych. Pozostałe 4 porty obsługują operacje pamięci do dwóch 256-bitowych operacji ładowania i jednej 256-bitowej operacji przechowywania w cyklu."

Sekcja 2.2.4 (Cache i Memory Subsystem) ma następującą uwagę: "pamięć podręczna danych pierwszego poziomu obsługuje dwa mikroprocesory obciążenia każdy cykl; każdy mikroprocesor może pobrać do 32 bajtów danych."

Sekcja 2.2.4.1 (Load and Store Operation Enhancements) zawiera następujące informacje: pamięć podręczna danych L1 może obsługiwać dwa 256-bitowe (32 bajty) ładowanie i jedną 256-bitową (32 bajty) operacje magazynowania w każdym cyklu. Unified L2 może obsługiwać jedną linię pamięci podręcznej (64 bajty) w każdym cyklu. Dodatkowo dostępne są 72 bufory ładunkowe i 42 bufory magazynowe do obsługi wykonywania mikro-operacji w locie.

Pozostałe sekcje (2.3 i tak dalej, dedykowany do Sandy Bridge i innych mikroarchitektur).

Sekcja 2.3.4 (Rdzeń wykonania) zawiera dodatkowe szczegóły.

Planer może wysyłać do sześciu mikro-operacji w każdym cyklu, po jednym na każdym porcie. Poniższa tabela podsumowuje, które operacje mogą być wysłane na jakim porcie.
  • Port 0: ALU, Shift, Mul, STTNI, Int-Div, 128b-Mov, Blend, 256b-Mov
  • Port 1: ALU, Fast LEA, Slow LEA, MUL, Shuf, Blend, 128bMov, Add, CVT
  • Port 2 & Port 3: Load_Addr, Store_addr
  • Port 4: Store_data
  • Port 5: ALU, Shift, Branch, Fast LEA, Shuf, Blend, 128b-Mov, 256b-Mov

Sekcja 2.3.5.1 (przegląd operacji ładowania i przechowywania) może być również przydatna do zrozumienia, jak wykonać szybką kopię pamięci, a także sekcja 2.4.4.1 (ładowanie i przechowywanie).

Dla innych architektur procesorów jest to ponownie-dwie jednostki obciążenia i jedna jednostka magazynowa. Tabela 2-4 (Parametry Pamięci Podręcznej z mikroarchitektury Skylake) posiada następujące informacje:

Pasmo szczytowe (bajty / cyc):

  • Pamięć podręczna danych pierwszego poziomu: 96 bajtów (2x32b Load + 1*32B Store)
  • Pamięć podręczna drugiego poziomu: 64 bajty
  • Pamięć podręczna trzeciego poziomu: 32 bajty.

Zrobiłem również testy prędkości na moim procesorze Intel Core i5 6600 (Skylake, 14nm, wydany we wrześniu 2015) z pamięcią DDR4, i to potwierdziło teorie. Na przykład, mój test pokazał, że przy użyciu generic 64-bit rejestry do kopiowania pamięci, nawet wiele rejestrów równolegle, pogarszają wydajność. Ponadto wystarczy użycie tylko 2 rejestrów XMM - dodanie trzeciego nie zwiększa wydajności.

Jeśli twój procesor ma bit CPUID AVX, możesz skorzystać z dużych, 256-bitowych (32-bajtowych) rejestrów YMM do kopiowania pamięci, aby zająć dwie pełne jednostki obciążenia. Obsługa AVX została po raz pierwszy wprowadzona przez Intela z procesorami Sandy Bridge, dostarczonymi w I kwartale 2011 roku, a później przez AMD z procesorem Bulldozer dostarczonym w III kwartale 2011.

// first cycle  
vmovdqa ymm0, ymmword ptr [rcx+0]      // load 1st 32-byte part using first load unit
vmovdqa ymm1, ymmword ptr [rcx+20h]    // load 2nd 32-byte part using second load unit

// second cycle
vmovdqa ymmword ptr [rdx+0], ymm0      // store 1st 32-byte part using the single store unit

// third cycle
vmovdqa ymmword ptr [rdx+20h], ymm1    ; store 2nd 32-byte part - using the single store unit (this instruction will require a separate cycle since there is only one store unit, and we cannot do two stores in a single cycle)

add ecx, 40h // these instructions will be used by a different unit since they don't invoke load or store, so they won't require a new cycle
add edx, 40h

Jest również korzyść z szybkości, jeśli pętla-rozwinąć ten kod co najmniej 8 razy. Jak pisałem wcześniej, dodawanie kolejnych rejestrów oprócz ymm0 i ymm1 nie zwiększa wydajności, ponieważ są tylko dwie jednostki obciążenia i jedna jednostka magazynowa. Dodawanie pętli typu "dec r9 jnz @@again" obniża wydajność, ale proste "add ecx/edx" nie.

Wreszcie, jeśli twój procesor ma rozszerzenie AVX-512, możesz użyć 512-bitowych (64-bajtowych) rejestrów do kopiowania pamięci:

vmovdqu64   zmm0, [rcx+0]           ; load 1st 64-byte part
vmovdqu64   zmm1, [rcx+40h]         ; load 2nd 64-byte part 

vmovdqu64   [rdx+0], zmm0           ; store 1st 64-byte part
vmovdqu64   [rdx+40h], zmm1         ; store 2nd 64-byte part 

add     rcx, 80h
add     rdx, 80h    

AVX-512 jest obsługiwane przez następujące procesory: Xeon Phi x200, wydany w 2016; Skylake EP/EX Xeon "Purley" (Xeon E5-26XX V5) procesory (H2 2017); Procesory Cannonlake (H2 2017), procesory Skylake-X - Core i9-7×××x, i7-7×××x, i5-7×××X - wydany w czerwcu 2017.

Należy pamiętać, że pamięć musi być wyrównana do rozmiaru używanych rejestrów. Jeśli tak nie jest, użyj instrukcji "unaligned": vmovdqu i moveups.

 3
Author: Maxim Masiutin,
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
2017-07-01 11:13:13