Dua pengungkit pada chapter sebelumnya menjalankan graph tanpa menyentuh isinya. Quantization dan pruning bergerak lebih jauh: keduanya mengubah representasi bobot itu sendiri — quantization menyimpan angka dalam presisi lebih rendah, pruning membuang bobot yang kontribusinya paling kecil. Imbalannya berupa model yang lebih kecil dan (pada kondisi yang tepat) lebih cepat; harganya berupa selisih output yang harus diukur, bukan diasumsikan. Chapter ini menjalankan dynamic quantization int8 dan magnitude pruning 30% pada model nyata di CPU, lalu membaca hasilnya dengan jujur — termasuk saat penghematan tidak terjadi.

Post-Training Quantization versus Quantization-Aware Training #

Quantization punya dua jalur utama. Post-training quantization (PTQ) mengubah model yang sudah selesai dilatih tanpa training tambahan; quantization-aware training (QAT) menyisipkan simulasi kuantisasi ke dalam proses training agar model belajar bertahan terhadap kehilangan presisi.

AspekPTQ (dynamic)QAT
Kapan dilakukansetelah training, tanpa data lengkapselama training, butuh data dan pipeline
Upayasatu pemanggilan fungsitraining ulang beberapa epoch
Penurunan akurasikecil hingga sedangpaling kecil, terkendali
Cocok untukdeployment cepat, model besar (khas LLM)model kecil yang sensitif presisi

Dynamic quantization pada CPU mengubah bobot layer Linear ke int8 sementara aktivasi tetap dihitung fp32 — bobot dikuantisasi saat model dimuat. Versi LLM dari teknik ini (hingga 4-bit) dibahas khusus di chapter Quantization LLM , sedangkan analogi presisi campurannya saat training ada di Mixed Precision Training .

Praktik: Dynamic Quantization Int8 #

Berkas quantize_dynamic.py melatih MLP empat kelas berukuran menengah, menerapkan quantize_dynamic, lalu membandingkan ukuran, waktu, selisih output, dan akurasi:

python
"""Dynamic quantization: bobot int8 untuk inference CPU.

Alur: latih MLP (32-256-64-4) -> ukur ukuran state_dict fp32 vs int8
-> ukur waktu inference -> bandingkan output fp32 vs int8 -> bandingkan
akurasi pada data uji. Menjalankan skrip ini memunculkan
DeprecationWarning dari torch.ao.quantization pada stderr; API masih
berfungsi di PyTorch 2.14 dan jalur migrasinya adalah torchao.
"""

import io
import time

import torch
import torch.nn as nn
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)
X, y = make_classification(
    n_samples=1024,
    n_features=32,
    n_informative=20,
    n_classes=4,
    random_state=42,
)
X = (X - X.mean(axis=0)) / X.std(axis=0)
X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, y, test_size=0.25, random_state=42
)
loader = DataLoader(
    TensorDataset(
        torch.tensor(X_latih, dtype=torch.float32),
        torch.tensor(y_latih, dtype=torch.long),
    ),
    batch_size=64,
    shuffle=True,
    num_workers=0,
    generator=torch.Generator().manual_seed(42),
)

model = nn.Sequential(
    nn.Linear(32, 256),
    nn.ReLU(),
    nn.Linear(256, 64),
    nn.ReLU(),
    nn.Linear(64, 4),
)
kriteria = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.5)
for _ in range(30):
    for X_batch, y_batch in loader:
        optimizer.zero_grad()
        loss = kriteria(model(X_batch), y_batch)
        loss.backward()
        optimizer.step()
model.eval()

X_uji_tensor = torch.tensor(X_uji, dtype=torch.float32)
y_uji_tensor = torch.tensor(y_uji, dtype=torch.long)

# ---------- 1. Dynamic quantization ----------
model_int8 = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)


def ukuran_state_dict(m):
    """Simpan state_dict ke memori, kembalikan ukuran byte."""
    penampung = io.BytesIO()
    torch.save(m.state_dict(), penampung)
    return penampung.tell()


def waktu_inference(m, jumlah_iterasi=50):
    """Warm-up lalu durasi tercepat satu inference batch 256."""
    batch_besar = X_uji_tensor[:256]
    with torch.no_grad():
        for _ in range(10):
            m(batch_besar)
        terbaik = float("inf")
        for _ in range(3):
            mulai = time.perf_counter()
            for _ in range(jumlah_iterasi):
                m(batch_besar)
            terbaik = min(terbaik, time.perf_counter() - mulai)
    return terbaik / jumlah_iterasi * 1000


