Chapter LoRA dan QLoRA menyinggung fakta yang tak terhindarkan: model 7B dalam fp16 memakan 14 GB memori hanya untuk bobotnya, dan itu baru satu model — belum termasuk aktivasi, KV cache, dan sistem operasi. Kuantisasi menjawab dengan pertukaran yang terukur: meluruskan presisi tiap bobot dari 16 bit ke 8 atau 4 bit, memori terpangkas separuh atau tiga perempat, sementara kualitas keluaran turun dalam batas yang bisa diterima. Bab ini membedah mekanismenya — skala per grup, grid NF4, posisi dekuantisasi dalam perhitungan — lalu membuktikan seluruh klaimnya dengan kuantisasi NumPy murni yang bisa Anda jalankan dan audit.

Aritmetika Memori #

Ukuran bobot mengikuti perkalian yang sederhana: jumlah parameter kali bit per parameter dibagi delapan. Untuk model 7B:

PresisiBitUkuran bobotPenyusut
fp323228,0 GB1,0×
fp16/bf161614,0 GB2×
int887,0 GB4×
int443,5 GB8×

Angka-angka ini dihitung ulang secara nyata pada demo di bawah, bukan dikutip dari hafalan. Skala praktisnya: GPU 8 GB yang tidak muat memuat model 7B fp16 mendadak cukup untuk int4 — dengan sisa memori untuk KV cache dan aktivasi.

Kuantisasi Pasca-Training dengan Skala per Grup #

Kuantisasi pasca-training (post-training quantization, PTQ) mengubah bobot model yang sudah terlatih tanpa melatih ulang. Format kerjanya simetris: satu grup bobot berbagi satu konstanta skala s, nilai integer disimpan sebagai q = round(w/s), dan dekuantisasi mengembalikan ŵ ≈ q·s. Dua keputusan menentukan mutunya:

  • Lebar bit: int8 punya 255 level sehingga pembulatannya nyaris tidak terasa; int4 hanya 15 level, sehingga pemilihan skala menjadi krusial.
  • Ukuran grup: skala yang dihitung per baris penuh (mis. 128 elemen) murah tapi kena outlier — satu nilai ekstrem melebarkan rentang dan menggerus resolusi grup. Grup kecil (64 elemen lazimnya) menyesuaikan skala secara lokal dengan biaya simpan skala tambahan.

Varian penting lainnya adalah grid non-seragam. Bobot jaringan terdistribusi mendekati gaussian dengan puncak di nol, sedangkan grid int4 seragam membentang dari nilai minimum sampai maksimum — akibatnya level-levelnya terkonsentrasi di ekor yang jarang dihuni dan terlalu renggang di tengah yang padat. NF4 (NormalFloat 4-bit) yang dipakai QLoRA menempatkan level pada kuantil distribusi normal: rapat di sekitar nol, renggang di ekor.

Demo: Kuantisasi NumPy Murni #

Berkas kuantisasi_mini.py menjalankan tiga pengujian: aritmetika ukuran, kuantisasi per grup pada matriks float32 kecil dengan tiga konfigurasi, dan perbandingan grid seragam versus grid kuantil pada 200.000 bobot gaussian:

python
"""Kuantisasi bobot LLM: hitungan ukuran, int8/int4 per grup, dan grid NF4.

Bagian 1: aritmetika ukuran model 7B pada empat presisi.
Bagian 2: kuantisasi simetris per grup pada matriks float32 kecil —
          int8 per baris, int4 per baris, int4 per grup 4 elemen.
Bagian 3: grid seragam vs grid kuantil normal (konsep NF4) pada
          200.000 bobot berdistribusi gaussian.
"""

import numpy as np

rng = np.random.default_rng(0)

# ---------- 1. Aritmetika ukuran model 7B ----------
print("== 1. ukuran bobot model 7B parameter ==")
n_param = 7_000_000_000
for nama, bit in (("fp32", 32), ("fp16", 16), ("int8", 8), ("int4", 4)):
    gb = n_param * bit / 8 / 1e9
    print(f"  {nama:5s} {bit:2d} bit -> {gb:5.1f} GB")

# ---------- 2. Kuantisasi simetris per grup ----------
W = rng.normal(0, 1.0, size=(4, 8)).astype(np.float32)
print()
print("== 2. kuantisasi per grup pada matriks 4x8 ==")
print("matriks float32 (baris 0):")
print("  " + "  ".join(f"{v:+.3f}" for v in W[0]))


