Tại sao thứ tự của các vòng lặp ảnh hưởng đến hiệu suất khi lặp qua một mảng 2D?


360

Dưới đây là hai chương trình gần như giống hệt nhau ngoại trừ việc tôi đã chuyển đổi ijcác biến xung quanh. Cả hai đều chạy trong những khoảng thời gian khác nhau. Ai đó có thể giải thích tại sao điều này xảy ra?

Phiên bản 1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

Phiên bản 2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}


7
Bạn có thể thêm một số kết quả điểm chuẩn?
hư 101


14
@ naught101 Các điểm chuẩn sẽ cho thấy sự khác biệt về hiệu suất ở bất cứ đâu trong khoảng từ 3 đến 10 lần. Đây là C / C ++ cơ bản, tôi hoàn toàn bối rối khi làm thế nào điều này nhận được nhiều phiếu bầu ...
TC1

12
@ TC1: Tôi không nghĩ đó là cơ bản; có thể trung gian. Nhưng không có gì ngạc nhiên khi những thứ "cơ bản" có xu hướng hữu ích với nhiều người hơn, do đó nhiều người ủng hộ. Hơn nữa, đây là một câu hỏi khó cho google, ngay cả khi nó là "cơ bản".
LarsH

Câu trả lời:


595

Như những người khác đã nói, vấn đề là lưu trữ đến vị trí bộ nhớ trong mảng : x[i][j]. Đây là một chút hiểu biết tại sao:

Bạn có một mảng 2 chiều, nhưng bộ nhớ trong máy tính vốn dĩ là 1 chiều. Vì vậy, trong khi bạn tưởng tượng mảng của bạn như thế này:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

Máy tính của bạn lưu trữ nó trong bộ nhớ dưới dạng một dòng duy nhất:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

Trong ví dụ thứ 2, bạn truy cập vào mảng bằng cách lặp qua số thứ 2 trước, nghĩa là:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

Có nghĩa là bạn đang đánh tất cả theo thứ tự. Bây giờ hãy nhìn vào phiên bản đầu tiên. Bạn đang làm:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

Do cách C đặt ra mảng 2 chiều trong bộ nhớ, bạn đang yêu cầu nó nhảy khắp nơi. Nhưng bây giờ cho kicker: Tại sao điều này lại quan trọng? Tất cả các truy cập bộ nhớ đều giống nhau, phải không?

Không: vì bộ nhớ cache. Dữ liệu từ bộ nhớ của bạn được đưa đến CPU theo từng phần nhỏ (được gọi là 'dòng bộ đệm'), thường là 64 byte. Nếu bạn có số nguyên 4 byte, điều đó có nghĩa là bạn nhận được 16 số nguyên liên tiếp trong một gói nhỏ gọn. Nó thực sự khá chậm để lấy các khối bộ nhớ này; CPU của bạn có thể thực hiện rất nhiều công việc trong thời gian cần thiết để tải một dòng bộ đệm.

Bây giờ hãy nhìn lại thứ tự truy cập: Ví dụ thứ hai là (1) lấy một đoạn 16 ints, (2) sửa đổi tất cả chúng, (3) lặp lại 4000 * 4000/16 lần. Điều đó thật tuyệt vời và nhanh chóng, và CPU luôn có một cái gì đó để làm việc.

Ví dụ đầu tiên là (1) lấy một đoạn gồm 16 ints, (2) chỉ sửa đổi một trong số chúng, (3) lặp lại 4000 * 4000 lần. Điều đó sẽ đòi hỏi gấp 16 lần số lần "tìm nạp" từ bộ nhớ. CPU của bạn thực sự sẽ phải dành thời gian ngồi chờ bộ nhớ đó xuất hiện, và trong khi nó ngồi xung quanh bạn đang lãng phí thời gian quý báu.

Lưu ý quan trọng:

Bây giờ bạn đã có câu trả lời, đây là một lưu ý thú vị: không có lý do cố hữu nào mà ví dụ thứ hai của bạn phải là nhanh. Chẳng hạn, ở Fortran, ví dụ đầu tiên sẽ nhanh và ví dụ thứ hai chậm. Đó là bởi vì thay vì mở rộng mọi thứ thành các "hàng" khái niệm như C, Fortran mở rộng thành "các cột", tức là:

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

Bố cục của C được gọi là 'hàng chính' và Fortran được gọi là 'cột chính'. Như bạn có thể thấy, điều rất quan trọng là phải biết liệu ngôn ngữ lập trình của bạn là hàng chính hay chuyên ngành! Đây là một liên kết để biết thêm: http://en.wikipedia.org/wiki/Row-major_order


14
Đây là một câu trả lời khá kỹ lưỡng; đó là những gì tôi được dạy khi xử lý lỗi bộ nhớ cache và quản lý bộ nhớ.
Makoto

7
Bạn có các phiên bản "thứ nhất" và "thứ hai" sai cách; ví dụ đầu tiên thay đổi chỉ mục đầu tiên trong vòng lặp bên trong và sẽ là ví dụ thực thi chậm hơn.
phê

Câu trả lời chính xác. Nếu Mark muốn đọc thêm về gritty nitty như vậy, tôi sẽ giới thiệu một cuốn sách như Viết mã tuyệt vời.
wkl

8
Điểm thưởng cho việc chỉ ra rằng C đã thay đổi thứ tự hàng từ Fortran. Đối với tính toán khoa học, kích thước bộ đệm L2 là tất cả mọi thứ bởi vì nếu tất cả các mảng của bạn phù hợp với L2 thì việc tính toán có thể được hoàn thành mà không cần đến bộ nhớ chính.
Michael Storesin

