จบชุดบทเรียนนี้เราจะเดินครบ แล้วปิดท้ายด้วยโมเดลของเราเองบน NPU:
.tflite int8 → _vela.tflite ที่ NPU อ่านออก ด้วย quantize_vela.shAIM_* — สี่ฟังก์ชันที่ห่อโมเดลให้ edge_ai เรียกได้ (ai_models/README.md ของ SDK)edge_ai.latency() บนบอร์ดปลายทางของวันนี้: โมเดล gesture ของเราโผล่ใน edge_ai.models() ให้ verdict บน NPU แล้วเราเติมช่อง MCU ในตารางเทียบเป้าหมายได้ด้วยเลขจริง
วันนี้เน้น "quantize ให้ถูก + เทียบให้เป็น" — ไม่ใช่แค่ทำให้รันได้ แต่ต้อง วัดได้ ว่าแต่ละที่แลกอะไรกับอะไร
เราอยู่ปลายบล็อก Training (Pillar 4) — ต่อยอดตรงจาก บทเรียน 5.3–5.5 กับ 5.6–5.7 มาปิดวง "train once, run everywhere" ให้ครบทั้งสามเป้าหมาย
บทเรียน 5.6–5.7 เราจับ "ไฟล์เดียว รันหลายที่" ได้แล้ว วันนี้เติมเป้าหมายที่มี ข้อยกเว้น ข้อเดียว — NPU ต้องคอมไพล์เพิ่ม เพราะมันไม่ใช่ CPU ที่รันกราฟทั่วไปได้
จำตาราง "train once, run everywhere" จาก บทเรียน 1.1–1.3 ได้ไหม มีบรรทัดเดียวที่ column "ทำอะไรกับไฟล์" ไม่ว่าง:
| เป้าหมาย | ทำอะไรกับ .tflite int8 |
รันด้วย |
|---|---|---|
| Web | ใช้ไฟล์เดิม (แปลงเป็น float I/O) | LiteRT.js |
| Cortex-A (Linux) | ใช้ไฟล์เดิม ไม่แก้ | ai-edge-litert |
| PC / Docker | ใช้ไฟล์เดิม | ai-edge-litert |
| MCU + Ethos-U55 | คอมไพล์ผ่าน Vela เพิ่ม 1 ครั้ง | TFLite-Micro บนบอร์ด |
นี่ไม่ใช่ข้อเสียของ MCU มันคือราคาของความเร็ว: NPU เร็วและประหยัดกว่ามาก แลกกับต้องคอมไพล์เพิ่มหนึ่งขั้น เราจ่ายขั้นนั้นวันนี้
โมเดล MCU ต้องเป็น full-integer int8 — น้ำหนักและ activation ทุกตัวเป็นจำนวนเต็ม 8 บิต ไม่ใช่ float 32 บิต
(scale, zero) ไว้ — เวลาใช้งานเราต้อง quantize input ด้วยคู่นี้เป๊ะ (q = round(x/scale + zero))
model_int8.tfliteจาก บทเรียน 5.3–5.5 คือจุดตั้งต้นของวันนี้ — เราไม่เทรนใหม่ เราแค่พาไฟล์นี้ไปให้ NPU รัน
▸ ลองเล่นสด (GeoGebra): เปิด Interactive Math Lab — ลากจุด/เลื่อนสไลเดอร์ดูสมการขยับตาม
ค่าต่อเนื่อง "snap" ไปยังกริด int8 ที่ใกล้ที่สุด — โมเดลเล็กลง ~4 เท่า และเร็วขึ้นบน NPU
หัวใจของ int8 คือสูตรสั้นๆ สองบรรทัด แปลง float ↔ จำนวนเต็ม 8 บิต ไปกลับ ด้วยคู่ค่าที่โมเดลฝังไว้เอง
quantize (float → int8, ตอนป้อน input เข้าโมเดล):
dequantize (int8 → float, ตอนอ่าน output กลับมา):
อ่านสัญลักษณ์แบบภาษาคน:
ทำไมเรื่องนี้สำคัญกับชุดบทเรียนนี้: PTQ (บทเรียน 5.3–5.5) เป็นคนหา ที่พอดีกับช่วงค่าจริงจาก representative dataset แล้ว ฝังคู่ ไว้ในทุก tensor เราจึงอ่านมาใช้ตรงๆ ไม่ต้องเดา (ดูโค้ด in_scale, in_zero = inp["quantization"] ในสไลด์ถัดๆ ไป) และเพราะ Vela ไม่แตะสูตรนี้เลย accuracy บน NPU จึงควรเท่ากับ int8 บน PC (ต่างได้แค่ระดับการปัดเศษของ kernel) — ถ้าบนบอร์ดเพี้ยนมาก แปลว่า front-end ใช้ ไม่ตรง ไม่ใช่ Vela ผิด
จำง่ายๆ: คือ "ขนาดหนึ่งก้าว" ของ int8 · คือ "ศูนย์อยู่ตรงไหน" — สองค่านี้เป๊ะเมื่อไร float กับ int8 ก็เล่าเรื่องเดียวกัน
ethos-u-vela คือ คอมไพเลอร์ของ Arm สำหรับ Ethos-U NPU มันรับ .tflite int8 แล้วมองหา subgraph ที่ NPU ทำได้ เอาไปแทนด้วย custom op ตัวเดียวชื่อ ethos-u
--accelerator-config ethos-u55-128 = บอก Vela ว่า NPU ตัวเราคือ U55 ที่ 128 MAC/รอบจุดสำคัญ: Vela ไม่แก้คณิตของโมเดล มันแค่จัดของใหม่ให้ NPU เร่งได้ → accuracy ควรเท่าเดิม (ต่างได้แค่ระดับการปัดเศษ) สิ่งที่เปลี่ยนคือ latency กับพลังงาน นี่คือเหตุผลที่ int8 บน PC = ground truth ของ MCU
สคริปต์ในชุดบทเรียนนี้ห่อ Vela ไว้ให้เหลือคำสั่งเดียว รับ .tflite int8 คืน _vela.tflite ใน output/:
./quantize_vela.sh model_int8.tflite
# -> ./output/model_int8_vela.tflite
ข้างในสคริปต์ทำแค่นี้ (อ่านได้เต็มใน quantize_vela.sh):
set -euo pipefail
MODEL="${1:-model_int8.tflite}"
[ -f "$MODEL" ] || { echo "no such file: $MODEL"; exit 1; }
vela --accelerator-config ethos-u55-128 --optimise Performance "$MODEL"
--optimise Performance = ให้ Vela เน้นความเร็ว (มีอีกโหมด Size เน้นประหยัดหน่วยความจำ)vela ติดตั้งอยู่ใน Docker image ของ บทเรียน 5.3–5.5 แล้ว — ไม่ต้องลงเอง รันในคอนเทนเนอร์เดิมได้เลย
set -euo pipefailคือนิสัยสคริปต์ที่ดี: ถ้าขั้นไหนพลาด สคริปต์หยุดทันที ไม่เดินต่อแบบเงียบๆ ให้เราหลงคิดว่าสำเร็จ
พอถึงตรงนี้เรามีไฟล์ .tflite สามหน้าตา จากน้ำหนักชุดเดียวกัน อย่าสับสน — แต่ละไฟล์มีบ้านของมัน
model_int8.tflite เป็นตัวกลาง: ใช้เองบน PC/Cortex-A ได้ และ เป็น input ของ Vela ไปต่อเป็นไฟล์ MCU_vela.tflite มี ethos-u custom op → เบราว์เซอร์/Cortex-A รันไม่ได้ · model_web.tflite float I/O → NPU ไม่ใช้จำง่ายๆ: หนึ่งน้ำหนัก สามบรรจุภัณฑ์ — เลือกบรรจุภัณฑ์ให้ตรงเป้าหมาย ถ้าเอาไฟล์ผิดไปผิดที่ มันจะโหลดไม่ขึ้นเลย (fail ชัด ไม่ใช่ fail เงียบ)
_vela.tflite เป็นแค่ "ไบต์ของกราฟ" บอร์ดยังไม่รู้จักมันในฐานะโมเดล เราต้อง ห่อ ด้วยสัญญา 4 ฟังก์ชันที่ edge_ai เรียกเป็น — เรียกว่า สัญญา AIM_* (ai_models/README.md ของ SDK)
int AIM_GESTURE_init(void); // 0 = ok, <0 = fail
int AIM_GESTURE_enqueue(const float *in); // ป้อน input หนึ่ง window
int AIM_GESTURE_dequeue(float *out); // 0 = มี verdict (เติม out[]), <0 = ยังไม่มี
void AIM_GESTURE_finalize(void); // (ไม่ถูกเรียกตอน runtime)
edge_ai ไม่สนว่าข้างในเป็นโมเดลอะไร ขอแค่มี 4 ฟังก์ชันนี้SUCCESS(0), NODATA(-1), ERROR(-2), STREAMEND(-3)ทะเบียนโมเดลของ
edge_aiเป็น shape-driven — พอโมเดลเรามีสัญญาครบ มันจะโผล่ในedge_ai.models()เองโดยไม่ต้องแตะ MicroPython หรือ IPC เลยแม้แต่บรรทัดเดียว
_vela.tflite → สัญญา AIM_* ทำได้สองทาง (ai_models/README.md ของ SDK):
กับดักที่แท้จริงไม่ใช่ตัวกราฟ แต่คือ feature parity — โมเดลเสียง/เรดาร์คาดหวัง feature vector เฉพาะ (เช่น 512-pt Hann FFT → 20-band mel → log) ต้องทำ front-end ให้เป๊ะเหมือนตอนเทรน มิสแมตช์ = ล้มเหลวแบบเงียบๆ อันดับ 1
เมื่อ wrap เสร็จ การ register โมเดลใหม่ใช้แก้แค่ 3 จุด (ai_models/README.md ของ SDK):
rm -rf proj_cm55/build; make program EDGE_AI_MODEL=combo + hard power-cycle — เท่านั้นเฟิร์มแวร์มี
FALL_ROW,GESTURE_ROW,KEYWORD_ROWคอมเมนต์ค้างไว้เป็นตัวอย่าง pattern นี้อยู่แล้ว — โมเดลที่ใช้เซนเซอร์เดิม (IMU) ไม่ต้องเขียน feed ใหม่
หมายเหตุ: ซอร์สเฟิร์มแวร์ BENTO ที่ใช้ทำคอร์สนี้ยังไม่เปิด ใน SDK สาธารณะ tesaiot-pse84-devkit-sdk
ai_engineมาเป็นไลบรารี prebuilt จึงเพิ่มโมเดลใหม่ด้วยai_engine_register()จากโค้ดของเราตอนรัน หรือใส่โมเดลแทนช่องเดิม ตามหัวข้อ Filling a model slot ในproj_cm55/modules/ai_models/README.md
พอโมเดลรันได้ทุกที่แล้ว คำถามวิศวกรที่แท้จริงคือ "มันควรอยู่ที่ไหน?" — คำตอบมาจากการวัด ไม่ใช่ความรู้สึก
accuracy ของทั้งสามควร ตรงกัน (int8 กราฟเดียวกัน) แต่ latency กับพลังงานต่างกันคนละโลก — สคริปต์วันนี้วัดตัวเลขจริงมาวางเทียบให้เห็นด้วยตา
เราวัด "เวลาอนุมานล้วน" (เฉพาะช่วง invoke) ไม่รวม normalize/quantize — เพราะ edge_ai.latency() บนบอร์ดก็วัดเฉพาะช่วงนั้น จะได้เทียบตรงประเด็น
| เป้าหมาย | วัดด้วย | ได้เลขจาก |
|---|---|---|
| PC / Cortex-A | time.perf_counter() คร่อม it.invoke() |
สคริปต์ s14 บน PC |
| Web | performance.now() คร่อม model.run() |
เบราว์เซอร์ (หรือ bench float ใน s14) |
| MCU + NPU | r['latency_ms'] จาก edge_ai.result() |
รันบนบอร์ดจริง |
t0 = time.perf_counter()
it.invoke() # อนุมานล้วน ไม่รวม pre/post
total_ms += (time.perf_counter() - t0) * 1000.0
ตัวเลข MCU ต้องมาจากบอร์ดจริงเท่านั้น — เราวัดบน PC ไม่ได้ เพราะไม่มี NPU. สคริปต์จึงเว้นช่อง MCU ไว้ให้เราเอา
edge_ai.latency()มาเติมด้วย--mcu-ms
บทเรียน 5.6–5.7 เราพิสูจน์ parity ระหว่าง PC กับเว็บมาแล้ว วันนี้ขยายไป NPU: int8 บน PC = accuracy ที่ NPU ควรได้ เพราะ Vela ไม่แตะคณิต (ต่างได้แค่ระดับการปัดเศษของ kernel)
# bench int8 บน PC — ได้ทั้ง accuracy (ground truth) และ latency
correct += int(o.argmax() == y[i]) # นับคลาสที่ชนะตรงกับคลาสจริง
acc = correct / len(X)
บทเรียนซ้ำจาก บทเรียน 5.6–5.7 ที่ยกระดับ: feature parity คือกับดัก ไม่ใช่ตัวกราฟ — ทั้งสามเป้าหมายต้องเดิน front-end เดียวกัน accuracy ถึงจะตรง
accuracy เท่ากันแล้ว จุดที่ NPU ชนะขาดคือ latency กับ พลังงานต่อการอนุมาน — และสองอย่างนี้มาจากเหตุผลเดียวกัน
นี่คือเหตุผลที่ Vela คุ้มค่าจ่ายขั้นคอมไพล์เพิ่ม — เราแลก "หนึ่งขั้นตอนตอน build" กับ "เร็วขึ้นหลายเท่า + แบตอยู่นานขึ้นมาก ตอน run ทุกครั้ง"
ตารางเทียบให้ตัวเลข แต่การอ่านมันคือทักษะวิศวกร ระวังสามกับดักนี้:
shaking พลาดครึ่งหนึ่งล่ะ? ดู confusion matrix (จาก eval_pc.py) ไม่ใช่แค่เลขรวม นี่คือบทเรียน false positive/negative จากบล็อก Analysis ที่ออกดอกตรงนี้edge_ai.latency() หลาย ๆ ครั้งแล้วดูค่าสูงสุดเอง (ฝั่ง C มี inference_us_max ใน ai_result_t)# เทียบให้ครบ: ไม่ใช่แค่ accuracy รวม แต่ดูว่าพลาดคลาสไหน (eval_pc.py)
for i, row in enumerate(cm): # cm = confusion matrix
print("%-8s %s" % (dt.CLASSES[i], row))
เป้าหมายของชุดบทเรียนไม่ใช่ "เลขสวย" แต่คือ อ่านเลขเป็น — ตอบได้ว่าโมเดลเราพร้อมขึ้นงานจริงไหม หรือยังพลาดคลาสสำคัญอยู่