เหตุใดจึงมีผลกระทบอย่างมากเมื่อวนลูปมากกว่าอาร์เรย์ที่มี 240 องค์ประกอบขึ้นไป


230

เมื่อเรียกใช้ลูปผลรวมเหนืออาร์เรย์ใน Rust ฉันสังเกตเห็นว่าประสิทธิภาพลดลงอย่างมากเมื่อCAPACITY> = 240. CAPACITY= 239 เร็วกว่าประมาณ 80 เท่า

มีการเพิ่มประสิทธิภาพการรวบรวมพิเศษสนิมกำลังทำสำหรับอาร์เรย์ "สั้น"?

rustc -C opt-level=3รวบรวมกับ

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}


4
อาจมี 240 คุณล้นสายแคช CPU? หากเป็นกรณีนี้ผลลัพธ์ของคุณจะเฉพาะเจาะจงกับ CPU มาก
rodrigo

11
ทำซ้ำที่นี่ ตอนนี้ฉันเดาแล้วว่ามันมีบางอย่างเกี่ยวข้องกับการเปิดลูป
rodrigo

คำตอบ:


355

ข้อมูลสรุป : ต่ำกว่า 240, LLVM คลายการวนรอบด้านในอย่างเต็มที่และช่วยให้สังเกตได้ว่าสามารถเพิ่มประสิทธิภาพวนซ้ำซ้ำเพื่อทำลายมาตรฐานของคุณ



คุณพบเกณฑ์มายากลข้างต้นซึ่ง LLVM หยุดการดำเนินการเพิ่มประสิทธิภาพบางอย่าง ขีด จำกัด คือ 8 ไบต์ * 240 = 1920 ไบต์ (อาเรย์ของคุณเป็นอาร์เรย์ของusizes ดังนั้นความยาวจะถูกคูณด้วย 8 ไบต์โดยถือว่า x86-64 CPU) ในการวัดประสิทธิภาพนี้การเพิ่มประสิทธิภาพเฉพาะอย่างเดียวซึ่งดำเนินการเฉพาะสำหรับความยาว 239 เท่านั้นจะเป็นตัวกำหนดความแตกต่างของความเร็วอย่างมาก แต่เริ่มช้า:

(รหัสทั้งหมดในคำตอบนี้ถูกรวบรวมด้วย-C opt-level=3)

pub fn foo() -> usize {
    let arr = [0; 240];
    let mut s = 0;
    for i in 0..arr.len() {
        s += arr[i];
    }
    s
}

รหัสง่าย ๆ นี้จะสร้างคร่าวๆที่แอสเซมบลีที่คาดไว้: ห่วงเพิ่มองค์ประกอบ แต่ถ้าคุณเปลี่ยน240ไป239, ที่ปล่อยออกมาชุมนุมแตกต่างกันค่อนข้างมาก เห็นมันบน Godbolt คอมไพเลอร์ Explorer ที่ นี่เป็นส่วนเล็ก ๆ ของการประชุม:

movdqa  xmm1, xmmword ptr [rsp + 32]
movdqa  xmm0, xmmword ptr [rsp + 48]
paddq   xmm1, xmmword ptr [rsp]
paddq   xmm0, xmmword ptr [rsp + 16]
paddq   xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq   xmm0, xmmword ptr [rsp + 1840]
paddq   xmm1, xmmword ptr [rsp + 1856]
paddq   xmm0, xmmword ptr [rsp + 1872]
paddq   xmm0, xmm1
pshufd  xmm1, xmm0, 78
paddq   xmm1, xmm0

นี่คือสิ่งที่เรียกว่าloop unrolling : LLVM วางเนื้อความไว้หลาย ๆ ครั้งเพื่อหลีกเลี่ยงการเรียกใช้คำสั่ง "loop management" ทั้งหมดเช่นการเพิ่มตัวแปร loop ตรวจสอบว่าวนรอบได้สิ้นสุดลงแล้วหรือข้ามไปยังจุดเริ่มต้นของวนรอบ .

