Before you read: This reflects my current understanding. I am still learning and may have gotten things wrong. If something looks off, I would genuinely appreciate the correction.

The question I started with

There's a chip called the ESP32-S3. Espressif makes it, it costs about three dollars, and it's inside smart plugs, sensors, toy robots, and half the IoT gadgets you've ever opened up. The marketing calls it an AI-capable chip, and points at a hardware feature called PIE (Processor Instruction Extensions) that can do eight 16-bit multiplications in a single clock cycle.

Which sounds great. But I kept wondering what it means in practice. Does that hardware actually make real code faster? Where does it stop helping? And how far can a three-dollar chip carry the words "AI-capable" before they snap?

There's only one honest way to find out: benchmark the operation AI leans on hardest, matrix multiplication. Build it four ways, from the schoolbook version up to the dedicated hardware, run each one on the actual chip, and let the numbers talk.

So that's what I did.

What is matrix multiplication and why does AI care about it

A matrix is just a grid of numbers. Multiply two of them, A and B, and you get a third, C. Every number in C comes from taking a row of A and a column of B, multiplying them element by element, and adding it all up. That row-times-column sum is called a dot product.

The simplest possible version of the code looks like this:

for (int i = 0; i < n; i++)         // for each row of A
  for (int j = 0; j < n; j++)       // for each column of B
    for (int k = 0; k < n; k++)     // dot product
      C[i][j] += A[i][k] * B[k][j];

Three nested loops. For an n×n matrix, that's n³ multiplications. Double n and the work goes up eight times. This is why big matrix multiplications hurt.

And why does AI care? Because a neural network, underneath everything, is a stack of matrix multiplications. Every layer takes an input vector, multiplies it by a weight matrix, and hands the result to the next layer. Training is finding the right values for those matrices. Inference is pushing your input through them, one multiply after another.

So if a chip multiplies matrices fast, it runs neural networks fast. If it struggles here, it struggles at everything with AI in the name. This is the benchmark that matters.

The hardware and why memory is everything

The ESP32-S3 has two Xtensa LX7 cores running at up to 240 MHz. On the chip itself sits 512 KB of SRAM, fast and close. Hanging off a bus is 8 MB of external PSRAM, much roomier and meaningfully slower.

ChipESP32-S3R8 Ā· LX7
Clock speed240 MHz
Internal SRAM512 KB
External PSRAM8 MB Ā· Octal @ 80 MHz
L1 Data Cache32 KB Ā· 8-way
Cache line size32 bytes

The single most important thing about this chip, and honestly about most chips, is the memory hierarchy. The CPU can tick 240 million times a second, but the data it chews on has to come from somewhere, and where it lives decides how fast anything actually runs.

Concept explainer

What is a cache and why does it exist?

Picture the CPU as a chef. The registers, where the actual computation happens, are the chef's hands. The L1 cache is the countertop: small, right there, instant to grab from. Internal SRAM is a shelf across the room, a short walk. PSRAM is the storage room down the hallway. Plenty of space, but every trip costs real time.

A cache keeps recently used data on the countertop so you stop walking to the storage room. When the CPU wants something, it checks the cache first. If it's there, a cache hit, it's instant. If not, a cache miss, and it fetches from slower memory at a cost of many cycles. Writing fast code on small hardware is mostly the art of having the data on the countertop before the chef reaches for it.

On the ESP32-S3 the data cache is 32 KB. I confirmed this in the sdkconfig file; it's configurable between 16 and 32 KB and defaults to 32 in ESP-IDF v5.x. The cache line is 32 bytes, so every miss drags in 32 consecutive bytes from slower memory.

Here's the hierarchy laid out with the one speed difference this whole benchmark hangs on:

⚔
L1 Data Cache, 32 KB
On-chip. Automatically managed by hardware. This is where hot data lives.
~1 cycle
šŸ’¾
Internal SRAM, 512 KB
On-chip. Fast. But most of this is used by FreeRTOS, stack, and code. Not much left for data.
~4 cycles
🐢
External PSRAM, 8 MB
Off-chip. Connected via Octal SPI bus at 80 MHz. Where large matrices live. Very slow.
~40 cycles