def kuant_per_grup(M, bit, ukuran_grup):
    """Kuantisasi simetris lalu dekuantisasi; skala dihitung per grup."""
    lvl = 2 ** (bit - 1) - 1              # 127 untuk int8, 7 untuk int4
    hasil = np.zeros_like(M)
    for i in range(M.shape[0]):
        for j in range(0, M.shape[1], ukuran_grup):
            g = M[i, j:j + ukuran_grup]
            s = np.abs(g).max() / lvl     # skala grup
            q = np.clip(np.round(g / s), -lvl, lvl)
            hasil[i, j:j + ukuran_grup] = q * s
    return hasil


def error_relatif(asli, kembali):
    return np.linalg.norm(asli - kembali) / np.linalg.norm(asli)


kasus = [("int8  skala per baris (grup 8)", 8, 8),
         ("int4  skala per baris (grup 8)", 4, 8),
         ("int4  skala per grup 4 elemen", 4, 4)]
for nama, bit, grup in kasus:
    Wk = kuant_per_grup(W, bit, grup)
    print(f"  {nama:30s} -> error relatif {error_relatif(W, Wk):6.3%}")

Wq = kuant_per_grup(W, 8, 8)
print("baris 0 hasil dekuantisasi int8:")
print("  " + "  ".join(f"{v:+.3f}" for v in Wq[0]))

# ---------- 3. Grid seragam vs grid kuantil normal ----------
bobot = rng.normal(0, 1.0, size=200_000)
lvl_seragam = np.arange(-7, 8) * (np.abs(bobot).max() / 7)   # int4 seragam
lvl_nf4 = np.quantile(bobot, (2 * np.arange(16) + 1) / 32)   # kuantil = NF4


def kuant_grid(data, levels):
    """Kuantisasi nearest-neighbor: tiap nilai dibulatkan ke level terdekat."""
    batas = (levels[:-1] + levels[1:]) / 2
    idx = np.clip(np.searchsorted(batas, data, side="right"),
                  0, len(levels) - 1)
    return levels[idx]


rmse = lambda x: float(np.sqrt(np.mean((bobot - x) ** 2)))
e_seragam = rmse(kuant_grid(bobot, lvl_seragam))
e_nf4 = rmse(kuant_grid(bobot, lvl_nf4))
print()
print("== 3. grid 4-bit untuk 200.000 bobot gaussian ==")
print(f"  grid seragam (rentang penuh) : RMSE {e_seragam:.4f}")
print(f"  grid kuantil normal          : RMSE {e_nf4:.4f}  (konsep NF4)")
print(f"  perbaikan                    : {100 * (1 - e_nf4 / e_seragam):.1f}%")
print("  level NF4 di sekitar nol (rapat), di ekor (renggang):")
print("    " + ", ".join(f"{b:+.2f}" for b in lvl_nf4[5:11]))
print("    " + ", ".join(f"{b:+.2f}" for b in lvl_nf4[:3]) + ", ...")

Output yang Dihasilkan:

text
== 1. ukuran bobot model 7B parameter ==
  fp32  32 bit ->  28.0 GB
  fp16  16 bit ->  14.0 GB
  int8   8 bit ->   7.0 GB
  int4   4 bit ->   3.5 GB

== 2. kuantisasi per grup pada matriks 4x8 ==
matriks float32 (baris 0):
  +0.126  -0.132  +0.640  +0.105  -0.536  +0.362  +1.304  +0.947
  int8  skala per baris (grup 8) -> error relatif 0.342%
  int4  skala per baris (grup 8) -> error relatif 6.721%
  int4  skala per grup 4 elemen  -> error relatif 5.619%
baris 0 hasil dekuantisasi int8:
  +0.123  -0.133  +0.637  +0.103  -0.534  +0.359  +1.304  +0.945

== 3. grid 4-bit untuk 200.000 bobot gaussian ==
  grid seragam (rentang penuh) : RMSE 0.1952
  grid kuantil normal          : RMSE 0.1535  (konsep NF4)
  perbaikan                    : 21.4%
  level NF4 di sekitar nol (rapat), di ekor (renggang):
    -0.40, -0.24, -0.08, +0.08, +0.24, +0.40
    -1.86, -1.32, -1.01, ...

