Optimisasi yang dilakukan berdasarkan firasat hampir selalu salah sasaran. Kode yang terasa lambat sering kali bukan penyebab utama; bottleneck sesungguhnya kerap bersembunyi di tempat yang tidak dicurigai, seperti konversi string di dalam perulangan atau query database yang dipanggil berulang. Chapter penutup section ini merangkum disiplin kerja yang sudah kita latih sepanjang Course: ukur dahulu, ubah kemudian, lalu ukur lagi untuk membuktikan dampaknya.

Mengapa Firasat Sering Salah #

Otak kita menilai biaya kode dari penampakannya, sedangkan komputer membayar biaya dari perilaku nyatanya di mesin. Sebuah fungsi dengan perulangan bersarang bisa jauh lebih murah daripada satu baris yang kelihatan polos, karena yang mahal bukan jumlah baris melainkan jumlah alokasi memori, akses cache, dan syscall. Donald Knuth merangkum bahaya ini dalam kutipan yang terkenal: premature optimization is the root of all evil — kejahatan utamanya bukan optimisasi itu sendiri, melainkan optimisasi yang dilakukan sebelum ada bukti.

Dalam praktik sehari-hari, keputusan yang diambil tanpa pengukuran menghasilkan tiga kerugian yang saling memperkuat. Pertama, waktu habis untuk mempercepat kode yang tidak signifikan terhadap total waktu. Kedua, kode menjadi lebih rumit dan sulit diuji tanpa manfaat yang terukur. Ketiga, tim kehilangan kepercayaan terhadap proses optimisasi sehingga inisiatif performa berikutnya lebih sulit disetujui. Data pengukuran memutus lingkaran ini karena menjadikan diskusi performa sebagai diskusi fakta, bukan selera.

Empat Langkah Disiplin #

Alur kerja optimisasi yang sehat selalu berbentuk siklus, bukan lompatan langsung ke penyuntingan kode:

  1. Ukur baseline. Catat angka sebelum perubahan apa pun — durasi via benchmark, alokasi via ReportAllocs atau runtime.MemStats, titik panas via profil pprof.
  2. Temukan bottleneck dari data. Urutkan hasil profil dan pilih satu target dengan dampak terbesar; jangan menebak dari nama fungsi yang “kelihatan berat”.
  3. Ubah satu hal saja. Satu perubahan per siklus membuat efeknya terbaca bersih pada pengukuran berikutnya.
  4. Ukur ulang dan bandingkan. Jika tidak ada perbaikan yang berarti, kembalikan perubahan tersebut dan lanjut ke kandidat berikutnya.

Pada chapter Benchmark dan Profiling kita sudah mempraktikkan langkah pertama dan kedua, sedangkan chapter Mengurangi Allocation dan Efisiensi Slice dan Map berisi katalog perubahan yang lazim menjadi kandidat langkah ketiga.

Program Demo: Tebakan Versus Data #

Program berikut menyandingkan dua implementasi penghitung frekuensi kata. Kedua versi menghasilkan keluaran yang identik, sehingga dari sisi kebenaran tidak ada pilihan yang salah. Biaya sesungguhnya baru terlihat setelah kita mengukur alokasi memori di kedua fungsi tersebut:

go
package main

import (
	"fmt"
	"runtime"
	"strings"
)

// hitungKataNaif menghitung frekuensi kata dengan potong-memotong string
// berulang: sekilas terlihat wajar, tetapi tiap strings.Fields mengalokasi
// slice baru.
func hitungKataNaif(teks string) map[string]int {
	hasil := map[string]int{}
	for _, baris := range strings.Split(teks, "\n") {
		for _, kata := range strings.Fields(baris) {
			hasil[kata]++
		}
	}
	return hasil
}

