การอ่านแบบสุ่มที่ขนานกันดูเหมือนว่าจะทำงานได้ดี - เพราะอะไร


18

พิจารณาโปรแกรมคอมพิวเตอร์ที่ง่ายมากต่อไปนี้:

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

นี่คือและคืออาร์เรย์ -element ของไบต์และคืออาร์เรย์ของคำศัพท์ -element ที่นี่มีขนาดใหญ่เช่น (เพื่อให้มีเพียงเศษเสี้ยวเล็กน้อยของข้อมูลที่พอดีกับหน่วยความจำแคชทุกประเภท)xYnพีnnn=2วันที่ 31

สมมติว่าประกอบด้วยตัวเลขสุ่มกระจายสม่ำเสมอระหว่างและnพี1n

จากมุมมองของฮาร์ดแวร์สมัยใหม่สิ่งนี้ควรหมายถึงสิ่งต่อไปนี้:

  • การอ่านราคาถูก (การอ่านตามลำดับ)พี[ผม]
  • การอ่านมีราคาแพงมาก (การอ่านแบบสุ่ม; การอ่านเกือบทั้งหมดเป็นแคชที่หายไป; เราจะต้องดึงข้อมูลแต่ละไบต์จากหน่วยความจำหลัก)x[พี[ผม]]
  • การเขียนนั้นถูก (การเขียนตามลำดับ)Y[ผม]

และนี่คือสิ่งที่ฉันสังเกต โปรแกรมช้ามากเมื่อเปรียบเทียบกับโปรแกรมที่อ่านและเขียนตามลำดับเท่านั้น ยิ่งใหญ่

มาถึงคำถาม: โปรแกรมนี้ขนานกับแพลตฟอร์มมัลติคอร์ที่ทันสมัยได้อย่างไร


สมมติฐานของฉันคือว่าโปรแกรมนี้ไม่ขนานกัน ท้ายที่สุดคอขวดคือหน่วยความจำหลัก แกนเดียวเสียเวลาไปแล้วส่วนใหญ่รอข้อมูลจากหน่วยความจำหลัก

อย่างไรก็ตามนี่ไม่ใช่สิ่งที่ฉันสังเกตเห็นเมื่อฉันเริ่มทดลองกับอัลกอริธึมที่คอขวดทำงานแบบนี้!

ฉันเพียงแค่แทนที่ไร้เดียงสา for-loop ด้วย OpenMP parallel for-loop (โดยพื้นฐานแล้วมันจะแบ่งช่วงเป็นส่วนที่เล็กกว่าและเรียกใช้ส่วนเหล่านี้ในแกน CPU ที่แตกต่างกันในแบบคู่ขนาน)[1,n]

สำหรับคอมพิวเตอร์ที่มีความเร็วต่ำการเพิ่มความเร็วนั้นเล็กน้อย แต่สำหรับแพลตฟอร์มระดับบนฉันรู้สึกประหลาดใจที่ฉันได้รับการเพิ่มความเร็วใกล้เชิงเส้นที่ยอดเยี่ยม ตัวอย่างที่เป็นรูปธรรมบางอย่าง (การกำหนดเวลาที่แน่นอนอาจจะค่อนข้างปิดมีการเปลี่ยนแปลงแบบสุ่มจำนวนมากซึ่งเป็นเพียงการทดลองที่รวดเร็ว)

  • 2 x 4-core Xeon (รวม 8 แกน): ตัวคูณ 5-8 speedups เมื่อเปรียบเทียบกับเวอร์ชั่นแบบเธรดเดี่ยว

  • 2 x 6-core Xeon (รวม 12 คอร์): ปัจจัย 8-14 speedups เมื่อเปรียบเทียบกับเวอร์ชั่นเธรดเดี่ยว

