bytes.Buffer in Go: optimasi yang tidak berfungsi

Banyak programmer Go yang akrab dengan byte . Buffer . Salah satu kelebihannya adalah memungkinkan Anda untuk menghindari mengalokasikan memori pada heap dengan cara yang sama seperti " optimasi buffer / ukuran kecil ":


type Buffer struct { bootstrap [64]byte //        // ...   } 

Hanya ada satu masalah. Pengoptimalan ini tidak berfungsi .


Pada akhir artikel ini, Anda akan mengetahui mengapa pengoptimalan ini tidak berfungsi dan apa yang dapat kami lakukan.


Seperti yang dimaksud, "optimasi buffer kecil"


Mari kita perkenalkan definisi bytes.Buffer sedikit disederhanakan. bytes.Buffer :


 const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte } 

Ketika kita melakukan tindakan pada Buffer , misalnya, memanggil metode Buffer.Write , catatan selalu dibuat di buf , namun, sebelum catatan ini, Buffer.grow(n) diluncurkan di dalam setiap metode yang serupa, yang memastikan bahwa ada cukup ruang dalam irisan ini untuk n byte berikutnya.


Grow mungkin terlihat seperti ini:


 func (b *Buffer) grow(n int) { //         bytes.Buffer. l := len(b.buf) //   Buffer need := n + l have := cap(b.buf) - l if have >= need { b.buf = b.buf[:need] return } if need <= smallBufSize { //     , //   . b.buf = b.bootstrap[:] } else { // growFactor -     . //     need  need*2. newBuf := make([]byte, need, growFactor(need)) copy(newBuf, b.buf) b.buf = newBuf } } 

Asumsi yang digunakan dalam implementasi Buffer.grow kami


Kami membuat asumsi bahwa len(b.buf) adalah panjang data aktual dalam Buffer, yang akan membutuhkan Write untuk menggunakan metode append untuk menambahkan byte baru ke slice. Ini bukan kasus dalam bytes.Buffer dari perpustakaan standar, tetapi sebagai contoh, ini adalah detail implementasi yang tidak relevan.




Jika b dialokasikan pada stack, maka bootstrap di dalamnya dialokasikan pada stack, yang berarti bahwa slice b.buf akan menggunakan kembali memori di dalam b tanpa memerlukan alokasi tambahan.


Ketika grow mengungkapkan bahwa array bootstrap sudah tidak cukup, irisan "nyata" baru akan dibuat, di mana elemen dari penyimpanan sebelumnya (dari "buffer kecil") kemudian akan disalin. Setelah itu, Buffer.bootstrap akan kehilangan relevansinya. Jika Buffer.Reset , cap(b.buf) akan tetap sama dan tidak akan ada lagi kebutuhan untuk array bootstrap .


Memori hilang dalam tumpukan


Lebih lanjut diharapkan bahwa pembaca setidaknya secara dangkal akrab dengan apa analisis pelarian di Go.


Pertimbangkan situasi berikut:


 func f() *Buffer { var b bytes.Buffer // leak.go:11:6: moved to heap: b return &b // leak.go:12:9: &b escapes to heap } 

Di sini b akan dialokasikan pada heap. Alasan untuk ini adalah pointer bocor ke b :


 $ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b 

Terminologi


Dalam artikel ini, "bocor" dan "melarikan diri" digunakan hampir secara sinonim.


Ada beberapa perbedaan dalam kompiler itu sendiri, misalnya, nilai "lolos ke tumpukan", tetapi parameter fungsi adalah "bocor param x".


Parameter bocor berarti bahwa argumen yang lolos untuk parameter ini akan dialokasikan pada heap. Dengan kata lain, parameter bocor menyebabkan argumen untuk melarikan diri ke tumpukan.




Di atas adalah kasus yang jelas, tetapi bagaimana dengan ini:


 func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } 

Di sini kita hanya perlu 1 byte, semuanya cocok dengan bootstrap , buffer itu sendiri bersifat lokal dan tidak "lepas" dari fungsinya. Anda mungkin terkejut, tetapi hasilnya akan sama, alokasi b di heap.



Yang pasti, Anda dapat memeriksa ini menggunakan patokan:


 BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op 

Daftar patokan


 package p import ( "bytes" "testing" ) func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } func BenchmarkLength(b *testing.B) { for i := 0; i < bN; i++ { _ = length() } } 



Penjelasan 112 B / op


Ketika runtime meminta pengalokasi untuk N byte, tidak perlu tepatnya N byte dialokasikan.