That 40-cycle PSRAM penalty drives everything you're about to see. The CPU can multiply in 1 cycle. If the data lives in PSRAM, it sits idle for 40 waiting for it. For memory-bound work, this chip runs at roughly 2.5% of its theoretical compute speed. Forty cycles of waiting for one cycle of work.

Concept explainer

What is PIE SIMD and what does it actually do?

SIMD stands for Single Instruction, Multiple Data. A normal multiply instruction takes two numbers and gives you one result. A SIMD instruction multiplies many pairs at once inside wider registers, in the same single clock cycle.

PIE gives the S3 128-bit vector registers. A 16-bit integer is 16 bits, so eight of them fit in one register. The instruction EE.VMULAS.S16.QACC grabs two packed registers, multiplies all 8 pairs at once, and accumulates. That's what "8-wide SIMD" means: one instruction doing the work of eight.

A B a0a1a2a3a4a5a6a7××××××××b0b1b2b3b4b5b6b7 Σ accumulator 1 clock
One EE.VMULAS.S16.QACC: eight 16-bit multiplies and an accumulate, in one clock cycle.

On paper that's an 8× speedup. Whether you get it depends entirely on whether you can feed the beast. A SIMD unit waiting on PSRAM is exactly as fast as a scalar unit waiting on PSRAM. Hardware is only as fast as the pipeline behind it.

The four implementations, explained from scratch

Each implementation isolates one variable, so you can see what the compiler contributes, what the memory access pattern contributes, and what the dedicated hardware contributes. All matrices live in PSRAM, because that's where they would have to live for any realistically sized model. Internal SRAM is far too small.

01 matmul_naive, the honest baseline -O0

The textbook three loops, nothing else. I told the compiler to do nothing with -O0: no unrolling, no register reuse, no reordering. This is what you get if you copy the algorithm out of a textbook and hit build.

Its real crime is how it reads matrix B. The inner loop reads B[k][j], and as k climbs you jump across rows of B, touching memory locations 2×n bytes apart at every step. For large matrices nearly every one of those reads is a cache miss, and every miss is a 40-cycle trip to PSRAM. The CPU mostly stands around waiting.

for (int i = 0; i < n; i++) {
  for (int j = 0; j < n; j++) {
    int32_t acc = 0;
    for (int k = 0; k < n; k++) {
      acc += (int32_t)A[i*n+k] * (int32_t)B[k*n+j]; // B[k][j]: jumping rows = cache miss
    }
    C[i*n+j] = (int16_t)acc;
  }
}
02 matmul_opt, let the compiler try -O3

The identical code, compiled with -O3. GCC now throws everything it has at it: unrolled loops, scheduled instructions, values pinned in registers, redundant work eliminated. The algorithm hasn't changed a character. Only the compiler's output has.

That makes it a clean experiment. The gap between naive and opt is purely the compiler's doing. Whatever gap remains after that is work the compiler cannot do for you.

And this is the lesson it teaches: the compiler is brilliant at instruction-level cleverness and helpless against a bad memory access pattern. If your data is in PSRAM and you're striding across it, no compiler flag will save you.

03 matmul_tiled, working with the cache instead of against it -O3 Ā· T=32

Here's where it gets fun. Instead of walking the full matrices in one pass, you chop them into small sub-matrices called tiles and work through one tile-triple at a time. Pick a tile size that fits in L1, and suddenly all the data for the hot inner loop is already sitting in cache. No PSRAM trips where it hurts.

How tiling works

The intuition behind cache-blocking

Think about what naive does. For every output element you touch an entire row of A and an entire column of B. At 256×256, matrix B alone is 128 KB. It doesn't come close to fitting in a 32 KB cache, so you keep re-fetching the same data from PSRAM, over and over, for every single output element.

Tiling reorders the work. You compute a small block of C at a time, and while you do, you only need one tile of A and one tile of B. If those three tiles fit in cache together, every element gets loaded from PSRAM once, reused many times from cache, and that's the whole trick. PSRAM traffic collapses.

#define TILE 32  // 3 tiles x 32^2 x 2 bytes = 6 KB total, fits well in 32 KB cache

