ข้อมูลสรุป : ต่ำกว่า 240, LLVM คลายการวนรอบด้านในอย่างเต็มที่และช่วยให้สังเกตได้ว่าสามารถเพิ่มประสิทธิภาพวนซ้ำซ้ำเพื่อทำลายมาตรฐานของคุณ
คุณพบเกณฑ์มายากลข้างต้นซึ่ง LLVM หยุดการดำเนินการเพิ่มประสิทธิภาพบางอย่าง ขีด จำกัด คือ 8 ไบต์ * 240 = 1920 ไบต์ (อาเรย์ของคุณเป็นอาร์เรย์ของusize
s ดังนั้นความยาวจะถูกคูณด้วย 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_LOOPS
CAPACITY + IN_LOOPS
ไป และนี่เป็นสาเหตุที่ทำให้เกิดความแตกต่างอย่างมาก
หมายเหตุเพิ่มเติม: คุณสามารถทำอะไรกับสิ่งนี้ได้ไหม ไม่ได้จริงๆ LLVM ต้องมีขีด จำกัด ของเวทมนตร์เช่นนี้หากไม่มีการปรับให้เหมาะสม LLVM อาจใช้เวลาตลอดไปในการสร้างรหัสให้สมบูรณ์ แต่เราสามารถตกลงกันได้ว่ารหัสนี้เป็นของปลอมอย่างมาก ในทางปฏิบัติฉันสงสัยว่าจะเกิดความแตกต่างอย่างมาก ความแตกต่างเนื่องจากการวนรอบเต็มการคลี่คลายมักไม่ใช่ปัจจัยที่ 2 ในกรณีเหล่านี้ ดังนั้นไม่จำเป็นต้องกังวลเกี่ยวกับกรณีการใช้งานจริง
ในฐานะที่เป็นโน้ตสุดท้ายเกี่ยวกับรหัส Rust สำนวน: arr.iter().sum()
เป็นวิธีที่ดีกว่าในการสรุปองค์ประกอบทั้งหมดของอาร์เรย์ และการเปลี่ยนแปลงนี้ในตัวอย่างที่สองไม่ได้นำไปสู่ความแตกต่างที่โดดเด่นใด ๆ ในชุดประกอบที่ปล่อยออกมา คุณควรใช้เวอร์ชันสั้นและเป็นสำนวนเว้นแต่ว่าคุณวัดว่ามันเจ็บประสิทธิภาพ