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:

StatistikArtiPeran dalam test
minbatas bawah teoretispembanding “seberapa jauh dari ideal”
mediannilai tengahgambaran umum yang tahan lonjakan
p9595% run lebih cepat dari angka iniambang yang paling sering dipakai
maksrun terburukpengungkap 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.

python
"""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:

  1. Baris 12-23 (_baris_mentah): data dibangun sekali di level modul. Setup bukan bagian yang diukur — mencampurnya ke dalam pengukuran adalah galat klasik benchmark.
  2. 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.
  3. 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.

python
"""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:

python
"""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)
bash
python3 catat_baseline.py

Output yang Dihasilkan:

text
baseline tersimpan: {'min': 45.79, 'median': 47.06, 'p95': 55.85, 'maks': 58.87}
json
{"p95_ms": 55.85}
bash
python3 -m pytest test_performance.py -q

Output yang Dihasilkan:

text
..                                                                       [100%]
2 passed in 2.67s

Ambang 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:

bash
AMBANG_MS=5 python3 -m pytest test_performance.py -q

Output yang Dihasilkan:

text
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.34s

Jebakan Pengukuran #

JebakanGejalaPenangkal
Pemanasanrun pertama paling lambatbuang beberapa run awal
Lonjakan GC/alokasimaks melonjak sesekaliuji p95, bukan rerata
Runner bersamahasil naik-turun antar runmargin 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.