for (int ii = 0; ii < n; ii += TILE)
  for (int jj = 0; jj < n; jj += TILE)
    for (int kk = 0; kk < n; kk += TILE)
      // inner 3 loops work on one 32x32 block each, all in cache
      for (int i = ii; i < ii+TILE; i++)
        for (int j = jj; j < jj+TILE; j++) {
          int32_t acc = C[i*n+j];
          for (int k = kk; k < kk+TILE; k++)
            acc += (int32_t)A[i*n+k] * (int32_t)B[k*n+j];
          C[i*n+j] = (int16_t)acc;
        }

T=32 isn't a guess. Three 32×32 tiles of i16 use 3 × 32² × 2 bytes = 6 KB, comfortably inside the 32 KB cache with room left for the stack and the code.

A × B = C 32 KB L1 cache 3 tiles = 6 KB everything else stays in PSRAM until its turn
Tiling: one small block from each matrix fits in cache together, so the hot loop never touches PSRAM.

I also tried T=64, which looked great on paper: 24 KB, fits in 32. It was 2.3× slower at n=256. The catch is that the cache isn't exclusively yours. FreeRTOS, the benchmark harness, the stack and the program instructions all fight for the same 32 KB, so what's actually free for your matrices is much less. T=32 survives the competition because it's conservative. T=64 was on the edge and lost. Which is exactly why you measure instead of calculate.

04 matmul_espdsp, using the PIE SIMD hardware -O3 Ā· PIE i16

This one uses Espressif's esp-dsp library, whose hand-optimized dsps_dotprod_s16 runs on the EE.VMULAS.S16.QACC PIE instruction. The 8-wide SIMD, eight 16-bit multiplies per clock.

Before multiplying, B gets transposed into a temporary Bt. That matters: the dot product wants both vectors contiguous in memory, one row of A against one row of Bt. Transposing first makes every inner-loop access sequential, which is precisely what keeps a SIMD unit fed.

Only i16 gets this path. PIE has no equivalent instruction for 32-bit integers or floats, so those fall back to the same scalar code as opt_O3. That's why the i32 and f32 numbers for the two are identical.

A detour worth mentioning: The original plan was to write custom PIE assembly directly in the source file. This turned out to be harder than expected. The Xtensa assembler rejects the .option directive. That is RISC-V syntax and does not exist in the Xtensa toolchain. GCC's inline assembly constraint system does not recognize Q registers (the PIE vector registers) as named clobbers, because they are TIE extension registers that exist outside GCC's standard model of the machine. After several failed attempts, the decision was made to use Espressif's own esp-dsp library, which wraps all of this correctly and is what Espressif themselves recommend for production use. The file is called matmul_espdsp.c rather than matmul_asm.c to be honest about what it actually does.

Results

Everything measured at 240 MHz, every data point the median of 10 runs. Timing uses CCOUNT, a hardware counter that ticks every clock cycle, cross-checked against esp_timer, which agreed at 239.8 cycles per microsecond.

22.8Ɨ Total speedup naive → SIMD at 128Ɨ128 i16
18.9 ms 128Ɨ128 i16 with SIMD real-time capable
23.5Ɨ PSRAM cliff factor 128→256 scaling (expected 8Ɨ)

i16 benchmark, raw cycles

nnaive_O0opt_O3tiled_O3espdsp
8Ɨ825,2885,9944,4416,648
16Ɨ16190,24841,45826,74522,256
32Ɨ321,481,994312,066187,32198,248
64Ɨ6411,694,6192,427,6441,498,096516,551
128Ɨ128103,655,52829,446,05613,270,1404,541,825
256Ɨ2562,438,528,2772,058,759,211106,912,458148,902,291

Speedup over naive_O0 (i16)

nopt_O3tiled_O3espdsp
8Ɨ84.2Ɨ5.7Ɨ3.8Ɨ
16Ɨ164.6Ɨ7.1Ɨ8.5Ɨ
32Ɨ324.7Ɨ7.9Ɨ15.1Ɨ
64Ɨ644.8Ɨ7.8Ɨ22.6Ɨ
128Ɨ1283.5Ɨ7.8Ɨ22.8Ɨ
256Ɨ2561.2Ɨ22.8Ɨ16.4Ɨ

