Sebagian besar waktu yang kita habiskan untuk optimasi sebenarnya terbuang karena kita menebak bagian mana yang lambat. Kamu ganti loop jadi list comprehension di satu tempat, padahal bottleneck aslinya ada di fungsi lain yang cuma dipanggil tiga kali. Profiling menghilangkan tebakan itu. Di sini saya tunjukkan cara menemukan bagian lambat pada kode Python menggunakan cProfile, line_profiler, dan memory_profiler, lalu mengoptimalkannya berdasarkan angka hasil pengukuran.

Mulai dari Data, Bukan Firasat

Aturannya sederhana: ukur dulu, baru optimasi. Tanpa angka kamu tidak tahu apakah perubahanmu membuat kode lebih cepat atau justru menambah kompleksitas tanpa manfaat. Tiga alat yang saya bahas punya peran berbeda. cProfile memberi gambaran menyeluruh soal fungsi mana yang paling banyak menghabiskan waktu. line_profiler memerinci waktu per baris di dalam satu fungsi. memory_profiler melacak berapa banyak memori yang terpakai. Ketiganya gratis dan bisa dipasang dalam hitungan detik.

Siapkan Contoh Kode

Kita butuh kode yang cukup realistis untuk diprofil. Simpan skrip berikut sebagai laporan.py. Skrip ini membuat 200 ribu transaksi dummy, menghitung total per kategori, memfilter transaksi besar, lalu membangun string laporan. Ada dua jebakan performa di dalamnya yang sengaja saya tanam.

python
import random
import time

random.seed(42)


def generate_transactions(n):
    """Buat data dummy: daftar transaksi dengan kategori dan nominal."""
    categories = ["makanan", "transport", "hiburan", "belanja", "kesehatan"]
    return [
        {"id": i, "category": random.choice(categories), "amount": random.randint(1000, 500000)}
        for i in range(n)
    ]


def slow_total_per_category(transactions):
    """Hitung total nominal per kategori."""
    totals = {}
    for txn in transactions:
        cat = txn["category"]
        if cat not in totals:
            totals[cat] = 0
        totals[cat] += txn["amount"]
    return totals


def slow_filter_big(transactions):
    """Ambil transaksi di atas ambang nominal."""
    result = []
    for txn in transactions:
        if txn["amount"] > 250000:
            result.append(txn)
    return result


def expensive_string_build(transactions):
    """Bangun string laporan - penggabungan string yang mahal (O(n^2))."""
    report = ""
    for txn in transactions[:5000]:
        report = report + f"txn-{txn['id']}:{txn['category']}\n"
    return report


def main():
    transactions = generate_transactions(200_000)

    t0 = time.perf_counter()
    totals = slow_total_per_category(transactions)
    t1 = time.perf_counter()

    big = slow_filter_big(transactions)
    t2 = time.perf_counter()

    report = expensive_string_build(transactions)
    t3 = time.perf_counter()

    print(f"total_per_category : {t1 - t0:.4f}s")
    print(f"filter_big         : {t2 - t1:.4f}s")
    print(f"string_build       : {t3 - t2:.4f}s")
    print(f"kategori={len(totals)} transaksi_besar={len(big)}")


if __name__ == "__main__":
    main()

Profiling Menyeluruh dengan cProfile

cProfile sudah ikut dalam instalasi standar Python, jadi tidak perlu pasang apa-apa. Jalankan skrip lewat modul cProfile dan urutkan hasilnya berdasarkan waktu kumulatif (cumtime):

bash
python -m cProfile -s cumtime laporan.py

Outputnya panjang, tapi fokus ke tabel di bagian bawah. Kolom ncalls menunjukkan jumlah panggilan, tottime waktu murni yang dihabiskan di dalam fungsi itu sendiri, dan cumtime waktu total termasuk fungsi-fungsi yang dipanggil di dalamnya. Lihat expensive_string_build: setiap iterasi membuat string baru yang makin panjang, jadi operasinya O(n²) dan langsung melompat ke urutan atas tabel.

Urutkan dengan -s tottime kalau kamu curiga ada satu fungsi berat yang tidak memanggil fungsi lain. Urutkan dengan -s cumtime untuk melihat rantai panggilan dari atas ke bawah. Saya biasanya mulai dari cumtime.

