Saat melakukan optimasi performa pada library level rendah seperti foreign function interface, allocator, runtime helper, atau komponen pemanggilan fungsi dinamis, risiko utamanya bukan hanya hasil benchmark yang memburuk. Risiko yang lebih mahal adalah regresi ABI, bug yang hanya muncul di arsitektur tertentu, dan perubahan yang tampak cepat di mesin pengembang tetapi tidak stabil di CI atau produksi.

Karena itu, strategi uji untuk optimasi performa library tanpa regresi ABI harus memisahkan verifikasi fungsional dari verifikasi performa. Benchmark penting, tetapi tidak boleh menjadi satu-satunya sinyal kebenaran. Untuk library sistem seperti libffi, pendekatan yang lebih aman adalah membangun test pyramid yang terdiri dari unit test, compatibility test, integration test, dan microbenchmark verification dengan guardrail yang realistis.

Mengapa optimasi library level rendah berisiko tinggi

Library level rendah sering beroperasi di area yang sensitif terhadap ABI: layout argumen, alignment, register calling convention, stack frame, marshalling type, dan interaksi dengan compiler serta linker. Pada kasus seperti optimasi cache rencana pemanggilan di libffi, perubahan yang terlihat lokal dapat memengaruhi jalur eksekusi yang bergantung pada:

  • ukuran dan alignment tipe primitif maupun agregat,
  • calling convention per arsitektur,
  • endianness,
  • perbedaan ABI antar sistem operasi,
  • optimasi compiler seperti inlining, LTO, atau vectorization,
  • state internal seperti cache invalidation atau object lifetime.

Masalahnya, banyak bug ABI tidak muncul sebagai crash yang konsisten. Gejalanya bisa berupa nilai balik salah hanya untuk struktur tertentu, korupsi stack yang muncul jauh setelah pemanggilan, atau hasil berbeda antara x86_64 dan AArch64. Karena itu, benchmark yang menunjukkan peningkatan latensi tidak otomatis membuktikan patch aman.

Test pyramid untuk library sistem

Untuk library aplikasi biasa, test pyramid sering dibahas sebagai unit test, integration test, dan end-to-end test. Untuk library sistem, bentuknya sedikit berbeda. Lapisan yang lebih relevan adalah:

  1. Unit test untuk invariant internal dan logika murni.
  2. Compatibility test untuk kontrak ABI dan variasi signature.
  3. Integration test untuk perilaku nyata melalui compiler, linker, dan bahasa pemakai.
  4. Microbenchmark verification untuk memeriksa apakah optimasi benar-benar memberi manfaat dan tidak regresif secara kasar.

Urutannya penting. Jika compatibility test belum kuat, benchmark tidak banyak berarti karena Anda bisa saja mengukur jalur yang cepat tetapi salah.

1) Unit test: validasi invariant internal

Unit test pada library sistem harus fokus pada logika yang bisa diuji secara deterministik tanpa ketergantungan lingkungan penuh. Contohnya:

  • pemilihan jalur cache hit vs cache miss,
  • hashing atau keying untuk cache plan/call interface,
  • normalisasi metadata tipe,
  • validasi lifecycle object: init, clone, free, reuse,
  • error path ketika tipe atau konfigurasi tidak valid.

Jika Anda sedang mengoptimalkan mekanisme cache untuk rencana pemanggilan, unit test sebaiknya membuktikan bahwa dua signature identik menghasilkan key yang sama, sementara signature yang berbeda pada alignment atau ABI menghasilkan key berbeda.

// Pseudocode C untuk invariant cache key
void test_cache_key_changes_when_abi_changes(void) {
    call_sig a = make_sig(ABI_SYSV, RET_I64, args_i32_i32());
    call_sig b = make_sig(ABI_WIN64, RET_I64, args_i32_i32());

    assert(cache_key(a) != cache_key(b));
}

void test_cache_key_stable_for_same_signature(void) {
    call_sig a = make_sig(ABI_SYSV, RET_I64, args_i32_i32());
    call_sig b = make_sig(ABI_SYSV, RET_I64, args_i32_i32());

    assert(cache_key(a) == cache_key(b));
}

Mengapa ini penting: banyak bug performa berakar dari asumsi salah bahwa dua konfigurasi pemanggilan setara. Unit test menangkap kesalahan ini jauh sebelum crash muncul pada arsitektur lain.

2) Compatibility test: inti pencegahan regresi ABI

Inilah lapisan terpenting untuk topik ini. Compatibility test memverifikasi bahwa library masih memanggil fungsi target dengan ABI yang benar untuk berbagai kombinasi tipe, ukuran, alignment, dan cara pengembalian nilai.

