
Embedded AI inference on microcontrollers faces severe resource constraints: limited SRAM (often under 256 KB), Flash storage boundaries, tight power budgets, and deterministic latency deadlines. The Arm Cortex-M4 processor is one of the most widely deployed cores in edge computing, yet it lacks dedicated neural processing units (NPUs) or vector extensions like Helium (Armv8.1-M MVE).
Deploying deep neural network layers—specifically 2D convolutions, depthwise separable convolutions, and dense matrix multiplications—using unoptimized scalar C yields unacceptable latency, frequent bus contention, and excessive battery drain. This guide analyzes how Arm’s CMSIS-NN library circumvents these architectural constraints by leveraging the Cortex-M4’s Armv7E-M DSP extension, SIMD instructions, and memory layout techniques to accelerate neural network inference.
In deep learning literature, a standard 2D convolution layer is mathematically formulated as a multi-dimensional tensor summation:
// 2D Convolution Tensor Equationy[n, h, w, k] = SUM_{c=0..C-1} SUM_{r=0..R-1} SUM_{s=0..S-1} (x[n, h * s_h + r - p_h, w * s_w + s - p_w, c] * w[k, r, s, c]) + b[k]
While this notation appears daunting, each index maps directly to physical memory dimensions and loop iterators in an embedded application:
+----------+-----------------------------------------------------------------------+| Symbol | Embedded Hardware / Tensor Meaning |+----------+-----------------------------------------------------------------------+| n | Batch index (n = 0, batch size = 1 for edge inference) || h, w | Output pixel spatial coordinates (row h, column w) || k | Output channel / filter index (0 to K - 1) || c | Input channel index (0 to C - 1, e.g., 3 for RGB) || r, s | Spatial row and column within the kernel filter (e.g., 3x3 window) || s_h, s_w | Stride step along height and width dimensions || p_h, p_w | Zero-padding added to spatial borders || x[...] | Input activation value at the calculated receptive field position || w[...] | Weight coefficient for filter k at position (r, s, c) || b[k] | 32-bit bias value accumulated into output channel k || y[...] | Resulting output activation value |+----------+-----------------------------------------------------------------------+
To an embedded firmware developer, this equation translates directly into 6 nested for loops traversing memory:
// Naive 6-Loop Scalar C Implementation of 2D Convolutionvoid naive_conv2d_scalar(const int8_t *input, // Tensor shape: [IN_H, IN_W, IN_CH]const int8_t *weight, // Tensor shape: [OUT_CH, K_H, K_W, IN_CH]const int32_t *bias, // Vector shape: [OUT_CH]int8_t *output, // Tensor shape: [OUT_H, OUT_W, OUT_CH]int in_h, int in_w, int in_ch,int k_h, int k_w,int stride_y, int stride_x,int pad_y, int pad_x,int out_h, int out_w, int out_ch){// Loops 1 & 2: Slide across each output row (h) and column (w)for (int out_y = 0; out_y < out_h; out_y++) {for (int out_x = 0; out_x < out_w; out_x++) {// Loop 3: Compute each output feature channel (k)for (int k = 0; k < out_ch; k++) {int32_t accumulator = bias[k]; // Initialize accumulator with channel bias// Loops 4 & 5: Traverse filter kernel window (r, s)for (int r = 0; r < k_h; r++) {for (int s = 0; s < k_w; s++) {int in_y = out_y * stride_y + r - pad_y;int in_x = out_x * stride_x + s - pad_x;// Boundary check for zero-paddingif (in_y >= 0 && in_y < in_h && in_x >= 0 && in_x < in_w) {// Loop 6: Multiply-accumulate across all input channels (c)for (int c = 0; c < in_ch; c++) {int input_idx = (in_y * in_w + in_x) * in_ch + c;int weight_idx = ((k * k_h + r) * k_w + s) * in_ch + c;accumulator += (int32_t)input[input_idx] * (int32_t)weight[weight_idx];}}}}// Requantize 32-bit accumulator to int8 range [-128, 127]int output_idx = (out_y * out_w + out_x) * out_ch + k;output[output_idx] = requantize_and_saturate_s8(accumulator, k);}}}}
To see how the calculation evaluates a single output point, consider a 3x3 kernel operating on a single-channel (C = 1) input patch with stride = 1 and padding = 0:
Input Patch x[3x3]: Kernel Weights w[3x3]:+-----+-----+-----+ +-----+-----+-----+| 12 | 4 | -8 | | 2 | -1 | 0 |+-----+-----+-----+ +-----+-----+-----+| 0 | 15 | -3 | * | 1 | 3 | -2 |+-----+-----+-----+ +-----+-----+-----+| -6 | 7 | 2 | | -1 | 0 | 1 |+-----+-----+-----+ +-----+-----+-----+Channel Bias b[0] = +5Step-by-step element-wise Multiply-Accumulate (MAC):(r=0, s=0): 12 * 2 = +24(r=0, s=1): 4 * (-1) = -4(r=0, s=2): -8 * 0 = 0(r=1, s=0): 0 * 1 = 0(r=1, s=1): 15 * 3 = +45(r=1, s=2): -3 * (-2) = +6(r=2, s=0): -6 * (-1) = +6(r=2, s=1): 7 * 0 = 0(r=2, s=2): 2 * 1 = +2-----------------------------Sum of Products = +79Add Bias (+5) = +84Accumulator Output: Acc = 84 (scaled down and saturated to int8)
When compiled as nested for loops in standard C, several severe architectural penalties occur on the Cortex-M4:
LDRB) requires four separate bus transfers to load 4 bytes. This consumes four clock cycles and leaves 75% of the 32-bit data bus unutilized.MUL/MLA instructions, consuming multiple clock cycles per weight tap.CMSIS-NN overcomes the hardware limitations of the Armv7E-M architecture through three primary engineering strategies: im2col transformation, 32-bit packed word loads, and dual 16-bit DSP MAC execution (SMLAD).
im2col + Matrix Multiplication ReformulationRather than traversing non-contiguous memory in nested sliding-window loops, CMSIS-NN converts the 2D convolution into a General Matrix Multiply (GEMM). A lightweight scratchpad buffer is allocated in SRAM to store an unrolled column matrix (im2col). This rearranges spatial receptive field patches into contiguous memory blocks, enabling continuous sequential word-aligned loads.
SXTB16 and SMLADWhile the Cortex-M4 cannot execute a 4x 8-bit MAC in hardware, it features a specialized DSP instruction: SMLAD (Signed Multiply-Accumulate Dual). SMLAD multiplies two signed 16-bit halves of register Rn with two signed 16-bit halves of register Rm, and adds both products into a 32-bit accumulator Ra in a single clock cycle:
// SMLAD Instruction Arithmetic CalculationAcc = Acc + (Rn[15:0] * Rm[15:0]) + (Rn[31:16] * Rm[31:16])
The 32-bit register bit layout and single-cycle ALU execution operate as follows:
Rn Register (32-bit): [ Rn[31:16] (Halfword 1) | Rn[15:0] (Halfword 0) ]Rm Register (32-bit): [ Rm[31:16] (Halfword 1) | Rm[15:0] (Halfword 0) ]| |v (*) v (*)Product 1 Product 0\ /+-------------+-----------+v (+)Acc += (P1 + P0)
CMSIS-NN harnesses this instruction for 8-bit arithmetic through an unpacking sequence that processes 4 byte pairs across 2 cycles:
// Executing 4x int8 MACs in 2 Clock Cycles via SMLAD:Given 4 packed input bytes: x = [ x3, x2, x1, x0 ] = [ -12, 20, -5, 10 ]Given 4 packed weight bytes: w = [ w3, w2, w1, w0 ] = [ 3, -1, 4, 2 ]Initial Accumulator: Acc = 100Step 1: 32-bit Word Fetches (1 cycle per LDR)LDR R0, [R_in] -> R0 = [ -12 | 20 | -5 | 10 ]LDR R1, [R_wt] -> R1 = [ 3 | -1 | 4 | 2 ]Step 2: Sign-extend even bytes (x0, x2) into 16-bit lanes (1 cycle)SXTB16 R2, R0 -> R2 = [ 0x0014 (20) | 0x000A (10) ]SXTB16 R3, R1 -> R3 = [ 0xFFFF (-1) | 0x0002 (2) ]Step 3: First SMLAD Execution (Cycle 1)SMLAD Acc, R2, R3, AccCalculation:Product 0 = 10 * 2 = 20Product 1 = 20 * -1 = -20Sum = 20 + (-20) = 0Acc = 100 + 0 = 100Step 4: Rotate and sign-extend odd bytes (x1, x3) (1 cycle)SXTB16 R2, R0, ROR #8 -> R2 = [ 0xFFF4 (-12) | 0xFFFB (-5) ]SXTB16 R3, R1, ROR #8 -> R3 = [ 0x0003 (3) | 0x0004 (4) ]Step 5: Second SMLAD Execution (Cycle 2)SMLAD Acc, R2, R3, AccCalculation:Product 0 = -5 * 4 = -20Product 1 = -12 * 3 = -36Sum = -20 + (-36) = -56Acc = 100 + (-56) = 44Total Throughput: 4 Multiply-Accumulates computed in 2 CPU cycles (2 MACs/cycle)!
In quantized inference (per the TensorFlow Lite Micro symmetric/asymmetric int8 specification), accumulating products of 8-bit inputs and 8-bit weights requires 32-bit integers. Once convolution finishes over a kernel patch, the 32-bit accumulated sum must be scaled back to the 8-bit range [-128, 127]:
// Fixed-Point Requantization Formula:Real_Value = Scale * (Quantized_Value - Zero_Point)Effective Scale Multiplier:M = (Scale_in * Scale_weight) / Scale_outFixed-point decomposition (Q31 format multiplier M0 and shift):M = (M0 / 2^31) * 2^(shift), where 0.5 <= M0 < 1.0
CMSIS-NN evaluates this using 64-bit integer multiplication and the Cortex-M4’s single-cycle saturation instruction:
// CMSIS-NN Requantization and Saturation Implementationint8_t requantize_and_saturate_s8(int32_t val, int32_t mult, int32_t shift, int32_t out_offset){// 1. 64-bit fixed-point multiplication prevents intermediate overflowint64_t total = (int64_t)val * mult;// 2. Rounding addition and bit-shiftint32_t scaled = (int32_t)((total + (1LL << (30 - shift))) >> (31 - shift));// 3. Apply output zero-point offsetscaled += out_offset;// 4. Single-cycle hardware saturation to int8 [-128, 127] via SSAT instructionreturn (int8_t)__SSAT(scaled, 8);}
The diagram below illustrates the exact hardware execution path of quantized int8 tensor data through the Cortex-M4 DSP pipeline during a CMSIS-NN convolution:
+---------------------------------------------------------------------------------+| Cortex-M4 SIMD Execution Dataflow (int8 Quantized) |+---------------------------------------------------------------------------------+| || SRAM Tensor Buffers (Packed int8 Activations & Weights) || +-------------------+ +-------------------+ || | x3 | x2 | x1 | x0| | w3 | w2 | w1 | w0| (4x int8 per 32-bit word) || +-------------------+ +-------------------+ || | | || | LDR (32-bit Load) | LDR (32-bit Load) || v v || +-------------------+ +-------------------+ || | R0: [x3,x2,x1,x0] | | R1: [w3,w2,w1,w0] | || +-------------------+ +-------------------+ || | | || | SXTB16 / Packing | SXTB16 / Packing || v v || +-------------------+ +-------------------+ || | Pair A: [x2, x0] | * * | Pair A: [w2, w0] | (Sign-extended to 16-bit) || | Pair B: [x3, x1] | * * | Pair B: [w3, w1] | || +-------------------+ +-------------------+ || \ / || \ / || v v || +-----------------------+ || | SMLAD / SMLADX | Dual 16-bit Multiply-Accumulate || | (2 MACs / cycle) | Acc += (x0*w0) + (x2*w2) || +-----------------------+ || | || v || +-----------------------+ || | 32-bit Accumulator | Sum of products (with bias added) || +-----------------------+ || | || v || +-----------------------+ || | Requantize & Saturate | Scale multiplier, shift, & SSAT to int8 || +-----------------------+ || | || v || +---------------------------------------+ || | SRAM Output Buffer (Packed int8) | STR (Single-cycle 32-bit store) || +---------------------------------------+ |+---------------------------------------------------------------------------------+
LDR instruction loads 4 bytes simultaneously, reducing bus transactions by 75%.SXTB16 unrolls the packed bytes into signed 16-bit integers without ALU branching or shift loops.SMLAD performs two parallel 16-bit signed multiply-accumulates with 32-bit accumulation in 1 clock cycle.SSAT (Signed Saturate) instruction clips the final scaled fixed-point value into the [-128, 127] range in a single cycle, preventing arithmetic overflow without conditional jump instructions.arm_convolve_s8The following C implementation demonstrates how to configure and execute an optimized 2D convolution using CMSIS-NN v5+. It adheres to the per-channel quantization specification used by TensorFlow Lite for Microcontrollers.
Before allocating buffers, the spatial output dimensions and im2col scratchpad requirements are calculated:
// Output Spatial Dimension Calculation:Output_Height = floor((Input_Height - Kernel_Height + 2 * Padding_Y) / Stride_Y) + 1Output_Width = floor((Input_Width - Kernel_Width + 2 * Padding_X) / Stride_X) + 1// Worked Example (Input: 24x24, Kernel: 3x3, Padding: 0, Stride: 1):Output_Height = ((24 - 3 + 2 * 0) / 1) + 1 = (21 / 1) + 1 = 22Output_Width = ((24 - 3 + 2 * 0) / 1) + 1 = (21 / 1) + 1 = 22// Cortex-M4 im2col Scratchpad SRAM Buffer Calculation:// Holds two columns of unpacked 16-bit values for dual-MAC SMLAD processing:Scratchpad_Bytes = 2 * (Input_Channels * Kernel_Height * Kernel_Width) * sizeof(int16_t)// Worked Example (3 input channels, 3x3 kernel, 2 bytes/int16):Scratchpad_Bytes = 2 * (3 * 3 * 3) * 2 bytes = 2 * 27 * 2 = 108 bytes
#include <stdint.h>#include <stdbool.h>#include "arm_math.h"#include "arm_nnfunctions.h"// Define layer dimensions#define INPUT_X 24#define INPUT_Y 24#define INPUT_CH 3#define KERNEL_X 3#define KERNEL_Y 3#define OUTPUT_CH 16#define PAD_X 0#define PAD_Y 0#define STRIDE_X 1#define STRIDE_Y 1// Calculate output spatial dimensions: ((W - K + 2P)/S) + 1#define OUTPUT_X (((INPUT_X - KERNEL_X + 2 * PAD_X) / STRIDE_X) + 1) // 22#define OUTPUT_Y (((INPUT_Y - KERNEL_Y + 2 * PAD_Y) / STRIDE_Y) + 1) // 22// Statically allocate tensor buffers (typically placed in SRAM via linker script)static int8_t input_data[INPUT_X * INPUT_Y * INPUT_CH];static int8_t filter_data[OUTPUT_CH * KERNEL_Y * KERNEL_X * INPUT_CH]; // OHWI layoutstatic int32_t bias_data[OUTPUT_CH];static int8_t output_data[OUTPUT_X * OUTPUT_Y * OUTPUT_CH];// Per-channel quantization parameters (generated during model quantization)static int32_t output_mult[OUTPUT_CH];static int32_t output_shift[OUTPUT_CH];// Scratchpad buffer for Cortex-M4 DSP im2col matrix multiplication// Buffer requirement on Cortex-M4 DSP cores: 2 * INPUT_CH * KERNEL_X * KERNEL_Y * sizeof(int16_t) = 108 bytes#define SCRATCH_BUF_SIZE (2 * INPUT_CH * KERNEL_X * KERNEL_Y * sizeof(int16_t))static int16_t conv_scratchpad[SCRATCH_BUF_SIZE / sizeof(int16_t)];arm_cmsis_nn_status run_convolution_layer(void){// 1. Configure tensor dimension structurescmsis_nn_dims input_dims = { .n = 1, .h = INPUT_Y, .w = INPUT_X, .c = INPUT_CH };cmsis_nn_dims filter_dims = { .n = OUTPUT_CH, .h = KERNEL_Y, .w = KERNEL_X, .c = INPUT_CH };cmsis_nn_dims bias_dims = { .n = 1, .h = 1, .w = 1, .c = OUTPUT_CH };cmsis_nn_dims output_dims = { .n = 1, .h = OUTPUT_Y, .w = OUTPUT_X, .c = OUTPUT_CH };// 2. Configure convolution parameterscmsis_nn_conv_params conv_params = {.input_offset = 128, // Input zero-point offset (asymmetric int8).output_offset = -128, // Output zero-point offset.stride = { .w = STRIDE_X, .h = STRIDE_Y },.padding = { .w = PAD_X, .h = PAD_Y },.dilation = { .w = 1, .h = 1 },.activation = { .min = -128, .max = 127 } // Clamped to int8 range};// 3. Configure per-channel quantization parameterscmsis_nn_per_channel_quant_params quant_params = {.multiplier = output_mult,.shift = output_shift};// 4. Verify scratchpad buffer sizeint32_t required_buf_size = arm_convolve_s8_get_buffer_size(&input_dims, &filter_dims);if (required_buf_size > (int32_t)sizeof(conv_scratchpad)) {return ARM_CMSIS_NN_ARG_ERROR;}// 5. Initialize CMSIS-NN execution contextcmsis_nn_context ctx = {.buf = conv_scratchpad,.size = required_buf_size};// 6. Execute the SIMD-accelerated convolution kernelarm_cmsis_nn_status status = arm_convolve_s8(&ctx,&conv_params,&quant_params,&input_dims, input_data,&filter_dims, filter_data,&bias_dims, bias_data,&output_dims, output_data);return status;}
To quantify the acceleration provided by CMSIS-NN, benchmarks were conducted on an Arm Cortex-M4 (STM32F429ZI) clocked at 180 MHz with 0 wait-state internal SRAM execution.
Workload specification:
24 × 24 × 3 (H × W × C)3 × 3, 16 output channels1, Padding: 022 × 22 × 16// Total Multiply-Accumulate (MAC) Operations Calculation:Total_MACs = Output_Height * Output_Width * Output_Channels * (Kernel_Height * Kernel_Width * Input_Channels)Step-by-Step Breakdown:1. Output spatial elements : 22 * 22 = 484 pixels2. Output channels (filters): 16 filters3. Filter volume per filter : 3 * 3 * 3 (H * W * In_CH) = 27 weights4. Total MAC operations : 484 * 16 * 27 = 209,088 MACs5. Total FLOP equivalent : 209,088 * 2 = 418,176 FLOPs (1 mult + 1 add)
| Layer Architecture | Naive Scalar C (ms) | CMSIS-NN SIMD (ms) | Speedup Factor | CPU Cycles (Naive) | CPU Cycles (CMSIS-NN) |
|---|---|---|---|---|---|
| 2D Convolution (3x3, 16 ch) | 8.24 ms | 2.12 ms | 3.89x | ~1,483,200 | ~381,600 |
| Depthwise Conv (3x3, 16 ch) | 2.45 ms | 0.74 ms | 3.31x | ~441,000 | ~133,200 |
| Fully Connected (128 units) | 3.12 ms | 0.82 ms | 3.80x | ~561,600 | ~147,600 |
| ReLU Activation (7,744 elem) | 0.42 ms | 0.11 ms | 3.82x | ~75,600 | ~19,800 |
// Cycle-per-MAC Efficiency Metrics (STM32F429ZI @ 180 MHz):Cycles_per_MAC = Total_Execution_Cycles / Total_MACsNaive Scalar C: 1,483,200 cycles / 209,088 MACs = 7.09 cycles / MACCMSIS-NN SIMD: 381,600 cycles / 209,088 MACs = 1.82 cycles / MACSpeedup Factor = 7.09 / 1.82 = 3.89x
SMLAD execution.arm_convolve_s8_get_buffer_size(), the im2col scratchpad consumes only 108 bytes of SRAM for this layer, preserving precious microcontroller RAM.Deploying quantized neural networks to embedded hardware requires systematic validation to avoid silent numerical divergence:
DWT_CYCCNT:
Measure kernel execution using the Cortex-M Data Watchpoint and Trace (DWT) cycle counter:CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;uint32_t start = DWT->CYCCNT;run_convolution_layer();uint32_t cycles = DWT->CYCCNT - start;
ctx.buf) across sequential layers to ensure zero memory waste during inference.Quick Links
Legal Stuff





