Chapter lalu ditutup dengan pertanyaan terbuka: apakah pengurangan alokasi yang kita hitung juga terasa pada durasi? Menjawabnya membutuhkan benchmark, yaitu pengukuran kinerja yang dirancang agar angkanya bisa dipercaya. Benchmark yang keliru justru lebih berbahaya daripada tidak mengukur sama sekali, karena angkanya tampak ilmiah padahal menyesatkan keputusan kita. Dua sumber kebohongan terbesarnya ada di dalam compiler sendiri (pekerjaan yang dihapus sebelum sempat diukur) dan di lingkungan eksekusi (kebisingan yang membuat satu kali pengukuran tidak berarti). Chapter ini membangun disiplinnya satu per satu: menahan dead-code elimination, memanaskan program, mengulang pengukuran, lalu merapikan semuanya menjadi harness kecil milik kita.

Pekerjaan yang Hilang Sebelum Diukur #

Compiler yang bekerja pada level -O2 memanggul semua izin yang kita bahas pada chapter Compiler Optimization, termasuk yang paling menjebak untuk benchmark: dead-code elimination (DCE). Bila hasil sebuah perhitungan tidak pernah dibaca, compiler boleh menghapus seluruh perhitungannya — dari sudut pandangnya, program menghasilkan output yang sama persis tanpa pekerjaan itu. Kita buktikan pada program yang mengukur loop penjumlahan yang sama dalam dua kondisi:

cpp Demo dead-code elimination — angka durasi bervariasi antar eksekusi dan antar mesin
#include <chrono>
#include <iostream>
#include <vector>

template <class T>
inline void doNotOptimize(const T& nilai) {
    asm volatile("" : : "g"(nilai) : "memory");
}

long long hitungJumlah(const std::vector<int>& data) {
    long long total = 0;
    for (int x : data) total += x;
    return total;
}

int main() {
    std::vector<int> data(1000000);
    for (std::size_t i = 0; i < data.size(); ++i) data[i] = static_cast<int>(i % 1000);

    // A: hasil dibuang — compiler bebas menghapus seluruh loop
    auto a = std::chrono::steady_clock::now();
    {
        long long buangan = hitungJumlah(data);
        (void)buangan;
    }
    auto b = std::chrono::steady_clock::now();

    // B: hasil dijaga — loop harus benar-benar berjalan
    auto c = std::chrono::steady_clock::now();
    long long hasil = hitungJumlah(data);
    doNotOptimize(hasil);
    auto d = std::chrono::steady_clock::now();

    std::cout << "hasil dibuang      : "
              << std::chrono::duration_cast<std::chrono::microseconds>(b - a).count()
              << " us\n";
    std::cout << "hasil dijaga       : "
              << std::chrono::duration_cast<std::chrono::microseconds>(d - c).count()
              << " us\n";
    return 0;
}

Hasil Eksekusi (build -O2 di platform uji; angka durasi bervariasi antar eksekusi):

text
hasil dibuang      : 0 us
hasil dijaga       : 274 us

Loop yang diukur identik, datanya identik, tetapi versi yang hasilnya dibuang tercatat 0 mikrosekon: loop itu sudah tidak ada di binary — yang kita ukur bukan penjumlahan, melainkan ketiadaannya. Perhatikan juga bahwa trik lama (void)buangan; tidak menolong: cast ke void hanya menenangkan warning, bukan memaksa perhitungan tetap ada. Satu-satunya cara yang andal adalah membuat hasil benar-benar terbaca oleh sesuatu yang tidak bisa dioptimasi compiler.

Menjaga Hasil dengan Idiom doNotOptimize #

Idiom yang dipakai benchmark serius adalah menyentuh nilai lewat assembly inline yang kosong: compiler tidak berani menganggap nilai itu tidak terpakai, sementara instruksi yang dihasilkan tidak melakukan apa pun. Program berikut membandingkan pola rawan dan pola aman secara berdampingan:

cpp
// Menjaga hitungan benchmark agar tidak dihapus compiler (dead-code
// elimination). Idiom doNotOptimize memberi tahu compiler bahwa hasil
// benar-benar dipakai. Idiom GCC/Clang; Google Benchmark punya versinya.
#include <iostream>
#include <vector>

// "Memakai" nilai tanpa melakukan apa pun yang terlihat: instruksi asm
// kosong yang membaca nilai, plus memory clobber.
template <class T>
inline void doNotOptimize(const T& nilai) {
    asm volatile("" : : "g"(nilai) : "memory");
}

// Batas optimisasi: compiler menganggap memori bisa berubah di titik ini.
inline void clobberMemory() {
    asm volatile("" : : : "memory");
}

