لماذا لا تسرع const رمز C / C ++؟


قبل بضعة أشهر ، ذكرت في منشور واحد أن هذه خرافة ، كما لو أن const يساعد في تمكين تحسينات برنامج التحويل البرمجي في C و C ++ . قررت أن هذا البيان يجب أن يشرح ، خاصة لأنني كنت أؤمن بهذه الأسطورة من قبل سأبدأ بالنظرية والأمثلة الصناعية ، ثم انتقل إلى التجارب والمعايير بناءً على قاعدة الكود الحقيقية - SQLite.

اختبار بسيط


لنبدأ ، كما بدا لي ، بأبسط وأوضح مثال على تسريع كود C مع const . دعنا نقول أن لدينا إعلانين وظيفة:

 void func(int *x); void constFunc(const int *x); 

ونفترض أن هناك نسختين من الكود:

 void byArg(int *x) { printf("%d\n", *x); func(x); printf("%d\n", *x); } void constByArg(const int *x) { printf("%d\n", *x); constFunc(x); printf("%d\n", *x); } 

لتنفيذ printf() ، يجب على المعالج استرداد *x من الذاكرة من خلال مؤشر. من الواضح أن تنفيذ constByArg() يمكن أن يكون أسرع قليلاً ، لأن المترجم يعرف أن *x ثابت ، لذلك ليست هناك حاجة لتحميل قيمته مرة أخرى بعد قيام constFunc() بذلك. أليس كذلك؟ دعونا نرى رمز التجميع الذي تم إنشاؤه بواسطة دول مجلس التعاون الخليجي مع تمكين التحسينات:

 $ gcc -S -Wall -O3 test.c $ view test.s 

وها هي نتيجة المجمّع الكاملة لـ byArg() :

 byArg: .LFB23: .cfi_startproc pushq %rbx .cfi_def_cfa_offset 16 .cfi_offset 3, -16 movl (%rdi), %edx movq %rdi, %rbx leaq .LC0(%rip), %rsi movl $1, %edi xorl %eax, %eax call __printf_chk@PLT movq %rbx, %rdi call func@PLT # The only instruction that's different in constFoo movl (%rbx), %edx leaq .LC0(%rip), %rsi xorl %eax, %eax movl $1, %edi popq %rbx .cfi_def_cfa_offset 8 jmp __printf_chk@PLT .cfi_endproc 

يتمثل الاختلاف الوحيد بين تعليمة برمجية المجمّع التي تم إنشاؤها بواسطة byArg() و constByArg() أن constByArg() لديه call constFunc@PLT ، كما في التعليمات البرمجية المصدر. const نفسها لا فرق.

حسنًا ، كان ذلك دول مجلس التعاون الخليجي. ربما نحتاج إلى مترجم أكثر ذكاء. قل كلانج.

 $ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll 

هنا هو رمز المتوسطة. إنه أكثر إحكاما من المجمّع ، وسأقوم بإسقاط كلتا الوظيفتين ، بحيث تفهم ما أقصده "لا فرق ، باستثناء المكالمة":

 ; Function Attrs: nounwind uwtable define dso_local void @byArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @func(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } ; Function Attrs: nounwind uwtable define dso_local void @constByArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @constFunc(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } 

الخيار الذي يعمل (النوع)


