GCC neden tamsayı bölünmesinin uygulanmasında garip bir sayı ile çarpmayı kullanıyor?


229

Okuma divve mulmontaj işlemleri hakkında okudum ve C'de basit bir program yazarak bunları çalışırken görmeye karar verdim:

Dosya bölmesi.c

#include <stdlib.h>
#include <stdio.h>

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

Ve sonra montaj dili kodu ile:

gcc -S division.c -O0 -masm=intel

Ancak oluşturulan division.sdosyaya bakıldığında, herhangi bir div işlemi içermiyor! Bunun yerine, biraz kayma ve sihirli sayılarla bir tür kara büyü yapar. İşte hesaplayan bir kod snippet'i i/5:

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now, 
                                  ; so we can assign it to j

Burada neler oluyor? GCC neden div kullanmıyor? Bu sihirli sayıyı nasıl üretiyor ve neden her şey çalışıyor?


29
gcc bölümleri sabitlere göre optimize eder, bölümleri 2,3,4,5,6,7,8 ile deneyin ve büyük olasılıkla her durum için çok farklı kodlar göreceksiniz.
Jabberwocky

29
Not: Sihirli sayı bir veya hemen hemen (2 ^ 64) * 4/5 olarak -3689348814741910323dönüştürülür . CCCCCCCCCCCCCCCDuint64_t
chux - Monica'yı

32
@qiubit: Derleyici, yalnızca eniyileme devre dışı bırakıldığı için verimsiz kod üretmez. Örneğin, kodun yeniden sıralanmasını veya değişken eliminasyonu içermeyen önemsiz bir "optimizasyon" gerçekleştirilecektir. Esasen tek bir kaynak ifadesi, bu işlem için en etkin koda ayrı olarak tercüme edilecektir. Derleyici optimizasyonu, yalnızca tek bir deyim yerine çevredeki kodu dikkate alır.
Clifford

20
Bu müthiş makaleyi okuyun: Division of Labor
Jester

9
Bazı derleyiciler aslında edecek optimizasyon devre dışı olduğundan sapkınca verimsiz kodu oluşturmak. Özellikle, tek tek kod satırlarında kesme noktaları ayarlama yeteneği gibi hata ayıklamayı kolaylaştırmak için yapacaklardır. GCC, aslında, gerçek bir "optimizasyon yok" moduna sahip olmaması nedeniyle oldukça sıra dışıdır, çünkü optimizasyonlarının çoğu yapısal olarak açıktır. Bu, GCC ile görebileceğiniz bir örnektir. Öte yandan Clang, ve MSVC, olacak bir yayarlar divadresindeki yönerge -O0. (cc Clifford)
Cody Gri

Yanıtlar:


169

