Topik ini membahas performa program C++ dari penyebab fisiknya, yaitu cara data tersusun di memori dan cara prosesor mengambilnya. Pembahasan mencakup padding dan tata letak struct, pola akses yang ramah cache, pola pengukuran benchmark yang benar, serta peran profiler dalam menemukan titik panas program. Bagian akhir membantah sejumlah mitos optimasi yang sering dipercaya tanpa pengukuran. Demonstrasi pada topik ini sengaja menampilkan angka yang dapat dihitung ulang, bukan waktu nyata, karena hasil pengukuran waktu berubah antar mesin.
Tata letak struct dan padding#
Prosesor tidak membaca memori satu byte sekaligus, melainkan per blok tetap sebesar 64 byte yang disebut cache line. Setiap tipe juga memiliki kebutuhan perataan (alignment), yaitu alamat yang harus habis dibagi ukuran tertentu, sehingga compiler menyisipkan byte kosong bernama padding di antara member struct. Akibatnya, urutan member memengaruhi ukuran struct meskipun isi membernya persis sama. Program berikut membandingkan dua struct dengan member yang sama namun urutan berbeda.
#include <cstddef>
#include <iostream>
struct CatatanSalahUrutan {
bool aktif;
double suhu;
int hitung;
};
struct CatatanTerurut {
double suhu;
int hitung;
bool aktif;
};
int main() {
std::cout << "sizeof CatatanSalahUrutan: " << sizeof(CatatanSalahUrutan) << '\n';
std::cout << "sizeof CatatanTerurut : " << sizeof(CatatanTerurut) << '\n';
std::cout << "alignof keduanya : " << alignof(CatatanSalahUrutan) << '\n';
std::cout << "offset salah urutan : aktif=" << offsetof(CatatanSalahUrutan, aktif)
<< ", suhu=" << offsetof(CatatanSalahUrutan, suhu)
<< ", hitung=" << offsetof(CatatanSalahUrutan, hitung) << '\n';
std::cout << "offset terurut : suhu=" << offsetof(CatatanTerurut, suhu)
<< ", hitung=" << offsetof(CatatanTerurut, hitung)
<< ", aktif=" << offsetof(CatatanTerurut, aktif) << '\n';
return 0;
}sizeof CatatanSalahUrutan: 24
sizeof CatatanTerurut : 16
alignof keduanya : 8
offset salah urutan : aktif=0, suhu=8, hitung=16
offset terurut : suhu=0, hitung=8, aktif=12Pada CatatanSalahUrutan, member bool satu byte menuntut member double berikutnya berada di alamat kelipatan delapan. Tujuh byte padding karenanya menyisip setelah aktif, dan empat byte lagi melengkapi struct agar ukurannya kelipatan perataan 8. Versi terurut menaruh double lebih dahulu sehingga padding tinggal tiga byte di ekor, dan ukurannya turun dari 24 menjadi 16 byte. Selisih itu berlipat pada array besar, karena 50 persen lebih banyak objek muat dalam cache line yang sama. Kebiasaan yang disarankan adalah mengurutkan member dari yang terbesar ke yang terkecil bila tata letak memang penting.
Pola akses dan cache line#
Urutan pengambilan satu byte menarik seluruh cache line, sehingga elemen bertetangga yang diakses berurutan dibayar satu pengisian blok untuk banyak elemen. Pola ini menjelaskan mengapa penelusuran matriks baris-demi-baris berbeda biaya cache dari kolom-demi-kolom, padahal keduanya mengunjungi jumlah elemen yang sama. Program berikut menghitung pengisian cache line secara langsung dengan membandingkan nomor garis 64 byte dari alamat setiap elemen yang dikunjungi.
#include <cstdint>
#include <iostream>
int main() {
const int BARIS = 1024;
const int KOLOM = 1024;
// alignas(64) menempatkan array tepat pada batas cache line
alignas(64) static int matriks[BARIS * KOLOM];
for (int k = 0; k < BARIS * KOLOM; ++k) {
matriks[k] = 1;
}
// baris-demi-baris: elemen bertetangga dikunjungi berurutan
int jumlah = 0;
std::uint64_t pengisianBaris = 0;
std::uintptr_t garisLama = 0;
bool pertama = true;
for (int i = 0; i < BARIS; ++i) {
for (int j = 0; j < KOLOM; ++j) {
int& elemen = matriks[i * KOLOM + j];
std::uintptr_t garis = reinterpret_cast<std::uintptr_t>(&elemen) / 64;
if (pertama || garis != garisLama) {
++pengisianBaris;
garisLama = garis;
pertama = false;
}
jumlah += elemen;
}
}
std::cout << "jumlah elemen: " << jumlah << '\n';
std::cout << "baris-demi-baris: " << pengisianBaris << " pengisian cache line\n";
// kolom-demi-kolom: lompatan satu baris penuh setiap langkah
jumlah = 0;
std::uint64_t pengisianKolom = 0;
garisLama = 0;
pertama = true;
for (int i = 0; i < KOLOM; ++i) {
for (int j = 0; j < BARIS; ++j) {
int& elemen = matriks[j * KOLOM + i];
std::uintptr_t garis = reinterpret_cast<std::uintptr_t>(&elemen) / 64;
if (pertama || garis != garisLama) {
++pengisianKolom;
garisLama = garis;
pertama = false;
}
jumlah += elemen;
}
}
std::cout << "kolom-demi-kolom: " << pengisianKolom << " pengisian cache line\n";
return 0;
}jumlah elemen: 1048576
baris-demi-baris: 65536 pengisian cache line
kolom-demi-kolom: 1048576 pengisian cache lineKedua penelusuran menambahkan elemen yang sama persis, tetapi jumlah pengisiannya berbeda enam belas kali lipat. Satu cache line memuat enam belas int berukuran empat byte, sehingga penelusuran berurutan membayar satu pengisian untuk enam belas elemen. Sebaliknya, lompatan satu baris penuh berukuran 4096 byte membuat setiap elemen menarik blok yang belum pernah dipakai. Atribut alignas(64) menjaga alamat awal array tepat pada batas blok agar hitungan bersih dari efek perataan. Pola semacam inilah yang diperbaiki lebih dahulu pada kode yang lambat, sebelum mencoba optimasi yang lebih halus.
Benchmark dan profiler#
Benchmark adalah pengukuran waktu yang dilakukan secara terkendali, sedangkan profiler adalah alat yang memeriksa program yang sedang berjalan untuk melaporkan bagian mana yang paling banyak menghabiskan waktu. Keduanya satu pasangan, karena benchmark menjawab seberapa cepat dan profiler menjawab di bagian mana. Pengukuran waktu dilakukan dengan std::chrono, dan polanya berbentuk berikut.
// tidak-dijalankan (hasil pengukuran waktu nyata tidak deterministik; polanya saja yang ditampilkan)
#include <chrono>
void kerjaYangDiukur();
int main() {
auto mulai = std::chrono::steady_clock::now();
kerjaYangDiukur();
auto selesai = std::chrono::steady_clock::now();
auto durasi = std::chrono::duration_cast<std::chrono::microseconds>(selesai - mulai);
return durasi.count() > 0 ? 0 : 0;
}Pola tersebut memakai steady_clock karena jam ini tidak melompat saat waktu sistem diubah. Praktik benchmark yang sehat memuat pemanasan singkat, beberapa pengulangan dengan pelaporan median, dan data masukan yang sama persis antarpengukuran. Keluaran pengukuran semacam ini sengaja tidak ditampilkan pada topik karena angkanya berbeda di setiap mesin, sedangkan polanya tetap sama.
Profiler pada ekosistem GCC bekerja lewat alat perf. Perintah perf stat membaca penghitung perangkat keras seperti cache miss, sedangkan perf record yang diikuti perf report menghasilkan daftar fungsi terpanas. Alternatif yang lebih tua adalah gprof melalui flag -pg, dan IDE lengkap menyediakan profiler grafis yang membaca sumber data yang sama. Titik panas (hot spot) adalah fungsi yang muncul paling atas pada laporan tersebut, dan itulah satu-satunya tempat yang layak diberi usaha optimasi.
Mitos optimasi yang umum#
- Kode yang lebih pendek pasti lebih cepat. Compiler menerjemahkan kode menjadi instruksi mesin, bukan mempertahankan bentuknya, sehingga satu baris yang padat dapat menghasilkan lebih banyak instruksi daripada beberapa baris yang jelas. Keputusan yang benar mengecek hasil kompilasi atau pengukuran, bukan panjang barisnya.
- Perluasan perulangan manual selalu membantu. Menyalin badan perulangan berkali-kali memang mengurangi pemeriksaan kondisi, tetapi optimizer pada
-O2dan-O3sudah melakukan perluasan itu ketika terbukti menguntungkan. Salinan manual justru mengaburkan kode dan mengikat pilihan optimizer. - Referensi selalu lebih cepat daripada menyalin. Menghindari salinan penting untuk objek besar, sedangkan menyalin satu
intataudoublesering lebih murah daripada mengejar lewat pointer karena nilai kecil ikut hidup di register dan cache. Kebiasaan mengganti setiap parameter dengan referensi justru dapat merusak lokalitas. - std::vector selalu lebih lambat daripada array mentah.
std::vectormenyimpan elemennya secara kontigu seperti array mentah, sehingga pola akses dan lokalitasnya identik. Pemeriksaan batas ekstra hanya muncul pada operasi tertentu sepertiat, sedangkan akses[]dan perulangan range-based dikompilasi menjadi kode yang sama dengan array. - Optimasi halus dikerjakan lebih dahulu. Usaha pada bagian yang bukan titik panas tidak memindahkan total waktu sama sekali, sekecil apa pun triknya. Urutan kerja yang benar adalah mengukur, membaca laporan profiler, memperbaiki titik panas, lalu mengukur ulang untuk memastikan perbaikannya nyata.
Ringkasan#
- Padding muncul karena kebutuhan perataan member, dan mengurutkan member dari yang terbesar mengecilkan struct serta menambah jumlah objek per cache line.
- Akses berurutan membayar satu pengisian cache line untuk enam belas
int, sedangkan lompatan satu baris membayarnya per elemen, sebagaimana dihitung langsung oleh demonstrasi. - Benchmark mengukur total waktu dengan
std::chronopada build beroptimasi, dan profiler sepertiperfmenunjuk fungsi terpanas yang layak diperbaiki. - Mitos optimasi dibantah dengan pengukuran, karena compiler, tipe data, dan cache membuat intuisi sederhana sering keliru.
Referensi#
- Optimize Options — GCC, Using the GNU Compiler Collection
— Perilaku flag
-O0hingga-O3dan penjelasan optimasi yang diterapkan. - sizeof operator — cppreference.com — Ukuran tipe, perataan, dan hubungannya dengan tata letak objek.
- Standard library header <chrono> — cppreference.com — Jam steady_clock dan utilitas durasi untuk pengukuran waktu.