<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Systems Performance]]></title><description><![CDATA[Systems Performance]]></description><link>https://kedar-joshi.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Systems Performance</title><link>https://kedar-joshi.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 03:16:26 GMT</lastBuildDate><atom:link href="https://kedar-joshi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Closing the loop: performance benchmarking a control problem on ARM Cortex-M7]]></title><description><![CDATA[Picking up where I left off
My last post 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 be]]></description><link>https://kedar-joshi.hashnode.dev/closing-the-loop-performance-benchmarking-a-control-problem-on-arm-cortex-m7</link><guid isPermaLink="true">https://kedar-joshi.hashnode.dev/closing-the-loop-performance-benchmarking-a-control-problem-on-arm-cortex-m7</guid><dc:creator><![CDATA[kedar joshi]]></dc:creator><pubDate>Fri, 11 Sep 2026 15:06:47 GMT</pubDate><content:encoded><![CDATA[<h2>Picking up where I left off</h2>
<p>My <a href="https://kedar-joshi.hashnode.dev/benchmarking-a-buck-converter-loop-on-bare-metal-arm-cortex-m7">last post</a> 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 <strong>100 cycles</strong>, and I left it there. Now I wanted to look deeper and see if that could be brought down further.</p>
<h2>Phase 7: what does -O2 actually buy you?</h2>
<p>I didn't want to just rebuild the whole project at <code>-O2</code>, 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:</p>
<pre><code class="language-c">__attribute__((optimize("O2")))
__attribute__((noinline))
static void control_step_dtcm_zerodiv_o2(void)
{
    /* exact same body as control_step_dtcm_zerodiv() */
    ...
}
</code></pre>
<p><code>noinline</code> matters here, without it, GCC would just inline this <code>-O2</code> function straight into its <code>-O0</code> caller and the whole point of isolating it disappears.</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
</tr>
</thead>
<tbody><tr>
<td><code>-O0</code> (last post's best)</td>
<td>100</td>
</tr>
<tr>
<td>Same code, <code>-O2</code></td>
<td>71</td>
</tr>
</tbody></table>
<p>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:</p>
<pre><code class="language-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
</code></pre>
<p>(<code>vfnms</code>/<code>vfms</code> are the same, just for subtraction instead of addition.)</p>
<p>One honest caveat: I can't say that whole 29-cycle drop is "the FMA instruction." <code>-O0</code> also just wastes a lot of cycles shuffling things through the stack that <code>-O2</code> stops doing regardless of fusion. This number is both effects mixed together, not FMA alone.</p>
<h2>Phase 8: can we make use of SMLAD instruction ?</h2>
<p>If we look at this equation:</p>
<pre><code class="language-c">duty = KP * error + KI * integral;
</code></pre>
<p>That's two multiplies added together. Exactly the shape the M7's <code>SMLAD</code> instruction is built for: one cycle, two 16-bit multiplies, one add. The other two lines (<code>di_dt</code>, <code>dv_dt</code>) 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.</p>
<p><strong>Before → after, for the four variables in that line:</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Before (float)</th>
<th>After (fixed-point)</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td><code>KP</code></td>
<td>0.05</td>
<td>Q1.15, full precision</td>
<td>small constant, fits with room to spare</td>
</tr>
<tr>
<td><code>error</code></td>
<td>up to ~5.0, can overshoot</td>
<td>Q4.11, ±16 headroom</td>
<td>needs range more than precision</td>
</tr>
<tr>
<td><code>integral</code></td>
<td>tiny running total (<code>DT</code> = 2e-8)</td>
<td>Q1.15, full precision</td>
<td>needs every bit or it rounds down to zero</td>
</tr>
<tr>
<td><code>KI</code></td>
<td>0.01</td>
<td>Q4.11 → stored as <code>0.009765625</code></td>
<td>forced into the smaller format to match <code>error</code>'s product — a ~2.3% error, worth knowing about rather than finding out later</td>
</tr>
</tbody></table>
<p><strong>What comes out of</strong> <code>SMLAD</code><strong>, and what happens to it after:</strong></p>
<pre><code class="language-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 &lt;&lt; 10)) &gt;&gt; 11);  /* shift back down into q15 range */
duty = (float)duty_q15 * INV_Q15_SCALE;                       /* back to an ordinary float */
</code></pre>
<p>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 — <code>di_dt</code>, <code>dv_dt</code>, both still plain float the whole time.</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
</tr>
</thead>
<tbody><tr>
<td>Float, <code>-O0</code></td>
<td>100</td>
</tr>
<tr>
<td>Float, <code>-O2</code></td>
<td>71</td>
</tr>
<tr>
<td>q15 + SMLAD for duty only</td>
<td>203</td>
</tr>
</tbody></table>
<p>SMLAD itself really is cheap. One instruction, one cycle, for two multiplies and an add. But getting <code>error</code> and <code>integral</code> 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 <code>-O0</code> float version.</p>
<h2>Phase 9: can we go lower?</h2>
<p>Every state variable in this whole project is <code>volatile</code>. <code>volatile</code> 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 <code>volatile</code> from the state (keeping it only on the cycle-count variable itself, which is the actual thing being measured) let <code>-O2</code> keep the variables  <code>error</code>, <code>integral</code>, <code>duty</code>, <code>di_dt</code>, <code>dv_dt</code>, sitting in FPU registers the whole way through, only writing back to memory at the end.</p>
<p>Second idea, from just staring at the five lines: <code>dv_dt = INV_C * (i_l - v_out*INV_R)</code> never actually needed <code>duty</code>, <code>error</code>, or <code>integral</code> — it only needs <code>i_l</code> and <code>v_out</code>, and those don't change until the very end of the function. Moving <code>dv_dt</code> equation earlier changes nothing about the math or the result:</p>
<pre><code class="language-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;
</code></pre>
<p>I tested both changes separately, same as every other phase here:</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
<th>Change</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (<code>-O2</code>, still volatile)</td>
<td>71</td>
<td>—</td>
</tr>
<tr>
<td>Volatile dropped only</td>
<td>60</td>
<td>−11</td>
</tr>
<tr>
<td>Reordered only, volatile kept</td>
<td>67</td>
<td>−4</td>
</tr>
<tr>
<td>Both together</td>
<td>56</td>
<td>−15</td>
</tr>
</tbody></table>
<p>−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 <code>dv_dt</code>'s instructions actually mixed in with the rest of the chain's instructions.</p>
<p><strong>New best case: 56 cycles.</strong> That's 44% off the original <code>-O0</code> number, from software changes alone, same chip, same clock.</p>
<p>I also measured these combinations:</p>
<table>
<thead>
<tr>
<th>Build</th>
<th>Cycles</th>
</tr>
</thead>
<tbody><tr>
<td><code>-O0</code>, volatile</td>
<td>~100</td>
</tr>
<tr>
<td><code>-O0</code>, non-volatile</td>
<td>~100 (predicted not measured)</td>
</tr>
<tr>
<td><code>-O2</code>, volatile</td>
<td>71</td>
</tr>
<tr>
<td><code>-O2</code>, non-volatile</td>
<td>60</td>
</tr>
</tbody></table>
<p>(I didn't actually build that second row — <code>-O0</code> never keeps a value in a register across two statements regardless of any qualifier, so I don't expect removing <code>volatile</code> there to move the number.)</p>
<p>The honest ranking, apples to apples: <code>-O0</code> sits around 100 whether or not <code>volatile</code> is there, because <code>-O0</code> throws away the exact opportunity <code>volatile</code> would even be blocking. <code>-O2</code> + volatile drops to 71 purely from fusion and general codegen quality. <code>-O2</code> + non-volatile drops further to 60, and that last 11-cycle gap is <code>volatile</code>'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.</p>
<h2>Where things stand now</h2>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Cycles</th>
</tr>
</thead>
<tbody><tr>
<td>Original <code>-O0</code> baseline</td>
<td>294</td>
</tr>
<tr>
<td>Best case last post (all <code>-O0</code>)</td>
<td>100</td>
</tr>
<tr>
<td>Phase 7: same code, one function at <code>-O2</code></td>
<td>71</td>
</tr>
<tr>
<td>Phase 8: duty via SMLAD</td>
<td>203 (unexpected but makes sense kind of..)</td>
</tr>
<tr>
<td>Phase 9: <code>-O2</code> + no volatile + reordered</td>
<td>56</td>
</tr>
</tbody></table>
<h2>Is this the limit, or am I missing something?</h2>
<ul>
<li><p>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 <code>SMLAD</code> is actually built for, could easily go the other way.</p>
</li>
<li><p>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.</p>
</li>
<li><p><code>v_out</code>, <code>i_l</code>, and <code>duty</code> here would be read from an ADC register and a PWM compare register. Those two specific touchpoints do need <code>volatile</code> 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 (<code>error</code>, <code>integral</code>, <code>duty</code>, <code>di_dt</code>, <code>dv_dt</code>) as ordinary non-volatile values exactly like this phase did, then write the final <code>duty</code> into the PWM register once at the end. Two real volatile touches instead of zero.</p>
</li>
</ul>
<p>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.</p>
<p>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. </p>
<p>Please reach out if you want to look at the code! </p>
<p>Next up is a new physics problem on a different silicon.</p>
<p>Happy weekend :)</p>
]]></content:encoded></item><item><title><![CDATA[Benchmarking a Buck-Converter Loop on Bare-Metal ARM Cortex-M7



]]></title><description><![CDATA[TL;DR: I set out to understand how fast a simple control loop can generate duty-cycle values to feed to a MOSFET gate driver.
I benchmarked how placement of data in memory/cache, clock frequency, and ]]></description><link>https://kedar-joshi.hashnode.dev/benchmarking-a-buck-converter-loop-on-bare-metal-arm-cortex-m7</link><guid isPermaLink="true">https://kedar-joshi.hashnode.dev/benchmarking-a-buck-converter-loop-on-bare-metal-arm-cortex-m7</guid><category><![CDATA[ARM]]></category><category><![CDATA[cortex]]></category><category><![CDATA[bare-metal]]></category><category><![CDATA[Systems Programming]]></category><category><![CDATA[System Architecture]]></category><dc:creator><![CDATA[kedar joshi]]></dc:creator><pubDate>Mon, 07 Sep 2026 17:44:32 GMT</pubDate><content:encoded><![CDATA[<p><em>TL;DR: I set out to understand how fast a simple control loop can generate duty-cycle values to feed to a MOSFET gate driver.
I benchmarked how placement of data in memory/cache, clock frequency, and instruction caching each affect how fast that control loop can run.
The point: even with high-end MOSFETs capable of switching at very high frequencies, you're still rate-limited by how fast your controller can compute and update the duty cycle. The silicon under your control loop matters as much as the power stage.</em></p>
<h2>Where this started</h2>
<p>I was talking to a few folks building power supply components for AI infrastructure. A lot of engineering is currently focused on innovative Gallium-Nitride based FETs due to their high switching frequencies and low switching losses at high frequencies. I was more curious around how a controller could  cater to such high frequencies. Basically, the controller has to supply a PWM signal to the MOSFET to switch it. So although you have a high performance MOSFET, you need an equally capable controller to be able to switch it. </p>
<p>These converters sit on drones, aircraft, robots, data centers. They need to be cost effective and light-weight and small size. So you can't use a GPU or a desktop computer. You need a microcontroller or a DSP. </p>
<p>So the goal became: take a DSP/microcontroller, reduce the problem (mathematically) to a single simplistic loop, calculate the number of CPU cycles it takes to generate the output of this loop, optimize it. </p>
<p>I have deliberately kept it super simple: no I/O, no ADC, no output PWM, single core CPU. It all boils down to how fast this loop can run, given it has all the inputs 'somewhere' close to the CPU</p>
<p>My development board is a Nucleo-F767ZI: an STM32F767ZI, Arm Cortex-M7, clockable up to 216 MHz, with 512KB of RAM split into 128KB of DTCM, 368KB of AXI SRAM1, and 16KB of AXI SRAM2, plus 16KB each of L1 instruction and data cache.</p>
<p>The datasheet -&gt; <a href="https://www.st.com/resource/en/datasheet/stm32f767zi.pdf">https://www.st.com/resource/en/datasheet/stm32f767zi.pdf</a> </p>
<h2>The application under test</h2>
<p>The thing being optimized is deliberately simple: a simulated single-phase buck converter (12V in, 5V target out) driven by a PI controller — proportional-integral, no derivative term. </p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9e2f8a4b0879bbd8f05404/5845d89d-c4a2-4591-b355-dfae4f55ee7d.png" alt="buck_converter_circuit" /></p>
<p>The physics:</p>
<pre><code class="language-text">L * di_L/dt = D(t) * V_in - v_out
C * dv_out/dt = i_L - v_out / R
</code></pre>
<p>discretized with forward Euler. The controller itself is:</p>
<pre><code class="language-text">error    = V_target - v_out
integral += error * dt
duty      = Kp * error + Ki * integral   (clamped to [0.01, 0.95])
</code></pre>
<p>Our main control loop computes duty cycle (a float, 0.01–0.95) → that gets written into a timer's compare register → the timer hardware generates the actual high-frequency square wave at that duty ratio → a gate driver IC boosts and level-shifts that logic-level signal to what the MOSFET gates need. The control loop only ever touches the first step; everything from the timer onward is fixed-function hardware doing exactly what it's told.</p>
<p>The whole point is: <strong>how fast can this one real control loop run, and what does each architectural detail actually buy us?</strong></p>
<p>That question collapses to a single metric. <code>control_step()</code> runs once per switching cycle, so:</p>
<pre><code class="language-text">max_switching_frequency = clock_frequency / cycles_per_control_step_call
</code></pre>
<p>Every experiment below is really just an attempt to shrink the denominator.</p>
<h2>Methodology</h2>
<p>Timing comes from the Cortex-M7's built-in DWT (Data Watchpoint and Trace) cycle counter — a free-running counter built into the core, no peripheral clock or external hardware needed:</p>
<pre><code class="language-c">#define DEMCR       (*(volatile uint32_t *)0xE000EDFCUL)
#define DWT_LAR     (*(volatile uint32_t *)0xE0001FB0UL)
#define DWT_CTRL    (*(volatile uint32_t *)0xE0001000UL)
#define DWT_CYCCNT  (*(volatile uint32_t *)0xE0001004UL)