Untuk library seperti libffi, kumpulan compatibility test idealnya mencakup:

  • argumen integer kecil dan besar, signed dan unsigned,
  • floating-point dan variasi presisi,
  • pointer dan null pointer,
  • struct kecil, struct besar, nested struct, dan struct dengan padding,
  • kombinasi argumen campuran integer/floating-point,
  • return value primitif dan agregat,
  • variadic function bila didukung,
  • calling convention yang berbeda jika platform mendukungnya.

Strategi yang efektif adalah membuat fungsi referensi kecil di C yang perilakunya mudah diverifikasi, lalu memanggilnya melalui jalur library yang sedang dioptimalkan. Nilai hasilnya dibandingkan dengan pemanggilan langsung.

// Contoh fungsi referensi untuk uji ABI
struct pair {
    int a;
    double b;
};

struct pair echo_pair(struct pair x) {
    return x;
}

double mix_args(int a, float b, double c, struct pair p) {
    return a + b + c + p.a + p.b;
}

Lalu pengujiannya memverifikasi:

  • hasil numerik tepat, bukan hanya mendekati, jika tipe memungkinkan,
  • layout struct tidak bergeser,
  • nilai tidak rusak setelah beberapa iterasi,
  • jalur cache dan non-cache menghasilkan output identik.

Prinsip penting: compatibility test harus lebih luas dari benchmark. Benchmark sering hanya memakai satu atau dua signature populer, padahal regresi ABI biasanya tersembunyi pada kombinasi yang jarang.

3) Integration test: uji lewat toolchain dan konsumen nyata

Setelah invariant internal dan kontrak ABI lolos, Anda masih perlu memastikan integrasi nyata bekerja. Integration test pada library sistem biasanya melibatkan:

  • membangun library dengan beberapa compiler,
  • menautkan ke program C kecil sebagai konsumen,
  • bila relevan, menguji melalui binding bahasa lain seperti Python, Ruby, atau runtime internal,
  • menjalankan skenario multi-file untuk memastikan simbol, visibility, dan linkage tetap benar.

Tujuannya bukan menambah cakupan sebanyak mungkin, melainkan mendeteksi masalah yang tidak terlihat di unit test: misalnya perilaku berbeda karena packing, macro compile-time, atau perbedaan flags compiler.

Jika library sering dipakai oleh bahasa tingkat tinggi, satu integration test sederhana yang membuat binding memanggil fungsi dengan beberapa signature penting bisa sangat bernilai. Ini membantu mendeteksi perubahan yang lolos di level C tetapi rusak pada marshalling layer nyata.

4) Microbenchmark verification: verifikasi, bukan sumber kebenaran tunggal

Microbenchmark tetap dibutuhkan untuk memastikan optimasi memang memberi efek pada jalur panas. Namun untuk library sistem, benchmark harus ditempatkan sebagai alat verifikasi performa, bukan pengganti pengujian kebenaran.

Benchmark yang baik untuk kasus ini biasanya memisahkan:

  • jalur inisialisasi atau persiapan,
  • jalur cache miss,
  • jalur cache hit,
  • biaya pemanggilan langsung sebagai pembanding kasar,
  • signature ringan vs signature kompleks.

Ini penting karena optimasi cache sering memperbaiki skenario tertentu sambil menambah overhead pada skenario lain. Tanpa pemisahan benchmark, angka agregat bisa menyesatkan.

Pisahkan benchmark dari functional test

Salah satu kesalahan umum adalah menjalankan benchmark di test suite utama lalu menggagalkan pipeline jika hasilnya dianggap terlalu lambat. Untuk library level rendah, ini sering menghasilkan flaky CI. Alasan utamanya:

  • runner CI berbagi CPU dengan job lain,
  • frekuensi CPU dapat berubah,
  • noise dari scheduler dan kontensi cache,
  • perbedaan mesin fisik atau virtualisasi,
  • ASLR, thermal throttling, dan variasi background process.

Karena itu, praktik yang lebih aman adalah:

  • functional test selalu berjalan dan menjadi gate utama merge,
  • benchmark dijalankan di lane terpisah, baik periodik, on-demand, atau pada runner khusus,
  • hasil benchmark direkam terhadap baseline dan dianalisis sebagai sinyal tambahan.

Jika benchmark dijadikan gate utama pada infrastruktur yang tidak stabil, Anda berisiko menolak patch yang benar atau menerima patch yang salah hanya karena noise pengukuran.

Baseline performa dan guardrail CI yang realistis

Menetapkan baseline yang bermakna

Baseline performa sebaiknya tidak berupa satu angka absolut dari laptop pengembang. Gunakan baseline yang:

  • diambil pada lingkungan yang relatif konsisten,
  • mewakili skenario penting, bukan hanya satu benchmark sintetis,
  • dibandingkan terhadap revisi acuan yang jelas,
  • disimpan bersama metadata seperti compiler, flags, arsitektur, dan mode build.