ในกรณีที่คุณสงสัย: paddqคำแนะนำและคล้ายกันคือคำแนะนำ SIMD ซึ่งช่วยให้รวมค่าหลายค่าในแบบคู่ขนาน นอกจากนี้ยังมีการใช้ 16- ไบต์ SIMD 16 รีจิสเตอร์ ( xmm0และxmm1) ในแบบคู่ขนานเพื่อให้ระดับการเรียนการสอนที่ขนานกันของซีพียูสามารถดำเนินการตามคำแนะนำทั้งสองนี้ได้ในเวลาเดียวกัน ท้ายที่สุดพวกเขาเป็นอิสระจากกัน ในท้ายที่สุดการลงทะเบียนทั้งสองจะถูกรวมเข้าด้วยกันแล้วรวมเข้ากับผลลัพธ์สเกลาร์ในแนวนอน

กระแสหลัก x86 ซีพียู (ไม่ใช่ Atom ที่ใช้พลังงานต่ำ) สามารถโหลด 2 เวกเตอร์ต่อนาฬิกาได้จริง ๆ เมื่อพวกเขาเข้าสู่แคช L1d และpaddqปริมาณงานอย่างน้อย 2 ต่อนาฬิกาโดยมีความหน่วงรอบ 1 รอบสำหรับซีพียูส่วนใหญ่ ดูhttps://agner.org/optimize/และคำถามและคำตอบเกี่ยวกับตัวสะสมหลายตัวเพื่อซ่อนเวลาแฝง (ของ FP FMA สำหรับผลิตภัณฑ์ดอท) และปัญหาคอขวดในปริมาณงานแทน

LLVM ไม่เล็กเหยียด loops บางเมื่อมันไม่ได้อย่างเต็มที่ unrolling และยังคงใช้สะสมหลาย ดังนั้นโดยทั่วไปแบนด์วิดท์หน้าและปัญหาคอขวดเวลาแฝงท้ายไม่ใช่ปัญหาใหญ่สำหรับการวนซ้ำที่สร้างโดย LLVM แม้ว่าจะไม่มีการคลี่เต็ม


แต่การคลายลูปจะไม่รับผิดชอบต่อความแตกต่างของปัจจัย 80! อย่างน้อยก็ไม่วนแยกออกจากกันโดยลำพัง ลองดูที่โค้ดการเปรียบเทียบจริงซึ่งใส่ลูปหนึ่งเข้าไปในอีกอันหนึ่ง:

const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;

pub fn foo() -> usize {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }

    let mut sum = 0;
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }

    sum
}

( บน Godbolt Compiler Explorer )

ชุดประกอบCAPACITY = 240ดูปกติ: ลูปซ้อนกันสองลูป (ในตอนเริ่มต้นของฟังก์ชั่นนั้นมีโค้ดบางส่วนสำหรับการเริ่มต้นซึ่งเราจะไม่สนใจ) สำหรับ 239 อย่างไรก็ตามมันดูแตกต่างกันมาก! เราเห็นว่าการเตรียมใช้งานลูปเริ่มต้นและลูปด้านในไม่ได้ถูกม้วน: จนถึงตอนนี้

ข้อแตกต่างที่สำคัญคือในปี 239 LLVM สามารถคิดได้ว่าผลลัพธ์ของลูปภายในไม่ได้ขึ้นอยู่กับลูปภายนอก! ด้วยเหตุนี้ LLVM จึงปล่อยโค้ดที่โดยพื้นฐานแล้วเรียกใช้งานวงภายในเท่านั้น (คำนวณผลรวม) จากนั้นจำลองวงรอบนอกโดยเพิ่มsumจำนวนครั้ง!

ครั้งแรกที่เราเห็นการชุมนุมเกือบเหมือนเดิมข้างต้น (การชุมนุมที่เป็นตัวแทนของวงใน) หลังจากนั้นเราจะเห็นสิ่งนี้ (ฉันแสดงความคิดเห็นเพื่ออธิบายการชุมนุมความเห็นที่*มีความสำคัญอย่างยิ่ง):

        ; at the start of the function, `rbx` was set to 0

        movq    rax, xmm1     ; result of SIMD summing up stored in `rax`
        add     rax, 711      ; add up missing terms from loop unrolling
        mov     ecx, 500000   ; * init loop variable outer loop
.LBB0_1:
        add     rbx, rax      ; * rbx += rax
        add     rcx, -1       ; * decrement loop variable
        jne     .LBB0_1       ; * if loop variable != 0 jump to LBB0_1
        mov     rax, rbx      ; move rbx (the sum) back to rax
        ; two unimportant instructions omitted
        ret                   ; the return value is stored in `rax`

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