Mari kita telaah kode di atas baris per baris:

  1. Baris 16-20 (aritmetika ukuran): n_param * bit / 8 / 1e9 — rumus satu baris yang menghasilkan seluruh tabel presisi di atas; setiap angka lahir dari perhitungan, bukan dikutip dari luar.
  2. Baris 25-42 (kuant_per_grup): inti PTQ simetris. Baris 30 menetapkan jumlah level (127 untuk int8, 7 untuk int4); baris 35 menghitung skala grup dari nilai mutlak terbesar; baris 36 membulatkan ke integer lalu memotong ke rentang level; baris 37 mengalikan kembali skala — persis siklus q = round(w/s) dan ŵ = q·s.
  3. Baris 48-53 (tiga konfigurasi): int8 per baris hanya menimbulkan error relatif 0,342% — nyaris bebas. Turun ke int4 dengan grup yang sama melemparkan error ke 6,7%; memecah baris menjadi grup 4 elemen menurunkannya ke 5,6% karena tiap grup kecil memperoleh skala yang pas dengan rentang lokalnya. Di sinilah letak manfaat skala per grup.
  4. Baris 59-60 (dua grid): grid seragam membentang sampai nilai mutlak terbesar dari 200.000 sampel (baris 59); grid NF4 menempatkan 16 level pada kuantil (2i+1)/32 (baris 60) — cara yang sama seperti bitsandbytes menurunkan level NF4 dari kuantil distribusi normal.
  5. Baris 63-68 (kuant_grid): kuantisasi nearest-neighbor — batas antar level adalah titik tengahnya, searchsorted menemukan level terdekat untuk tiap bobot.
  6. Baris 71-81 (hasil): grid kuantil mengurangi RMSE 21,4% dibanding grid seragam pada data yang sama — dan daftar levelnya memperlihatkan alasannya: jarak antar level di sekitar nol hanya 0,16, sedangkan di ekor mencapai 0,5 lebih. Bobot gaussian memang menumpuk di tengah, jadi level yang rapat di tengah menampung lebih banyak bobot dengan pembulatan halus.

Weight-Only: GEMM, GEMV, dan Posisi Dekuantisasi #

Kuantisasi yang kita bahas bersifat weight-only: bobot disimpan 4-bit, tetapi aktivasi dan keluaran tetap fp16/bf16. Saat layer dihitung, grup bobot didekuantisasi tepat sebelum perkalian matriks, lalu hasilnya dibuang — memori besar yang dihemat, akurasi aritmetika tetap tinggi. Efeknya paling terasa pada dua pola perhitungan:

  • GEMV (decode, satu token): setiap langkah decoding mengalikan satu vektor aktivasi dengan seluruh matriks bobot — operasi ini dibatasi kecepatan memori (memory-bound), sehingga bobot 4-bit yang kecil langsung mempercepat pemuatan data.
  • GEMM (prefill/pelatihan, batch matriks): komputasinya padat sehingga gain kecepatan lebih kecil; manfaat utamanya tetap pada hemat memori.

Sisi lainnya adalah QAT (quantization-aware training): operasi kuantisasi-dekuantisasi disisipkan ke dalam pelatihan (dengan pseudo-quantization pada forward pass) sehingga model belajar menjadi tangguh terhadap pembulatan. QAT lebih mahal tetapi menjadi pilihan saat PTQ menurunkan kualitas melebihi anggaran yang diizinkan — misalnya pada model kecil atau tugas yang ketat.

PendekatanKapan dipakaiBiayaKualitas
PTQ int8model besar, toleransi kecilmenit, tanpa data latihpenurunan nyaris nol
PTQ int4/NF4perangkat memori kecilmenit, kalibrasi opsionalpenurunan kecil-menengah
QATmodel kecil, batas ketatpelatihan penuh ulangterjaga paling baik

Di ekosistem Hugging Face, seluruh jalur ini tersedia siap pakai: bitsandbytes menyediakan load_in_8bit/load_in_4bit dengan tipe NF4 dan double quantization di dokumentasinya , sementara PyTorch membungkus PTQ dan QAT di panduan kuantisasi resminya .

Bobot yang hemat memori hanyalah separuh persamaan inferensi. Separuh lainnya menentukan token apa yang dipilih dari distribusi probabilitas — greedy, sampling bertemperatur, top-k, top-p, hingga beam search — dan bagaimana KV cache membuat generasi panjang tetap terjangkau. Semuanya menunggu di bab berikutnya: Inference dan Decoding .