Tại sao hoán vị ma trận 512x512 chậm hơn nhiều so với hoán vị ma trận 513x513?


218

Sau khi tiến hành một số thí nghiệm trên ma trận vuông có kích thước khác nhau, một mô hình đã xuất hiện. Lúc nào cũng vậy, hoán vị một ma trận kích thước 2^nchậm hơn so với hoán vị một kích thước2^n+1 . Đối với các giá trị nhỏ của n, sự khác biệt là không lớn.

Tuy nhiên, sự khác biệt lớn xảy ra so với giá trị 512. (ít nhất là đối với tôi)

Tuyên bố miễn trừ trách nhiệm: Tôi biết hàm không thực sự hoán đổi ma trận do sự hoán đổi kép của các phần tử, nhưng nó không có sự khác biệt.

Theo mã:

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

Thay đổi MATSIZEcho phép chúng tôi thay đổi kích thước (duh!). Tôi đã đăng hai phiên bản trên ideone:

Trong môi trường của tôi (MSVS 2010, tối ưu hóa hoàn toàn), sự khác biệt là tương tự:

  • kích thước 512 - trung bình 2,19 ms
  • kích thước 513 - trung bình 0,57 ms

Tại sao chuyện này đang xảy ra?


9
Mã của bạn trông không thân thiện với tôi.
CodeInChaos

7
Vấn đề này khá giống với câu hỏi này: stackoverflow.com/questions/7905760/

Muốn làm nổi bật, @CodesInChaos? (Hoặc bất cứ ai khác.)
corazza

@Bane Làm thế nào về việc đọc câu trả lời được chấp nhận?
CodeInChaos

4
@nzomkxia Thật vô nghĩa khi đo bất cứ thứ gì mà không tối ưu hóa. Khi tối ưu hóa bị vô hiệu hóa, mã được tạo sẽ bị vứt đầy rác bên ngoài sẽ che giấu các nút thắt khác. (chẳng hạn như bộ nhớ)
Bí ẩn

Câu trả lời:


197

Lời giải thích đến từ Agner Fog trong Tối ưu hóa phần mềm trong C ++ và nó làm giảm cách thức dữ liệu được truy cập và lưu trữ trong bộ đệm.

Để biết các điều khoản và thông tin chi tiết, hãy xem mục wiki về bộ nhớ đệm , tôi sẽ thu hẹp nó ở đây.

Một bộ đệm được tổ chức theo bộdòng . Tại một thời điểm, chỉ có một bộ được sử dụng, trong đó bất kỳ dòng nào nó chứa có thể được sử dụng. Bộ nhớ mà một dòng có thể nhân đôi số lần cho chúng ta kích thước bộ đệm.

Đối với một địa chỉ bộ nhớ cụ thể, chúng ta có thể tính toán tập hợp nào sẽ phản chiếu nó với công thức:

set = ( address / lineSize ) % numberOfsets

Loại công thức này lý tưởng cho phân phối đồng đều trên các tập hợp, bởi vì mỗi địa chỉ bộ nhớ đều có khả năng được đọc (tôi nói lý tưởng ).

Rõ ràng là sự chồng chéo có thể xảy ra. Trong trường hợp bộ nhớ cache bị mất, bộ nhớ được đọc trong bộ đệm và giá trị cũ được thay thế. Hãy nhớ rằng mỗi bộ có một số dòng, trong đó một dòng ít được sử dụng gần đây nhất được ghi đè bằng bộ nhớ mới đọc.

Tôi sẽ cố gắng làm theo ví dụ từ Agner:

Giả sử mỗi bộ có 4 dòng, mỗi dòng chứa 64 byte. Trước tiên chúng tôi cố gắng đọc địa chỉ 0x2710, được thiết lập 28. Và sau đó chúng tôi cũng cố gắng để đọc địa chỉ 0x2F00, 0x3700, 0x3F000x4700. Tất cả những thứ này thuộc về cùng một bộ. Trước khi đọc 0x4700, tất cả các dòng trong bộ sẽ bị chiếm dụng. Đọc bộ nhớ đó cho thấy một dòng hiện có trong tập hợp, dòng ban đầu đang giữ 0x2710. Vấn đề nằm ở chỗ chúng ta đọc các địa chỉ (ví dụ này) 0x800cách nhau. Đây là bước tiến quan trọng (một lần nữa, cho ví dụ này).

Bước tiến quan trọng cũng có thể được tính toán:

criticalStride = numberOfSets * lineSize

Các biến cách nhau criticalStridehoặc nhiều lần tranh nhau cho cùng một dòng bộ đệm.

Đây là phần lý thuyết. Tiếp theo, lời giải thích (cũng là Agner, tôi đang theo dõi chặt chẽ để tránh mắc lỗi):

