Pipeline TokoKita yang benar bisa saja terlalu lambat untuk SLA laporan pagi. Chapter Pipeline Performance memprofil satu pipeline untuk menemukan bottleneck — itu pekerjaan investigasi. Performance testing adalah disiplin yang berbeda: performa dijadikan test yang berjalan berulang, dengan angka, ambang, dan vonis lulus-gagal yang otomatis. Seperti regression test mengunci perilaku, performance test mengunci anggaran waktu; pelanggarannya terlihat sebagai test merah, bukan sebagai komplain pengguna yang paling dulu mengetahuinya.
Statistik yang Layak Diuji #
Satu kali mengukur tidak layak diuji: hasilnya bercampur pemanasan interpreter, alokasi memori, dan beban mesin yang sesaat. Pengukuran yang layak diuji dijalankan berulang, lalu diringkas:
| Statistik | Arti | Peran dalam test |
|---|---|---|
| min | batas bawah teoretis | pembanding “seberapa jauh dari ideal” |
| median | nilai tengah | gambaran umum yang tahan lonjakan |
| p95 | 95% run lebih cepat dari angka ini | ambang yang paling sering dipakai |
| maks | run terburuk | pengungkap lonjakan sesekali |
Kita menguji p95, bukan rerata: rerata membiarkan 5% run terburuk bersembunyi di balik mayoritas yang cepat — padahal justru run terburuk itulah yang dirasakan pemakai.
Benchmark sebagai Kode #
Beban kerjanya pipeline mini yang telah kita kenal: parse 20 ribu baris JSON menjadi DataFrame, lalu agregat per kota. Data mentah dibangun sekali di awal agar tidak ikut terukur.
"""Beban kerja pipeline mini + harness pengukuran berulang."""
import json
import random
import statistics
import time
import pandas as pd
KOTA = ["Jakarta", "Bandung", "Surabaya", "Medan"]
def _baris_mentah() -> str:
"""Bangun 20 ribu baris JSONL sekali di awal (bukan bagian yang diukur)."""
random.seed(7)
baris = [
json.dumps({"order_id": i + 1, "kota": random.choice(KOTA),
"total": random.randint(25000, 500000)})
for i in range(20000)
]
return "\n".join(baris)
MENTAH = _baris_mentah()
def jalankan_sekali() -> pd.DataFrame:
"""Satu iterasi beban kerja: parse JSON -> DataFrame -> agregat per kota."""
data = [json.loads(b) for b in MENTAH.splitlines()]
df = pd.DataFrame(data)
return df.groupby("kota", as_index=False).agg(
jumlah=("order_id", "size"), total=("total", "sum"))
def ukur(percobaan: int = 25, buang_awal: int = 3) -> dict:
"""Ukur berulang; run pemanasan dibuang, laporan min/median/p95/maks."""
for _ in range(buang_awal):
jalankan_sekali()
waktu_ms = []
for _ in range(percobaan):
awal = time.perf_counter()
jalankan_sekali()
waktu_ms.append((time.perf_counter() - awal) * 1000)
return {
"min": round(min(waktu_ms), 2),
"median": round(statistics.median(waktu_ms), 2),
"p95": round(statistics.quantiles(waktu_ms, n=20)[18], 2),
"maks": round(max(waktu_ms), 2),
}Mari kita telaah baris-baris kode di atas:
- Baris 12-23 (
_baris_mentah): data dibangun sekali di level modul. Setup bukan bagian yang diukur — mencampurnya ke dalam pengukuran adalah galat klasik benchmark. - Baris 34-37 (
ukur, pemanasan): tiga run pertama dibuang, analogi JVM warmup yang kita kenal dari ekosistem Spark — run pertama selalu paling lambat karena cache dan import masih dingin. - Baris 39-48 (statistik):
statistics.quantiles(..., n=20)[18]adalah kuintil ke-19 dari 20 — cara stdlib menyebut p95 tanpa pustaka luar.
Dua Gerbang: Ambang Mutlak dan Baseline #
Test berikut memasang dua gerbang sekaligus: ambang mutlak AMBANG_MS (default 250 ms) dan gerbang baseline — p95 hari ini tidak boleh lebih dari 1,5 kali p95 tercatat. Ambang hidup di variabel lingkungan supaya bisa disetel tanpa mengubah kode.
"""Performance test sebagai kode: ambang p95 dan gerbang baseline."""
import json
import os
import pytest
from ukur import ukur
AMBANG_MS = float(os.environ.get("AMBANG_MS", "250"))
@pytest.fixture(scope="module")
def hasil_ukur():
"""Ukur sekali untuk seluruh modul; hasil dipakai dua test di bawah."""
return ukur()
def test_p95_di_bawah_ambang(hasil_ukur):
assert hasil_ukur["p95"] < AMBANG_MS, (
f"p95 {hasil_ukur['p95']} ms melebihi ambang {AMBANG_MS} ms")
def test_tidak_memburuk_dari_baseline(hasil_ukur):
with open("baseline.json", encoding="utf-8") as f:
baseline = json.load(f)
batas = baseline["p95_ms"] * 1.5
assert hasil_ukur["p95"] <= batas, (
f"p95 {hasil_ukur['p95']} ms melebihi batas baseline "
f"{baseline['p95_ms']} ms x 1.5")Baseline dicatat sekali oleh skrip kecil berikut, lalu masuk ke repositori:
"""Catat hasil ukur terkini sebagai berkas baseline."""
import json
from ukur import ukur
hasil = ukur()
with open("baseline.json", "w", encoding="utf-8") as f:
json.dump({"p95_ms": hasil["p95"]}, f)
print("baseline tersimpan:", hasil)python3 catat_baseline.pyOutput yang Dihasilkan:
baseline tersimpan: {'min': 45.79, 'median': 47.06, 'p95': 55.85, 'maks': 58.87}{"p95_ms": 55.85}python3 -m pytest test_performance.py -qOutput yang Dihasilkan:
.. [100%]
2 passed in 2.67sAmbang 250 ms dipilih setelah p95 terukur sekitar 56 ms — sekitar empat kali lipat ruang lega. Sekarang kita perlihatkan bentuk kegagalan yang sesungguhnya: ambang yang mustahil, 5 ms:
AMBANG_MS=5 python3 -m pytest test_performance.py -qOutput yang Dihasilkan:
F. [100%]
=================================== FAILURES ===================================
___________________________ test_p95_di_bawah_ambang ___________________________
hasil_ukur = {'maks': 76.5, 'median': 69.6, 'min': 46.97, 'p95': 76.48}
def test_p95_di_bawah_ambang(hasil_ukur):
> assert hasil_ukur["p95"] < AMBANG_MS, (
f"p95 {hasil_ukur['p95']} ms melebihi ambang {AMBANG_MS} ms")
E AssertionError: p95 76.48 ms melebihi ambang 5.0 ms
E assert 76.48 < 5.0
test_performance.py:19: AssertionError
=========================== short test summary info ============================
FAILED test_performance.py::test_p95_di_bawah_ambang - AssertionError: p95 76...
1 failed, 1 passed in 3.34sJebakan Pengukuran #
| Jebakan | Gejala | Penangkal |
|---|---|---|
| Pemanasan | run pertama paling lambat | buang beberapa run awal |
| Lonjakan GC/alokasi | maks melonjak sesekali | uji p95, bukan rerata |
| Runner bersama | hasil naik-turun antar run | margin lega; bandingkan tren |
Baris hasil_ukur pada output di atas adalah pengungkapan yang perlu dibaca teliti: pada run pertama p95 tercatat 55,85 ms, pada run berikutnya 76,48 ms — di mesin yang sama, tanpa perubahan kode. Variansi sebesar itu lazim pada runner bersama 2 CPU seperti lingkungan ini; ambang 250 ms tetap hijau, gerbang baseline 1,5x pun masih lolos — 76,48 melebihi 55,85 tapi di bawah 83,8. Ambang yang disetel 10% di atas pengukuran terakhir akan berkedip merah-hijau, dan test yang berkedip pada akhirnya tidak dibaca lagi orang.
Untuk proyek nyata, pustaka pytest-benchmark mengotomatkan pola ini: penyimpanan hasil, perbandingan antar run, dan laporan siap baca. Anggaran waktu kini teruji; seluruh gerbang — unit, contract, regression, performance — tinggal dirangkai menjadi satu pintu. Chapter Continuous Integration menyusun pintu itu.