ميكانيكا اللغة في تنميط الذاكرة

مقدمة



هذا هو المقال الثالث من بين أربع مقالات في سلسلة ستوفر نظرة ثاقبة لميكانيكا وتصميم المؤشرات والمكدسات والأكوام وتحليل الهروب ودلالات القيمة / المؤشر في Go. هذا المنشور هو حول تنميط الذاكرة.



جدول محتويات دورة المقالات:



  1. ميكانيكا اللغة على الأكوام والمؤشرات ( ترجمة )
  2. ميكانيكا اللغة في تحليل الهروب ( ترجمة )
  3. ميكانيكا اللغة على التنميط الذاكرة
  4. فلسفة التصميم في البيانات والدلالات


شاهد هذا الفيديو لمشاهدة عرض توضيحي لهذا الرمز:

DGopherCon Singapore (2017) - Escape Analysis



المقدمة



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



برنامج



كنت أرغب في معرفة المزيد عن حزمة io ، لذلك توصلت إلى مهمة صغيرة لنفسي. بالنظر إلى تدفق من البايتات ، اكتب دالة يمكنها العثور على سلسلة الفيس واستبدالها بسلسلة الأحرف الكبيرة Elvis. نحن نتحدث عن ملك ، لذا يجب كتابة اسمه بأحرف كبيرة دائمًا.



هنا رابط لحل: play.golang.org/p/n_SzF4Cer4

هنا رابط للمعايير: play.golang.org/p/TnXrxJVfLV



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



هنا هو المدخل الذي سنستخدمه والمخرج المتوقع من وظيفة algOne.



قائمة 1



Input:
abcelvisaElvisabcelviseelvisaelvisaabeeeelvise l v i saa bb e l v i saa elvi
selvielviselvielvielviselvi1elvielviselvis

Output:
abcElvisaElvisabcElviseElvisaElvisaabeeeElvise l v i saa bb e l v i saa elvi
selviElviselvielviElviselvi1elviElvisElvis


يوجد أدناه قائمة بوظيفة algOne.



القائمة 2



 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := io.ReadFull(input, buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := io.ReadFull(input, buf[:end]); err != nil {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


أريد أن أعرف مدى جودة عمل هذه الوظيفة ومقدار الضغط الذي تمارسه على الكومة. لمعرفة ذلك ، دعنا نجري معيارًا.



المرجعية



لقد كتبت معيارًا يستدعي وظيفة algOne لأداء المعالجة على تدفق البيانات.



قائمة 3



15 func BenchmarkAlgorithmOne(b *testing.B) {
16     var output bytes.Buffer
17     in := assembleInputStream()
18     find := []byte("elvis")
19     repl := []byte("Elvis")
20
21     b.ResetTimer()
22
23     for i := 0; i < b.N; i++ {
24         output.Reset()
25         algOne(in, find, repl, &output)
26     }
27 }


يمكننا تشغيل هذا المعيار باستخدام اختبار go test مع مفاتيح -bench و -benchtime و -benchmem.



القائمة 4



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        2000000          2522 ns/op       117 B/op            2 allocs/op


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



التنميط



لإنشاء بيانات التوصيف ، قم بتشغيل المعيار مرة أخرى ، ولكن هذه المرة سوف نستعلم عن ملف تعريف الذاكرة باستخدام مفتاح -memprofile.



قائمة 5



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op


بعد الانتهاء من الاختبار ، أنشأت أداة الاختبار ملفين جديدين.



القائمة 6



~/code/go/src/.../memcpu
$ ls -l
total 9248
-rw-r--r--  1 bill  staff      209 May 22 18:11 mem.out       (NEW)
-rwxr-xr-x  1 bill  staff  2847600 May 22 18:10 memcpu.test   (NEW)
-rw-r--r--  1 bill  staff     4761 May 22 18:01 stream.go
-rw-r--r--  1 bill  staff      880 May 22 14:49 stream_test.go


توجد الكود المصدرية في مجلد memcpu في وظيفة algOne لملف stream.go ووظيفة قياس الأداء في ملف stream_test.go. تم تسمية الملفين الجديدين اللذين تم إنشاؤهما باسم mem.out و memcpu.test. يحتوي ملف mem.out على بيانات ملف التعريف ، ويحتوي ملف memcpu.test ، المسمى باسم المجلد ، على ثنائي الاختبار الذي نحتاجه للوصول إلى الرموز عند النظر إلى بيانات ملف التعريف.



مع وجود بيانات الملف الشخصي وثنائي الاختبار في مكانه الصحيح ، يمكننا تشغيل أداة pprof لفحص بيانات الملف الشخصي.



قائمة 7



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _


عند تحديد سمات الذاكرة والبحث عن الفاكهة المعلقة المنخفضة ، يمكنك استخدام الخيار -alloc_space بدلاً من الخيار الافتراضي -inuse_space. سيُظهر لك هذا مكان حدوث كل تخصيص ، سواء كان ذلك في الذاكرة أم لا عند الاستيلاء على ملف التعريف.



في مربع الإدخال (pprof) ، يمكننا التحقق من وظيفة algOne باستخدام الأمر list. يأخذ هذا الأمر تعبيرًا عاديًا كوسيطة للعثور على الوظيفة (الوظائف) التي تريد عرضها.



القائمة 8



(pprof) list algOne
Total: 335.03MB
ROUTINE ======================== .../memcpu.algOne in code/go/src/.../memcpu/stream.go
 335.03MB   335.03MB (flat, cum)   100% of Total
        .          .     78:
        .          .     79:// algOne is one way to solve the problem.
        .          .     80:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
        .          .     81:
        .          .     82: // Use a bytes Buffer to provide a stream to process.
 318.53MB   318.53MB     83: input := bytes.NewBuffer(data)
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
  16.50MB    16.50MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := io.ReadFull(input, buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])
(pprof) _


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



قد نفترض أن التخصيص يحدث لأن الاستدعاء للبايتات. NewBuffer يشارك البايت. قيمة المخزن التي تنشئ مكدس الاستدعاء. ومع ذلك ، فإن وجود قيمة في العمود المسطح (العمود الأول في إخراج pprof) يخبرني أنه يتم تخصيص القيمة لأن دالة algOne تقسمها بطريقة تجعلها تتراكم.



أعلم أن العمود المسطح يمثل التخصيصات في الوظيفة ، لذا ألق نظرة على ما يعرضه أمر القائمة لوظيفة Benchmark التي تستدعي algOne.



القائمة 9



(pprof) list Benchmark
Total: 335.03MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
        0   335.03MB (flat, cum)   100% of Total
        .          .     18: find := []byte("elvis")
        .          .     19: repl := []byte("Elvis")
        .          .     20:
        .          .     21: b.ResetTimer()
        .          .     22:
        .   335.03MB     23: for i := 0; i < b.N; i++ {
        .          .     24:       output.Reset()
        .          .     25:       algOne(in, find, repl, &output)
        .          .     26: }
        .          .     27:}
        .          .     28:
