Collatz varsayımını elle yazılmış montajdan daha hızlı test etmek için C ++ kodu - neden?


833

Bu iki çözümü Project Euler Q14 için derleme ve C ++ 'da yazdım . Collatz varsayımını test etmek için aynı özdeş kaba kuvvet yaklaşımıdır . Montaj çözeltisi,

nasm -felf64 p14.asm && gcc p14.o -o p14

C ++ ile derlendi

g++ p14.cpp -o p14

Montaj, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Hızı ve her şeyi iyileştirmek için derleyici optimizasyonlarını biliyorum, ancak montaj çözümümü daha da optimize etmek için pek çok yol görmüyorum (matematiksel olarak değil programlama yoluyla).

C ++ kodu her terimde modül ve her çift terimde bir bölüme sahiptir, burada montaj çift terimde sadece bir bölümdür.

Ancak montaj, C ++ çözümünden ortalama 1 saniye daha uzun sürüyor. Bu neden? Özellikle meraktan soruyorum.

Yürütme süreleri

Sistemim: 1.4 GHz Intel Celeron 2955U üzerinde 64 bit Linux (Haswell mikro mimarisi).


232
GCC'nin C ++ programınız için oluşturduğu montaj kodunu incelediniz mi?
ruakh

69
-SDerleyicinin oluşturduğu montajı almak için derleyin . Derleyici, modülün bölünmeyi aynı anda yaptığını anlayacak kadar akıllıdır.
user3386109

267
Ben senin seçenekler olduğunu düşünüyorum 1. Kişisel ölçüm tekniği kusurludur, 2. derleyici sen misin montaj daha iyi yazıyor ya 3. derleyici kullandığı büyü.
Galik


18
@jefferson Derleyici daha hızlı kaba kuvvet kullanabilir. Örneğin belki SSE talimatları ile.
user253751

Yanıtlar:


1896

64 bit DIV komutunun ikiye bölmenin iyi bir yol olduğunu düşünüyorsanız, derleyicinin asm çıkışının elle yazılmış kodunuzu geçmesine şaşmamalı -O0 (hızlı derleme, ekstra optimizasyon yok ve sonra / her C deyiminden önce bir hata ayıklayıcı değişkenleri değiştirebilir).

Bkz Agner Fog'un Optimize Meclisi kılavuzuEtkili bir asm yazmayı öğrenmek için . Ayrıca, belirli CPU'lar için özel ayrıntılar için talimat tabloları ve bir mikroarşı kılavuzu vardır. Ayrıca bakınız daha mükemmel bağlantılar için etiket wiki.

Ayrıca derleyiciyi elle yazılmış asm ile yenmekle ilgili bu daha genel soruya bakın: Satır içi montaj dili yerel C ++ kodundan daha yavaş mı? . TL: DR: evet yanlış yaparsanız (bu soru gibi).

Genellikle derleyicinin işini yapmasına izin verirsiniz, özellikle de verimli bir şekilde derleyebilen C ++ yazmaya çalışırsanız . Ayrıca montaj derlenmiş dillerden daha hızlı mı? . Yanıtlardan biri, çeşitli C derleyicilerinin bazı basit işlevleri serin hilelerle nasıl optimize ettiğini gösteren bu düzgün slaytlara bağlantı veriyor . Matt Godbolt'un CppCon2017 tartışma “ Derleyicim son zamanlarda benim için ne yaptı? Derleyicinin Kapağının Açılması ”benzer bir damardadır .


even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

Intel div r64Haswell'de 36 uops, 32-96 döngü gecikme süresi ve 21-74 döngü başına bir verim. (Artı 2 Uops RBX ve sıfır RDX kurmak için, ancak sipariş dışı yürütme bunları erken çalıştırabilir). DIV gibi yüksek üop sayımlı talimatlar mikro kodludur ve bu da ön uç darboğazlarına neden olabilir. Bu durumda, gecikme en önemli faktördür, çünkü döngü taşınan bir bağımlılık zincirinin bir parçasıdır.

shr rax, 1aynı imzasız bölümü yapar: 1c gecikme ile 1 uop ve saat döngüsü başına 2 çalıştırabilir.

Karşılaştırma için, 32 bit bölme daha hızlıdır, ancak kaymalara karşı hala korkunçtur. idiv r329 uops, 22-29c gecikme süresi ve Haswell'deki 8-11c verim başına bir.


Gcc'nin-O0 asm çıkışına ( Godbolt derleyici gezgini ) bakarak görebileceğiniz gibi , sadece vardiya talimatlarını kullanır . clang -O0, düşündüğünüz gibi safça derler, hatta 64 bit IDIV'i iki kez kullanıyor. (Optimizasyon yaparken, kaynak IDIV kullanıyorlarsa, kaynak aynı işlenenlerle bir bölme ve modül yaptığında derleyiciler her iki IDIV çıktısını kullanır)

GCC'nin tamamen saf bir modu yoktur; her zaman GIMPLE aracılığıyla dönüşüm gerçekleştirir, yani bazı "optimizasyonlar" devre dışı bırakılamaz . Buna sabit bölünmeyi tanımayı ve IDIV'den kaçınmak için vardiyaları (2'nin gücü) veya sabit nokta çarpımsal tersini (2'nin gücü olmayan) kullanmayı içerir ( div_by_13yukarıdaki godbolt bağlantısına bakın).

gcc -Os(boyutu optimize) yapar , ne yazık ki, hatta çarpımsal ters kodu sadece biraz daha büyük ama çok daha hızlı olduğu durumlarda, non-güç-of-2 bölünmesi için kullanımı idiv.


Derleyiciye yardım

(bu vaka için özet: kullanım uint64_t n)

Her şeyden önce, sadece optimize edilmiş derleyici çıktısına bakmak ilginç. ( -O3). -O0hız temelde anlamsızdır.

Asm çıkışınıza bakın (Godbolt üzerinde veya GCC / clang montaj çıkışından "gürültü" nasıl kaldırılır? Bölümüne bakın ). Derleyici ilk etapta en uygun kodu yapmadığında: C / C ++ kaynağınızı derleyiciyi daha iyi kod yapmaya yönlendirecek şekilde yazmak genellikle en iyi yaklaşımdır . Birliği bilmeli ve neyin etkili olduğunu bilmelisiniz, ama bu bilgiyi dolaylı olarak uyguluyorsunuz. Derleyiciler de iyi bir fikir kaynağıdır: bazen clang serin bir şey yapar ve aynı şeyi yapmak için gcc'yi elle tutabilirsiniz: bu cevabı ve aşağıdaki @ Veedrac'ın kodundaki unrolled olmayan döngü ile ne yaptığımı görün.)

Bu yaklaşım taşınabilirdir ve 20 yıl içinde gelecekteki bazı derleyiciler, gelecekteki donanımda (x86 ya da değil) verimli olan her şeyi derleyebilir, belki de yeni ISA uzantısı veya otomatik vektörleme kullanarak. 15 yıl öncesinden elle yazılmış x86-64 asm, Skylake için genellikle en uygun şekilde ayarlanmaz. örneğin karşılaştır ve dal makro-füzyonu o zamanlar yoktu. Bir mikro mimariye yönelik el yapımı asm için şimdi en uygun olan, diğer mevcut ve gelecekteki CPU'lar için uygun olmayabilir. @ Johnfound'un cevabı hakkındaki yorumlar, bu kod üzerinde büyük etkisi olan AMD Bulldozer ve Intel Haswell arasındaki büyük farklılıkları tartışıyor. Ama teoride g++ -O3 -march=bdver3ve g++ -O3 -march=skylakedoğru olanı yapacağız. (Veya -march=native.) Veya -mtune=...diğer CPU'ların desteklemeyebileceği talimatları kullanmadan ayarlamak için.

Benim hissettiğim derleyiciyi umursadığınız mevcut bir CPU için iyi olana yönlendirmek, gelecekteki derleyiciler için sorun olmamalı. Umarız kod dönüştürmenin yollarını bulmak için mevcut derleyicilerden daha iyidir ve gelecekteki CPU'lar için uygun bir yol bulabilirler. Ne olursa olsun, gelecek x86 muhtemelen mevcut x86'da iyi olan hiçbir şeyde korkunç olmayacak ve gelecekteki derleyici daha iyi bir şey görmüyorsa, C kaynağınızdan veri hareketi gibi bir şey uygularken asm'ye özgü tuzaklardan kaçınacaktır.

