الذهاب يتيح لك الكتابة في المجمع. لكن مؤلفي اللغة كتبوا مثل هذه المكتبة القياسية بحيث لا يتعين القيام بها. هناك طرق لكتابة التعليمات البرمجية المحمولة والسريعة في نفس الوقت. كيف؟ مرحبا بكم تحت قص.
البدء في كتابة وظائف في المجمع في الذهاب بسيط جدا. على سبيل المثال ، قم بتعريف (التصريح الأمامي) بوظيفة
Add
، والتي تضيف 2 int64:
func Add(a int64, b int64) int64
هذه وظيفة عادية ، لكن جسم الوظيفة مفقود. سوف أقسم المترجم بشكل معقول عند محاولة ترجمة حزمة.
% go build examples/asm ./decl.go:4:6: missing function body
إضافة ملف بالملحق .s وتنفيذ الوظيفة في المجمع.
TEXT ·Add(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET
الآن يمكنك تجميع واختبار واستخدام
Add
كدالة طبيعية. يتم استخدام هذا على نطاق واسع من قبل مطوري اللغة أنفسهم في حزم
وقت التشغيل ، والرياضيات ، و bytealg ، syscall ، وتعكس ، والتشفير . يسمح لك هذا باستخدام تحسينات معالج الأجهزة
والأوامر غير الممثلة باللغة .
ولكن هناك مشكلة - لا يمكن تحسين الوظائف على asm ومضمنة (مضمنة). بدون هذا ، يمكن أن تكون النفقات العامة باهظة.
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
كانت هناك عدة اقتراحات لتجميع مضمّنة ، مثل التوجيه
asm(...)
في gcc. لم يتم قبول أي منهم. بدلاً من ذلك ، انتقل الوظائف
المضمنة المضافة.
الذهاب مدمجة في وظائف مكتوبة في الذهاب عادي. لكن المترجم يعرف أنه يمكن استبداله بشيء أكثر أمثل. في Go 1.13 ، يتم تضمين الوظائف المدمجة في
math/bits
sync/atomic
.
وظائف في هذه الحزم لها توقيعات الهوى. في الواقع ، يكررون توقيعات أوامر المعالج. يسمح هذا للمترجم ، إذا كانت البنية المستهدفة تدعم ، باستبدال استدعاءات الوظائف بشفافية بتعليمات المجمّع.
أدناه ، أرغب في التحدث عن طريقتين مختلفتين يقوم فيه برنامج التحويل البرمجي go بإنشاء رمز أكثر فاعلية باستخدام وظائف مضمنة.
عدد السكان
هذا العدد من الوحدات في التمثيل الثنائي لعدد هو بدائي تشفير مهم. نظرًا لأن هذه عملية مهمة ، فإن معظم وحدة المعالجة المركزية الحديثة توفر التنفيذ في الأجهزة.
توفر حزمة
math/bits
هذه العملية في وظائف
OnesCount*
. يتم التعرف عليها واستبدالها
POPCNT
معالج
POPCNT
.
لنرى كيف يمكن أن يكون هذا أكثر فعالية ، دعنا نقارن 3 تطبيقات. الأول هو
خوارزمية Kernigan .
func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count }
يتزامن عدد دورات الخوارزمية مع عدد البتات المحددة. المزيد من وحدات البت - وقت تنفيذ أطول ، مما قد يؤدي إلى تسرب المعلومات على قنوات الجهات الخارجية.
الخوارزمية الثانية هي من
هاكر ديلايت .
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) }
تسمح استراتيجية الفجوة والقهر لهذا الإصدار بالعمل مع O (log₂) من عدد طويل ، ولوقت ثابت من عدد البتات ، وهو أمر مهم للتشفير. دعونا نقارن الأداء مع
math/bits.OnesCount64
.
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) }
لكي نكون صادقين ، نقوم بتمرير نفس المعلمات إلى الوظائف: تسلسل من 0 إلى bN. هذا صحيح بالنسبة لطريقة Kernigan ، حيث يزداد وقت التنفيذ مع زيادة عدد وحدات البت في وسيطة الإدخال.
➚ BenchmarkKernighan-4 100000000 12.9 ns/op BenchmarkPopcnt-4 485724267 2.63 ns/op BenchmarkMathBitsOnesCount64-4 1000000000 0.673 ns/op
math/bits.OnesCount64
يفوز في السرعة 4 مرات. ولكن هل يستخدم تطبيق الأجهزة بالفعل ، أم هل قام المترجم بتحسين الخوارزمية بشكل أفضل من Hackers Delight؟ حان الوقت للدخول في المجمع.
go test -c
هناك أداة بسيطة لتفكيك objdump لأداة go ، لكنني (على عكس مؤلف المقال الأصلي) ، سأستخدم IDA.
هناك الكثير يحدث هنا. الأهم من ذلك: تم
POPCNT
تعليمة
POPCNT
في رمز الاختبار نفسه ، كما كنا نأمل. هذا يجعل banchmark أسرع من البدائل.
هذا المتفرعة مثيرة للاهتمام.
cmp cs:runtime_x86HasPOPCNT, 0 jz lable
نعم ، هذا هو polyphile على المجمع. لا تدعم جميع المعالجات
POPCNT
. عندما يبدأ البرنامج ، قبل
main
الخاص بك ،
runtime.cpuinit
وظيفة
runtime.cpuinit
إذا كانت هناك تعليمات ضرورية
runtime.x86HasPOPCNT
في
runtime.x86HasPOPCNT
.
runtime.x86HasPOPCNT
. في كل مرة يتحقق البرنامج من إمكانية استخدام
POPCNT
، أو استخدام ملف متعدد الملفات. نظرًا لأن قيمة
runtime.x86HasPOPCNT
لا تتغير بعد التهيئة ، فإن التنبؤ
runtime.x86HasPOPCNT
المعالج يكون دقيقًا نسبيًا.
عداد ذري
الدوال الجوهرية هي رمز Go منتظم ؛ يمكن أن تكون مضمنة في دفق التعليمات. على سبيل المثال ، سنجعل تجريدًا من العداد بطرق من التوقيعات الغريبة لوظائف الحزمة الذرية.
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() }
شخص ما سوف يعتقد أن مثل هذا OOP سيضيف النفقات العامة. لكن Go ليس Java - اللغة لا تستخدم الربط في وقت التشغيل إلا إذا كنت تستخدم الواجهات بشكل صريح. سيتم طي الكود أعلاه في دفق فعال من تعليمات المعالج. كيف ستبدو الرئيسية؟
بالترتيب
c.inc
يتحول إلى
lock xadd [rax], 1
- إضافة ذرية إلى x86.
c.get
يصبح تعليمة
mov
المعتادة ، والتي تكون ذرية بالفعل في x86. يصبح
c.reset
التبادل الذري لـ
xchg
بين السجل الفارغ والذاكرة.
استنتاج
الوظائف المدمجة هي حل أنيق يوفر الوصول إلى العمليات منخفضة المستوى دون توسيع نطاق مواصفات اللغة. إذا كانت البنية لا تحتوي على بدائل مزامنة / ذرية محددة (مثل بعض المتغيرات ARM) ، أو عمليات من الرياضيات / بت ، ثم يقوم المترجم بإدراج polyfile في حالة نقية.