(pprof) _


نظرًا لوجود قيمة فقط في عمود نائب الرئيس (العمود الثاني) ، فهذا يخبرني أن Benchmark لا تخصص أي شيء بشكل مباشر. تأتي جميع التخصيصات من استدعاءات الوظائف التي يتم تنفيذها داخل هذه الحلقة. يمكنك أن ترى أن جميع أرقام التخصيص من هاتين النداءين إلى القائمة متشابهة.



ما زلنا لا نعرف سبب تخصيص قيمة البايت. هذا هو المكان الذي يكون فيه مفتاح التبديل -gcflags "-m -m" لأمر go build مفيدًا. يمكن لمحلل التعريف أن يخبرك فقط بالقيم التي يتم نقلها إلى الكومة ، بينما يخبرك الإصدار بالسبب.



تقرير المترجم



دعنا نسأل المترجم عن القرارات التي يتخذها لتحليل الهروب في الكود.



قائمة 10



$ go build -gcflags "-m -m"


ينتج عن هذا الأمر الكثير من الإخراج. نحتاج فقط إلى البحث في المخرجات عن أي شيء يحتوي على stream.go: 83 ، لأن stream.go هو اسم الملف الذي يحتوي على هذا الرمز ، بينما يحتوي السطر 83 على بنية قيمة بايت. بعد البحث نجد 6 أسطر.



قائمة 11



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }

./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


نحن مهتمون بالسطر الأول الذي وجدناه من خلال البحث عن stream.go: 83.



قائمة 12



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }


هذا يؤكد أن بايت. قيمة المخزن المؤقت لم تختف حيث تم دفعها إلى مكدس الاستدعاءات. حدث هذا لأن استدعاء bytes.NewBuffer لم يحدث أبدًا ، وكان الكود داخل الوظيفة مضمّنًا.



هذا هو سطر الكود المعني:



القائمة 13



83     input := bytes.NewBuffer(data)


نظرًا لأن المترجم قرر تضمين بايتات استدعاء دالة NewBuffer ، فإن الكود الذي كتبته يتحول إلى هذا:



قائمة 14



input := &bytes.Buffer{buf: data}


هذا يعني أن الدالة algOne تُنشئ قيمة bytes.Buffer مباشرةً. السؤال المطروح الآن هو ، ما الذي يجعل القيمة تخرج من إطار مكدس algOne؟ هذه الإجابة موجودة في الأسطر الخمسة الأخرى التي وجدناها في التقرير.



القائمة 15



./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


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



واجهات



لا أتذكر القيام بتعيين قيمة الواجهة على الإطلاق في الكود. ومع ذلك ، إذا نظرت إلى السطر 93 ، يتضح لك ما يجري.



