Bab 44 — Performa: Cepat Itu Diukur, Bukan Ditebak

Seri A1 — Rekayasa Perangkat Lunak Profesional

CatatanDi bab ini, Anda akan:
  • Mengukur program dengan profiling (cProfile, line_profiler, memory_profiler) sebelum mengoptimalkan
  • Memilih struktur data yang tepat (list, set, dict) untuk mencari data dengan cepat
  • Memakai vektorisasi numpy agar jutaan angka dihitung sekaligus
  • Memakai caching (functools.lru_cache) dan membaca hitungan kena/meleset
  • Mempercepat rekap data pohon: awal proyek bersambung Seri A1

Semua skrip bab ini ada di folder kode/bab44/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Selamat datang di tingkat Advanced! Aplikasi dari Seri I5 sudah berjalan. Sekarang ia harus menghadapi dunia nyata: data bertambah dari ratusan baris menjadi puluhan ribu, dan rekap yang tadinya instan mulai terasa lambat. Bayangkan Anda mengelola gudang kayu yang makin penuh. Mencari satu karung kayu dengan membuka semua karung satu per satu itu lambat. Menyimpan daftar letaknya di buku catatan membuat pencarian jadi cepat. Bab ini mengajarkan cara menemukan di mana program Anda “membuka semua karung”, lalu memperbaikinya.

Aturan emas performa: ukur dulu, jangan menebak. Programmer berpengalaman pun sering salah menebak bagian yang lambat. Mengoptimalkan bagian yang salah hanya membuat kode lebih rumit tanpa membuatnya lebih cepat.

Siklus optimasi: ukur, temukan, perbaiki satu hal, ukur lagi.

Profiling: mencari titik terlambat dengan cProfile

Profiling adalah mengukur bagian mana dari program yang menghabiskan waktu. Python membawa profiler bawaan bernama cProfile. Skrip berikut menjumlahkan luas bidang dasar pohon per plot dengan cara yang agak boros:

01_cprofile.py
# Bab 44 - cProfile: mencari fungsi yang paling berat
import cProfile
import pstats
import random


def buat_data(n):
    rng = random.Random(42)
    return [(rng.choice("ABC"), rng.uniform(10, 60)) for _ in range(n)]


def luas_dasar(diameter_cm):
    return 3.14159 * (diameter_cm / 200) ** 2


def total_per_plot(data, plot):
    total = 0.0
    for p, d in data:
        if p == plot:
            total += luas_dasar(d)
    return total


def rekap(data):
    hasil = {}
    for plot in "ABC":
        hasil[plot] = total_per_plot(data, plot)
    return hasil


data = buat_data(3000)
profil = cProfile.Profile()
profil.runcall(rekap, data)
statistik = pstats.Stats(profil)

# Waktu berbeda di tiap komputer, jadi kita tampilkan hanya yang stabil:
# berapa kali tiap fungsi dipanggil, dan urutan "waktu kumulatif".
ours = {k: v for k, v in statistik.stats.items() if k[0].endswith("01_cprofile.py")}
print("fungsi            jumlah panggilan")
for (_, _, nama), (_, panggilan, _, _, _) in sorted(ours.items(), key=lambda kv: kv[0][2]):
    print(f"{nama:17} {panggilan:6}")

urut = sorted(ours.items(), key=lambda kv: kv[1][3], reverse=True)
print("urutan dari yang paling lama (kumulatif):", [k[2] for k, _ in urut])
fungsi            jumlah panggilan
luas_dasar          3000
rekap                  1
total_per_plot         3
urutan dari yang paling lama (kumulatif): ['rekap', 'total_per_plot', 'luas_dasar']

Cara membacanya:

  • luas_dasar dipanggil 3000 kali: sekali untuk tiap pohon. total_per_plot dipanggil 3 kali (satu per plot), dan rekap sekali.
  • Urutan waktu kumulatif (waktu kumulatif = waktu fungsi itu ditambah semua yang ia panggil) selalu dari pembungkus terluar ke fungsi terdalam: rekap, lalu total_per_plot, lalu luas_dasar.
PeringatanAWAS

Skrip ini sengaja tidak mencetak angka detik. Waktu berbeda di tiap komputer dan tiap kali dijalankan, bahkan di komputer yang sama. Angka yang stabil adalah jumlah panggilan dan urutan. Jangan menyimpulkan apa pun dari satu kali pengukuran waktu.

Supaya Anda tahu seperti apa keluaran lengkapnya, skrip berikut mengambil keluaran asli cProfile sekali saja:

07_waktu_statis.py
# Bab 44 - Keluaran cProfile asli (lengkap dengan angka waktu)
import cProfile
import pstats
import random


def buat_data(n):
    rng = random.Random(42)
    return [(rng.choice("ABC"), rng.uniform(10, 60)) for _ in range(n)]


def luas_dasar(diameter_cm):
    return 3.14159 * (diameter_cm / 200) ** 2


def total_per_plot(data, plot):
    total = 0.0
    for p, d in data:
        if p == plot:
            total += luas_dasar(d)
    return total


def rekap(data):
    return {plot: total_per_plot(data, plot) for plot in "ABC"}


data = buat_data(300_000)
profil = cProfile.Profile()
profil.runcall(rekap, data)
pstats.Stats(profil).strip_dirs().sort_stats("cumulative").print_stats(4)
         300005 function calls in 0.293 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.293    0.293 07_waktu_statis.py:25(rekap)
        3    0.179    0.060    0.293    0.098 07_waktu_statis.py:17(total_per_plot)
   300000    0.114    0.000    0.114    0.000 07_waktu_statis.py:13(luas_dasar)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Keluaran di atas diambil sekali dari komputer penulis (arahan @statis), karena angka waktunya tidak mungkin sama persis di komputer Anda. Angka waktu di komputer Anda berbeda, tetapi bentuknya sama. Kolom penting: ncalls (jumlah panggilan), tottime (waktu di dalam fungsi itu saja), cumtime (waktu kumulatif). Jumlah panggilan di sini (300000 untuk luas_dasar, total 300005) sama persis di komputer Anda karena ia dihitung, bukan diukur.

line_profiler: sampai ke tingkat baris

cProfile menunjukkan fungsi mana yang berat. Bila fungsinya panjang, line_profiler (paket tambahan: pip install line_profiler) menunjukkan baris mana. Biasanya Anda memakainya lewat perintah kernprof, tetapi di sini kita memakai antarmuka Python-nya agar bisa dijalankan sebagai skrip biasa:

02_line_profiler.py
# Bab 44 - line_profiler: menghitung berapa kali tiap baris dijalankan
import linecache

from line_profiler import LineProfiler


def ringkas(diameter):
    jumlah_besar = 0
    total = 0.0
    for d in diameter:
        if d >= 30:
            jumlah_besar += 1
        total += d
    return jumlah_besar, total / len(diameter)


diameter = [32.5, 45.2, 28.0, 51.5, 29.9, 30.0]

profiler = LineProfiler()
ringkas_terpantau = profiler(ringkas)
ringkas_terpantau(diameter)

stats = profiler.get_stats()
for (berkas, baris_awal, nama), isi in stats.timings.items():
    print(f"Fungsi {nama}: kolom 'kali' = berapa kali baris dijalankan")
    for nomor, kali, _waktu in isi:
        kode = linecache.getline(berkas, nomor).strip()
        print(f"  {kali:3} kali  {kode}")
Fungsi ringkas: kolom 'kali' = berapa kali baris dijalankan
    1 kali  jumlah_besar = 0
    1 kali  total = 0.0
    7 kali  for d in diameter:
    6 kali  if d >= 30:
    4 kali  jumlah_besar += 1
    6 kali  total += d
    1 kali  return jumlah_besar, total / len(diameter)

Dari tujuh baris, baris for dijalankan 7 kali (6 elemen ditambah satu kali pemeriksaan “sudah habis”), if 6 kali, dan jumlah_besar += 1 hanya 4 kali (diameter 32,5; 45,2; 51,5; dan 30,0, yang >= 30). Pada program sungguhan, line_profiler juga memberi kolom waktu per baris; kita hanya menampilkan jumlahnya karena stabil.

memory_profiler: mengukur memori

Kadang masalahnya bukan waktu, melainkan memori. memory_profiler mengukur pemakaian memori sebuah fungsi. Kita membandingkan dua cara menjumlahkan dua juta angka:

03_memori.py
# Bab 44 - memory_profiler: list menyimpan semua, generator menghasilkan satu per satu
from memory_profiler import memory_usage


def pakai_list(n):
    angka = [i * 1.5 for i in range(n)]
    return sum(angka)


def pakai_generator(n):
    return sum(i * 1.5 for i in range(n))


# Di Windows, memory_usage memulai proses baru, jadi wajib dalam "if __name__".
if __name__ == "__main__":
    n = 2_000_000
    puncak_list = max(memory_usage((pakai_list, (n,)), interval=0.01))
    puncak_gen = max(memory_usage((pakai_generator, (n,)), interval=0.01))

    print("hasil sama:", pakai_list(1000) == pakai_generator(1000))
    print("puncak memori list lebih besar dari generator:", puncak_list > puncak_gen)
    print("selisihnya lebih dari 20 MB:", puncak_list - puncak_gen > 20)