Semua hasil di bawah ini adalah untuk kombinasi GOOS=linux dan GOARCH=AMD64 .

 package benchmark import "testing" //go:noinline func alloc9() []byte { return make([]byte, 9) } func BenchmarkAlloc9(b *testing.B) { for i := 0; i < bN; i++ { _ = alloc9() } } 

Jika Anda menjalankan go test -bench=. -benchmem go test -bench=. -benchmem dengan tes ini:


 BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op 

9 byte diminta, dialokasikan 16. Sekarang kembali ke bytes.Buffer :


 fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104 

Mari kita lihat $ GOROOT / src / runtime / sizeclasses.go :


 // class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% // 6 80 8192 102 32 19.07% // 7 96 8192 85 32 15.95% // 8 112 8192 73 16 13.56% // ...  

Itu tidak masuk ke dalam 96 byte, 112 dipilih.




Tetapi mengapa ini terjadi?


Apa yang terjadi dan mengapa


Beberapa analisis situasi dapat ditemukan dalam masalah yang disebutkan di awal.
Ada juga alat reproduksi sederhana .


Tempat masalahnya hanya dalam penugasan b.buf = b.bootstrap[:] . Kode ini membuat analisis melarikan diri berasumsi bahwa b.bootstrap "melarikan diri", dan karena ini adalah array, maka ia disimpan di dalam objek itu sendiri, yang berarti bahwa semua b harus dialokasikan pada heap.


Jika bootstrap adalah slice, bukan array, maka ini tidak akan terjadi, karena ada optimasi adhoc untuk menetapkan irisan dari objek ke objek itu sendiri:


 //   ,     , // object      . object.buf1 = object.buf2[a:b] 

Jawaban mengapa optimisasi ini tidak berfungsi untuk array telah dirumuskan di atas, tetapi di sini ada penekanan dari esc.go # L835-L866 itu sendiri (seluruh kode optimasi disorot oleh referensi):


 // Note, this optimization does not apply to OSLICEARR, // because it does introduce a new pointer into b that was not already there // (pointer to b itself). After such assignment, if b contents escape, // b escapes as well. If we ignore such OSLICEARR, we will conclude // that b does not escape when b contents do. 

Perlu ditambahkan di sini bahwa untuk penganalisa pointer ada beberapa level "kebocoran", utamanya:


  1. Objek itu sendiri lolos (b lolos). Dalam hal ini, objek itu sendiri perlu dialokasikan di heap.
  2. Unsur-unsur objek (b isi melarikan diri) melarikan diri. Dalam hal ini, pointer pada objek dianggap melarikan diri.

Kasing dengan array adalah spesial karena jika array bocor, objek yang memuatnya juga harus bocor.


melarikan diri analisis membuat keputusan tentang apakah mungkin untuk menempatkan objek pada tumpukan atau tidak, hanya mengandalkan informasi yang tersedia di tubuh fungsi yang dianalisis. Metode Buffer.grow mengambil b oleh pointer, sehingga perlu menghitung tempat yang sesuai untuk diletakkan. Karena dalam kasus array kita tidak dapat membedakan antara "b escape" dan "b contents escape" , kita harus lebih pesimis dan sampai pada kesimpulan bahwa b tidak aman untuk ditempatkan pada stack.


Asumsikan sebaliknya, bahwa pola self-assignment menyelesaikan yang sama untuk array seperti halnya untuk irisan:


 package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:] // ignoring self-assignment to b.slice sink = b.array // b.array escapes to heap // b does not escape } 

Keputusan untuk menempatkan b pada tumpukan dalam situasi ini akan menyebabkan bencana: setelah keluar dari fungsi di mana b dibuat, memori yang akan sink akan merujuk tidak lebih dari sampah.


Array pointer


Bayangkan Buffer kita dinyatakan sedikit berbeda:


 const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte } 

Tidak seperti array biasa, pointer ke array tidak akan menyimpan semua elemen di dalam Buffer itu sendiri. Ini berarti bahwa jika alokasi bootstrap di heap tidak memerlukan alokasi Buffer di heap. Karena analisis pelarian dapat mengalokasikan bidang penunjuk pada tumpukan jika memungkinkan, kita dapat mengasumsikan bahwa definisi Buffer seperti itu lebih berhasil.


