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áoDeprecationWarning: torch.ao.quantization is deprecated. Cảnh báo này khuyên chuyển eager mode sang APIquantize_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ồmquantize_cho mô hình (đặc biệt là LLM) và luồng PT2E dựa trêntorch.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.
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)
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 , 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#
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 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):
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:
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 | |
torchao Int8WeightOnly (eager) |
24,1 MB | 33,9 ms | |
torchao Int8DynamicActivationInt8Weight (eager) |
24,1 MB | 4,2 ms |
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_dynamiclà đặ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. Int8WeightOnlychạ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ùngtorch.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.
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 mà mô hình khai báo:
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.
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:
- Kích thước của file sẽ triển khai (không phải
state_dicttrong bộ nhớ). - Độ 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.
- Độ 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.
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
torch.ao.quantization.quantize_dynamiclượng tử những lớp nào?Vì sao: Mô hình tích chập cần static quantization. Đây là một trong những hiểu lầm được liệt kê ở Chương 13.2Vì sao
Int8WeightOnlycủa torchao chạy eager lại chậm hơn FP32?Vì sao: Tốn thêm công giải lượng tử mà không bớt được phép tính nào. torchao được thiết kế để dùng cùngtorch.compile.3Một báo cáo lượng tử hoá đáng tin cần đủ những con số nào?
Vì sao: Đostate_dicttrong bộ nhớ thay vì file triển khai, hay đo độ trễ trên máy dev thay vì thiết bị đích, là hai cách phổ biến nhất để tự lừa mình.
Đã trả lời 0/3