Rincian Per Baris dengan line_profiler

cProfile memberi tahu fungsi mana yang lambat, tapi tidak sampai ke barisnya. line_profiler menutup celah itu. Pasang dulu, lalu tambahkan dekorator @profile ke fungsi yang mau kamu periksa.

bash
pip install line_profiler
python
@profile
def expensive_string_build(transactions):
    report = ""
    for txn in transactions[:5000]:
        report = report + f"txn-{txn['id']}:{txn['category']}\n"
    return report
bash
kernprof -l -v laporan.py

Dekorator @profile tidak perlu diimpor — line_profiler menyuntikkannya saat runtime lewat perintah kernprof. Hasilnya tabel per baris: kolom Time menunjukkan total waktu di baris itu, Hits jumlah eksekusi, dan % Time persentasenya. Di fungsi ini kamu akan melihat satu baris yang menelan hampir seluruh waktu: report = report + ....

Melacak Memori dengan memory_profiler

Beberapa program tidak lambat karena CPU, tapi karena terus mengalokasikan memori. memory_profiler menunjukkan pertumbuhan penggunaan memori per baris. Pasang dan beri dekorator @profile juga:

bash
pip install memory_profiler
python
@profile
def generate_transactions(n):
    categories = ["makanan", "transport", "hiburan", "belanja", "kesehatan"]
    return [
        {"id": i, "category": random.choice(categories), "amount": random.randint(1000, 500000)}
        for i in range(n)
    ]
bash
python -m memory_profiler laporan.py

Outputnya menampilkan kolom Mem usage dan Increment. Increment bernilai positif besar pada baris tertentu berarti baris itulah yang paling banyak menambah pemakaian memori. Untuk data berukuran besar, mengganti struktur list-of-dicts dengan tipe yang lebih ringkas atau generator sering kali menurunkan jejak memori drastis.

Optimasi Setelah Tahu Bottleneck

Sekarang kita tahu dua titik masalahnya: penggabungan string O(n²) dan loop manual untuk menghitung total. Perbaikan pertama mengganti report = report + ... dengan "".join(...). Perbaikan kedua mengganti loop manual dengan Counter bawaan. Versi yang sudah dioptimalkan terlihat begini:

python
from collections import Counter


def fast_total_per_category(transactions):
    return Counter(txn["category"] for txn in transactions)


def fast_filter_big(transactions):
    return [txn for txn in transactions if txn["amount"] > 250000]


def fast_string_build(transactions):
    lines = [f"txn-{txn['id']}:{txn['category']}" for txn in transactions[:5000]]
    return "\n".join(lines)

Kalau setelah semua ini fungsimu masih lambat dan sifatnya CPU-bound, arahkan ke eksekusi paralel lewat threading dan multiprocessing. Kalau lambatnya karena menunggu I/O seperti request HTTP atau baca file, asyncio jauh lebih pas daripada paralelisme. Dua-duanya baru efektif setelah kamu yakin jenis bottleneck-nya.

Ukur lagi setelah optimasi. Pada contoh ini, "".join memangkas waktu expensive_string_build dari sekitar setengah detik menjadi beberapa milidetik. Angka pastinya bergantung mesinmu — yang penting polanya: ukur, ubah, ukur lagi.

Kapan Profiling Layak Dilakukan

Jangan profiling untuk skrip sekali pakai berumur 50 baris yang selesai dalam 0,2 detik. Profiling paling terasa di jalur panas (hot path): kode yang dieksekusi jutaan kali, endpoint API yang dipanggil terus-menerus, atau batch job yang memproses data besar. Untuk kasus data tabel berukuran besar, pertimbangkan juga mengganti Pandas dengan Polars sebelum menyentuh logika bisnismu. Sering kali penggantian library memberi lompatan kecepatan yang lebih besar daripada micro-optimization.

Mulailah dari satu skrip yang kamu anggap lambat. Jalankan python -m cProfile -s cumtime skripmu.py, temukan fungsi di urutan teratas, lalu gali lebih dalam dengan line_profiler. Optimasi hanya pada bagian yang angka-angkanya bilang perlu dioptimasi.