Giả sử ma trận 64x64 (hãy nhớ rằng, các hiệu ứng thay đổi tùy theo bộ đệm) với bộ đệm 8kb, 4 dòng trên mỗi bộ * kích thước dòng là 64 byte. Mỗi dòng có thể chứa 8 trong số các phần tử trong ma trận (64-bit int).

Bước tiến quan trọng sẽ là 2048 byte, tương ứng với 4 hàng của ma trận (liên tục trong bộ nhớ).

Giả sử chúng tôi đang xử lý hàng 28. Chúng tôi đang cố lấy các phần tử của hàng này và trao đổi chúng với các phần tử từ cột 28. 8 phần tử đầu tiên của hàng tạo thành một dòng bộ đệm, nhưng chúng sẽ đi vào 8 phần khác nhau các dòng bộ đệm trong cột 28. Hãy nhớ rằng, sải chân quan trọng cách nhau 4 hàng (4 phần tử liên tiếp trong một cột).

Khi đạt đến phần tử 16 trong cột (4 dòng bộ đệm cho mỗi bộ và cách nhau 4 hàng = rắc rối), phần tử ex-0 sẽ bị đuổi khỏi bộ đệm. Khi chúng tôi đến cuối cột, tất cả các dòng bộ đệm trước đó sẽ bị mất và cần tải lại khi truy cập vào phần tử tiếp theo (toàn bộ dòng được ghi đè).

Có một kích thước không phải là một trong nhiều bước tiến quan trọng làm rối loạn kịch bản hoàn hảo này vì thảm họa, vì chúng ta không còn phải đối phó với các yếu tố có bước tiến quan trọng trên chiều dọc, do đó số lần tải lại bộ đệm bị giảm nghiêm trọng.

Một từ chối trách nhiệm khác - tôi chỉ cần giải thích xung quanh lời giải thích và hy vọng tôi đóng đinh nó, nhưng tôi có thể bị nhầm lẫn. Dù sao, tôi đang chờ phản hồi (hoặc xác nhận) từ Mysticial . :)


Oh và lần sau. Chỉ cần ping tôi trực tiếp qua Lounge . Tôi không tìm thấy mọi trường hợp tên trên SO. :) Tôi chỉ thấy điều này thông qua các thông báo email định kỳ.
Bí ẩn

@Mysticial @Luchian Grigore Một trong những người bạn của tôi nói với tôi rằng Intel core i3máy tính của anh ta chạy trên Ubuntu 11.04 i386cho thấy hiệu năng gần như tương tự với gcc 4.6 . Và cũng giống như vậy đối với máy tính của tôi Intel Core 2 Duovới mingw gcc4.4 , ai đang chạy windows 7(32). Nó cho thấy sự khác biệt lớn khi Tôi biên dịch phân khúc này với một máy tính cũ hơn một chút intel centrinovới gcc 4.6 , người đang chạy ubuntu 12.04 i386.
Hongxu Chen

Cũng lưu ý rằng quyền truy cập bộ nhớ trong đó các địa chỉ khác nhau bởi một bội số của 4096 có sự phụ thuộc sai vào CPU gia đình SnB của Intel. (tức là bù tương tự trong một trang). Điều này có thể làm giảm thông lượng khi một số hoạt động là cửa hàng, đặc biệt. một sự pha trộn của tải và cửa hàng.
Peter Cordes

which goes in set 24thay vào đó, bạn có nghĩa là "trong tập 28 "? Và bạn có giả định 32 bộ?
Ruslan

Bạn đã đúng, đó là 28. :) Tôi cũng đã kiểm tra kỹ giấy được liên kết, để biết giải thích ban đầu, bạn có thể điều hướng đến 9.2 Tổ chức bộ
đệm

78

Luchian đưa ra lời giải thích về lý do tại sao hành vi này xảy ra, nhưng tôi nghĩ rằng đó là một ý tưởng hay để chỉ ra một giải pháp khả thi cho vấn đề này và đồng thời hiển thị một chút về các thuật toán lãng quên bộ nhớ cache.

Thuật toán của bạn về cơ bản là:

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

Điều này thật kinh khủng đối với một CPU hiện đại. Một giải pháp là biết chi tiết về hệ thống bộ nhớ cache của bạn và điều chỉnh thuật toán để tránh những vấn đề đó. Hoạt động tuyệt vời miễn là bạn biết những chi tiết đó .. không đặc biệt là di động.

Chúng ta có thể làm tốt hơn thế không? Có, chúng tôi có thể: Một cách tiếp cận chung cho vấn đề này là các thuật toán lãng quên bộ đệm mà như tên gọi đã tránh được việc phụ thuộc vào kích thước bộ đệm cụ thể [1]

Giải pháp sẽ như thế này:

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

Hơi phức tạp hơn một chút, nhưng một thử nghiệm ngắn cho thấy một điều khá thú vị trên e8400 cổ của tôi với bản phát hành VS2010 x64, mã kiểm tra cho 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