ตอนนี้มันคาดไม่ถึงเลย คำถาม:

  1. ได้อย่างแม่นยำทำไมชนิดของ parallelise โปรแกรมนี้ให้ดี ? เกิดอะไรขึ้นกับฮาร์ดแวร์ (การคาดเดาปัจจุบันของฉันคือบางสิ่งตามบรรทัดเหล่านี้: การอ่านแบบสุ่มจากเธรดที่แตกต่างกันคือ "pipelined" และอัตราเฉลี่ยของการตอบคำถามเหล่านี้สูงกว่าในกรณีของเธรดเดี่ยวมาก)

  2. มันเป็นความจำเป็นต้องใช้หลายกระทู้และหลายแกนที่จะได้รับ speedups ใด ๆ หากการ pipelining บางอย่างเกิดขึ้นจริงในอินเตอร์เฟสระหว่างหน่วยความจำหลักและซีพียูแอปพลิเคชันแบบเธรดเดียวไม่สามารถทำให้หน่วยความจำหลักทราบว่าอีกไม่นานมันจะต้องมี , , ... และคอมพิวเตอร์สามารถเริ่มดึงข้อมูลแคชที่เกี่ยวข้องจากหน่วยความจำหลักได้หรือไม่ หากเป็นไปได้ในหลักการฉันจะทำอย่างไรให้สำเร็จในทางปฏิบัติx[พี[ผม]]x[พี[ผม+1]]

  3. รูปแบบทางทฤษฎีที่ถูกต้องคืออะไรที่เราสามารถใช้วิเคราะห์โปรแกรมประเภทนี้ (และทำการคาดการณ์ประสิทธิภาพที่ถูกต้อง )


แก้ไข:ขณะนี้มีซอร์สโค้ดและผลลัพธ์การวัดประสิทธิภาพที่นี่: https://github.com/suomela/parallel-random-read

ตัวอย่างของตัวเลข ballpark ( ):n=232

  • ประมาณ 42 ns ต่อการวนซ้ำ (การอ่านแบบสุ่ม) ด้วยเธรดเดี่ยว
  • ประมาณ 5 ns ต่อการทำซ้ำ (สุ่มอ่าน) ที่มี 12 คอร์

คำตอบ:


9

ลืมปัญหาทั้งหมดที่เกี่ยวข้องกับการเข้าถึงหน่วยความจำหลักและแคชระดับ 3 จากมุมมองแบบขนานโดยไม่สนใจปัญหาเหล่านี้โปรแกรมจะขนานกันอย่างสมบูรณ์แบบเมื่อใช้ตัวประมวลผล (หรือแกนประมวลผล) เนื่องจากเมื่อคุณแบ่งพาร์ติชันงานที่จะทำผ่านการย่อยสลายโดเมนแต่ละคอร์จะต้องประมวลผลหรือองค์ประกอบและไม่มีการสื่อสารและ / หรือการประสานค่าใช้จ่ายเพราะไม่มีการพึ่งพาการทำงานระหว่าง โปรเซสเซอร์ ดังนั้นไม่สนใจปัญหาหน่วยความจำที่คุณคาดหวังเพิ่มความเร็วเท่ากับPพีnพีnพีพี

ตอนนี้เรามาพิจารณาปัญหาความทรงจำ การเพิ่มความเร็วแบบซุปเปอร์ลิเนียร์ที่คุณสังเกตเห็นบนโหนด Xeon ระดับไฮเอนด์ของคุณนั้นเป็นธรรมดังต่อไปนี้

ระบบแบบขนานอาจแสดงพฤติกรรมดังกล่าวหากหน่วยความจำของมันเป็นลำดับชั้นและหากเวลาในการเข้าถึงเพิ่มขึ้น (ในขั้นตอนไม่ต่อเนื่อง) กับหน่วยความจำที่ใช้โดยโปรแกรม ในกรณีนี้ความเร็วในการคำนวณที่มีประสิทธิภาพอาจช้ากว่าตัวประมวลผลแบบอนุกรมมากกว่าบนคอมพิวเตอร์แบบขนานโดยใช้ตัวประมวลผลที่คล้ายกัน เนื่องจากอัลกอริทึมแบบซีเควนเชียลที่ใช้หน่วยความจำไบต์จะใช้เพียงไบต์ในแต่ละโปรเซสเซอร์ของระบบประมวลผลแบบตัวประมวลผลแบบขนานในขณะที่แคชและผลกระทบหน่วยความจำเสมือนอาจลดอัตราการคำนวณที่มีประสิทธิภาพของโปรเซสเซอร์แทนnn/พีพี

สำหรับไบต์เราต้องการหน่วยความจำ 2048 Mbytes แต่เมื่อใช้ 12 คอร์ในตัวอย่างสุดท้ายของคุณทุกคอร์ต้องจัดการกับข้อมูลเพียง 2048/12 เมกกะไบท์ซึ่งประมาณ 170 เมกกะไบท์ โปรเซสเซอร์ Xeon ระดับสูงติดตั้งแคชระดับ 3 ซึ่งมีขนาดตั้งแต่ 15 ถึง 30 Mbytes ขนาด เห็นได้ชัดว่าด้วยขนาดแคชขนาดใหญ่นี้อัตราส่วนการเข้าชมแคชจะสูงและสิ่งนี้อธิบายได้ดีหรือแม้กระทั่งความเร็วเชิงเส้นที่สังเกตได้ดีn=2วันที่ 31

