32 bit döngü sayacının 64 bit ile değiştirilmesi, Intel CPU'larda _mm_popcnt_u64 ile çılgın performans sapmaları sunar


1424

popcountBüyük veri dizilerine giden en hızlı yolu arıyordum . Ben karşılaştı çok garip bir etkisi: den döngü değişkeni değiştirme unsignediçin uint64_tbenim PC'de% 50 tarafından yapılan performans düşüşü.

Kıyaslamak

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

Gördüğünüz gibi, rastgele bir arabellek oluşturuyoruz, boyutu komut satırından okunan xmegabayt x. Daha sonra, arabellek üzerinde tekrarlar popcountve popcount gerçekleştirmek için x86 intrinsic bir unrolls sürümünü kullanırız. Daha kesin bir sonuç elde etmek için popcount'u 10.000 kez yapıyoruz. Popcount zamanlarını ölçüyoruz. Büyük harf, iç döngü değişkeni, unsignedküçük harf iç döngü değişkendir uint64_t. Bunun bir fark yaratmaması gerektiğini düşündüm, ama tam tersi.

(Kesinlikle çılgın) sonuçlar

Böyle derlemek (g ++ sürümü: Ubuntu 4.8.2-19ubuntu1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

İşte Haswell Core i7-4770K CPU'mun @ 3.50 GHz'deki sonuçları test 1(1 MB rasgele veri):

  • imzasız 41959360000 0.401554 sn 26.113 GB / s
  • uint64_t 41959360000 0.759822 sn 13.8003 GB / s

Gördüğünüz gibi, verimi uint64_tsürümü olan sadece yarısı biri unsignedsürümü! Sorun şu ki, farklı montaj üretiliyor, ama neden? İlk olarak, bir derleyici hata düşündüm, bu yüzden denedim clang++(Ubuntu Clang sürüm 3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

Sonuç: test 1

  • imzasız 41959360000 0,3998293 sn 26,3267 GB / s
  • uint64_t 41959360000 0.680954 saniye 15.3986 GB / s

Yani, neredeyse aynı sonuç ve hala garip. Ama şimdi çok garipleşiyor. Ben bir sabit girdi ile okunan arabellek boyutunu değiştirin 1, bu yüzden değiştirmek:

uint64_t size = atol(argv[1]) << 20;

için

uint64_t size = 1 << 20;

Böylece, derleyici derleme zamanında tampon boyutunu bilir. Belki bazı optimizasyonlar ekleyebilir! İşte numaraları g++:

  • imzasız 41959360000 0.509156 sn 20.5944 GB / s
  • uint64_t 41959360000 0.508673 sn 20.6139 GB / s

Şimdi, her iki versiyon da eşit derecede hızlı. Ancak, unsigned daha da yavaş var ! Bu bırakılan 26için 20 GB/sböylece sabit bir değer kurşun olmayan bir sabit yerini deoptimization . Cidden, burada neler olduğuna dair hiçbir fikrim yok! Ama şimdi clang++yeni sürümle:

  • imzasız 41959360000 0.677009 sn 15.4884 GB / s
  • uint64_t 41959360000 0.676909 saniye 15.4906 GB / s

Bir dakika ne? Şimdi, her iki sürüm de 15 GB / sn'lik yavaş sayıya düştü . Böylece, sabit olmayan bir değeri sabit bir değerle değiştirmek her iki durumda da Clang için yavaş koda yol açar !

Ivy Bridge CPU'lu bir meslektaşımdan karşılaştırmamı derlemesini istedim. Benzer sonuçlar aldı, bu yüzden Haswell gibi görünmüyor. İki derleyici burada garip sonuçlar ürettiğinden, aynı zamanda bir derleyici hatası gibi görünmüyor. Burada bir AMD CPU'muz yok, bu yüzden sadece Intel ile test yapabildik.

Daha fazla delilik, lütfen!

İlk örneği (ile olanı atol(argv[1])) alın ve staticdeğişkenin önüne a koyun , yani:

static uint64_t size=atol(argv[1])<<20;

İşte g ++ sonuçları:

  • imzasız 41959360000 0.396728 sn 26.4306 GB / s
  • uint64_t 41959360000 0.509484 saniye 20.5811 GB / s

Yay, başka bir alternatif . Hala 26 GB / s'lik hızlı bir hıza sahibiz u32, ancak u64en azından 13 GB / s'den 20 GB / s sürümüne geçmeyi başardık ! Meslektaşımın bilgisayarında, u64sürüm versiyondan daha hızlı hale geldi u32ve en hızlı sonucu verdi. Ne yazık ki, bu sadece işe yarıyor g++, clang++umurumda değil gibi görünüyor static.

Benim sorum

Bu sonuçları açıklayabilir misiniz? Özellikle:

  • Nasıl arasında böyle bir fark var olabilir u32ve u64?
  • Sabit olmayan bir aralığın sabit bir arabellek boyutuyla değiştirilmesi daha az optimum kodu nasıl tetikleyebilir ?
  • staticAnahtar kelimenin eklenmesi u64döngüyü nasıl daha hızlı hale getirebilir ? Meslektaşımın bilgisayarındaki orijinal koddan daha hızlı!

Optimizasyonun zor bir bölge olduğunu biliyorum, ancak böyle küçük değişikliklerin yürütme süresinde % 100 fark yaratabileceğini ve sabit bir tampon boyutu gibi küçük faktörlerin sonuçları tekrar tamamen karıştırabileceğini asla düşünmedim . Tabii ki, her zaman 26 GB / s popcount edebilen sürüme sahip olmak istiyorum. Düşünebildiğim tek güvenilir yol, bu kasa için kopyala yapıştırma tertibatını ve satır içi montajı kullanmaktır. Küçük değişikliklere deli gibi görünen derleyicilerden kurtulmanın tek yolu budur. Ne düşünüyorsun? Kodu en yüksek performansla güvenilir bir şekilde almanın başka bir yolu var mı?

Demontaj

İşte çeşitli sonuçlar için sökme:

G ++ / u32 / sabit olmayan bufsize'den 26 GB / s sürüm :

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

G ++ / u64 / sabit olmayan bufsize'den 13 GB / s sürümü :

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

Clang ++ / u64 / sabit olmayan bufsize'den 15 GB / s sürüm :

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

G ++ / u32 & u64 / const bufsize'den 20 GB / s sürüm :

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

Clang ++ / u32 & u64 / const bufsize sürümünden 15 GB / sn sürümü :

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

İlginçtir, en hızlı (26 GB / s) sürüm de en uzundur! Kullanan tek çözüm gibi görünüyor lea. Bazı sürümler jbatlamak için, bazıları ise atlamak için kullanılır jne. Ancak bunun dışında tüm sürümler karşılaştırılabilir gibi görünüyor. % 100 performans boşluğunun nereden kaynaklanabileceğini görmüyorum, ancak meclisi deşifre etme konusunda çok becerikli değilim. En yavaş (13 GB / s) sürüm çok kısa ve iyi görünüyor. Bunu açıklayan var mı?

Dersler öğrenildi

Bu sorunun cevabı ne olursa olsun; Gerçekten sıcak döngülerde her detayın önemli olduğunu öğrendim , hatta sıcak kodla herhangi bir ilişkisi yok gibi görünen detaylar bile . Bir döngü değişkeni için ne tür kullanacağımı hiç düşünmedim, ancak gördüğünüz gibi küçük bir değişiklik % 100 fark yaratabilir! staticAnahtar kelimenin boyut değişkeninin önüne eklenmesiyle gördüğümüz gibi, bir arabellek depolama türü bile büyük bir fark yaratabilir ! Gelecekte, sistem performansı için çok önemli olan çok sıkı ve sıcak döngüler yazarken her zaman çeşitli derleyiciler üzerinde çeşitli alternatifleri test edeceğim.

İlginç olan şey, döngüyü dört kez açmış olmama rağmen performans farkının hala çok yüksek olması. Böylece, kilidini açsanız bile, yine de büyük performans sapmalarından etkilenebilirsiniz. Oldukça ilginç.


8
PEKİ BİRÇOK YORUM! Şunları yapabilirsiniz bunları chat görüntülemek isterseniz kendi, ama artık burada orada eklemeyin terk hatta ve!
Shog9

3
Ayrıca bkz. GCC Sayı 62011, popcnt komutunda Yanlış Veri Bağımlılığı . Başka biri bunu sağladı, ancak temizlik sırasında kaybolmuş gibi görünüyor.
jww

Söyleyemem ama statik olan sürüm için demontajlardan biri mi? Değilse, yayını düzenleyebilir ve ekleyebilir misiniz?
Kelly

Yanıtlar:


1552

Culprit: Yanlış Veri Bağımlılığı (ve derleyici bunun farkında bile değil)

Sandy / Ivy Bridge ve Haswell işlemcilerinde talimat:

popcnt  src, dest

Hedef kayıt defterine yanlış bağımlılık olduğu görülüyor dest. Talimat sadece ona yazsa bile, destçalıştırmadan önce talimat hazır olana kadar bekleyecektir . Bu yanlış bağımlılık (şimdi) Intel tarafından erratum HSD146 (Haswell) ve SKL029 (Skylake ) olarak belgelenmiştir.

Skylake bunu lzcntve için düzelttitzcnt .
Cannon Gölü (ve Buz Gölü) bunu düzeltti popcnt.
bsf/ bsrgerçek çıkış bağımlılığına sahip: giriş = 0 için değiştirilmemiş çıkış. (Ancak , intrinsics ile bundan yararlanmanın bir yolu yoktur - yalnızca AMD belgeleri ve derleyicileri ortaya çıkarmaz.)

(Evet, bu talimatların tümü aynı yürütme biriminde çalışır ).


Bu bağımlılık 4 popcntsaniyeyi tek bir döngü yinelemesinden ibaret değildir . İşlemcinin farklı döngü yinelemelerini paralel hale getirmesini imkansız hale getirerek döngü yinelemeleri arasında geçiş yapabilir.

unsignedVs uint64_tve diğer ince ayarlar doğrudan sorunu etkilemez. Ancak kayıtları değişkenleri atayan kayıt ayırıcıyı etkiler.

Sizin durumunuzda, hızlar, kayıt ayırıcısının ne yapmaya karar verdiğine bağlı olarak (yanlış) bağımlılık zincirine yapışan şeyin doğrudan bir sonucudur.

  • 13 GB / s'lik bir zincir var: popcnt- add- popcnt- popcnt→ sonraki yineleme
  • 15 GB / s'lik bir zincir var: popcnt- add- popcnt- add→ sonraki yineleme
  • 20 GB / s'lik bir zincir var: popcnt- popcnt→ sonraki yineleme
  • 26 GB / s'lik bir zincir var: popcnt- popcnt→ sonraki aşama

20 GB / s ve 26 GB / s arasındaki fark, dolaylı adreslemenin küçük bir artefaktı gibi görünüyor. Her iki durumda da, bu hıza ulaştığınızda işlemci diğer darboğazlara çarpmaya başlar.


Bunu test etmek için, derleyiciyi atlamak ve tam olarak istediğim montajı almak için satır içi montaj kullandım. Ben de countdeğişkenleri, ölçütlerle karışabilecek diğer tüm bağımlılıkları kırmak için ayırdım.

Sonuçlar burada:

Sandy Bridge Xeon @ 3.5 GHz: (tam test kodu altta bulunabilir)

  • GCC 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

Farklı Kayıtlar: 18.6195 GB / s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

Aynı Kayıt: 8.49272 GB / s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

Kırık zincir ile aynı Kayıt: 17.8869 GB / s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

Peki derleyici ile ilgili sorun nedir?

Görünüşe göre ne GCC ne de Visual Studio popcntböyle yanlış bir bağımlılığa sahip olduğunun farkında değil . Bununla birlikte, bu yanlış bağımlılıklar nadir değildir. Sadece derleyicinin farkında olup olmadığı meselesi.

popcnttam olarak en çok kullanılan talimat değildir. Bu nedenle, büyük bir derleyicinin böyle bir şeyi özleyebilmesi gerçekten sürpriz değil. Ayrıca, bu sorundan bahseden hiçbir yerde belge yoktur. Intel bunu ifşa etmezse, dışarıdaki hiç kimse tesadüfen karşılaşana kadar bilemez.

( Güncelleme: 4.9.2 sürümünden itibaren , GCC bu yanlış bağımlılığın farkındadır ve optimizasyonlar etkinleştirildiğinde bunu telafi etmek için kod üretir. Clang, MSVC ve hatta Intel'in kendi ICC'si gibi diğer satıcıların büyük derleyicileri henüz farkında değildir. Bu mikro mimari erratum ve onu telafi eden kod yayınlamayacaktır.)

CPU neden bu kadar yanlış bağımlılığa sahip?

Biz spekülasyon olabilir: o kadar aynı yürütme birimi üzerinde çalışır bsf/ do bir çıkış bağımlılığı var. ( POPCNT donanımda nasıl uygulanır? ). Bu talimatlar için Intel, input = 0 için tamsayı sonucunu "undefined" (ZF = 1 ile) olarak belgelendirir, ancak Intel donanımı aslında eski yazılımı bozmamak için daha güçlü bir garanti verir: çıktı değiştirilmedi. AMD bu davranışı belgelemektedir.bsr

Muhtemelen bu yürütme birimi için bazı uopsların çıktıya bağımlı olmasını sağlamak, ancak bazılarını yapmak bir şekilde sakıncalıdır.

AMD işlemcilerin bu yanlış bağımlılığa sahip oldukları görülmemektedir.


Test kodunun tamamı referans olarak aşağıdadır:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

Aynı derecede ilginç bir karşılaştırma ölçütü burada bulunabilir: http://pastebin.com/kbzgL8si
Bu karşılaştırma ölçütü popcnt(yanlış) bağımlılık zincirindeki s sayısını değiştirir .

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s

3
Merhaba millet! Burada birçok geçmiş yorum; yeni bir tane bırakmadan önce lütfen arşivi inceleyin .
Shog9

1
@ JustinL.it bu konu Clang'da 7.0'dan itibaren düzeltilmiş gibi görünüyor
Dan M.

@PeterCordes Programlayıcı kadar yürütme birimi olduğunu sanmıyorum. Bağımlılıkları izleyen zamanlayıcıdır. Bunu yapmak için, talimatlar, her biri programlayıcı tarafından aynı şekilde işlenen bir dizi "talimat sınıfı" olarak gruplandırılır. Böylece, tüm 3-döngülü "yavaş-int" komutları, komut çizelgeleme amacıyla aynı "sınıf" a atıldı.
Mysticial

@Mysticial: Hala böyle mi düşünüyorsun? Bu mantıklı, ancak imul dst, src, immçıktı bağımlılığı yok ve ikisi de yavaş değil lea. Ne de pdep, ama bu 2 giriş işlenen ile kodlanmış VEX. Yanlış depresyona neden olan yürütme biriminin kendisi değil ; Bu, mimari kayıt işlenenlerini fiziksel kayıtlar olarak yeniden adlandırdığı için RAT ve sayı / yeniden adlandırma aşamasına bağlıdır. Muhtemelen bir uop-code -> bağımlılık modeli ve port seçimleri tablosu gerektirir ve aynı yürütme birimi için tüm uopsları birlikte gruplandırmak o tabloyu basitleştirir. Daha ayrıntılı olarak anlatmak istediğim buydu.
Peter Cordes

Cevabınızı düzenlememi ister misiniz, yoksa programlayıcı hakkında ilk başta söyledikleriniz gibi bir şeyi söylemeye geri koymak isterseniz. SKL'nin lzcnt / tzcnt için yanlış dep düşürdüğü, ancak popcnt olmadığı gerçeği bize bir şey söylemeli, ama IDK ne. Yeniden adlandır / RAT ile ilgili başka bir olası işaret, SKL'nin dizinlenmiş bir adresleme modunu lzcnt / tzcnt için bir bellek kaynağı olarak ayırmasıdır, ancak popcnt değildir. Açıkçası, yeniden adlandırma birimi, arka ucun temsil edebileceği uops oluşturmak zorunda.
Peter Cordes

50

Denemek için eşdeğer bir C programı kodladım ve bu garip davranışı onaylayabilirim. Dahası, gcc'nin 64 bitlik bir uint kullanmasına neden olduğu gcciçin 64 bit tamsayının (muhtemelen size_ther durumda olması gerekir ...) daha iyi olduğuna inanıyor uint_fast32_t.

Meclis ile biraz mucking yaptım:
Sadece 32-bit versiyonunu alın, 32-bit talimatları / kayıtları programın iç pop-döngüsünde 64-bit versiyonuyla değiştirin. Gözlem: Kod 32-bit sürümü kadar hızlı!

Programın diğer bölümleri hala 32 bit sürümü kullandığından, değişkenin boyutu gerçekten 64 bit olmadığı için bu bir hack'tir, ancak iç popcount döngüsü performansa hakim olduğu sürece, bu iyi bir başlangıçtır .

Daha sonra programın 32-bit sürümünden iç döngü kodunu kopyaladım, 64-bit'e hackledim, 64-bit sürümün iç döngüsünün yerini almak için kayıtlarla uğraştım. Bu kod aynı zamanda 32 bit sürüm kadar hızlı çalışır.

Benim sonucum, bunun 32-bit komutların gerçek hız / gecikme avantajı değil, derleyici tarafından kötü komut zamanlaması olmasıdır.

(Dikkat: Meclisi hackledim, fark etmeden bir şeyleri kırabilirdim. Sanmıyorum.)


1
“Dahası, gcc 64-bit tamsayının […] daha iyi olduğuna inanıyor, çünkü uint_fast32_t kullanmak gcc'nin 64-bit uint kullanmasına neden oluyor.” Ne yazık ki ve pişmanım, bu türlerin arkasında sihir ve derin kod içgözlemi yoktur. Bunların henüz tüm olası yerler ve tüm platformdaki her program için tek tip tanımlarından başka bir yol sunduğunu görmedim. Kesin tip seçiminin arkasında oldukça fazla düşünülmüş olabilir, ancak her biri için tek bir tanım, her uygulama için muhtemelen uygun olmayabilir. Daha fazla okuma: stackoverflow.com/q/4116297 .
Keno

2
@Keno Çünkü sizeof(uint_fast32_t)tanımlanması gerekiyor. Olmamasına izin verirseniz, bu hileyi yapabilirsiniz, ancak bu sadece bir derleyici uzantısı ile gerçekleştirilebilir.
wizzwizz4

25

Bu bir cevap değil, ancak sonuçları yorum yaparsam okumak zor.

Bu sonuçları bir Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz) ile elde ediyorum . clang -O3 -msse4 -lstdc++ a.cpp -o a(-O2 aynı sonucu alır) ile derledim .

ile konuşmak uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

ile konuşmak uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

Ben de denedim:

  1. Test sırasını tersine çevirin, sonuç aynıdır, böylece önbellek faktörünü dışlar.
  2. Var fortersten deyimini: for (uint64_t i=size/8;i>0;i-=4). Bu aynı sonucu verir ve derlemenin boyutu her yinelemede 8'e (beklendiği gibi) bölmeyecek kadar akıllı olduğunu kanıtlar.

İşte benim vahşi tahminim:

Hız faktörü üç bölümden oluşur:

  • kod önbelleği: uint64_tsürüm daha büyük kod boyutuna sahiptir, ancak bunun Xeon CPU'um üzerinde bir etkisi yoktur. Bu, 64 bit sürümünü yavaşlatır.

  • Kullanılan talimatlar. Sadece döngü sayısına değil, arabellek iki sürümde 32 bit ve 64 bit dizinle erişildiğine dikkat edin. Bir 64-bit ofset ile bir işaretçiye erişmek, özel bir 64-bit kayıt ve adresleme gerektirirken, 32-bit ofset için hemen kullanabilirsiniz. Bu, 32 bit sürümü daha hızlı hale getirebilir.

  • Talimatlar yalnızca 64 bit derlemesinde (yani önceden getirme) yayımlanır. Bu, 64 bit'i daha hızlı hale getirir.

Üç faktör birlikte gözlenen görünüşte çelişkili sonuçlarla eşleşir.


4
İlginç, derleyici sürümü ve derleyici bayrakları ekleyebilir misiniz? En iyi şey, makinenizde sonuçların ters çevrilmesidir, yani u64 kullanmak daha hızlıdır . Şimdiye kadar, döngü değişkenimin hangi tipte olduğunu hiç düşünmedim, ancak bir dahaki sefere iki kez düşünmek zorundayım :).
gexicide

