2011-09-13 32 views
15

Tôi cho hai chức năng cho việc tìm kiếm các sản phẩm của hai ma trận:Tại sao thứ tự của các vòng trong thuật toán nhân ma trận ảnh hưởng đến hiệu năng?

void MultiplyMatrices_1(int **a, int **b, int **c, int n){ 
     for (int i = 0; i < n; i++) 
      for (int j = 0; j < n; j++) 
       for (int k = 0; k < n; k++) 
        c[i][j] = c[i][j] + a[i][k]*b[k][j]; 
    } 

void MultiplyMatrices_2(int **a, int **b, int **c, int n){ 
     for (int i = 0; i < n; i++) 
      for (int k = 0; k < n; k++) 
       for (int j = 0; j < n; j++) 
        c[i][j] = c[i][j] + a[i][k]*b[k][j]; 
} 

Tôi chạy và lưu hồ sơ hai thực thi sử dụng gprof, mỗi với mã giống hệt nhau ngoại trừ chức năng này. Thứ hai là nhanh hơn (khoảng 5 lần) nhanh hơn đối với ma trận có kích thước 2048 x 2048. Bất kỳ ý tưởng nào về lý do tại sao?

Trả lời

30

Tôi tin rằng những gì bạn đang xem là ảnh hưởng của locality of reference trong hệ thống phân cấp bộ nhớ của máy tính.

Thông thường, bộ nhớ máy tính được tách biệt thành các loại khác nhau mà có những đặc điểm hiệu suất khác nhau (điều này thường được gọi là memory hierarchy). Bộ nhớ nhanh nhất nằm trong thanh ghi của bộ xử lý, có thể được truy cập và đọc trong một chu kỳ đồng hồ đơn. Tuy nhiên, thường chỉ có một số ít các thanh ghi này (thường không quá 1KB). Bộ nhớ chính của máy tính, mặt khác, là rất lớn (nói, 8GB), nhưng là chậm hơn nhiều để truy cập. Để cải thiện hiệu suất, máy tính thường được xây dựng vật lý để có several levels of caches ở giữa bộ xử lý và bộ nhớ chính. Các cache này chậm hơn so với các thanh ghi nhưng nhanh hơn nhiều so với bộ nhớ chính, vì vậy nếu bạn truy cập bộ nhớ có vẻ gì đó trong bộ nhớ cache, nó sẽ nhanh hơn rất nhiều nếu bạn phải vào bộ nhớ chính (thông thường, từ 5-25x nhanh hơn). Khi truy cập bộ nhớ, trước tiên bộ xử lý sẽ kiểm tra bộ nhớ cache cho giá trị đó trước khi quay lại bộ nhớ chính để đọc giá trị. Nếu bạn truy cập vào các giá trị trong bộ nhớ cache, bạn sẽ có hiệu suất tốt hơn nhiều nếu bạn bỏ qua bộ nhớ, truy cập ngẫu nhiên các giá trị.

Hầu hết các chương trình được viết theo cách mà nếu một byte trong bộ nhớ được đọc vào bộ nhớ, chương trình sẽ đọc nhiều giá trị khác nhau xung quanh vùng bộ nhớ đó. Do đó, các cache này thường được thiết kế để khi bạn đọc một giá trị duy nhất từ ​​bộ nhớ, một khối bộ nhớ (thường ở đâu đó giữa 1KB và 1MB) các giá trị xung quanh giá trị đó cũng được đưa vào bộ đệm. Bằng cách đó, nếu chương trình của bạn đọc các giá trị gần đó, chúng đã có trong bộ nhớ cache và bạn không cần phải đi đến bộ nhớ chính.

Bây giờ, một chi tiết cuối cùng - trong C/C++, mảng được lưu trữ theo thứ tự hàng lớn, có nghĩa là tất cả các giá trị trong một hàng của ma trận được lưu trữ cạnh nhau. Vì vậy, trong bộ nhớ mảng trông giống như hàng đầu tiên, sau đó hàng thứ hai, sau đó hàng thứ ba, vv

Cho điều này, chúng ta hãy nhìn vào mã của bạn. Phiên bản đầu tiên trông giống như sau:

for (int i = 0; i < n; i++) 
     for (int j = 0; j < n; j++) 
      for (int k = 0; k < n; k++) 
       c[i][j] = c[i][j] + a[i][k]*b[k][j]; 

Bây giờ, hãy nhìn vào dòng mã bên trong nhất. Trên mỗi lần lặp lại, giá trị của k đang thay đổi ngày càng tăng. Điều này có nghĩa là khi chạy vòng lặp trong cùng, mỗi lần lặp của vòng lặp có thể bị thiếu bộ nhớ cache khi tải giá trị của b[k][j].Lý do là vì ma trận được lưu trữ theo thứ tự hàng lớn, mỗi khi bạn tăng k, bạn sẽ bỏ qua toàn bộ hàng của ma trận và nhảy xa hơn vào bộ nhớ, có thể vượt quá các giá trị bạn đã lưu trong bộ nhớ cache . Tuy nhiên, bạn không bỏ lỡ khi tra cứu c[i][j] (kể từ ij giống nhau), cũng như bạn sẽ không bỏ lỡ a[i][k], bởi vì các giá trị theo thứ tự hàng lớn và nếu giá trị a[i][k] được lưu trong bộ nhớ cache trước đó lặp lại, giá trị của a[i][k] đọc trên lần lặp này là từ một vị trí bộ nhớ liền kề. Do đó, trên mỗi lần lặp của vòng lặp trong cùng, bạn có thể có một bộ nhớ cache bỏ lỡ.

Nhưng xem xét phiên bản thứ hai này:

for (int i = 0; i < n; i++) 
     for (int k = 0; k < n; k++) 
      for (int j = 0; j < n; j++) 
       c[i][j] = c[i][j] + a[i][k]*b[k][j]; 

