Chương 35. TinyML và xu hướng AI trên thiết bị nhúng
Mục tiêu
- Hiểu TinyML là gì: chạy suy luận (inference) mô hình học máy nhỏ ngay trên MCU, không gửi dữ liệu lên cloud.
- Hiểu lượng tử hoá (quantization) int8 và vì sao nó là chìa khoá để suy luận ML chạy được trên phần cứng không có FPU/heap lớn.
- Biết kiến trúc chung của TensorFlow Lite for Microcontrollers và vì sao nó dùng đúng các kỹ thuật C++ đã học (arena tĩnh — chương 22).
Câu chuyện: gửi âm thanh liên tục lên cloud để phát hiện một từ khoá
Một thiết bị "luôn lắng nghe" (voice assistant nhỏ) cần phát hiện từ khoá đánh thức (wake word). Gửi audio thô liên tục qua mạng để một server xử lý tốn pin, tốn băng thông, và có vấn đề riêng tư nghiêm trọng (micro luôn truyền dữ liệu thô ra ngoài). Giải pháp TinyML: một mô hình rất nhỏ (vài chục KB) chạy ngay trên MCU, chỉ phát tín hiệu (và có thể chỉ khi đó mới gửi dữ liệu lên cloud) khi phát hiện đúng từ khoá — giảm hẳn năng lượng, băng thông, và rủi ro riêng tư.
Kiến thức
1. Vì sao phải lượng tử hoá (quantization)
Mô hình học máy huấn luyện thường dùng số thực dấu phẩy động 32-bit (float32). Trên MCU không có FPU (Floating-Point Unit) phần cứng, mỗi phép toán float được mô phỏng bằng phần mềm — chậm hơn hàng chục lần so với phép toán số nguyên. Lượng tử hoá chuyển trọng số và dữ liệu sang int8 (đôi khi int16), theo công thức affine:
gia_tri_thuc ≈ (gia_tri_luong_tu - zero_point) × scale
scale (một số float, tính một lần lúc chuẩn bị mô hình) và zero_point (một số nguyên nhỏ) cho phép "nén" một khoảng giá trị thực về 256 mức int8 — đánh đổi lấy sai số nhỏ (thường chấp nhận được cho các mô hình phân loại/phát hiện) để đổi lấy tốc độ và kích thước nhỏ hơn nhiều lần.
2. Suy luận bằng số nguyên: nhân-cộng-dồn (MAC) lượng tử hoá
// Mot "no-ron" tuyen tinh luong tu hoa int8 (dot product + bias + kich hoat ReLU), theo dung tinh
// than TinyML (TensorFlow Lite for Microcontrollers): tinh toan CHI bang so nguyen, khong co dau phay
// dong nao trong duong suy luan chinh - phu hop MCU khong co FPU hoac muon toc do/nang luong tot hon.
#include <array>
#include <cstdint>
#include <cstdio>
// Luong tu hoa affine don gian: gia_tri_thuc = (gia_tri_int8 - zero_point) * scale.
struct QuantParams {
float scale;
std::int8_t zero_point;
};
// Tich luy trong int32 (khong phai int8/int16) de tranh TRAN SO khi cong don nhieu tich int8*int8 -
// day la ly do TFLite Micro luon dung buffer tich luy rong hon kieu du lieu dau vao.
std::int32_t quantized_dot(const std::array<std::int8_t, 4>& input, const std::array<std::int8_t, 4>& weights,
QuantParams in_q, QuantParams w_q) {
std::int32_t acc = 0;
for (std::size_t i = 0; i < input.size(); ++i) {
const std::int32_t x = static_cast<std::int32_t>(input[i]) - in_q.zero_point;
const std::int32_t w = static_cast<std::int32_t>(weights[i]) - w_q.zero_point;
acc += x * w; // tich hai so <= 8 bit, ket qua toi da ~16 bit; cong don 4 lan van an toan trong int32
}
return acc;
}
// Requantize: dua tong int32 (theo thang do scale_in * scale_w) ve lai int8 theo thang do dau ra,
// roi ap ReLU (cat am ve 0) - dung mau TFLite Micro (tinh toan bang so nguyen, khong dung float o day
// ngoai duy nhat buoc quy doi thang do nay - ban toi uu that dung nhan so nguyen co dinh, khong float).
std::int8_t requantize_relu(std::int32_t acc, float scale_in, float scale_w, QuantParams out_q) {
const float real_value = static_cast<float>(acc) * scale_in * scale_w;
const float requantized = real_value / out_q.scale + static_cast<float>(out_q.zero_point);
const float relu = requantized > static_cast<float>(out_q.zero_point) ? requantized : static_cast<float>(out_q.zero_point);
// LAM TRON ve so nguyen gan nhat, khong cat cut: cat cut (static_cast thang) lam lech ket qua ve
// phia 0 mot cach co he thong - voi mang nhieu tang, sai lech nay tich luy lai rat nhanh.
const float rounded = relu >= 0.0f ? relu + 0.5f : relu - 0.5f;
const float clamped = rounded > 127.0f ? 127.0f : (rounded < -128.0f ? -128.0f : rounded);
return static_cast<std::int8_t>(clamped);
}
int main() {
// Dau vao va trong so da duoc luong tu hoa san (vd boi cong cu huan luyen/luong tu hoa offline).
const std::array<std::int8_t, 4> input{20, -5, 40, 10};
const std::array<std::int8_t, 4> weights{3, -2, 1, 4};
const QuantParams in_q{0.05f, 0}; // scale=0.05, zero_point=0 (du lieu dau vao da chuan hoa quanh 0)
const QuantParams w_q{0.01f, 0};
const QuantParams out_q{0.01f, 0}; // thang do dau ra min hon dau vao: giu duoc do phan giai sau ReLU
const std::int32_t acc = quantized_dot(input, weights, in_q, w_q);
const std::int8_t output = requantize_relu(acc, in_q.scale, w_q.scale, out_q);
std::printf("tich luy (int32) = %d\n", static_cast<int>(acc));
std::printf("gia tri thuc uoc tinh = %.4f\n", static_cast<double>(acc) * in_q.scale * w_q.scale);
std::printf("dau ra sau requantize + ReLU (int8) = %d\n", static_cast<int>(output));
std::printf("\nSo sanh: cung phep toan bang float32 truc tiep:\n");
float float_acc = 0.0f;
for (std::size_t i = 0; i < input.size(); ++i)
float_acc += static_cast<float>(input[i]) * in_q.scale * static_cast<float>(weights[i]) * w_q.scale;
std::printf("float32: %.4f (so voi uoc tinh tu int32 o tren, chenh lech la sai so luong tu hoa)\n",
static_cast<double>(float_acc));
}
tich luy (int32) = 150
gia tri thuc uoc tinh = 0.0750
dau ra sau requantize + ReLU (int8) = 7
So sanh: cung phep toan bang float32 truc tiep:
float32: 0.0750 (so voi uoc tinh tu int32 o tren, chenh lech la sai so luong tu hoa)
Đây là một "nơ-ron" tuyến tính đơn giản nhất — trong thực tế, một mạng nơ-ron tích chập (CNN) nhỏ có hàng nghìn phép toán tương tự lặp lại, nhưng nguyên lý cốt lõi giống hệt:
- Tích luỹ trong kiểu rộng hơn (
int32cho tích của các sốint8): tích hai số 8-bit có thể tới ~16 bit, cộng dồn nhiều tích lại dễ vượt quáint16— đây là ứng dụng trực tiếp của nguyên tắc "chọn đúng độ rộng kiểu số" (chương 7) vào một bối cảnh cụ thể: tràn số nguyên trong suy luận ML là lỗi âm thầm, cho ra kết quả sai mà không có cảnh báo nào. - Requantize: sau khi tích luỹ, kết quả phải quy đổi trở lại thang đo
int8của tầng tiếp theo, và phải làm tròn chứ không cắt cụt — cắt cụt luôn lệch về phía 0 một cách có hệ thống, sai lệch đó tích luỹ rất nhanh qua nhiều tầng. Ví dụ dùngfloatcho bước nhânscalenày để dễ đọc; bản triển khai tối ưu thật (như TensorFlow Lite Micro) dùng nhân số nguyên cố định + dịch bit, tránhfloathoàn toàn. - Một chi tiết đáng chú ý ngay trong output: giá trị trước khi làm tròn lẽ ra đúng bằng 7.5, nhưng kết quả in ra là
7chứ không phải8. Lý do:0.075không biểu diễn được chính xác bằngfloatnhị phân (giá trị thật hơi nhỏ hơn, cỡ 7.4999...), nên phép làm tròn cho ra 7. Đây không phải lỗi của code làm tròn — đó chính là lý do các triển khai thật loại bỏfloatkhỏi đường suy luận: cùng một mô hình, cùng một dữ liệu, kết quả phải giống hệt nhau trên mọi thiết bị, và số nguyên cho điều đó cònfloatthì không đảm bảo. - ReLU (cắt âm về 0, hoặc về
zero_pointsau lượng tử hoá) là hàm kích hoạt phổ biến nhất, rẻ nhất để tính (chỉ so sánh, không cần hàm siêu việt như sigmoid/tanh).
So sánh kết quả int32/lượng tử hoá với phép tính float32 trực tiếp trong ví dụ cho thấy sai số lượng tử hoá — nhỏ trong ví dụ này, nhưng với mô hình thật, sai số tích luỹ qua nhiều tầng là lý do cần đánh giá độ chính xác sau lượng tử hoá trên tập dữ liệu thật trước khi triển khai, không chỉ tin vào lý thuyết.
3. Kiến trúc TensorFlow Lite for Microcontrollers (tham khảo)
Framework TinyML phổ biến nhất hiện nay là TensorFlow Lite for Microcontrollers (TFLite Micro), viết bằng C++. Vài quyết định thiết kế của nó khớp trực tiếp với các chương trước — không phải trùng hợp, mà vì cùng ràng buộc phần cứng dẫn tới cùng giải pháp:
- Arena bộ nhớ tĩnh: interpreter nhận một vùng nhớ cố định (
uint8_t tensor_arena[N]) lúc khởi tạo, cấp phát mọi tensor trung gian từ đó — đúng ý tưởngstd::pmr::monotonic_buffer_resource(chương 22), không heap hệ thống. - Không cấp phát động sau khi khởi tạo: toàn bộ kích thước bộ nhớ cần thiết được tính một lần khi nạp mô hình, sau đó suy luận lặp lại nhiều lần không cấp phát thêm — dự đoán được thời gian và bộ nhớ, đúng nguyên tắc chương 20.
- Không exception: interpreter trả mã lỗi (
TfLiteStatus), không ném ngoại lệ — đúng tinh thần chương 19.
Chữ ký API chính xác của TFLite Micro thay đổi theo phiên bản — sách không đưa ví dụ chạy được bằng chính framework này vì môi trường biên dịch của sách không có sẵn thư viện; tra tài liệu chính thức cho phiên bản bạn dùng.
4. Xu hướng khác đáng theo dõi
- NPU tích hợp trong MCU: một số dòng MCU mới (Cortex-M55 + Ethos-U, các chip AI nhúng chuyên dụng) có phần cứng tăng tốc phép nhân-cộng-dồn hàng loạt — code C++ điều khiển NPU thường qua thư viện vendor, tương tự HAL đã học ở chương 25.
- Mô hình càng nhỏ, càng chuyên biệt: xu hướng TinyML không chạy mô hình tổng quát lớn, mà huấn luyện mô hình rất nhỏ, rất chuyên biệt cho đúng một bài toán (phát hiện một loại rung động bất thường, một từ khoá) — kích thước Flash quyết định độ phức tạp mô hình được phép dùng.
Góc nhúng
- Đo thời gian suy luận bằng bộ đếm chu kỳ CPU (DWT trên Cortex-M, nhắc lại chương 20) trước/sau lượng tử hoá — chênh lệch tốc độ giữa
floatmô phỏng phần mềm vàint8phần cứng thường rất lớn trên MCU không FPU. - Kích thước mô hình (file trọng số) cộng với code interpreter phải vừa Flash còn lại sau firmware chính — đo bằng
size(chương 20) như mọi thành phần khác của firmware. - Với các phép nhân số nguyên cố định (fixed-point) thay
floatở bước requantize thật, ôn lại kỹ thuật số nguyên điểm cố định đã giới thiệu ở chương 7.
Lỗi thường gặp
- Tích luỹ tích các số
int8/int16trực tiếp trong kiểu hẹp, gây tràn số âm thầm — luôn tích luỹ trong kiểu rộng hơn đáng kể. - Quên
zero_pointkhi lượng tử hoá/giải lượng tử hoá, chỉ nhânscale— sai kết quả có hệ thống (lệch, không phải nhiễu ngẫu nhiên). - Đánh giá độ chính xác mô hình chỉ bằng lý thuyết, không đo trên tập dữ liệu thật sau khi lượng tử hoá.
- Cấp phát tensor động trong vòng lặp suy luận thay vì tính trước một arena cố định lúc khởi tạo.
Bài tập
- Đổi trọng số/đầu vào trong ví dụ để tạo ra tràn số nếu tích luỹ bằng
int16thay vìint32— quan sát (bằng tính tay hoặc chạy thử) kết quả sai khác thế nào. - Viết phiên bản
requantize_reludùng nhân số nguyên cố định + dịch bit thay chofloat(gợi ý: nhânscalevới một hằng số lớn, làm tròn về số nguyên, rồi dịch phải). - Tính tay sai số giữa kết quả
int8lượng tử hoá vàfloat32trong ví dụ; thử vớiscalenhỏ hơn/lớn hơn và quan sát ảnh hưởng tới sai số. - Tìm hiểu (qua tài liệu chính thức) kích thước arena bộ nhớ (
tensor_arena) mà một ví dụ mẫu của TFLite Micro (như "hello_world" hoặc "micro_speech") sử dụng, và đối chiếu với RAM của một MCU cụ thể bạn quan tâm.
Tóm tắt
- TinyML chạy suy luận ML ngay trên MCU, ưu tiên năng lượng/băng thông/riêng tư so với gửi dữ liệu lên cloud.
- Lượng tử hoá
int8biến suy luận thành phép toán số nguyên (nhanh hơn, nhỏ hơnfloat32trên phần cứng không FPU), đổi lấy sai số cần đo đạc, không giả định. - TFLite Micro và các framework TinyML khác dùng lại chính các kỹ thuật C++ nhúng đã học: arena tĩnh, không heap sau khởi tạo, không exception — vì cùng ràng buộc phần cứng dẫn tới cùng giải pháp.