วิธีการนี้จะเปลี่ยนรันไทม์จากCAPACITY * IN_LOOPSCAPACITY + IN_LOOPSไป และนี่เป็นสาเหตุที่ทำให้เกิดความแตกต่างอย่างมาก


หมายเหตุเพิ่มเติม: คุณสามารถทำอะไรกับสิ่งนี้ได้ไหม ไม่ได้จริงๆ LLVM ต้องมีขีด จำกัด ของเวทมนตร์เช่นนี้หากไม่มีการปรับให้เหมาะสม LLVM อาจใช้เวลาตลอดไปในการสร้างรหัสให้สมบูรณ์ แต่เราสามารถตกลงกันได้ว่ารหัสนี้เป็นของปลอมอย่างมาก ในทางปฏิบัติฉันสงสัยว่าจะเกิดความแตกต่างอย่างมาก ความแตกต่างเนื่องจากการวนรอบเต็มการคลี่คลายมักไม่ใช่ปัจจัยที่ 2 ในกรณีเหล่านี้ ดังนั้นไม่จำเป็นต้องกังวลเกี่ยวกับกรณีการใช้งานจริง

ในฐานะที่เป็นโน้ตสุดท้ายเกี่ยวกับรหัส Rust สำนวน: arr.iter().sum()เป็นวิธีที่ดีกว่าในการสรุปองค์ประกอบทั้งหมดของอาร์เรย์ และการเปลี่ยนแปลงนี้ในตัวอย่างที่สองไม่ได้นำไปสู่ความแตกต่างที่โดดเด่นใด ๆ ในชุดประกอบที่ปล่อยออกมา คุณควรใช้เวอร์ชันสั้นและเป็นสำนวนเว้นแต่ว่าคุณวัดว่ามันเจ็บประสิทธิภาพ


2
@ lukas-kalbertodt ขอบคุณสำหรับคำตอบที่ยอดเยี่ยม! ตอนนี้ฉันยังเข้าใจว่าทำไมรหัสต้นฉบับที่อัปเดตsumโดยตรงไม่ใช่คนในพื้นที่sกำลังทำงานช้ากว่ามาก for i in 0..arr.len() { sum += arr[i]; }
Guy Korland

4
@LukasKalbertodt มีบางอย่างเกิดขึ้นใน LLVM ที่เปิดใช้ AVX2 ไม่ควรสร้างความแตกต่างขนาดใหญ่ Repro'd สนิมด้วย
Mgetz

4
@Metz ที่น่าสนใจ! แต่ฉันก็ไม่ได้ฟังดูบ้าไปกว่านี้ที่จะทำให้เกณฑ์นั้นขึ้นอยู่กับคำสั่ง SIMD ที่มีอยู่เพราะสิ่งนี้จะกำหนดจำนวนของคำสั่งในการวนซ้ำที่ไม่ได้ควบคุมอย่างสมบูรณ์ แต่น่าเสียดายที่ฉันไม่สามารถพูดได้อย่างแน่นอน คงจะดีถ้ามี LLVM dev ที่ตอบคำถามนี้
Lukas Kalbertodt

7
ทำไมคอมไพเลอร์หรือ LLVM ไม่ตระหนักว่าการคำนวณทั้งหมดสามารถทำได้ในเวลารวบรวม ฉันคาดว่าจะมีลูปผลลัพธ์ฮาร์ดโค้ด หรือการใช้การInstantป้องกันนั้นคืออะไร?
ชื่อที่ไม่

4
@JosephGarvin: ฉันคิดว่าเป็นเพราะการปล่อยเต็มเกิดขึ้นเพื่อให้ผ่านการเพิ่มประสิทธิภาพในภายหลังเพื่อดูว่า โปรดจำไว้ว่าการเพิ่มประสิทธิภาพคอมไพเลอร์ยังคงสนใจในการรวบรวมอย่างรวดเร็วเช่นเดียวกับการสร้าง asm ที่มีประสิทธิภาพดังนั้นพวกเขาจึงต้องจำกัดความซับซ้อนที่เลวร้ายที่สุดของการวิเคราะห์ใด ๆ ที่พวกเขาทำดังนั้นจึงไม่ต้องใช้เวลาหลายชั่วโมง / วัน . แต่ใช่นี่คือการเพิ่มประสิทธิภาพที่ผิดพลาดสำหรับขนาด> = 240 ฉันสงสัยว่าถ้าไม่เพิ่มประสิทธิภาพการวนซ้ำภายในลูปนั้นมีเจตนาที่จะหลีกเลี่ยงการทำเกณฑ์มาตรฐานอย่างง่ายหรือไม่? อาจไม่ได้ แต่อาจจะ
Peter Cordes

