Dlaczego transpozycja matrycy 512x512 jest znacznie wolniejsza niż transpozycja matrycy 513x513?

Po przeprowadzeniu kilku eksperymentów na kwadratowych matrycach o różnych rozmiarach, pojawił się wzór. Niezmiennie, transpozycja macierzy o rozmiarze 2^n jest wolniejsza niż transpozycja macierzy o rozmiarze 2^n+1. Dla małych wartości n różnica nie jest duża.

Duże różnice występują jednak nad wartością 512. (przynajmniej dla mnie)

Zastrzeżenie: wiem, że funkcja nie transponuje macierzy z powodu podwójnej zamiany elementów, ale nie różnica.

Następuje według kodu:

#define SAMPLES 1000
#define MATSIZE 512

#include <time.h>
#include <iostream>
int mat[MATSIZE][MATSIZE];

void transpose()
{
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
   {
       int aux = mat[i][j];
       mat[i][j] = mat[j][i];
       mat[j][i] = aux;
   }
}

int main()
{
   //initialize matrix
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
       mat[i][j] = i+j;

   int t = clock();
   for ( int i = 0 ; i < SAMPLES ; i++ )
       transpose();
   int elapsed = clock() - t;

   std::cout << "Average for a matrix of " << MATSIZE << ": " << elapsed / SAMPLES;
}

Zmiana MATSIZE pozwala nam zmienić rozmiar (duh!). Wrzuciłem dwie wersje na ideone:

W moim środowisku (MSVS 2010, pełne optymalizacje) różnica jest podobna:

  • Rozmiar 512 - średni 2.19 ms
  • Rozmiar 513 - średnia 0.57 ms

Dlaczego to się dzieje?

Author: Luchian Grigore, 2012-07-10

3 answers

Wyjaśnienie pochodzi od Agnera mgły w Optymalizacja oprogramowania w C++ i zmniejsza się do tego, jak dane są dostępne i przechowywane w pamięci podręcznej.

Po warunki i szczegółowe informacje, zobacz wpis wiki na temat buforowania , zawężę go tutaj.

Pamięć podręczna jest zorganizowana w zestawy i linie . W tym czasie używany jest tylko jeden zestaw, z którego można użyć dowolnej z zawartych w nim linii. Pamięć linii może odzwierciedlać razy liczbę linii daje nam rozmiar pamięci podręcznej.

Dla konkretnego adresu pamięci, możemy obliczyć, który zestaw powinien go odzwierciedlać ze wzoru:

set = ( address / lineSize ) % numberOfsets

Ten rodzaj formuły idealnie daje równomierny rozkład między zbiorami, ponieważ każdy adres pamięci jest równie prawdopodobny do odczytania(powiedziałem idealnie ).

Jest jasne, że mogą wystąpić nakładania się. W przypadku braku pamięci podręcznej, pamięć jest odczytywana w pamięci podręcznej i Stara wartość jest zastępowana. Pamiętaj, że każdy zestaw ma kilka linii, z który najmniej ostatnio używany jest nadpisywany nowo odczytaną pamięcią.

[[13]}postaram się nieco podążyć za przykładem Agnera:

Załóżmy, że każdy zbiór ma 4 linie, z których każda zawiera 64 bajty. Najpierw próbujemy odczytać adres 0x2710, który wchodzi w set 28. I wtedy też staramy się odczytać adresy 0x2F00, 0x3700, 0x3F00 i 0x4700. Wszystkie należą do tego samego zestawu. Przed odczytaniem 0x4700 wszystkie linie w zestawie byłyby zajęte. Odczytywanie tej pamięci eksmituje istniejącą linia w zestawie, linia, która początkowo trzymała 0x2710. Problem polega na tym, że odczytujemy adresy, które są (dla tego przykładu) 0x800 osobno. Jest to krok krytyczny (ponownie w tym przykładzie).

Krok krytyczny można również obliczyć:

criticalStride = numberOfSets * lineSize

Zmienne odstępowane criticalStride lub wielokrotne odstępy dla tych samych linii pamięci podręcznej.

To jest część teorii. Następnie Wyjaśnienie (również Agner, śledzę go uważnie, aby uniknąć błędy):

Załóżmy macierz 64x64 (pamiętaj, efekty różnią się w zależności od pamięci podręcznej) z 8kB pamięci podręcznej, 4 linie na zestaw * rozmiar linii 64 bajtów. Każda linia może zawierać 8 elementów w macierzy (64-bitowe int).

Krok krytyczny będzie wynosił 2048 bajtów, co odpowiada 4 rzędom macierzy (która jest ciągła w pamięci).

Załóżmy, że przetwarzamy rząd 28. Próbujemy pobrać elementy tego wiersza i zamienić je na elementy z kolumny 28. Pierwsze 8 elementów wiersza składa się z linii bufora, ale zostaną one podzielone na 8 różnych linii bufora w kolumnie 28. Pamiętaj, że krok krytyczny jest oddalony o 4 rzędy (4 kolejne elementy w kolumnie).

Gdy element 16 zostanie osiągnięty w kolumnie (4 linie bufora na zestaw & 4 wiersze od siebie = problem), Element ex-0 zostanie eksmitowany z bufora. Gdy dojdziemy do końca kolumny, wszystkie poprzednie linie bufora zostałyby utracone i potrzebne byłoby przeładowanie przy dostępie do następnego elementu (całej linii jest nadpisany).

Rozmiar, który nie jest wielokrotnością kroku krytycznego, psuje ten idealny scenariuszna katastrofę, ponieważ nie mamy już do czynienia z elementami, które są oddalone od siebie w pionie, więc liczba przeładowań pamięci podręcznej jest znacznie zmniejszona.