The opt_O3 speedup collapsing from 4.8× at n=64 to 1.2× at n=256 is my favourite number in the entire benchmark. The compiler didn't get worse. The data left the cache.

Speedup breakdown at 128Ɨ128 i16, the sweet spot

Cycles at n=128 i16, lower is better
naive_O0
103.7M
opt_O3
29.4M
tiled_O3
13.3M
espdsp
4.5M

256Ɨ256 across all data types

impli16i32f32
naive_O02,438M2,715M2,439M
opt_O32,058M2,072M2,060M
tiled_O3106M442M430M
espdsp148M2,072M2,072M

Notice i32 and i16 running neck and neck for naive and opt. At 256×256 everyone is bottlenecked on PSRAM, and data type stops mattering when you spend your life waiting for memory. For tiled, i16 is 4× faster than i32 and f32, because smaller elements mean more of them per tile and better cache use per byte. For espdsp, i32 and f32 have no SIMD path at all, so they run exactly as fast as opt_O3.

What the numbers actually say

Finding 01

The compiler is powerful, until the data leaves the cache

From n=8 to n=64, -O3 delivers a steady 4.7 to 4.8×. That's real work: unrolling, scheduling, register allocation, all of it earning its keep.

Then at n=128 it drops to 3.5×. At n=256, 1.2×. The compiler didn't change. What changed is that three 256×256 matrices occupy 192 KB, blowing past the 32 KB cache and even the 512 KB SRAM. Every read of B in the inner loop goes to PSRAM at 40 cycles a trip. The CPU is stalled roughly 97% of the time.

The lesson: compilers optimize instructions. When your bottleneck is memory latency, instruction cleverness has nothing left to grab. The compiler cannot rewrite your algorithm to touch memory in a better order. That part is your job.

Finding 02

Memory layout mattered more than hardware acceleration

This one genuinely surprised me. Tiling delivers 22.8× over naive and 19× over -O3 at 256×256. The dedicated SIMD hardware also tops out at 22.8× over naive at its best. Same headline number, completely different roads: tiling gets there by fixing the memory bottleneck, SIMD gets there only where the memory bottleneck already doesn't exist.

Put bluntly: if you could apply exactly one optimization to a large matmul on this chip, the right answer is cache-blocking, not SIMD. No special hardware, no library. Just reorganizing the computation to respect the memory hierarchy.

This is the memory wall, a very general phenomenon wearing a three-dollar costume. The gap between compute speed and memory bandwidth has been widening for decades, and on constrained embedded hardware, memory access patterns dominate performance more than anything else does.

Finding 03

There is a cliff at 256Ɨ256 that looks nothing like normal scaling

Matmul is O(n³): double the size, eight times the work. That's exactly what shows up at every size transition up to 128, with naive scaling at 7.5 to 8.9×, right on theory. Then from 128 to 256 it scales at 23.5×. Nearly three times worse than the math says it should.

TransitionActual scaling (naive i16)Theoretical (O(n³))
8 → 167.5Ɨ8Ɨ
16 → 327.8Ɨ8Ɨ
32 → 647.9Ɨ8Ɨ
64 → 1288.9Ɨ8Ɨ
128 → 25623.5Ɨ8Ɨ

Because three 128×128 matrices just barely fit inside the 512 KB SRAM, and the 256×256 ones don't. They spill wholesale into PSRAM. That's the memory cliff, and it's visible in every implementation except tiled, which was built specifically to step around it. The algorithm didn't get worse at the cliff. The hardware underneath it changed.

Finding 04

PIE SIMD gives 2.9x over tiling, but only when data is in cache

At n=64 and n=128, where the working set fits and the SIMD unit stays fed, espdsp runs 2.9× faster than tiled. That's a real hardware win: 4.5M cycles against 13.3M at 128×128, done in 18.9 ms at 240 MHz. Fast enough for keyword spotting, gesture classification, and small sensor models in real time.

The theoretical max from 8-wide SIMD is 8×. The measured win is 2.9×, about 37% efficiency. The missing factor goes to real costs: transposing B first (overhead that grows with n²), calling the dot product once per output element (n² calls), the function call overhead on each, and the loop bookkeeping around the SIMD instructions. A serious kernel would batch many outputs per call. But even at 37%, the hardware is clearly pulling.

