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.
| Aspek | PTQ (dynamic) | QAT |
|---|---|---|
| Kapan dilakukan | setelah training, tanpa data lengkap | selama training, butuh data dan pipeline |
| Upaya | satu pemanggilan fungsi | training ulang beberapa epoch |
| Penurunan akurasi | kecil hingga sedang | paling kecil, terkendali |
| Cocok untuk | deployment 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:
"""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:
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:
- Baris 64 (
quantize_dynamic): satu pemanggilan mengganti seluruh layernn.Linearmenjadi versi int8 — inilah seluruh upaya PTQ. Argumen{nn.Linear}memilih jenis layer yang dikuantisasi; bobot disimpan qint8 sementara aktivasi tetap fp32. - Baris 67-72 (
ukuran_state_dict): ukuran diukur dengan menyimpan state_dict ke memori (io.BytesIO) — angka yang sama dengan ukuran berkas di disk. - Baris 75-89 (
waktu_inference): batch 256, warm-up 10, lalu tercepat dari tiga putaran — metodologi yang sama dengan chapter Optimasi Latensi . - 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:
"""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:
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:
- Baris 76-77 (
prune.l1_unstructured): untuk tiap layer Linear, 30% bobot dengan nilai absolut terkecil dinolkan lewat mask. Selama fase ini state_dict menyimpanweight_origdanweight_mask— dua tensor tambahan yang menjelaskan ukuran 3861 byte sebelumremove. - Baris 79-80 (verifikasi sparsity): 38 dari 128 bobot kini nol (29,7% — pembulatan
amount=0.3pada 128 elemen menghasilkan 38, bukan tepat 38,4). Akurasi turun tipis dari 0,9531 ke 0,9375: satu kesalahan tambahan pada 128 sampel uji. - Baris 84-98 (
prune.remove): mask dilebur menjadi bobot permanen; state_dict kembali berisiweightbiasa — 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.