Quantizationgiáo trình tự học
Chương 106 phút đọcQuy trình lượng tử hoá

10Thực hành với PyTorch và TensorFlow Lite#

10.1PyTorch#

10.1.1Tình trạng API (cập nhật 2026)#

PyTorch có hai thế hệ công cụ lượng tử:

  • torch.ao.quantization (eager mode và FX graph mode): API "cổ điển" mà phần lớn bài hướng dẫn trên mạng đang dùng. Ở PyTorch 2.14, chỉ cần gọi là thấy cảnh báo DeprecationWarning: torch.ao.quantization is deprecated. Cảnh báo này khuyên chuyển eager mode sang API quantize_ của torchao, và chuyển FX sang PT2E (nay cũng nằm trong torchao). API cũ vẫn chạy được, nên vẫn hữu ích để học cơ chế.
  • torchao (pip install torchao): thư viện chính thức hiện nay, gồm quantize_ cho mô hình (đặc biệt là LLM) và luồng PT2E dựa trên torch.export.

Mọi đoạn mã PyTorch dưới đây đều đã chạy thật trên môi trường nêu ở đầu tài liệu. Kết quả in ra nằm trong code/torch_experiments_output.txt.

10.1.2Static quantization với eager mode#

Eager mode đòi hỏi viết lại mô hình theo ba quy tắc: có QuantStub/DeQuantStub đánh dấu nơi bắt đầu và kết thúc vùng lượng tử; mỗi ReLU là một module riêng (không dùng F.relu) để gộp được; và các phép như cộng skip-connection phải dùng FloatFunctional.

Python
import torch, torch.nn as nn
import torch.ao.quantization as tq

class QNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.quant = tq.QuantStub()                        # FP32 -> INT8 ở đầu vào
        self.conv1, self.bn1, self.relu1 = nn.Conv2d(1, 16, 3, padding=1, bias=False), nn.BatchNorm2d(16), nn.ReLU()
        self.dw, self.bn2, self.relu2 = nn.Conv2d(16, 16, 3, padding=1, groups=16, bias=False), nn.BatchNorm2d(16), nn.ReLU()
        self.pw, self.bn3, self.relu3 = nn.Conv2d(16, 32, 1, bias=False), nn.BatchNorm2d(32), nn.ReLU()
        self.pool = nn.MaxPool2d(2)
        self.conv4, self.bn4, self.relu4 = nn.Conv2d(32, 32, 3, padding=1, bias=False), nn.BatchNorm2d(32), nn.ReLU()
        self.gap = nn.AdaptiveAvgPool2d(1)
        self.fc = nn.Linear(32, 10)
        self.dequant = tq.DeQuantStub()                    # INT8 -> FP32 ở đầu ra

    def forward(self, x):
        x = self.quant(x)
        x = self.relu1(self.bn1(self.conv1(x)))
        x = self.relu2(self.bn2(self.dw(x)))
        x = self.pool(self.relu3(self.bn3(self.pw(x))))
        x = self.relu4(self.bn4(self.conv4(x)))
        x = self.fc(torch.flatten(self.gap(x), 1))
        return self.dequant(x)

model = QNet(); model.load_state_dict(fp32_state_dict, strict=False); model.eval()

torch.backends.quantized.engine = "x86"                  # ARM / điện thoại: "qnnpack"
model.qconfig = tq.get_default_qconfig("x86")            # trọng số per-channel đối xứng, activation per-tensor
tq.fuse_modules(model, [["conv1", "bn1", "relu1"], ["dw", "bn2", "relu2"],
                        ["pw", "bn3", "relu3"], ["conv4", "bn4", "relu4"]], inplace=True)   # Mục 7.3–7.4
tq.prepare(model, inplace=True)                          # chèn observer
with torch.no_grad():
    model(X_calib)                                       # calibration (Chương 8)
tq.convert(model, inplace=True)                          # thay bằng kernel INT8 thật
print(model.conv1)
Kết quả chạy
QuantizedConvReLU2d(1, 16, kernel_size=(3, 3), stride=(1, 1), scale=0.0270916..., zero_point=0, padding=(1, 1))

