Song song đọc ngẫu nhiên dường như hoạt động tốt - tại sao?


18

Hãy xem xét chương trình máy tính rất đơn giản sau đây:

for i = 1 to n:
    y[i] = x[p[i]]

Ở đây y là mảng n- byte của byte và p là mảng n -element của từ. Ở đây n là lớn, ví dụ, n = 2 31 (do đó chỉ một phần không đáng kể của dữ liệu phù hợp với bất kỳ loại bộ nhớ đệm nào).xynpnnn=231

Giả sử bao gồm các số ngẫu nhiên , phân bố đồng đều giữa và .1 np1n

Từ quan điểm của phần cứng hiện đại, điều này có nghĩa như sau:

  • đọc là rẻ (đọc tuần tự)p[i]
  • đọc rất tốn kém (đọc ngẫu nhiên; hầu như tất cả các lần đọc là lỗi bộ nhớ cache; chúng tôi sẽ phải tìm nạp từng byte riêng lẻ từ bộ nhớ chính)x[p[i]]
  • viết là rẻ (viết tuần tự).y[i]

Và đây thực sự là những gì tôi đang quan sát. Chương trình này rất chậm so với một chương trình chỉ đọc và ghi tuần tự. Tuyệt quá.

Bây giờ đến câu hỏi: chương trình này song song như thế nào trên các nền tảng đa lõi hiện đại?


Giả thuyết của tôi là chương trình này không song song tốt. Rốt cuộc, nút cổ chai là bộ nhớ chính. Một lõi đơn đã lãng phí phần lớn thời gian của nó chỉ chờ một số dữ liệu từ bộ nhớ chính.

Tuy nhiên, đây không phải là những gì tôi quan sát thấy khi tôi bắt đầu thử nghiệm một số thuật toán trong đó nút cổ chai là loại hoạt động này!

Tôi chỉ đơn giản thay thế vòng lặp for ngây thơ bằng vòng lặp song song OpenMP (về bản chất, nó sẽ chỉ phân chia phạm vi thành các phần nhỏ hơn và chạy song song các phần này trên các lõi CPU khác nhau).[1,n]

Trên các máy tính cấp thấp, tăng tốc thực sự là nhỏ. Nhưng trên các nền tảng cao cấp hơn, tôi đã ngạc nhiên rằng mình đang có được sự tăng tốc gần như tuyến tính tuyệt vời. Một số ví dụ cụ thể (thời gian chính xác có thể là một chút, có rất nhiều biến thể ngẫu nhiên; đây chỉ là những thử nghiệm nhanh):

  • 2 x Xe 4 lõi (trong tổng số 8 lõi): tăng tốc 5-8 lần so với phiên bản đơn luồng.

  • 2 x Xe 6 lõi (trong tổng số 12 lõi): tăng tốc hệ số 8-14 so với phiên bản đơn luồng.

Bây giờ điều này là hoàn toàn bất ngờ. Câu hỏi:

  1. Chính xác thì tại sao loại chương trình này song song tốt như vậy ? Điều gì xảy ra trong phần cứng? (Dự đoán hiện tại của tôi là một cái gì đó dọc theo các dòng này: số lần đọc ngẫu nhiên từ các luồng khác nhau là "pipelined" và tỷ lệ trung bình để có câu trả lời cho các câu hỏi này cao hơn nhiều so với trường hợp của một luồng.)

  2. nhất thiết phải sử dụng nhiều luồng và nhiều lõi để đạt được bất kỳ sự tăng tốc nào không? Nếu một loại đường ống thực sự diễn ra trong giao diện giữa bộ nhớ chính và CPU, thì không thể là một ứng dụng đơn luồng cho bộ nhớ chính biết rằng nó sẽ sớm cần , x [ p [ i + 1 ] ] , ... và máy tính có thể bắt đầu tìm nạp các dòng bộ đệm có liên quan từ bộ nhớ chính không? Nếu điều này là có thể về nguyên tắc, làm thế nào để tôi đạt được nó trong thực tế?x[p[i]]x[p[i+1]]

  3. Mô hình lý thuyết đúng mà chúng ta có thể sử dụng để phân tích loại chương trình này (và đưa ra dự đoán chính xác về hiệu suất) là gì?


Chỉnh sửa: Hiện tại có một số mã nguồn và kết quả điểm chuẩn có sẵn tại đây: https://github.com/suomela/abul-random-read