Elle yazılmış asm, optimize edici için bir kara kutudur, bu nedenle satır içi bir girdiyi derleme zamanı sabiti haline getirdiğinde sabit yayılım çalışmaz. Diğer optimizasyonlar da etkilenir. Asm'ı kullanmadan önce https://gcc.gnu.org/wiki/DontUseInlineAsm adresini okuyun . (Ve MSVC tarzı satır içi asm'dan kaçının: girişler / çıkışlar, ek yük ekleyen bellekten geçmelidir .)

Bu durumda : nimzalı bir türünüz vardır ve gcc doğru yuvarlamayı veren SAR / SHR / ADD dizisini kullanır. (Negatif girişler için IDIV ve aritmetik kaydırma "yuvarlak" ifadeleri , bkz. SAR insn set ref manuel girişi ). (Gcc nnegatif olup olamayacağını veya ne olduğunu kanıtlayamazsa IDK . İmzalı taşma tanımsız bir davranıştır, bu yüzden yapabilmelidir.)

Kullanmalıydın uint64_t n, böylece sadece SHR olabilir. Ve bu yüzden longsadece 32 bit (örneğin x86-64 Windows) olan sistemler için taşınabilir .


BTW, gcc'nin optimize edilmiş asm çıkışı oldukça iyi görünüyor (kullanarak unsigned long n) : içine girdiği iç döngü bunu main()yapar:

 # from gcc5.4 -O3  plus my comments

 # edx= count=1
 # rax= uint64_t n

.L9:                   # do{
    lea    rcx, [rax+1+rax*2]   # rcx = 3*n + 1
    mov    rdi, rax
    shr    rdi         # rdi = n>>1;
    test   al, 1       # set flags based on n%2 (aka n&1)
    mov    rax, rcx
    cmove  rax, rdi    # n= (n%2) ? 3*n+1 : n/2;
    add    edx, 1      # ++count;
    cmp    rax, 1
    jne   .L9          #}while(n!=1)

  cmp/branch to update max and maxi, and then do the next n

İç döngü dalsızdır ve döngü taşınan bağımlılık zincirinin kritik yolu:

  • 3 bileşenli LEA (3 döngü)
  • cmov (Haswell'de 2 döngü, Broadwell'de veya daha sonra 1c).

Toplam: yineleme başına 5 döngü, gecikme darboğazı . Sıra dışı yürütme, buna paralel olarak her şeyle ilgilenir (teoride: 5c / iter'de gerçekten çalışıp çalışmadığını görmek için mükemmel sayaçlarla test etmedim).

cmov(TEST tarafından üretilen) FLAGS girişinin üretilmesi, RAX girişinden (LEA-> MOV'dan) daha hızlıdır, bu nedenle kritik yolda değildir.

Benzer şekilde, CMOV'un RDI girişini üreten MOV-> SHR kritik yoldan çıkar, çünkü LEA'dan daha hızlıdır. IvyBridge ve sonraki sürümlerde MOV sıfır gecikme süresine sahiptir (kayıt yeniden adlandırma zamanında işlenir). (Hala bir uop ve boru hattında bir yuva alır, bu yüzden ücretsiz değil, sadece sıfır gecikme). LEA dep zincirindeki ekstra MOV, diğer CPU'lardaki darboğazın bir parçasıdır.

Cmp / jne de kritik yolun bir parçası değildir: döngüsel yoldan değil, çünkü kontrol bağımlılıkları kritik yoldaki veri bağımlılıklarından farklı olarak dal tahmini + spekülatif yürütme ile işlenir.


Derleyiciyi dayak

GCC burada oldukça iyi bir iş çıkardı. Bunun inc edxyerineadd edx, 1 kullanarak bir kod baytı kaydedebilir , çünkü kimse P4 ve kısmi bayrağı değiştiren talimatlar için yanlış bağımlılıklarını umursamaz.

Ayrıca tüm MOV talimatlarını kaydedebilir ve TEST: SHR, CF = biti dışarı kaydırır, böylece / cmovcyerine kullanabiliriz .testcmovz

 ### Hand-optimized version of what gcc does
.L9:                       #do{
    lea     rcx, [rax+1+rax*2] # rcx = 3*n + 1
    shr     rax, 1         # n>>=1;    CF = n&1 = n%2
    cmovc   rax, rcx       # n= (n&1) ? 3*n+1 : n/2;
    inc     edx            # ++count;
    cmp     rax, 1
    jne     .L9            #}while(n!=1)

@ Johnfound'un başka bir akıllı hile için cevabına bakın: SHP'nin bayrak sonucuna dallayarak CMP'yi kaldırın ve CMOV için kullanın: sıfır, sadece n 1 (veya 0) ile başlasaydı. (Eğlenceli gerçek: Nehalem veya önceki sürümlerde bulunan SHR sayısı! = 1, bayrak sonuçlarını okursanız bir duraklamaya neden olur . Bu şekilde tek-uop yaptılar. 1'e kadar özel kodlama gayet iyi.)

MOV'dan kaçınmak Haswell'deki gecikmeye hiç yardımcı olmaz (x86'nın MOV'u gerçekten "ücretsiz" olabilir mi? Bunu neden hiç üretemiyorum? ). MOV sıfır gecikme olmayan Intel IvB öncesi ve AMD Bulldozer ailesi gibi CPU'larda önemli ölçüde yardımcı olur . Derleyicinin boşa giden MOV talimatları kritik yolu etkiler. BD'nin karmaşık LEA ve CMOV'larının her ikisi de daha düşük gecikmedir (sırasıyla 2c ve 1c), bu nedenle gecikmenin daha büyük bir kısmıdır. Ayrıca, yalnızca iki tamsayı ALU borusu olduğundan, iş akışı darboğazları bir sorun haline gelir. AMD işlemcisinden zamanlama sonuçları aldığı @ johnfound'un cevabına bakın .

Haswell'de bile, bu sürüm, kritik olmayan bir UOP'nin kritik yolda bir yürütme bağlantı noktasını çaldığı ve yürütmeyi 1 döngü geciktirdiği bazı gecikmelerden kaçınarak biraz yardımcı olabilir. (Buna kaynak çakışması denir). Ayrıca, nserpiştirilmiş bir döngüde paralel olarak birden çok değer yaparken yardımcı olabilecek bir kayıt da kaydeder (aşağıya bakın).

LEA'nın gecikmesi adresleme moduna , Intel SnB ailesi CPU'lara bağlıdır. 3c için 3c ( [base+idx+const]iki ayrı ekleme alır), ancak sadece 2c veya daha az bileşenli 1c (bir ekleme). Bazı CPU'lar (Core2 gibi) tek bir döngüde 3 bileşenli bir LEA bile yapar, ancak SnB ailesi bunu yapmaz. Daha da kötüsü, Intel SnB ailesi gecikmeleri standartlaştırır, böylece 2c uops yoktur , aksi takdirde 3 bileşenli LEA, Bulldozer gibi sadece 2c olur. (3 bileşenli LEA, AMD'de de daha yavaş değil, daha yavaştır).

Yani lea rcx, [rax + rax*2]/ Haswell gibi Intel SnB ailesi işlemcilerde inc rcxsadece 2c gecikme süresine sahiptir lea rcx, [rax + rax*2 + 1]. BD'de bile, Core2'de ise daha da kötüsü. Normalde 1c gecikme süresinden tasarruf etmeye değmeyecek ekstra bir ÜOP'ye mal olur, ancak gecikme burada en büyük darboğazdır ve Haswell ekstra üop verimini işlemek için yeterince geniş bir boru hattına sahiptir.

Ne gcc, icc ne de clang (godbolt üzerinde) SHR'nin CF çıkışını kullanmaz, her zaman bir AND veya TEST kullanır . Aptal derleyiciler. : P Çok karmaşık makinelerdir, ancak zeki bir insan onları genellikle küçük ölçekli problemlerde yenebilir. (Bunu düşünmek için binlerce ila milyonlarca kez daha uzun bir süre göz önüne alındığında, elbette! Derleyiciler, işleri yapmak için mümkün olan her yolu aramak için kapsamlı algoritmalar kullanmazlar, çünkü çok fazla satır içi kodu optimize ederken bu çok uzun sürer. Ayrıca boru hattını hedef mikromimaride modellemezler, en azından IACA veya diğer statik analiz araçlarıyla aynı ayrıntıda değil ; sadece bazı buluşsal yöntemler kullanırlar.)