Để ý zero_point=0: vì dải đầu ra được đo sau ReLU nên α=0\alpha = 0, và phép cắt thực hiện luôn ReLU như đã nói ở Mục 6.4.

Kết quả: độ chính xác 99,07% (FP32: 99,26%), kích thước state_dict từ 48,5 KB xuống 20,5 KB. Tỉ lệ nén chỉ khoảng 2,4 lần chứ không phải 4 lần, vì với mô hình nhỏ như vậy, phần siêu dữ liệu khi tuần tự hóa (tên tensor, scale, zero-point, cấu trúc) chiếm tỉ trọng lớn. Với mô hình lớn, tỉ lệ tiến gần 4.

10.1.3QAT với eager mode#

Python
import torch.ao.nn.intrinsic.qat as nniqat

model = QNet(); model.load_state_dict(fp32_state_dict, strict=False)   # BẮT ĐẦU TỪ MÔ HÌNH ĐÃ HUẤN LUYỆN
model.train()
model.qconfig = tq.get_default_qat_qconfig("x86")
tq.fuse_modules_qat(model, [["conv1", "bn1", "relu1"], ["dw", "bn2", "relu2"],
                            ["pw", "bn3", "relu3"], ["conv4", "bn4", "relu4"]], inplace=True)
tq.prepare_qat(model, inplace=True)                      # chèn FakeQuantize, mô phỏng gộp BN

opt = torch.optim.Adam(model.parameters(), lr=5e-4)     # learning rate nhỏ: đây là fine-tune
for epoch in range(10):
    if epoch == 6: model.apply(tq.disable_observer)      # cố định dải lượng tử
    if epoch == 7: model.apply(nniqat.freeze_bn_stats)   # cố định thống kê BatchNorm
    train_one_epoch(model, opt)

model.eval()
qmodel = tq.convert(model)

Độ chính xác sau khi convert: 98,33% (xem code/torch_qat_eager.py). Ở INT8, con số này ngang PTQ trong phạm vi dao động (4 ảnh trên 540), đúng như kết luận ở Mục 9.3: QAT chỉ đáng dùng khi PTQ không đủ.

Một bài học từ chính quá trình viết tài liệu. Phiên bản đầu tiên của đoạn mã trên huấn luyện QAT từ đầu với learning rate 3×10−33 \times 10^{-3} và chỉ đạt 87,2%. QAT là fine-tune một mô hình đã hội tụ, với learning rate nhỏ và vài epoch. Không nên coi nó là cách huấn luyện từ đầu.

10.1.4PT2E với torchao#

Luồng PT2E xuất mô hình thành đồ thị bằng torch.export, nên không cần sửa mã mô hình (không cần QuantStub, không cần ReLU riêng):

Python
from torchao.quantization.pt2e.quantize_pt2e import prepare_pt2e, convert_pt2e
import torchao.quantization.pt2e.quantizer.x86_inductor_quantizer as xiq

m = fp32_model.eval()                                        # mô hình gốc, viết bằng F.relu cũng được
exported = torch.export.export(
    m, (X_calib[:4],),
    dynamic_shapes={"x": {0: torch.export.Dim("batch")}}     # cho phép batch size thay đổi
).module()

quantizer = xiq.X86InductorQuantizer().set_global(xiq.get_default_x86_inductor_quantization_config())
prepared = prepare_pt2e(exported, quantizer)                 # chèn observer
with torch.no_grad():
    prepared(X_calib)                                        # calibration
quantized = convert_pt2e(prepared)                           # đồ thị có các cặp quantize/dequantize
# Để có kernel INT8 nhanh: torch.compile(quantized)

Độ chính xác: 99,26%, bằng FP32. Lưu ý thực tế: nếu bỏ tham số dynamic_shapes, đồ thị bị cố định batch size 4 và lỗi ngay khi chạy với batch khác (Guard failed: x.size()[0] == 4). Mình đã gặp lỗi này khi chạy thử.