4
@birryree: Những thứ mà mọi lập trình viên nên biết về bộ nhớ cũng có sẵn miễn phí .
phê

68

Không có gì để làm với lắp ráp. Điều này là do nhớ cache .

C mảng đa chiều được lưu trữ với kích thước cuối cùng là nhanh nhất. Vì vậy, phiên bản đầu tiên sẽ bỏ lỡ bộ đệm trong mỗi lần lặp, trong khi phiên bản thứ hai sẽ không. Vì vậy, phiên bản thứ hai nên nhanh hơn đáng kể.

Xem thêm: http://en.wikipedia.org/wiki/Loop_interchange .


23

Phiên bản 2 sẽ chạy nhanh hơn nhiều vì nó sử dụng bộ nhớ cache của máy tính của bạn tốt hơn phiên bản 1. Nếu bạn nghĩ về nó, các mảng chỉ là các vùng bộ nhớ liền kề nhau. Khi bạn yêu cầu một phần tử trong một mảng, hệ điều hành của bạn có thể sẽ đưa một trang bộ nhớ vào bộ đệm có chứa phần tử đó. Tuy nhiên, vì một vài thành phần tiếp theo cũng có trên trang đó (vì chúng liền kề nhau), nên lần truy cập tiếp theo sẽ nằm trong bộ đệm! Đây là những gì phiên bản 2 đang làm để tăng tốc.

Phiên bản 1, mặt khác, là truy cập cột yếu tố khôn ngoan, và không phải là hàng khôn ngoan. Loại truy cập này không liền kề ở cấp bộ nhớ, vì vậy chương trình không thể tận dụng bộ nhớ đệm của hệ điều hành nhiều như vậy.


Với các kích thước mảng này, có lẽ trình quản lý bộ đệm trong CPU chứ không phải trong HĐH chịu trách nhiệm ở đây.
krlmlr

12

Lý do là truy cập dữ liệu bộ nhớ cache cục bộ. Trong chương trình thứ hai, bạn quét tuyến tính thông qua bộ nhớ có lợi từ việc lưu trữ và tìm nạp trước. Mẫu sử dụng bộ nhớ của chương trình đầu tiên của bạn trải rộng hơn nhiều và do đó có hành vi bộ đệm tệ hơn.


11

Bên cạnh các câu trả lời tuyệt vời khác về lượt truy cập bộ đệm, cũng có một sự khác biệt tối ưu hóa có thể có. Vòng lặp thứ hai của bạn có khả năng được trình biên dịch tối ưu hóa thành một cái gì đó tương đương với:

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

Điều này ít có khả năng cho vòng lặp đầu tiên, bởi vì nó sẽ cần tăng con trỏ "p" với 4000 mỗi lần.

EDIT: p++ và thậm chí *p++ = ..có thể được biên dịch thành một lệnh CPU trong hầu hết các CPU. *p = ..; p += 4000không thể, vì vậy có ít lợi ích hơn trong việc tối ưu hóa nó. Điều đó cũng khó khăn hơn, bởi vì trình biên dịch cần biết và sử dụng kích thước của mảng bên trong. Và nó không xảy ra thường ở vòng lặp bên trong trong mã thông thường (nó chỉ xảy ra đối với các mảng đa chiều, trong đó chỉ số cuối cùng được giữ không đổi trong vòng lặp và bước thứ hai đến cuối cùng), vì vậy tối ưu hóa ít được ưu tiên hơn .


Tôi không nhận được 'vì nó sẽ cần phải nhảy con trỏ "p" với 4000 lần mỗi lần' nghĩa là gì.
Veedrac

@Veedrac Con trỏ sẽ cần được tăng lên với 4000 bên trong vòng lặp bên trong: p += 4000isop++
fishinear

Tại sao trình biên dịch sẽ thấy rằng một vấn đề? iđã được tăng lên bởi một giá trị không phải là đơn vị, với điều kiện đó là gia tăng con trỏ.
Veedrac

Tôi đã thêm lời giải thích
câu cá vào

Hãy thử gõ int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }vào gcc.godbolt.org . Cả hai dường như biên dịch về cơ bản giống nhau.
Veedrac

7

Dòng này là thủ phạm:

x[j][i]=i+j;

Phiên bản thứ hai sử dụng bộ nhớ liên tục, do đó sẽ nhanh hơn đáng kể.

Tôi đã thử với

x[50000][50000];

và thời gian thực hiện là 13 giây đối với phiên bản 1 so với 0,6 đối với phiên bản 2.


4

Tôi cố gắng đưa ra một câu trả lời chung chung.

Bởi vì i[y][x]là một tốc ký cho *(i + y*array_width + x)C (hãy thử các lớp int P[3]; 0[P] = 0xBEEF;).

Khi bạn lặp đi lặp lại y, bạn lặp đi lặp lại trên khối kích thước array_width * sizeof(array_element). Nếu bạn có điều đó trong vòng lặp bên trong của bạn, thì bạn sẽ có các array_width * array_heightlần lặp qua các khối đó.

Bằng cách lật thứ tự, bạn sẽ chỉ có array_heightcác lần lặp chunk, và giữa bất kỳ lần lặp nào, bạn sẽ chỉ có các array_widthlần lặp sizeof(array_element).

Mặc dù trên các CPU x86 thực sự cũ, điều này không quan trọng lắm, ngày nay, x86 thực hiện rất nhiều việc tìm nạp trước và lưu trữ dữ liệu. Bạn có thể tạo ra nhiều lỗi nhớ cache theo thứ tự lặp chậm hơn.

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.