def akurasi(m):
    with torch.no_grad():
        prediksi = m(X_uji_tensor).argmax(dim=1)
    return (prediksi == y_uji_tensor).float().mean().item()


with torch.no_grad():
    out_fp32 = model(X_uji_tensor)
    out_int8 = model_int8(X_uji_tensor)

u_fp32, u_int8 = ukuran_state_dict(model), ukuran_state_dict(model_int8)
t_fp32, t_int8 = waktu_inference(model), waktu_inference(model_int8)

print(f"ukuran state_dict fp32 : {u_fp32:8d} byte")
print(f"ukuran state_dict int8 : {u_int8:8d} byte  (rasio {u_fp32 / u_int8:.2f}x)")
print(f"waktu inference fp32   : {t_fp32:8.3f} ms")
print(f"waktu inference int8   : {t_int8:8.3f} ms")
print(f"selisih output maksimum: {(out_fp32 - out_int8).abs().max().item():.4f}")
print(f"akurasi fp32           : {akurasi(model):.4f}")
print(f"akurasi int8           : {akurasi(model_int8):.4f}")
print("catatan: angka waktu bervariasi antar eksekusi.")

Output yang Dihasilkan:

text
ukuran state_dict fp32 :   103317 byte
ukuran state_dict int8 :    30783 byte  (rasio 3.36x)
waktu inference fp32   :    0.097 ms
waktu inference int8   :    0.175 ms
selisih output maksimum: 0.5001
akurasi fp32           : 0.7852
akurasi int8           : 0.7773
catatan: angka waktu bervariasi antar eksekusi.

Mari kita telaah kode di atas baris per baris:

  1. Baris 64 (quantize_dynamic): satu pemanggilan mengganti seluruh layer nn.Linear menjadi versi int8 — inilah seluruh upaya PTQ. Argumen {nn.Linear} memilih jenis layer yang dikuantisasi; bobot disimpan qint8 sementara aktivasi tetap fp32.
  2. Baris 67-72 (ukuran_state_dict): ukuran diukur dengan menyimpan state_dict ke memori (io.BytesIO) — angka yang sama dengan ukuran berkas di disk.
  3. Baris 75-89 (waktu_inference): batch 256, warm-up 10, lalu tercepat dari tiga putaran — metodologi yang sama dengan chapter Optimasi Latensi .
  4. Baris 98-100 (perbandingan output): selisih logits maksimum 0,5 terlihat besar, tetapi perhatikan dua baris terakhir: akurasi hanya bergeser 0,7852 ke 0,7773. Logits yang sedikit bergeser jarang mengubah kelas pemenang — inilah mengapa keputusan go/no-go quantization selalu diambil dari metrik tugas, bukan dari selisih mentah.

Hasil ini juga menyimpan pelajaran jujur: waktu inference int8 justru lebih lambat (0,175 lawan 0,097 ms). Dynamic quantization dirancang untuk model besar yang bottleneck-nya memuat bobot — pada MLP kecil ini, biaya dekuantisasi di tiap operasi melebihi penghematannya. Manfaat yang pasti didapat hari ini adalah ukuran: 3,36 kali lipat lebih kecil.

Praktik: Magnitude Pruning 30% #

Pruning membuang bobot bernilai paling kecil dengan asumsi magnitudo kecil berarti kontribusi kecil. Berkas pruning_magnitude.py memangkas 30% bobot setiap layer Linear, mengukur akurasi sebelum dan sesudah, lalu mempermanenkan hasilnya:

python
"""Magnitude pruning: memangkas bobot terkecil lalu menilai dampaknya.

Alur: latih MLP pada make_moons -> ukur akurasi -> pangkas 30% bobot
terkecil tiap layer Linear (l1_unstructured) -> ukur sparsity dan
akurasi setelah pruning -> permanenkan pruning (prune.remove) ->
bandingkan jumlah bobot nol dan ukuran state_dict.
"""

import io

import torch
import torch.nn as nn
import torch.nn.utils.prune as prune
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)
X, y = make_moons(n_samples=512, noise=0.20, random_state=42)
X = (X - X.mean(axis=0)) / X.std(axis=0)
X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, y, test_size=0.25, random_state=42
)
loader = DataLoader(
    TensorDataset(
        torch.tensor(X_latih, dtype=torch.float32),
        torch.tensor(y_latih, dtype=torch.long),
    ),
    batch_size=32,
    shuffle=True,
    num_workers=0,
    generator=torch.Generator().manual_seed(42),
)