2
@gexicide: 16.8201'den 16.8126'ya atlamayı "daha hızlı" yapmam.
user541686

2
@Mehrdad: Yani atlama arasındaki biridir 12.9ve 16.8bu nedenle, unsigneddaha hızlı burada. Kıyaslamamda bunun tersi, yani 26 için unsigned, 15 içinuint64_t
gexicide

@gexicide Buffer [i] adresleme farkını fark ettiniz mi?
Maskelenemez Kesme

@Calvin: Hayır, ne demek istiyorsun?
gexicide

10

Yetkili bir cevap veremiyorum, ancak olası bir nedene genel bir bakış sunuyorum. Bu referans , döngünüzün gövdesindeki talimatlar için gecikme ve iş hacmi arasında 3: 1 oranın olduğunu açıkça göstermektedir. Aynı zamanda çoklu sevkıyatın etkilerini de gösterir. Modern x86 işlemcilerde üç tamsayı birimi olduğu için (verme veya alma), genellikle döngü başına üç komut göndermek mümkündür.

Dolayısıyla, pik boru hattı ile çoklu dağıtım performansı ve bu mekanizmaların başarısızlığı arasında performansta altı faktöre sahibiz. X86 komut setinin karmaşıklığının, ilginç kırılmanın gerçekleşmesini oldukça kolay hale getirdiği oldukça iyi bilinmektedir. Yukarıdaki belgenin harika bir örneği var:

