Fitur Go bawaan


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.

Source: https://habr.com/ru/post/id467673/


All Articles