Finding 05

SIMD loses to tiling at 256Ɨ256, and this is worth understanding

At n=256, espdsp (148M cycles) is noticeably slower than tiled (106M). Which feels wrong at first. Shouldn't hardware always help? Sit with it for a minute and it makes complete sense.

At 256 the matrices live in PSRAM. espdsp calls the dot product 65,536 times, once per output element, paying prologue, epilogue and argument passing on every call, plus the transpose's access pattern. And the SIMD unit stalls on PSRAM exactly like the scalar code does. It can multiply 8 numbers at once. It cannot make a 40-cycle memory fetch take less than 40 cycles.

Tiling wins because it went after the root cause and starved the PSRAM traffic. SIMD sped up the computing and left the waiting untouched.

A SIMD engine is only as fast as the data pipeline feeding it.

What this means for the AI claim

So, is the ESP32-S3 AI-capable? Based on these measurements: yes, honestly so, with limits that are worth stating just as honestly.

Up to 128×128 with i16 data, the chip does 22.8× over naive and finishes a full multiplication in under 20 ms. That is genuinely enough for:

Past 128×128 it hits the memory wall. PSRAM cannot feed the CPU no matter how the code is arranged. At 256×256 tiled still holds its 22.8× over naive, but the absolute numbers get ugly, and real models with bigger layers will make them uglier.

The S3 is not competing with inference accelerators or a Raspberry Pi, and it was never trying to. It was built for small, always-on inference at milliwatt power and a three-dollar price. At that job, the label on the box is earned.

The bottom line: "AI-capable" means small AI, meaning models with layers ≤128Ɨ128. The chip is genuinely good at keyword spotting, gesture detection, and lightweight sensor inference. It cannot run MobileNet, anything resembling a language model, or modern image classification. Both things are true.

And the deeper lesson has nothing to do with this particular chip. On any hardware where compute outruns memory, the bottleneck is almost never the CPU. Reorganizing memory access beat dedicated SIMD hardware in this benchmark. Thinking about where data lives and how it moves is the most valuable performance skill on constrained hardware, full stop.

The things that went wrong

Two things ate more time than the actual benchmarking. I'm writing them down because they're exactly the kind of thing tutorials never mention.

GCC 14.2.0 internal compiler error

Early on, the build kept dying with an internal compiler error deep in GCC's IRA (Integrated Register Allocator) pass, triggered by esp_lcd_panel_rgb.c. It only fired with one specific combination: -mdisable-hardware-atomics (needed for octal PSRAM), plus -Og, plus having esp_lcd in the build. Three innocent things, one crash.

The fix was to throw esp_lcd out of the build in CMakeLists.txt, since the project never uses it. It's a known bug in that version of the Xtensa toolchain. The compiler itself was crashing, not my code. The lesson: before you spend hours debugging your own files, check whether the error is even coming from your side of the fence.

Xtensa PIE inline assembly turned out to be a dead end

The original plan was to write the SIMD path in inline assembly, calling PIE instructions directly. That hit two walls in about ten minutes. The Xtensa assembler doesn't know the .option directive, which turns out to be RISC-V syntax. And GCC's inline assembly needs you to declare which registers you touch, but the PIE vector registers (Q0 to Q7) are TIE extension registers that live entirely outside GCC's machine description. There is no constraint string for them. As far as GCC is concerned, they do not exist.

After trying standalone assembly files, intrinsics and different toolchain versions, the honest conclusion was that Espressif's own esp-dsp library had been the right answer from the start. It's production-tested, maintained, and wraps the PIE instructions correctly. The implementation file is named matmul_espdsp.c on purpose: it uses the library, not hand-written assembly, and I'd rather the filename say so.

View source on GitHub Full code, sdkconfig, raw benchmark output, and build notes
→
Environment: ESP-IDF v5.5.3 Ā· xtensa-esp-elf GCC 14.2.0 Ā· esp-dsp ^1.0.0 Ā· Board: XH-S3E N16R8 Ā· Target: esp32s3