Basit döngü açma yardımcı olmaz ; bu döngü, döngü tepegözünde / veriminde değil, döngüde taşınan bir bağımlılık zincirinin gecikmesini azaltır. Bu, CPU'nun iki iş parçacığından gelen talimatları serpiştirmek için çok zamana sahip olduğu için hiper iş parçacığıyla (veya başka bir SMT türüyle) iyi olacağı anlamına gelir. Bu, döngüyü içeri paralel hale getirme anlamına gelir main, ancak bu iyidir, çünkü her evre sadece bir değer aralığını kontrol edebilir nve sonuç olarak bir çift tamsayı üretebilir.

Tek bir iplik içinde elle serpiştirmek de uygun olabilir . Belki bir çift sayı için diziyi paralel olarak hesaplayın, çünkü her biri sadece birkaç kayıt alır ve hepsi aynı max/ maxi. Bu daha fazla talimat düzeyinde paralellik yaratır .

Hile, başka bir başlangıç değeri elde etmeden önce tüm ndeğerlere ulaşılıp 1beklenmeyeceğine nveya diğer sekansın kayıtlarına dokunmadan son koşula ulaşan sadece bir tane için yeni bir başlangıç ​​noktası alıp almayacağına karar verir. Muhtemelen her zincirin yararlı veriler üzerinde çalışmasını sağlamak en iyisidir, aksi takdirde sayacını şartlı olarak arttırmanız gerekir.


Hatta bunu henüz nulaşılmayan vektör öğeleri için sayacı şartlı olarak artırmak için SSE paketlenmiş karşılaştırmalı öğelerle bile yapabilirsiniz 1. Daha sonra bir SIMD koşullu artış uygulamasının daha da uzun gecikmesini gizlemek niçin havada daha fazla değer vektörü tutmanız gerekir . Belki sadece 256b vektör (4x uint64_t) ile değer .

Ben bir 1"yapışkan" tespiti yapmak için en iyi strateji sayacı artırmak için eklediğiniz herkesin vektörü maskelemek olduğunu düşünüyorum. 1Bir öğede bir öğeyi gördükten sonra , arttırma vektörü sıfır olacaktır ve + = 0 bir işlem yapılmaz.

Manuel vektörleştirme için denenmemiş fikir

# starting with YMM0 = [ n_d, n_c, n_b, n_a ]  (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1):  increment vector
# ymm5 = all-zeros:  count vector

.inner_loop:
    vpaddq    ymm1, ymm0, xmm0
    vpaddq    ymm1, ymm1, xmm0
    vpaddq    ymm1, ymm1, set1_epi64(1)     # ymm1= 3*n + 1.  Maybe could do this more efficiently?

    vprllq    ymm3, ymm0, 63                # shift bit 1 to the sign bit

    vpsrlq    ymm0, ymm0, 1                 # n /= 2

    # FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
    vpblendvpd ymm0, ymm0, ymm1, ymm3       # variable blend controlled by the sign bit of each 64-bit element.  I might have the source operands backwards, I always have to look this up.

    # ymm0 = updated n  in each element.

    vpcmpeqq ymm1, ymm0, set1_epi64(1)
    vpandn   ymm4, ymm1, ymm4         # zero out elements of ymm4 where the compare was true

    vpaddq   ymm5, ymm5, ymm4         # count++ in elements where n has never been == 1

    vptest   ymm4, ymm4
    jnz  .inner_loop
    # Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero

    vextracti128 ymm0, ymm5, 1
    vpmaxq .... crap this doesn't exist
    # Actually just delay doing a horizontal max until the very very end.  But you need some way to record max and maxi.

Bunu elle yazılmış asm yerine intrinseklerle uygulayabilir ve uygulamalısınız.


Algoritmik / uygulama geliştirme:

Aynı mantığı daha verimli bir asm ile uygulamanın yanı sıra, mantığı basitleştirmenin veya gereksiz çalışmalardan kaçınmanın yollarını arayın. örneğin, dizilerdeki ortak sonları saptamak için not edin. Ya da daha iyisi, aynı anda 8 bite bakın (gnasher'ın cevabı)

@ EOF, tzcnt(veya bsf) n/=2bir adımda birden çok yineleme yapmak için kullanılabileceğine işaret eder . Bu muhtemelen SIMD vektörleştirmesinden daha iyidir; hiçbir SSE veya AVX komutu bunu yapamaz. Yine de n, farklı tamsayı kayıtlarında paralel olarak birden fazla skaler yapmakla uyumludur .

Yani döngü şöyle görünebilir:

goto loop_entry;  // C++ structured like the asm, for illustration only
do {
   n = n*3 + 1;
  loop_entry:
   shift = _tzcnt_u64(n);
   n >>= shift;
   count += shift;
} while(n != 1);

Bu, önemli ölçüde daha az yineleme yapabilir, ancak BMI2 içermeyen Intel SnB ailesi CPU'larda değişken sayımlı kaymalar yavaştır. 3 uops, 2c gecikme. (FLAGS'a giriş bağımlılıkları vardır çünkü count = 0, işaretlerin değiştirilmediği anlamına gelir. Bunu bir veri bağımlılığı olarak ele alırlar ve bir uop yalnızca 2 girişe sahip olabilirler (zaten HSW / BDW öncesi)). Bu, x86'nın çılgın-CISC tasarımından şikayet eden insanların bahsettiği tür. X86 CPU'ları, ISA bugün benzer şekilde bile sıfırdan tasarlanmışsa, olduğundan daha yavaş hale getirir. (yani bu, hız / güç gerektiren "x86 vergisinin" bir parçasıdır.) SHRX / SHLX / SARX (BMI2) büyük bir kazançtır (1 uop / 1c gecikme).

Ayrıca tzcnt'yi (Haswell ve daha sonra 3c) kritik yola koyar, bu nedenle döngü taşınan bağımlılık zincirinin toplam gecikmesini önemli ölçüde uzatır. Yine de, bir CMOV veya kayıt tutma hazırlığı ihtiyacını ortadan kaldırır n>>1. @ Veedrac'ın cevabı, çok etkili olan çoklu iterasyonlar için tzcnt / shift'i erteleyerek tüm bunların üstesinden gelir (aşağıya bakınız).

BSF veya TZCNT'yi birbirinin yerine kullanabiliriz , çünkü no noktada asla sıfır olamaz. TZCNT'nin makine kodu, BMI1'i desteklemeyen CPU'larda BSF olarak çözülür. (Anlamsız önekler göz ardı edilir, bu nedenle REP BSF BSF olarak çalışır).

TZCNT, onu destekleyen AMD CPU'larda BSF'den çok daha iyi performans gösterir, bu nedenle REP BSFgiriş çıkıştan ziyade sıfır ise ZF'yi ayarlamayı umursamanız bile , kullanmak iyi bir fikir olabilir . Bazı derleyiciler bunu __builtin_ctzllbile kullandığınızda yapar -mno-bmi.