Tamsayı bölümü, düzinelerce döngüye kadar gecikme süresi ve kötü verim ile modern bir işlemci üzerinde gerçekleştirebileceğiniz en yavaş aritmetik işlemlerden biridir. (X86 için Agner Fog'un talimat tablolarına ve mikroarşı kılavuzuna bakın ).

Böleni vaktinden önce biliyorsanız, bölmeyi eşdeğer etkiye sahip bir dizi başka işlemle (çarpma, toplama ve kaydırma) değiştirerek önleyebilirsiniz. Birkaç işleme ihtiyaç duyulsa bile, genellikle tamsayı bölümünün kendisinden çok daha hızlı bir halidir.

C /operatörünü çoklu talimat dizisi yerine bu şekilde uygulamak div, sadece GCC'nin sabitler tarafından bölme yapmanın varsayılan yoludur. İşlemler arasında optimizasyon gerektirmez ve hata ayıklama için bile hiçbir şeyi değiştirmez. (Kullanarak -Oskullanımına GCC olsun küçük bir kod boyutu için divolsa da,.) Kullanılarak bölünme çarpımsal ters kullanarak kullanmak gibidir leayerine mulveadd

Sonuç olarak, yalnızca görmek eğiliminde divveya idivbölen derleme zamanında bilinmiyorsa çıktı.

Derleyicinin bu dizileri nasıl oluşturduğu hakkında bilgi ve bunları kendiniz için oluşturmanıza izin veren kod (braindead derleyicisiyle çalışmadığınız sürece kesinlikle gereksizdir) için bkz. Libdivide .


5
FP ve tamsayı işlemlerini bir hız karşılaştırmasında toplamanın adil olmadığından emin değilim, @fuz. Belki de Sneftel, bölünmenin modern bir işlemci üzerinde yapabileceğiniz en yavaş tamsayı işlemi olduğunu söylemelidir ? Ayrıca, bu "sihir" ile ilgili daha fazla açıklama için bazı bağlantılar yorumlarda verilmiştir. Görünürlük için cevabınızda toplamanın uygun olacağını düşünüyor musunuz? 1 , 2 , 3
Cody Gray

1
İşlem sırası işlevsel olarak aynı olduğundan ... bu her zaman bir zorunluluktur, hatta -O3. Derleyici, olası tüm giriş değerleri için doğru sonuçları veren bir kod yapmalıdır. Bu sadece kayan nokta ile değişir -ffast-mathve AFAIK "tehlikeli" tamsayı optimizasyonu yoktur. (Optimizasyon etkinleştirildiğinde, derleyici, yalnızca negatif olmayan işaretli tamsayılar için çalışan bir şey kullanmasına izin veren olası değerler aralığı hakkında bir şey kanıtlayabilir.)
Peter Cordes

6
Asıl cevap, gcc-O0'ın hala C'yi makine koduna dönüştürmenin bir parçası olarak kodu dahili gösterimlerle dönüştürmesidir . Sadece modüler çarpımsal terslemelerin varsayılan olarak -O0(ile değil -Os) de etkinleştirildiği görülür . Diğer derleyiciler (clang gibi), adresindeki 2 gücü olmayan sabitler için DIV kullanır -O0. ile ilgili: Sanırım Collatz-varsayım elle yazılmış asm cevap
Peter Cordes

6
@PeterCordes Ve evet, sanırım GCC (ve diğer birçok derleyici) "optimizasyon devre dışı bırakıldığında ne tür optimizasyonlar uygulanmaktadır" için iyi bir mantık bulmayı unuttu. Günün daha iyi bir kısmını belirsiz bir kodgen hatasını takip ederek geçirdim, şu an için biraz rahatsızım.
Sneftel

9
@Sneftel: Muhtemelen derleyici geliştiricilerine kodlarının beklenenden daha hızlı çalışmasından aktif olarak şikayet eden uygulama geliştiricilerinin sayısı nispeten az olduğu için.
dan04

122

5'e bölmek, 1/5 ile çarpmakla aynıdır, bu da yine 4/5 ile çarpmak ve sağ 2 biti kaydırmakla aynıdır. İlgili değer CCCCCCCCCCCCCCCD, onaltılı bir noktadan sonra konursa 4/5 değerinin ikili gösterimidir (yani, dört beşinci için ikili yinelenir 0.110011001100- neden için aşağıya bakın). Sanırım buradan alabilirsin! Sabit nokta aritmetiğini kontrol etmek isteyebilirsiniz (sonunda bir tamsayıya yuvarlandığını unutmayın).

Neden olarak, çarpma bölmeden daha hızlıdır ve bölen sabitlendiğinde, bu daha hızlı bir yoldur.

Sabit nokta açısından açıklayan, nasıl çalıştığına dair ayrıntılı bir yazma için bir öğretici olan Karşılıklı Çarpma bölümüne bakın . Karşılıklı bulma algoritmasının nasıl çalıştığını ve imzalı bölme ve modulo ile nasıl başa çıkılacağını gösterir.

Bir dakikalığına 0.CCCCCCCC...(hex) veya 0.110011001100...binary'nin 4/5 olduğunu düşünelim . İkili gösterimi 4'e bölün (sağa 2 yer kaydırın) ve 0.001100110011...önemsiz denetim yoluyla elde edilecek orijinalin eklenebileceğini elde edeceğiz 0.111111111111..., ki bu açıkça 1'e eşittir 0.9999999..., ondalık olarak aynı şekilde bire eşittir. Bu nedenle, bunu biliyoruz x + x/4 = 1, bu yüzden 5x/4 = 1, x=4/5. Bu daha sonra CCCCCCCCCCCCDyuvarlama için onaltılı olarak temsil edilir (mevcut olanın ötesindeki ikili basamak a olacaktır 1).


2
@ user2357112 Kendi yanıtınızı göndermekten çekinmeyin, ancak kabul etmiyorum. Çarpmayı 64.0 bit ile 0.64 bit çarpma olarak düşünebilir, en düşük 64 bit atılan 128 bitlik bir sabit cevap, daha sonra 4'e bölünür (ilk paragrafta belirttiğim gibi). Bit hareketlerini eşit derecede iyi açıklayan alternatif bir modüler aritmetik cevap ortaya çıkarabilirsiniz, ancak bunun bir açıklama olarak çalıştığından eminim.
abligh

6
Değer aslında "CCCCCCCCCCCCCCCD" dir. Son D önemlidir, sonuç kesildiğinde kesin bölümlerin doğru cevapla çıkmasını sağlar.
plugwash

4
Boşver. 128 bit çarpma sonucunun üst 64 bitini aldıklarını görmedim; çoğu dilde yapabileceğiniz bir şey değil, bu yüzden başlangıçta olduğunu fark etmedim. Bu cevap, 128 bitlik sonucun üst 64 bitini almanın, sabit noktalı bir sayı ile çarpmaya ve aşağı yuvarlamaya nasıl eşdeğer olduğuna açık bir şekilde değinerek çok daha iyi olacaktır. (Ayrıca, neden 1/5 yerine 4/5 olması gerektiğini ve neden aşağı yerine 4/5 yukarı yuvarlamamız gerektiğini açıklamak iyi olur.)
user2357112 Monica

2
Bir yuvarlama sınırı boyunca bir bölümü 5 yukarı doğru atmak için bir hatanın ne kadar büyük olması gerektiğini düşünmeniz ve ardından bunu hesaplamanızdaki en kötü durum hatasıyla karşılaştırmanız gerekir. Muhtemelen gcc geliştiricileri bunu yaptı ve her zaman doğru sonuçları vereceği sonucuna vardı.
plugwash

3
Aslında, mümkün olan en yüksek 5 giriş değerini kontrol etmeniz gerekir, eğer bunlar doğru yuvarlanırsa, diğer her şey de gerekir.
plugwash

60

Genel olarak çarpma bölünmeden çok daha hızlıdır. Dolayısıyla, karşılıklı ile çarparak kaçabilirsek, bölünmeyi bir sabitle önemli ölçüde hızlandırabiliriz.

Bir kırışıklık, karşılıklılığı tam olarak temsil edemeyeceğimizdir (bölünme iki güçle olmadıkça, ancak bu durumda genellikle bölünmeyi biraz kaydırmaya dönüştürebiliriz). Bu nedenle, doğru cevapları sağlamak için karşılıklılığımızdaki hatanın nihai sonucumuzda hatalara neden olmadığına dikkat etmeliyiz.

-3689348814741910323, 0.64 sabit noktada ifade edilen 4/5 değerinin biraz üzerinde bir değer olan 0xCCCCCCCCCCCCCCCD'dir.

64 bit tamsayıyı 0.64 sabit nokta sayısıyla çarptığımızda 64.64 sonuç elde ederiz. Değeri 64 bit tamsayı olarak kesiyoruz (sıfıra doğru etkili bir şekilde yuvarlıyoruz) ve sonra dörde bölünen ve tekrar kesen başka bir kaydırma gerçekleştiriyoruz Bit seviyesine bakarak her iki kesmeyi de tek bir kesme olarak ele alabileceğimiz açıktır.

Bu bize en azından 5'e bölünmenin yaklaşık bir tahminini veriyor, ancak bize sıfıra doğru doğru yuvarlanmış kesin bir cevap veriyor mu?

Kesin bir cevap almak için hatanın yuvarlama sınırını zorlamayacak kadar küçük olması gerekir.

5'e bölünmenin kesin cevabı her zaman kesirli kısmı 0, 1/5, 2/5, 3/5 veya 4/5 olacaktır. Bu nedenle, çarpılan ve kaydırılan sonuçta 1/5 değerinden daha düşük bir pozitif hata, sonucu hiçbir zaman yuvarlama sınırının üzerine itmeyecektir.

Sabitimizdeki hata (1/5) * 2-64'tür . İ değeri 2 64'ten küçüktür, bu nedenle çarpma işleminden sonraki hata 1/5'den küçüktür. 4 ile ayrılmasından sonra hatayı daha az (1/5) * 2 daha -2 .

(1/5) * 2 −2 <1/5, böylece cevap her zaman kesin bir bölünme yapmaya ve sıfıra yuvarlamaya eşit olacaktır.


Ne yazık ki bu tüm bölenlerde işe yaramıyor.

4 / 7'yi sıfıra yuvarlayarak 0.64 sabit nokta sayısı olarak göstermeye çalışırsak, (6/7) * 2-64 hatası ile sonuçlanırız . 2 64'ün hemen altındaki bir i değeri ile çarptıktan sonra 6/7'nin hemen altında bir hata ile sonuçlanırız ve dörde böldükten sonra 1,5 / 7'nin hemen altında 1/7'den büyük bir hata ile sonuçlanırız.

Bu nedenle, 7'yi doğru bir şekilde uygulamak için 0.65 sabit nokta numarasıyla çarpmamız gerekir. Bunu, sabit nokta numaramızın daha düşük 64 bitiyle çarparak, ardından orijinal numarayı ekleyerek (bu, taşıma bitine taşabilir) ve sonra taşıma boyunca bir döndürme yaparak uygulayabiliriz.


8
Bu cevap "zaman ayırmak istediğimden daha karmaşık görünen matematikten" modüler çarpımsal tersleri mantıklı bir şeye dönüştürdü. Anlaması kolay sürüm için +1. Derleyici tarafından oluşturulan sabitleri kullanmaktan başka bir şey yapmam gerekmedi, bu yüzden sadece matematiği açıklayan diğer makaleleri gözden kaçırdım.
Peter Cordes

2
Kodda modüler aritmetik ile ilgili bir şey görmüyorum. Diğer yorumcuların bunu nereden aldığını bilmiyorum.
plugwash

3
Modulo 2 ^ n, bir kayıttaki tüm tamsayı matematik gibi. en.wikipedia.org/wiki/…
Peter Cordes

4
@PeterCordes modüler çarpım tersleri tam bölme için kullanılır, afaik genel bölme için yararlı değildir
harold

4
@PeterCordes sabit noktalı karşılıklı çarpma? Herkesin ne dediğini bilmiyorum ama muhtemelen bunu tanımlayacağım, oldukça açıklayıcı
harold

12

Burada, Visual Studio ile gördüğüm değerleri ve kodu üreten bir algoritmanın belgesine bağlantı (çoğu durumda) ve hala bir değişken tamsayının sabit bir tamsayı ile bölünmesi için GCC'de kullanıldığını varsayıyorum.

http://gmplib.org/~tege/divcnst-pldi94.pdf

Makalede, bir uword'de N bitleri vardır, bir udword'de 2N bitleri vardır, n = pay = temettü, d = payda = bölen, ℓ başlangıçta tavana (log2 (d)) ayarlanmıştır, shpre önceden kaydırılır (çarpmadan önce kullanılır) ) = e = d cinsinden sondaki sıfır bit sayısı, shpost kaydırma sonrası (çarpma işleminden sonra kullanılır), prec kesinlik = N - e = N - shpre'dir. Amaç, vardiya öncesi, çarpma ve vardiya sonrası n / d hesaplamasını optimize etmektir.