64-bit sağ vardiyalar için Pentium 4 performansı gerçekten kötü. 64 bitlik sola kaydırma ve 32 bitlik tüm vardiyalar kabul edilebilir performansa sahiptir. Görünüşe göre, üst 32 bitten ALU'nun alt 32 bitine kadar olan veri yolu iyi tasarlanmamıştır.

Kişisel olarak, bir sıcak döngünün dört çekirdekli bir çipin belirli bir çekirdeğinde oldukça yavaş çalıştığı garip bir durumla karşılaştım (hatırlıyorsam AMD). Aslında bu çekirdeği kapatarak harita azaltma hesaplamasında daha iyi performans elde ettik.

Burada tahminim tamsayı birimleri için çekişme: popcntdöngü sayacı ve adres hesaplamaları 32 bit genişlik sayacıyla neredeyse hiç tam hızda çalışamaz, ancak 64 bit sayaç çekişme ve boru hattı duraklarına neden olur. Döngü gövdesi yürütmesi başına toplamda yaklaşık 12 döngü, birden fazla gönderime sahip potansiyel olarak 4 döngü olduğundan, tek bir durak çalışma süresini 2 faktörle makul şekilde etkileyebilir.

Statik bir değişken kullanılarak indüklenen değişiklik, sadece küçük bir talimat sırasına neden olduğunu tahmin ediyorum, 32 bit kodun çekişme için bazı devrilme noktasında olduğu başka bir ipucu.

