# Closing the loop: performance benchmarking a control problem on ARM Cortex-M7

## Picking up where I left off

My [last post](https://kedar-joshi.hashnode.dev/benchmarking-a-buck-converter-loop-on-bare-metal-arm-cortex-m7) benchmarked a simulated buck-converter control loop on a Nucleo-F767ZI. I talked about memory placement, killing off divisions, clock speed, I/D-cache. Last best was **100 cycles**, and I left it there. Now I wanted to look deeper and see if that could be brought down further.

## Phase 7: what does -O2 actually buy you?

I didn't want to just rebuild the whole project at `-O2`, because then every number from the last post would shift at once and I'd have no way to say which change caused what. GCC lets you override the optimization level for one function only, so I did that instead:

```c
__attribute__((optimize("O2")))
__attribute__((noinline))
static void control_step_dtcm_zerodiv_o2(void)
{
    /* exact same body as control_step_dtcm_zerodiv() */
    ...
}
```

`noinline` matters here, without it, GCC would just inline this `-O2` function straight into its `-O0` caller and the whole point of isolating it disappears.

| Variant | Cycles |
| --- | --- |
| `-O0` (last post's best) | 100 |
| Same code, `-O2` | 71 |

29% off, just from a compiler flag on one function. I checked the disassembly to see why, and it's satisfying: GCC glued almost every multiply-then-add in the function into a single fused-multiply instruction, on its own, no code change from me:

```plaintext
vfma.f32 s13, s14, s10     ; integral += error * DT
vfma.f32 s15, s14, s11     ; the KI * integral term
vfnms.f32 s14, s13, s15    ; duty * V_IN, fused into a subtract
vfms.f32 s15, s8, s9       ; v_out * INV_R, same deal
vfma.f32 s13, s14, s12     ; i_l += di_dt * DT
vfma.f32 s14, s15, s12     ; v_out += dv_dt * DT
```

(`vfnms`/`vfms` are the same, just for subtraction instead of addition.)

One honest caveat: I can't say that whole 29-cycle drop is "the FMA instruction." `-O0` also just wastes a lot of cycles shuffling things through the stack that `-O2` stops doing regardless of fusion. This number is both effects mixed together, not FMA alone.

## Phase 8: can we make use of SMLAD instruction ?

If we look at this equation:

```c
duty = KP * error + KI * integral;
```

That's two multiplies added together. Exactly the shape the M7's `SMLAD` instruction is built for: one cycle, two 16-bit multiplies, one add. The other two lines (`di_dt`, `dv_dt`) don't have that shape, so I left them alone. Only this one line moved to fixed-point; everything else in the function stayed plain float.

**Before → after, for the four variables in that line:**

| Variable | Before (float) | After (fixed-point) | Why |
| --- | --- | --- | --- |
| `KP` | 0.05 | Q1.15, full precision | small constant, fits with room to spare |
| `error` | up to ~5.0, can overshoot | Q4.11, ±16 headroom | needs range more than precision |
| `integral` | tiny running total (`DT` = 2e-8) | Q1.15, full precision | needs every bit or it rounds down to zero |
| `KI` | 0.01 | Q4.11 → stored as `0.009765625` | forced into the smaller format to match `error`'s product — a ~2.3% error, worth knowing about rather than finding out later |

**What comes out of** `SMLAD`**, and what happens to it after:**

```c
int32_t duty_acc = __smlad(KP_KI_PACKED, err_int, 0);        /* KP*error + KI*integral, one instruction */
int16_t duty_q15 = (int16_t)((duty_acc + (1 << 10)) >> 11);  /* shift back down into q15 range */
duty = (float)duty_q15 * INV_Q15_SCALE;                       /* back to an ordinary float */
```

One instruction does both multiplies and the add. The two lines after it undo the fixed-point trip: shift the accumulator back into q15 range, then convert straight back to float so the rest of the function — `di_dt`, `dv_dt`, both still plain float the whole time.

| Variant | Cycles |
| --- | --- |
| Float, `-O0` | 100 |
| Float, `-O2` | 71 |
| q15 + SMLAD for duty only | 203 |

SMLAD itself really is cheap. One instruction, one cycle, for two multiplies and an add. But getting `error` and `integral` into the right shape for it costs way more than that: rounding and converting each one to fixed-point, then packing them together, adds up to somewhere around 20-25 instructions to save maybe 6. On a chip with a real hardware FPU, plain float math is already about as fast as instructions get, so there was never much room for this to pay off on just one lone multiply-add. Honestly: SMLAD lost here. Roughly 2x worse than the plain `-O0` float version.

## Phase 9: can we go lower?

Every state variable in this whole project is `volatile`. `volatile` forces a real memory access on every single read and write, even inside one function, even when nothing else could possibly be touching that memory at the same time. Looking at the Phase 7 disassembly, that's exactly what's happening — loads and stores to DTCM sitting in between instructions that could otherwise have just passed values register-to-register. Dropping `volatile` from the state (keeping it only on the cycle-count variable itself, which is the actual thing being measured) let `-O2` keep the variables  `error`, `integral`, `duty`, `di_dt`, `dv_dt`, sitting in FPU registers the whole way through, only writing back to memory at the end.

Second idea, from just staring at the five lines: `dv_dt = INV_C * (i_l - v_out*INV_R)` never actually needed `duty`, `error`, or `integral` — it only needs `i_l` and `v_out`, and those don't change until the very end of the function. Moving `dv_dt` equation earlier changes nothing about the math or the result:

```c
float error = V_TARGET - v_out;
float dv_dt = INV_C * (i_l - v_out * INV_R);   /* moved up, same value either way */
integral += error * DT;
duty = KP * error + KI * integral;
```

I tested both changes separately, same as every other phase here:

| Variant | Cycles | Change |
| --- | --- | --- |
| Baseline (`-O2`, still volatile) | 71 | — |
| Volatile dropped only | 60 | −11 |
| Reordered only, volatile kept | 67 | −4 |
| Both together | 56 | −15 |

−11 and −4 add up to exactly −15, so these are two genuinely separate wins. And the disassembly backs both of them up: the de-volatiled version loads each variable once and stores it once instead of round-tripping through memory constantly; the reordered version shows `dv_dt`'s instructions actually mixed in with the rest of the chain's instructions.

**New best case: 56 cycles.** That's 44% off the original `-O0` number, from software changes alone, same chip, same clock.

I also measured these combinations:

| Build | Cycles |
| --- | --- |
| `-O0`, volatile | ~100 |
| `-O0`, non-volatile | ~100 (predicted not measured) |
| `-O2`, volatile | 71 |
| `-O2`, non-volatile | 60 |

(I didn't actually build that second row — `-O0` never keeps a value in a register across two statements regardless of any qualifier, so I don't expect removing `volatile` there to move the number.)

The honest ranking, apples to apples: `-O0` sits around 100 whether or not `volatile` is there, because `-O0` throws away the exact opportunity `volatile` would even be blocking. `-O2` + volatile drops to 71 purely from fusion and general codegen quality. `-O2` + non-volatile drops further to 60, and that last 11-cycle gap is `volatile`'s entire real cost that only shows up once you're running an optimization level aggressive enough to have wanted the register in the first place.

## Where things stand now

| Stage | Cycles |
| --- | --- |
| Original `-O0` baseline | 294 |
| Best case last post (all `-O0`) | 100 |
| Phase 7: same code, one function at `-O2` | 71 |
| Phase 8: duty via SMLAD | 203 (unexpected but makes sense kind of..) |
| Phase 9: `-O2` + no volatile + reordered | 56 |

## Is this the limit, or am I missing something?

*   Phase 8's loss is specific to this chip and this shape of math. Something with several dot products back to back, which is what `SMLAD` is actually built for, could easily go the other way.
    
*   Phase 9's reorder trick only found one independent piece to exploit. A busier control loop might get a lot more out of the same idea.

* `v_out`, `i_l`, and `duty` here would be read from an ADC register and a PWM compare register. Those two specific touchpoints do need `volatile` in a real implementation. The Phase 9 finding doesn't disappear because of that, though, it just narrows: read the ADC once into a plain local at the top of the function, do all the intermediate math (`error`, `integral`, `duty`, `di_dt`, `dv_dt`) as ordinary non-volatile values exactly like this phase did, then write the final `duty` into the PWM register once at the end. Two real volatile touches instead of zero.
    

So here's a genuine question for anyone who does this for a living: is 56 cycles close to what this chip can actually do for this shape of math, or is there real headroom left that I just haven't found? If you've pushed a Cortex-M7 control loop further than this, I'd genuinely like to hear how.

Either way it was worth the time. Went from 294 cycles down to 56 by just paying attention to what the compiler and the hardware were actually doing, one change at a time. 

Please reach out if you want to look at the code! 

Next up is a new physics problem on a different silicon.

Happy weekend :)