وهنا هو الكود الذي يكون فيه وجود const أمرًا const :

 void localVar() { int x = 42; printf("%d\n", x); constFunc(&x); printf("%d\n", x); } void constLocalVar() { const int x = 42; // const on the local variable printf("%d\n", x); constFunc(&x); printf("%d\n", x); } 

رمز المجمّع لـ localVar() ، الذي يحتوي على إرشادات محسّنة خارج constLocalVar() :

 localVar: .LFB25: .cfi_startproc subq $24, %rsp .cfi_def_cfa_offset 32 movl $42, %edx movl $1, %edi movq %fs:40, %rax movq %rax, 8(%rsp) xorl %eax, %eax leaq .LC0(%rip), %rsi movl $42, 4(%rsp) call __printf_chk@PLT leaq 4(%rsp), %rdi call constFunc@PLT movl 4(%rsp), %edx # not in constLocalVar() xorl %eax, %eax movl $1, %edi leaq .LC0(%rip), %rsi # not in constLocalVar() call __printf_chk@PLT movq 8(%rsp), %rax xorq %fs:40, %rax jne .L9 addq $24, %rsp .cfi_remember_state .cfi_def_cfa_offset 8 ret .L9: .cfi_restore_state call __stack_chk_fail@PLT .cfi_endproc 

الوسيطة LLVM هي أنظف قليلاً. load قبل أن يتم تحسين المكالمة الثانية إلى printf() خارج constLocalVar() :

 ; Function Attrs: nounwind uwtable define dso_local void @localVar() local_unnamed_addr #0 { %1 = alloca i32, align 4 %2 = bitcast i32* %1 to i8* call void @llvm.lifetime.start.p0i8(i64 4, i8* nonnull %2) #4 store i32 42, i32* %1, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 42) call void @constFunc(i32* nonnull %1) #4 %4 = load i32, i32* %1, align 4, !tbaa !2 %5 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) call void @llvm.lifetime.end.p0i8(i64 4, i8* nonnull %2) #4 ret void } 

لذلك ، constLocalVar() بنجاح إعادة التشغيل *x ، لكن قد تلاحظ شيئًا غريبًا: في الهيئات localVar() و constLocalVar() نفس الدعوة إلى constFunc() . إذا تمكن المترجم من معرفة أن constFunc() لم يعدل *x في constLocalVar() ، فلماذا لا يستطيع أن يفهم أن استدعاء الوظيفة نفسه لم يعدل *x في localVar() ؟

التفسير هو لماذا const في C غير عملي لاستخدام التحسين. في C ، يحتوي const بشكل أساسي على معنيين محتملين:

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

فيما يلي مثال توضيحي لتطبيق constFunc() :

 // x is just a read-only pointer to something that may or may not be a constant void constFunc(const int *x) { // local_var is a true constant const int local_var = 42; // Definitely undefined behaviour by C rules doubleIt((int*)&local_var); // Who knows if this is UB? doubleIt((int*)x); } void doubleIt(int *x) { *x *= 2; } 

أعطى constFunc() مؤشر const إلى متغير غير تابع. نظرًا لأن المتغير لم يكن const البداية ، فقد يتحول constFunc() إلى كاذب ويقوم بتعديل المتغير بقوة بدون بدء UB. لذلك ، لا يفترض المحول البرمجي أنه بعد إرجاع constFunc() سيكون للمتغير نفس القيمة. المتغير في constLocalVar() هو في الحقيقة const ، لذلك لا يمكن للمترجم أن يفترض أنه لن يتم تغييره ، لأنه هذه المرة سيكون UB لـ constFunc() ، بحيث يقوم المترجم بربط const بالكتابة إلى المتغير.

byArg() و constByArg() من المثال الأول لا أمل فيها ، لأن المترجم لا يمكنه constByArg() ما إذا كانت *x هي const .

لكن من أين جاء التناقض؟ إذا كان بإمكان المحول البرمجي افتراض أن constFunc() لا يغير الوسيطة الخاصة به عند استدعائه من constLocalVar() ، constLocalVar() عندئذٍ تطبيق نفس التحسينات على constFunc() ، أليس كذلك؟ لا. لا يمكن للمترجم أن يفترض أنه سيتم استدعاء constLocalVar() على الإطلاق. وإذا لم يحدث ذلك (على سبيل المثال ، لأنه مجرد نتيجة إضافية لمولد الشفرة أو تشغيل الماكرو) ، فإن constFunc() يمكنه تغيير البيانات بهدوء دون بدء UB.

قد تحتاج إلى قراءة الأمثلة المذكورة أعلاه وشرح عدة مرات. لا تقلق أنه يبدو سخيفًا - إنه كذلك. لسوء الحظ ، تعد الكتابة إلى متغيرات const أسوأ أنواع UB: في أغلب الأحيان ، لا يعرف المترجم ما إذا كان سيكون UB. لذلك ، عندما يرى المحول البرمجي const ، يجب أن يبدأ من حقيقة أنه يمكن لأي شخص تغييره في مكان ما ، مما يعني أن المحول البرمجي لا يمكنه استخدام const للتحسين. في الواقع ، هذا صحيح ، لأن الكثير من كود C الحقيقي يحتوي على رفض const في نمط "أعرف ما أقوم به".

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

 int x = 42, y = 0; printf("%d %d\n", x, y); y += x; printf("%d %d\n", x, y); 

لذلك const مجدية تقريبًا للتحسين ، وذلك بسبب:

  1. مع بعض الاستثناءات ، يضطر المترجم إلى تجاهلها ، نظرًا لأن بعض الكود يمكن أن يفشل قانونًا.
  2. في معظم الاستثناءات السابقة ، لا يزال بإمكان المترجم أن يفهم أن المتغير ثابت.

C ++


إذا كتبت في C ++ ، فيمكن أن تؤثر const على توليد الكود من خلال التحميل الزائد للوظائف. يمكن أن يكون لديك const زائد const من نفس الوظيفة ، ويمكن تحسين const غير const (بواسطة مبرمج ، وليس مترجم) ، على سبيل المثال ، لنسخ أقل.

 void foo(int *p) { // Needs to do more copying of data } void foo(const int *p) { // Doesn't need defensive copies } int main() { const int x = 42; // const-ness affects which overload gets called foo(&x); return 0; } 

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

تجربة مع SQLite3


يكفي نظرية وأمثلة بعيدة المنال. ما هو تأثير const على codebase الحقيقي؟ قررت تجربة SQLite DB (الإصدار 3.30.0) ، لأنه:

  • ويستخدم const.
  • هذا هو قاعدة رمز غير بديهي (أكثر من 200 KLOC).
  • كقاعدة بيانات ، يتضمن عددًا من الآليات ، بدءًا بمعالجة قيم السلسلة وينتهي بتحويل الأرقام إلى تاريخ.
  • يمكن اختباره مع تحميل معالج محدود.

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

تدريب


لقد صنعت نسختين من الكود المصدري . أحدهما تم تجميعه في الوضع العادي ، والثاني معالج مسبقًا باستخدام الاختراق لتحويل const إلى أمر خامل:

 #define const 

يمكن لـ (GNU) إضافة هذا في أعلى كل ملف باستخدام الأمر sed -i '1i#define const' *.c *.h .

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

المقارنة المباشرة للرموز المترجمة لا معنى لها ، لأن التغيير البسيط يمكن أن يؤثر على نظام الذاكرة بالكامل ، مما سيؤدي إلى تغيير في المؤشرات ومكالمات الوظائف في الكود بأكمله. لذلك ، أخذت فريقًا تم تفكيكه ( objdump -d libSQLite3.so.0.8.6 ) objdump -d libSQLite3.so.0.8.6 لكل تعليمة. على سبيل المثال ، هذه الوظيفة:

 000000000005d570 <SQLite3_blob_read>: 5d570: 4c 8d 05 59 a2 ff ff lea -0x5da7(%rip),%r8 # 577d0 <SQLite3BtreePayloadChecked> 5d577: e9 04 fe ff ff jmpq 5d380 <blobReadWrite> 5d57c: 0f 1f 40 00 nopl 0x0(%rax) 

يتحول إلى:

 SQLite3_blob_read 7lea 5jmpq 4nopl 

عند التحويل البرمجي ، لم أغير إعدادات تجميع SQLite.

تحليل التعليمات البرمجية المترجمة


بالنسبة إلى libSQLite3.so ، احتلت النسخة ذات const 4،740،704 بايت ، أي حوالي 0.1٪ أكثر من الإصدار بدون const مع 4،736،712 بايت. في كلتا الحالتين ، تم تصدير 1374 وظيفة (لا تحسب وظائف المساعد على مستوى منخفض في PLT) ، و 13 لديها أي اختلافات في القوالب.

وكانت بعض التغييرات ذات الصلة إلى اختراق preprocessing. على سبيل المثال ، إليك إحدى الوظائف التي تم تغييرها (أزلت بعض التعريفات الخاصة بـ SQLite):

 #define LARGEST_INT64 (0xffffffff|(((int64_t)0x7fffffff)<<32)) #define SMALLEST_INT64 (((int64_t)-1) - LARGEST_INT64) static int64_t doubleToInt64(double r){ /* ** Many compilers we encounter do not define constants for the ** minimum and maximum 64-bit integers, or they define them ** inconsistently. And many do not understand the "LL" notation. ** So we define our own static constants here using nothing ** larger than a 32-bit integer constant. */ static const int64_t maxInt = LARGEST_INT64; static const int64_t minInt = SMALLEST_INT64; if( r<=(double)minInt ){ return minInt; }else if( r>=(double)maxInt ){ return maxInt; }else{ return (int64_t)r; } } 

إذا أزلنا const ، فإن هذه الثوابت تتحول إلى متغيرات static . لا أفهم لماذا يجب على أي شخص لا يهتم بـ const جعل هذه المتغيرات static . إذا أزلنا كلٍّ من static و const ، فإن مجلس التعاون الخليجي const مجددًا ثوابت ، وسنحصل على نفس النتيجة. بسبب هذه المتغيرات static const ، تبين أن التغييرات في ثلاث وظائف من أصل ثلاثة عشر كانت خاطئة ، لكنني لم أصلحها.

يستخدم SQLite العديد من المتغيرات العامة ، وترتبط غالبية تحسينات const الحقيقية بهذا: مثل استبدال مقارنة مع متغير بمقارنة مع ثابت ، أو إعادة حلقة إلى الوراء خطوة واحدة خطوة (لفهم نوع التحسينات التي تم إجراؤها ، لقد استخدمت Radare ). بعض التغييرات لا تستحق الذكر. يحتوي SQLite3ParseUri() على 487 تعليمات ، ولكن const إجراء تغيير واحد فقط: أخذ هذين المقارنات:

 test %al, %al je <SQLite3ParseUri+0x717> cmp $0x23, %al je <SQLite3ParseUri+0x717> 

وتبادلت:

 cmp $0x23, %al je <SQLite3ParseUri+0x717> test %al, %al je <SQLite3ParseUri+0x717> 

المعايير


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

CONST
بدون const
الحد الأدنى
10،658
10،803
متوسط
11،571
11،519
كحد أقصى.
11،832
11،658
متوسط
11،531
11،492

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

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

CONSTبدون const
N100100
الفئة الوسطى (متوسط ​​الرتبة)121.3879.62
مان ويتني2912
Z-5.10
قيمة 2-s جانب<10 -6
الفرق المتوسط ​​هو HL
-0.056 ثانية.
95 في المئة فاصل الثقة
-0.077 ... -0.038 ثانية.

وجد اختبار U فرقًا ذو دلالة إحصائية في الأداء. لكن - مفاجأة! - تحولت النسخة دون const إلى أسرع ، بحوالي 60 مللي ثانية ، أي بنسبة 0.5٪. يبدو أن العدد الصغير من "التحسينات" التي تم إجراؤها لم تكن تستحق الزيادة في مقدار الكود. من غير المحتمل أن تقوم const بتنشيط أي تحسينات رئيسية ، مثل ناقل السيارات. بالطبع ، قد يعتمد عدد الأميال المقطوعة على علامات مختلفة في المترجم ، أو على نسخته ، أو على قاعدة الشفرة ، أو على شيء آخر. ولكن يبدو لي صادقا أن أقول أنه حتى لو const تحسين أداء C ، لم ألاحظ هذا.

فما هو المطلوب const ل؟


لجميع عيوبها ، فإن const في C / C ++ مفيد لتوفير سلامة النوع. على وجه الخصوص ، إذا كنت تستخدم const بالاقتران مع دلالات النقل و std::unique_pointer ، فيمكنك تطبيق ملكية المؤشر الصريح. كانت حالة عدم اليقين المتعلقة بملكية المؤشر مشكلة كبيرة في أكواد برامج C ++ الأقدم التي تزيد عن 100 KLOC ، لذلك أنا ممتن لـ const لحلها.

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

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


All Articles