Bu titiz analiz olmadığını biliyoruz, ama bu olan makul bir açıklama.


2
Ne yazık ki, (Core 2?) 'Den bu yana, bu kodda bulunmayan çarpma / bölme dışında 32 bit ve 64 bit tam sayı işlemleri arasında neredeyse hiçbir performans farkı yoktur.
Mysticial

@Gene: Tüm sürümlerin boyutu bir kayıt defterinde sakladığını ve döngüdeki yığından asla okumadığını unutmayın. Dolayısıyla, adres hesaplaması karışımda olamaz, en azından döngü içinde olamaz.
gexicide

@Gene: Gerçekten ilginç bir açıklama! Ancak ana WTF noktalarını açıklamıyor: Boru hattının durması nedeniyle 64bit'in 32bit'ten daha yavaş olması bir şey. Ancak durum buysa, 64 bit sürümü 32 bit sürümünden güvenilir bir şekilde yavaş olmamalı mı? Bunun yerine, derleme zamanı sabiti arabellek boyutu kullanılırken 32 bit sürümü için bile üç farklı derleyici yavaş kod yayar; arabellek boyutunu tekrar statik olarak değiştirmek işleri tamamen değiştirir. 64bit versiyonunun çok daha hızlı olduğu meslektaşlarımın makinesinde (ve Calvin'in cevabında) bir dava bile vardı! Kesinlikle öngörülemez gibi görünüyor ..
gexicide

@Mysticial Bu benim açımdan. IU, veri yolu süresi vb. İçin sıfır çekişme olduğunda en yüksek performans farkı yoktur. Referans açıkça göstermektedir. Süreklilik her şeyi farklı kılar. İşte Intel Core literatüründen bir örnek: "Tasarımda yer alan yeni bir teknoloji, iki x86 talimatını tek bir mikro işlemde birleştiren Macro-Ops Fusion. Örneğin, karşılaştırma gibi bir koşullu kod ve koşullu atlama maalesef bu teknoloji 64 bit modunda çalışmıyor. " Yani yürütme hızında 2: 1 oranımız var.
Gene

@gexicide Ne dediğini anlıyorum, ama kastettiğimden daha fazla çıkarım yapıyorsun. En hızlı çalışan kod boru hattı ve gönderme kuyrukları tam tutuyor diyorum. Bu durum kırılgandır. Toplam veri akışına 32 bit eklemek ve komutların yeniden sıralanması gibi küçük değişiklikler bunu kırmak için yeterlidir. Kısacası, OP'nin ilerlemenin ve test etmenin tek yol olduğu iddiası doğrudur.
Gene

10

Bu işlemi biraz hızlandıran bir dizin yerine bir işaretçi kullanarak Visual Studio 2013 Express ile denedim . Bu adresleme ofset + register + (kayıt << 3) yerine ofset + kayıt olduğundan şüpheleniyorum. C ++ kodu.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

montaj kodu: r10 = bfrptr, r15 = bfrend, rsi = sayım, rdi = tampon, r13 = k:

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main

9

GCC'ye geçmeyi denediniz -funroll-loops -fprefetch-loop-arraysmi?

Bu ek optimizasyonlarla aşağıdaki sonuçları elde ediyorum:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s

3
Ancak yine de, sonuçların tamamen garip (önce imzasız, daha sonra uint64_t daha hızlı), çünkü unrolling yanlış bağımlılığın ana sorununu çözmez.
gexicide

7

İndirgeme adımını döngü dışına taşımayı denediniz mi? Şu anda gerçekten gerekli olmayan bir veri bağımlılığınız var.

Deneyin:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

Ayrıca bazı garip takma adlarınız var, katı takma adlandırma kurallarına uygun olduğundan emin değilim.


2
Soruyu okuduktan sonra yaptığım ilk şey buydu. Bağımlılık zincirini kırın. Sonuç olarak, performans farkı değişmiyor (en azından bilgisayarımda - GCC 4.7.3 ile Intel Haswell).
Nils Pipenbrinck

1
@BenVoigt: Sıkı takma adlara uygundur. void*ve char*esasen "hafızanın bir kısmına işaretçiler" olarak kabul edildiklerinden, diğer adlara ayrılabilecek iki türdür! Veri bağımlılığının kaldırılmasıyla ilgili fikriniz optimizasyon için iyidir, ancak soruyu cevaplamaz. Ve @NilsPipenbrinck'in dediği gibi, hiçbir şeyi değiştirmiyor gibi görünüyor.
gexicide

@gexicide: Sıkı örtüşme kuralı simetrik değildir. A char*erişmek için kullanabilirsiniz T[]. Sen olamaz güvenle kullanabilir T*bir erişmek için char[], ve kod ikincisi yapılacak görünür.
Ben Voigt

@BenVoigt: O zaman mallocmalloc geri döndükçe void*ve hiçbir şeyi yorumlayamadığınız bir şeyden asla kurtulamazsınız T[]. Bundan eminim void*ve char*katı takma adlandırma konusunda aynı semantiğe sahiptiler. Ancak, bu oldukça offtopic sanırım :)
gexicide 19