Intel CPU'larda da aynı şeyi yaparlar, bu yüzden önemli olan tek şey baytı saklayın. Intel'deki (Skylake öncesi) TZCNT'nin, giriş = 0 olan BSF'nin hedefini değiştirmeden bıraktığı belgelenmemiş davranışı desteklemek için, tıpkı BSF gibi, sözde sadece yazma çıktı işlenenine yanlış bağımlılığı vardır. Bu nedenle, yalnızca Skylake için optimizasyon yapmadıkça bunun etrafında çalışmanız gerekir, bu nedenle ekstra REP baytından kazanılacak hiçbir şey yoktur. (Intel, x86 ISA kılavuzunun gerektirdiği şeyin ötesine geçer, olmaması gereken bir şeye bağlı olan veya geriye dönük olarak izin verilmeyen bir kodun kırılmasını önlemek için. Örneğin, Windows 9x'in güvenli olduğu TLB girişlerinin spekülatif olarak önceden getirilmesi kabul edilmez. kod yazıldığında, Intel TLB yönetim kurallarını güncellemeden önce .)

Her neyse, Haswell'deki LZCNT / TZCNT, POPCNT ile aynı yanlış dep'e sahip: bu Soru & Cevap'ya bakın . Bu nedenle gcc'nin @ Veedrac'ın kodu için asm çıkışında, dst = src kullanmadığında TZCNT'nin hedefi olarak kullanmak üzere olan kayıtta dep zincirini xor-zeroing ile kırdığını görüyorsunuz. TZCNT / LZCNT / POPCNT, hedeflerini hiçbir zaman tanımsız veya değiştirilmemiş bırakmadığından, Intel CPU'lardaki çıkışa bu yanlış bağımlılık bir performans hatası / sınırlamasıdır. Muhtemelen bazı transistörlerin / güçlerin aynı yürütme birimine giden diğer uopslar gibi davranmalarına değer. Tek üstünlük, başka bir uarch sınırlaması ile etkileşimdir: bir bellek operandını dizinlenmiş adresleme modu ile mikro kaynaştırabilirler Haswell'de, ancak Intel'in LZCNT / TZCNT için yanlış dep'i kaldırdığı Skylake'de, endeksli adresleme modlarını "laminatsız" hale getirirken, POPCNT hala herhangi bir addr modunu mikro kaynaştırabilir.


Diğer cevaplardaki fikir / kod iyileştirmeleri:

@ hidefromkgb'ın cevabı , 3n + 1'den sonra bir sağ vardiya yapabileceğinizin garantili olduğunu gözlemliyor . Bunu, adımlar arasındaki kontrolleri atlamaktan daha verimli bir şekilde hesaplayabilirsiniz. Bu cevaptaki asm uygulaması kırılmış olsa da (SHRD'den sonra bir sayım> 1 olan tanımsız olan OF'ye bağlıdır) ve yavaş: ROR rdi,2daha hızlıdır SHRD rdi,rdi,2ve kritik yolda iki CMOV talimatı kullanmak ekstra bir TEST'ten daha yavaştır paralel olarak çalışabilir.

Tidied / geliştirilmiş C'yi (derleyiciyi daha iyi asm üretmeye yönlendirir) koydum ve Godbolt üzerinde + C daha hızlı asm (C'nin altındaki yorumlarda) çalıştığını test ettim: @ hidefromkgb'ın cevabındaki bağlantıya bakın . (Bu yanıt, büyük Godbolt URL'lerinden 30 bin karakter sınırına ulaştı, ancak kısa bağlantılar çürüyebilir ve yine de goo.gl için çok uzundu.)

Ayrıca bir dizeye dönüştürmek write()ve bir kerede bir karakter yazmak yerine bir çıktı yapmak için çıktı-baskı geliştirildi . Bu, perf stat ./collatz(performans sayaçlarını kaydetmek için) ile tüm programın zamanlaması üzerindeki etkiyi en aza indirir ve kritik olmayan asmın bazılarını gizlemiştim.


@ Veedrac'ın kodu

İhtiyacımız olduğunu bildiğimiz kadar sağa kaymadan ve döngüye devam etmek için kontrol ettiğimde küçük bir hız kazandım . Sınır = 1e8 için 7.5s'den Core2Duo'da (Merom) 16.7'lik bir açma faktörü ile 7.275s'ye kadar.

kodu + Godbolt üzerine yorumlar . Bu sürümü clang ile kullanmayın; erteleme döngüsüyle aptalca bir şey yapar. Bir tmp sayacı kullanmak kve daha sonra eklemek countdaha sonra clang ne yapar, ancak bu biraz gcc acıyor.

Yorumlardaki tartışmaya bakın: Veedrac'ın kodu BMI1'e sahip CPU'larda mükemmeldir (yani Celeron / Pentium değil)


4
Bir süre önce vectorized yaklaşımı denedim, yardımcı olmadı (çünkü skaler kodda çok daha iyisini yapabilirsiniz tzcntve vectorized durumda vektör öğeleri arasında en uzun süren sırayla kilitli kalırsınız).
EOF

3
@EOF: hayır, vektör elemanlarından herhangi biri isabet 1ettiğinde ( hepsi PCMPEQ / PMOVMSK ile kolayca tespit edilebilir) yerine iç döngüden kopmak istedim. Ardından, sonlanan tek öğeyle (ve sayaçlarıyla) uğraşmak ve döngüye geri atlamak için PINSRQ ve şeyleri kullanırsınız. Bu, iç döngüden çok sık koptuğunuzda kolayca kayba dönüşebilir, ancak bu, iç halkanın her yinelemesinde her zaman 2 veya 4 yararlı iş öğesi elde ettiğiniz anlamına gelir. Yine de memoization iyi bir nokta.
Peter Cordes

4
@jefferson En iyi yönettiğim godbolt.org/g/1N70Ib . Daha akıllıca bir şey yapabileceğimi umuyordum, ama öyle görünmüyor.
Veedrac

87
Bu gibi inanılmaz cevaplar beni şaşırtan şey, bu tür ayrıntılara gösterilen bilgidir. Bu seviyeye kadar asla bir dil veya sistem bilmeyeceğim ve nasıl yapılacağını bilemeyeceğim. Aferin efendim.
camden_kid

8
Efsanevi cevap !!
Sumit Jain

104

C ++ derleyicisinin yetkili bir montaj dili programcısından daha uygun kod üretebileceğini iddia etmek çok kötü bir hatadır. Ve özellikle bu durumda. İnsan kodu derleyicinin yapabileceği her zaman daha iyi yapabilir ve bu özel durum bu iddianın iyi bir örneğidir.

Gördüğünüz zamanlama farkı, söz konusu montaj kodunun iç döngülerdeki optimal koddan çok uzak olmasıdır.

