Mengapa const tidak mempercepat kode C / C ++?


Beberapa bulan yang lalu, saya menyebutkan dalam satu posting bahwa ini adalah mitos, seolah-olah const membantu mengaktifkan optimisasi kompiler di C dan C ++ . Saya memutuskan bahwa pernyataan ini harus dijelaskan, terutama karena saya sendiri percaya pada mitos ini sebelumnya. Saya akan mulai dengan teori dan contoh buatan, kemudian beralih ke percobaan dan tolok ukur berdasarkan basis kode nyata - SQLite.

Tes sederhana


Mari kita mulai dengan, sepertinya bagi saya, contoh paling sederhana dan paling jelas untuk mempercepat kode C dengan const . Katakanlah kita memiliki dua deklarasi fungsi:

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

Dan anggaplah ada dua versi kode:

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

Untuk menjalankan printf() , prosesor harus mengambil *x dari memori melalui sebuah pointer. Tentunya, eksekusi constByArg() bisa sedikit lebih cepat, karena kompiler tahu bahwa *x adalah konstanta, jadi tidak perlu memuat nilainya lagi setelah constFunc() melakukannya. Benar? Mari kita lihat kode assembler yang dihasilkan oleh GCC dengan optimisasi diaktifkan:

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

Dan berikut ini adalah hasil assembler lengkap untuk 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 

Satu-satunya perbedaan antara kode assembler yang dihasilkan oleh byArg() dan constByArg() adalah constByArg() memiliki call constFunc@PLT , seperti pada kode sumber. const sendiri tidak ada bedanya.

Oke, itu GCC. Mungkin kita membutuhkan kompiler yang lebih pintar. Katakan Dentang.

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

Ini adalah kode perantara. Ini lebih kompak daripada assembler, dan saya akan menjatuhkan kedua fungsi, sehingga Anda mengerti apa yang saya maksud dengan "tidak ada perbedaan, kecuali untuk panggilan":

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

Opsi yang (tipe) berfungsi


Dan di sini adalah kode di mana kehadiran const sangat penting:

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

Kode assembler untuk localVar() , yang berisi dua instruksi yang dioptimalkan di luar 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 

Middleware LLVM sedikit lebih bersih. load sebelum panggilan kedua ke printf() dioptimalkan di luar 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 } 

Jadi, constLocalVar() berhasil mengabaikan reboot *x , tetapi Anda mungkin melihat sesuatu yang aneh: di badan localVar() dan constLocalVar() panggilan yang sama ke constFunc() . Jika kompiler dapat mengetahui bahwa constFunc() tidak mengubah *x di constLocalVar() , maka mengapa ia tidak bisa mengerti bahwa panggilan fungsi yang sama tidak mengubah *x di localVar() ?

Penjelasannya adalah mengapa const dalam C tidak praktis untuk digunakan sebagai optimisasi. Dalam C, const pada dasarnya memiliki dua kemungkinan makna:

  • itu mungkin berarti bahwa variabel adalah nama samaran read-only untuk beberapa data, yang mungkin atau mungkin tidak konstan.
  • atau itu bisa berarti bahwa variabel tersebut benar-benar konstan. Jika Anda melepaskan ikatan dari pointer ke nilai konstan, dan kemudian menulis untuk itu, Anda akan mendapatkan perilaku yang tidak terdefinisi. Di sisi lain, tidak akan ada masalah jika const adalah pointer ke nilai yang bukan konstanta.

Berikut ini adalah contoh penerapan 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; } 

localVar() memberikan constFunc() pointer const ke variabel non- const . Karena variabel awalnya bukan const , constFunc() dapat berubah menjadi pembohong dan secara paksa memodifikasi variabel tanpa memulai UB. Oleh karena itu, kompilator tidak dapat mengasumsikan bahwa setelah mengembalikan constFunc() variabel akan memiliki nilai yang sama. Variabel dalam constLocalVar() benar-benar adalah const , sehingga kompiler tidak dapat berasumsi bahwa itu tidak akan berubah, karena kali ini akan menjadi UB untuk constFunc() , sehingga kompiler akan melepaskan ikatan const dan menulis ke variabel.

Fungsi byArg() dan constByArg() dari contoh pertama tidak ada harapan, karena kompiler tidak dapat constByArg() apakah *x adalah const .

Tetapi dari mana datangnya ketidakkonsistenan? Jika kompilator dapat menganggap bahwa constFunc() tidak mengubah argumennya ketika dipanggil dari constLocalVar() , maka ia dapat menerapkan optimisasi yang sama ke panggilan constFunc() , kan? Tidak. Compiler tidak dapat berasumsi bahwa constLocalVar() akan dipanggil sama sekali. Dan jika tidak (misalnya, karena itu hanya beberapa hasil tambahan dari pembuat kode atau operasi makro), maka constFunc() dapat dengan tenang mengubah data tanpa memulai UB.

