Özet : 240'ın altında, LLVM iç döngüyü tamamen açar ve tekrar döngüsünü optimize ederek kıyaslama ölçünüzü bozabileceğini fark etmesini sağlar.
Üzerinde LLVM'nin belirli optimizasyonları yapmayı bıraktığı sihirli bir eşik buldunuz . Eşik 8 bayt * 240 = 1920 bayttır (diziniz usize
s dizisidir , bu nedenle uzunluk x86-64 CPU olduğu varsayılarak 8 bayt ile çarpılır). Bu kıyaslamada, sadece 239 uzunluğu için yapılan özel bir optimizasyon, devasa hız farkından sorumludur. Ama yavaşça başlayalım:
(Bu yanıttaki tüm kodlar derlenmiştir -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
}
Bu basit kod, kabaca beklenen montajı üretecektir: elemanları ekleyen bir döngü. Değiştirmek Ancak, 240
hiç 239
, montaj yayılan oldukça çok farklıdır. Godbolt Derleyici Gezgini'nde görün . İşte montajın küçük bir kısmı:
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
Bu, loop unrolling olarak adlandırılan şeydir : LLVM, tüm bu "loop yönetim talimatlarını" yürütmek, yani loop değişkenini artırmak, loop'un sona erdiğini ve loop'un başlangıcına atlamak için döngü gövdesine bir süre yapıştırır. .
Merak ediyorsanız: paddq
ve benzer talimatlar, birden çok değeri paralel olarak toplamaya izin veren SIMD talimatlarıdır. Ayrıca, CPU'nun komut düzeyi paralelliğinin temel olarak bu komutların ikisini aynı anda yürütebilmesi için iki adet 16 baytlık SIMD kaydı ( xmm0
ve xmm1
) paralel olarak kullanılır. Sonuçta, birbirlerinden bağımsızlar. Sonunda, her iki kayıt birlikte eklenir ve sonra skaler sonuca yatay olarak toplanır.
Modern ana akım x86 CPU'lar (düşük güçlü Atom değil), L1d önbelleğinde vurulduklarında gerçekten saat başına 2 vektör yükü yapabilir ve çoğu CPU'da paddq
1 döngü gecikmesi ile işlem hacmi saat başına en az 2'dir. Bkz https://agner.org/optimize/ ve ayrıca bu soru-cevap verim yerine birden fazla (bir nokta ürünün AP FMA'nın) gizlemek gecikme için akümülatör ve tıkanıklık hakkında.
LLVM göz önüne sermek küçük döngüler yapar bazıları o değilken tamamen unrolling ve hala çoklu akümülatörleri kullanır. Bu nedenle, genellikle, ön uç bant genişliği ve arka uç gecikme darboğazları, tam açma olmadan bile LLVM tarafından üretilen döngüler için büyük bir sorun değildir.
Ancak loop unrolling, faktör 80'in performans farkından sorumlu değildir! En azından tek başına döngü çözme değil. Bir döngüyü bir diğerinin içine yerleştiren gerçek karşılaştırma koduna bir göz atalım:
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 Derleyici Gezgini'nde )
Montaj CAPACITY = 240
normal görünüyor: iki iç içe ilmek. (İşlevin başlangıcında, yalnızca göz ardı edeceğimiz bazı kodlar vardır.) 239 için, çok farklı görünüyor! Başlangıç döngüsünün ve iç döngünün açıldığını görüyoruz: şimdiye kadar beklenen.
Önemli fark, 239 için LLVM'nin iç halkanın sonucunun dış halkaya bağlı olmadığını anlayabilmesidir! Sonuç olarak, LLVM temel olarak sadece iç döngüyü (toplamı hesaplayarak) yürüten ve daha sonra sum
bir sürü kez ekleyerek dış döngüyü simüle eden kod yayar !
İlk olarak yukarıdaki ile hemen hemen aynı montajı görüyoruz (iç halkayı temsil eden montaj). Daha sonra bunu görüyoruz (meclisi açıklamak için yorum yaptım; yorumlarınız *
özellikle önemlidir):
; 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`
Burada görebileceğiniz gibi, iç halkanın sonucu alınır, dış halkanın koştuğu sıklıkta toplanır ve sonra geri döner. LLVM bu optimizasyonu sadece yapabilir, çünkü iç halkanın dıştan bağımsız olduğu anlaşılmıştır.
Bu gelen çalışma zamanı değişiklikleri anlamına CAPACITY * IN_LOOPS
içinCAPACITY + IN_LOOPS
. Bu büyük performans farkından sorumludur.
Ek bir not: bu konuda herhangi bir şey yapabilir misiniz? Pek sayılmaz. LLVM, onlarsız LLVM optimizasyonlarının belirli bir kodda tamamlanması sonsuza kadar sürebilecek sihirli eşiklere sahip olmalıdır. Ancak bu kodun oldukça yapay olduğunu da kabul edebiliriz. Uygulamada böyle büyük bir farkın olacağından şüpheliyim. Tam döngü açılmasından kaynaklanan fark, bu durumlarda genellikle faktör 2 bile değildir. Yani gerçek kullanım durumları için endişelenmenize gerek yok.
Deyimsel Rust kodu hakkında son bir not olarak: arr.iter().sum()
bir dizinin tüm elemanlarını özetlemenin daha iyi bir yoludur. Ve ikinci örnekte bunu değiştirmek, yayılan montajda kayda değer farklılıklara yol açmaz. Performansı zedelediğini ölçmedikçe kısa ve deyimsel versiyonlar kullanmalısınız.