(Aşağıdaki kod 32 bittir, ancak 64 bit'e kolayca dönüştürülebilir)

Örneğin, sıra işlevi yalnızca 5 talimata göre optimize edilebilir:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Tüm kod şöyle görünür:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Bu kodu derlemek için, FreshLib gereklidir.

Testlerimde (1 GHz AMD A4-1200 işlemci), yukarıdaki kod sorudan C ++ kodundan yaklaşık dört kat daha hızlıdır (derlendiğinde -O0: 430 ms - 1900 ms) ve iki kattan daha hızlıdır (430 ms vs. 830 ms) C ++ kodu ile derlendiğinde -O3.

Her iki programın çıktısı aynıdır: i = 837799'da maks sekans = 525.


6
Ha, bu akıllıca. SHR, ZF'yi yalnızca EAX 1 (veya 0) ise ayarlar. Gcc'nin -O3çıktısını optimize ederken bunu kaçırdım , ancak iç döngüye yaptığınız diğer tüm optimizasyonları tespit ettim. (Ama neden INC yerine sayaç artışı için LEA kullanıyorsunuz? Bu noktada bayrakları cümbüş ettirmek ve belki P4 (hem INC hem de SHR için eski bayraklara yanlış bağımlılık) dışında herhangi bir yavaşlamaya yol açmak sorun değil. t kadar sayıda bağlantı noktasında çalışır ve kaynak çakışmalarının kritik yolu daha sık geciktirmesine neden olabilir.)
Peter Cordes

4
Oh, aslında Buldozer, derleyici çıktısı ile verimde darboğaz oluşturabilir. Gecikme CMOV ve 3 bileşenli LEA'ya Haswell'den (ki düşündüğüm) sahiptir, bu nedenle döngü taşınan dep zinciri kodunuzda sadece 3 döngüdür. Ayrıca, tamsayı kayıtları için sıfır gecikmeli MOV talimatları yoktur, bu nedenle g ++ 'ın boşa olduğu MOV talimatları aslında kritik yolun gecikmesini artırır ve Buldozer için çok önemlidir. Evet, el optimizasyonu, derleyiciyi işe yaramaz yönergeleri çiğnemek için yeterince modern olmayan CPU'lar için gerçekten önemli bir şekilde yendi.
Peter Cordes

95
" C ++ derleyicisinin daha iyi olduğunu iddia etmek çok kötü bir hatadır. Ve özellikle bu durumda. İnsan her zaman kodu daha iyi yapabilir ve bu özel sorun bu iddianın iyi bir örneğidir. " Bunu tersine çevirebilirsiniz ve aynı derecede geçerli olacaktır. . “ Bir insanın daha iyi olduğunu iddia etmek çok kötü bir hatadır. Ve özellikle bu durumda. İnsan her zaman kodu daha da kötüleştirebilir ve bu özel soru bu iddianın iyi bir örneğidir. ” Yani burada bir noktanız olduğunu düşünmüyorum , bu tür genellemeler yanlıştır.
luk32

5
@ luk32 - Ancak sorunun yazarı hiç bir argüman olamaz, çünkü montaj dili bilgisi sıfıra yakındır. İnsan ve derleyici hakkındaki her argüman, insanı en azından orta düzeyde bir asm bilgisi ile örtük olarak varsayar. Daha fazla: "İnsan yazılı kod her zaman daha iyi veya derleyici tarafından üretilen kodla aynı olacak" teoreminin resmi olarak kanıtlanması çok kolaydır.
johnfound

30
@ luk32: Yetenekli bir insan derleyici çıktısıyla başlayabilir (ve genellikle başlamalıdır). Dolayısıyla, gerçekten daha hızlı olduklarından (ayarladığınız hedef donanımda) emin olmak için girişimlerinizi karşılaştırdığınız sürece, derleyiciden daha kötü yapamazsınız. Ama evet, bunun biraz güçlü bir ifade olduğu konusunda hemfikirim. Derleyiciler genellikle acemi asm kodlayıcılardan çok daha iyi sonuç verir. Ancak derleyicilerin ortaya çıkardıklarına kıyasla bir veya iki komut kaydetmek genellikle mümkündür. (Uarch'a bağlı olarak her zaman kritik yolda değil). Oldukça kullanışlı karmaşık makine parçaları, ancak "akıllı" değiller.
Peter Cordes

24

Daha fazla performans için: Basit bir değişiklik n = 3n + 1'den sonra n'nin eşit olacağını gözlemler, böylece hemen 2'ye bölebilirsiniz. Ve n 1 olmayacak, bu yüzden test etmenize gerek yok. Böylece birkaç if ifadesini kaydedebilir ve yazabilirsiniz:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

İşte büyük bir kazanç: n'nin en düşük 8 bitine bakarsanız, 2 sekiz kata bölünene kadar tüm adımlar bu sekiz bit tarafından tamamen belirlenir. Örneğin, son sekiz bit 0x01 ise, bu ikilik sayınız ???? 0000 0001 o zaman sonraki adımlar:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Böylece tüm bu adımlar tahmin edilebilir ve 256k + 1'in yerini 81k + 1 almıştır. Tüm kombinasyonlar için benzer bir şey olacaktır. Böylece büyük bir anahtar deyimiyle bir döngü yapabilirsiniz:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Döngüyü n ≤ 128'e kadar çalıştırın, çünkü o noktada n, 2'ye kadar sekizden az bölme ile 1 olabilir ve bir seferde sekiz veya daha fazla adım atmak, ilk kez 1'e ulaştığınız noktayı kaçırmanıza neden olur. Sonra "normal" döngüye devam edin - ya da size kaç adım daha ulaşmanız gerektiğini söyleyen bir tablo hazırlayın 1.

PS. Peter Cordes'in önerisinin daha da hızlı hale getireceğinden şüpheleniyorum. Biri dışında hiçbir koşullu dal olmayacak ve döngü gerçekten sona erdiği zamanlar dışında doğru tahmin edilecektir. Kod şöyle bir şey olurdu

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

Pratikte, bir seferde son 9, 10, 11, 12 bitlik n'nin işlenmesinin daha hızlı olup olmayacağını ölçersiniz. Her bit için, tablodaki giriş sayısı iki katına çıkar ve tablolar artık L1 önbelleğine sığmadığında bir yavaşlama beklerim.

PPS. İşlem sayısına ihtiyacınız varsa: Her yinelemede iki tam sekiz bölüm ve değişken sayıda (3n + 1) işlem yaparız, bu nedenle işlemleri saymak için açık bir yöntem başka bir dizi olacaktır. Ancak aslında adım sayısını hesaplayabiliriz (döngünün yineleme sayısına dayanarak).

Sorunu biraz yeniden tanımlayabiliriz: n'i tek ise (3n + 1) / 2 ile değiştirin ve n'yi n / 2 ile değiştirin. Sonra her yineleme tam 8 adım yapacak, ama hile düşünebilirsiniz :-) Yani r <n - 3n + 1 işlemleri ve s <- n / 2 işlemleri olduğunu varsayalım. Sonuç tam olarak n '= n * 3 ^ r / 2 ^ s olacaktır, çünkü n <- 3n + 1, n <- 3n * (1 + 1 / 3n) anlamına gelir. Logaritmayı alarak r = (s + log2 (n '/ n)) / log2 (3) buluyoruz.

Döngüyü n ≤ 1,000,000'e kadar yaparsak ve n ≤ 1,000,000 herhangi bir başlangıç ​​noktasından kaç tekrarlamaya ihtiyaç duyulduğu önceden hesaplanmış bir tabloya sahipsek, en yakın tamsayıya yuvarlanmış olarak yukarıdaki gibi r'yi hesaplamak, s gerçekten büyük olmadıkça doğru sonucu verecektir.


2
Veya bir anahtar yerine çarpma ve sabit ekleme için veri arama tabloları oluşturun. İki 256 girişli tabloyu indekslemek bir atlama tablosundan daha hızlıdır ve derleyiciler muhtemelen bu dönüşümü aramamaktadır.
Peter Cordes

1
Hmm, bu gözlemin bir an için Collatz varsayımını kanıtlayabileceğini düşündüm, ama hayır, elbette hayır. Olası her 8 bit için, hepsi bitene kadar sınırlı sayıda adım vardır. Ancak bu 8 bitlik desenlerden bazıları, bit dizisinin geri kalanını 8'den fazla uzatır, bu nedenle bu sınırsız büyümeyi veya tekrar eden bir döngüyü dışlayamaz.
Peter Cordes

Güncellemek countiçin üçüncü bir diziye ihtiyacınız var, değil mi? adders[]size kaç tane sağ-vardiya yapıldığını söylemiyor.
Peter Cordes

Daha büyük tablolar için, önbellek yoğunluğunu artırmak için daha dar türler kullanmaya değer. Çoğu mimaride, a'dan sıfır uzayan bir yük uint16_tçok ucuzdur. X86 üzerinde, sadece ucuza olarak sıfır-uzanan 32-bit itibaren var unsigned intetmek uint64_t. (Sadece bir yük liman uop ihtiyacı Intel CPU'lar üzerinde bellekten MOVZX ama AMD CPU'lar yanı ALU gerek yoktur.) Ah BTW, neden kullanıyorsunuz size_tiçin lastBits? 32 bit tipinde -m32ve hatta -mx32(32 bit işaretçilerle uzun mod). Kesinlikle yanlış tip n. Sadece kullan unsigned.
Peter Cordes

20

