bytes.Buffer in Go: تحسينات لا تعمل

العديد من مبرمجي Go على دراية بالبايت . تتمثل إحدى ميزاته في أنه يسمح لك بتجنب تخصيص ذاكرة في الكومة بالطريقة نفسها التي يتم بها " تحسين التخزين المؤقت / الحجم الصغير ":


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

هناك مشكلة واحدة فقط. لا يعمل هذا التحسين.


في نهاية هذه المقالة ، ستكتشف لماذا لا يعمل هذا التحسين وما يمكننا القيام به حيال ذلك.


كما هو مقصود ، "تحسين المخزن المؤقت الصغير"


دعونا نقدم تعريفًا مبسطًا قليلاً bytes.Buffer


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

عندما نقوم بتنفيذ إجراءات على Buffer ، على سبيل المثال ، استدعاء أسلوب Buffer.Write ، يتم إجراء السجل دائمًا في buf ، ومع ذلك ، قبل هذا السجل ، يتم Buffer.grow(n) داخل كل طريقة مماثلة ، مما يضمن وجود مساحة كافية في هذه الشريحة لـ بايت n القادمة.


قد يبدو النمو شيئًا مثل هذا:


 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 } } 

الافتراضات المستخدمة في تنفيذ Buffer.grow


نحن len(b.buf) أن len(b.buf) هو طول البيانات الفعلي في المخزن المؤقت ، الأمر الذي يتطلب Write لاستخدام أساليب الإلحاق لإضافة وحدات بايت جديدة إلى الشريحة. هذا ليس هو الحال في bytes.Buffer من المكتبة القياسية ، ولكن كمثال ، هذه تفاصيل تنفيذ غير ذات صلة.




إذا b تخصيص b على المكدس ، فسيتم تخصيص bootstrap بداخلها على المكدس ، مما يعني أن الشريحة b.buf ستعيد استخدام الذاكرة داخل b دون الحاجة إلى تخصيص إضافي.


عندما يكشف grow عن أن مصفوفة bootstrap غير كافية بالفعل ، سيتم إنشاء شريحة "حقيقية" جديدة ، حيث سيتم بعد ذلك نسخ عناصر من التخزين السابق (من "المخزن المؤقت الصغير"). بعد ذلك ، Buffer.bootstrap أهميته. إذا تم Buffer.Reset ، cap(b.buf) كما هي ولن تكون هناك حاجة بعد الآن إلى مصفوفة bootstrap .


تشغيل الذاكرة في كومة الذاكرة المؤقتة


من المتوقع أيضًا أن يكون القارئ على الأقل سطحيًا على دراية بما هو تحليل الهروب في Go.


خذ بعين الاعتبار الحالة التالية:


 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 } 

هنا سيتم تخصيص b في كومة الذاكرة المؤقتة. والسبب في ذلك هو مؤشر التسريب إلى b :


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

المصطلحات


في هذه المقالة ، يتم استخدام "التسرب" و "الهروب" بشكل مترادف تقريبًا.


هناك بعض الاختلاف في المترجم نفسه ، على سبيل المثال ، القيمة "الهروب إلى كومة الذاكرة المؤقتة" ، ولكن معلمات الدالة هي "تسرب المعلمة x".


تعني معلمة التسرب أنه سيتم تخصيص الوسيطة التي تم تمريرها لهذه المعلمة في كومة الذاكرة المؤقتة. بمعنى آخر ، تؤدي المعلمة المتسربة إلى هروب الحجج إلى كومة.




ما سبق كان حالة واضحة ، ولكن ماذا عن هذا:


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

هنا نحتاج إلى بايت واحد فقط ، كل شيء يناسب ملف bootstrap ، المخزن المؤقت نفسه محلي ولا "يهرب" من الوظيفة. قد تفاجأ ، ولكن النتيجة ستكون هي نفسها ، b تخصيص في كومة الذاكرة المؤقتة.



للتأكد ، يمكنك التحقق من ذلك باستخدام المعيار:


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

قائمة مرجعية


 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() } } 



شرح 112 ب / ع


عندما يطلب وقت التشغيل تخصيص N بايت ، ليس من الضروري تخصيص N بايت بالضبط.


جميع النتائج أدناه هي مزيج من GOOS=linux و 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() } } 

إذا قمت بتشغيل go test -bench=. -benchmem go test -bench=. -benchmem مع هذا الاختبار:


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

تم طلب 9 بايت ، تم تخصيص 16. عودة الآن إلى bytes.Buffer


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

دعونا نلقي نظرة على $ 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% // ...  

لا يتناسب مع 96 بايت ، تم تحديد 112.




ولكن لماذا يحدث هذا؟


ماذا يحدث ولماذا


يمكن العثور على بعض تحليل الوضع في القضية المذكورة في البداية.
هناك أيضا ناسخ بسيط .


مكان المشكلة فقط في المهمة b.buf = b.bootstrap[:] . يجعل هذا الكود تحليل الهروب يفترض أن b.bootstrap "يهرب" ، وبما أنه صفيف ، فإنه يتم تخزينه داخل الكائن نفسه ، مما يعني أنه يجب تخصيص كل b في الكومة.


إذا كان bootstrap عبارة عن شريحة ، وليس صفيفًا ، فلن يحدث هذا ، لأن هناك تحسينًا مخصصًا لتخصيص شرائح من الكائن إلى الكائن نفسه:


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

تم بالفعل الإجابة عن سبب عدم عمل هذا التحسين للصفائف أعلاه ، ولكن هنا ضغط من esc.go # L835-L866 نفسه (يتم تمييز رمز التحسين بالكامل حسب المرجع):


 // 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. 

