Columnar storage menekan jumlah byte yang dibaca dari disk. Tetapi begitu data berada di memori, masih ada satu keputusan desain yang menentukan kecepatan: bagaimana engine menghitung — satu baris dalam satu waktu, atau ribuan baris dalam satu waktu? Database klasik memakai model iterator yang memproses baris demi baris; engine analitik modern memproses data per vektor. Perbedaan ini menjelaskan sebagian besar celah performa antara sistem lama dan baru, jadi kita bedah dulu konsepnya, lalu mengukurnya sendiri pada satu juta pesanan TokoKita.

Model Iterator versus Model Vectorized #

Eksekusi query tersusun dari operator: scan, filter, proyeksi, agregasi. Model iterator klasik (sering disebut model Volcano) menyusun operator sebagai pipeline: operator filter memanggil operator scan satu kali untuk satu baris, memeriksa baris itu, lalu mengembalikan satu baris ke operator di atasnya. Setiap baris menularis satu rangkaian panggilan fungsi.

Model vectorized memindah satuan kerja dari satu baris menjadi satu batch — di DuckDB berukuran 2.048 baris per vektor. Operator filter menerima satu vektor penuh, membandingkan seluruh kolom nilai dalam satu operasi array, lalu meneruskan vektor yang lolos. Biaya overhead per baris dibagi 2.048, prosesor bekerja pada data yang berdampingan di cache, dan instruksi SIMD dapat dipakai.

AspekRow-at-a-timeVectorized
Satuan kerja1 baris1 vektor (±2.048 baris)
Panggilan fungsi per juta barisjutaanratusan
Lokalitas cacheburuk (data tersebar)baik (data berdampingan)
Cocok untukOLTP (hasil kecil)OLAP (agregasi besar)

Benchmark: Satu Juta Baris, Tiga Gaya Eksekusi #

Pertanyaannya satu: berapa total nilai pesanan Jakarta? Data yang identik dihitung tiga cara — Python loop (wakil row-at-a-time), NumPy (wakil operasi array per batch), dan DuckDB (engine vectorized penuh dengan SQL):

python
"""Ch3: Vectorized query execution — baris-per-baris vs per-batch.

Satu juta pesanan TokoKita, satu pertanyaan analitik:
total nilai pesanan Jakarta. Tiga gaya eksekusi pada data yang sama:
1. Python loop row-at-a-time (model iterator klasik)
2. NumPy (operasi array per-batch, satu instruksi untuk banyak nilai)
3. DuckDB (engine analitik vectorized, vektor default 2048 baris)
"""
import time

import duckdb
import numpy as np
import pyarrow as pa

N = 1_000_000
KOTA = np.array(["Jakarta", "Bandung", "Surabaya", "Medan", "Denpasar"])

# --- data identik untuk ketiga gaya ---
rng = np.random.default_rng(42)
kode = rng.integers(0, 5, N)              # indeks kota per pesanan
nilai = 15000.0 + rng.integers(0, 500, N) * 250.0
baris = list(zip(KOTA[kode].tolist(), nilai.tolist()))
print(f"baris pesanan: {N:,}")

# --- 1. Python loop: satu baris satu kali kerja interpreter ---
t0 = time.perf_counter()
total_loop = 0.0
jumlah_loop = 0
for kota, harga in baris:
    if kota == "Jakarta":
        total_loop += harga
        jumlah_loop += 1
durasi_loop = time.perf_counter() - t0
print(f"Python loop   : {durasi_loop:.3f} detik  ({durasi_loop/N*1e9:.0f} ns/baris)")

# --- 2. NumPy: mask + sum sebagai operasi array (batch) ---
t0 = time.perf_counter()
mask = kode == 0
total_np = float(nilai[mask].sum())
jumlah_np = int(mask.sum())
durasi_np = time.perf_counter() - t0
print(f"NumPy batch   : {durasi_np:.3f} detik  ({durasi_np/N*1e9:.0f} ns/baris)")

