- Sebuah optimasi di assembly AArch64 yang mencoba mengurangi satu cabang dalam loop justru menjadi 4 kali lebih lambat, dan penyebabnya adalah penggunaan pasangan panggil-kembali
bl/ret secara asimetris sehingga membingungkan prediktor cabang
- Kode asli melakukan dua cabang per iterasi loop,
bl foo dan b loop, tetapi kode yang diubah mencoba mengurangi jumlah cabang dengan menetapkan x30 lewat satu bl loop, lalu membuat beberapa ret kembali ke alamat yang sama
- Di M1 Max, hasil penjumlahan array 1024
float menunjukkan kode bl/ret normal memakan 969ns, tetapi struktur satu bl dan banyak ret melambat menjadi 3.85µs; saat ret diganti dengan br x30, performa pulih menjadi 913ns
- Penghitung performa Instruments menunjukkan bahwa pada penjumlahan 1 miliar elemen, kegagalan prediksi cabang return pada struktur
bl/ret asimetris mencapai sekitar 93%, dan FETCH_RESTART, MAP_DISPATCH_BUBBLE, serta MAP_REWIND juga meningkat tajam
- Implementasi yang lebih cepat berlanjut ke
foo yang di-inline, Rust iter().sum(), serta NEON SIMD dengan unrolling loop manual; versi SIMD final turun hingga 94ns, tetapi urutan penjumlahan floating-point berubah sehingga hasilnya bisa berbeda
Efek balik yang muncul di loop AArch64
- Fungsi contoh menelusuri array
float, meneruskan tiap nilai ke foo, lalu foo memperbarui nilai akumulasi g
- Konversi AArch64 sederhananya memiliki alur berikut
- Di awal loop, dicek apakah
n == 0
- Nilai dibaca dengan
ldr s1, [x0], #4
- Subrutin dipanggil dengan
bl foo
ret milik foo kembali ke instruksi setelah bl
- Lalu
b loop melompat kembali ke titik awal loop
foo berbentuk mendekati naked function yang memakai stack frame dan register yang sama dengan fungsi induk, membaca s1 dan mengakumulasi ke s0
Peran bl dan ret
bl adalah instruksi branch and link yang melompat ke label tujuan sambil menyimpan alamat instruksi berikutnya ke register link lr atau x30
ret melompat ke alamat yang tersimpan di register link
- Pada struktur asli,
bl foo dan ret saling berpasangan, dan ret selalu kembali ke instruksi tepat setelah bl
Optimasi “kurangi satu cabang” yang gagal
- Struktur yang dimodifikasi mencoba mengurangi satu cabang di dalam loop tanpa mengubah
foo
- Di awal fungsi, dipanggil
bl loop untuk menaruh alamat awal loop ke x30
- Setelah memeriksa kondisi selesai loop, alur jatuh langsung ke kode
foo tanpa cabang terpisah
ret milik foo kembali ke loop yang tersimpan di x30
- Dalam struktur ini,
x30 tidak berubah selama isi loop berjalan, sehingga ret yang berulang selalu kembali ke alamat yang sama
foo yang sederhana hanya berupa satu baris kode penjumlahan float seperti berikut
foo:
fadd s0, s0, s1
ret
- Dalam kasus ini, seluruh fungsi menghitung jumlah semua nilai pada array
float masukan
Hasil benchmark dan masalah prediksi cabang
- Dengan
criterion, array berisi 1024 elemen dibenchmark di CPU M1 Max
bl/ret normal: 969ns
- Satu
bl, banyak ret: 3.85µs
- Kode yang mengurangi satu cabang justru sekitar 4 kali lebih lambat daripada kode asli yang memakai dua cabang
- Cliff dan Dan menilai prediktor cabang menjadi bingung karena pasangan
bl/ret tidak cocok
- Menurut dokumentasi ARM,
RET memungkinkan prosesor mengenali bahwa ini adalah return dari fungsi sehingga prediksi cabang bisa lebih akurat
BR LR juga bisa melakukan hal yang sama secara fungsional
- Namun
RET adalah instruksi terpisah yang bisa dikenali prosesor sebagai return fungsi
- Jika prediksi cabang benar, pipeline akan berisi instruksi yang tepat dan bisa menghindari waktu tunggu pengambilan instruksi dari memori
Stack alamat return dan eksperimen br x30
- Prediktor cabang kemungkinan memelihara stack alamat return fungsi secara internal
- Saat
bl dieksekusi, alamat return di-push ke stack
- Saat melihat
ret, prosesor mengasumsikan ia akan kembali ke alamat return dari bl terbaru
- Berdasarkan alamat itu, prosesor memulai prefetch dan eksekusi spekulatif lebih dulu, lalu melakukan
pop dari stack
- Pendekatan ini bekerja baik saat
bl/ret memang merupakan pasangan yang cocok
- Jika banyak
ret berulang memakai alamat yang sama, prediksi bisa gagal, menyebabkan prefetch yang sia-sia, eksekusi spekulatif yang keliru, serta stall atau flush pada pipeline
- Sesuai saran Dan, ketika
ret diganti menjadi br x30, penurunan performa pun hilang
bl/ret normal: 969ns
- Satu
bl, banyak ret: 3.85µs
- Satu
bl, banyak br x30: 913ns
- Versi
br x30 sedikit lebih cepat dari kode asli karena hanya melakukan satu cabang per iterasi loop
Penghitung performa Instruments
- Penghitung performa untuk dua program pertama diperiksa dengan Instruments
- Pengukuran dilakukan saat menjumlahkan array dengan 1 miliar elemen
- Pada
bl/ret asimetris, kegagalan prediksi cabang return terjadi sekitar 93%
| Counter |
bl/ret normal |
Satu bl, banyak ret |
BRANCH_RET_INDIR_MISPRED_NONSPECIFIC |
92 |
928,644,975 |
FETCH_RESTART |
61,121 |
987,765,276 |
MAP_DISPATCH_BUBBLE |
1,155,632 |
7,350,085,139 |
MAP_REWIND |
6,412,734 |
2,789,499,545 |
- Apple tidak mendokumentasikan counter-counter ini secara lengkap
- Counter lain diduga merupakan efek lanjutan dari prediksi cabang yang buruk
FETCH_RESTART: kemungkinan prefetch yang salah
MAP_DISPATCH_BUBBLE: kemungkinan terkait pipeline stall
MAP_REWIND: kemungkinan eksekusi spekulatif keliru yang harus diputar balik
Cara membuatnya lebih cepat
- Contoh ini adalah kode edukatif, dan alasan
foo dijadikan subrutin lebih dekat ke struktur penjelasan daripada “kode tercepat yang mungkin”
- Jika isi
foo diketahui saat build dan jaraknya masih dalam batas lompatan maksimum, bl dan ret bisa dihapus sepenuhnya dengan inline
- Dari 969ns menjadi 911ns, sekitar 6% lebih cepat
- Jika cukup menulis
f.iter().sum() di Rust, waktunya turun hingga 833ns
pub fn sum_slice(f: &[f32]) -> f32 {
f.iter().sum()
}
SIMD dan loop unrolling manual
- Implementasi SIMD AArch64 manual terdiri dari tiga loop
loop: menambahkan nilai satu per satu ke s0 sampai jumlah nilai tersisa menjadi kelipatan 4
simd: menambahkan 4 nilai sekaligus ke register vektor v1, lalu berulang sampai jumlah nilai tersisa menjadi kelipatan 8
simd2: versi simd yang di-unroll 2 kali sehingga memproses 8 nilai per iterasi dan mengakumulasi ke v1 dan v2
- Saat fungsi selesai, nilai dalam
v1 dan v2 diakumulasikan ke s0 lalu dikembalikan
- Type punning dilakukan dengan memperlakukan
x0 yang berupa float* seolah double*, sehingga 128 bit, yaitu 4 buah float, dibaca sebagai d3 dan d4
mov v3.d[1], v4.d[0] memindahkan 64 bit dari d4 ke 64 bit atas v3
- Di
fadd v1.4s, v1.4s, v3.4s, sufiks .4s membuatnya diperlakukan sebagai empat float
- Implementasi SIMD ini berjalan dalam 94ns, sekitar 8.8 kali lebih cepat daripada versi Rust sebelumnya yang membutuhkan 833ns
Ringkasan performa keseluruhan dan hal yang perlu diperhatikan
| Implementasi |
Waktu |
bl/ret normal |
969ns |
Satu bl, banyak ret |
3.85µs |
Satu bl, banyak br x30 |
913ns |
Loop biasa dengan b |
911ns |
| Ditulis ulang dalam Rust |
833ns |
| SIMD + loop unrolling manual |
94ns |
- Kode SIMD mengubah urutan penjumlahan floating-point
- Penjumlahan floating-point tidak memenuhi sifat asosiatif, jadi versi SIMD mungkin tidak menghasilkan hasil yang sama dengan kode lurus
- Ini juga bisa menjadi alasan compiler tidak menghasilkan instruksi SIMD untuk penjumlahan tersebut
- Semua kode dipublikasikan di GitHub
- Benchmark bisa direproduksi di mesin ARM64 dengan menjalankan
cargo bench
1 komentar
Komentar di Hacker News
Kode optimasi terakhir menyelesaikan penjumlahan array berisi 1024 bilangan floating-point 32-bit dalam 94 ns.
Selama 94 ns itu, teman lama kita 6502 1 MHz mungkin baru mulai mempertimbangkan apakah perlu memberi sinyal ke chip memori untuk mengambil byte pertama dari instruksi pertama program.
Namun kode ini sepenuhnya bergantung pada asumsi bahwa ia berjalan di dalam cache. Kalau tidak, bahkan M1 Max yang kuat seperti disebut di artikel pun akan terhenti menunggu pengambilan memori pertama. DRAM itu lambat.
Raymond Chen sudah membahas hal yang sama hampir 20 tahun lalu: https://devblogs.microsoft.com/oldnewthing/20041216-00/?p=36...
Selain pemahaman dasar tentang sifat semikonduktor silikon dan doping, hampir tidak ada yang intuitif di sini. Jika Anda belum melihat diagram rangkaian die, wiring, dan jalurnya, hampir tidak ada alasan untuk berharap A lebih cepat daripada B kecuali para engineer dan datasheet menyatakannya secara eksplisit. Saya rasa ini terutama berlaku pada ARM.
Yang ditambahkan artikel ini adalah koreksi sederhana dengan mengganti
retmenjadi instruksibrlain. Dengan begitu pasangannya kembali “simetris”, dan kita bisa mendapatkan kode yang sedikit lebih cepat tanpa merusak branch predictor.Tentu saja apa pun mungkin, dan loop biasa untuk menjumlahkan array memang berbentuk instruksi yang menyuruh komputer mengakumulasi elemen satu per satu.
Namun, misalnya, membuat empat nilai akumulasi secara paralel dengan SIMD lalu menjumlahkannya di akhir sulit dibilang lebih salah daripada menambahkan elemen satu per satu.
Penjumlahan floating-point pada dasarnya harus dianggap memiliki rentang galat, dan jawaban dalam rentang itu seharusnya valid. Jika ada pengetahuan khusus tentang floating-point input, bahasa seharusnya menyediakan cara untuk menyatakan maksud itu secara eksplisit. Karena loop paling dasar menjadi default, menurut saya semestinya secara default ia memberikan performa terbaik.
Cara naif menambahkan satu per satu dalam loop memang obvious, tetapi ada metode yang lebih canggih yang memberi batas lebih baik atas total galat akumulasi, dan penjumlahan Kahan adalah contoh yang terkenal: https://en.wikipedia.org/wiki/Kahan_summation_algorithm
Jika datanya streaming, mungkin memang hanya bisa menambahkan satu per satu. Namun jika bisa memakai N buffer berukuran tetap, muncul persoalan seperti subset mana yang dijadikan jumlah parsial saat angka baru masuk, bagaimana menambahkannya ke jumlah akumulasi, dan apakah pilihan itu memiliki perbaikan galat yang dapat dibuktikan.
Misalnya, menghitung
[1e50, -1e50, 1e3, 1e3]sebagai(((1e50 + -1e50) + 1e3) + 1e3)menghasilkan2e3, tetapi menghitungnya sebagai((1e50 + 1e3) + (-1e50 + 1e3))menghasilkan0.Hal serupa terjadi saat menambahkan banyak nilai kecil ke satu nilai besar, sehingga
(((1e3 + 1e3) + 1e3) ... + 1e50)dan(((1e50 + 1e3) + 1e3) ... + 1e3)cukup berbeda.Jika hasil aritmetika tidak stabil antar-build, itu benar-benar menjadi mimpi buruk. Saat software dibangun ulang dengan input yang sama lalu dijalankan, hasilnya tidak boleh berbeda.
Dulu saya juga pernah mengalami kasus khas Intel: FPU secara internal memakai register 80-bit sementara memori memakai 64-bit, sehingga ketika waktu pengisian/pengosongan register berubah, waktu pembulatan juga berubah dan hasilnya ikut berbeda. Kita bisa mengatur flag FPU global saat program dimulai untuk memaksa pembulatan pada setiap operasi.
Selalu ada jawaban yang benar, dan menurut saya compiler setidaknya secara default tidak boleh melakukan perubahan yang salah. Namun cara bagi programmer untuk mengekspresikan maksudnya dengan lebih jelas selalu disambut baik.
Menerapkan SIMD pada loop floating-point bisa saja menjadi default, tetapi karena itu akan merusak banyak kode lama dan output sering berubah menjadi non-deterministik, fitur ini menjadi sesuatu yang harus dipilih secara eksplisit oleh programmer.
Selain itu, banyak programmer mungkin tidak mengetahui fakta ini, sehingga jika
float Sum(float[] values)mulai mengembalikan nilai yang berbeda, mereka mungkin tidak punya cara untuk mengetahui bahwa penyebabnya adalah vektorisasi. Karena itu, misalnya, pustaka standar .NET memakai SIMD untukintegers.Sum(), tetapi tidak untukfloats.Sum().Setelah hanya membaca baris “setelah memeriksa akhir loop, eksekusi jatuh langsung ke dalam fungsi
footanpa branch”, saya langsung berpikir, “Ah, itu masalahnya”Saya kira ini akan menjadi pembahasan mendalam tentang heuristik branch predictor yang canggih, tetapi pada akhirnya ini hanyalah pelanggaran heuristik dasar
Jangan mengira Anda bisa mendapatkan peningkatan kecepatan luar biasa dengan memakai instruksi
call/retyang tidak berpasangan. Branch predictor sudah mempertahankan shadow stack untuk alamat return sejak puluhan tahun laluJadi risiko semacam ini memang harus didokumentasikan, tetapi perancang seharusnya boleh berasumsi bahwa orang yang menulis assembly secara langsung telah membaca dokumentasinya
Di sisi lain, Sophie Wilson menulis implementasi BBC BASIC asli untuk ARM, tetapi saat itu belum ada branch predictor. Meski karena 32-bit aturannya berbeda, saya penasaran bagaimana AArch64 membuat kode melambat ketika asumsi arsitektural berubah
Ini referensi SNL klasik “Do not taunt happy fun ball”: https://www.youtube.com/watch?v=GmqeZl8OI2M
Jangan sampai terlewat bahwa ini artikel tahun 2023. Sekarang sudah agak usang, dan sejak Rust 1.78 compiler memakai loop unrolling yang lebih agresif dan sedikit SIMD: https://godbolt.org/z/zhbobW7rr
Artikel asli mengatakan “melihat assembly-nya, terlihat loop unrolling” dan menautkan https://godbolt.org/z/Kv77abW6c, tetapi itu memakai “Rust Nightly” yang terus berubah. Sekarang loop unrolling-nya lebih banyak
Loop unrolling mulai muncul sejak Rust 1.59: https://godbolt.org/z/5PTnWrWf7
Dari kode GitHub, versi yang dipakai adalah Rust
1.67.0-nightly, versi 2022-11-27Saya menjalankan benchmark sendiri pada hardware yang sama dengan nightly Rust 1.81 terbaru yang memiliki loop unrolling agresif, tetapi tidak ada perbedaan; kecepatannya sama seperti 1,5 tahun lalu
Ini artikel tahun 2023. Diskusi saat itu: https://news.ycombinator.com/item?id=34520498
Tidak masalah jika diposting ulang sekitar setahun kemudian, dan tautan ke thread lama ini untuk pembaca yang ingin tahu lebih jauh
Karena saya tidak terlalu akrab dengan assembly ARM/ARM64, saya sempat bingung bagaimana
x0bertambahconst float f = *data++;menjadildr s1, [x0], #4, dan instruksi ini tampaknya membaca nilai sekaligus menambahx0sebesar 4Sepertinya nilai negatif juga bisa dipakai, jadi traversal mundur pun mungkin. Cukup keren. Di x86_64 sepertinya tidak ada satu instruksi tunggal yang membaca sekaligus menambah
lodsdanstosmasing-masing melakukan baca/tulis sekaligus increment padarsiataurdi. Ada jugamovsyang menyalin antara dua alamat memori sambil melakukan incrementBiasanya dipakai bersama
repuntuk mengulang sebanyakrcxkali. Misalnyamemset10 byte bisa berbentukmov rcx, 10,mov rdi, dest,mov rax, 0,rep stosbJika memakai sufiks
w,d,q, masing-masing akan maju 2, 4, dan 8 byteArtikelnya bagus, tetapi sayang tabelnya terus berganti antara satuan µs dan ns, sehingga sulit dipindai untuk dibandingkan
Saya terkejut mereka tidak mencoba cara yang kurang cerdik terlebih dahulu sebelum mengoptimalkan kode
Jika assembly ditulis ulang, di bagian bawah loop hanya dibutuhkan satu branch, dan untuk
X1pun pengurangan untuk perbandingan serta decrement terpisah bisa diganti dengan satu operasi ALULebih jauh lagi,
foobisa saja di-inline, dan instruksiRETjuga bisa dihilangkan tanpa trikBL/RETyang tidak berpasangan. Saya belum menjalankan benchmark sendiri, jadi tidak tahu seberapa cepat hasilnya dalam praktikcbnzseharusnyacbz. CBZ melakukan branch ke label jika register bernilai 0, sedangkan CBNZ melakukan branch jika bukan 0