Một số ví dụ về số liệu sân bóng ( ):n=232

  • khoảng 42 ns mỗi lần lặp (đọc ngẫu nhiên) với một chuỗi
  • khoảng 5 ns mỗi lần lặp (đọc ngẫu nhiên) với 12 lõi.

Câu trả lời:


9

pnpnpp

Bây giờ, hãy tính đến các vấn đề bộ nhớ. Việc tăng tốc siêu tuyến tính mà bạn thực sự quan sát được trên nút dựa trên Xeon cao cấp của mình là hợp lý như sau.

nn/pp

n= =231

n

Cuối cùng, ngoài QSM (Bộ nhớ chia sẻ xếp hàng) , tôi không biết về bất kỳ mô hình song song lý thuyết nào khác có tính đến mức độ tranh chấp để truy cập vào bộ nhớ dùng chung (trong trường hợp của bạn, khi sử dụng OpenMP, bộ nhớ chính được chia sẻ giữa các lõi và bộ nhớ cache luôn được chia sẻ giữa các lõi). Dù sao, mặc dù mô hình là thú vị, nó đã không đạt được thành công lớn.


1
Nó cũng có thể giúp xem xét điều này vì mỗi lõi cung cấp một lượng song song mức độ bộ nhớ cố định ít nhiều, ví dụ, tải 10 x [] trong quá trình tại một thời điểm nhất định. Với 0,5% cơ hội trúng trong L3 được chia sẻ, một luồng sẽ có cơ hội 0,995 ** 10 (95 +%) để yêu cầu tất cả các tải đó chờ phản hồi bộ nhớ chính. Với 6 lõi cung cấp tổng cộng 60 x [] lượt đọc đang chờ xử lý, gần như có 26% khả năng ít nhất một lần đọc sẽ đạt được trong L3. Ngoài ra, MLP càng nhiều, bộ điều khiển bộ nhớ càng có thể lên lịch truy cập để tăng băng thông thực tế.
Paul A. Clayton

5

Tôi quyết định tự mình thử __builtin_prefetch (). Tôi sẽ đăng nó ở đây dưới dạng câu trả lời trong trường hợp người khác muốn kiểm tra nó trên máy của họ. Kết quả gần với những gì Jukka mô tả: Giảm khoảng 20% ​​thời gian chạy khi tìm nạp trước 20 phần tử so với tìm trước 0 phần tử phía trước.

Các kết quả:

prefetch =   0, time = 1.58000
prefetch =   1, time = 1.47000
prefetch =   2, time = 1.39000
prefetch =   3, time = 1.34000
prefetch =   4, time = 1.31000
prefetch =   5, time = 1.30000
prefetch =   6, time = 1.27000
prefetch =   7, time = 1.28000
prefetch =   8, time = 1.26000
prefetch =   9, time = 1.27000
prefetch =  10, time = 1.27000
prefetch =  11, time = 1.27000
prefetch =  12, time = 1.30000
prefetch =  13, time = 1.29000
prefetch =  14, time = 1.30000
prefetch =  15, time = 1.28000
prefetch =  16, time = 1.24000
prefetch =  17, time = 1.28000
prefetch =  18, time = 1.29000
prefetch =  19, time = 1.25000
prefetch =  20, time = 1.24000
prefetch =  19, time = 1.26000
prefetch =  18, time = 1.27000
prefetch =  17, time = 1.26000
prefetch =  16, time = 1.27000
prefetch =  15, time = 1.28000
prefetch =  14, time = 1.29000
prefetch =  13, time = 1.26000
prefetch =  12, time = 1.28000
prefetch =  11, time = 1.30000
prefetch =  10, time = 1.31000
prefetch =   9, time = 1.27000
prefetch =   8, time = 1.32000
prefetch =   7, time = 1.31000
prefetch =   6, time = 1.30000
prefetch =   5, time = 1.27000
prefetch =   4, time = 1.33000
prefetch =   3, time = 1.38000
prefetch =   2, time = 1.41000
prefetch =   1, time = 1.41000
prefetch =   0, time = 1.59000

Mã số:

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

void cracker(int *y, int *x, int *p, int n, int pf) {
    int i;
    int saved = pf;  /* let compiler optimize address computations */

    for (i = 0; i < n; i++) {
        __builtin_prefetch(&x[p[i+saved]]);
        y[i] += x[p[i]];
    }
}