Bây giờ, kể từ khi bạn đang tăng j trên mỗi lần lặp, chúng ta hãy suy nghĩ về bao nhiêu bộ nhớ cache nhớ bạn có thể sẽ có trên báo cáo kết quả trong cùng. Bởi vì các giá trị nằm trong thứ tự hàng lớn, giá trị của c[i][j] có thể nằm trong bộ nhớ cache, bởi vì giá trị của c[i][j] từ lần lặp trước đó có khả năng được lưu vào bộ nhớ cache và sẵn sàng để đọc. Tương tự, b[k][j] có thể được lưu trong bộ nhớ cache và vì ik không thay đổi, rất có thể là a[i][k] cũng được lưu trong bộ nhớ cache. Điều này có nghĩa rằng trên mỗi lần lặp của vòng lặp bên trong, bạn có thể không có bộ nhớ cache bị bỏ sót.

Nói chung, điều này có nghĩa là phiên bản thứ hai của mã không có khả năng bị mất bộ nhớ cache trên mỗi lần lặp của vòng lặp, trong khi phiên bản đầu tiên gần như chắc chắn sẽ xảy ra. Do đó, vòng lặp thứ hai có thể nhanh hơn lần đầu tiên, như bạn đã thấy.

Điều thú vị là nhiều trình biên dịch bắt đầu có hỗ trợ mẫu để phát hiện phiên bản thứ hai của mã nhanh hơn phiên bản đầu tiên. Một số sẽ cố gắng tự động viết lại mã để tối đa hóa tính song song. Nếu bạn có một bản sao của Purple Dragon Book, Chương 11 sẽ thảo luận cách thức các trình biên dịch này hoạt động.

Ngoài ra, bạn có thể tối ưu hóa hiệu suất của vòng lặp này hơn nữa bằng cách sử dụng các vòng phức tạp hơn. Ví dụ, một kỹ thuật được gọi là blocking có thể được sử dụng để tăng hiệu suất bằng cách tách mảng thành các phần con có thể được lưu trong bộ nhớ cache lâu hơn, sau đó sử dụng nhiều thao tác trên các khối này để tính toán kết quả tổng thể.

Hy vọng điều này sẽ hữu ích!

+1

+1 Giải thích tuyệt vời thực sự! Ngoài ra gợi ý @Kerrek SB thực hiện về việc gỡ lỗi bộ nhớ cache thêm nhiều chi tiết kỹ thuật hơn. – rbaleksandar

0

Có lẽ thứ hai phải bỏ qua trong bộ nhớ nhiều hơn để truy cập vào các phần tử mảng. Nó có thể là một cái gì đó khác, quá - bạn có thể kiểm tra mã biên dịch để xem những gì đang thực sự xảy ra.

5

Đây cũng có thể là địa phương bộ nhớ. Khi bạn sắp xếp lại vòng lặp, bộ nhớ cần thiết trong vòng lặp bên trong gần nhất và có thể được lưu trong bộ nhớ cache, trong khi trong phiên bản không hiệu quả bạn cần truy cập bộ nhớ từ toàn bộ tập dữ liệu.

Cách kiểm tra giả thuyết này là chạy trình gỡ rối bộ nhớ cache (như cachegrind) trên hai đoạn mã và xem có bao nhiêu lỗi bộ nhớ cache mà chúng phải chịu.

+0

+1 để đề cập đến cachegrind –

0

Ngoài vị trí bộ nhớ còn có tối ưu hóa trình biên dịch. Một chìa khóa cho hoạt động vector và ma trận là bỏ vòng lặp.

for (int k = 0; k < n; k++) 
    c[i][j] = c[i][j] + a[i][k]*b[k][j]; 

Bạn có thể thấy trong vòng lặp bên trong này ij không thay đổi. Điều này có nghĩa nó có thể được viết lại như

for (int k = 0; k < n; k+=4) { 
    int * aik = &a[i][k]; 
    c[i][j] += 
     + aik[0]*b[k][j] 
     + aik[1]*b[k+1][j] 
     + aik[2]*b[k+2][j] 
     + aik[3]*b[k+3][j]; 
} 

Bạn có thể thấy sẽ có

  • gấp bốn lần vòng ít hơn và truy cập vào c [i] [j]
  • a [i] [k] đang được truy cập liên tục trong bộ nhớ
  • truy cập bộ nhớ và nhân có thể được pipelined (gần như đồng thời) trong CPU.

Điều gì xảy ra nếu n không phải là bội số của 4 hoặc 6 hoặc 8? (hoặc bất kỳ trình biên dịch nào quyết định hủy bỏ nó) Trình biên dịch xử lý gọn gàng này cho bạn.;)

Để tăng tốc giải pháp này nhanh hơn, bạn có thể thử chuyển đổi ma trận b trước tiên. Đây là một ít công việc và mã hóa, nhưng nó có nghĩa là truy cập vào b-transposed cũng liên tục trong bộ nhớ. (Khi bạn đang hoán đổi [k] với [j])

Một việc khác bạn có thể làm để cải thiện hiệu suất là nhân nhiều phép nhân. Điều này có thể cải thiện hiệu suất theo hệ số 3 trên CPU 4 lõi.

Cuối cùng bạn có thể xem xét sử dụng float hoặc double Bạn có thể nghĩ int sẽ nhanh hơn, tuy nhiên đó không phải là luôn luôn như vậy là hoạt động điểm nổi có thể được tối ưu hóa nhiều hơn (cả về phần cứng và trình biên dịch)

Thứ hai Ví dụ có c [i] [j] đang thay đổi trên mỗi lần lặp mà làm cho khó tối ưu hóa hơn.

Các vấn đề liên quan