Anda mungkin perlu membaca contoh dan penjelasan di atas beberapa kali. Jangan khawatir kedengarannya tidak masuk akal - itu. Sayangnya, menulis ke variabel const adalah jenis UB terburuk: paling sering, kompiler bahkan tidak tahu apakah itu UB. Oleh karena itu, ketika kompilator melihat const , harus melanjutkan dari fakta bahwa seseorang dapat mengubahnya di suatu tempat, yang berarti bahwa kompiler tidak dapat menggunakan const untuk optimisasi. Dalam praktiknya, ini benar, karena banyak kode C yang sebenarnya berisi penolakan const dengan gaya "Saya tahu apa yang saya lakukan."

Singkatnya, ada banyak situasi di mana kompiler tidak diperbolehkan menggunakan const untuk optimisasi, termasuk mengambil data dari lingkup lain menggunakan pointer, atau menempatkan data di heap. Atau bahkan lebih buruk, biasanya dalam situasi di mana kompiler tidak dapat menggunakan const , ini tidak perlu. Sebagai contoh, setiap kompiler yang menghargai diri sendiri dapat memahami tanpa const bahwa dalam kode ini x adalah konstanta:

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

Jadi const hampir tidak berguna untuk optimasi, karena:

  1. Dengan beberapa pengecualian, kompiler terpaksa mengabaikannya, karena beberapa kode dapat secara hukum melepaskan ikatan.
  2. Dalam sebagian besar pengecualian di atas, kompiler masih dapat memahami bahwa variabel adalah konstanta.

C ++


Jika Anda menulis dalam C ++, maka const dapat memengaruhi pembuatan kode melalui fungsi yang berlebihan. Anda dapat memiliki kelebihan const dan non- const dari fungsi yang sama, dan non- const dapat dioptimalkan (oleh seorang programmer, bukan kompiler), misalnya, untuk menyalin lebih sedikit.

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

Di satu sisi, saya tidak berpikir bahwa dalam praktiknya ini sering diterapkan dalam kode C ++. Di sisi lain, agar benar-benar membuat perbedaan, seorang programmer harus membuat asumsi yang tidak tersedia untuk kompiler, karena mereka tidak dijamin oleh bahasa.

Eksperimen dengan SQLite3


Teori yang cukup dan contoh-contoh yang dibuat-buat. Apa efek const terhadap basis kode nyata? Saya memutuskan untuk bereksperimen dengan SQLite DB (versi 3.30.0), karena:

  • Ini menggunakan const.
  • Ini adalah basis kode nontrivial (lebih dari 200 KLOC).
  • Sebagai basis data, ia mencakup sejumlah mekanisme, mulai dengan memproses nilai string dan berakhir dengan konversi angka hingga saat ini.
  • Itu dapat diuji dengan beban prosesor yang terbatas.

Selain itu, penulis dan programmer yang terlibat dalam pengembangan telah menghabiskan waktu bertahun-tahun untuk meningkatkan produktivitas, sehingga kita dapat berasumsi bahwa mereka tidak melewatkan sesuatu yang jelas.

Persiapan


Saya membuat dua salinan kode sumber . Satu dikompilasi dalam mode normal, dan yang kedua pra-diproses menggunakan hack untuk mengubah const menjadi perintah idle:

 #define const 

(GNU) sed dapat menambahkan ini di atas setiap file dengan perintah sed -i '1i#define const' *.c *.h .

SQLite sedikit memperumit masalah, menggunakan skrip untuk menghasilkan kode selama pembuatan. Untungnya, kompiler memperkenalkan banyak noise ketika mencampur kode dengan const dan tanpa const , sehingga Anda dapat segera melihat dan mengkonfigurasi skrip untuk menambahkan kode anti- const saya.

Perbandingan langsung dari kode yang dikompilasi tidak masuk akal, karena perubahan kecil dapat memengaruhi skema memori keseluruhan, yang akan mengarah pada perubahan pointer dan pemanggilan fungsi di seluruh kode. Oleh karena itu, saya mengambil gips yang dibongkar ( objdump -d libSQLite3.so.0.8.6 ) sebagai ukuran biner dan nama mnemonik dari setiap instruksi. Misalnya, fungsi ini:

 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) 

Berubah menjadi:

 SQLite3_blob_read 7lea 5jmpq 4nopl 

Saat mengkompilasi, saya tidak mengubah pengaturan perakitan SQLite.

Analisis kode terkompilasi


