Klaim tentang performa hanya bermakna apabila dilandasi angka pengukuran yang dapat diulang. Topik ini membangun kerangka pengukuran sederhana menggunakan timer dari paket core:time, menerapkan pola hangatkan, ulangi, dan ambil median pada dua versi penjumlahan array, lalu membaca angka yang dihasilkannya. Bagian akhir memperkenalkan pemrofilan cache-miss sebagai langkah lanjutan untuk menjelaskan penyebab di balik angka tersebut.
Mengapa menduga tidak cukup#
Intuisi tentang kode yang lebih cepat sering meleset karena dua lapisan berada di antara kode sumber dan eksekusi. Lapisan pertama adalah compiler yang dapat menyisipkan badan proc, memvektorisasi perulangan, dan menghapus perhitungan yang hasilnya tidak pernah dipakai. Penghapusan semacam itu disebut dead code elimination. Lapisan kedua adalah perangkat keras, yaitu prosesor yang menjalankan instruksi secara beririsan, cache yang menyembunyikan biaya akses memori, dan prediktor cabang yang menyesuaikan diri dengan pola lalu lintas. Kode yang tampak lebih pendek belum tentu dijalankan lebih cepat, sedangkan kode yang tampak lambat mungkin telah ditulis ulang sepenuhnya oleh compiler.
Karena itu, setiap klaim performa diperlakukan sebagai hipotesis yang wajib diuji sebelum dinyatakan benar. Pengukuran menjawab tiga hal sekaligus, yaitu versi mana yang lebih cepat, seberapa besar selisihnya, dan apakah selisih itu konsisten antarpengulangan. Agar dapat dibandingkan, angka yang dilaporkan selalu disertai kondisi pengukurannya, meliputi ukuran data dan konfigurasi build. Prinsip yang sama berlaku bagi klaim tata letak data pada modul ini, termasuk manfaat pemisahan field panas dan dingin.
Timer dinding dan timer monotonic#
Paket core:time menyediakan dua jam dengan sifat yang berbeda. Proc time.now() membaca jam dinding (wall clock), yaitu jam sistem yang menunjukkan waktu nyata dan dapat melompat maju atau mundur ketika jam disetel ulang atau disinkronkan lewat jaringan. Proc time.tick_now() membaca jam monotonic, yaitu penghitung yang tidak pernah mundur dan tidak terpengaruh lompatan jam. Pada Linux, pembacaan time.now() bersumber dari CLOCK_REALTIME, sedangkan time.tick_now() bersumber dari CLOCK_MONOTONIC_RAW. Untuk mengukur durasi eksekusi, jam monotonic adalah pilihan yang tepat karena selang yang dibacanya tidak dapat negatif.
Kedua proc mengembalikan tipe yang berbeda dan tidak dapat dicampur. time.now() mengembalikan nilai bertipe time.Time, sedangkan time.tick_now() mengembalikan time.Tick. Selang antara dua pembacaan Time dihitung dengan time.diff(start, end), sedangkan Tick dihitung dengan time.tick_diff(start, end) atau lebih singkat dengan time.tick_since(start). Seluruh selang tersebut bertipe time.Duration, yakni bilangan bulat 64 bit dengan satuan nanodetik, dan dikonversi ke satuan terbaca melalui time.duration_milliseconds atau time.duration_seconds. Konstanta time.Millisecond hingga time.Second memudahkan penulisan durasi tetap, misalnya sebagai argumen time.sleep.
// tidak-dijalankan
// pola minimal pengukuran dengan jam monotonic dari core:time
t_mulai := time.tick_now()
kerjakan_beban(data)
durasi := time.tick_since(t_mulai)
fmt.printf("durasi %d ns\n", time.duration_nanoseconds(durasi))Pola pemakaiannya selalu sama. Timer dibaca tepat sebelum kode yang diukur, kode tersebut dijalankan, kemudian selangnya dihitung setelah kode selesai. Nilai yang tercatat mencakup seluruh biaya di antara dua pembacaan, termasuk biaya pembacaan tick itu sendiri. Pada contoh topik ini, durasi yang diukur setara milidetik, sehingga biaya dua pembacaan tick yang jauh lebih kecil dapat diabaikan.
Pola pengukuran: hangatkan, ulangi, ambil median#
Satu eksekusi tunggal tidak cukup menjadi dasar kesimpulan. Hasilnya dipengaruhi keadaan yang tidak berulang, misalnya cache yang masih dingin, penjadwalan sistem operasi yang menyisipkan proses lain, dan frekuensi prosesor yang belum stabil. Kerangka pengukuran pada topik ini memperkecil pengaruh keadaan tersebut melalui langkah tetap berikut.
- Hangatkan dengan menjalankan proc yang diukur satu kali dan membuang hasilnya, sehingga data telah berada di cache dan jalur kodenya sudah dieksekusi.
- Ulangi pengukuran lima belas kali menggunakan jam monotonic dan simpan setiap durasinya.
- Ambil median dengan mengurutkan lima belas durasi lalu memilih nilai tengahnya, sehingga satu atau dua eksekusi yang terganggu tidak menggeser hasil.
- Laporkan durasi per elemen beserta kondisi pengukurannya, yaitu banyaknya elemen dan konfigurasi build.
Proc ukur pada program di bagian berikutnya merangkum tiga langkah pertama. Proc yang dibandingkan dilewatkan sebagai parameter bertipe proc, sehingga kedua versi diukur dengan kode pengukuran yang sama dan tidak ada salinan kerangka yang bisa saling berbeda. Dengan cara ini, perbedaan hasil hanya dapat bersumber dari proc yang diukur, bukan dari perbedaan kerangka pengukurannya.
Dua versi penjumlahan array#
Kasus uji yang dibandingkan adalah penjumlahan seluruh elemen slice []i32 berisi tujuh juta nilai. Versi pertama disebut versi naive karena menjumlahkan satu elemen per iterasi melalui satu variabel penjumlah, bentuk yang paling lugas untuk kasus ini. Versi kedua menggabungkan empat elemen per iterasi melalui empat penjumlah mandiri p0 hingga p3, sedangkan sisa elemen di ekor array ditangani perulangan pendek. Kedua versi wajib menghasilkan jumlah yang sama, dan program mencetak keduanya sebagai pemeriksaan kebenaran sebelum angka kecepatan dibaca. Perbandingan sengaja diambil dari operasi paling sederhana, yakni penjumlahan, agar penyebab selisihnya dapat dijelaskan sampai ke struktur kodenya tanpa terganggu detail algoritma.
package main
import "core:fmt"
import "core:time"
// Menjumlahkan seluruh elemen satu per satu, versi paling lugas.
jumlah_naive :: proc(data: []i32) -> (total: i64) {
for v in data {
total += i64(v)
}
return
}
// Menggabungkan empat elemen per iterasi melalui empat penjumlah mandiri.
jumlah_empat :: proc(data: []i32) -> (total: i64) {
n := len(data)
i := 0
p0, p1, p2, p3: i64
for i + 4 <= n {
p0 += i64(data[i])
p1 += i64(data[i + 1])
p2 += i64(data[i + 2])
p3 += i64(data[i + 3])
i += 4
}
total = p0 + p1 + p2 + p3
for i < n {
total += i64(data[i])
i += 1
}
return
}
// Median lima belas durasi: nilai tengah setelah disalin dan diurutkan.
median_15 :: proc(d: [15]time.Duration) -> time.Duration {
a := d
for i := 1; i < len(a); i += 1 {
k := a[i]
j := i - 1
for j >= 0 && a[j] > k {
a[j + 1] = a[j]
j -= 1
}
a[j + 1] = k
}
return a[7]
}
// Pola pengukuran: hangatkan sekali, ulangi lima belas kali, ambil median.
ukur :: proc(data: []i32, f: proc([]i32) -> i64) -> time.Duration {
f(data) // pemanasan: satu eksekusi yang hasilnya dibuang
d: [15]time.Duration
for i := 0; i < 15; i += 1 {
t := time.tick_now()
f(data)
d[i] = time.tick_since(t)
}
return median_15(d)
}
main :: proc() {
n := 7_000_000
data := make([]i32, n)
defer delete(data)
for i in 0 ..< n {
data[i] = i32(i % 7)
}
d_naive := ukur(data, jumlah_naive)
d_empat := ukur(data, jumlah_empat)
// biaya per elemen dalam nanodetik, dibulatkan ke bilangan bulat terdekat
biaya_naive := (time.duration_nanoseconds(d_naive) + i64(n) / 2) / i64(n)
biaya_empat := (time.duration_nanoseconds(d_empat) + i64(n) / 2) / i64(n)
fmt.printf("elemen : %d\n", n)
fmt.printf("jumlah naive : %d\n", jumlah_naive(data))
fmt.printf("jumlah empat elemen : %d\n", jumlah_empat(data))
fmt.printf("biaya naive : %d ns per elemen\n", biaya_naive)
fmt.printf("biaya empat elemen : %d ns per elemen\n", biaya_empat)
}# bangun dan jalankan program pengukuran dari direktori topik
odin run main.odin -fileelemen : 7000000
jumlah naive : 21000000
jumlah empat elemen : 21000000
biaya naive : 3 ns per elemen
biaya empat elemen : 2 ns per elemenIsi data ditetapkan secara deterministik dengan i32(i % 7), sehingga jumlah total kedua versi selalu 21.000.000 dan keluarannya tidak bergantung pada angka acak. Baris biaya pada keluaran dihitung dari durasi median dibagi dengan banyaknya elemen, kemudian dibulatkan ke nanodetik terdekat. Pada sandbox tempat topik ini ditulis, kedua baris biaya tercetak 3 dan 2 nanodetik per elemen pada hampir seluruh eksekusi, sehingga angka ini dapat dijadikan patokan dengan toleransi pembulatan satu nanodetik.
Membaca angka yang terukur#
Nilai absolut dari satu eksekusi pengukuran pada sandbox ini tercatat sekitar 22 ms untuk versi naive dan sekitar 14 ms untuk versi empat elemen, atau sekitar 1,6 kali lebih cepat. Angka tersebut bervariasi antareksekusi dan antaramesin, tetapi urutan pemenangnya pada build default konsisten. Penyebab keunggulannya dapat dijelaskan dari struktur kodenya. Penjumlahan satu variabel membentuk rantai dependensi karena penjumlahan elemen berikutnya menunggu hasil elemen sebelumnya, sedangkan empat penjumlah mandiri dapat berjalan beririsan pada prosesor yang sama. Perulangan versi gabungan juga berputar empat kali lebih sedikit, sehingga biaya percabangan dan penambahan indeks berkurang.
Kondisi build mengubah kesimpulan secara menyeluruh, dan di sinilah pelaporan kondisi menjadi wajib. Ketika program yang sama dibangun dengan -o:speed dan hasil pengukurannya dikonsumsi, versi naive turun menjadi sekitar 2 ms karena compiler memvektorisasi perulangannya, dan urutan pemenang berbalik. Bila hasilnya tidak pernah dikonsumsi, versi naive tercatat 0,00 ms karena perulangannya dihapus seluruhnya oleh dead code elimination. Timer tidak berbohong pada kedua kasus tersebut, tetapi program yang diukurnya berhak dihapus atau berubah bentuk menurut konfigurasi build.
Kerangka yang sama dapat menguji klaim tata letak data pada bagian-bagian lain modul ini. Proc yang membandingkan traversal field panas dengan traversal tata letak kolom cukup disubstitusikan sebagai parameter proc tanpa menyentuh kode pengukurannya. Dengan demikian, perbandingan antareksperimen tetap setara, yaitu beban data yang sama, banyaknya pengulangan yang sama, dan statistik yang sama.
Pemrofilan cache-miss sebagai langkah lanjutan#
Timer menjawab pertanyaan berapa lama, tetapi tidak menjawab mengapa selisih itu terjadi. Untuk menjawab pertanyaan penyebab, tersedia profiler, yaitu alat yang mengamati eksekusi program dan mengaitkan waktu atau peristiwa perangkat keras dengan proc serta baris kode. Peristiwa yang paling relevan bagi pola data adalah cache miss, yakni akses memori yang tidak dilayani cache sehingga harus dilanjutkan ke tingkat cache berikutnya atau ke memori utama dengan biaya puluhan hingga ratusan siklus. Traversal yang melompati memori dengan pola tidak beraturan menghasilkan cache miss jauh lebih banyak daripada traversal berurutan yang memakai setiap baris cache sampai tuntas.
Pada Linux, alat perf menghitung peristiwa cache-miss per proc melalui penghitung kinerja (performance counter) yang disediakan prosesor, sedangkan Intel VTune menyajikan analisis serupa dengan rincian tingkat mikroarsitektur. Keduanya tidak dijalankan pada topik ini karena memerlukan pemasangan dan izin sistem yang berada di luar sandbox, sehingga tidak ada keluarannya yang dikutip di sini. Pembagian kerjanya perlu dibawa selanjutnya, yaitu timer menetapkan apakah selisih itu nyata dan seberapa besar, kemudian pemrofilan cache-miss menjelaskan mekanisme di balik selisih tersebut.
Ringkasan#
- Klaim performa wajib didukung angka pengukuran karena compiler dan perangkat keras dapat menulis ulang eksekusi kode sehingga intuisi mudah meleset.
- Durasi diukur dengan jam monotonic, yaitu
time.tick_now()dantime.tick_since()dari core:time, karena jam dindingtime.now()dapat melompat. - Pola pengukurannya adalah hangatkan sekali, ulangi lima belas kali, ambil median, lalu laporkan durasi per elemen beserta ukuran data dan konfigurasi build.
- Hasil yang diukur harus dikonsumsi dan kondisi build dilaporkan, karena
-o:speeddapat memvektorisasi atau bahkan menghapus perulangan yang diukur.
Referensi#
- Overview — Odin Language — Ikhtisar resmi bahasa Odin yang menjadi dasar sintaks proc, slice, dan perulangan pada topik ini.
- package time — pkg.odin-lang.org
— Dokumentasi resmi paket core:time untuk
time.now,time.tick_now, dantime.Duration.