Go memungkinkan Anda untuk menulis di assembler. Tetapi penulis bahasa menulis perpustakaan standar sedemikian rupa sehingga tidak perlu dilakukan. Ada beberapa cara untuk menulis kode portabel dan cepat secara bersamaan. Bagaimana? Selamat datang di bawah cut.
Memulai menulis fungsi di assembler in go sangat sederhana. Misalnya, mendeklarasikan (meneruskan pernyataan) fungsi
Add
, yang menambahkan 2 int64:
func Add(a int64, b int64) int64
Ini adalah fungsi normal, tetapi fungsi tubuh tidak ada. Kompiler akan bersumpah secara wajar ketika mencoba mengkompilasi sebuah paket.
% go build examples/asm ./decl.go:4:6: missing function body
Tambahkan file dengan ekstensi .s dan terapkan fungsinya di assembler.
TEXT ยทAdd(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET
Sekarang Anda dapat mengkompilasi, menguji dan menggunakan
Add
sebagai fungsi normal. Ini banyak digunakan oleh pengembang bahasa sendiri dalam paket
runtime, matematika, bytealg, syscall, reflect, crypto . Ini memungkinkan Anda untuk menggunakan pengoptimalan prosesor dan
perintah yang tidak terwakili dalam bahasa .
Tetapi ada masalah - fungsi pada asm tidak dapat dioptimalkan dan built-in (inline). Tanpa ini, overhead bisa menjadi penghalang.
var Result int64 func BenchmarkAddNative(b *testing.B) { var r int64 for i := 0; i < bN; i++ { r = int64(i) + int64(i) } Result = r } func BenchmarkAddAsm(b *testing.B) { var r int64 for i := 0; i < bN; i++ { r = Add(int64(i), int64(i)) } Result = r }
BenchmarkAddNative-8 1000000000 0.300 ns/op BenchmarkAddAsm-8 606165915 1.930 ns/op
Ada beberapa saran untuk assembler inline, seperti arahan
asm(...)
dalam gcc. Tak satu pun dari mereka diterima. Di tempat ini, pergi menambahkan fungsi
intrinsik .
Fungsi built-in ditulis dalam plain go. Tetapi kompiler tahu bahwa mereka dapat diganti dengan sesuatu yang lebih optimal. Di Go 1.13, fungsi yang disematkan terkandung dalam
math/bits
dan
sync/atomic
.
Fungsi dalam paket ini memiliki tanda tangan mewah. Bahkan, mereka mengulangi tanda tangan perintah prosesor. Ini memungkinkan kompiler, jika arsitektur target mendukung, untuk secara transparan mengganti panggilan fungsi dengan instruksi assembler.
Di bawah ini saya ingin berbicara tentang dua cara berbeda di mana kompiler go membuat kode yang lebih efisien menggunakan fungsi bawaan.
Jumlah populasi
jumlah unit dalam representasi biner dari angka ini adalah primitif kriptografi yang penting. Karena ini adalah operasi yang penting, sebagian besar CPU modern menyediakan implementasi dalam perangkat keras.
Paket
math/bits
menyediakan operasi ini dalam fungsi
OnesCount*
. Mereka dikenali dan diganti dengan
POPCNT
prosesor
POPCNT
.
Untuk melihat bagaimana ini bisa lebih efisien, mari kita bandingkan 3 implementasi. Yang pertama adalah
algoritma Kernigan .
func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count }
Jumlah siklus algoritma bertepatan dengan jumlah bit yang ditetapkan. Lebih banyak bit - waktu eksekusi lebih lama, yang berpotensi menyebabkan kebocoran informasi pada saluran pihak ketiga.
Algoritma kedua adalah dari
Hacker's Delight .
func hackersdelight(x uint64) uint8 { const m1 = 0b0101010101010101010101010101010101010101010101010101010101010101 const m2 = 0b0011001100110011001100110011001100110011001100110011001100110011 const m4 = 0b0000111100001111000011110000111100001111000011110000111100001111 const h1 = 0b0000000100000001000000010000000100000001000000010000000100000001 x -= (x >> 1) & m1 x = (x & m2) + ((x >> 2) & m2) x = (x + (x >> 4)) & m4 return uint8((x * h1) >> 56) }
Strategi membagi dan menaklukkan memungkinkan versi ini bekerja untuk O (logโ) dari angka yang panjang, dan untuk waktu yang konstan dari jumlah bit, yang penting untuk kriptografi. Mari kita bandingkan kinerja dengan
math/bits.OnesCount64
.
func BenchmarkKernighan(b *testing.B) { var r int for i := 0; i < bN; i++ { r = kernighan(uint64(i)) } runtime.KeepAlive(r) } func BenchmarkPopcnt(b *testing.B) { var r int for i := 0; i < bN; i++ { r = hackersdelight(uint64(i)) } runtime.KeepAlive(r) } func BenchmarkMathBitsOnesCount64(b *testing.B) { var r int for i := 0; i < bN; i++ { r = bits.OnesCount64(uint64(i)) } runtime.KeepAlive(r) }
Sejujurnya, kami meneruskan parameter yang sama ke fungsi: urutan dari 0 hingga bN Ini lebih benar untuk metode Kernigan, karena waktu pelaksanaannya meningkat dengan jumlah bit dari argumen input.
โ BenchmarkKernighan-4 100000000 12.9 ns/op BenchmarkPopcnt-4 485724267 2.63 ns/op BenchmarkMathBitsOnesCount64-4 1000000000 0.673 ns/op
math/bits.OnesCount64
menang dalam kecepatan 4 kali. Tetapi apakah itu benar-benar menggunakan implementasi perangkat keras, atau apakah kompiler hanya mengoptimalkan algoritma dari Hackers Delight? Saatnya masuk ke assembler.
go test -c
Ada utilitas sederhana untuk membongkar objek go objdump, tapi saya (tidak seperti penulis artikel asli), saya akan menggunakan IDA.
Ada banyak hal yang terjadi di sini. Paling penting: instruksi
POPCNT
x86 dibangun ke dalam kode tes itu sendiri, seperti yang kita harapkan. Ini membuat banchmark lebih cepat daripada alternatif.
Percabangan ini menarik.
cmp cs:runtime_x86HasPOPCNT, 0 jz lable
Ya, ini polifil di assembler. Tidak semua prosesor mendukung
POPCNT
. Ketika program dimulai, sebelum
main
Anda, fungsi
runtime.cpuinit
memeriksa apakah ada instruksi yang diperlukan dan menyimpannya di
runtime.x86HasPOPCNT
. Setiap kali program memeriksa apakah mungkin untuk menggunakan
POPCNT
, atau menggunakan polyfile. Karena nilai
runtime.x86HasPOPCNT
tidak berubah setelah inisialisasi, prediksi percabangan prosesor relatif akurat.
Penghitung atom
Fungsi intrinsik adalah kode Go biasa, mereka dapat inline dalam aliran instruksi. Sebagai contoh, kami akan membuat abstraksi dari penghitung dengan metode dari tanda tangan aneh dari fungsi paket atom.
package main import ( "sync/atomic" ) type counter uint64 func (c *counter) get() uint64 { return atomic.LoadUint64((*uint64)(c)) } func (c *counter) inc() uint64 { return atomic.AddUint64((*uint64)(c), 1) } func (c *counter) reset() uint64 { return atomic.SwapUint64((*uint64)(c), 0) } func F() uint64 { var c counter c.inc() c.get() return c.reset() } func main() { F() }
Seseorang akan berpikir bahwa OOP akan menambah overhead. Tapi Go bukan Java - bahasa tidak menggunakan binding in runtime kecuali Anda secara eksplisit menggunakan antarmuka. Kode di atas akan diciutkan menjadi aliran instruksi prosesor yang efisien. Seperti apa tampilan utama?
Dalam rangka.
c.inc
berubah menjadi
lock xadd [rax], 1
- penambahan atom x86.
c.get
menjadi instruksi
mov
biasa, yang sudah menjadi atom di x86.
c.reset
menjadi pertukaran atom
xchg
antara register nol dan memori.
Kesimpulan
Fungsi tertanam adalah solusi rapi yang menyediakan akses ke operasi tingkat rendah tanpa memperluas spesifikasi bahasa. Jika arsitektur tidak memiliki sinkronisasi khusus / primitif atomik (seperti beberapa varian ARM), atau operasi dari matematika / bit, maka kompiler menyisipkan polyfile di perjalanan murni.