long long hitungJumlah(const std::vector<int>& data) {
    long long total = 0;
    for (int x : data) total += x;
    return total;
}

int main() {
    std::vector<int> data(1000000);
    for (std::size_t i = 0; i < data.size(); ++i) data[i] = static_cast<int>(i % 1000);

    // Pola rawan: hasil tidak pernah dipakai. Pada build teroptimasi,
    // compiler boleh menghapus seluruh loop yang ingin kita ukur.
    long long terbuang = hitungJumlah(data);
    (void)terbuang;

    // Pola aman: hasil "dipakai" oleh doNotOptimize pada setiap run.
    long long jumlah = 0;
    for (int run = 0; run < 5; ++run) {
        jumlah = hitungJumlah(data);
        doNotOptimize(jumlah);
        clobberMemory();
    }
    std::cout << "checksum : " << jumlah << '\n';
    std::cout << "elemen   : " << data.size() << " x 5 run\n";
    return 0;
}

Output yang Dihasilkan:

text
checksum : 499500000
elemen   : 1000000 x 5 run

Bedah mekanismenya:

  1. doNotOptimize: asm volatile tanpa satu instruksi pun, tetapi mendaftarkan nilai sebagai input dan memory sebagai clobber. Compiler harus menganggap assembly itu membaca nilai dan mengubah memori yang tak diketahui, sehingga perhitungan menuju nilai itu tidak bisa dihapus.
  2. clobberMemory: bentuk paling ringan dari idiom yang sama — tanpa input, hanya memberi tahu compiler bahwa memori bisa berubah pada titik itu. Nilai yang tersimpan di register harus ditulis ulang ke memori, mirip efek pemanggilan fungsi eksternal.
  3. Pola rawan: terbuang tidak pernah dipakai, jadi pada build teroptimasi pemanggilan hitungJumlah pertama boleh hilang — inilah jebakan yang kita lihat pada demo sebelumnya.
  4. Pola aman: hasil setiap run disalurkan ke doNotOptimize lalu ke clobberMemory, sehingga lima kali penjumlahan satu juta elemen benar-benar dieksekusi.
  5. Checksum 499500000: jumlah 0+1+…+999 dikali seribu — nilai deterministik yang sekaligus membuktikan perhitungan benar.

Disiplin Pengukuran: Pemanasan dan Pengulangan #

Satu kali pengukuran hampir selalu keliru, dan bukan karena stopwatch-nya buruk. Eksekusi pertama membayar biaya yang tidak akan dibayar lagi: data belum ada di cache, branch predictor belum mengenali pola cabang, dan sistem operasi baru saja memetakan halaman baru. Karena itu harness yang layak selalu melakukan warm-up: beberapa eksekusi pembuka yang hasilnya dibuang agar kondisi eksekusi berikutnya stabil. Setelah pemanasan, ukur beberapa kali (lima sampai sepuluh run umumnya cukup) dan laporkan nilai terbaik atau median — nilai terbaik mendekati kemampuan sebenarnya karena merupakan run dengan gangguan paling sedikit.

Kebiasaan pembandingan yang sehat juga sama pentingnya dengan angkanya. Bandingkan dua versi di mesin yang sama, flag yang sama, beban sistem yang serupa, dan dalam rentang waktu berdekatan; angka absolut dari mesin berbeda tidak boleh diadu. Gunakan std::chrono::steady_clock yang monoton — bukan system_clock yang bisa melompat karena penyesuaian jam — dan waspadai resolusinya: untuk mengukur operasi yang terlalu cepat, jalankan operasi itu banyak kali dalam satu pengukuran lalu bagi.

Harness Mini Berbasis chrono #

Aturan-aturan di atas muat dalam satu template pendek: pemanasan, beberapa run, ambil terbaik, dan jaga hasil dengan checksum agar tidak dihapus. Program berikut membandingkan penjumlahan semua elemen dengan penjumlahan setiap elemen ke-16 — perbandingan yang akan terasa familier dari chapter Cache Locality:

cpp
// Mini benchmark harness: pemanasan, beberapa run, lalu laporan terbaik.
// Struktur laporan (checksum) deterministik; angka mikrosekon bervariasi
// antar eksekusi dan antar mesin — karena itu angka durasi tidak pernah
// kita jadikan output terverifikasi.
#include <algorithm>
#include <chrono>
#include <iostream>
#include <vector>