// hitungKataEfisien memakai satu kali strings.Fields untuk seluruh teks
// dan menerima kapasitas awal dari pemanggil.
func hitungKataEfisien(teks string, kapasitas int) map[string]int {
	hasil := make(map[string]int, kapasitas)
	for _, kata := range strings.Fields(teks) {
		hasil[kata]++
	}
	return hasil
}

// ukurAlokasi mengembalikan jumlah alokasi dan total byte yang terjadi
// selama menjalankan fungsi f. Angka ini deterministik untuk input tetap.
func ukurAlokasi(f func()) (alokasi int64, jumlahByte int64) {
	var awal, akhir runtime.MemStats
	runtime.ReadMemStats(&awal)
	f()
	runtime.ReadMemStats(&akhir)
	return int64(akhir.Mallocs - awal.Mallocs), int64(akhir.TotalAlloc - awal.TotalAlloc)
}

func main() {
	kalimat := strings.Repeat("belajar go itu menyenangkan dan cepat\n", 20)

	// Dua implementasi memberikan hasil yang sama...
	naif := hitungKataNaif(kalimat)
	efisien := hitungKataEfisien(kalimat, 8)
	fmt.Println("Hasil sama        :", len(naif) == len(efisien))

	// ...tetapi biaya alokasinya sangat berbeda. Tebakan saja tidak akan
	// menunjukkan ini; pengukuran yang menunjukkan.
	aN, bN := ukurAlokasi(func() { hitungKataNaif(kalimat) })
	aE, bE := ukurAlokasi(func() { hitungKataEfisien(kalimat, 8) })
	fmt.Printf("Naif      : %d alokasi, %d byte\n", aN, bN)
	fmt.Printf("Efisien   : %d alokasi, %d byte\n", aE, bE)

	// Keputusan optimisasi diambil dari data, bukan dari kesan sekilas.
	fmt.Println("Optimasi dibenarkan oleh data:", aE < aN)
}

Output yang Dihasilkan:

text
Hasil sama        : true
Naif      : 23 alokasi, 2528 byte
Efisien   : 1 alokasi, 2048 byte
Optimasi dibenarkan oleh data: true

Mari kita telaah bagian-bagian kunci program di atas:

  1. Fungsi hitungKataNaif: memecah teks per baris dengan strings.Split, lalu memanggil strings.Fields sekali per baris. Setiap pemanggilan tersebut menghasilkan slice kata baru — 20 baris berarti 20 deretan alokasi kecil yang tidak terlihat dari luar.
  2. Fungsi hitungKataEfisien: memanggil strings.Fields tepat satu kali untuk seluruh teks dan membangun map dengan kapasitas awal. Hasilnya identik, tetapi jumlah alokasi runtuh dari 23 menjadi 1.
  3. Fungsi ukurAlokasi: membaca runtime.MemStats sebelum dan sesudah f() berjalan, lalu menghitung selisih Mallocs dan TotalAlloc. Untuk input yang tetap, angka ini stabil antar eksekusi sehingga cocok sebagai bukti, bukan sekadar kesan.
  4. Fungsi main: membandingkan kedua versi secara berdampingan. Baris pertama membuktikan kebenaran tidak berubah; dua baris berikutnya adalah bukti biaya; baris terakhir mengubah bukti menjadi keputusan.

Kedua fungsi sama-sama “benar” dan sama-sama cepat untuk data sekecil ini. Justru di situlah pelajarannya: tanpa pengukuran, kita tidak memiliki alasan apa pun untuk memilih satu di antara keduanya, dan optimisasi hanya akan menjadi selera gaya kode.

Metodologi Pengukuran yang Jujur #

Benchmark yang adil punya syarat yang mudah dilanggar tanpa disadari. Input kedua versi harus identik — perbandingan antara fungsi yang memproses data berbeda tidak bermakna. Inisialisasi yang tidak diukur harus dikeluarkan dengan b.ResetTimer(). Setiap angka yang dilaporkan perlu disertai konteksnya: mesin, jumlah iterasi, dan fluktuasi antar eksekusi, karena angka nanodetik tanpa konteks terasa lebih presisi daripada kenyataannya. Terakhir, bedakan biaya satu kali (inisialisasi map, koneksi awal) dari biaya berulang; yang kedua inilah yang membaik lewat optimisasi.