Bir udword çarpanının (maksimum boyut N + 1 bittir) nasıl oluşturulduğunu tanımlayan, ancak işlemi açıkça açıklamayan şekil 6.2'ye gidin. Bunu aşağıda açıklayacağım.

Şekil 4.2 ve şekil 6.2, çarpanların çoğu için çarpanın N bit veya daha az çarpanına nasıl indirgenebileceğini göstermektedir. Denklem 4.5, şekil 4.1 ve 4.2'deki N + 1 bit çarpanlarıyla uğraşmak için kullanılan formülün nasıl türetildiğini açıklar.

Modern X86 ve diğer işlemciler durumunda, çarpma süresi sabittir, bu nedenle ön kaydırma bu işlemcilerde yardımcı olmaz, ancak yine de çarpanı N + 1 bitlerinden N bitlerine indirmeye yardımcı olur. GCC veya Visual Studio'nun X86 hedefleri için ön kaydırmayı ortadan kaldırıp kaldırmadığını bilmiyorum.

Şekil 6.2'ye dönme. Mlow ve mhigh için pay (temettü) yalnızca bir paydadan daha büyük olabilir (payda (bölen)> 2 ^ (N-1) (ℓ == N => mlow = 2 ^ (2N)), bu durumda n / d için optimize edilmiş değiştirme bir karşılaştırmadır (n> = d, q = 1, başka q = 0 ise), bu nedenle çarpan oluşturulmaz. Mlow ve mhigh'in başlangıç ​​değerleri N + 1 bit olacaktır ve her N + 1 bit değerini (mlow veya mhigh) üretmek için iki udword / uword bölmesi kullanılabilir. Örnek olarak X86'yı 64 bit modunda kullanma:

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