# --- 3. DuckDB: engine vectorized di atas data yang sama ---
tabel = pa.table({
    "kota": KOTA[kode],
    "nilai": nilai,
})
con = duckdb.connect()
con.register("pesanan_view", tabel)
q = ("SELECT sum(nilai) AS total, count(*) AS jumlah "
     "FROM pesanan_view WHERE kota = 'Jakarta'")
con.execute(q).fetchall()  # run pemanasan: parse + rencana eksekusi
t0 = time.perf_counter()
hasil = con.execute(q).fetchone()
durasi_duck = time.perf_counter() - t0
print(f"DuckDB        : {durasi_duck:.3f} detik  ({durasi_duck/N*1e9:.0f} ns/baris)")

print()
print(f"jumlah pesanan Jakarta : {jumlah_loop:,} (loop) == {jumlah_np:,} (NumPy) == {hasil[1]:,} (DuckDB)")
print(f"total nilai            : {total_loop:,.0f} == {total_np:,.0f} == {hasil[0]:,.0f}")
print(f"loop vs NumPy          : {durasi_loop/durasi_np:,.0f}x")
print(f"loop vs DuckDB         : {durasi_loop/durasi_duck:,.0f}x")

Output yang Dihasilkan:

text
baris pesanan: 1,000,000
Python loop   : 0.100 detik  (100 ns/baris)
NumPy batch   : 0.006 detik  (6 ns/baris)
DuckDB        : 0.015 detik  (15 ns/baris)

jumlah pesanan Jakarta : 200,282 (loop) == 200,282 (NumPy) == 200,282 (DuckDB)
total nilai            : 15,523,756,750 == 15,523,756,750 == 15,523,756,750
loop vs NumPy          : 16x
loop vs DuckDB         : 7x

Mari kita telaah baris-baris kode di atas:

  1. Baris 18-22 (persiapan data): generator NumPy dengan seed tetap memastikan ketiga gaya menerima baris yang persis sama; baris mengubahnya menjadi list of tuple — bentuk yang paling nyaman bagi loop Python.
  2. Baris 25-34 (loop row-at-a-time): untuk tiap baris, interpreter mengevaluasi perbandingan string, branching, dan penjumlahan — semuanya satu per satu selama satu juta kali.
  3. Baris 36-42 (NumPy batch): kode == 0 membandingkan seluruh array dalam satu operasi, nilai[mask].sum() menjumlahkan yang lolos — dua operasi array untuk satu juta baris.
  4. Baris 44-57 (DuckDB): data dibungkus pa.table lalu didaftarkan sebagai view; setelah satu run pemanasan (agar parsing SQL dan penyusunan rencana tidak ikut terhitung), agregasi diukur pada run kedua.

Membaca Hasil Benchmark #

Ketiga gaya menghasilkan angka yang identik, jadi perbandingannya adil. Loop Python membayar sekitar 100 nanodetik per baris — itu adalah harga interpretasi bytecode, pengecekan tipe, dan lompatan cabang untuk setiap baris. NumPy memangkasnya menjadi 6 nanodetik per baris karena perulangan terjadi di kode C terkompilasi yang memproses data berdampingan. DuckDB berada di antara keduanya (15 ns/baris) dan tetap 7 kali lebih cepat dari loop murni — dan itu pun sudah termasuk eksekusi SQL penuh, konversi tipe, serta penyalinan hasil ke Python.

Perlu jujur di sini: NumPy lebih cepat daripada DuckDB pada eksperimen ini karena data sudah tersedia di memori sebagai array siap pakai. Keunggulan DuckDB muncul ketika data harus dibaca dari berkas Parquet di disk, dijahit dengan JOIN, atau dikelompokkan dengan GROUP BY — pekerjaan yang tidak dikerjakan NumPy. Meski begitu, urutan finishnya konsisten dengan teori: kedua gaya batch mengalahkan row-at-a-time dengan jarak meyakinkan. Kita lihat nanti di chapter Distributed Query Processing bahwa vektor juga menjadi satuan pembagian kerja antar thread.

Eksekusi vectorized adalah mesin di balik semua engine yang akan kita temui di section ini. Yang pertama dan paling mudah dijangkau adalah DuckDB — engine analitik yang berjalan di dalam proses Python kita sendiri, pembahasan chapter berikutnya.