Untuk aplikasi nyata, pengukuran di laboratorium perlu dicegang dengan pengukuran di production. Endpoint net/http/pprof yang kita bahas pada chapter Diagnostik di Production memungkinkan pengambilan profil dari beban kerja asli, dan pola probe pada chapter Health, Readiness, dan Liveness menjaga sistem tetap terpantau setelah perubahan dirilis. Ketika benchmark unit dan profil production berbeda cerita, percayakan prioritas pada profil production karena di sanalah pengguna berada.

Ringkasan Disiplin #

Kerangka akhirnya sederhana: data mendahului keputusan, satu perubahan pada satu waktu, dan setiap klaim perbaikan harus disertai angka sebelum-sesudah. Paham 80/20 tetap berlaku — sebagian besar waktu eksekusi dikonsumsi sebagian kecil kode — jadi biarkan profil menunjukkan di mana 20 persen itu berada. Optimisasi yang lahir dari angka bukan hanya lebih cepat; ia juga lebih mudah dipertahankan karena alasan keberadaannya tercatat. Dengan disiplin ini, section performance ditutup dan kita siap membawa seluruh bekal Course ini ke tahap akhir: tooling dan alur kerja yang menjaga kualitas secara berkelanjutan.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ukur-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"runtime"
	"strings"
)

// hitungKataNaif menghitung frekuensi kata dengan potong-memotong string
// berulang: sekilas terlihat wajar, tetapi tiap strings.Fields mengalokasi
// slice baru.
func hitungKataNaif(teks string) map[string]int {
	hasil := map[string]int{}
	for _, baris := range strings.Split(teks, "\n") {
		for _, kata := range strings.Fields(baris) {
			hasil[kata]++
		}
	}
	return hasil
}

// hitungKataEfisien memakai satu kali strings.Fields untuk seluruh teks
// dan menerima kapasitas awal dari pemanggil.
func hitungKataEfisien(teks string, kapasitas int) map[string]int {
	hasil := make(map[string]int, kapasitas)
	for _, kata := range strings.Fields(teks) {
		hasil[kata]++
	}
	return hasil
}

// ukurAlokasi mengembalikan jumlah alokasi dan total byte yang terjadi
// selama menjalankan fungsi f. Angka ini deterministik untuk input tetap.
func ukurAlokasi(f func()) (alokasi int64, jumlahByte int64) {
	var awal, akhir runtime.MemStats
	runtime.ReadMemStats(&awal)
	f()
	runtime.ReadMemStats(&akhir)
	return int64(akhir.Mallocs - awal.Mallocs), int64(akhir.TotalAlloc - awal.TotalAlloc)
}

func main() {
	kalimat := strings.Repeat("belajar go itu menyenangkan dan cepat\n", 20)

	// Dua implementasi memberikan hasil yang sama...
	naif := hitungKataNaif(kalimat)
	efisien := hitungKataEfisien(kalimat, 8)
	fmt.Println("Hasil sama        :", len(naif) == len(efisien))

	// ...tetapi biaya alokasinya sangat berbeda. Tebakan saja tidak akan
	// menunjukkan ini; pengukuran yang menunjukkan.
	aN, bN := ukurAlokasi(func() { hitungKataNaif(kalimat) })
	aE, bE := ukurAlokasi(func() { hitungKataEfisien(kalimat, 8) })
	fmt.Printf("Naif      : %d alokasi, %d byte\n", aN, bN)
	fmt.Printf("Efisien   : %d alokasi, %d byte\n", aE, bE)

	// Keputusan optimisasi diambil dari data, bukan dari kesan sekilas.
	fmt.Println("Optimasi dibenarkan oleh data:", aE < aN)
}