240 veya daha fazla öğeye sahip bir dizi üzerinde döngü yaparken neden büyük bir performans etkisi var?


230

Rust'da bir dizi üzerinde bir toplam döngüsü çalıştırırken, CAPACITY= 240 CAPACITYise büyük bir performans düşüşü fark ettim. = 239 yaklaşık 80 kat daha hızlı.

Rust'un "kısa" diziler için yaptığı özel derleme optimizasyonu var mı?

İle derlendi 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
Belki 240 ile CPU önbellek hattını taşıyorsunuz? Bu durumda, sonuçlarınız CPU'ya özel olacaktır.
rodrigo

11
Burada çoğaltılmıştır . Şimdi bunun döngü çözmeyle bir ilgisi olduğunu tahmin ediyorum.
rodrigo

Yanıtlar:


355

Ö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 usizes 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, 240hiç 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: paddqve 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ı ( xmm0ve 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 paddq1 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 = 240normal 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 sumbir 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_LOOPSiç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.


2
@ lukas-kalbertodt harika cevap için teşekkürler! şimdi de neden sumyerel değil doğrudan güncellenen orijinal kodun sçok daha yavaş çalıştığını anlıyorum . for i in 0..arr.len() { sum += arr[i]; }
Guy Korland

4
@LukasKalbertodt LLVM'de AVX2'yi açmak başka bir şey oluyor, bu kadar büyük bir fark yaratmamalıdır . Çok paslanmaya başladı
Mgetz

4
@Mgetz İlginç! Ama bu eşiği mevcut SIMD talimatlarına bağlı hale getirmek benim için çok çılgınca gelmiyor, çünkü bu sonuçta tamamen açılmamış bir döngüdeki talimat sayısını belirler. Ama ne yazık ki, kesin olarak söyleyemem. LLVM geliştiricisinin buna cevap vermesi tatlı olur.
Lukas Kalbertodt

7
Derleyici veya LLVM neden tüm hesaplamanın derleme zamanında yapılabileceğini fark etmiyor? Ben döngü sonucu sabit kodlanmış olması beklenirdi. Yoksa Instantbunu önlemenin kullanımı mı?
Yaratıcı Olmayan İsim

4
@JosephGarvin: Sanırım, daha sonraki optimizasyon geçişinin bunu görmesine izin vermek için tamamen unrolling gerçekleşiyor. Optimizasyon derleyicilerinin hızlı bir şekilde derlemeyi ve verimli bir asm yapmayı önemsediğini unutmayın, bu nedenle yaptıkları herhangi bir analizin en kötü durum karmaşıklığını sınırlamaları gerekir, bu nedenle karmaşık döngülerle bazı kötü kaynak kodlarını derlemek saat / gün almaz . Ama evet, bu açıkça boyut = = 240 için kaçırılan bir optimizasyon. Döngüler içinde döngüleri optimize etmemek basit ölçütleri kırmak için kasıtlı olup olmadığını merak ediyorum? Muhtemelen hayır, ama belki.
Peter Cordes

30

Lukas'ın cevabına ek olarak, bir yineleyici kullanmak istiyorsanız, şunu deneyin:

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

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

Range Morgan hakkında öneri için @Chris Morgan'a teşekkürler.

Montaj optimize oldukça iyidir:

example::bar:
        movabs  rax, 14340000000
        ret

3
Ya da daha iyisi (0..CAPACITY).sum::<usize>() * IN_LOOPS, aynı sonucu verir.
Chris Morgan

11
Aslında meclisin aslında hesaplama yapmadığını açıklıyorum, ancak LLVM bu durumda cevabı önceden hesapladı.
Josep

rustcBu güç azalmasını yapma fırsatını kaçırdığına şaşırıyorum . Bununla birlikte, bu özel bağlamda, bu bir zamanlama döngüsü gibi görünüyor ve bunun kasıtlı olarak optimize edilmemesini istiyorsunuz. Bütün mesele, bu sayıyı sıfırdan tekrarlamak ve tekrar sayısına bölmektir. C'de (gayri resmi) deyim döngü sayacını volatile, örneğin Linux çekirdeğindeki BogoMIPS sayacı olarak bildirmektir . Rust'da bunu başarmanın bir yolu var mı? Olabilir, ama bilmiyorum. Harici arama yapmak fnyardımcı olabilir.
Davislor

1
@Davislor: volatileo hafızayı senkronize olmaya zorlar. Bunu döngü sayacına uygulamak, döngü sayacı değerinin yalnızca gerçek olarak yeniden yüklenmesini / depolanmasını zorlar. Döngü gövdesini doğrudan etkilemez. Bu nedenle bunu kullanmanın daha iyi bir yolu volatile int sink, derleyicinin döngü sayacını istediği gibi optimize etmesine izin vermek, ancak zorlamak için döngüden sonra (döngü taşınan bir bağımlılık varsa) veya her yinelemeye gerçek önemli sonucu veya bir şeyi atamaktır. hayata istediğiniz sonucu bunu saklayabilirsiniz böylece bir kayıt.
Peter Cordes

1
@Davislor: Rust'un satır içi asm sözdizimi GNU C gibi bir şey olduğunu düşünüyorum. Derleyiciyi bir kayıttaki bir değeri depolamaya zorlamadan gerçekleştirmeye zorlamak için satır içi asm kullanabilirsiniz . Bunu her döngü yinelemesi sonucunda kullanmak, onu optimize etmeyi durdurabilir. (Ancak dikkatli değilseniz otomatik vektörlemeden de). Örneğin, MSVC'deki "Escape" ve "Clobber" eşdeğeri 2 makroyu açıklar (bunları gerçekten mümkün olmayan MSVC'ye nasıl aktaracağınızı sorarken) ve Chandler Carruth'un kullanımlarını gösterdiği konuşmasına bağlar.
Peter Cordes
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.