เกี่ยวกับคำถามที่สองของคุณสถาปัตยกรรมปัจจุบันได้ดึงข้อมูลล่วงหน้าแล้วโดย eviting บรรทัดแคชและแทนที่พวกเขาตามที่จำเป็นในการใช้ประโยชน์จากตำแหน่งของข้อมูลชั่วคราวและอวกาศ แต่สิ่งนี้จะไม่เพียงพอสำหรับการประมวลผลแกนประมวลผลเดียวที่ 2048 เมกกะไบท์ หากคุณ จำกัดถึงประมาณ 170 เมกกะไบท์คุณควรเห็นการแสดงเดียวกันบนแกนเดียวมากขึ้นหรือน้อยลงเนื่องจากตอนนี้คุณกำลังทำงานภายใต้เงื่อนไขเดียวกันn

ในที่สุดนอกเหนือจากQSM (Queuing Shared Memory)ฉันไม่ได้ตระหนักถึงรูปแบบทางทฤษฎีแบบขนานอื่น ๆ ที่คำนึงถึงในระดับเดียวกับการต่อสู้เพื่อเข้าถึงหน่วยความจำที่ใช้ร่วมกัน (ในกรณีของคุณเมื่อใช้ OpenMP หน่วยความจำหลักจะใช้ร่วมกันระหว่างแกน และแคชจะถูกแชร์อยู่เสมอรวมทั้งในคอร์) อย่างไรก็ตามถึงแม้ว่าตัวแบบจะน่าสนใจ แต่ก็ไม่ประสบความสำเร็จ


1
นอกจากนี้ยังอาจช่วยให้ดูที่แต่ละคอร์ที่ให้จำนวนหน่วยความจำขนานระดับคงที่มากขึ้นหรือน้อยลงเช่น 10 x [] โหลดในกระบวนการในเวลาที่กำหนด ด้วยโอกาส 0.5% ที่การโจมตีใน L3 ที่ใช้ร่วมกันเธรดเดียวจะมีโอกาส 0.995 ** 10 (95 +%) ที่ต้องการโหลดทั้งหมดเหล่านั้นเพื่อรอการตอบสนองหน่วยความจำหลัก ด้วย 6 คอร์ที่ให้การอ่านที่ค้างอยู่ทั้งหมด 60 x [] มีโอกาสเกือบ 26% ที่การอ่านอย่างน้อยหนึ่งครั้งจะเข้าสู่ L3 ยิ่งไปกว่านั้น MLP ยิ่งควบคุมหน่วยความจำได้มากขึ้นสามารถกำหนดเวลาการเข้าถึงเพื่อเพิ่มแบนด์วิดท์ที่เกิดขึ้นจริง
Paul A. Clayton

5

ฉันตัดสินใจลอง __builtin_prefetch () ด้วยตัวเอง ฉันโพสต์ไว้ที่นี่เป็นคำตอบในกรณีที่คนอื่นต้องการทดสอบบนเครื่องของพวกเขา ผลลัพธ์ใกล้เคียงกับสิ่งที่ Jukka อธิบาย: ลดลง 20% ในเวลาทำงานเมื่อดึงข้อมูลองค์ประกอบล่วงหน้า 20 รายการเปรียบเทียบกับดึงองค์ประกอบ 0 ล่วงหน้า

ผล:

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

รหัส:

#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. การเข้าถึง DDR3 เป็นไปตามจริง http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdfสไลด์ 20 และ 24 แสดงให้เห็นว่าเกิดอะไรขึ้นในเมมโมรี่บัสระหว่างการดำเนินการอ่านแบบ pipelined

  2. (ไม่ถูกต้องบางส่วนดูด้านล่าง) ไม่จำเป็นต้องใช้หลายเธรดหากสถาปัตยกรรม CPU รองรับการดึงข้อมูลล่วงหน้าของแคช โมเดิร์น x86 และ ARM รวมถึงสถาปัตยกรรมอื่น ๆ อีกมากมายมีคำสั่ง prefetch ที่ชัดเจน หลายคนพยายามที่จะตรวจสอบรูปแบบในการเข้าถึงหน่วยความจำและทำการดึงข้อมูลล่วงหน้าโดยอัตโนมัติ การสนับสนุนซอฟต์แวร์เฉพาะคอมไพเลอร์เช่น GCC และ Clang มี __builtin_prefech () ที่แท้จริงสำหรับการดึงข้อมูลล่วงหน้าอย่างชัดเจน