hasil sama: True
puncak memori list lebih besar dari generator: True
selisihnya lebih dari 20 MB: True

List menyimpan semua angka di memori sekaligus; generator (Bab 30) menghasilkan satu per satu lalu membuangnya. Hasilnya sama, tetapi puncak memorinya jauh berbeda. Kita hanya mencetak perbandingan, bukan angka megabita yang berubah-ubah.

TipTIPS

Di Windows, memory_usage memulai proses baru, sehingga kode utama wajib berada di bawah if __name__ == "__main__":. Tanpa itu, Anda akan mendapat RuntimeError tentang “bootstrapping phase”. Itu bukan bug paket, melainkan aturan multiprocessing di Windows.

Struktur data yang tepat

Sering kali optimasi terbesar bukan trik rumit, melainkan memilih wadah yang tepat. Mencari sebuah item dalam list berarti memeriksa dari depan satu per satu. Pada set dan dict (yang memakai tabel hash), Python “melompat” langsung ke tempatnya.

04_struktur_data.py
# Bab 44 - Struktur data yang tepat: list vs set vs dict
import timeit

kode_pohon = [f"P{i:05d}" for i in range(20_000)]
daftar = list(kode_pohon)
himpunan = set(kode_pohon)
kamus = {k: i for i, k in enumerate(kode_pohon)}

dicari = "P19999"  # paling belakang: kasus terburuk untuk list

t_list = min(timeit.repeat(lambda: dicari in daftar, number=200, repeat=5))
t_set = min(timeit.repeat(lambda: dicari in himpunan, number=200, repeat=5))
t_dict = min(timeit.repeat(lambda: kamus[dicari], number=200, repeat=5))

print("hasil sama:", (dicari in daftar) == (dicari in himpunan) == (dicari in kamus))
print("set lebih dari 100x lebih cepat dari list:", t_list / t_set > 100)
print("dict lebih dari 100x lebih cepat dari list:", t_list / t_dict > 100)
hasil sama: True
set lebih dari 100x lebih cepat dari list: True
dict lebih dari 100x lebih cepat dari list: True

Kita mencari kode pohon yang letaknya paling belakang dari 20.000 kode, kasus terburuk bagi list. Set dan dict lebih dari 100 kali lebih cepat. Pelajaran praktis:

Kebutuhan Pilih Alasan
Daftar berurutan, sering diurutkan list urutan dijaga
Cek “apakah ada?” berulang kali set pencarian langsung
Cari data lengkap dari kuncinya dict kunci menunjuk nilai
Antrean masuk-keluar dua ujung collections.deque murah di kedua ujung

Vektorisasi: menghitung sekaligus dengan numpy

Perulangan for di Python itu fleksibel tetapi lambat untuk jutaan angka, karena tiap putaran melewati banyak lapisan. Vektorisasi berarti menyerahkan seluruh perulangan ke numpy, yang menjalankannya dalam kode C yang cepat:

05_vektorisasi.py
# Bab 44 - Vektorisasi dengan numpy: hitung banyak angka sekaligus
import timeit

import numpy as np

rng = np.random.default_rng(42)
diameter_cm = rng.uniform(10, 60, size=1_000_000)


def luas_dengan_loop(diameter):
    hasil = []
    for d in diameter:
        hasil.append(3.14159 * (d / 200) ** 2)
    return hasil


def luas_vektor(diameter):
    return 3.14159 * (diameter / 200) ** 2


sampel = diameter_cm[:5]
print("loop  :", [round(float(x), 5) for x in luas_dengan_loop(sampel)])
print("vektor:", np.round(luas_vektor(sampel), 5).tolist())

t_loop = min(timeit.repeat(lambda: luas_dengan_loop(diameter_cm), number=1, repeat=3))
t_vektor = min(timeit.repeat(lambda: luas_vektor(diameter_cm), number=1, repeat=3))
print("vektor lebih dari 10x lebih cepat:", t_loop / t_vektor > 10)
loop  : [0.18626, 0.08014, 0.22003, 0.15811, 0.01699]
vektor: [0.18626, 0.08014, 0.22003, 0.15811, 0.01699]
vektor lebih dari 10x lebih cepat: True

Dua cara memberi hasil yang sama (lima angka contoh identik), tetapi cara vektor lebih dari 10 kali lebih cepat untuk sejuta diameter. Aturan jempolnya: bila Anda menulis for atas array numpy, hampir pasti ada cara yang lebih baik.