template <class F>
void lapor(const char* nama, F fungsi, int run = 5) {
    for (int i = 0; i < 3; ++i) fungsi();          // pemanasan (warm-up)
    double terbaik = 1e100;
    long long checksum = 0;
    for (int i = 0; i < run; ++i) {
        auto mulai = std::chrono::steady_clock::now();
        long long hasil = fungsi();                 // hasil dipakai: penjaga anti-DCE
        auto selesai = std::chrono::steady_clock::now();
        checksum ^= hasil;
        double us = std::chrono::duration<double, std::micro>(selesai - mulai).count();
        terbaik = std::min(terbaik, us);
    }
    std::cout << nama << " terbaik " << terbaik << " us, checksum " << checksum << '\n';
}

long long jumlahSemua(const std::vector<int>& d) {
    long long t = 0;
    for (int x : d) t += x;
    return t;
}

long long jumlahTiapKe16(const std::vector<int>& d) {
    long long t = 0;
    for (std::size_t i = 0; i < d.size(); i += 16) t += d[i];
    return t;
}

int main() {
    std::vector<int> data(1000000);
    for (std::size_t i = 0; i < data.size(); ++i) data[i] = static_cast<int>(i % 1000);

    lapor("jumlah semua elemen  ", [&] { return jumlahSemua(data); });
    lapor("jumlah tiap 16 elemen", [&] { return jumlahTiapKe16(data); });
    return 0;
}

Output program memuat angka durasi yang berbeda antar eksekusi dan antar mesin — karena itu durasinya tidak kita kunci sebagai output terverifikasi; atribut nondeterministic pada blok kode di atas menandai hal itu secara eksplisit. Bagian checksum-nya sendiri deterministik dan berfungsi sebagai bukti bahwa kedua fungsi mengolah data yang sama. Berikut salah satu eksekusi nyata pada build -O2 di platform uji:

bash
g++ -std=c++23 -Wall -Wextra -O2 bench-harness.cpp -o bench-harness
./bench-harness

Hasil Eksekusi (contoh satu eksekusi; angka durasi bervariasi):

text
jumlah semua elemen   terbaik 241.291 us, checksum 499500000
jumlah tiap 16 elemen terbaik 166.886 us, checksum 31000000

Bedah harness-nya:

  1. lapor(nama, fungsi, run): template yang menerima lambda apa pun yang mengembalikan angka — satu alat untuk semua eksperimen pengukuran kita ke depan.
  2. Pemanasan tiga kali: hasilnya dibuang; tugasnya memanaskan cache dan predictor seperti yang dibahas di atas.
  3. Best-of-five: setiap run diukur dengan steady_clock, dan hanya durasi terkecil yang dilaporkan.
  4. checksum ^= hasil: hasil dipakai sehingga tidak bisa dihapus compiler (anti-DCE), sekaligus penjaga kebenaran — checksum yang berubah antar versi berarti optimasi kita merusak perhitungan.
  5. Angka hasil eksekusi: penjumlahan penuh 240-an us versus setiap elemen ke-16 sekitar 166 us — versi kedua menyentuh enam persen data dan hanya separuh cache line-nya.

Google Benchmark: Ketika Harness Mini Tidak Cukup #

Harness mini mengajarkan prinsipnya, tetapi proyek nyata biasanya memakai Google Benchmark: pustaka yang menyediakan registrasi benchmark, statistik lengkap (rata-rata, deviasi, outlier), parameterisasi rentang input, dan anti-DCE bawaan. Pustaka ini tidak terpasang di sandbox tutorial ini, jadi berikut kita tampilkan polanya sebagai ilustrasi — jalankan sendiri setelah memasangnya:

cpp Pola Google Benchmark (ilustrasi; pustaka tidak terpasang di sandbox)
#include <benchmark/benchmark.h>
#include <vector>

static void BM_JumlahSemua(benchmark::State& state) {
    std::vector<int> data(1000000, 1);
    for (auto _ : state) {              // state mengatur jumlah pengulangan
        long long total = 0;
        for (int x : data) total += x;
        benchmark::DoNotOptimize(total);   // penjaga anti-DCE bawaan
    }
}
BENCHMARK(BM_JumlahSemua);
BENCHMARK_MAIN();

Pola for (auto _ : state) menggantikan loop run manual kita: pustakalah yang menentukan berapa kali eksekusi agar statistiknya layak, dan benchmark::DoNotOptimize adalah versi pustaka dari idiom yang kita bangun sendiri di atas. Setelah memahami kedua lapisannya — idiom anti-DCE dan disiplin pengulangan — Anda bisa membaca hasil pustaka mana pun secara kritis alih-alih memercayainya buta.

Dengan alat ukur yang jujur, kita kini bisa menyelidiki sumber biaya yang tidak tampak pada model biaya sederhana: instruksi cabang dan cara prosesor menebak arahnya. Fenomena itu — dan cara menaklulkannya — menjadi isi chapter berikutnya.

Rujukan resmi: std::chrono::steady_clock dan GCC Inline Asm .