Tại sao có tác động hiệu suất lớn khi lặp qua một mảng có 240 phần tử trở lên?


230

Khi chạy một vòng lặp tổng trên một mảng trong Rust, tôi nhận thấy hiệu suất giảm rất lớn khi CAPACITY> = 240. CAPACITY= 239 nhanh hơn khoảng 80 lần.

Có tối ưu hóa biên dịch đặc biệt mà Rust đang thực hiện cho các mảng "ngắn" không?

Tổng hợp với 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
Có lẽ với 240 bạn đang tràn ra một dòng bộ đệm CPU? Nếu đó là trường hợp, kết quả của bạn sẽ rất cụ thể CPU.
Rodrigo

11
Sinh sản tại đây . Bây giờ tôi đoán rằng nó có một cái gì đó để làm với vòng lặp không kiểm soát.
Rodrigo

Câu trả lời:


355

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 usizes, 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 240thà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 paddqhướ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 ( xmm0xmm1) đượ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à paddqthô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 = 240vẻ 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 summộ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.


2
@ lukas-kalbertodt cảm ơn vì câu trả lời tuyệt vời! bây giờ tôi cũng hiểu tại sao mã gốc được cập nhật sumtrực tiếp trên không phải cục bộ slại chạy chậm hơn nhiều. for i in 0..arr.len() { sum += arr[i]; }
Guy Korland

4
@LukasKalbertodt Một cái gì đó khác đang diễn ra trong LLVM khi bật AVX2 không nên tạo ra sự khác biệt lớn như vậy. Repro'd cũng bị rỉ sét
Mgetz

4
@Mgetz Thú vị! Nhưng đối với tôi, điều đó không có vẻ quá điên rồ khi khiến ngưỡng đó phụ thuộc vào các hướng dẫn SIMD có sẵn, vì điều này cuối cùng sẽ xác định số lượng hướng dẫn trong một vòng lặp hoàn toàn không được kiểm soát. Nhưng thật không may, tôi không thể nói chắc chắn. Sẽ thật ngọt ngào khi có một nhà phát triển LLVM trả lời điều này.
Lukas Kalbertodt

7
Tại sao trình biên dịch hoặc LLVM không nhận ra rằng toàn bộ phép tính có thể được thực hiện tại thời điểm biên dịch? Tôi đã dự kiến ​​sẽ có kết quả vòng lặp mã hóa cứng. Hay là việc sử dụng để Instantngăn chặn điều đó?
Tên không sáng tạo

4
@JosephGarvin: Tôi cho rằng đó là vì việc không kiểm soát hoàn toàn xảy ra để cho phép vượt qua tối ưu hóa sau này để thấy điều đó. Hãy nhớ rằng tối ưu hóa trình biên dịch vẫn quan tâm đến việc biên dịch nhanh chóng, cũng như tạo ra asm hiệu quả, vì vậy chúng phải hạn chế sự phức tạp trong trường hợp xấu nhất của bất kỳ phân tích nào mà chúng thực hiện để không mất nhiều giờ / ngày để biên dịch một số mã nguồn khó chịu với các vòng lặp phức tạp . Nhưng vâng, đây rõ ràng là một tối ưu hóa bị bỏ lỡ cho kích thước> = 240. Tôi tự hỏi nếu không tối ưu hóa các vòng lặp bên trong các vòng lặp là cố ý để tránh phá vỡ các điểm chuẩn đơn giản? Có lẽ là không, nhưng có lẽ.
Peter Cordes

30

Ngoài câu trả lời của Lukas, nếu bạn muốn sử dụng một trình vòng lặp, hãy thử điều này:

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

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

Cảm ơn @Chris Morgan về gợi ý về mẫu phạm vi.

Việc lắp ráp tối ưu hóa khá tốt:

example::bar:
        movabs  rax, 14340000000
        ret

3
Hoặc tốt hơn, vẫn (0..CAPACITY).sum::<usize>() * IN_LOOPSmang lại kết quả tương tự.
Chris Morgan

11
Tôi thực sự sẽ giải thích rằng hội đồng không thực sự thực hiện phép tính, nhưng LLVM đã tính toán trước câu trả lời trong trường hợp này.
Josep

Tôi hơi ngạc nhiên khi rustcbỏ lỡ cơ hội để thực hiện việc giảm sức mạnh này. Tuy nhiên, trong bối cảnh cụ thể này, đây dường như là một vòng lặp thời gian và bạn cố tình muốn nó không được tối ưu hóa. Toàn bộ vấn đề là lặp lại việc tính toán số lần đó từ đầu và chia cho số lần lặp lại. Trong C, thành ngữ (không chính thức) cho điều đó là khai báo bộ đếm vòng lặp như volatile, ví dụ bộ đếm BogoMIPS trong nhân Linux. Có cách nào để đạt được điều này trong Rust không? Có thể có, nhưng tôi không biết điều đó. Gọi một bên ngoài fncó thể giúp đỡ.
Davislor

1
@Davislor: volatilebuộc bộ nhớ đó phải đồng bộ. Áp dụng nó vào bộ đếm vòng lặp chỉ buộc tải lại / lưu trữ giá trị bộ đếm vòng lặp thực tế. Nó không ảnh hưởng trực tiếp đến cơ thể vòng lặp. Đó là lý do tại sao một cách tốt hơn để sử dụng nó thường là gán kết quả quan trọng thực sự volatile int sinkhoặc một cái gì đó sau vòng lặp (nếu có phụ thuộc vòng lặp) hoặc mỗi lần lặp, để cho trình biên dịch tối ưu hóa bộ đếm vòng lặp theo cách nó muốn nhưng buộc nó để cụ thể hóa kết quả mà bạn muốn trong một thanh ghi để nó có thể lưu trữ nó.
Peter Cordes

1
@Davislor: Tôi nghĩ rằng Rust có cú pháp asm nội tuyến giống như GNU C. Bạn có thể sử dụng mã asm nội tuyến để buộc trình biên dịch cụ thể hóa một giá trị trong một thanh ghi mà không buộc nó phải lưu trữ nó. Sử dụng kết quả của mỗi lần lặp lại có thể ngăn không cho nó tối ưu hóa. (Nhưng cũng từ tự động véc tơ nếu bạn không cẩn thận). ví dụ: "Escape" và "Clobber" tương đương trong MSVC giải thích 2 macro (trong khi hỏi cách chuyển chúng sang MSVC không thực sự có thể) và liên kết với bài nói chuyện của Chandler Carruth nơi anh ta cho thấy việc sử dụng chúng.
Peter Cordes
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.