static inline void dwt_init(void)
{
    DEMCR   |= (1UL &lt;&lt; 24);   /* TRCENA: turn on the trace/debug block */
    DWT_LAR  = 0xC5ACCE55UL;  /* unlock DWT registers */
    DWT_CYCCNT = 0UL;
    DWT_CTRL |= 1UL;          /* enable the free-running cycle counter */
}
</code></pre>
<p>Each variant of <code>control_step()</code> runs in a tight loop of 10,000 iterations, timed like this:</p>
<pre><code class="language-c">for (loop_count = 0; loop_count &lt; 10000UL; loop_count++)
{
    uint32_t start = DWT_CYCCNT;
    control_step_variant_N();
    uint32_t stop = DWT_CYCCNT;
    elapsed_cycles_variant_N = stop - start;
}
</code></pre>
<p>Only the <em>last</em> iteration's timing survives, that's the steady-state cost once everything has settled.</p>
<p>One more deliberate choice: everything below was built at <code>-O0</code>. That means no compiler optimizations hiding what a straightforward, un-clever implementation actually costs — every intermediate value round-trips through a stack slot, every <code>const</code> read is a real memory load. </p>
<h2>The five variants under test</h2>
<p>Every experiment below reuses the exact same PI control math from <code>control_step()</code> — physics and controller code untouched. Here's the naive version, the starting point every variant is measured against:</p>
<pre><code class="language-c">static void control_step_naive(void)
{
    float error = V_TARGET - v_out;
    integral += error * DT;
    duty = KP * error + KI * integral;

    if (duty &gt; 0.95f) duty = 0.95f;
    if (duty &lt; 0.01f) duty = 0.01f;

    /* three real divisions, no precomputed reciprocals */
    float di_dt = (1.0f / L_VAL) * ((duty * V_IN) - v_out);
    float dv_dt = (1.0f / C_VAL) * (i_l - (v_out / R_LOAD));

    i_l   += di_dt * DT;
    v_out += dv_dt * DT;
}
</code></pre>
<p>Three <code>vdiv.f32</code> instructions per call: <code>1.0f / L_VAL</code>, <code>1.0f / C_VAL</code>, and <code>v_out / R_LOAD</code>. What changes between variants is only two things: where the live state (<code>v_out</code>, <code>i_l</code>, <code>duty</code>, <code>integral</code>) physically sits in memory, and how many of those three divisions get precomputed away as constants — <code>INV_L</code> (= 1/L_VAL), <code>INV_C</code> (= 1/C_VAL), <code>INV_R</code> (= 1/R_LOAD) — instead of recomputed on every call. Five variants exist, testing those two axes independently:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Function</th>
<th>Memory</th>
<th>Divisions per call</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>control_step_sram1()</code></td>
<td>AXI SRAM1 (368KB)</td>
<td>3 real <code>vdiv.f32</code></td>
</tr>
<tr>
<td>2</td>
<td><code>control_step_sram2()</code></td>
<td>AXI SRAM2 (16KB)</td>
<td>3 (identical math to #1)</td>
</tr>
<tr>
<td>3</td>
<td><code>control_step_dtcm_naive()</code></td>
<td>DTCM (128KB)</td>
<td>3 (identical math to #1/#2)</td>
</tr>
<tr>
<td>4</td>
<td><code>control_step_dtcm_partial_div()</code></td>
<td>DTCM</td>
<td>1 (<code>INV_L</code>, <code>INV_C</code> precomputed)</td>
</tr>
<tr>
<td>5</td>
<td><code>control_step_dtcm_zerodiv()</code></td>
<td>DTCM</td>
<td>0 (<code>INV_L</code>, <code>INV_C</code>, <code>INV_R</code> precomputed)</td>
</tr>
</tbody></table>
<p>Variants 4 and 5 aren't a separate axis from 1/2/3 — they're the <em>same</em> DTCM placement, just with the arithmetic changed. </p>
<p>All five variants run back-to-back in the same build, so a single flash-and-run gives a direct, apples-to-apples comparison instead of stitching together numbers from separate rebuilds.</p>
<h2>Phase 0 — starting point: SRAM1 and SRAM2</h2>
<p>The naive, 3-division version of <code>control_step()</code> was measured first with its state living in SRAM1, then again with it moved to SRAM2 — two different physical RAM banks, both reached over the chip's shared AXI bus matrix rather than wired straight into the core. Referenced directly from the datasheet </p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9e2f8a4b0879bbd8f05404/ae3268d8-26f8-4645-859d-db781255543b.png" alt="sram1_path" /></p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
<th>Max switching frequency</th>
</tr>
</thead>
<tbody><tr>
<td>SRAM1 naive</td>
<td>333</td>
<td>48.0 kHz</td>
</tr>
<tr>
<td>SRAM2 naive</td>
<td>340</td>
<td>47.1 kHz</td>
</tr>
</tbody></table>
<p>The 7-cycle gap is noise. Per ST's AN4667 system architecture app note, SRAM1 and SRAM2 are described identically: both plain banks accessible from the AHB bus matrix, with no documented latency difference between them. This is the least-optimized memory placement available on this chip.</p>
<h2>Phase 1 — moving to DTCM</h2>
<p>Same math, same constants, only the variables' physical address changed — this time onto DTCM, the 128KB bank wired directly to the Cortex-M7 core with zero wait states, bypassing the shared bus matrix entirely. Referenced directly from the datasheet</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9e2f8a4b0879bbd8f05404/99e1eb86-0f0e-4195-b003-c608953e5cca.png" alt="dtcm_path" /></p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
<th>Δ vs. SRAM1</th>
</tr>
</thead>
<tbody><tr>
<td>SRAM1 naive</td>
<td>333</td>
<td>—</td>
</tr>
<tr>
<td>DTCM naive</td>
<td>294</td>
<td>−39 (−12%)</td>
</tr>
</tbody></table>
<p>A ~12% drop just from moving three floats and one duty-cycle variable off the shared bus. This DTCM-vs-SRAM gap held at a nearly constant ~39-41 cycles in absolute terms through every later phase that didn't involve caching, even as it shrank as a percentage.</p>
<h2>Phase 2 — DTCM, partial division removal</h2>
<p><code>vdiv.f32</code> is a real cost, but there's no published Cortex-M7 instruction-timing table to quote. The only way to find out is to measure before/after.</p>
<p><code>INV_L</code> and <code>INV_C</code> — the reciprocals of the inductor and capacitor values — get precomputed once as compile-time constants, removing 2 of the 3 divisions per call.</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
<th>Δ vs. DTCM naive</th>
</tr>
</thead>
<tbody><tr>
<td>DTCM naive</td>
<td>294</td>
<td>—</td>
</tr>
<tr>
<td>DTCM partial-div</td>
<td>269</td>
<td>−25</td>
</tr>
</tbody></table>
<p>~14 cycles per division removed after pre-compute. </p>
<h2>Phase 3 — DTCM, all divisions removed</h2>
<p><code>R_LOAD</code> is just as fixed a constant as <code>L_VAL</code>/<code>C_VAL</code> — only <code>v_out</code> (the numerator) varies at runtime — so the last division, <code>v_out / R_LOAD</code>, becomes <code>v_out * INV_R</code> the same way the other two did.</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Cycles</th>
<th>Δ vs. partial-div</th>
<th>Δ vs. naive</th>
</tr>
</thead>
<tbody><tr>
<td>DTCM partial-div</td>
<td>269</td>
<td>—</td>
<td>−25</td>
</tr>
<tr>
<td>DTCM zero-div</td>
<td>258</td>
<td>−11</td>
<td>−36</td>
</tr>
</tbody></table>
<p>Worth noting: the first two divisions saved ~12-13 cycles each (25 total), but the third only saved ~11. Division latency on this core isn't perfectly uniform per call.</p>
<h2>Phase 4 — 216 MHz</h2>
<p>Reaching the chip's full 216 MHz from bare metal takes a specific sequence of register writes: enabling Over-drive mode (required above 180 MHz), raising flash wait states <em>before</em> switching the clock so the core doesn't fetch corrupted instructions once flash can no longer keep up, then configuring and locking the PLL. </p>
<p>Result, same 5-way comparison, now at 216 MHz:</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>16 MHz</th>
<th>216 MHz</th>
<th>Δ</th>
</tr>
</thead>
<tbody><tr>
<td>SRAM1 naive</td>
<td>333</td>
<td>459</td>
<td>+126</td>
</tr>
<tr>
<td>SRAM2 naive</td>
<td>340</td>
<td>462</td>
<td>+122</td>
</tr>
<tr>
<td>DTCM naive</td>
<td>294</td>
<td>418</td>
<td>+124</td>
</tr>
<tr>
<td>DTCM partial-div</td>
<td>269</td>
<td>394</td>
<td>+125</td>
</tr>
<tr>
<td>DTCM zero-div</td>
<td>258</td>
<td>391</td>
<td>+133</td>
</tr>
</tbody></table>
<p>Cycle counts went <em>up</em> by a strikingly uniform ~126 cycles across every variant, regardless of memory placement or division count. That uniformity is itself informative: it's strong evidence the added cost is pure instruction-fetch overhead (same code, same Flash, same new wait-state count for everyone), fully decoupled from the DTCM/SRAM/division questions under test.</p>
<p>The clock went up 13.5x; max achievable switching frequency only went up ~9-10x (e.g. zero-div: 62.0 kHz → 552.4 kHz). That gap — 13.5x of clock buying only ~9.5x of usable frequency — is the real, quantified cost of running unaccelerated flash fetches at high speed.</p>
<h2>Phase 5 — I-cache</h2>
<p>I-cache is the direct mitigation for exactly that gap:</p>
<pre><code class="language-c">#define SCB_CCR   (*(volatile uint32_t *)0xE000ED14UL)
#define ICIALLU   (*(volatile uint32_t *)0xE000EF50UL)

static inline void icache_enable(void)
{
    ICIALLU = 0UL;              /* invalidate I-cache (write-only, self-clearing) */
    __asm volatile ("dsb");
    __asm volatile ("isb");
    SCB_CCR |= (1UL &lt;&lt; 17);     /* IC: instruction cache enable */
    __asm volatile ("dsb");
    __asm volatile ("isb");
}
</code></pre>
<table>
<thead>
<tr>
<th>Variant</th>
<th>216MHz, no cache</th>
<th>216MHz, I-cache</th>
<th>Cycles recovered</th>
</tr>
</thead>
<tbody><tr>
<td>SRAM1 naive</td>
<td>459</td>
<td>401</td>
<td>58 (46%)</td>
</tr>
<tr>
<td>SRAM2 naive</td>
<td>462</td>
<td>408</td>
<td>54 (44%)</td>
</tr>
<tr>
<td>DTCM naive</td>
<td>418</td>
<td>362</td>
<td>56 (45%)</td>
</tr>
<tr>
<td>DTCM partial-div</td>
<td>394</td>
<td>343</td>
<td>51 (41%)</td>
</tr>
<tr>
<td>DTCM zero-div</td>
<td>391</td>
<td>333</td>
<td>58 (44%)</td>
</tr>
</tbody></table>
<p>I-cache recovered a real, uniform ~55 cycles per call — but that's only ~44% of the 216 MHz penalty, not all of it. The likely reason: I-cache only accelerates <em>instruction</em> fetches. <code>control_step()</code> also loads several <code>const float</code> values every call (<code>L_VAL</code>, <code>C_VAL</code>, <code>R_LOAD</code>, <code>V_TARGET</code>, <code>KP</code>, <code>KI</code>, <code>DT</code>), and those live in <code>.rodata</code>, which sits in Flash right next to the code. Loading one is a <code>vldr</code> from a Flash address: a <em>data</em> read. I-cache does nothing for that.</p>
<h2>Phase 6 — D-cache</h2>
<p>D-cache covers exactly the gap I-cache couldn't: data reads/writes to anything reached over the AXI bus matrix — Flash <code>.rodata</code> <em>and</em> SRAM1/SRAM2. It does not cover DTCM, which bypasses the bus matrix entirely.</p>
<pre><code class="language-c">#define SCB_CCSIDR  (*(volatile uint32_t *)0xE000ED80UL)
#define SCB_CSSELR  (*(volatile uint32_t *)0xE000ED84UL)
#define SCB_DCISW   (*(volatile uint32_t *)0xE000EF60UL)

static inline void dcache_enable(void)
{
    uint32_t ccsidr, sets, ways;

    SCB_CSSELR = 0UL;                        /* select L1 data cache */
    __asm volatile ("dsb");

    ccsidr = SCB_CCSIDR;
    sets = (ccsidr &amp; 0x0FFFE000UL) &gt;&gt; 13;    /* NumSets - 1 */
    do {
        ways = (ccsidr &amp; 0x00001FF8UL) &gt;&gt; 3; /* Associativity - 1 */
        do {
            SCB_DCISW = ((sets &lt;&lt; 5) &amp; 0x00003FE0UL) | ((ways &lt;&lt; 30) &amp; 0xC0000000UL);
        } while (ways-- != 0UL);
    } while (sets-- != 0UL);

    __asm volatile ("dsb");
    __asm volatile ("isb");
    SCB_CCR |= (1UL &lt;&lt; 16);   /* DC: data cache enable */
    __asm volatile ("dsb");
    __asm volatile ("isb");
}
</code></pre>
<p>Result:</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>I-cache only</th>
<th>I+D-cache</th>
<th>Drop</th>
</tr>
</thead>
<tbody><tr>
<td>SRAM1 naive</td>
<td>401</td>
<td>151</td>
<td>250</td>
</tr>
<tr>
<td>SRAM2 naive</td>
<td>408</td>
<td>151</td>
<td>257</td>
</tr>
<tr>
<td>DTCM naive</td>
<td>362</td>
<td>154</td>
<td>208</td>
</tr>
<tr>
<td>DTCM partial-div</td>
<td>343</td>
<td>116</td>
<td>227</td>
</tr>
<tr>
<td>DTCM zero-div</td>
<td>333</td>
<td>100</td>
<td>233</td>
</tr>
</tbody></table>
<p><strong>154 vs. 151 vs. 151.</strong> The DTCM-vs-SRAM penalty that held steady at 12-16% through every single earlier phase is gone. Once the working set is small enough to be fully cache-resident (which this tiny, hot, 10,000-times-repeated loop is), <em>where the data physically lives stops mattering</em>. Caching didn't shrink the memory-placement penalty, rather it erased it.</p>
<p>That still leaves a real question: DTCM never touches D-cache at all, so why did the three DTCM variants drop by 208-233 cycles too, not just "modestly"? DTCM's own state genuinely never goes through D-cache. But <code>control_step()</code> also reads several shared plant/controller constants (<code>V_TARGET</code>, <code>DT</code>, <code>KP</code>, <code>KI</code>, <code>L_VAL</code>, <code>V_IN</code>, <code>C_VAL</code>, <code>R_LOAD</code>) that live in Flash <code>.rodata</code>, and those <em>are</em> data-cacheable — for every variant, DTCM included. Once D-cache is warm, each of those reads turns from "cross the AXI bus matrix, pay flash wait states" into "hit in cache" — and a cache hit is fundamentally faster than any bus transaction, wait states or not, because it skips the AXI-bus-matrix crossing entirely, not merely the 216MHz-specific wait-state tax.</p>
<p>Every variant gets the same "Flash constants are now cacheable" benefit, since they all read the same shared constants. SRAM1/SRAM2 get an <em>additional</em> benefit on top of that — their own state variables, which used to cost a bus-matrix trip on every access, now also hit in D-cache. DTCM never collects that second benefit (its state was already bypassing the bus matrix for free), which is exactly why its drop is the smaller of the two groups, not why it barely dropped at all.</p>
<p>Two honest caveats belong right next to that result, not buried in a footnote. First, this doesn't generalize to every workload — a larger working set that doesn't fit in 16KB of D-cache, or a colder/less-repetitive access pattern, would very likely show the DTCM-vs-SRAM gap again. Second, part of the drop's overall size (not its DTCM-vs-SRAM split) is specific to <code>-O0</code>: GCC reloads each <code>const float</code> from Flash on <em>every use</em>, not once per function (<code>DT</code> alone is referenced three times in <code>control_step()</code>). Each of those redundant reloads was a full stalled Flash read before; now they're all cache hits. An <code>-O2</code> build would keep more of these values in registers to begin with, so D-cache's relative contribution there would likely look smaller.</p>
<h2>Summary in a few words</h2>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Cycles</th>
<th>Clock</th>
<th>Max switching frequency</th>
</tr>
</thead>
<tbody><tr>
<td>Phase 0: SRAM1 naive</td>
<td>333</td>
<td>16 MHz</td>
<td>48.0 kHz</td>
</tr>
<tr>
<td>Best case: DTCM zero-div + 216MHz + I+D-cache</td>
<td>100</td>
<td>216 MHz</td>
<td>2.16 MHz</td>
</tr>
</tbody></table>
<p>That's roughly a <strong>45x improvement</strong> in the number that actually matters for a real converter — achievable switching frequency — built entirely from independently measured, real-hardware levers: moving off the shared bus into DTCM, eliminating divisions, raising the clock, enabling I-cache, enabling D-cache.</p>
<p>Again, this is purely raw bare metal performance of the hot loop. We have ignored the ADC, PWM generation, real switch inefficiency and other losses. It also won't be fully analogous to production DSP code, since the state space will likely be larger and other data will be competing for the same cache/SRAM/DTCM space. But the experiment sets a clear direction for where to focus optimization effort. That's the whole point: how fast can we ultimately go with the silicon?</p>
<p>In future, I will post more around such software optimizations meant for real physics problems. Happy week ahead :) </p>
]]></content:encoded></item></channel></rss>