Caching: jangan menghitung dua kali

Bila fungsi yang sama dipanggil berulang dengan masukan yang sama, simpan hasilnya. functools.lru_cache melakukannya otomatis:

06_cache.py
# Bab 44 - Caching dengan functools.lru_cache: kena (hit) dan meleset (miss)
from functools import lru_cache

panggilan_asli = 0


@lru_cache(maxsize=None)
def tarif_per_jenis(jenis):
    global panggilan_asli
    panggilan_asli += 1          # hitungan "kerja berat" yang sebenarnya
    return {"Jati": 120.0, "Meranti": 95.0, "Mahoni": 80.0}.get(jenis, 50.0)


pohon = ["Jati", "Meranti", "Jati", "Mahoni", "Jati", "Meranti", "Sengon", "Jati"]
total = sum(tarif_per_jenis(j) for j in pohon)

print("total tarif:", total)
print("kerja berat sungguhan dijalankan:", panggilan_asli, "kali dari", len(pohon), "pemanggilan")
info = tarif_per_jenis.cache_info()
print("kena (hits):", info.hits, "| meleset (misses):", info.misses, "| tersimpan:", info.currsize)

tarif_per_jenis.cache_clear()
print("setelah cache_clear:", tarif_per_jenis.cache_info())
total tarif: 800.0
kerja berat sungguhan dijalankan: 4 kali dari 8 pemanggilan
kena (hits): 4 | meleset (misses): 4 | tersimpan: 4
setelah cache_clear: CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)

Ada 8 pohon tetapi hanya 4 jenis berbeda. Pemanggilan pertama untuk tiap jenis meleset (miss: belum ada di cache, kerja berat dijalankan): 4 kali. Sisanya kena (hit: hasil diambil dari cache): 4 kali. real_calls membuktikan kerja berat benar-benar hanya 4 kali. Nilai total 800,0 dihitung dari 4 kali Jati (120 x 4 = 480), 2 Meranti (95 x 2 = 190), 1 Mahoni (80), 1 Sengon (bawaan 50): 480 + 190 + 80 + 50 = 800.

CatatanINGAT

lru_cache hanya cocok untuk fungsi yang murni: hasilnya hanya bergantung pada argumen, dan argumennya harus bisa di-hash (angka, teks, tuple; bukan list). Jangan men-cache fungsi yang hasilnya berubah dari waktu ke waktu (misalnya membaca harga hari ini) tanpa cara menghapus cache (cache_clear()).

Proyek Bersambung A1: mempercepat rekap nilai kayu

Inilah awal proyek Seri A1. Aplikasi kita menghitung nilai kayu per plot dan jenis dari 50.000 pohon (harga fiktif, hanya angka contoh). Versi lambat menyimpan data per baris dan memindai semua pohon untuk tiap kombinasi plot dan jenis. Versi cepat menyimpan data per kolom dan menghitung semuanya sekaligus:

08_proyek_percepat.py
# Bab 44 - Proyek bersambung A1: rekap nilai kayu per plot dan jenis, versi lambat lalu versi cepat
import cProfile
import pstats
import timeit

import numpy as np

JENIS = ["Jati", "Meranti", "Mahoni", "Sengon"]
PLOT = ["A", "B", "C"]
HARGA_M3 = np.array([4_000_000, 2_500_000, 3_000_000, 1_000_000])   # angka contoh fiktif per jenis


def buat_kolom(n):
    """Data disimpan per KOLOM (array numpy): bentuk yang cocok untuk dihitung serempak."""
    rng = np.random.default_rng(42)
    return {
        "jenis": rng.integers(0, len(JENIS), n),
        "plot": rng.integers(0, len(PLOT), n),
        "diameter_cm": rng.uniform(10, 60, n).round(1),
        "tinggi_m": rng.uniform(5, 25, n).round(1),
    }


# ---- Versi 1: lambat. Data per BARIS (list of dict), tiap rekap memindai semuanya lagi ----
def volume_m3(p):
    return 3.14159 * (p["diameter_cm"] / 200) ** 2 * p["tinggi_m"] * 0.5


def nilai_lambat(baris):
    hasil = {}
    for plot in range(len(PLOT)):
        for jenis in range(len(JENIS)):
            total = 0.0
            for p in baris:
                if p["plot"] == plot and p["jenis"] == jenis:
                    total += volume_m3(p) * HARGA_M3[jenis]
            hasil[(PLOT[plot], JENIS[jenis])] = float(total)
    return hasil