Tapi ini dalam teori. Dalam praktiknya, penunjuk ke array tidak memiliki banyak pemrosesan dan jatuh ke dalam kategori yang sama dengan potongan dari array biasa, yang tidak sepenuhnya benar. CL133375: cmd / compile / internal / gc: handle slice yang ditugaskan sendiri di esc.go bertujuan untuk memperbaiki situasi ini.


Misalkan perubahan ini telah diterima ke kompiler Go.


Nilai nol kami hilang


Sayangnya, transisi dari [64]byte ke *[64]byte memiliki masalah: sekarang kita tidak dapat menggunakan bootstrap tanpa menginisialisasi secara eksplisit, nilai nol Buffer tidak lagi berguna, kita memerlukan konstruktor.


 func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} } 

Kami mengembalikan Buffer , bukan *Buffer , untuk menghindari masalah dengan analisis pointer (sangat konservatif di Go), dan dengan mempertimbangkan fakta bahwa NewBuffer selalu dibangun di tempat panggilan, tidak akan ada penyalinan yang tidak perlu.


Setelah menyematkan tubuh NewBuffer di tempat panggilan, analisis pelarian dapat mencoba untuk membuktikan bahwa new(*[smallBufSize]byte) tidak melebihi umur bingkai fungsi yang disebutnya. Jika demikian, maka alokasi akan berada di tumpukan.


Intel bytebuf


Optimasi yang dijelaskan di atas diterapkan dalam paket intel-go / bytebuf .


Perpustakaan ini mengekspor tipe bytebuf.Buffer , yang menduplikasi 99,9% bytes.Buffer . Semua perubahan bytebuf.New untuk memperkenalkan konstruktor ( bytebuf.New ) dan pointer ke array bukan array biasa:


 type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] - bootstrap [64]byte // helps small buffers avoid allocation. + bootstrap *[64]byte // helps small buffers avoid allocation. lastRead readOp // last read operation (for Unread*). } 

Berikut ini adalah perbandingan kinerja dengan bytes.Buffer :


 name old time/op new time/op delta String/empty-8 138ns ±13% 24ns ± 0% -82.94% (p=0.000 n=10+8) String/5-8 186ns ±11% 60ns ± 1% -67.82% (p=0.000 n=10+10) String/64-8 225ns ±10% 108ns ± 6% -52.26% (p=0.000 n=10+10) String/128-8 474ns ±17% 338ns ±13% -28.57% (p=0.000 n=10+10) String/1024-8 889ns ± 0% 740ns ± 1% -16.78% (p=0.000 n=9+10) name old alloc/op new alloc/op delta String/empty-8 112B ± 0% 0B -100.00% (p=0.000 n=10+10) String/5-8 117B ± 0% 5B ± 0% -95.73% (p=0.000 n=10+10) String/64-8 176B ± 0% 64B ± 0% -63.64% (p=0.000 n=10+10) String/128-8 368B ± 0% 256B ± 0% -30.43% (p=0.000 n=10+10) String/1024-8 2.16kB ± 0% 2.05kB ± 0% -5.19% (p=0.000 n=10+10) name old allocs/op new allocs/op delta String/empty-8 1.00 ± 0% 0.00 -100.00% (p=0.000 n=10+10) String/5-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/64-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/128-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) String/1024-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) 

Semua informasi lain tersedia di README .


Karena ketidakmampuan untuk menggunakan nilai nol dan mengikat ke fungsi membangun New , tidak mungkin untuk menerapkan optimasi ini ke bytes.Buffer . bytes.Buffer .


Apakah ini satu-satunya cara untuk membuat bytes.Buffer lebih cepat. bytes.Buffer ? Jawabannya adalah tidak. Tapi ini jelas merupakan metode yang membutuhkan perubahan minimal dalam implementasi.


Rencana Analisis Pelarian


Dalam bentuknya saat ini, analisis pelarian di Go cukup lemah. Hampir semua operasi dengan nilai pointer mengarah ke alokasi di heap, bahkan jika ini bukan keputusan yang masuk akal.


Saya akan mencoba mengarahkan sebagian besar waktu yang saya curahkan untuk proyek golang / go untuk menyelesaikan masalah ini, sehingga beberapa perbaikan mungkin terjadi pada rilis mendatang (1.12).


Anda dapat membaca tentang hasil dan detail struktur internal bagian kompiler ini di salah satu artikel saya berikutnya. Saya akan mencoba untuk memberikan serangkaian rekomendasi yang akan membantu dalam beberapa kasus untuk menyusun kode sehingga memiliki alokasi memori yang kurang diinginkan.

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


All Articles