1
Şahsen ben doğru yolu düşünüyorumuint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Ben Voigt

6

TL; DR: __builtinBunun yerine intrinsics kullanın; yardım edebilirler.

Yapmak başardı gcc4.8.4 (ve gcc.godbolt.org hatta 4.7.3) kullanarak bunun için en uygun kodu oluşturmak __builtin_popcountllaynı montaj talimatı kullanır, ama şanslı alır ve beklenmedik bir yok kod yapmak olur yanlış bağımlılık hatası nedeniyle uzun döngü taşınan bağımlılık.

Kıyaslama kodumdan% 100 emin değilim, ancak objdumpçıktı görüşlerimi paylaşıyor gibi görünüyor. Herhangi bir talimat olmadan derleyici unroll döngüsünü yapmak için başka hileler ( ++ivs i++) kullanıyorum movl(garip davranış, söylemeliyim).

Sonuçlar:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

Karşılaştırma kodu:

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

Derleme seçenekleri:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

GCC sürümü:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Linux çekirdek sürümü:

3.19.0-58-generic

CPU bilgileri:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:

3
'Yanlış dep -funroll-loopstarafından oluşturulan bir döngü taşınan bağımlılık zinciri üzerinde darboğaz olmayan kod yapmak için sadece iyi şanslar popcnt. Yanlış bağımlılığı bilmeyen eski bir derleyici sürümü kullanmak bir risktir. Olmadan -funroll-loops, gcc 4.8.5'in döngüsü, sayım yerine popcnt gecikmesinde darboğaz oluşturacaktır, çünkü sayılırrdx . Gcc 4.9.3 tarafından derlenen aynı kod, xor edx,edxbağımlılık zincirini kırmak için bir ekler .
Peter Cordes