การทำไฮเปอร์เธรดในสไตล์ Intel ดูเหมือนจะทำงานได้ดีมากสำหรับโปรแกรมที่ใช้เวลาส่วนใหญ่ในการรอแคช จากประสบการณ์ของฉันในการคำนวณปริมาณงานที่เพิ่มขึ้นการเพิ่มความเร็วจะสูงกว่าจำนวนแกนประมวลผลเล็กน้อย

แก้ไข: ฉันผิดในจุดที่ 2 ดูเหมือนว่าในขณะที่การดึงข้อมูลล่วงหน้าสามารถเพิ่มประสิทธิภาพการเข้าถึงหน่วยความจำสำหรับคอร์เดียวแบนด์วิดธ์หน่วยความจำรวมของหลายคอร์นั้นมากกว่าแบนด์วิธของแกนเดี่ยว ยิ่งใหญ่กว่านั้นขึ้นอยู่กับซีพียู

prefetcher ฮาร์ดแวร์และการเพิ่มประสิทธิภาพอื่น ๆ ร่วมกันทำให้การเปรียบเทียบเป็นเรื่องที่ยุ่งยากมาก มีความเป็นไปได้ที่จะสร้างกรณีที่การดึงข้อมูลล่วงหน้าอย่างชัดเจนมีผลกระทบต่อประสิทธิภาพอย่างชัดเจนหรือไม่มีอยู่จริงเกณฑ์มาตรฐานนี้เป็นหนึ่งในสิ่งหลัง


__builtin_prefech ฟังดูดีมาก น่าเสียดายที่ในการทดสอบอย่างรวดเร็วของฉันดูเหมือนจะไม่ช่วยเรื่องประสิทธิภาพของเธรดเดี่ยวมากนัก (<10%) ฉันควรคาดหวังการปรับปรุงความเร็วขนาดใหญ่ในแอปพลิเคชันประเภทนี้อย่างไร
Jukka Suomela

ฉันคาดหวังมากกว่านี้ เนื่องจากฉันรู้ว่า prefetch มีผลกระทบอย่างมีนัยสำคัญใน DSP และเกมฉันจึงต้องทดสอบตัวเอง กลายเป็นช่องกระต่ายลงไปลึกกว่า ...
Juhani Simola

ความพยายามครั้งแรกของฉันคือการสร้างคำสั่งสุ่มคงที่ที่เก็บไว้ในอาร์เรย์จากนั้นวนซ้ำตามลำดับที่มีและไม่มีการดึงข้อมูลล่วงหน้า ( gist.github.com/osimola/7917602 ) นั่นทำให้ความแตกต่างประมาณ 2% บน Core i5 เสียงเหมือน prefetch ไม่ทำงานเลยหรือตัวคาดการณ์ฮาร์ดแวร์เข้าใจทางอ้อม
Juhani Simola

1
ดังนั้นการทดสอบความพยายามครั้งที่สอง ( gist.github.com/osimola/7917568 ) เข้าถึงหน่วยความจำตามลำดับที่สร้างโดยเมล็ดสุ่มแบบคงที่ ในครั้งนี้รุ่นการดึงข้อมูลล่วงหน้าจะเร็วกว่าการดึงข้อมูลล่วงหน้าไม่ถึง 2 เท่าและเร็วกว่าการดึงข้อมูลล่วงหน้าอีก 3 เท่า โปรดทราบว่ารุ่นการดึงข้อมูลล่วงหน้าจะทำการคำนวณต่อการเข้าถึงหน่วยความจำมากกว่ารุ่นที่ไม่ดึงข้อมูลล่วงหน้า
Juhani Simola

ดูเหมือนว่าจะขึ้นอยู่กับเครื่อง ฉันลองใช้รหัส Pat Morin ด้านล่าง (ไม่สามารถแสดงความคิดเห็นกับโพสต์นั้นเนื่องจากฉันไม่มีชื่อเสียง) และผลลัพธ์ของฉันอยู่ที่ 1.3% สำหรับค่าการดึงข้อมูลที่แตกต่างกัน
Juhani Simola
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.