قائمة 16



 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }


استدعاء استدعاء io.ReadFull يستدعي تعيين الواجهة. إذا نظرت إلى تعريف الدالة io.ReadFull ، يمكنك أن ترى أنها تقبل متغير إدخال من خلال نوع واجهة.



قائمة 17



type Reader interface {
      Read(p []byte) (n int, err error)
}

func ReadFull(r Reader, buf []byte) (n int, err error) {
      return ReadAtLeast(r, buf, len(buf))
}


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



استخدم الواجهة عندما:



  • يجب على مستخدمي API تقديم تفاصيل التنفيذ.
  • تحتوي واجهة برمجة التطبيقات على العديد من التطبيقات التي يجب أن تدعمها داخليًا.
  • تم تحديد أجزاء من API التي قد تتغير وتتطلب الفصل.


لا تستخدم الواجهة:



  • من أجل استخدام الواجهة.
  • لتعميم الخوارزمية.
  • عندما يمكن للمستخدمين إعلان واجهاتهم الخاصة.


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



دعنا نغير الكود لإزالة الحزمة io ونستخدم طريقة القراءة مباشرة على متغير الإدخال.



يزيل تغيير الرمز هذا الحاجة إلى استيراد حزمة io ، لذا للحفاظ على جميع أرقام الأسطر كما هي ، أستخدم معرفًا فارغًا لاستيراد حزمة io. سيؤدي ذلك إلى إبقاء الواردات في القائمة.



قائمة 18



 12 import (
 13     "bytes"
 14     "fmt"
 15     _ "io"
 16 )

 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := input.Read(buf[:end]); err != nil || n < end {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := input.Read(buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := input.Read(buf[:end]); err != nil || n < end {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


عندما نقيس تغيير هذا الرمز ، يمكننا أن نرى أنه لا يوجد المزيد من التخصيص للبايت.



قائمة 19



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op


ونرى أيضًا تحسنًا في الأداء بنحو 29٪. تغير الوقت من 2570 نانو ثانية / مرجع إلى 1814 نانو ثانية / مرجع سابق. الآن وقد تم حل هذا الأمر ، يمكننا التركيز على تخصيص شريحة مساعدة للبوف. إذا استخدمنا ملف التعريف مرة أخرى لبيانات الملف الشخصي الجديدة التي أنشأناها للتو ، فيمكننا تحديد سبب التخصيصات المتبقية بالضبط.



قائمة 20



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) list algOne
Total: 7.50MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
     11MB       11MB (flat, cum)   100% of Total
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
     11MB       11MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := input.Read(buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])


التخصيص المتبقي الوحيد موجود في السطر 89 ، وهو لإنشاء شريحة مساعدة.



إطارات المكدس



نريد أن نعرف لماذا يحدث التخصيص للشريحة المساعدة للبوف؟ لنقم بتشغيل الإنشاء مرة أخرى باستخدام الخيار -gcflags "-m -m" وابحث عن stream.go: 89.



قائمة 21



$ go build -gcflags "-m -m"
./stream.go:89: make([]byte, size) escapes to heap
./stream.go:89:   from make([]byte, size) (too large for stack) at ./stream.go:89


يوضح التقرير أن المصفوفة المساعدة "كبيرة جدًا بالنسبة للمكدس." هذه الرسالة مضللة. النقطة ليست أن المصفوفة كبيرة جدًا ، ولكن المحول البرمجي لا يعرف حجم المصفوفة المساعدة في وقت الترجمة.



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



لإظهار ذلك ، دعنا نرمز حجم الشريحة مؤقتًا إلى 5 ونشغل المعيار مرة أخرى.



قائمة 22



89     buf := make([]byte, 5)


لا يوجد المزيد من التخصيصات هذه المرة.



قائمة 23



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


إذا ألقيت نظرة أخرى على تقرير المترجم ، فسترى أنه لم يتم نقل أي شيء إلى الكومة.



قائمة 24



$ go build -gcflags "-m -m"
./stream.go:83: algOne &bytes.Buffer literal does not escape
./stream.go:89: algOne make([]byte, 5) does not escape


من الواضح أننا لا نستطيع ترميز حجم الشريحة ، لذلك علينا أن نتعايش مع تخصيص واحد لهذه الخوارزمية.



التخصيصات والأداء



قارن مكاسب الأداء التي حققناها مع كل إعادة بناء ديون.



القائمة 25



Before any optimization
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op

Removing the bytes.Buffer allocation
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op

Removing the backing array allocation
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


حصلنا على زيادة في الأداء بحوالي 29٪ بسبب حقيقة أننا أزلنا البايت. تخصيص احتياطي وتسريع ~ 33٪ بعد إزالة جميع التخصيصات. التخصيصات هي حيث يمكن أن يتأثر أداء التطبيق.



خاتمة



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



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



All Articles