تجنّب الفحوص غير الضرورية لكون مؤشر المصفوفة nil داخل حلقة
توجد بعض العيوب في التطبيق الحالي للمصرِّف القياسي الرسمي لـ Go (v1.24.n). أحدها أن بعض فحوص كون مؤشر المصفوفة nil لا تُنقل خارج الحلقات. إليك مثالاً يُظهر هذا العيب.
// unnecessary-checks.go
package pointers
import "testing"
const N = 1000
var a [N]int
//go:noinline
func g0(a *[N]int) {
for i := range a {
a[i] = i // السطر 12
}
}
//go:noinline
func g1(a *[N]int) {
_ = *a // السطر 18
for i := range a {
a[i] = i // السطر 20
}
}
func Benchmark_g0(b *testing.B) {
for i := 0; i < b.N; i++ { g0(&a) }
}
func Benchmark_g1(b *testing.B) {
for i := 0; i < b.N; i++ { g1(&a) }
}
لنُشغّل قياسات الأداء مع خيار المصرِّف -S، فنحصل على المخرجات التالية (حُذفت النصوص غير المهمة):
$ go test -bench=. -gcflags=-S unnecessary-checks.go
...
0x0004 00004 (unnecessary-checks.go:12) TESTB AL, (AX)
0x0006 00006 (unnecessary-checks.go:12) MOVQ CX, (AX)(CX*8)
...
0x0000 00000 (unnecessary-checks.go:18) TESTB AL, (AX)
0x0002 00002 (unnecessary-checks.go:18) XORL CX, CX
0x0004 00004 (unnecessary-checks.go:19) JMP 13
0x0006 00006 (unnecessary-checks.go:20) MOVQ CX, (AX)(CX*8)
...
Benchmark_g0-4 494.8 ns/op
Benchmark_g1-4 399.3 ns/op
من المخرجات، يمكننا أن نجد أن تطبيق g1 أعلى أداءً من تطبيق g0، حتى وإن كان تطبيق g1 يحتوي سطر شيفرة إضافياً واحداً (السطر 18). لماذا؟ تُجيب تعليمات التجميع (assembly) المُخرَجة عن هذا السؤال.
في تطبيق g0، تُولَّد تعليمة TESTB داخل الحلقة، بينما في تطبيق g1 تُولَّد تعليمة TESTB خارج الحلقة. وتُستخدم تعليمة TESTB للتحقق مما إذا كان المعطى a مؤشراً nil أم لا. وفي هذه الحالة المحددة، يكفي الفحص مرة واحدة. وهكذا يتجنّب سطر الشيفرة الإضافي العيب الموجود في تطبيق المصرِّف.
يوجد تطبيق ثالث لا يقل أداءً عن تطبيق g1. يستخدم التطبيق الثالث شريحة مشتقة من معطى مؤشر المصفوفة.
//go:noinline
func g2(x *[N]int) {
a := x[:]
for i := range a {
a[i] = i
}
}
يرجى ملاحظة أن هذا العيب قد يُصلَح في إصدارات مستقبلية من المصرِّف.
ويرجى أيضاً ملاحظة أنه إذا كانت دوال التطبيقات الثلاث قابلة للتصريف الضمني (inline)، فستتغير نتائج القياس كثيراً. ولهذا السبب تُستخدم هنا توجيهات المصرِّف //go:noinline. (قبل سلسلة أدوات Go الإصدار v1.18، كانت توجيهات المصرِّف //go:noinline غير ضرورية فعلاً هنا. لأن سلسلة أدوات Go قبل الإصدار v1.18 لم تكن تصرّف ضمنياً أي دالة تحتوي على حلقة for-range.)
الحالة التي يكون فيها مؤشر المصفوفة حقلاً في بنية
في الحالات التي يكون فيها مؤشر المصفوفة حقلاً في بنية، تكون الأمور أكثر تعقيداً قليلاً. لا يفيد السطر _ = *t.a في الشيفرة التالية في تجنّب عيب المصرِّف. فعلى سبيل المثال، في الشيفرة التالية، يكون فرق الأداء بين الدالة f1 والدالة f0 صغيراً. (بل إن الدالة f1 قد تكون أبطأ، إذا وُلِّدت تعليمة NOP داخل حلقتها.)
type T struct {
a *[N]int
}
//go:noinline
func f0(t *T) {
for i := range t.a {
t.a[i] = i
}
}
//go:noinline
func f1(t *T) {
_ = *t.a
for i := range t.a {
t.a[i] = i
}
}
لنقل فحوص كون مؤشر المصفوفة nil خارج الحلقة، ينبغي نسخ الحقل t.a إلى متغير محلي، ثم تبنّي الحيلة المقدَّمة أعلاه:
//go:noinline
func f3(t *T) {
a := t.a
_ = *a
for i := range a {
a[i] = i
}
}
أو ببساطة اشتق شريحة من حقل مؤشر المصفوفة:
//go:noinline
func f4(t *T) {
a := t.a[:]
for i := range a {
a[i] = i
}
}
نتائج القياس:
Benchmark_f0-4 622.9 ns/op
Benchmark_f1-4 637.4 ns/op
Benchmark_f2-4 511.3 ns/op
Benchmark_f3-4 390.1 ns/op
Benchmark_f4-4 387.6 ns/op
تؤكد النتائج استنتاجاتنا السابقة.
لاحظ أن الدالة f2 المذكورة في نتائج القياس معرَّفة كما يلي
//go:noinline
func f2(t *T) {
a := t.a
for i := range a {
a[i] = i
}
}
تطبيق f2 ليس بسرعة تطبيقَي f3 وf4، لكنه أسرع من تطبيقَي f0 وf1. ومع ذلك، فذلك حديث آخر.
إذا لم تُعدَّل عناصر حقل مؤشر المصفوفة (اقتُصر على قراءتها) داخل الحلقة، فإن طريقة f1 تكون بذات أداء طريقتَي f3 وf4.
شخصياً، أرى أننا ينبغي أن نحاول في معظم الحالات استخدام طريقة الشريحة (طريقة f4) للحصول على أفضل أداء، لأن المصرِّف القياسي الرسمي لـ Go يحسّن الشرائح عموماً أفضل من المصفوفات.
تجنّب عمليات إلغاء الإشارة غير الضرورية إلى المؤشرات داخل حلقة
أحياناً، لا يكون المصرِّف القياسي الرسمي الحالي لـ Go (v1.24.n) ذكياً بما يكفي لتوليد تعليمات التجميع بالطريقة الأكثر تحسيناً. لذلك علينا كتابة الشيفرة بطريقة أخرى للحصول على أفضل أداء. فعلى سبيل المثال، في الشيفرة التالية، يكون أداء الدالة f أقل بكثير من أداء الدالة g.
// avoid-indirects_test.go
package pointers
import "testing"
//go:noinline
func f(sum *int, s []int) {
for _, v := range s { // السطر 8
*sum += v // السطر 9
}
}
//go:noinline
func g(sum *int, s []int) {
var n = *sum
for _, v := range s { // السطر 16
n += v // السطر 17
}
*sum = n
}
var s = make([]int, 1024)
var r int
func Benchmark_f(b *testing.B) {
for i := 0; i < b.N; i++ {
f(&r, s)
}
}
func Benchmark_g(b *testing.B) {
for i := 0; i < b.N; i++ {
g(&r, s)
}
}
نتائج القياس (حُذفت النصوص غير المهمة):
$ go test -bench=. -gcflags=-S avoid-indirects_test.go
...
0x0009 00009 (avoid-indirects_test.go:9) MOVQ (AX), SI
0x000c 00012 (avoid-indirects_test.go:9) ADDQ (BX)(DX*8), SI
0x0010 00016 (avoid-indirects_test.go:9) MOVQ SI, (AX)
0x0013 00019 (avoid-indirects_test.go:8) INCQ DX
0x0016 00022 (avoid-indirects_test.go:8) CMPQ CX, DX
0x0019 00025 (avoid-indirects_test.go:8) JGT 9
...
0x000b 00011 (avoid-indirects_test.go:16) MOVQ (BX)(DX*8), DI
0x000f 00015 (avoid-indirects_test.go:16) INCQ DX
0x0012 00018 (avoid-indirects_test.go:17) ADDQ DI, SI
0x0015 00021 (avoid-indirects_test.go:16) CMPQ CX, DX
0x0018 00024 (avoid-indirects_test.go:16) JGT 11
...
Benchmark_f-4 3024 ns/op
Benchmark_g-4 566.6 ns/op
تُظهر تعليمات التجميع المُخرَجة أن المؤشر sum يُلغى الإشارة إليه داخل الحلقة في الدالة f. وعملية إلغاء الإشارة عملية ذاكرة. أما في الدالة g، فتحدث عمليات إلغاء الإشارة خارج الحلقة، ولا تعالج التعليمات المُولَّدة للحلقة سوى السجلات (registers). ومعالجة تعليمات CPU للسجلات أسرع بكثير من معالجتها للذاكرة، ولهذا يكون أداء الدالة g أعلى بكثير من أداء الدالة f.
هذا ليس عيباً في المصرِّف. في الواقع، الدالتان f وg غير متكافئتين (وإن كانت نتائجهما متماثلة في معظم حالات الاستخدام العملية). فعلى سبيل المثال، إذا استُدعيتا كما تُظهر الشيفرة التالية، فإنهما تُرجعان نتيجتين مختلفتين (الشكر لـ skeeto@reddit على إجراء هذا التصحيح).
{
var s = []int{1, 1, 1}
var sum = &s[2]
f(sum, s)
println(*sum) // 6
}
{
var s = []int{1, 1, 1}
var sum = &s[2]
g(sum, s)
println(*sum) // 4
}
هناك تطبيق آخر عالي الأداء لهذه الحالة المحددة، وهو نقل وسيط المؤشر خارج جسم الدالة (ومرة أخرى، هو ليس مكافئاً تماماً للدالة f ولا للدالة g):
//go:noinline
func h(s []int) int {
var n = 0
for _, v := range s {
n += v
}
return n
}
func use_h(s []int) {
var sum = new(int)
*sum += h(s)
...
}