3
Eski derleyicilerle, kodunuz OP'nin yaşadığı tam olarak aynı performans varyasyonuna karşı savunmasız olacaktır: görünüşte önemsiz değişiklikler, bir soruna neden olacağına dair hiçbir fikri olmadığı için gcc'yi yavaşlatabilir. Eski bir derleyicide bir durumda işe yarayacak bir şey bulmak soru değil .
Peter Cordes

2
Kayıt için, x86intrin.h'ın _mm_popcnt_*GCC fonksiyonlar zorla etrafında sarmalayıcılarını inlined__builtin_popcount* ; satır içi, biri diğerine tam olarak eşit olmalıdır. Aralarında geçiş yaparak oluşabilecek herhangi bir fark göreceğinizden şüpheliyim.
ShadowRanger

-2

Her şeyden önce, en yüksek performansı tahmin etmeye çalışın - https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf , özellikle Ek C.

Sizin durumunuzda, POPCNT komutunun gecikme süresi = 3 saat ve verim = 1 saat olduğunu gösteren tablo C-10. Verim, saatlerde maksimum oranınızı gösterir (mümkün olan en iyi bant genişliği numaranızı elde etmek için popcnt64 olması durumunda çekirdek frekansı ve 8 bayt ile çarpın).

Şimdi derleyicinin ne yaptığını inceleyin ve döngüdeki diğer tüm talimatların verimlerini toplayın. Bu oluşturulan kod için mümkün olan en iyi tahmini verecektir.

Sonunda, döngü yerine talimatlar arasındaki veri bağımlılıklarına bakın, çünkü verim yerine gecikme-büyük gecikmeyi zorlarlar - bu nedenle veri akış zincirleri üzerinde tek yinelemenin bölünmüş talimatları bölün ve bunlar arasındaki gecikmeyi hesaplayın ve onlardan saf olarak alın. veri akışı bağımlılıkları dikkate alınarak kaba bir tahmin verecektir.

Ancak, sizin durumunuzda, kodu doğru şekilde yazmak tüm bu karmaşıklıkları ortadan kaldıracaktır. Aynı sayım değişkenine biriktirmek yerine, farklı olanlara biriktirin (count0, count1, ... count8 gibi) ve sonunda onları toplayın. Ya da hatta bir dizi sayım [8] oluşturun ve elemanlarına birikin - belki de vektörleştirilecek ve daha iyi bir verim elde edeceksiniz.