model = nn.Sequential(nn.Linear(2, 32), nn.ReLU(), nn.Linear(32, 2))
kriteria = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.5)
for _ in range(30):
    for X_batch, y_batch in loader:
        optimizer.zero_grad()
        loss = kriteria(model(X_batch), y_batch)
        loss.backward()
        optimizer.step()
model.eval()

X_uji_tensor = torch.tensor(X_uji, dtype=torch.float32)
y_uji_tensor = torch.tensor(y_uji, dtype=torch.long)


def akurasi(m):
    with torch.no_grad():
        prediksi = m(X_uji_tensor).argmax(dim=1)
    return (prediksi == y_uji_tensor).float().mean().item()


def jumlah_bobot(m):
    """Total elemen bobot Linear beserta jumlah nolnya."""
    total = 0
    nol = 0
    for layer in m:
        if isinstance(layer, nn.Linear):
            total += layer.weight.numel()
            nol += int((layer.weight == 0).sum())
    return total, nol


def ukuran_state_dict(m):
    penampung = io.BytesIO()
    torch.save(m.state_dict(), penampung)
    return penampung.tell()


# ---------- 1. Sebelum pruning ----------
total, nol = jumlah_bobot(model)
print(f"sebelum pruning : akurasi {akurasi(model):.4f} | "
      f"bobot nol {nol}/{total} ({nol / total:.1%})")

# ---------- 2. Magnitude pruning 30% ----------
for layer in model:
    if isinstance(layer, nn.Linear):
        prune.l1_unstructured(layer, name="weight", amount=0.3)

total, nol = jumlah_bobot(model)
print(f"sesudah pruning : akurasi {akurasi(model):.4f} | "
      f"bobot nol {nol}/{total} ({nol / total:.1%})")

# ---------- 3. Permanenkan dan bandingkan ukuran ----------
ukuran_sebelum = ukuran_state_dict(model)
for layer in model:
    if isinstance(layer, nn.Linear):
        prune.remove(layer, name="weight")
ukuran_sesudah = ukuran_state_dict(model)
print(f"prune.remove    : bobot nol menjadi permanen")
print(f"ukuran state_dict: {ukuran_sebelum} byte -> {ukuran_sesudah} byte")
print("(penyusutan berasal dari mask sementara yang ikut dibuang;")
print(" bobot nol itu sendiri tetap tersimpan penuh sebagai float32)")
print("hemat nyata baru muncul saat kompresi berkas (zip/gzip) atau")
print("runtime sparse yang memang mendukungnya.")

Output yang Dihasilkan:

text
sebelum pruning : akurasi 0.9531 | bobot nol 0/128 (0.0%)
sesudah pruning : akurasi 0.9375 | bobot nol 38/128 (29.7%)
prune.remove    : bobot nol menjadi permanen
ukuran state_dict: 3861 byte -> 2973 byte
(penyusutan berasal dari mask sementara yang ikut dibuang;
 bobot nol itu sendiri tetap tersimpan penuh sebagai float32)
hemat nyata baru muncul saat kompresi berkas (zip/gzip) atau
runtime sparse yang memang mendukungnya.

Mari kita telaah kode di atas baris per baris:

  1. Baris 76-77 (prune.l1_unstructured): untuk tiap layer Linear, 30% bobot dengan nilai absolut terkecil dinolkan lewat mask. Selama fase ini state_dict menyimpan weight_orig dan weight_mask — dua tensor tambahan yang menjelaskan ukuran 3861 byte sebelum remove.
  2. Baris 79-80 (verifikasi sparsity): 38 dari 128 bobot kini nol (29,7% — pembulatan amount=0.3 pada 128 elemen menghasilkan 38, bukan tepat 38,4). Akurasi turun tipis dari 0,9531 ke 0,9375: satu kesalahan tambahan pada 128 sampel uji.
  3. Baris 84-98 (prune.remove): mask dilebur menjadi bobot permanen; state_dict kembali berisi weight biasa — itulah sumber penyusutan 3861 ke 2973 byte, bukan sparistasnya. Angka nol tetap tersimpan penuh sebagai float32; kompresi berkas (gzip pada berkas .pth) atau runtime sparse-lah yang mengubah nol menjadi penghematan sungguhan.

Kedua teknik ini memperketat pertanyaan berikutnya: di perangkat mana sebaiknya model hasilnya berjalan? CPU adalah tempat kebanyakan inference dunia sesungguhnya terjadi — murah, ada di mana-mana — dan punya disiplin optimasinya sendiri. Chapter berikutnya merangkum metodologi benchmark CPU yang benar sebelum kita menengadah ke GPU.