Mỗi phần cứng có quantizer riêng: X86InductorQuantizer cho CPU x86, ArmInductorQuantizer cho ARM, và các quantizer của ExecuTorch (XNNPACK, Qualcomm, Core ML…) cho thiết bị di động. Quantizer quyết định op nào được lượng tử và theo cấu hình nào, cho khớp với kernel mà backend đó có.

10.1.5Dynamic và weight-only: đo độ trễ#

Thí nghiệm trên một MLP 1024→4096→4096→1024 (96 MB ở FP32), batch 16, một luồng CPU:

Python
import torch.ao.quantization as tq
from torchao.quantization import quantize_, Int8WeightOnlyConfig, Int8DynamicActivationInt8WeightConfig

m1 = tq.quantize_dynamic(copy.deepcopy(base), {nn.Linear}, dtype=torch.qint8)     # API cũ
m2 = copy.deepcopy(base); quantize_(m2, Int8WeightOnlyConfig())                   # torchao
m3 = copy.deepcopy(base); quantize_(m3, Int8DynamicActivationInt8WeightConfig())  # torchao
Cách Kích thước Độ trễ Sai số tương đối đầu ra
FP32 96,0 MB 14,3 ms 0
torch.ao quantize_dynamic 24,0 MB 2,3 ms 2,4×10−22{,}4 \times 10^{-2}
torchao Int8WeightOnly (eager) 24,1 MB 33,9 ms 6,7×10−36{,}7 \times 10^{-3}
torchao Int8DynamicActivationInt8Weight (eager) 24,1 MB 4,2 ms 1,6×10−21{,}6 \times 10^{-2}

Cách đọc bảng:

  • Kích thước giảm đúng 4 lần như lý thuyết.
  • Tăng tốc khoảng 6 lần của quantize_dynamic là đặc thù phần cứng. CPU thử nghiệm có lệnh AVX-512 VNNI và AMX-INT8 chuyên cho phép nhân int8. Trên CPU không có các lệnh này, mức tăng tốc nhỏ hơn nhiều.
  • Int8WeightOnly chạy eager chậm hơn FP32 hơn 2 lần. Nếu không có torch.compile để sinh kernel hợp nhất, trọng số bị giải lượng tử về số thực ở mỗi lần gọi, tốn thêm công mà không bớt được phép tính nào. torchao được thiết kế để dùng cùng torch.compile. Đây là minh chứng cụ thể cho nhận định ở Mục 1.4: lượng tử hóa chỉ nhanh khi có kernel phù hợp.
  • Độ trễ dao động vài chục phần trăm giữa các lần chạy (FP32 đo được từ 14 đến 17 ms), nên luôn cần khởi động (warm-up) và lấy trung bình nhiều lần.

Dynamic quantization trong torch.ao chỉ hỗ trợ nn.Linear, nn.LSTM, nn.GRU và một số lớp hồi quy khác, không hỗ trợ nn.Conv2d. Mô hình tích chập cần static quantization.

10.2TensorFlow Lite / LiteRT#

TensorFlow Lite đã được Google đổi tên thành LiteRT (2024); trình thông dịch có thể cài riêng qua gói ai-edge-litert. API chuyển đổi tf.lite.TFLiteConverter vẫn giữ nguyên.

Lưu ý

môi trường dựng tài liệu này không cài TensorFlow, nên các đoạn mã TF dưới đây không được chạy kiểm chứng như phần PyTorch. Chúng viết theo tài liệu chính thức về post-training quantization của TensorFlow.

Python
import tensorflow as tf

def make_converter():                       # tạo converter MỚI cho mỗi cấu hình để tránh cấu hình cũ dính lại
    return tf.lite.TFLiteConverter.from_keras_model(model)

def representative_data_gen():              # dùng cho calibration (Chương 8)
    for x in tf.data.Dataset.from_tensor_slices(calib_images).batch(1).take(300):
        yield [x]

# 1) Dynamic range: trọng số INT8 lượng tử sẵn; activation lượng tử lúc chạy ở các op hỗ trợ
c = make_converter()
c.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_dynamic = c.convert()

# 2) Float16: trọng số lưu FP16
c = make_converter()
c.optimizations = [tf.lite.Optimize.DEFAULT]
c.target_spec.supported_types = [tf.float16]
tflite_fp16 = c.convert()