Untuk libSQLite3.so, versi dengan const ditempati 4.740.704 byte, sekitar 0,1% lebih dari versi tanpa const dengan 4.736.712 byte. Dalam kedua kasus, 1374 fungsi diekspor (tidak termasuk fungsi pembantu tingkat rendah di PLT), dan 13 memiliki perbedaan dalam gips.

Beberapa perubahan terkait dengan peretasan preprocessing. Sebagai contoh, berikut adalah salah satu fungsi yang diubah (saya menghapus beberapa definisi khusus untuk 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; } } 

Jika kita menghapus const , maka konstanta ini berubah menjadi variabel static . Saya tidak mengerti mengapa siapa pun yang tidak peduli tentang const membuat variabel ini static . Jika kita menghapus static dan const , maka GCC akan kembali menganggapnya sebagai konstanta, dan kita akan mendapatkan hasil yang sama. Karena variabel static const seperti itu, perubahan dalam tiga fungsi dari tiga belas ternyata salah, tetapi saya tidak memperbaikinya.

SQLite menggunakan banyak variabel global, dan sebagian besar optimasi const terhubung dengan ini: seperti mengganti perbandingan dengan variabel dengan perbandingan dengan konstanta, atau memutar kembali sebagian loop dengan satu langkah (untuk memahami seperti apa optimasi yang dilakukan, saya menggunakan Radare ). Beberapa perubahan tidak layak disebut. SQLite3ParseUri() berisi 487 instruksi, tetapi const hanya membuat satu perubahan: ambil dua perbandingan ini:

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

Dan bertukar:

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

Tingkatan yang dicapai


SQLite hadir dengan uji regresi untuk mengukur kinerja, dan saya menjalankannya ratusan kali untuk setiap versi kode menggunakan pengaturan standar SQLite. Waktu pelaksanaan dalam detik:

const
Tanpa const
Minimum
10.658
10.803
Median
11.571
11.519
Maksimum
11.832
11.658
Rata-rata
11.531
11.492

Secara pribadi, saya tidak melihat banyak perbedaan. Saya menghapus const dari seluruh program, jadi jika ada perbedaan yang nyata, maka mudah untuk diperhatikan. Namun, jika kinerja sangat penting bagi Anda, maka akselerasi kecil pun dapat menyenangkan Anda. Mari kita lakukan analisis statistik.

Saya suka menggunakan tes Mann-Whitney U untuk tugas-tugas seperti itu. Ini mirip dengan uji t yang lebih terkenal, yang dirancang untuk menentukan perbedaan dalam kelompok, tetapi lebih tahan terhadap variasi acak kompleks yang terjadi ketika mengukur waktu di komputer (karena sakelar konteks yang tidak dapat diprediksi, kesalahan dalam halaman memori, dll.). Inilah hasilnya:

constTanpa const
N100100
Kategori tengah (Peringkat rata-rata)121.3879.62
Mann-whitney u2912
Z-5.10
Nilai p 2 sisi<10 -6
Perbedaan rata-rata adalah HL
-0,056 dtk.
Interval kepercayaan 95 persen
-0,077 ... -0,038 dtk.

Uji U menemukan perbedaan yang signifikan secara statistik dalam kinerja. Tapi - kejutan! - Versi tanpa const ternyata lebih cepat, sekitar 60 ms, yaitu 0,5%. Tampaknya jumlah kecil "optimasi" yang dibuat tidak sebanding dengan peningkatan jumlah kode. Tidak mungkin const mengaktifkan optimasi besar apa pun, seperti auto-vektorisasi. Tentu saja, jarak tempuh Anda mungkin bergantung pada berbagai flag di kompiler, atau pada versinya, atau pada basis kode, atau pada hal lain. Tapi sepertinya saya jujur ​​mengatakan bahwa bahkan jika const meningkatkan kinerja C, saya tidak memperhatikan ini.

Jadi untuk apa const?


Untuk semua kekurangannya, const di C / C ++ berguna untuk memberikan keamanan tipe. Khususnya, jika Anda menggunakan const dalam kombinasi dengan pindahkan semantik dan std::unique_pointer , Anda bisa mengimplementasikan kepemilikan pointer eksplisit. Ketidakpastian kepemilikan pointer adalah masalah besar dalam basis kode C ++ lama lebih dari 100 KLOC, jadi saya berterima kasih kepada const untuk menyelesaikannya.

Namun, sebelum saya menggunakan const untuk memberikan keamanan tipe. Saya mendengar bahwa dianggap benar untuk menggunakan const mungkin untuk meningkatkan kinerja. Saya mendengar bahwa jika kinerja benar-benar penting, maka Anda harus memperbaiki kode untuk menambahkan lebih banyak const , bahkan jika kode menjadi kurang dapat dibaca. Kedengarannya masuk akal pada saat itu, tetapi sejak itu saya menyadari bahwa ini tidak benar.

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


All Articles