# ---- Versi 2: cepat. Data per kolom, satu kali hitung serempak ----
def nilai_cepat(kolom):
    volume = 3.14159 * (kolom["diameter_cm"] / 200) ** 2 * kolom["tinggi_m"] * 0.5
    nilai = volume * HARGA_M3[kolom["jenis"]]
    kelompok = kolom["plot"] * len(JENIS) + kolom["jenis"]
    jumlah = np.bincount(kelompok, weights=nilai, minlength=len(PLOT) * len(JENIS))
    return {(PLOT[k // len(JENIS)], JENIS[k % len(JENIS)]): float(jumlah[k]) for k in range(len(jumlah))}


kolom = buat_kolom(50_000)
baris = [{k: kolom[k][i].item() for k in kolom} for i in range(50_000)]   # versi baris untuk versi lambat

lambat = nilai_lambat(baris)
cepat = nilai_cepat(kolom)
sama = all(abs(lambat[k] - cepat[k]) < 1e-6 * lambat[k] for k in lambat)
print("kedua versi memberi hasil sama:", sama)
print("jumlah kelompok (plot x jenis):", len(cepat))
print("lambat mengunjungi tiap pohon", len(PLOT) * len(JENIS), "kali; cepat hanya 1 kali")

panggilan = {}
for nama, fungsi, data in [("lambat", nilai_lambat, baris), ("cepat", nilai_cepat, kolom)]:
    profil = cProfile.Profile()
    profil.runcall(fungsi, data)
    st = pstats.Stats(profil)
    panggilan[nama] = sum(v[1] for k, v in st.stats.items() if k[2] == "volume_m3")
    print(f"versi {nama}: pemanggilan volume_m3 = {panggilan[nama]}")

t_lambat = min(timeit.repeat(lambda: nilai_lambat(baris), number=1, repeat=3))
t_cepat = min(timeit.repeat(lambda: nilai_cepat(kolom), number=1, repeat=3))
print("versi cepat lebih dari 20x lebih cepat:", t_lambat / t_cepat > 20)
kedua versi memberi hasil sama: True
jumlah kelompok (plot x jenis): 12
lambat mengunjungi tiap pohon 12 kali; cepat hanya 1 kali
versi lambat: pemanggilan volume_m3 = 50000
versi cepat: pemanggilan volume_m3 = 0
versi cepat lebih dari 20x lebih cepat: True

Perhatikan urutannya: pertama membuktikan hasilnya sama (tanpa itu, “lebih cepat” tak ada artinya), lalu cProfile menunjukkan versi lambat memanggil volume_m3 50.000 kali dan versi cepat tidak sama sekali, lalu perbandingan waktu yang kita nyatakan secara kualitatif (lebih dari 20 kali). Angka “20 kali” itu batas longgar; di komputer Anda selisih sebenarnya bisa jauh lebih besar atau sedikit lebih kecil.

Proyek ini kita bawa ke bab berikutnya: kode yang sudah cepat sebaiknya dikemas rapi menjadi paket.

Latihan

  1. Jalankan 01_cprofile.py dengan 6000 pohon, bukan 3000. Perkirakan lebih dulu: berapa panggilan luas_dasar? Cocokkan dengan keluaran.
  2. Ubah 04_struktur_data.py agar mencari kode "P00000" (paling depan). Apakah list masih jauh lebih lambat? Jelaskan dugaan Anda.
  3. Tambahkan @lru_cache(maxsize=2) pada tarif_per_jenis di 06_cache.py dan perhatikan perubahan misses. Mengapa bisa bertambah?
  4. Tantangan: ubah 08_proyek_percepat.py agar juga menghitung rata-rata diameter per plot dan jenis dengan np.bincount (petunjuk: bagi jumlah diameter dengan banyaknya pohon per kelompok), lalu cocokkan dengan versi lambat.

Ringkasan Bab

  • Ukur dulu, lalu perbaiki satu hal, lalu ukur lagi. Menebak titik lambat sering salah.
  • cProfile menunjukkan fungsi mana yang berat; line_profiler menunjukkan barisnya; memory_profiler mengukur memori.
  • Waktu tidak stabil antar komputer. Andalkan jumlah panggilan dan perbandingan, bukan angka detik tunggal.
  • Pilih struktur data yang tepat: set dan dict untuk pencarian, list untuk urutan.
  • Vektorisasi dengan numpy menggantikan for atas jutaan angka.
  • lru_cache menyimpan hasil fungsi murni; cache_info() menghitung kena dan meleset.

Selanjutnya, di Bab 45: pengemasan, mengubah folder kode menjadi paket Python yang rapi dan bisa dipasang orang lain.