Oldukça ilgisiz bir kayda göre: daha fazla performans kesmek!

  • [ilk «varsayım» nihayet @ShreevatsaR tarafından tartışıldı; kaldırıldı]

  • Sekanstan geçerken, mevcut elemanın 2 mahallesinde sadece 3 olası durum elde edebiliriz N (ilk gösterilen):

    1. [tek çift]
    2. [tek çift]
    3. [çift] [çift]

    Hesaplamak için aşağıdaki 2 unsurlar vasıtasıyla geçmiş sıçrama için (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1ve N >> 2sırasıyla.

    Her iki durumda da (1) ve (2) için ilk formülü kullanmak mümkün olduğunu kanıtlayalım (N >> 1) + N + 1.

    Durum (1) açıktır. Durum (2) (N & 1) == 1, N'nin 2-bit uzunluğunda olduğunu ve bitlerinin baen önemliden en önemsiz olduğunu varsayarsak (genelliği kaybetmeden) a = 1:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    nerede B = !b. İlk sonucun sağa kaydırılması bize tam olarak istediğimizi verir.

    QED: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Kanıtlandığı gibi, tek bir üçlü işlem kullanarak sekans 2 elemanlarını tek seferde geçebiliriz. Başka bir 2 kat daha fazla zaman kaybı.

Ortaya çıkan algoritma şöyle görünür:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

İşte karşılaştırıyoruz n > 2 çünkü dizinin toplam uzunluğu tekse, işlem 1 yerine 2'de durabilir.

[DÜZENLE:]

Bunu meclise çevirelim!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Derlemek için şu komutları kullanın:

nasm -f elf64 file.asm
ld -o file file.o

Godbolt'taki Peter Cordes'un C ve geliştirilmiş / hata düzeltilmiş versiyonuna bakın . (editörün notu: Öğelerimi cevabınıza koyduğum için özür dilerim, ancak cevabım Godbolt bağlantılarından + metinden 30 bin karakter sınırına ulaştı!)


2
Hiçbir ayrılmaz yoktur Qböyle 12 = 3Q + 1. İlk noktan doğru değil, methinks.
Veedrac

1
@Veedrac: Bununla uğraşıyor: ROR / TEST ve sadece bir CMOV kullanılarak bu cevaptaki uygulamadan daha iyi bir asm ile uygulanabilir. Benim CPU üzerindeki bu asm kodu sonsuz-döngüler, bu görünüşte ile SHRD veya ROR sonra tanımlanmamış arasında dayandığı sayısı> 1. Aynı zamanda kaçınmak için denemek için her çareye başvurur mov reg, imm32, görünüşe bayt kaydetmek için, ama sonra kullandığı 64-bit sürümü her yerde, hatta için xor rax, rax, bu yüzden çok sayıda gereksiz REX önekleri var. Taşmayı nönlemek için sadece iç döngüde tutan regs üzerinde REX'e ihtiyacımız var .
Peter Cordes

1
Zamanlama sonuçları (bir Core2Duo E6600'den: Merom 2.4GHz. Karmaşık-LEA = 1c gecikme süresi, CMOV = 2c) . En iyi tek adımlı asm iç döngü uygulaması (Johnfound'dan): Bu @main döngüsünün çalışması başına 111 ms. Bu C'nin gizlenmiş sürümümden derleyici çıktısı (bazı tmp değişkenleriyle): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108ms. Clang'ın asm iç döngüsünün geliştirilmiş versiyonundan: 92ms (karmaşık LEA'nın 1c değil 3c olduğu SnB ailesinde çok daha büyük bir gelişme görmeliyim). Bu asm döngüsünün gelişmiş + çalışma sürümümden (SHRD değil ROR + TEST kullanarak): 87ms. Yazdırmadan önce 5 tekrar ile ölçüldü
Peter Cordes

2
İşte ilk 66 kayıt ayarlayıcı (OEIS'te A006877); Hatta çiftleri kalın olarak işaretledim: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 626331, 837799, 1117065, 1501353, 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR

1
@hidefromkgb Harika! Ve şimdi diğer noktanızı da takdir ediyorum: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1 ve 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. Güzel gözlem!
ShreevatsaR

6

C ++ programları, kaynak koddan makine kodu oluşturulması sırasında derleme programlarına çevrilir. Montajın C ++ 'dan daha yavaş olduğunu söylemek neredeyse yanlış olur. Ayrıca, oluşturulan ikili kod derleyiciden derleyiciye farklılık gösterir. Böylece akıllı bir C ++ derleyicisi , ikili kodun aptal bir montajcının kodundan daha optimum ve verimli üretilmesini sağlayabilir .

Ancak profilleme yönteminizin bazı kusurları olduğuna inanıyorum. Profil oluşturma için genel yönergeler şunlardır:

  1. Sisteminizin normal / boş durumda olduğundan emin olun. Başlattığınız veya CPU'yu yoğun olarak kullanan (veya ağ üzerinden yoklama) çalışan tüm işlemleri (uygulamaları) durdurun.
  2. Veri boyutunuzun boyutu daha büyük olmalıdır.
  3. Testiniz 5-10 saniyeden uzun sürmelidir.
  4. Sadece bir numuneye güvenmeyin. Testinizi N kez yapın. Sonuçları toplayın ve sonucun ortalamasını veya ortalamasını hesaplayın.

Evet, resmi bir profil oluşturmadım ama ikisini de birkaç kez çalıştırdım ve 3 saniyeden 2 saniye anlatabiliyorum. Her neyse cevap verdiğiniz için teşekkürler. Zaten burada iyi bir bilgi aldım
jeffer oğul

9
Muhtemelen sadece bir ölçüm hatası değildir , elle yazılmış asm kodu sağa kaydırma yerine 64 bit DIV komutunu kullanıyor. Cevabımı gör. Ancak evet, doğru ölçüm yapmak da önemlidir.
Peter Cordes

7
Madde işaretleri kod bloğundan daha uygun biçimlendirmedir. Lütfen metninizi kod bloğuna koymayı bırakın, çünkü kod değildir ve tek aralıklı yazı tipinden yararlanamaz.
Peter Cordes

16
Bunun soruyu nasıl cevapladığını gerçekten göremiyorum. Bu meclis kodu veya C ++ kod olmadığı konusunda belirsiz bir soru değil belki daha hızlı olması --- o konuda çok spesifik bir sorudur asıl kod o yardımsever söz kendisinde temin ediyor. Cevabınız bu koddan hiç bahsetmiyor veya herhangi bir karşılaştırma yapmıyor. Elbette, nasıl kıyaslanacağına dair ipuçlarınız temel olarak doğrudur, ancak gerçek bir cevap vermek için yeterli değildir.
Cody Gray

6

Collatz sorunu için, "kuyrukları" önbelleğe alarak performansında önemli bir artış elde edebilirsiniz. Bu bir zaman / bellek değiş tokuşu. Bkz . Notlama ( https://en.wikipedia.org/wiki/Memoization ). Diğer zaman / bellek değişimleri için dinamik programlama çözümlerini de inceleyebilirsiniz.

Örnek python uygulaması:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))

1
gnasher'ın cevabı kuyrukları önbelleğe almaktan çok daha fazlasını yapabileceğinizi gösteriyor: yüksek bitler bir sonra olacakları etkilemez ve add / mul sadece taşı sola yayar, böylece yüksek bitler düşük bitlere olanları etkilemez. yani, bir kerede 8 bit (veya herhangi bir sayı) bit gitmek için çarpma ve bitlerin geri kalanına uygulamak için sabitler ekleme için LUT aramalarını kullanabilirsiniz. kuyrukları hatırlamak, bu gibi birçok problemde kesinlikle yararlıdır ve bu sorun için henüz daha iyi bir yaklaşım düşünmediyseniz veya doğru olmadığını kanıtlayın.
Peter Cordes

2
Gnasher'in yukarıdaki fikrini doğru bir şekilde anlarsam, kuyruk belleğinin dikey bir optimizasyon olduğunu düşünüyorum. Böylece her ikisini de yapabilirsiniz. Gnasher'ın algoritmasına not eklemekten ne kadar kazanabileceğinizi araştırmak ilginç olurdu.
Emanuel Landeholm

2
Belki de sonuçların yoğun kısmını saklayarak notu daha ucuz hale getirebiliriz. N için bir üst sınır belirleyin ve bunun üzerinde hafızayı bile kontrol etmeyin. Bunun altında, hash işlevi olarak hash (N) -> N kullanın, bu nedenle key = dizideki konum ve saklanması gerekmez. 0Henüz mevcut olmayan araçların girişi . Tabloda yalnızca tek N'yi depolayarak daha da optimize edebiliriz, böylece hash işlevi n>>11'i atar n>>tzcnt(n). Garip olduğundan emin olmak için her zaman bir veya bir şeyle bitecek adım kodunu yazın .
Peter Cordes

1
Bu, bir dizinin ortasındaki çok büyük N değerlerinin birden fazla dizide ortak olma olasılığının daha düşük olduğu fikrime dayanıyor, bu yüzden onları hatırlamamaktan çok fazla şey kaçırmıyoruz. Ayrıca, makul büyüklükte bir N, çok büyük N ile başlayanlar da dahil olmak üzere birçok uzun dizinin bir parçası olacaktır. Rasgele anahtarları depolayabilecek bir tablo.) Yakındaki N başlangıcının sıra değerlerinde benzerlik olup olmadığını görmek için herhangi bir isabet oranı testi yaptınız mı?
Peter Cordes

2
Bazı büyük N için tüm n <N için önceden hesaplanmış sonuçları depolayabilirsiniz. Böylece bir karma tablonun ek yüküne ihtiyacınız yoktur. Bu tablodaki veriler olacak her başlangıç değeri için en sonunda kullanılabilir. Sadece Collatz dizisinin her zaman sona erdiğini doğrulamak istiyorsanız (1, 4, 2, 1, 4, 2, ...): Bu işlemin n> 1 için olduğunu kanıtlamaya eşdeğer olduğu kanıtlanabilir. orijinalinden daha az olmak Ve bunun için, önbellek kuyrukları yardımcı olmaz.
gnasher729

5

Yorumlardan:

Ancak, bu kod asla durmaz (tamsayı taşması nedeniyle)!?! Yves Daoust

Birçok numaraları için o olacak değil taşacak.

O takdirde edecektir taşacak - o şanssız başlangıç tohumların biri için, overflown sayısı çok büyük olasılıkla başka taşma olmadan 1 doğru yakınlaşırlar olacaktır.

Yine de bu ilginç bir soru teşkil ediyor, taşma-siklik tohum sayısı var mı?

Herhangi bir basit yakınsama serisi iki değer gücü ile başlar (yeterince açık?).

2 ^ 64 sıfıra taşacak, bu algoritmaya göre tanımlanmamış sonsuz döngü (sadece 1 ile bitiyor), ancak shr raxZF = 1 üretilmesinden dolayı cevapta en uygun çözüm bitecek .

2 ^ 64 üretebilir miyiz? Başlangıç ​​numarası 0x5555555555555555tek sayı ise, sonraki sayı 3n + 1'dir; bu 0xFFFFFFFFFFFFFFFF + 1= 0. Teorik olarak tanımlanmamış algoritma durumunda, ancak johnfound'un optimize edilmiş cevabı ZF = 1'den çıkarak iyileşecektir. cmp rax,1Peter Cordes sonsuz bir döngüde sona erecek (QED varyantı 1, tanımsız ile "Cheapo" 0numarası).

Olmadan döngü yaratacak daha karmaşık bir sayıya ne dersiniz 0? Açıkçası, emin değilim, Matematik teorim herhangi bir ciddi fikir almak için çok puslu, nasıl ciddi bir şekilde başa çıkmak için. Ancak sezgisel olarak, serinin her sayı için 1'e yakınlaşacağını söyleyebilirim: 0 <sayı, çünkü 3n + 1 formülü, orijinal sayının (veya ara) 2 asal olmayan her faktörünü er ya da geç 2 gücüne yavaş yavaş dönüştürecek . Bu yüzden orijinal seriler için sonsuz döngü hakkında endişelenmemize gerek yok, sadece taşma bizi engelleyebilir.

Bu yüzden sayfaya birkaç sayı koydum ve 8 bitlik kesik sayılara baktım.

Orada taşan üç değer vardır 0: 227, 170ve 85( 85doğrudan gidiş 0yönünde ilerleyen diğer iki, 85).

Ancak döngüsel taşma tohumu oluşturmanın bir değeri yoktur.

Funnily yeterli bir kontrol yaptım, hangi ilk sayı 8 bit kesme muzdarip ve zaten 27etkilenen! 9232Uygun kesilmemiş serilerde değere ulaşır (ilk kesilmiş değer 32212. adımdadır) ve 2-255 giriş numaralarından herhangi biri için kesilmemiş bir şekilde ulaşılan maksimum değer 13120( 255kendisi için), maksimum adım sayısıdır. yakınsama 1yaklaşık 128(+ -2, "1" sayılacak mı emin değilim, vb ...).

İlginç bir şekilde (benim için) sayı 9232, diğer birçok kaynak numarası için maksimum, bu kadar özel olan ne? : -O 9232= 0x2410... hmmm .. hiçbir fikrim yok.

Ne yazık ki neden yakınsama ve bunları kesiliyor etkileri nelerdir gelmez, bu serinin herhangi derin bir kavrayışa alamayan k bitleri, fakat cmp number,1sonlandırma koşulu o belirli giriş değeri olarak biten sonsuz döngüye algoritma koymak kesinlikle mümkün 0sonra kesme.

Ancak 278 bitlik durum için taşan değer bir çeşit uyarıcıdır, değere ulaşmak için adım sayısını sayarsanız 1, toplam k-bit tamsayı kümesindeki sayıların çoğunluğu için yanlış sonuç alırsınız. 8 bitlik tamsayılar için 256'dan 146 rakamı kesilmeden seriyi etkiledi (bazıları hala yanlışlıkla doğru sayıda adımı çarpabilir, belki kontrol etmek için çok tembelim).


"Taşma sayısı büyük olasılıkla başka taşma olmadan 1'e yakınlaşacaktır": kod asla durmaz. (Bu emin olmak için zamanın sonuna kadar bekleyemeyeceğim bir varsayımdır ...)
Yves Daoust

@Yvesdaoust oh, ama öyle mi? ... örneğin 278b kesilmiş seri şu şekilde görünür: 82 41 124 62 31 94 47 142 71 214 107 66 (kesilmiş) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (geri kalanı kesilmeden çalışır). Seni anlamadım üzgünüm. Kesilen değer şu anda devam eden seride daha önce ulaşılan değerlere eşit olursa asla durmazdı ve k-bit kesilmesine karşı böyle bir değer bulamıyorum (ama ya arkasındaki Matematik teorisini anlayamıyorum, neden Bu, 8/16/32/64 bitlik kesmeyi tutar, sadece sezgisel olarak işe yaradığını düşünüyorum).
Ped7g

1
Orijinal sorun tanımını daha önce kontrol etmeliydim: "Henüz kanıtlanmamış olmasına rağmen (Collatz Sorunu), tüm başlangıç ​​numaralarının 1'de bittiği düşünülüyor." ... Tamam, benim sınırlı puslu Matematik bilgisiyle bunun kavrayışa alamayan şaşmamak ...: D Ve sac deneylerinden Ben her için yakinsar size temin ederim 2- 255Ya kesilmeden (için, sayı 1,) veya 8 bit kesme ile (beklenen 1veya 0üç sayı için).
Ped7g

Hem, asla durmadığını söylediğimde, yani ... durmadığını. İsterseniz verilen kod sonsuza kadar çalışır.
Yves Daoust

1
Taşma sırasında neler olduğuna dair analiz için oy verildi. CMP tabanlı döngü de sıfır olarak sonlandırmak için cmp rax,1 / jna(yani do{}while(n>1)) kullanabilir. Taşmaya nne kadar yaklaştığımıza dair bir fikir vermek için, görülen maksimum değeri kaydeden döngünün enstrümanlı bir versiyonunu yapmayı düşündüm .
Peter Cordes

5

Derleyici tarafından oluşturulan kodu göndermediniz, bu yüzden burada bazı tahminler var, ancak görmeden bile, şunu söyleyebiliriz:

test rax, 1
jpe even

... şubeyi yanlış anlama şansı% 50'dir ve bu pahalı olacaktır.

Derleyici neredeyse her iki hesaplamayı da yapar (div / mod oldukça uzun bir gecikme olduğu için pazarlık payı daha fazladır, bu nedenle çarpma eki "ücretsiz" dir) ve bir CMOV ile devam eder. Elbette, yanlış tahmin edilme olasılığı yüzde sıfırdır .


1
Dallanmanın bir modeli vardır; örneğin, tek bir sayının ardından her zaman çift bir sayı gelir. Ama bazen 3n + 1 birden fazla sıfır biti bırakır ve o zaman bu yanlış tahmin eder. Cevabımda bölüm hakkında yazmaya başladım ve OP kodundaki diğer büyük kırmızı bayrağı ele almadım. (Eşlik koşulu kullanmanın sadece JZ veya CMOVZ ile karşılaştırıldığında gerçekten garip olduğunu da unutmayın. CPU için de daha kötüdür, çünkü Intel CPU'lar TEST / JZ'yi makro olarak birleştirebilir, ancak TEST / JPE'yi birleştiremez. Agner Fog, AMD'nin Herhangi bir JCC ile TEST / CMP, bu durumda sadece insan okuyucular için daha kötü)
Peter Cordes

5

Montaja bakmadan bile, en belirgin neden /= 2muhtemelen >>=1birçok işlemcinin çok hızlı bir vites değiştirme işlemine sahip olması gibi optimize edilmesidir . Ancak bir işlemcinin bir kaydırma işlemi olmasa bile, tamsayı bölümü kayan nokta bölümünden daha hızlıdır.

Düzenleme: milaj yukarıdaki "tamsayı bölümü kayan nokta bölümünden daha hızlıdır" deyiminde değişebilir. Aşağıdaki yorumlar, modern işlemcilerin tamsayı bölümü üzerinde fp bölünmesini optimize etmeye öncelik verdiklerini ortaya koymaktadır. Yani eğer birisi bu parçacığının sorusu hakkında, daha sonra derleyici Optimizasyon konusunda sorar Speedup için en olası nedeni aradığını /=2olarak >>=1bakmak için en iyi 1 yer olurdu.


Üzerinde ilgisiz bir not varsa, ngarip, ifadesi n*3+1bile her zaman olacaktır. Yani kontrol etmeye gerek yok. Bu dalı şu şekilde değiştirebilirsiniz:

{
   n = (n*3+1) >> 1;
   count += 2;
}

Böylece tüm açıklama

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

4
Tamsayı bölümü aslında modern x86 işlemcilerdeki FP bölümünden daha hızlı değildir. Bunun Intel / AMD'nin FP bölücülerine daha fazla transistör harcamasından kaynaklandığını düşünüyorum, çünkü bu daha önemli bir işlem. (Sabitlerle tamsayı bölme, modüler bir ters ile çarpma için optimize edilebilir). Agner Sis'in insn tablolarını kontrol edin ve DIVSD'yi (çift kesinlikli float) DIV r32(32 bit işaretsiz tam sayı) veya DIV r64(çok daha yavaş 64 bit işaretsiz tam sayı) ile karşılaştırın. Özellikle verim için, FP bölümü çok daha hızlıdır (mikro kodlu yerine tek uop ve kısmen boru hattı), ancak gecikme de daha iyidir.
Peter Cordes

1
OP'nin Haswell CPU'sunda: DIVSD, 1 uop, 10-20 döngü gecikme süresi, her 8-14c verim için birdir. div r6436 uops, 32-96c gecikme süresi ve 21-74c verim başına bir. Skylake, daha hızlı FP bölme verimine sahiptir (çok daha iyi gecikme olmadan 4c'de bir adet boru hattına sahiptir), ancak çok daha hızlı tamsayı div değil. AMD Bulldozer ailesinde işler benzer: DIVSD 1M-op, 9-27c gecikme süresi, 4.5-11c verim başına bir. div r6416M-ops, 16-75c gecikme süresi, 16-75c verim başına bir.
Peter Cordes

1
FP bölümü temel olarak tamsayı-çıkarma üsleri, tamsayı-bölme mantisleri, denormalleri tespit etmekle aynı şey değil mi? Ve bu 3 adım paralel olarak yapılabilir.
MSalters

2
@MSalters: evet, kulağa doğru geliyor, ama sonunda normalleştirme adımı ile üs ve mantiss arasında geçiş biti. double53 bitlik bir mantis var, ancak div r32Haswell'den daha yavaş . Bu kesinlikle Intel / AMD'nin ne kadar donanım sorunu attığına bağlı, çünkü hem tamsayı hem de fp bölücüler için aynı transistörleri kullanmıyorlar. Tamsayı skalerdir (tamsayı-SIMD bölünmesi yoktur) ve bir vektör 128b vektörleri (diğer vektör ALU'ları gibi 256b değil) işler. Büyük olan şey, tamsayı div'ın birçok uops, çevredeki kod üzerinde büyük etkisi olmasıdır.
Peter Cordes

Hata, mantis ve üs arasında bitleri kaydırmak değil, mantisi bir vardiya ile normalleştirmek ve üsse kaydırma miktarını eklemek.
Peter Cordes

4

Genel olarak bu göreve yönelik olmayan genel bir yanıt olarak: Çoğu durumda, yüksek düzeyde iyileştirmeler yaparak herhangi bir programı önemli ölçüde hızlandırabilirsiniz. Verileri birden çok kez yerine bir kez hesaplamak, gereksiz işlerden tamamen kaçınmak, önbellekleri en iyi şekilde kullanmak vb. Bunların üst düzeyde bir dilde yapılması çok daha kolaydır.

Montajcı kodu yazarak, optimize edici bir derleyicinin ne yaptığını geliştirmek mümkündür , ancak zor bir iştir. Ve bir kez yapıldıktan sonra, kodunuzu değiştirmek çok daha zordur, bu nedenle algoritmik iyileştirmeler eklemek çok daha zordur. Bazen işlemcinin üst düzey bir dilden kullanamayacağınız bir işlevi vardır, bu durumlarda satır içi montaj genellikle yararlıdır ve yine de üst düzey bir dil kullanmanıza izin verir.

Euler problemlerinde, çoğu zaman bir şey inşa ederek, neden yavaş olduğunu, daha iyi bir şey inşa ederek, neden yavaş olduğunu bularak vb. Başarılı olursunuz. Montajcıyı kullanmak çok ama çok zor. Olası hızın yarısında daha iyi bir algoritma genellikle tam hızda daha kötü bir algoritmayı yener ve toplayıcıda tam hızı elde etmek önemsiz değildir.


2
Buna tamamen katılıyorum. gcc -O3tam algoritma için Haswell'de en iyi% 20'lik bir kod oluşturdu. (Yani, soru sorulan bu, ve ilginç bir cevabı var çünkü sadece o kat hızlanma alınıyor cevabım ana odak noktası idi değil bunun doğru bir yaklaşım olduğu için.) Çok daha büyük speedups derleyici aramaya son derece uzak ihtimal olduğunu dönüşümler elde edildi , doğru vardiyaları ertelemek veya bir seferde 2 adım atmak gibi. Çok daha büyük hızlanmalar not / arama tablolarından elde edilebilir. Hala kapsamlı test, ancak saf kaba kuvvet değil.
Peter Cordes

2
Yine de, açıkça doğru olan basit bir uygulamaya sahip olmak, diğer uygulamaları test etmek için son derece yararlıdır. Yapacağım şey muhtemelen gcc'nin beklediğim gibi (çoğunlukla meraktan) dalsızca yapılıp yapılmadığını görmek için asm çıktısına bakmak ve daha sonra algoritmik iyileştirmelere geçmek.
Peter Cordes

-2

Basit cevap:

  • MOV RBX, 3 ve MUL RBX yapmak pahalıdır; RBX, RBX'i iki kez ekleyin

  • ADD 1 muhtemelen INC'den daha hızlı

  • MOV 2 ve DIV çok pahalıdır; sadece sağa kaydır

  • 64 bit kod genellikle 32 bit koddan belirgin şekilde daha yavaştır ve hizalama sorunları daha karmaşıktır; bunun gibi küçük programlarla bunları paketlemeniz gerekir, böylece 32 bit koddan daha hızlı olma şansına sahip olmak için paralel hesaplama yaparsınız

C ++ programınız için derleme listesini oluşturursanız, derlemenin derlemenizden nasıl farklı olduğunu görebilirsiniz.


4
1): 3 kez eklemek LEA'ya kıyasla aptal olacaktır. Ayrıca mul rbxOP'nin Haswell CPU'sunda 3c gecikme ile 2 uops vardır (ve saat başına 1 işlem). imul rcx, rbx, 3sadece 1 uop, aynı 3c gecikme süresine sahip. İki ADD komutu 2 u gecikmeli 2 uops olacaktır.
Peter Cordes

5
2) ADD 1 muhtemelen INC'den daha hızlıdır . Hayır, OP bir Pentium4 kullanmıyor . 3. noktanız bu cevabın tek doğru kısmıdır.
Peter Cordes

5
4) tamamen saçmalık gibi geliyor. İşaretçi ağır veri yapıları ile 64 bit kod daha yavaş olabilir, çünkü daha büyük işaretçiler daha büyük önbellek alanı anlamına gelir. Ancak bu kod yalnızca kayıtlarda çalışır ve 32 ve 64 bit modunda kod hizalama sorunları aynıdır. (Veri hizalama sorunları da öyle, hizalamanın x86-64 için daha büyük bir sorun olmasıyla ilgili hiçbir ipucu yok). Her neyse, kod döngü içindeki belleğe bile dokunmuyor.
Peter Cordes

Yorum yapan kişinin ne hakkında konuştuğunu bilmiyor. 64-bit CPU üzerinde bir MOV + MUL yapın, iki kez kendisine bir kayıt eklemekten kabaca üç kat daha yavaş olacaktır. Diğer yorumları da aynı derecede yanlış.
Tyler Durden

6
MOV + MUL kesinlikle aptal, ama MOV + ADD + ADD hala saçma (aslında ADD RBX, RBXiki kez yapmak 3 ile değil, 4 ile çarpılır). Şimdiye kadar en iyi yol lea rax, [rbx + rbx*2]. Ya da, bunu 3 bileşenli bir LEA yapma pahasına, +1 ile de yapın lea rax, [rbx + rbx*2 + 1] (cevabımda açıkladığım gibi, HSW'de 1 yerine 3c gecikme) Demek istediğim, 64-bit çarpmanın çok pahalı olmadığıydı. son Intel CPU'ları, çünkü inanılmaz derecede hızlı tamsayı çarpma ünitelerine sahipler ( MUL r644c verimi başına 6c gecikme olduğu AMD'ye kıyasla : hatta tamamen boru hattında değil)
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.