PS ve asla bir saniye boyunca kıyaslama yapmayın, önce çekirdeği ısıtın, daha sonra döngüyü en az 10 saniye veya daha iyi 100 saniye çalıştırın. Aksi takdirde, donanımdaki güç yönetimi ürün yazılımını ve DVFS uygulamasını test edersiniz :)

PPS Benchmarkın gerçekten ne kadar sürmesi gerektiğine dair sonsuz tartışmalar duydum. En zeki insanlar neden 10 saniye 11 veya 12 değil neden soruyorlar. Pratikte, sadece arka arkaya yüzlerce kez kıyaslama yapar ve sapmaları kaydedersiniz. Yani IS komik. Çoğu insan, yeni performans rekoru yakalamak için tam olarak ONCE'den sonra kaynağı değiştirir ve tezgah çalıştırır. Doğru şeyleri doğru yapın.

Hala ikna olmadınız mı? Sadece benchmark C'nin yukarıdaki versiyonunu assp1r1n3 ile kullanın ( https://stackoverflow.com/a/37026212/9706746 ) ve yeniden deneme döngüsünde 10000 yerine 100'ü deneyin.

RETRY = 100 ile 7960X gösterilerim:

Sayı: 203182300 Geçen: 0.008385 saniye Hız: 12.505379 GB / s

Sayı: 203182300 Geçen: 0.011063 saniye Hız: 9.478225 GB / s

Sayı: 203182300 Geçen: 0.011188 saniye Hız: 9.372327 GB / s

Sayı: 203182300 Geçen: 0.010393 saniye Hız: 10.089252 GB / s

Sayı: 203182300 Geçen: 0.009076 saniye Hız: 11.553283 GB / s

RETRY ile = 10000:

Sayı: 20318230000 Geçen: 0.661791 saniye Hız: 15.844519 GB / s

Sayı: 20318230000 Geçen: 0.665422 saniye Hız: 15.758060 GB / s

Sayı: 20318230000 Geçen: 0.660983 saniye Hız: 15.863888 GB / s

Sayı: 20318230000 Geçen: 0.665337 saniye Hız: 15.760073 GB / s

Sayı: 20318230000 Geçen: 0.662138 saniye Hız: 15.836215 GB / s

PPPS Son olarak, "kabul edilen cevap" ve diğer mistery ;-)

Hadi assp1r1n3'ün cevabını kullanalım - 2.5Ghz çekirdeği var. POPCNT'nin 1 saati var, kodu 64 bit popcnt kullanıyor. Yani matematik 2.5Ghz * 1 saat * 8 bayt = 20 GB / sn. Belki de 3Ghz civarında turbo artış nedeniyle 25Gb / s görüyor.

Böylece ark.intel.com adresine gidin ve i7-4870HQ arayın: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? q = i7-4870HQ

Bu çekirdek 3.7Ghz'ye kadar çalışabilir ve donanımı için gerçek maksimum hız 29.6 GB / s'dir. Peki başka bir 4GB / s nerede? Belki de, her bir yineleme içindeki döngü mantığı ve diğer çevreleyen kod için harcanır.

Şimdi bu yanlış bağımlılık nerede ? donanım neredeyse en yüksek oranda çalışır. Belki matematiğim kötü, bazen olur :)

PPPPPS HW errata'yı öneren hala insanlar suçlu, bu yüzden öneriyi takip ediyorum ve satır içi asm örneği oluşturdum, aşağıya bakın.

7960X'imde, ilk sürüm (cnt0'a tek çıkışlı) 11MB / s'de, ikinci sürüm (cnt0, cnt1, cnt2 ve cnt3'e çıkışlı) 33MB / s'de çalışır. Ve biri diyebilir - voila! çıktı bağımlılığı.

Tamam, belki de, bu şekilde kod yazmanın mantıklı olmaması ve çıktı bağımlılığı sorunu değil, aptal kod üretimi. Donanımı test etmiyoruz, maksimum performansı ortaya çıkarmak için kod yazıyoruz. HW OOO'nin bu "çıktı bağımlılıklarını" yeniden adlandırmasını ve gizlemesini bekleyebilirsiniz, ancak, sadece doğru şeyleri doğru yapın ve hiçbir zaman gizemle karşılaşmayacaksınız.

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}

Çekirdek saat döngülerinde zamanlama yapıyorsanız (saniye yerine), CPU'ya bağlı küçük bir döngü için 1 saniye çok zaman alır. 100ms bile büyük farklılıklar bulmak veya uop sayıları için mükemmel sayaçları kontrol etmek için iyidir. Özellikle donanım P-durumu yönetiminin, yük başladıktan sonra mikrosaniye cinsinden maksimum saat hızına ulaşmasına izin verdiği bir Skylake'de.
Peter Cordes

clang __builtin_popcountlAVX2 ile otomatik vektör yapabilir vpshufbve bunu yapmak için C kaynağında birden fazla akümülatöre ihtiyaç duymaz. Bundan emin değilim _mm_popcnt_u64; yalnızca AVX512-VPOPCNT ile otomatik vektörleştirilebilir. (Bkz. AVX-512 veya AVX-2 kullanarak büyük verilerde 1 bit sayma (nüfus sayımı) /)
Peter Cordes