# 3) Full integer INT8: bắt buộc có representative dataset
c = make_converter()
c.optimizations = [tf.lite.Optimize.DEFAULT]
c.representative_dataset = representative_data_gen
c.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]   # op không có bản INT8 -> báo lỗi, không lặng lẽ chạy FP32
c.inference_input_type = tf.int8
c.inference_output_type = tf.int8
tflite_int8 = c.convert()

# 4) 16x8: activation INT16, trọng số INT8; cho mô hình nhạy với sai số activation
c = make_converter()
c.optimizations = [tf.lite.Optimize.DEFAULT]
c.representative_dataset = representative_data_gen
c.target_spec.supported_ops = [tf.lite.OpsSet.EXPERIMENTAL_TFLITE_BUILTINS_ACTIVATIONS_INT16_WEIGHTS_INT8]
tflite_16x8 = c.convert()

Khi chạy mô hình full-integer, đầu vào phải được lượng tử bằng đúng (S,Z)(S, Z) mà mô hình khai báo:

Python
interpreter = tf.lite.Interpreter(model_content=tflite_int8)
interpreter.allocate_tensors()
inp, out = interpreter.get_input_details()[0], interpreter.get_output_details()[0]
S, Z = inp["quantization"]
x_q = np.clip(np.round(x / S + Z), -128, 127).astype(np.int8)            # công thức Mục 3.2
interpreter.set_tensor(inp["index"], x_q[None])
interpreter.invoke()
y_q = interpreter.get_tensor(out["index"])
S_o, Z_o = out["quantization"]
y = S_o * (y_q.astype(np.float32) - Z_o)                                  # dequantize

QAT trong TensorFlow dùng gói tensorflow-model-optimization. Từ TensorFlow 2.16, Keras mặc định là Keras 3, còn gói này vẫn dựa trên Keras 2, nên thường phải cài tf_keras và đặt biến môi trường TF_USE_LEGACY_KERAS=1. Ngoài ra, quantize_model chỉ nhận mô hình Sequential hoặc Functional gồm các lớp Keras chuẩn; các lớp đóng gói như hub.KerasLayer không lượng tử được.

Python
import tensorflow_model_optimization as tfmot

q_aware = tfmot.quantization.keras.quantize_model(model)   # chèn fake-quant (Hình 10)
q_aware.compile(optimizer=tf.keras.optimizers.Adam(1e-4),
                loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),
                metrics=["accuracy"])
q_aware.fit(train_images, train_labels, epochs=2, validation_split=0.1)

c = tf.lite.TFLiteConverter.from_keras_model(q_aware)
c.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_qat = c.convert()

10.3Đo lường đúng cách#

Một báo cáo lượng tử hóa đáng tin phải có đủ ba con số, đo trên thiết bị đích:

  1. Kích thước của file sẽ triển khai (không phải state_dict trong bộ nhớ).
  2. Độ trễ: có warm-up, cố định số luồng, báo cáo trung bình hoặc trung vị qua nhiều lần chạy, ghi rõ batch size.
  3. Độ chính xác trên đúng tập kiểm tra và đúng pipeline tiền xử lý của mô hình FP32 gốc.
Python
import os, time, torch

def size_mb(model):
    torch.save(model.state_dict(), "/tmp/_m.pt")
    s = os.path.getsize("/tmp/_m.pt") / 2**20
    os.remove("/tmp/_m.pt")
    return s

@torch.no_grad()
def latency_ms(model, x, n=100, warmup=10):
    for _ in range(warmup):
        model(x)
    t = time.perf_counter()
    for _ in range(n):
        model(x)
    return (time.perf_counter() - t) / n * 1000

Tự kiểm tra

3 câu. Chọn đáp án để xem ngay lời giải thích.

  1. 1torch.ao.quantization.quantize_dynamic lượng tử những lớp nào?

  2. 2Vì sao Int8WeightOnly của torchao chạy eager lại chậm hơn FP32?

  3. 3Một báo cáo lượng tử hoá đáng tin cần đủ những con số nào?

Đã trả lời 0/3