Saya menulis dua solusi ini untuk Project Euler Q14 , dalam perakitan dan dalam C ++. Mereka adalah pendekatan brute force yang sama identik untuk menguji dugaan Collatz . Solusi perakitan dirakit dengan
nasm -felf64 p14.asm && gcc p14.o -o p14
C ++ dikompilasi dengan
g++ p14.cpp -o p14
Majelis, 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;
}
Saya tahu tentang optimisasi kompiler untuk meningkatkan kecepatan dan segalanya, tetapi saya tidak melihat banyak cara untuk mengoptimalkan solusi perakitan saya lebih lanjut (berbicara secara terprogram bukan matematis).
Kode C ++ memiliki modulus setiap istilah dan pembagian setiap istilah genap, di mana perakitan hanya satu divisi per istilah genap.
Tetapi perakitan mengambil rata-rata 1 detik lebih lama dari solusi C ++. Kenapa ini? Saya bertanya terutama karena rasa ingin tahu.
Waktu eksekusi
Sistem saya: 64 bit Linux pada 1.4 GHz Intel Celeron 2955U (Haswell microarchitecture).
g++
(tidak dioptimalkan): rata-rata 1272 msg++ -O3
rata-rata 578 msasm (div) asli avg 2650 ms
Asm (shr)
rata-rata 679 ms@ johnfound asm , dirakit dengan nasm rata-rata 501 ms
@hidefromkgb asm rata-rata 200 ms
@hidefromkgb asm dioptimalkan oleh @Peter Cordes rata-rata 145 ms
@Veedrac C ++ rata-rata 81 ms dengan
-O3
, 305 ms dengan-O0
sumber
-S
untuk mendapatkan perakitan yang dihasilkan oleh kompiler. Kompiler cukup pintar untuk menyadari bahwa modulus melakukan pembagian pada saat yang sama.Jawaban:
Jika Anda berpikir instruksi DIV 64-bit adalah cara yang baik untuk membaginya dengan dua, maka tidak heran output kompiler mengalahkan kode tulisan tangan Anda, bahkan dengan
-O0
(kompilasi cepat, tanpa optimasi tambahan, dan simpan / muat ulang ke memori setelah / sebelum setiap pernyataan C sehingga debugger dapat memodifikasi variabel).Lihat panduan Perakitan Mengoptimalkan Agner Fog untuk mempelajari cara menulis asm efisien. Dia juga memiliki tabel instruksi dan panduan microarch untuk detail spesifik untuk CPU tertentu. Lihat jugax86 beri tag wiki untuk lebih banyak tautan perf.
Lihat juga pertanyaan yang lebih umum tentang mengalahkan compiler dengan asm yang ditulis tangan: Apakah bahasa assembly inline lebih lambat daripada kode C ++ asli? . TL: DR: ya jika Anda salah melakukannya (seperti pertanyaan ini).
Biasanya Anda baik-baik saja membiarkan kompiler melakukan tugasnya, terutama jika Anda mencoba menulis C ++ yang dapat dikompilasi secara efisien . Lihat juga apakah perakitan lebih cepat daripada bahasa yang dikompilasi? . Salah satu tautan jawaban ke slide rapi ini menunjukkan bagaimana berbagai kompiler C mengoptimalkan beberapa fungsi yang sangat sederhana dengan trik keren. Pembicaraan CppCon2017 Matt Godbolt akhir-akhir ini, “ Apa yang Telah Dilakukan Penyusun Saya untuk Saya? Membuka kunci Tutup Pengumpul ”dengan nada yang sama.
Pada Intel Haswell,
div r64
adalah 36 uops, dengan latensi 32-96 siklus , dan throughput satu per 21-74 siklus. (Ditambah 2 uops untuk mengatur RBX dan nol RDX, tetapi eksekusi out-of-order dapat menjalankannya lebih awal). Instruksi penghitungan-tinggi seperti DIV di-mikrokodekan, yang juga dapat menyebabkan kemacetan front-end. Dalam hal ini, latensi adalah faktor yang paling relevan karena merupakan bagian dari rantai ketergantungan yang digerakkan oleh loop.shr rax, 1
melakukan pembagian unsigned yang sama: Ini 1 uop, dengan latensi 1c , dan dapat menjalankan 2 siklus per jam.Sebagai perbandingan, pembagian 32-bit lebih cepat, tetapi masih mengerikan vs bergeser.
idiv r32
adalah 9 uops, 22-29c latency, dan satu per 8-11c throughput di Haswell.Seperti yang Anda lihat dari melihat
-O0
output asm gcc ( Godbolt compiler explorer ), ia hanya menggunakan instruksi shift . dentang-O0
memang mengkompilasi secara naif seperti yang Anda pikirkan, bahkan menggunakan IDIV 64-bit dua kali. (Ketika mengoptimalkan, kompiler memang menggunakan kedua output IDIV ketika sumber melakukan pembagian dan modulus dengan operan yang sama, jika mereka menggunakan IDIV sama sekali)GCC tidak memiliki mode yang sepenuhnya naif; selalu berubah melalui GIMPLE, yang berarti beberapa "optimisasi" tidak dapat dinonaktifkan . Ini termasuk mengenali pembagian-demi-konstan dan menggunakan shift (kekuatan 2) atau invers multiplikasi titik tetap (bukan kekuatan 2) untuk menghindari IDIV (lihat
div_by_13
di tautan godbolt di atas).gcc -Os
(optimalkan untuk ukuran) memang menggunakan IDIV untuk divisi non-power-of-2, sayangnya bahkan dalam kasus-kasus di mana kode inversi multiplikasi hanya sedikit lebih besar tetapi jauh lebih cepat.Membantu kompiler
(ringkasan untuk kasus ini: gunakan
uint64_t n
)Pertama-tama, hanya menarik untuk melihat output kompiler yang dioptimalkan. (
-O3
).-O0
kecepatan pada dasarnya tidak ada artinya.Lihatlah output asm Anda (pada Godbolt, atau lihat Bagaimana menghapus "noise" dari GCC / output rakitan? ). Ketika kompiler tidak membuat kode optimal di tempat pertama: Menulis sumber C / C ++ Anda dengan cara yang memandu kompiler membuat kode yang lebih baik biasanya merupakan pendekatan terbaik . Anda harus tahu ASM, dan tahu apa yang efisien, tetapi Anda menerapkan pengetahuan ini secara tidak langsung. Compiler juga merupakan sumber ide yang bagus: kadang-kadang dentang akan melakukan sesuatu yang keren, dan Anda dapat menahan gcc untuk melakukan hal yang sama: lihat jawaban ini dan apa yang saya lakukan dengan loop yang tidak terbuka dalam kode @ Veedrac di bawah.)
Pendekatan ini portabel, dan dalam 20 tahun beberapa kompiler masa depan dapat mengkompilasinya ke apa pun yang efisien pada perangkat keras masa depan (x86 atau tidak), mungkin menggunakan ekstensi ISA baru atau auto-vektorisasi. Tulisan tangan x86-64 asm dari 15 tahun yang lalu biasanya tidak akan optimal untuk Skylake. misal bandingkan & cabang-fusi makro tidak ada saat itu. Apa yang optimal sekarang untuk asm kerajinan tangan untuk satu mikroarsitektur mungkin tidak optimal untuk CPU lainnya saat ini dan di masa depan. Komentar pada jawaban @ johnfound membahas perbedaan besar antara AMD Bulldozer dan Intel Haswell, yang memiliki pengaruh besar pada kode ini. Namun secara teori,
g++ -O3 -march=bdver3
dang++ -O3 -march=skylake
akan melakukan hal yang benar. (Atau-march=native
.) Atau-mtune=...
hanya menyetel, tanpa menggunakan instruksi yang mungkin tidak didukung oleh CPU lain.Perasaan saya adalah bahwa membimbing kompiler ke asm itu bagus untuk CPU saat ini yang Anda pedulikan seharusnya tidak menjadi masalah bagi kompiler masa depan. Mereka diharapkan lebih baik daripada kompiler saat ini dalam menemukan cara untuk mengubah kode, dan dapat menemukan cara yang bekerja untuk CPU di masa depan. Apapun, x86 masa depan mungkin tidak akan mengerikan pada apa pun yang baik pada x86 saat ini, dan kompiler masa depan akan menghindari jebakan asm-spesifik saat mengimplementasikan sesuatu seperti pergerakan data dari sumber C Anda, jika tidak melihat sesuatu yang lebih baik.
ASM tulisan tangan adalah kotak hitam untuk pengoptimal, jadi propagasi konstan tidak berfungsi saat inlining menjadikan input konstanta waktu kompilasi. Optimalisasi lainnya juga terpengaruh. Baca https://gcc.gnu.org/wiki/DontUseInlineAsm sebelum menggunakan asm. (Dan hindari asline inline gaya MSVC: input / output harus melalui memori yang menambah overhead .)
Dalam hal ini : Anda
n
memiliki tipe yang ditandatangani, dan gcc menggunakan urutan SAR / SHR / ADD yang memberikan pembulatan yang benar. (IDIV dan "putaran" pergeseran-aritmatika berbeda untuk input negatif, lihat SAR dan masukkan entri manual ref ). (IDK jika gcc mencoba dan gagal membuktikan bahwa itun
tidak boleh negatif, atau apa. Signed-overflow adalah perilaku yang tidak terdefinisi, jadi seharusnya bisa.)Anda seharusnya sudah menggunakannya
uint64_t n
, jadi bisa saja SHR. Dan itu portabel untuk sistem di manalong
hanya 32-bit (misalnya x86-64 Windows).BTW, output asm yang dioptimalkan gcc terlihat cukup baik (menggunakan )
unsigned long n
: loop internal itumain()
melakukan hal ini:Loop dalam tidak memiliki cabang, dan jalur kritis dari rantai ketergantungan loop-carry adalah:
Total: 5 siklus per iterasi, hambatan latensi . Eksekusi out-of-order menangani semua hal lain secara paralel dengan ini (dalam teori: Saya belum menguji dengan counter perf untuk melihat apakah itu benar-benar berjalan pada 5c / iter).
Input FLAGS dari
cmov
(diproduksi oleh TEST) lebih cepat untuk diproduksi daripada input RAX (dari LEA-> MOV), jadi itu bukan di jalur kritis.Demikian pula, MOV-> SHR yang menghasilkan input RDI CMOV berada di luar jalur kritis, karena juga lebih cepat daripada LEA. MOV di IvyBridge dan yang lebih baru memiliki latensi nol (ditangani saat register-rename). (Masih membutuhkan uop, dan slot di pipeline, jadi tidak gratis, hanya nol latensi). MOV ekstra dalam rantai depa LEA adalah bagian dari hambatan pada CPU lain.
Cmp / jne juga bukan bagian dari jalur kritis: ini bukan loop-carry, karena dependensi kontrol ditangani dengan prediksi cabang + eksekusi spekulatif, tidak seperti dependensi data pada jalur kritis.
Mengalahkan kompiler
GCC melakukan pekerjaan yang cukup bagus di sini. Itu bisa menyimpan satu byte kode dengan menggunakan
inc edx
alih-alihadd edx, 1
, karena tidak ada yang peduli tentang P4 dan dependensi-salahnya untuk instruksi memodifikasi flag parsial.Itu juga bisa menyimpan semua instruksi MOV, dan TEST: SHR mengeset CF = bitnya digeser, jadi kita bisa menggunakan
cmovc
alih-alihtest
/cmovz
.Lihat jawaban @ johnfound untuk trik pintar lainnya: hapus CMP dengan bercabang pada hasil flag SHR serta menggunakannya untuk CMOV: nol hanya jika n adalah 1 (atau 0) untuk memulai. (Fakta asyik : SHR dengan hitungan! = 1 di Nehalem atau sebelumnya menyebabkan kemacetan jika Anda membaca hasil flag . Begitulah cara mereka membuatnya menjadi satu-uop. Namun, pengodean khusus shift-by-1 baik-baik saja.)
Menghindari MOV sama sekali tidak membantu latensi di Haswell ( Bisakah MOV x86 benar-benar "bebas"? Mengapa saya tidak bisa mereproduksi ini sama sekali? ). Itu membantu secara signifikan pada CPU seperti Intel pre-IvB, dan keluarga AMD Bulldozer, di mana MOV bukan nol-latensi. Instruksi MOV yang terbuang dari kompiler mempengaruhi jalan kritis Kompleks BD-LEA dan CMOV keduanya memiliki latensi yang lebih rendah (masing-masing 2c dan 1c), jadi ini adalah fraksi yang lebih besar dari latensi. Juga, bottleneck throughput menjadi masalah, karena hanya memiliki dua pipa ALU integer. Lihat jawaban @ johnfound , di mana ia mendapatkan hasil timing dari CPU AMD.
Bahkan di Haswell, versi ini dapat sedikit membantu dengan menghindari beberapa penundaan di mana uop yang tidak kritis mencuri port eksekusi dari port yang ada di jalur kritis, menunda eksekusi dengan 1 siklus. (Ini disebut konflik sumber daya). Ini juga menyimpan register, yang dapat membantu ketika melakukan beberapa
n
nilai secara paralel dalam satu loop yang disisipkan (lihat di bawah).Latensi LEA tergantung pada mode pengalamatan , pada CPU Intel SnB-family. 3c untuk 3 komponen (
[base+idx+const]
, yang membutuhkan dua tambahan terpisah), tetapi hanya 1c dengan 2 atau lebih sedikit komponen (satu tambahan). Beberapa CPU (seperti Core2) bahkan melakukan 3 komponen LEA dalam satu siklus, tetapi SnB-family tidak. Lebih buruk lagi, keluarga Intel SnB menstandarisasi latensi sehingga tidak ada 2c uops , jika tidak, LEA 3 komponen hanya akan 2c seperti Bulldozer. (LEA 3 komponen lebih lambat pada AMD juga, hanya saja tidak sebanyak).Jadi
lea rcx, [rax + rax*2]
/inc rcx
hanya latensi 2c, lebih cepat daripadalea rcx, [rax + rax*2 + 1]
, pada CPU Intel SnB-family seperti Haswell. Break-even di BD, dan lebih buruk di Core2. Memang membutuhkan biaya tambahan, yang biasanya tidak layak untuk menyimpan latensi 1c, tetapi latensi adalah hambatan utama di sini dan Haswell memiliki saluran pipa yang cukup luas untuk menangani throughput tambahan uop.Baik gcc, icc, atau clang (on godbolt) menggunakan output CF SHR, selalu menggunakan AND atau TEST . Kompiler konyol. : P Mereka adalah mesin-mesin rumit yang hebat, tetapi manusia yang pandai seringkali dapat mengalahkan mereka dalam masalah skala kecil. (Diberikan ribuan hingga jutaan kali lebih lama untuk memikirkannya, tentu saja! Kompiler tidak menggunakan algoritma lengkap untuk mencari setiap cara yang mungkin untuk melakukan sesuatu, karena itu akan memakan waktu terlalu lama ketika mengoptimalkan banyak kode inline, yang adalah apa mereka melakukan yang terbaik. Mereka juga tidak memodelkan pipa dalam mikroarsitektur target, setidaknya tidak dalam detail yang sama seperti IACA atau alat analisis statis lainnya; mereka hanya menggunakan beberapa heuristik.)
Buka gulungan sederhana tidak akan membantu ; bottleneck loop ini pada latensi rantai ketergantungan loop-carry, bukan pada overhead loop / throughput. Ini berarti akan lebih baik jika menggunakan hyperthreading (atau jenis SMT lainnya), karena CPU memiliki banyak waktu untuk menyisipkan instruksi dari dua utas. Ini berarti memparalelkan loop ke dalam
main
, tapi itu tidak masalah karena setiap thread dapat memeriksa rentangn
nilai dan menghasilkan sepasang integer sebagai hasilnya.Interleaving dengan tangan dalam satu utas mungkin juga bisa dilakukan . Mungkin menghitung urutan untuk sepasang angka secara paralel, karena masing-masing hanya membutuhkan pasangan register, dan mereka semua dapat memperbarui yang sama
max
/maxi
. Ini menciptakan paralelisme tingkat instruksi yang lebih banyak .Triknya adalah memutuskan apakah akan menunggu sampai semua
n
nilai telah tercapai1
sebelum mendapatkan pasangan lain darin
nilai awal , atau apakah akan keluar dan mendapatkan titik awal baru untuk hanya satu yang mencapai kondisi akhir, tanpa menyentuh register untuk urutan lainnya. Mungkin yang terbaik adalah menjaga setiap rantai bekerja pada data yang berguna, jika tidak Anda harus meningkatkan penghitungnya secara kondisional.Anda mungkin bahkan dapat melakukan ini dengan hal-hal yang dibungkus-bandingkan SSE untuk meningkatkan penghitung untuk elemen vektor di mana
n
belum tercapai1
. Dan untuk menyembunyikan latensi yang lebih lama dari implementasi kenaikan-kondisional SIMD, Anda harus menjaga lebih banyak vektorn
nilai di udara. Mungkin hanya bernilai dengan vektor 256b (4xuint64_t
).Saya pikir strategi terbaik untuk membuat deteksi
1
"lengket" adalah dengan menutupi vektor semua yang Anda tambahkan untuk menambah penghitung. Jadi setelah Anda melihat1
sebuah elemen, vektor-kenaikan akan memiliki nol, dan + = 0 adalah no-op.Gagasan yang belum diuji untuk vektorisasi manual
Anda dapat dan harus menerapkan ini dengan intrinsik alih-alih asm yang ditulis tangan.
Peningkatan algoritma / implementasi:
Selain hanya menerapkan logika yang sama dengan asm yang lebih efisien, cari cara untuk menyederhanakan logika, atau menghindari pekerjaan yang berlebihan. mis. memoize untuk mendeteksi akhiran umum untuk urutan. Atau bahkan lebih baik, lihat 8 bit tambahan sekaligus (jawaban gnasher)
@ EOF menunjukkan bahwa
tzcnt
(ataubsf
) dapat digunakan untuk melakukan beberapan/=2
iterasi dalam satu langkah. Itu mungkin lebih baik daripada vektorisasi SIMD; tidak ada instruksi SSE atau AVX yang dapat melakukannya. Ini masih kompatibel dengan melakukan beberapa skalarn
secara paralel di register integer yang berbeda.Jadi lingkarannya mungkin terlihat seperti ini:
Ini mungkin melakukan iterasi yang jauh lebih sedikit, tetapi perubahan jumlah variabel lambat pada CPU Intel SnB-family tanpa BMI2. 3 uops, 2c latency. (Mereka memiliki ketergantungan input pada FLAGS karena hitungan = 0 berarti bendera tidak dimodifikasi. Mereka menangani ini sebagai ketergantungan data, dan mengambil beberapa uops karena uop hanya dapat memiliki 2 input (toh HSW / BDW tetap)). Ini adalah jenis yang dikeluhkan orang tentang desain crazy-CISC x86. Itu membuat CPU x86 lebih lambat dari yang seharusnya jika ISA dirancang dari awal hari ini, bahkan dengan cara yang hampir sama. (Yaitu ini adalah bagian dari "pajak x86" yang membutuhkan kecepatan / daya.) SHRX / SHLX / SARX (BMI2) adalah kemenangan besar (latensi 1 uop / 1c).
Ini juga menempatkan tzcnt (3c di Haswell dan yang lebih baru) di jalur kritis, sehingga secara signifikan memperpanjang latensi total rantai ketergantungan loop-carry. Itu menghilangkan kebutuhan untuk CMOV, atau untuk mempersiapkan holding register
n>>1
. @ Veedrac menjawab semua ini dengan menunda tzcnt / shift untuk beberapa iterasi, yang sangat efektif (lihat di bawah).Kita dapat menggunakan BSF atau TZCNT dengan aman secara bergantian, karena
n
tidak pernah bisa nol pada saat itu. Kode mesin TZCNT mendekode sebagai BSF pada CPU yang tidak mendukung BMI1. (Awalan tanpa arti diabaikan, jadi REP BSF berjalan sebagai BSF).TZCNT berkinerja jauh lebih baik daripada BSF pada CPU AMD yang mendukungnya, jadi itu bisa menjadi ide yang baik untuk digunakan
REP BSF
, bahkan jika Anda tidak peduli tentang pengaturan ZF jika inputnya nol daripada output. Beberapa kompiler melakukan ini saat Anda menggunakannya__builtin_ctzll
bahkan dengan-mno-bmi
.Mereka melakukan hal yang sama pada CPU Intel, jadi simpan saja byte jika itu yang terpenting. TZCNT pada Intel (pra-Skylake) masih memiliki ketergantungan salah pada operan output yang seharusnya hanya ditulis, seperti BSF, untuk mendukung perilaku tidak berdokumen bahwa BSF dengan input = 0 membuat tujuannya tidak dimodifikasi. Jadi Anda perlu mengatasinya kecuali hanya mengoptimalkan untuk Skylake, jadi tidak ada untungnya dari byte REP tambahan. (Intel sering melampaui apa yang disyaratkan manual x86 ISA, untuk menghindari pemecahan kode yang digunakan secara luas yang bergantung pada sesuatu yang seharusnya tidak ada, atau yang tidak berlaku surut. Misalnya Windows 9x mengasumsikan tidak ada pengambilan prefetching spekulatif dari entri TLB , yang aman ketika kode ditulis, sebelum Intel memperbarui aturan manajemen TLB .)
Bagaimanapun, LZCNT / TZCNT di Haswell memiliki dep false yang sama dengan POPCNT: lihat T&J ini . Inilah sebabnya mengapa dalam asm output gcc untuk kode @ Veedrac, Anda melihatnya melanggar rantai dep dengan xor-zeroing pada register yang akan digunakan sebagai tujuan TZCNT ketika tidak menggunakan dst = src. Karena TZCNT / LZCNT / POPCNT tidak pernah meninggalkan tujuannya tidak terdefinisi atau tidak dimodifikasi, ketergantungan salah ini pada output pada CPU Intel adalah bug kinerja / pembatasan. Agaknya itu layak beberapa transistor / kekuatan untuk memiliki mereka berperilaku seperti uops lain yang pergi ke unit eksekusi yang sama. Satu-satunya kelebihan adalah interaksi dengan batasan uarch lain: mereka dapat micro-fuse operan memori dengan mode pengalamatan terindeks pada Haswell, tetapi pada Skylake di mana Intel menghapus dep false untuk LZCNT / TZCNT mereka "un-laminate" mode pengalamatan terindeks sementara POPCNT masih dapat melebur mikro setiap mode addr.
Perbaikan ide / kode dari jawaban lain:
@ hidefromkgb's jawaban memiliki pengamatan yang bagus bahwa Anda dijamin dapat melakukan satu shift tepat setelah 3n +1. Anda dapat menghitung ini bahkan lebih efisien daripada hanya meninggalkan cek di antara langkah-langkah. Implementasi asm dalam jawaban itu rusak, (tergantung pada OF, yang tidak didefinisikan setelah SHRD dengan hitungan> 1), dan lambat:
ROR rdi,2
lebih cepat dariSHRD rdi,rdi,2
, dan menggunakan dua instruksi CMOV pada jalur kritis lebih lambat daripada TEST tambahan yang bisa berjalan secara paralel.Saya menaruh Tidied / peningkatan C (yang memandu kompiler untuk menghasilkan asm yang lebih baik), dan menguji + bekerja lebih cepat asm (dalam komentar di bawah C) di Godbolt: lihat tautan di jawaban @ hidefromkgb . (Jawaban ini mencapai batas ar 30k dari URL Godbolt yang besar, tetapi tautan pendek dapat membusuk dan terlalu panjang untuk goo.gl.)
Juga meningkatkan hasil pencetakan untuk mengkonversi ke string dan membuat satu
write()
alih-alih menulis satu karakter sekaligus. Ini meminimalkan dampak pada waktu seluruh program denganperf stat ./collatz
(untuk merekam penghitung kinerja), dan saya menghilangkan beberapa asm non-kritis.@ Kode Veedrac
Saya mendapat speedup minor dari menggeser ke kanan sebanyak yang kita tahu perlu lakukan, dan memeriksa untuk melanjutkan loop. Dari 7,5 untuk batas = 1e8 ke 7,275, pada Core2Duo (Merom), dengan faktor membuka gulungan 16.
kode + komentar di Godbolt . Jangan gunakan versi ini dengan dentang; ia melakukan sesuatu yang konyol dengan defer-loop. Menggunakan penghitung tmp
k
dan kemudian menambahkannya untukcount
kemudian mengubah apa yang dilakukan dentang, tapi itu sedikit menyakitkan gcc.Lihat diskusi dalam komentar: Kode Veedrac sangat baik pada CPU dengan BMI1 (yaitu bukan Celeron / Pentium)
sumber
tzcnt
dan Anda terkunci ke urutan terpanjang di antara elemen-elemen vektor Anda dalam kasus vektor).1
, bukan ketika mereka semua memiliki (mudah terdeteksi dengan PCMPEQ / PMOVMSK). Kemudian Anda menggunakan PINSRQ dan hal-hal untuk mengutak-atik satu elemen yang diakhiri (dan penghitungnya), dan melompat kembali ke loop. Itu bisa dengan mudah berubah menjadi kerugian, ketika Anda terlalu sering keluar dari lingkaran dalam, tetapi itu berarti Anda selalu mendapatkan 2 atau 4 elemen pekerjaan yang berguna dilakukan setiap iterasi dari loop dalam. Poin bagus tentang memoisasi.Mengklaim bahwa kompiler C ++ dapat menghasilkan kode yang lebih optimal daripada programmer bahasa assembly yang kompeten adalah kesalahan yang sangat buruk. Dan khususnya dalam hal ini. Manusia selalu dapat membuat kode lebih baik daripada yang dapat dilakukan oleh kompiler, dan situasi khusus ini adalah ilustrasi yang baik untuk klaim ini.
Perbedaan waktu yang Anda lihat adalah karena kode rakitan dalam pertanyaan sangat jauh dari optimal di loop batin.
(Kode di bawah ini adalah 32-bit, tetapi dapat dengan mudah dikonversi menjadi 64-bit)
Misalnya, fungsi urutan hanya dapat dioptimalkan ke 5 instruksi:
Seluruh kode terlihat seperti:
Untuk mengkompilasi kode ini, FreshLib diperlukan.
Dalam pengujian saya, (prosesor 1 GHz AMD A4-1200), kode di atas kira-kira empat kali lebih cepat dari kode C ++ dari pertanyaan (ketika dikompilasi dengan
-O0
: 430 ms vs 1900 ms), dan lebih dari dua kali lebih cepat (430 ms vs 830 ms) ketika kode C ++ dikompilasi dengan-O3
.Output dari kedua program adalah sama: max sequence = 525 on i = 837799.
sumber
-O3
output gcc , tetapi saya melihat semua optimasi lain yang Anda lakukan pada loop dalam. (Tapi mengapa Anda menggunakan LEA untuk peningkatan penghitung alih-alih INC? Tidak apa-apa untuk mengibarkan bendera pada saat itu, dan menyebabkan perlambatan pada apa pun kecuali P4 (ketergantungan salah pada bendera lama untuk INC dan SHR). LEA bisa ' t berjalan pada banyak port, dan dapat menyebabkan konflik sumber daya menunda jalur kritis lebih sering.)Untuk kinerja lebih lanjut: Perubahan sederhana mengamati bahwa setelah n = 3n + 1, n akan genap, sehingga Anda dapat membaginya dengan 2 segera. Dan n tidak akan menjadi 1, jadi Anda tidak perlu mengujinya. Jadi, Anda dapat menyimpan beberapa jika pernyataan dan menulis:
Inilah kemenangan besar : Jika Anda melihat 8 bit terendah n, semua langkah sampai Anda dibagi 2 delapan kali sepenuhnya ditentukan oleh delapan bit tersebut. Misalnya, jika delapan bit terakhir adalah 0x01, itu dalam biner angka Anda ???? 0000 0001 maka langkah selanjutnya adalah:
Jadi semua langkah ini dapat diprediksi, dan 256k +1 diganti dengan 81k +1. Hal serupa akan terjadi untuk semua kombinasi. Jadi, Anda dapat membuat lingkaran dengan pernyataan beralih besar:
Jalankan loop sampai n ≤ 128, karena pada saat itu n bisa menjadi 1 dengan kurang dari delapan divisi dengan 2, dan melakukan delapan langkah atau lebih pada satu waktu akan membuat Anda kehilangan titik di mana Anda mencapai 1 untuk pertama kalinya. Kemudian lanjutkan loop "normal" - atau siapkan tabel yang memberi tahu Anda berapa banyak langkah lagi yang perlu mencapai 1.
PS. Saya sangat curiga saran Peter Cordes akan membuatnya lebih cepat. Tidak akan ada cabang kondisional sama sekali kecuali satu, dan yang akan diprediksi dengan benar kecuali ketika loop benar-benar berakhir. Jadi kodenya akan seperti itu
Dalam praktiknya, Anda akan mengukur apakah memproses 9, 10, 11, 12 bit terakhir sekaligus akan lebih cepat. Untuk setiap bit, jumlah entri dalam tabel akan berlipat ganda, dan saya mengharapkan perlambatan ketika tabel tidak masuk ke cache L1 lagi.
PPS. Jika Anda membutuhkan jumlah operasi: Dalam setiap iterasi kami melakukan tepat delapan divisi dengan dua, dan sejumlah variabel (3n +1) operasi, jadi metode yang jelas untuk menghitung operasi akan menjadi array lain. Tapi kita sebenarnya bisa menghitung jumlah langkah (berdasarkan jumlah iterasi dari loop).
Kita dapat mendefinisikan kembali masalah sedikit: Ganti n dengan (3n + 1) / 2 jika ganjil, dan ganti n dengan n / 2 jika genap. Maka setiap iterasi akan melakukan tepat 8 langkah, tetapi Anda dapat mempertimbangkan kecurangan itu :-) Jadi asumsikan ada operasi r n <- 3n + 1 dan operasi s n <- n / 2. Hasilnya akan persis n '= n * 3 ^ r / 2 ^ s, karena n <- 3n + 1 berarti n <- 3n * (1 + 1 / 3n). Mengambil logaritma kami menemukan r = (s + log2 (n '/ n)) / log2 (3).
Jika kita melakukan loop sampai n ≤ 1.000.000 dan memiliki tabel yang sudah dihitung berapa banyak iterasi yang dibutuhkan dari titik awal n ≤ 1.000.000 kemudian menghitung r seperti di atas, dibulatkan ke bilangan bulat terdekat, akan memberikan hasil yang tepat kecuali s benar-benar besar.
sumber
count
, Anda memerlukan array ketiga, bukan?adders[]
tidak memberi tahu Anda berapa banyak shift kanan yang dilakukan.uint16_t
sangat murah. Pada x86, hanya semurah nol-memanjang dari 32-bitunsigned int
keuint64_t
. (MOVZX dari memori pada Intel CPU hanya membutuhkan load-port uop, tetapi AMD AMD juga membutuhkan ALU.) Oh BTW, mengapa Anda menggunakansize_t
untuklastBits
? Ini adalah tipe 32-bit dengan-m32
, dan bahkan-mx32
(mode panjang dengan pointer 32-bit). Ini pasti tipe yang salah untukn
. Gunakan sajaunsigned
.Pada catatan yang agak tidak terkait: peretasan kinerja lebih banyak!
[«dugaan» pertama telah akhirnya dibongkar oleh @ShreevatsaR; dihapus]
Saat melintasi urutan, kami hanya bisa mendapatkan 3 kemungkinan kasus di 2-lingkungan dari elemen saat ini
N
(diperlihatkan pertama):Melompati 2 elemen ini berarti menghitung
(N >> 1) + N + 1
,((N << 1) + N + 1) >> 1
danN >> 2
, masing-masing.Mari kita buktikan bahwa untuk kedua kasus (1) dan (2) dimungkinkan untuk menggunakan rumus pertama
(N >> 1) + N + 1
,.Kasus (1) jelas. Kasus (2) menyiratkan
(N & 1) == 1
, jadi jika kita mengasumsikan (tanpa kehilangan generalitas) bahwa N adalah 2-bit panjang dan bitnyaba
dari yang paling signifikan hingga yang paling signifikan, makaa = 1
, dan berikut ini berlaku:mana
B = !b
. Pergeseran kanan hasil pertama memberi kita apa yang kita inginkan.QED:
(N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1
.Sebagai terbukti, kita dapat melintasi urutan 2 elemen sekaligus, menggunakan operasi ternary tunggal. Pengurangan 2 × waktu lagi.
Algoritma yang dihasilkan terlihat seperti ini:
Di sini kami membandingkan
n > 2
karena prosesnya mungkin berhenti pada 2 bukannya 1 jika total panjang urutannya ganjil.[EDIT:]
Mari terjemahkan ini ke dalam kumpulan!
Gunakan perintah ini untuk mengkompilasi:
Lihat C dan versi asm yang diperbaiki / diperbaiki bug oleh Peter Cordes di Godbolt . (catatan editor: Maaf karena meletakkan barang-barang saya di jawaban Anda, tetapi jawaban saya mencapai batas char 30k dari tautan + teks Godbolt!)
sumber
Q
seperti itu12 = 3Q + 1
. Poin pertama Anda tidak benar, metinks.mov reg, imm32
, tampaknya untuk menghemat byte, tetapi kemudian menggunakan byte, tetapi kemudian menggunakan Versi 64-bit mendaftar di mana-mana, bahkan untukxor rax, rax
, jadi ia memiliki banyak awalan REX yang tidak perlu. Kami jelas hanya membutuhkan REX pada regs yang memegangn
loop internal untuk menghindari overflow.-O3 -march=core2
: 96ms. gcc5.2: 108ms. Dari versi perbaikan dari loop batin asm dentang saya: 92ms (seharusnya melihat peningkatan yang lebih besar pada keluarga SnB, di mana LEA kompleks adalah 3c bukan 1c). Dari versi + kerja saya yang ditingkatkan dari loop asm ini (menggunakan ROR + TEST, bukan SHRD): 87ms. Diukur dengan 5 repetisi sebelum dicetakProgram C ++ diterjemahkan ke program perakitan selama pembuatan kode mesin dari kode sumber. Akan benar-benar salah untuk mengatakan bahwa perakitan lebih lambat daripada C ++. Selain itu, kode biner yang dihasilkan berbeda dari kompiler ke kompiler. Jadi kompiler C ++ yang cerdas dapat menghasilkan kode biner yang lebih optimal dan efisien daripada kode assembler yang bodoh.
Namun saya percaya metodologi pembuatan profil Anda memiliki kelemahan tertentu. Berikut ini adalah panduan umum untuk pembuatan profil:
sumber
Untuk masalah Collatz, Anda bisa mendapatkan peningkatan kinerja yang signifikan dengan melakukan caching "tails". Ini adalah pertukaran waktu / memori. Lihat: memoisasi ( https://en.wikipedia.org/wiki/Memoization ). Anda juga dapat melihat solusi pemrograman dinamis untuk pertukaran waktu / memori lainnya.
Contoh implementasi python:
sumber
0
cara belum hadir. Kita dapat lebih mengoptimalkan dengan hanya menyimpan N ganjil dalam tabel, jadi fungsi hash adalahn>>1
, membuang 1. Tulis kode langkah untuk selalu diakhiri dengann>>tzcnt(n)
atau sesuatu untuk memastikan itu ganjil.Dari komentar:
Untuk banyak angka itu tidak akan meluap.
Jika itu akan meluap - untuk salah satu dari benih awal yang tidak beruntung itu, jumlah overflown kemungkinan besar akan menyatu ke arah 1 tanpa luapan lainnya.
Masih ini menimbulkan pertanyaan menarik, apakah ada beberapa nomor benih overflow-siklik?
Setiap seri konvergensi akhir sederhana dimulai dengan kekuatan dua nilai (cukup jelas?).
2 ^ 64 akan melimpah ke nol, yang merupakan undefined loop berdasarkan algoritma (berakhir hanya dengan 1), tetapi solusi yang paling optimal dalam jawaban akan selesai karena
shr rax
menghasilkan ZF = 1.Bisakah kita menghasilkan 2 ^ 64? Jika angka awal adalah
0x5555555555555555
, itu angka ganjil, angka selanjutnya adalah 3n + 1, yaitu0xFFFFFFFFFFFFFFFF + 1
=0
. Secara teoritis dalam keadaan algoritma yang tidak ditentukan, tetapi jawaban yang dioptimalkan dari johnfound akan pulih dengan keluar pada ZF = 1. Thecmp rax,1
Peter Cordes akan berakhir dalam loop tak terbatas (QED varian 1, "murahan" melalui0
nomor yang tidak ditentukan ).Bagaimana dengan bilangan yang lebih kompleks, yang akan menciptakan siklus tanpa
0
? Terus terang, saya tidak yakin, teori Matematika saya terlalu kabur untuk mendapatkan ide yang serius, bagaimana menghadapinya secara serius. Tetapi secara intuitif saya akan mengatakan seri akan konvergen ke 1 untuk setiap angka: 0 <angka, karena rumus 3n + 1 perlahan akan mengubah setiap faktor prima non-2 dari angka asli (atau menengah) menjadi beberapa kekuatan 2, cepat atau lambat . Jadi kita tidak perlu khawatir tentang infinite loop untuk seri asli, hanya overflow yang bisa menghambat kita.Jadi saya hanya memasukkan beberapa angka ke lembar dan melihat angka terpotong 8 bit.
Ada tiga nilai meluap ke
0
:227
,170
dan85
(85
akan langsung ke0
, dua lainnya maju menuju85
).Tetapi tidak ada nilai untuk membuat benih luapan siklis.
Lucunya saya melakukan cek, yang merupakan angka pertama yang menderita pemotongan 8 bit, dan sudah
27
terpengaruh! Itu mencapai nilai9232
dalam seri non-terpotong yang tepat (nilai terpotong pertama adalah322
dalam langkah 12), dan nilai maksimum yang dicapai untuk salah satu dari 2-255 nomor input dengan cara non-terpotong adalah13120
(untuk255
dirinya sendiri), jumlah maksimum langkah untuk konvergen1
adalah sekitar128
(+ -2, tidak yakin apakah "1" akan dihitung, dll ...).Cukup menarik (bagi saya) jumlahnya
9232
maksimum untuk banyak nomor sumber lain, apa istimewanya? : -O9232
=0x2410
... hmmm .. tidak tahu.Sayangnya saya tidak bisa mendapatkan pemahaman mendalam dari seri ini, mengapa konvergen dan apa implikasi dari pemotongan mereka ke k bit, tetapi dengan
cmp number,1
kondisi terminating tentu saja mungkin untuk menempatkan algoritma ke dalam loop tak terbatas dengan nilai input tertentu yang berakhir0
setelah pemotongan.Tetapi nilai yang
27
meluap untuk kasus 8 bit adalah semacam peringatan, ini terlihat seperti jika Anda menghitung jumlah langkah untuk mencapai nilai1
, Anda akan mendapatkan hasil yang salah untuk sebagian besar angka dari total k-bit set integer. Untuk bilangan bulat 8 bit angka 146 dari 256 telah mempengaruhi seri oleh pemotongan (beberapa dari mereka mungkin masih mencapai jumlah langkah yang benar secara tidak sengaja mungkin, aku terlalu malas untuk memeriksa).sumber
27
seri dengan pemotongan 8b terlihat seperti ini: 82 41 124 62 31 94 47 142 71 214 107 66 (terpotong) 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 (sisanya berfungsi tanpa pemotongan). Saya tidak mengerti, maaf. Itu tidak akan pernah berhenti jika nilai terpotong akan sama dengan beberapa yang sebelumnya dicapai dalam seri yang sedang berlangsung saat ini, dan saya tidak dapat menemukan nilai seperti itu vs pemotongan k-bit (tapi saya juga tidak bisa mengetahui teori Matematika di belakang, mengapa ini tahan selama pemotongan 8/16/32/64 bit, hanya secara intuitif saya pikir itu berfungsi).2
-255
nomor, baik tanpa pemotongan (untuk1
), atau dengan pemotongan 8 bit (untuk yang diharapkan1
atau0
untuk tiga angka).cmp rax,1 / jna
(yaitudo{}while(n>1)
) untuk mengakhiri pada nol. Saya berpikir untuk membuat versi terinstal dari loop yang merekam max yangn
terlihat, untuk memberikan gambaran seberapa dekat kita dengan overflow.Anda tidak memposting kode yang dihasilkan oleh kompiler, jadi ada beberapa dugaan di sini, tetapi bahkan tanpa melihatnya, dapat dikatakan bahwa ini:
... memiliki peluang 50% untuk salah menduga cabang, dan itu akan menjadi mahal.
Kompiler hampir pasti melakukan kedua perhitungan (yang biayanya lebih besar karena div / mod latensi yang cukup lama, jadi tambah-ganda adalah "bebas") dan diikuti dengan CMOV. Yang, tentu saja, memiliki peluang nol persen untuk salah duga.
sumber
Bahkan tanpa melihat perakitan, alasan paling jelas adalah bahwa
/= 2
mungkin dioptimalkan karena>>=1
dan banyak prosesor memiliki operasi shift yang sangat cepat. Tetapi bahkan jika prosesor tidak memiliki operasi shift, divisi integer lebih cepat daripada divisi floating point.Sunting: jarak tempuh Anda mungkin berbeda pada pernyataan "pembagian bilangan bulat lebih cepat daripada pembagian floating point" di atas. Komentar di bawah ini mengungkapkan bahwa prosesor modern telah memprioritaskan mengoptimalkan divisi fp daripada divisi integer. Jadi, jika seseorang mencari alasan yang paling mungkin untuk percepatan yang ditanyakan oleh pertanyaan ini, maka kompilator mengoptimalkan
/=2
sebagai>>=1
tempat pertama yang terbaik untuk dilihat.Pada catatan yang tidak terkait , jika
n
aneh, ekspresin*3+1
akan selalu genap. Jadi tidak perlu memeriksa. Anda dapat mengubah cabang itu menjadiJadi seluruh pernyataan itu akan menjadi
sumber
DIV r32
(integer 32-bit unsigned) atauDIV r64
(integer unsigned 64-bit yang jauh lebih lambat). Khusus untuk throughput, pembagian FP jauh lebih cepat (single uop, bukan micro-coded, dan sebagian pipelined), tetapi latensi lebih baik juga.div r64
adalah 36 uops, 32-96c latency, dan satu per 21-74c throughput. Skylake memiliki throughput divisi FP yang lebih cepat (pipelined pada satu per 4c dengan latensi yang tidak jauh lebih baik), tetapi tidak lebih cepat integer div. Hal serupa pada keluarga AMD Bulldozer: DIVSD adalah 1M-op, latensi 9-27c, satu per throughput 4,5-11c.div r64
adalah 16M-ops, 16-75c latency, satu per 16-75c throughput.double
memiliki mantissa 53-bit, tetapi masih secara signifikan lebih lambat daripadadiv r32
di Haswell. Jadi itu pasti hanya masalah seberapa banyak hardware Intel / AMD melemparkan masalah, karena mereka tidak menggunakan transistor yang sama untuk pembagi integer dan fp. Integer adalah skalar (tidak ada pembagian integer-SIMD), dan satu vektor menangani 128b vektor (bukan 256b seperti vektor ALU lainnya). Yang penting adalah bahwa integer div adalah banyak uops, berdampak besar pada kode di sekitarnya.Sebagai jawaban umum, tidak secara khusus diarahkan pada tugas ini: Dalam banyak kasus, Anda dapat secara signifikan mempercepat program apa pun dengan melakukan perbaikan di tingkat tinggi. Seperti menghitung data satu kali, bukan berkali-kali, menghindari pekerjaan yang tidak perlu sepenuhnya, menggunakan cache dengan cara terbaik, dan sebagainya. Hal-hal ini jauh lebih mudah dilakukan dalam bahasa tingkat tinggi.
Menulis kode assembler, adalah mungkin untuk memperbaiki apa yang dilakukan oleh kompiler yang mengoptimalkan, tetapi ini adalah kerja keras. Dan begitu selesai, kode Anda jauh lebih sulit untuk dimodifikasi, sehingga jauh lebih sulit untuk menambahkan peningkatan algoritmik. Terkadang prosesor memiliki fungsionalitas yang tidak dapat Anda gunakan dari bahasa tingkat tinggi, perakitan inline sering berguna dalam kasus ini dan masih memungkinkan Anda menggunakan bahasa tingkat tinggi.
Dalam masalah Euler, sebagian besar waktu Anda berhasil dengan membangun sesuatu, menemukan mengapa itu lambat, membangun sesuatu yang lebih baik, menemukan mengapa itu lambat, dan seterusnya dan seterusnya. Itu sangat, sangat sulit menggunakan assembler. Algoritma yang lebih baik pada setengah kecepatan yang mungkin biasanya akan mengalahkan algoritma yang lebih buruk pada kecepatan penuh, dan mendapatkan kecepatan penuh dalam assembler bukanlah hal sepele.
sumber
gcc -O3
membuat kode yang berada dalam jarak 20% dari optimal pada Haswell, untuk algoritma yang tepat itu. (Mendapatkan speedup itu adalah fokus utama jawaban saya hanya karena itulah pertanyaan yang diajukan, dan memiliki jawaban yang menarik, bukan karena itu pendekatan yang tepat.) Speedup yang jauh lebih besar diperoleh dari transformasi yang tidak mungkin dicari oleh kompiler. , seperti menunda shift kanan, atau melakukan 2 langkah sekaligus. Speedup yang jauh lebih besar dari yang bisa didapat dari memoization / lookup-tables. Tes masih melelahkan, tapi bukan kekuatan kasar murni.Jawaban sederhana:
melakukan MOV RBX, 3 dan MUL RBX mahal; cukup ADD RBX, RBX dua kali
TAMBAH 1 mungkin lebih cepat daripada INC di sini
MOV 2 dan DIV sangat mahal; bergeser ke kanan
Kode 64-bit biasanya terasa lebih lambat dari kode 32-bit dan masalah perataan lebih rumit; dengan program kecil seperti ini Anda harus mengemasnya sehingga Anda melakukan komputasi paralel untuk memiliki peluang lebih cepat dari kode 32-bit
Jika Anda membuat daftar rakitan untuk program C ++ Anda, Anda dapat melihat perbedaannya dari rakitan Anda.
sumber
mul rbx
pada CPU Haswell OP adalah 2 uops dengan latensi 3c (dan 1 throughput clock).imul rcx, rbx, 3
hanya 1 uop, dengan latensi 3c yang sama. Dua instruksi ADD adalah 2 uops dengan latensi 2c.ADD RBX, RBX
dua kali akan dikalikan dengan 4, bukan 3). Sejauh ini cara terbaik adalahlea rax, [rbx + rbx*2]
. Atau, dengan biaya menjadikannya LEA 3-komponen, lakukan juga +1 denganlea rax, [rbx + rbx*2 + 1]
(latensi 3c pada HSW bukannya 1, seperti yang saya jelaskan dalam jawaban saya) Maksud saya adalah bahwa penggandaan 64-bit tidak terlalu mahal untuk CPU Intel baru-baru ini, karena mereka memiliki unit pengganda bilangan bulat yang sangat cepat (bahkan dibandingkan dengan AMD, di mana hal yang samaMUL r64
adalah latensi 6c, dengan satu throughput 4c: bahkan tidak sepenuhnya disalurkan melalui pipa.