Yine de, Intel'in optimizasyon kılavuzuna bakmak yardımcı olmaz: kabul edilen cevabın gösterdiği gibi, sorun beklenmedik bir çıkış bağımlılığıdır popcnt. Bu, Intel'in son mikro mimarileri için hatalarında belgelenmiştir, ancak sanırım o zaman değildi. Beklenmedik yanlış bağımlılıklar varsa, zincirleme analiziniz başarısız olacaktır, bu nedenle bu cevap iyi bir genel tavsiye ancak burada geçerli değildir.
Peter Cordes

1
Benimle dalga mı geçiyorsun? Elle yazılmış bir asm döngüsünde performans sayaçlarıyla deneysel olarak ölçebileceğim şeylere "inanmak" zorunda değilim. Onlar sadece gerçekler. Test ettim ve Skylake lzcnt/ için yanlış bağımlılığı düzeltti tzcnt, ama için değil popcnt. Intel.com/content/dam/www/public/us/en/documents/… adresindeki Intel'in erratum SKL029'una bakın . Ayrıca, gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 "geçersiz" değil, "çözüldü" düzeltildi. HW'de çıktı bağımlılığı olmadığı iddianız için bir temel yoktur.
Peter Cordes

1
popcnt eax, edx/ Gibi basit bir döngü dec ecx / jnzyaparsanız, saatte 1'de çalışmasını, popcnt işleminde ve alınan şube işleminde tıkanmasını beklersiniz. Ancak, yalnızca salt okunur olmasını popcntbekleseniz bile, EAX'ın tekrar tekrar üzerine yazılması için gecikmede tıkanmış 3 saatte sadece 1'de çalışır . Bir Skylake'in var, bu yüzden kendin deneyebilirsin.
Peter Cordes

-3

Tamam, OP'nin sorduğu alt sorulardan birine, mevcut sorularda ele alınmamış gibi görünen küçük bir cevap vermek istiyorum. Dikkat, herhangi bir test ya da kod oluşturma ya da sökme yapmadım, sadece başkalarının muhtemelen üzerine açıklanması için bir düşünce paylaşmak istedim.

staticPerformansı neden değiştiriyor?

Söz konusu hat: uint64_t size = atol(argv[1])<<20;

Kısa cevap

Erişmek için oluşturulan derleme bakar ve sizestatik olmayan sürüm için işaretçi dolaylama dahil ekstra adımlar olup olmadığını görmek istiyorum.

Uzun cevap

Değişkenin bildirilmiş olsun staticya da olmasın yalnızca bir kopyası olduğundan ve boyut değişmediğinden, farkın, kodu kodda kullanıldığı yerle birlikte değişkeni desteklemek için kullanılan belleğin konumu olduğunu teorize ederim. aşağı.

Tamam, bariz ile başlamak için, bir işlevin tüm yerel değişkenlerine (parametrelerle birlikte) depolama olarak kullanılmak üzere yığın üzerinde yer sağlandığını unutmayın. Açıkçası, main () için yığın çerçevesi asla temizlenmez ve yalnızca bir kez oluşturulur. Tamam, yapmaya ne dersin static? Bu durumda derleyici, sürecin genel veri alanında yer ayırmayı bilir, böylece konum bir yığın çerçevesinin kaldırılmasıyla temizlenemez. Ama yine de, sadece bir konumumuz var, fark ne? Yığındaki bellek konumlarına nasıl başvurulduğuyla ilgili olduğundan şüpheleniyorum.

Derleyici sembol tablosunu oluştururken, yalnızca boyut, vb. Gibi ilgili özelliklerle birlikte bir etiket için bir giriş yapar. Bellekte uygun alanı ayırması gerektiğini bilir, ancak biraz daha sonraya kadar bu konumu seçmez canlılık analizi yaptıktan sonra ve muhtemelen kayıt tahsisi. Bağlayıcı son montaj kodu için makine koduna hangi adresi sağlayacağını nasıl bilebilir? Ya son konumu bilir ya da konuma nasıl ulaşacağını bilir. Bir yığınla, konum tabanlı bir iki öğeye, yığın çerçevesine işaretçi ve daha sonra çerçeveye bir ofise atıf yapmak oldukça basittir. Bunun temel nedeni, bağlayıcının çalışma zamanından önce yığın çerçevesinin konumunu bilememesidir.


2
Kullanımın , OP için test ettiği Intel CPU'ların staticyanlış çıkış bağımlılığını etkileyecek şekilde, işlev için yazmaç ayırmayı değiştirmesinin popcnt, onlardan kaçınmayı bilmeyen bir derleyici ile gerçekleşmesi çok daha olası görünüyor . (Intel CPU'lardaki bu performans çukuru henüz keşfedilmediğinden.) Bir derleyici, staticotomatik bir depolama değişkeni gibi yerel bir değişkeni bir kayıtta tutabilir , ancak mainyalnızca bir kez çalıştığını varsayarak optimize etmezlerse , code-gen (değer yalnızca ilk çağrı tarafından ayarlandığından)
Peter Cordes

1
Her neyse, modlar [RIP + rel32]ve [rsp + 42]adresleme modları arasındaki performans farkı çoğu durumda oldukça ihmal edilebilir. cmp dword [RIP+rel32], immediatetek bir yük + cmp uop içine mikrofüzyon olamaz, ama bunun bir faktör olacağını düşünmüyorum. Dediğim gibi, döngüler içinde muhtemelen zaten bir kayıtta kalır, ancak C ++ tweaking farklı derleyici seçenekleri anlamına gelebilir.
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.