Chỉnh sửa: Về ảnh hưởng của kích thước: Nó ít rõ rệt hơn mặc dù vẫn đáng chú ý ở một mức độ nào đó, bởi vì chúng tôi đang sử dụng giải pháp lặp như một nút lá thay vì đệ quy xuống 1 (tối ưu hóa thông thường cho các thuật toán đệ quy). Nếu chúng tôi đặt LEAFSIZE = 1, bộ đệm không ảnh hưởng đến tôi [ 8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms- đó là bên trong lề lỗi, các dao động nằm trong khu vực 100ms; "điểm chuẩn" này không phải là điều mà tôi quá thoải mái nếu chúng tôi muốn các giá trị hoàn toàn chính xác])

[1] Nguồn cho nội dung này: Chà nếu bạn không thể nhận được một bài giảng từ một người làm việc với Leiserson và đồng ý về điều này .. Tôi cho rằng bài báo của họ là điểm khởi đầu tốt. Những thuật toán đó vẫn còn khá hiếm khi được mô tả - CLR có một chú thích duy nhất về chúng. Tuy nhiên, đó là một cách tuyệt vời để làm mọi người ngạc nhiên.


Chỉnh sửa (lưu ý: Tôi không phải là người đã đăng câu trả lời này; tôi chỉ muốn thêm câu này):
Đây là phiên bản C ++ hoàn chỉnh của đoạn mã trên:

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

2
Điều này sẽ có liên quan nếu bạn so sánh thời gian giữa các ma trận có kích thước khác nhau, không phải đệ quy và lặp lại. Hãy thử giải pháp đệ quy trên một ma trận có kích thước được chỉ định.
Luchian Grigore

@Luchian Vì bạn đã giải thích lý do tại sao anh ấy nhìn thấy hành vi, tôi nghĩ khá thú vị khi giới thiệu một giải pháp cho vấn đề này nói chung.
Voo

Bởi vì, tôi đang đặt câu hỏi tại sao một ma trận lớn hơn lại mất một thời gian ngắn hơn để xử lý, không tìm kiếm một thuật toán nhanh hơn ...
Luchian Grigore

@Luchian Sự khác biệt giữa 16383 và 16384 là .. 28 so với 27ms đối với tôi ở đây, hoặc khoảng 3,5% - không thực sự đáng kể. Và tôi sẽ ngạc nhiên nếu có.
Voo

3
Thật thú vị khi giải thích những gì recursiveTransposenó làm, tức là nó không lấp đầy bộ đệm bằng cách hoạt động trên các ô nhỏ ( LEAFSIZE x LEAFSIZEkích thước).
Matthieu M.

60

Như một minh họa cho lời giải thích trong câu trả lời của Luchian Grigore , đây là sự hiện diện của bộ đệm ma trận cho hai trường hợp ma trận 64x64 và 65x65 (xem liên kết ở trên để biết chi tiết về các con số).

Màu sắc trong hình động dưới đây có nghĩa như sau:

  • trắng - không có trong bộ nhớ cache,
  • màu xanh lợt - trong bộ nhớ cache,
  • màu xanh lá cây tươi sáng - nhấn bộ nhớ cache,
  • trái cam - chỉ cần đọc từ RAM,
  • màu đỏ - nhớ cache.

Trường hợp 64x64:

hoạt hình hiện diện bộ đệm cho ma trận 64x64

Lưu ý rằng hầu như mọi quyền truy cập vào một hàng mới đều dẫn đến việc bỏ lỡ bộ đệm. Và bây giờ nó trông như thế nào đối với trường hợp bình thường, ma trận 65x65:

hoạt hình hiện diện bộ đệm cho ma trận 65x65

Ở đây bạn có thể thấy rằng hầu hết các truy cập sau khi khởi động ban đầu là các lần truy cập bộ đệm. Đây là cách bộ đệm CPU được dự định để làm việc nói chung.


Mã tạo khung cho các hình ảnh động ở trên có thể được nhìn thấy ở đây .


Tại sao các lần truy cập bộ đệm quét dọc không được lưu trong trường hợp đầu tiên, nhưng chúng lại nằm trong trường hợp thứ hai? Có vẻ như một khối nhất định được truy cập chính xác một lần cho hầu hết các khối trong cả hai ví dụ.
Josiah Yoder

Tôi có thể thấy từ câu trả lời của @ LuchianGrigore rằng đó là vì tất cả các dòng trong cột thuộc về cùng một bộ.
Josiah Yoder

Vâng, minh họa tuyệt vời. Tôi thấy rằng họ đang ở cùng một tốc độ. Nhưng thật ra, họ không phải vậy sao?
kelalaka

@kelalaka vâng, FPS hoạt hình cũng vậy. Tôi đã không mô phỏng sự chậm lại, chỉ có màu sắc là quan trọng ở đây.
Ruslan

Sẽ rất thú vị khi có hai hình ảnh tĩnh minh họa các bộ bộ đệm khác nhau.
Josiah Yoder
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.