30

นอกจากคำตอบของ Lukas หากคุณต้องการใช้ตัววนซ้ำให้ลองทำดังนี้:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

ขอบคุณ @Chris Morgan สำหรับคำแนะนำเกี่ยวกับรูปแบบช่วง

การประกอบที่ดีที่สุดค่อนข้างดี:

example::bar:
        movabs  rax, 14340000000
        ret

3
หรือยังดีกว่า(0..CAPACITY).sum::<usize>() * IN_LOOPSซึ่งให้ผลเหมือนกัน
Chris Morgan

11
ฉันจะอธิบายว่าชุดประกอบนั้นไม่ได้ทำการคำนวณจริง แต่ LLVM ได้คำนวณคำตอบไว้แล้วในกรณีนี้
Josep

ฉันประหลาดใจที่rustcขาดโอกาสในการลดความแข็งแรงนี้ ในบริบทที่เฉพาะเจาะจงนี้ดูเหมือนว่าจะเป็นลูปการจับเวลาและคุณจงใจไม่ให้มันถูกปรับให้เหมาะสม จุดทั้งหมดคือการคำนวณซ้ำหลายครั้งตั้งแต่เริ่มต้นและหารด้วยจำนวนการทำซ้ำ ใน C สำนวน (ไม่เป็นทางการ) สำหรับการประกาศตัวนับลูปvolatileเช่นตัวนับ BogoMIPS ในเคอร์เนลลินุกซ์ มีวิธีที่จะทำให้สำเร็จใน Rust หรือไม่? อาจมี แต่ฉันไม่รู้ การโทรจากภายนอกfnอาจช่วยได้
Davislor

1
@Davislor: volatileบังคับให้หน่วยความจำนั้นซิงค์กัน การนำไปใช้กับตัวนับลูปบังคับให้โหลดซ้ำ / เก็บจริงของค่าตัวนับลูปเท่านั้น มันไม่ส่งผลกระทบโดยตรงต่อร่างกายของวง นั่นเป็นเหตุผลว่าทำไมวิธีที่ดีกว่าในการใช้งานคือการกำหนดผลลัพธ์ที่สำคัญจริง ๆ ให้กับvolatile int sinkหรือบางสิ่งบางอย่างหลังจากลูป (ถ้ามีการพึ่งพาแบบวนรอบ) หรือการวนซ้ำทุกครั้งเพื่อให้คอมไพเลอร์ปรับแต่งตัวนับลูป เพื่อให้ผลลัพธ์ที่คุณต้องการในการลงทะเบียนเป็นจริงเพื่อให้สามารถเก็บไว้ได้
Peter Cordes

1
@Davislor: ฉันคิดว่า Rust มีไวยากรณ์แบบอินไลน์ asm บางอย่างเช่น GNU C คุณสามารถใช้ inline asm เพื่อบังคับให้คอมไพเลอร์แสดงค่าในการลงทะเบียนโดยไม่ต้องบังคับให้เก็บไว้ การใช้สิ่งนั้นในผลลัพธ์ของการวนซ้ำแต่ละครั้งสามารถหยุดมันจากการปรับให้เหมาะสม (แต่จากการปรับเวกเตอร์อัตโนมัติหากคุณไม่ระวัง) เช่น"Escape" และ "Clobber" ที่เทียบเท่าใน MSVCอธิบายมาโคร 2 ตัว (ในขณะที่ขอวิธีย้ายพอร์ตไปยัง MSVC ซึ่งไม่สามารถทำได้จริง ๆ ) และลิงก์ไปยังการสนทนาของ Chandler Carruth ที่ซึ่งเขาใช้งาน
Peter Cordes
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.