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;
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()
:
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:
- Dengan beberapa pengecualian, kompiler terpaksa mengabaikannya, karena beberapa kode dapat secara hukum melepaskan ikatan.
- 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) {
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:
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:
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.