kolejne zastrzeżenie - po prostu pogodziłem się z wyjaśnieniem i mam nadzieję, że go trafiłem, ale mogę się mylić. W każdym razie czekam na odpowiedź (lub potwierdzenie) od mistyczny . :)

 173
Author: Luchian Grigore,
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
2018-09-21 05:39:26

Luchian daje wyjaśnienie Dlaczego Takie zachowanie się dzieje, ale pomyślałem, że dobrym pomysłem będzie pokazanie jednego możliwego rozwiązania tego problemu, a jednocześnie pokazanie trochę o algorytmach nieświadomych pamięci podręcznej.

Twój algorytm zasadniczo robi:

for (int i = 0; i < N; i++) 
   for (int j = 0; j < N; j++) 
        A[j][i] = A[i][j];

Co jest po prostu straszne dla nowoczesnego procesora. Jednym z rozwiązań jest poznanie szczegółów dotyczących systemu pamięci podręcznej i dostosowanie algorytmu, aby uniknąć tych problemów. Działa świetnie, o ile znasz te szczegóły.. nie szczególnie przenośny.

Czy stać nas na więcej? Tak możemy: ogólnym podejściem do tego problemu są algorytmy pamięci podręcznej które jak sama nazwa mówi unika uzależnienia od konkretnych rozmiarów pamięci podręcznej[1]

Rozwiązanie wyglądałoby tak:

void recursiveTranspose(int i0, int i1, int j0, int j1) {
    int di = i1 - i0, dj = j1 - j0;
    const int LEAFSIZE = 32; // well ok caching still affects this one here
    if (di >= dj && di > LEAFSIZE) {
        int im = (i0 + i1) / 2;
        recursiveTranspose(i0, im, j0, j1);
        recursiveTranspose(im, i1, j0, j1);
    } else if (dj > LEAFSIZE) {
        int jm = (j0 + j1) / 2;
        recursiveTranspose(i0, i1, j0, jm);
        recursiveTranspose(i0, i1, jm, j1);
    } else {
    for (int i = i0; i < i1; i++ )
        for (int j = j0; j < j1; j++ )
            mat[j][i] = mat[i][j];
    }
}

Nieco bardziej złożony, ale krótki test pokazuje coś ciekawego na moim ancient e8400 z wydaniem VS2010 x64, kod testowy dla MATSIZE 8192

int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));
    return 0;
}

results: 
recursive: 480.58ms
iterative: 3678.46ms

Edit: o wpływie wielkości: jest znacznie mniej wyraźny chociaż wciąż zauważalne w pewnym stopniu, to dlatego, że używamy rozwiązania iteracyjnego jako węzła liścia zamiast rekurencyjnego w dół do 1 (Zwykle Optymalizacja dla algorytmów rekurencyjnych). Jeśli ustawimy LEAFSIZE = 1, cache nie ma na mnie wpływu [8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms - to jest wewnątrz marginesu błędu, wahania są w obszarze 100ms; ten "benchmark" nie jest czymś, co byłoby zbyt wygodne, gdybyśmy chcieli całkowicie dokładne wartości])

[1] źródła do tego: Cóż, jeśli nie możesz zdobądź wykład od kogoś, kto pracował nad tym z Leisersonem.. Zakładam, że ich papiery to dobry punkt wyjścia. Algorytmy te są wciąż dość rzadko opisywane - CLR ma o nich jeden przypis. Mimo to jest to świetny sposób na zaskoczenie ludzi.


Edit (uwaga: to nie ja zamieściłem tę odpowiedź; chciałem tylko dodać):
Oto pełna wersja C++ powyższego kodu:

template<class InIt, class OutIt>
void transpose(InIt const input, OutIt const output,
    size_t const rows, size_t const columns,
    size_t const r1 = 0, size_t const c1 = 0,
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0,
    size_t const leaf = 0x20)
{
    if (!~c2) { c2 = columns - c1; }
    if (!~r2) { r2 = rows - r1; }
    size_t const di = r2 - r1, dj = c2 - c1;
    if (di >= dj && di > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, (r1 + r2) / 2, c2);
        transpose(input, output, rows, columns, (r1 + r2) / 2, c1, r2, c2);
    }
    else if (dj > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2) / 2);
        transpose(input, output, rows, columns, r1, (c1 + c2) / 2, r2, c2);
    }
    else
    {
        for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns);
            i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns)
        {
            for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows);
                j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows)
            {
                output[j2 + i1] = input[i2 + j1];
            }
        }
    }
}
 71
Author: Voo,
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-05-25 06:26:19

Jako ilustrację do wyjaśnienia w Luchian Grigore ' s answer, oto jak wygląda obecność pamięci podręcznej macierzy dla dwóch przypadków macierzy 64x64 i 65x65(zobacz link powyżej, aby uzyskać szczegóły dotyczące liczb).

Kolory w animacjach poniżej oznaczają:

  • biały – nie w pamięci podręcznej,
  • jasnozielony – in cache,
  • jasna zieleń – Cache hit,
  • orange – po prostu czytaj z RAM,
  • czerwony – Cache miss.

Obudowa 64x64:

animacja obecności pamięci podręcznej dla matrycy 64x64

Zauważ jak prawie każdy dostęp do nowego wiersza powoduje brak pamięci podręcznej. A teraz jak to wygląda w normalnym przypadku, matrycy 65x65:

animacja obecności pamięci podręcznej dla matrycy 65x65

Tutaj widać, że większość dostępów po początkowym rozgrzaniu to Cache hits. Tak ma działać pamięć podręczna procesora.

 31
Author: Ruslan,
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
2018-03-04 06:58:52