The exact kernel size, number of filters, and stride are determined by the reference implementation. The dominant cost is the single Conv2D layer — it accounts for the vast majority of all MACs in the model. The fully-connected layer has negligible MAC count by comparison.
For reference: ResNet-50 has ~4 billion MACs — tiny_conv is over 1,000× cheaper.
In the tiny_conv model, the Conv2D layer is followed immediately by a ReLU activation. TFLite fuses these two operations at export time — the quantization parameters absorb the ReLU constraint:
Without ReLU: output range is [-128, 127] (full int8)
With fused ReLU (activation ≥ 0): output range is [0, 127]
In the quantized kernel, this means the requantize() function clamps to [0, 127] instead of [-128, 127]:
int8_t requantize_relu(int32_t acc, int32_t bias,
int32_t mult, int shift, int8_t zero_point) {
acc += bias;
int64_t scaled = ((int64_t)acc * mult) >> shift;
int32_t q = (int32_t)scaled + zero_point;
// fused ReLU: clamp to [0, 127] instead of [-128, 127]
return (int8_t)(q < 0 ? 0 : (q > 127 ? 127 : q));
}
The TFLite flatbuffer encodes the activation type per operator — the conv_int8 kernel reads it and adjusts the clamp range accordingly. In our simplified kernel, we hardcode the range per layer.
When extracting weight tensors from a .tflite model, do not rely on hardcoded tensor indices — the TFLite converter may renumber them between versions:
# FRAGILE: tensor index may change with model version
weights = interpreter.get_tensor(2) # was index 2, may be 3 after reexport
# ROBUST: find tensor by name
tensor_details = interpreter.get_tensor_details()
conv_weights = next(
t for t in tensor_details if t['name'] == 'sequential/conv2d/Conv2D'
)
weights = interpreter.get_tensor(conv_weights['index'])
The export_weights.py script in the course reference implementation uses name-based lookup for this reason. Model re-training (even with identical architecture) can produce different tensor numbering.
TensorFlow Lite Micro is a C++ library with:
.tflite flatbuffer at runtime.Our RV32I core has:
TFLM will not fit. Instead, the course provides a dependency-free C kernel (kws_kernel.c): ~300 lines, no includes beyond stdint.h, implements exactly the tiny_conv operations we need. The weights and test vectors are in weights.h and test_vectors.h as const int8_t arrays.
This is the code you will profile (M04A03) and then accelerate (Project 3).
Running on the Tang Nano 9K via the Project 2 UART bootloader:
riscv64-unknown-elf-gcc -march=rv32im -O2 kws_kernel.c -o kws.elfobjcopy -O ihex kws.elf kws.hex && cat kws.hex > /dev/ttyUSB0The numbers you collect here are your baseline for Project 3. Write them down.
Given a float weight w_float = -0.83 and the layer's quantization parameters (scale = 0.0065, zero_point = 0):
And for w_float = 0.41, scale = 0.0065:
Dequantization reverses the operation:
The quantization error is
Key insight: the
multandshiftarrays inweights.hencode one scale factor per output channel — not one per layer — because different filters need different scales to minimize accuracy loss.
Before implementation, verify that the model fits in the Tang Nano 9K's 26 BRAM blocks (each 18 Kbit = 2.25 KB):
| Component | Size formula | Your value |
|---|---|---|
| Conv2D weights | KH × KW × C_in × C_out bytes |
|
| Conv2D bias (int32) | C_out × 4 bytes |
|
| Conv2D mult+shift (int32) | C_out × 8 bytes |
|
| FC weights | C_out × 4 bytes |
|
| FC bias | 4 × 4 bytes |
|
| Input activations (int8) | H × W × C_in bytes |
|
| Intermediate activations | H_out × W_out × C_out bytes |
|
| Total |
Expected: Conv2D weights dominate. Activations are small (< 4 KB). Total weights + biases for tiny_conv fit comfortably within the 26 available BRAMs — this is by design: the model was chosen to fit on-chip.
The KWS Inference Kernel: a detailed walkthrough of kws_kernel.c — the int8 MAC, the Conv2D inner loop, scale/zero_point requantization, and how to count MACs per layer. Lab 4 begins.