int main(void) {
    int n = 50000000;
    int *x, *y, *p, i, pf, k;
    clock_t start, stop;
    double elapsed;

    /* set up arrays */
    x = malloc(sizeof(int)*n);
    y = malloc(sizeof(int)*n);
    p = malloc(sizeof(int)*n);
    for (i = 0; i < n; i++)
        p[i] = rand()%n;

    /* warm-up exercise */
    cracker(y, x, p, n, pf);

    k = 20;
    for (pf = 0; pf < k; pf++) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }
    for (pf = k; pf >= 0; pf--) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }

    return 0;
}

4
  1. Truy cập DDR3 thực sự là đường ống. http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protatiox2.pdf slide 20 và 24 cho thấy những gì xảy ra trong bus bộ nhớ trong các hoạt động đọc đường ống.

  2. (sai một phần, xem bên dưới) Không cần nhiều luồng nếu kiến ​​trúc CPU hỗ trợ tìm nạp trước bộ đệm. Modern x86 và ARM cũng như nhiều kiến ​​trúc khác có một hướng dẫn tìm nạp rõ ràng. Ngoài ra, nhiều nỗ lực để phát hiện các mẫu trong truy cập bộ nhớ và thực hiện tìm nạp trước tự động. Phần mềm hỗ trợ dành riêng cho trình biên dịch, ví dụ GCC và Clang có nội tại __builtin_prefech () để tìm nạp trước rõ ràng.

Siêu phân luồng kiểu Intel dường như hoạt động rất tốt đối với các chương trình dành phần lớn thời gian chờ đợi bộ nhớ cache. Theo kinh nghiệm của tôi, trong khối lượng công việc chuyên sâu tính toán, tốc độ tăng rất ít so với số lượng lõi vật lý.

EDIT: Tôi đã sai ở điểm 2. Có vẻ như trong khi tìm nạp trước có thể tối ưu hóa truy cập bộ nhớ cho lõi đơn, băng thông bộ nhớ kết hợp của nhiều lõi lớn hơn băng thông của lõi đơn. Lớn hơn bao nhiêu, tùy thuộc vào CPU.

Trình tải trước phần cứng và các tối ưu hóa khác cùng nhau làm cho việc đo điểm chuẩn trở nên rất khó khăn. Có thể xây dựng các trường hợp trong đó tìm nạp trước rõ ràng có ảnh hưởng rất rõ ràng hoặc không tồn tại đến hiệu suất, điểm chuẩn này là một trong những trường hợp sau.


__builtin_prefech nghe có vẻ rất hứa hẹn. Thật không may, trong các thử nghiệm nhanh của tôi, nó dường như không giúp ích gì cho hiệu suất của một luồng đơn (<10%). Làm thế nào cải thiện tốc độ lớn tôi nên mong đợi trong loại ứng dụng này?
Jukka Suomela

Tôi mong đợi nhiều hơn. Vì tôi biết rằng prefetch có tác dụng đáng kể trong DSP và trò chơi, tôi đã phải tự thử nghiệm. Hóa ra lỗ thỏ đi sâu hơn ...
Juhani Simola 11/12/13

Nỗ lực đầu tiên của tôi là tạo ra một thứ tự ngẫu nhiên cố định được lưu trữ trong một mảng, sau đó lặp lại theo thứ tự đó có và không có prefetch ( gist.github.com/osimola/7917602 ). Điều đó mang lại sự khác biệt khoảng 2% trên Core i5. Âm thanh giống như prefetch hoàn toàn không hoạt động hoặc bộ dự đoán phần cứng hiểu được cảm ứng.
Juhani Simola

1
Vì vậy, kiểm tra cho điều đó, lần thử thứ hai ( gist.github.com/osimola/7917568 ) truy cập vào bộ nhớ theo trình tự được tạo bởi một hạt giống ngẫu nhiên cố định. Lần này, phiên bản tìm nạp trước nhanh gấp khoảng 2 lần so với không tìm nạp trước và nhanh hơn 3 lần so với tìm nạp trước 1 bước. Lưu ý rằng phiên bản tìm nạp trước sẽ tính toán nhiều hơn cho mỗi lần truy cập bộ nhớ so với phiên bản không tìm nạp trước.
Juhani Simola

Điều này dường như là phụ thuộc máy. Tôi đã thử mã của Pat Morin bên dưới (không thể nhận xét về bài đăng đó vì tôi không có tiếng tăm) và kết quả của tôi nằm trong khoảng 1,3% cho các giá trị tìm nạp khác nhau.
Juhani Simola
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.