تجدر الإشارة هنا إلى أنه بالنسبة لمحلل المؤشر هناك عدة مستويات من "التسريبات" ، أهمها:


  1. الكائن نفسه يهرب (يهرب b). في هذه الحالة ، يحتاج الكائن نفسه إلى تخصيص في كومة الذاكرة المؤقتة.
  2. عناصر الكائن (هروب محتويات ب) تهرب. في هذه الحالة ، تعتبر المؤشرات الموجودة في الكائن هروبًا.

حالة المصفوفة خاصة في أنه إذا تسرب المصفوفة ، يجب أن يتسرب الكائن الذي يحتوي عليه أيضًا.


يعرض تحليل الهروب قرارًا بشأن ما إذا كان من الممكن وضع كائن على المكدس أم لا ، بالاعتماد فقط على المعلومات المتوفرة في نص الوظيفة التي تم تحليلها. تأخذ طريقة Buffer.grow b حسب المؤشر ، لذلك تحتاج إلى حساب مكان مناسب لوضعه. نظرًا لأنه في حالة وجود مصفوفة ، لا يمكننا التمييز بين "b escape" "b contents escape" ، يجب أن نكون أكثر تشاؤمًا ونتوصل إلى استنتاج مفاده أن b آمن لوضعه على المكدس.


افترض العكس ، أن نمط self-assignment يحل نفسه بالنسبة للمصفوفات كما يفعل في الشرائح:


 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 } 

سيؤدي قرار وضع b على المكدس في هذه الحالة إلى كارثة: بعد الخروج من الوظيفة التي تم إنشاؤها b ، لن تكون الذاكرة التي سيشير إليها sink أكثر من القمامة.


مؤشرات الصفيف


تخيل أنه تم الإعلان عن Buffer لدينا بشكل مختلف قليلاً:


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

على عكس المصفوفة العادية ، لن يخزن مؤشر المصفوفة جميع العناصر داخل Buffer نفسه. وهذا يعني أنه إذا لم يتضمن تخصيص bootstrap على الكومة تخصيص Buffer على كومة الذاكرة المؤقتة. نظرًا لأن تحليل الهروب يمكن أن يخصص حقول المؤشر على المكدس عندما يكون ذلك ممكنًا ، يمكننا أن نفترض أن تعريف Buffer هذا أكثر نجاحًا.


لكن هذا نظريا. من الناحية العملية ، لا يحتوي مؤشر الصفيف على الكثير من المعالجة ويقع في نفس فئة الشريحة من صفيف عادي ، وهو غير صحيح تمامًا. CL133375: cmd / compile / internal / gc: معالجة التخصيص الذاتي لشريحة الصفيف في esc.go تهدف إلى تصحيح هذا الموقف.


افترض أن هذا التغيير قد تم قبوله في مترجم Go.


فقدنا القيمة صفر


لسوء الحظ ، فإن الانتقال من [64]byte إلى *[64]byte يعاني من مشكلة: الآن لا يمكننا استخدام bootstrap بدون تهيئته بشكل صريح ، تتوقف القيمة الصفرية لـ Buffer عن كونها مفيدة ، نحن بحاجة إلى مُنشئ.


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

نعيد Buffer ، وليس *Buffer ، لتجنب المشاكل في تحليل المؤشرات (وهي محافظة للغاية في Go) ، ومع الأخذ في الاعتبار حقيقة أن NewBuffer مدمج دائمًا في مكان المكالمة ، فلن يكون هناك نسخ غير ضروري.


بعد تضمين نص NewBuffer في مكان المكالمة ، يمكن أن يحاول تحليل الهروب إثبات أن new(*[smallBufSize]byte) لا يتجاوز عمر إطار الوظيفة التي يتم استدعاؤها. إذا كان الأمر كذلك ، فسيتم التخصيص على المكدس.


إنتل bytebuf


يتم تطبيق التحسين الموضح أعلاه في حزمة intel-go / bytebuf .


تصدر هذه المكتبة نوع bytebuf.Buffer ، الذي يكرر 99.9٪ bytes.Buffer . يتم تقليل جميع التغييرات إلى مقدمة مُنشئ ( bytebuf.New ) ومؤشر إلى صفيف بدلاً من صفيف عادي:


 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*). } 

فيما يلي مقارنة للأداء 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) 

تتوفر جميع المعلومات الأخرى في README .


نظرًا لعدم القدرة على استخدام القيمة الصفرية bytes.Buffer بوظيفة الإنشاء New ، لا يمكن تطبيق هذا التحسين على bytes.Buffer .


هل هذه هي الطريقة الوحيدة لجعل أسرع bytes.Buffer . الجواب لا. لكن هذه بالتأكيد طريقة تتطلب تغييرات طفيفة في التنفيذ.


خطط تحليل الهروب


تحليل الهروب في شكله الحالي ضعيف جدًا. تؤدي أي عملية تقريبًا بقيم المؤشر إلى تخصيصات في كومة الذاكرة المؤقتة ، حتى لو لم يكن هذا قرارًا معقولاً.


سأحاول توجيه معظم الوقت الذي أخصصه لمشروع golang / go لحل هذه المشاكل ، لذا فإن بعض التحسينات ممكنة في الإصدار القادم (1.12).


يمكنك أن تقرأ عن نتائج وتفاصيل التركيب الداخلي لهذا الجزء من المترجم في إحدى مقالاتي القادمة. سأحاول تقديم مجموعة من التوصيات التي ستساعد في بعض الحالات على تنظيم الشفرة بحيث تحتوي على تخصيصات ذاكرة غير مرغوب فيها أقل.

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


All Articles