Tóm tắt : dưới 240, LLVM hoàn toàn hủy bỏ vòng lặp bên trong và cho phép nó nhận thấy nó có thể tối ưu hóa vòng lặp lặp lại, phá vỡ điểm chuẩn của bạn.
Bạn đã tìm thấy ngưỡng ma thuật trên đó LLVM ngừng thực hiện các tối ưu hóa nhất định . Ngưỡng là 8 byte * 240 = 1920 byte (mảng của bạn là một mảng usize
s, do đó độ dài được nhân với 8 byte, giả sử CPU x86-64). Trong tiêu chuẩn này, một tối ưu hóa cụ thể - chỉ được thực hiện cho chiều dài 239 - chịu trách nhiệm cho sự khác biệt tốc độ rất lớn. Nhưng hãy bắt đầu từ từ:
(Tất cả mã trong câu trả lời này được biên dịch với -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
}
Mã đơn giản này sẽ tạo ra phần lắp ráp mà người ta mong đợi: một vòng lặp thêm các phần tử. Tuy nhiên, nếu bạn thay đổi 240
thành 239
, lắp ráp phát ra khác nhau khá nhiều. Xem nó trên Godbolt Compiler Explorer . Đây là một phần nhỏ của lắp ráp:
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
Đây là cái được gọi là unrolling loop : LLVM dán thân vòng lặp rất nhiều thời gian để tránh phải thực hiện tất cả các "hướng dẫn quản lý vòng lặp" đó, tức là tăng biến vòng lặp, kiểm tra xem vòng lặp đã kết thúc chưa và nhảy đến điểm bắt đầu của vòng lặp .
Trong trường hợp bạn đang tự hỏi: các paddq
hướng dẫn tương tự và tương tự là các hướng dẫn SIMD cho phép tổng hợp nhiều giá trị song song. Ngoài ra, hai thanh ghi SIMD 16 byte ( xmm0
và xmm1
) được sử dụng song song để tính song song mức lệnh của CPU về cơ bản có thể thực hiện hai trong số các lệnh này cùng một lúc. Rốt cuộc, họ độc lập với nhau. Cuối cùng, cả hai thanh ghi được thêm vào với nhau và sau đó được tổng hợp theo chiều ngang với kết quả vô hướng.
Các CPU x86 chính hiện đại (không phải nguyên tử năng lượng thấp) thực sự có thể thực hiện 2 lần tải vector mỗi xung nhịp khi chúng chạm vào bộ đệm L1d và paddq
thông lượng cũng ít nhất là 2 trên mỗi xung nhịp, với độ trễ 1 chu kỳ trên hầu hết các CPU. Xem https://agner.org/optizes/ và cả Hỏi & Đáp này về nhiều bộ tích lũy để che giấu độ trễ (của FP FMA cho một sản phẩm chấm) và thay vào đó là tắc nghẽn về thông lượng.
LLVM không hủy một số vòng lặp nhỏ khi nó không được kiểm soát hoàn toàn và vẫn sử dụng nhiều bộ tích lũy. Vì vậy, thông thường, băng thông phía trước và tắc nghẽn độ trễ phía sau không phải là vấn đề lớn đối với các vòng lặp do LLVM tạo ra ngay cả khi không kiểm soát hoàn toàn.
Nhưng unrolling vòng lặp không chịu trách nhiệm cho sự khác biệt hiệu suất của yếu tố 80! Ít nhất là không vòng lặp unrolling một mình. Chúng ta hãy xem mã điểm chuẩn thực tế, đặt một vòng lặp bên trong một vòng lặp khác:
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
}
( Trên trình thám hiểm trình biên dịch Godbolt )
Việc lắp ráp cho CAPACITY = 240
vẻ ngoài bình thường: hai vòng lặp lồng nhau. (Khi bắt đầu chức năng, có một số mã chỉ để khởi tạo, mà chúng ta sẽ bỏ qua.) Tuy nhiên, đối với 239, nó trông rất khác! Chúng tôi thấy rằng vòng lặp khởi tạo và vòng lặp bên trong đã không được kiểm soát: cho đến nay rất được mong đợi.
Sự khác biệt quan trọng là đối với 239, LLVM đã có thể tìm ra rằng kết quả của vòng lặp bên trong không phụ thuộc vào vòng lặp bên ngoài! Kết quả là, LLVM phát ra mã về cơ bản trước tiên chỉ thực hiện vòng lặp bên trong (tính tổng) và sau đó mô phỏng vòng lặp bên ngoài bằng cách thêm sum
một loạt các lần!
Đầu tiên chúng ta thấy gần như lắp ráp như trên (lắp ráp đại diện cho vòng lặp bên trong). Sau đó chúng ta thấy điều này (tôi đã bình luận để giải thích hội nghị; các ý kiến với *
đặc biệt quan trọng):
; 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`
Như bạn có thể thấy ở đây, kết quả của vòng lặp bên trong được lấy, được thêm vào thường xuyên như vòng lặp bên ngoài sẽ chạy và sau đó được trả về. LLVM chỉ có thể thực hiện tối ưu hóa này vì nó hiểu rằng vòng lặp bên trong độc lập với vòng ngoài.
Điều này có nghĩa là thời gian chạy thay đổi từ CAPACITY * IN_LOOPS
đếnCAPACITY + IN_LOOPS
. Và điều này chịu trách nhiệm cho sự khác biệt hiệu suất rất lớn.
Một lưu ý bổ sung: bạn có thể làm bất cứ điều gì về điều này? Không hẳn vậy. LLVM phải có các ngưỡng ma thuật như vậy nếu không có chúng, tối ưu hóa LLVM có thể mất mãi mãi để hoàn thành mã nhất định. Nhưng chúng ta cũng có thể đồng ý rằng mã này có tính nhân tạo cao. Trong thực tế, tôi nghi ngờ rằng một sự khác biệt lớn như vậy sẽ xảy ra. Sự khác biệt do không kiểm soát vòng lặp đầy đủ thường không phải là yếu tố 2 trong những trường hợp này. Vì vậy, không cần phải lo lắng về trường hợp sử dụng thực sự.
Như một lưu ý cuối cùng về mã Rust thành ngữ: arr.iter().sum()
là cách tốt hơn để tổng hợp tất cả các phần tử của một mảng. Và thay đổi điều này trong ví dụ thứ hai không dẫn đến bất kỳ sự khác biệt đáng chú ý nào trong lắp ráp phát ra. Bạn nên sử dụng các phiên bản ngắn và thành ngữ trừ khi bạn đo lường rằng điều đó làm tổn hại đến hiệu suất.