Untuk optimasi library sistem, yang sering lebih berguna adalah perubahan relatif per skenario, bukan satu skor gabungan. Misalnya, cache-hit path boleh meningkat, tetapi cache-miss path tidak boleh memburuk secara ekstrem.

Guardrail CI yang aman

Guardrail CI untuk topik ini idealnya dibagi menjadi tiga level:

  1. Hard gate: build harus sukses, unit test dan compatibility test harus lulus, sanitizers tidak boleh menemukan bug.
  2. Soft gate: benchmark memberi peringatan jika melewati ambang tertentu, tetapi tidak langsung menggagalkan merge.
  3. Manual review trigger: jika hasil performa berubah cukup besar pada jalur penting, patch perlu tinjauan tambahan atau rerun pada runner khusus.

Pendekatan ini lebih tahan terhadap noise sekaligus tetap menjaga disiplin performa.

# Contoh struktur job CI tingkat tinggi
jobs:
  build-and-test:
    steps:
      - build debug
      - run unit tests
      - run compatibility tests
      - run integration tests
      - run sanitizers

  perf-smoke:
    steps:
      - build release
      - run selected microbenchmarks
      - compare with stored baseline
      - mark warning on suspicious regression

  perf-lab:
    trigger: manual_or_scheduled
    steps:
      - run full benchmark suite on dedicated runner
      - publish trend report

Contoh di atas sengaja generik. Format detailnya bergantung pada CI yang digunakan, tetapi prinsip pemisahan lane sebaiknya dipertahankan.

Validasi deterministik untuk mencegah flaky benchmark

Jika benchmark tetap digunakan sebagai bagian dari verifikasi rutin, buat ia sedeterministik mungkin. Beberapa teknik yang praktis:

Kontrol input dan skenario

  • Gunakan input tetap, bukan data acak tanpa seed.
  • Pastikan jumlah iterasi cukup untuk mengurangi noise.
  • Pisahkan fase warm-up dari fase ukur.
  • Jangan campur biaya setup besar dengan biaya operasi inti jika yang diuji adalah jalur panas.

Kontrol lingkungan eksekusi

  • Gunakan mode build release yang konsisten.
  • Kurangi aktivitas paralel di runner benchmark.
  • Jika memungkinkan, jalankan pada mesin khusus atau set CPU affinity.
  • Catat compiler, flags, arsitektur, dan jenis runner.

Gunakan statistik sederhana yang masuk akal

Hindari menarik kesimpulan dari satu eksekusi. Lebih aman memakai beberapa pengulangan lalu membandingkan median atau distribusi ringkas. Minimum kadang berguna untuk melihat potensi terbaik, tetapi median umumnya lebih tahan noise.

Verifikasi kebenaran di dalam benchmark

Benchmark yang hanya mengukur waktu tanpa memeriksa output bisa memberi hasil palsu. Compiler dapat menghilangkan kerja yang dianggap tidak terpakai, atau bug dapat menghasilkan jalur yang lebih cepat karena melakukan lebih sedikit hal.

// Pseudocode: jangan ukur tanpa sink/output check
for (int i = 0; i < N; i++) {
    result = ffi_call_cached(plan, fn, args);
    checksum ^= normalize_result(result);
}
assert(checksum == expected_checksum);

Pola checksum sederhana membantu memastikan loop benchmark benar-benar mengeksekusi jalur yang ingin diukur.

Kapan benchmark tidak boleh menjadi gate utama

Ada beberapa situasi di mana hasil benchmark sebaiknya tidak menjadi penentu utama merge:

  • Runner tidak stabil: variasi antarrun tinggi dan sulit dipisahkan dari perubahan kode.
  • Patch menyentuh correctness sensitif: prioritas utama harus memastikan ABI dan keselamatan memori, bukan mengejar persen kecil performa.
  • Perubahan lintas arsitektur: hasil cepat di satu arsitektur belum mewakili platform lain.
  • Benchmark belum representatif: hanya mengukur satu signature atau satu jalur yang terlalu sempit.
  • Perubahan compiler/toolchain belum terkontrol: baseline lama tidak lagi sebanding.

Dalam kondisi ini, benchmark tetap berguna sebagai alarm, tetapi keputusan merge harus bertumpu pada correctness, kompatibilitas, dan analisis manual.

Tambahkan pengujian lintas arsitektur dan alat deteksi bug memori

Regresi ABI dan bug performa pada library sistem sering tersembunyi hingga berjalan di target berbeda. Karena itu, selain test pyramid di atas, tambahkan dua guardrail penting:

Matriks arsitektur minimal

Jika memungkinkan, uji setidaknya pada kombinasi yang mewakili kelas ABI berbeda, misalnya:

  • x86_64,
  • AArch64,
  • variasi sistem operasi yang benar-benar didukung proyek,
  • endianness berbeda bila relevan untuk proyek Anda.