Bunu GCC ile test edebilirsiniz. J = i / 5'in nasıl ele alındığını zaten gördünüz. J = i / 7'nin nasıl ele alındığına bir göz atın (N + 1 bit çarpan durumu olmalıdır).

Mevcut işlemcilerin çoğunda, çarpma işleminin sabit bir zamanlaması vardır, bu nedenle bir ön kaydırma gerekmez. X86 için, sonuç çoğu bölen için iki komut dizisi ve 7 gibi bölücüler için beş komut dizisidir (denklem 4.5 ve pdf dosyasının şekil 4.2'sinde gösterildiği gibi bir N + 1 bit çarpanı taklit etmek için). Örnek X86-64 kodu:

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...

Bu makale gcc'de uygulamayı açıklıyor, bu yüzden aynı algo'nun hala kullanıldığı güvenli bir varsayım.
Peter Cordes

1994 tarihli bu makale gcc'de uygulanmasını açıklıyor, bu nedenle gcc'nin algoritmasını güncellemesi için zaman vardı. Diğerlerinin bu URL'deki 94’ün ne anlama geldiğini kontrol etmek için zamanları olmaması durumunda
Ed Grimm

0

Ben biraz farklı bir açıdan cevaplayacağım: Çünkü buna izin verilir.

C ve C ++ soyut bir makineye karşı tanımlanmıştır. Derleyici bu programı soyut makine açısından as-if kuralına göre beton makinesine dönüştürür .

  • Derleyicinin, soyut makine tarafından belirtilen gözlemlenebilir davranışı değiştirmediği sürece HERHANGİ değişiklik yapmasına izin verilir. Derleyicinin kodunuzu mümkün olan en basit şekilde dönüştüreceği konusunda makul bir beklenti yoktur (birçok C programcısı bunu varsaydığında bile). Genellikle bunu yapar, çünkü derleyici performansı (doğrudan diğer cevaplarda tartışıldığı gibi) doğrudan yaklaşıma göre optimize etmek ister.
  • Herhangi bir koşulda derleyici farklı bir gözlemlenebilir davranışı olan bir şey için doğru bir programı "optimize" ederse, bu bir derleyici hatasıdır.
  • Kodumuzdaki tanımlanmamış davranışlar (işaretli tamsayı taşması klasik bir örnektir) ve bu sözleşme geçersizdir.
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.