Tidak semua proyek mampu menjalankan semuanya per pull request. Jika biaya tinggi, jadikan sebagian sebagai lane terjadwal atau pre-release gate. Yang penting, jangan menganggap satu arsitektur mewakili semua perilaku ABI.

Sanitizer dan alat diagnostik

Untuk library level rendah, sanitizer sering menangkap bug yang benchmark lewatkan, seperti:

  • address sanitizer untuk out-of-bounds dan use-after-free,
  • undefined behavior sanitizer untuk UB yang dapat berubah efeknya antar compiler,
  • alat pendeteksi kebocoran memori atau double free sesuai ekosistem build Anda.

Jika patch optimasi mengubah layout internal, cache ownership, atau lifecycle object, jalankan sanitizer pada test fungsional sebelum melihat angka benchmark.

Pola implementasi yang disarankan untuk kasus mirip libffi

Dalam konteks optimasi seperti cache plan/persiapan pemanggilan, strategi yang praktis adalah:

  1. Buat test fixture fungsi referensi C dengan variasi signature yang mewakili kasus penting.
  2. Uji jalur tanpa cache sebagai referensi correctness.
  3. Uji jalur dengan cache, lalu bandingkan hasilnya byte-for-byte atau value-for-value terhadap referensi.
  4. Jalankan compatibility test pada beberapa mode build dan, bila mungkin, beberapa arsitektur.
  5. Tambahkan microbenchmark terpisah untuk cache hit, cache miss, dan setup.
  6. Simpan baseline performa pada runner yang relatif stabil.
  7. Gunakan soft alert untuk regresi performa dan hard gate untuk correctness.

Dengan pola ini, Anda dapat bergerak cepat pada optimasi tanpa mengorbankan kontrak ABI.

Kesalahan umum yang sering terjadi

  • Mengukur benchmark debug build: hasilnya sering tidak relevan untuk keputusan optimasi.
  • Menggabungkan setup dan operasi inti: sulit mengetahui bagian mana yang benar-benar membaik.
  • Hanya menguji tipe primitif: bug ABI sering muncul pada struct, padding, atau return aggregate.
  • Mengandalkan satu compiler: perilaku UB dapat berbeda drastis.
  • Menjadikan benchmark CI sebagai hard gate tanpa runner khusus: menyebabkan flaky pipeline.
  • Tidak membandingkan jalur cache dan non-cache: Anda bisa mengoptimalkan jalur cepat yang ternyata salah secara semantik.

Checklist workflow verifikasi sebelum merge

Berikut checklist yang bisa dipakai untuk patch optimasi pada library level rendah:

  1. Definisikan hipotesis optimasi
    Tentukan jalur panas yang ingin dipercepat dan skenario yang berpotensi terdampak.
  2. Identifikasi risiko ABI
    Catat tipe, calling convention, alignment, atau lifecycle yang mungkin berubah.
  3. Tambahkan/ubah unit test
    Pastikan invariant internal baru tercakup, terutama untuk key cache dan invalidation.
  4. Perluas compatibility test
    Tambahkan signature yang relevan, termasuk struct, floating-point, dan kombinasi campuran.
  5. Bandingkan jalur lama vs jalur baru
    Verifikasi hasil identik pada fixture yang sama.
  6. Jalankan integration test
    Bangun dan uji lewat konsumen nyata atau program C kecil yang ditautkan ke library.
  7. Jalankan sanitizer
    Pastikan tidak ada masalah memori atau undefined behavior yang baru.
  8. Jalankan microbenchmark terpisah
    Ukur cache hit, cache miss, dan setup secara terpisah.
  9. Bandingkan dengan baseline
    Lihat perubahan relatif pada lingkungan yang konsisten, bukan satu angka tunggal.
  10. Tinjau hasil lintas arsitektur
    Minimal pastikan tidak ada platform yang diketahui rusak atau mencurigakan.
  11. Dokumentasikan trade-off
    Jika jalur tertentu sedikit melambat demi kasus dominan yang lebih baik, tulis alasan teknisnya.
  12. Tentukan level gate
    Correctness wajib hard gate; performa menjadi soft gate kecuali di runner benchmark khusus yang stabil.

Penutup

Strategi uji untuk optimasi performa library tanpa regresi ABI bertumpu pada satu prinsip: correctness lebih dahulu, benchmark setelahnya. Pada library level rendah seperti libffi, optimasi yang aman bukan yang sekadar terlihat lebih cepat, tetapi yang tetap benar di berbagai signature, compiler, dan arsitektur.

Jika Anda memisahkan benchmark dari functional test, membangun compatibility test yang kuat, memakai guardrail CI yang realistis, dan hanya menjadikan benchmark sebagai sinyal yang tepat konteksnya, Anda bisa mendorong peningkatan performa tanpa menukar stabilitas ABI dengan angka yang rapuh.