<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Dimitrios Kafetzis · Blog</title>
<link>https://dimitrios-kafetzis.github.io/blog.html</link>
<atom:link href="https://dimitrios-kafetzis.github.io/feed.xml" rel="self" type="application/rss+xml"/>
<description>Lessons learned while building operating systems, inference runtimes and research testbeds for constrained hardware.</description>
<language>en</language>
<lastBuildDate>Sun, 25 Oct 2026 09:00:00 +0000</lastBuildDate>
<item>
<title>A reproducible testbed for partitioned inference on real edge devices</title>
<link>https://dimitrios-kafetzis.github.io/blog/distributed-inference-testbed.html</link>
<guid isPermaLink="true">https://dimitrios-kafetzis.github.io/blog/distributed-inference-testbed.html</guid>
<pubDate>Sun, 25 Oct 2026 09:00:00 +0000</pubDate>
<description>Five Raspberry Pi 4 boards, an edge server and the NNPM web application: how the experimental platform of my PhD runs a partitioned-inference experiment end to end, under controlled network and compute conditions, and how anyone can rerun it.</description>
<content:encoded><![CDATA[<p>Where should a neural network be cut when the device that captured the input cannot run all of it in time? The answer depends on numbers that only real hardware gives: how long each layer takes on a particular board under a particular load, how many bytes cross the link at each candidate cut, how the wireless channel behaves that minute, and how long a request waits at the edge server behind requests from other devices. Simulation can rank options. It cannot produce those numbers.</p>

<p>The <a href="https://github.com/Dimitrios-Kafetzis/Distributed-Inference-Testbed">Distributed Inference Testbed</a> exists to produce them. It is the experimental platform of my PhD thesis, described in its Chapter 3 and Appendix B, and it has carried every hardware measurement in the thesis: the validation of the DNN partitioning model, the per-head and per-block profiles of decoder-only language models, and the HeadSplit experiments on distributed LLM inference. It is also a piece of software that anyone can clone and run in five minutes without owning a single board. This article walks through what it is, what one experiment does, what it measures, and what makes the results repeatable.</p>

<h2>What is on the rack</h2>

<p>The hardware is deliberately ordinary. Five Raspberry Pi 4 Model B boards sit on an open rack, two with 8 GB of memory and three with 4 GB, each with a 32 GB microSD card and no accelerator of any kind. Two to three metres away stands the edge server, an Intel i7-9700K desktop with 32 GB of memory. A USB 802.11ac adapter on that machine hosts an isolated 5 GHz access point. Nothing else is on that network, and it hands out no default route, so the delays we measure belong to our own traffic. The boards and their accessories cost roughly 450 to 500 euros, and the setup guide in the repository lists every part.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 320" role="img" aria-label="The testbed has two networks: an isolated 802.11ac data plane between the edge server's access point and the five boards, and a wired management plane through a switch for SSH, result collection, NTP and package installation" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="testbed-topology-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker></defs><rect x="30" y="70" width="210" height="150" rx="8" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.4"/><text x="135" y="94" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">edge server</text><text x="135" y="110" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">i7-9700K, 32 GB, Ubuntu 22.04</text><rect x="45" y="122" width="180" height="24" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="135" y="138" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">NNPM + orchestration</text><rect x="45" y="152" width="180" height="24" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="135" y="168" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">access point 802.11ac</text><rect x="45" y="182" width="180" height="24" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="135" y="198" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">NTP server, package mirror</text><rect x="330" y="250" width="110" height="34" rx="6" fill="var(--surface-2, #eaeef4)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="385" y="271" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">Ethernet switch</text><rect x="560" y="30" width="170" height="42" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="645" y="47" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">device 1  Raspberry Pi 4</text><text x="645" y="62" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">4 or 8 GB, INA260 on 5 V rail</text><line x1="240" y1="160" x2="560" y2="44" stroke="var(--fig-1, #0f8fa6)" stroke-width="2.2" stroke-linecap="round"/><line x1="440" y1="267" x2="560" y2="60" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="3 3"/><rect x="560" y="84" width="170" height="42" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="645" y="101" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">device 2  Raspberry Pi 4</text><text x="645" y="116" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">4 or 8 GB, INA260 on 5 V rail</text><line x1="240" y1="160" x2="560" y2="98" stroke="var(--fig-1, #0f8fa6)" stroke-width="2.2" stroke-linecap="round"/><line x1="440" y1="267" x2="560" y2="114" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="3 3"/><rect x="560" y="138" width="170" height="42" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="645" y="155" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">device 3  Raspberry Pi 4</text><text x="645" y="170" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">4 or 8 GB, INA260 on 5 V rail</text><line x1="240" y1="160" x2="560" y2="152" stroke="var(--fig-1, #0f8fa6)" stroke-width="2.2" stroke-linecap="round"/><line x1="440" y1="267" x2="560" y2="168" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="3 3"/><rect x="560" y="192" width="170" height="42" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="645" y="209" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">device 4  Raspberry Pi 4</text><text x="645" y="224" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">4 or 8 GB, INA260 on 5 V rail</text><line x1="240" y1="160" x2="560" y2="206" stroke="var(--fig-1, #0f8fa6)" stroke-width="2.2" stroke-linecap="round"/><line x1="440" y1="267" x2="560" y2="222" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="3 3"/><rect x="560" y="246" width="170" height="42" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="645" y="263" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">device 5  Raspberry Pi 4</text><text x="645" y="278" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">4 or 8 GB, INA260 on 5 V rail</text><line x1="240" y1="160" x2="560" y2="260" stroke="var(--fig-1, #0f8fa6)" stroke-width="2.2" stroke-linecap="round"/><line x1="440" y1="267" x2="560" y2="276" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="3 3"/><line x1="240" y1="200" x2="330" y2="267" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="3 3"/><rect x="262" y="26" width="276" height="34" rx="4" fill="var(--surface, #ffffff)"/><text x="400" y="40" font-size="10" text-anchor="middle" fill="var(--fig-1, #0f8fa6)">data plane: isolated 5 GHz 802.11ac</text><text x="400" y="54" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">inference, ping, tc-netem; no default route</text><rect x="262" y="290" width="276" height="26" rx="4" fill="var(--surface, #ffffff)"/><text x="400" y="302" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">management plane: SSH dispatch, results, NTP, mirror</text></svg><figcaption><b>Two networks, one purpose each.</b> The wireless data plane carries only what the experiment measures. Everything that operates the testbed, from SSH dispatch to result collection and time synchronisation, travels over the wired management plane and never competes with the measurement.</figcaption></figure>

<p>Each board also has an Ethernet cable to a small switch. That wired network carries SSH, result collection, NTP and package installation. The wireless network carries inference traffic and nothing else, so a log transfer can never compete with the measurement it is logging.</p>

<p>The edge server runs chrony as the NTP server for both networks. Each board polls it every few seconds, and a run refuses to start if any device's clock offset exceeds one millisecond. Optional INA260 sensors sit in the 5 V supply line of each board and report current, voltage and power over I2C, which is what turns a latency testbed into an energy testbed as well.</p>

<h2>One scenario, one experiment</h2>

<p>An experiment is one YAML file. It names the model, the devices, the repetitions and the seed, the scheduling policy of the edge server, and per device the network conditions, the compute load and any hardware constraints. A sweep field turns one scenario into a family of runs. This is the scenario that validates the DNN partitioning model under both scheduling policies:</p>

<pre><code>name: ch4_validation
model: alexnet
devices: [device1, device2, device3, device4]
repetitions: 5
seed: 2024
edge_server: {policy: SJF, nominal_frequency: true, profile_capacity: true}
inference: {enabled: true, partition_points: [0, 2, 4, 8, 11], repetitions: 50}
load:
  device2: {mode: processes, processes: 1}
  device3: {mode: processes, processes: 2}
  device4: {mode: processes, processes: 3}
optimisation: {enabled: true, algorithm: greedy, objective: total_delay}
sweep: {path: edge_server.policy, values: [RR, SJF]}</code></pre>

<p>The pipeline runs a scenario in seven stages. Preflight creates the run directory and writes the metadata before anything changes: the scenario, the Git commit, framework and kernel versions, and the NTP status of every node. Apply pins the edge server to its base frequency with turbo off, then on each device applies the frequency cap, the memory cgroup, the thread count, the tc-netem shaping, any background traffic, the load generator and the monitors, and waits a settling time. Profile picks a start instant a few seconds ahead and passes it to every device. Each device sleeps until its clock reaches that instant, captures its wall time and its monotonic counter together, and profiles the model layer by layer. Then it runs partitioned inference against the service on the edge server at each requested partition point and records the local, transmission, queueing and remote phases. Collect pings, captures a system snapshot, and copies every result file to the server with the hostname prefixed. Aggregate averages over repetitions, merges the monitor traces onto the common origin, and writes the three files the analytical models consume. Optimise searches for the partition vector. Teardown undoes everything.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 290" role="img" aria-label="One experiment runs in seven stages; every change the apply stage makes is journaled with its undo action before the next step, and teardown replays the journal in reverse on success, failure, Stop or after a crash" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="testbed-pipeline-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="testbed-pipeline-arr-j" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-2, #c47a0a)"/></marker><marker id="testbed-pipeline-arr-t" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-1, #0f8fa6)"/></marker></defs><rect x="19" y="36" width="96" height="50" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="67.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">preflight</text><text x="67.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">metadata, NTP check</text><line x1="115" y1="61" x2="122" y2="61" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-a)"/><rect x="123" y="36" width="96" height="50" rx="6" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="171.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">apply</text><text x="171.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">shape, load, limits</text><line x1="219" y1="61" x2="226" y2="61" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-a)"/><rect x="227" y="36" width="96" height="50" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="275.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">profile</text><text x="275.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">start signal T0</text><line x1="323" y1="61" x2="330" y2="61" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-a)"/><rect x="331" y="36" width="96" height="50" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="379.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">collect</text><text x="379.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">scp + hostname</text><line x1="427" y1="61" x2="434" y2="61" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-a)"/><rect x="435" y="36" width="96" height="50" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="483.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">aggregate</text><text x="483.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">3 thesis files</text><line x1="531" y1="61" x2="538" y2="61" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-a)"/><rect x="539" y="36" width="96" height="50" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="587.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">optimise</text><text x="587.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">RR / SJF search</text><line x1="635" y1="61" x2="642" y2="61" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-a)"/><rect x="643" y="36" width="96" height="50" rx="6" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="691.0" y="57" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">teardown</text><text x="691.0" y="74" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">undo in reverse</text><text x="171" y="112" font-size="9.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">journal.json</text><rect x="113" y="120" width="116" height="17" rx="3" fill="var(--surface, #ffffff)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="171" y="132" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">edge_freq_restore</text><rect x="113" y="140" width="116" height="17" rx="3" fill="var(--surface, #ffffff)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="171" y="152" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">cpufreq_restore</text><rect x="113" y="160" width="116" height="17" rx="3" fill="var(--surface, #ffffff)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="171" y="172" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">cgroup_remove</text><rect x="113" y="180" width="116" height="17" rx="3" fill="var(--surface, #ffffff)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="171" y="192" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">tc_clear</text><rect x="113" y="200" width="116" height="17" rx="3" fill="var(--surface, #ffffff)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="171" y="212" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">stop_process (load)</text><rect x="113" y="220" width="116" height="17" rx="3" fill="var(--surface, #ffffff)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="171" y="232" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">stop_process (monitor)</text><line x1="171.0" y1="86" x2="171.0" y2="100" stroke="var(--fig-2, #c47a0a)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-j)"/><text x="243" y="124" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">append the undo</text><text x="243" y="136" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">before the next step</text><text x="677" y="112" font-size="9.5" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">undo, last first</text><rect x="619" y="120" width="116" height="17" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="677" y="132" font-size="8" text-anchor="middle" fill="var(--fg, #121a2b)">stop_process (monitor)</text><rect x="619" y="140" width="116" height="17" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="677" y="152" font-size="8" text-anchor="middle" fill="var(--fg, #121a2b)">stop_process (load)</text><rect x="619" y="160" width="116" height="17" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="677" y="172" font-size="8" text-anchor="middle" fill="var(--fg, #121a2b)">tc_clear</text><rect x="619" y="180" width="116" height="17" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="677" y="192" font-size="8" text-anchor="middle" fill="var(--fg, #121a2b)">cgroup_remove</text><rect x="619" y="200" width="116" height="17" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="677" y="212" font-size="8" text-anchor="middle" fill="var(--fg, #121a2b)">cpufreq_restore</text><rect x="619" y="220" width="116" height="17" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="677" y="232" font-size="8" text-anchor="middle" fill="var(--fg, #121a2b)">edge_freq_restore</text><line x1="691.0" y1="86" x2="691.0" y2="100" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#testbed-pipeline-arr-t)"/><path d="M455,180 H605" fill="none" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#testbed-pipeline-arr-a)" stroke-dasharray="3 3"/><text x="425" y="170" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">also on failure, Stop,</text><text x="425" y="194" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">crash + clean_devices.sh</text><text x="380" y="272" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">nothing stays resident: every device process is tagged testbed-&lt;run id&gt;, grouped, and killed by group</text></svg><figcaption><b>Write the undo before the do.</b> Each change the apply stage makes is appended to the run journal with its undo action before the next step runs. Teardown replays the journal last-first, continues past failures and records them, then kills any process still carrying the run's tag.</figcaption></figure>

<p>Nothing of the testbed stays resident on a board. Every device-side process is started over SSH for the run, placed in its own process group, tagged with the run id, and killed by group at the end. The only things a board needs permanently are an operating system, an SSH server and the inference runtime. A resident agent would be an uncontrolled background load on the very devices whose background load the experiment controls. The price of that choice is that process lifetime must be managed explicitly, which is what the journal is for, and it holds whether a run completes, fails, is stopped from the web application, or is cleaned up after a crash.</p>

<h2>Controlled conditions</h2>

<p>The thesis defines three compute regimes, and the load generator implements them by name: light is 10 to 20 percent CPU and 10 to 20 percent memory, moderate is 30 to 50 and 30 to 40, heavy is 60 to 80 and 50 to 60. A continuous mode redraws a target every ten seconds from a seeded generator, so a run is noisy in a repeatable way. A processes mode runs a given number of fully busy processes, which is the background-process model of the delay analysis. The CPU load is a duty cycle on one core, busy for a fraction of each 100 ms period, so a 40 percent target really is 40 percent.</p>

<p>Network conditions are applied with tc-netem on the wireless interface: added delay with constant, uniform or square-wave variation over time, jitter, packet loss, a token-bucket rate cap, and optionally a different profile per destination. An iperf3 stream can add background traffic for the whole run. Heterogeneity is emulated with cpufreq caps, cgroups memory limits and thread counts, each recorded before it is applied and restored at teardown. A 1.8 GHz board can stand in for a 1.2 or 0.6 GHz one, and a 4 GB board for a 2 GB one, without touching the rack.</p>

<h2>What gets measured</h2>

<p>The profiler records, per layer, the latency, peak CPU, peak memory, operation count and output size, and per partition point the cumulative versions of those plus the bytes a cut would transmit. Partitioned inference is timed per phase: the local computation, the transmission from the first byte sent to the server's acknowledgement of the last, the wait in the server's queue, and the remote computation.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 240" role="img" aria-label="One partitioned inference request: the device runs stages 1 to s locally, transmits the cut tensor over the wireless link, waits in the edge server's queue behind other devices' requests under round-robin or shortest-job-first, and the server runs stages s+1 to S; the testbed records each phase separately" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="testbed-phases-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker></defs><text x="126" y="78" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">device i</text><line x1="140" y1="90" x2="720" y2="90" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="126" y="130" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">wireless link</text><line x1="140" y1="142" x2="720" y2="142" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="126" y="182" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">edge server</text><line x1="140" y1="194" x2="720" y2="194" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><rect x="140" y="62" width="160" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="220.0" y="74" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">local_ns</text><text x="220.0" y="85" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">stages 1 .. s on the device</text><rect x="302" y="114" width="160" height="26" rx="3" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1.2"/><text x="382.0" y="126" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">transmission_ns</text><text x="382.0" y="137" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">cut tensor, first byte to ack</text><rect x="464" y="166" width="120" height="26" rx="3" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-dasharray="4 3"/><text x="524.0" y="178" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">queue_wait_ns</text><text x="524.0" y="189" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">behind other devices</text><rect x="586" y="166" width="130" height="26" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="651.0" y="178" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">remote_ns</text><text x="651.0" y="189" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">stages s+1 .. S, RR or SJF</text><rect x="718" y="114" width="52" height="26" rx="3" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1.2"/><text x="744.0" y="131" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">result</text><line x1="300" y1="88" x2="302" y2="112" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#testbed-phases-arr-a)"/><line x1="462" y1="140" x2="464" y2="164" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#testbed-phases-arr-a)"/><line x1="716" y1="166" x2="720" y2="140" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#testbed-phases-arr-a)"/><path d="M140,212 v6 H770 v-6" fill="none" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/><text x="430" y="232" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">end-to-end delay per request; every phase is an integer in nanoseconds from perf_counter_ns</text><text x="140" y="44" font-size="9.5" text-anchor="start" fill="var(--muted, #5b6579)">partition point s: the layer after which computation moves to the server; s = S keeps everything local</text></svg><figcaption><b>One request, four phases.</b> The partition point decides how much work stays on the device and how large the tensor that crosses the link is. The queue wait depends on what the other devices are doing and on the server's scheduling policy, which is why the server is part of the experiment and not a fixed cost.</figcaption></figure>

<p>Around the inference runs a monitor samples CPU, memory, temperature, per-core frequency and the firmware throttling flags once a second, so a run can prove it never throttled. Power traces from the INA260 sensors integrate to energy per inference or per token and to the energy-delay product. Ten pings per target and an optional iperf3 throughput test characterise the link as it was during the run, not as the datasheet says.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 230" role="img" aria-label="Each board has its own monotonic counter; at the shared start instant T0 every board captures wall time and counter together, so any later sample maps to the common clock by arithmetic" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="testbed-clock-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker></defs><text x="136" y="58" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">device 1</text><line x1="150" y1="54" x2="700" y2="54" stroke="var(--line, #d8dee8)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#testbed-clock-arr-a)"/><line x1="150" y1="50" x2="150" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="198" y1="50" x2="198" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="246" y1="50" x2="246" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="294" y1="50" x2="294" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="342" y1="50" x2="342" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="390" y1="50" x2="390" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="438" y1="50" x2="438" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="486" y1="50" x2="486" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="534" y1="50" x2="534" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="582" y1="50" x2="582" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="630" y1="50" x2="630" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="678" y1="50" x2="678" y2="58" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><text x="150" y="71" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">perf_counter_ns = 0</text><circle cx="330" cy="54" r="5" fill="var(--fig-2, #c47a0a)" stroke="var(--surface, #ffffff)" stroke-width="2"/><circle cx="510" cy="54" r="4" fill="var(--fig-1, #0f8fa6)" stroke="var(--surface, #ffffff)" stroke-width="2"/><text x="136" y="108" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">device 2</text><line x1="150" y1="104" x2="700" y2="104" stroke="var(--line, #d8dee8)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#testbed-clock-arr-a)"/><line x1="187" y1="100" x2="187" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="235" y1="100" x2="235" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="283" y1="100" x2="283" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="331" y1="100" x2="331" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="379" y1="100" x2="379" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="427" y1="100" x2="427" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="475" y1="100" x2="475" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="523" y1="100" x2="523" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="571" y1="100" x2="571" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="619" y1="100" x2="619" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="667" y1="100" x2="667" y2="108" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><text x="187" y="121" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">perf_counter_ns = 0</text><circle cx="330" cy="104" r="5" fill="var(--fig-2, #c47a0a)" stroke="var(--surface, #ffffff)" stroke-width="2"/><circle cx="510" cy="104" r="4" fill="var(--fig-1, #0f8fa6)" stroke="var(--surface, #ffffff)" stroke-width="2"/><text x="136" y="158" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">device 3</text><line x1="150" y1="154" x2="700" y2="154" stroke="var(--line, #d8dee8)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#testbed-clock-arr-a)"/><line x1="168" y1="150" x2="168" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="216" y1="150" x2="216" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="264" y1="150" x2="264" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="312" y1="150" x2="312" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="360" y1="150" x2="360" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="408" y1="150" x2="408" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="456" y1="150" x2="456" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="504" y1="150" x2="504" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="552" y1="150" x2="552" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="600" y1="150" x2="600" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><line x1="648" y1="150" x2="648" y2="158" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round"/><text x="168" y="171" font-size="8.5" text-anchor="start" fill="var(--muted, #5b6579)">perf_counter_ns = 0</text><circle cx="330" cy="154" r="5" fill="var(--fig-2, #c47a0a)" stroke="var(--surface, #ffffff)" stroke-width="2"/><circle cx="510" cy="154" r="4" fill="var(--fig-1, #0f8fa6)" stroke="var(--surface, #ffffff)" stroke-width="2"/><line x1="330" y1="30" x2="330" y2="172" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2" stroke-linecap="round" stroke-dasharray="4 3"/><text x="330" y="22" font-size="10" text-anchor="middle" fill="var(--fig-2, #c47a0a)">T0: start signal, 3 s ahead</text><text x="510" y="184" font-size="9.5" text-anchor="middle" fill="var(--fig-1, #0f8fa6)">sample</text><text x="150" y="206" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">wall = origin.wall_time_s + (sample.perf_counter_ns - origin.perf_counter_ns) / 1e9</text><text x="150" y="222" font-size="9.5" text-anchor="start" fill="var(--muted, #5b6579)">preflight refuses to start if any device's NTP offset exceeds 1 ms (chrony on the edge server)</text></svg><figcaption><b>One clock for five boards.</b> Nanosecond monotonic timers on five boards have five unrelated zeros. Every record carries the pair captured at the start signal, so aligning traces from different devices is arithmetic rather than guesswork.</figcaption></figure>

<p>All timings are integers in nanoseconds from a monotonic counter, and every record carries the origin pair captured at the start signal. Traces from five boards line up by arithmetic, and the preflight check catches a drifting clock before the run instead of after it.</p>

<h2>From measurements to partitions</h2>

<p>The aggregate stage writes three files: the per-layer averages over repetitions, the largest feasible partition point per device under its memory and CPU constraints with a note on which constraint binds, and the measured link latencies. These are the inputs of the delay model of the thesis. For each device the model adds a local term from the measured cumulative layer times, a transmission term from the cut size and the measured rate plus half the round-trip time, and a remote term that depends on the scheduling policy at the edge server. Under round-robin the server gives each request one stage per round; under shortest-job-first it serves the smallest offloaded workload first, and the remote term of a device includes every workload ranked ahead of it.</p>

<p>The optimiser searches the space of partition vectors against that model. A greedy search moves one device one step at a time and re-evaluates the whole vector; an exhaustive search enumerates up to five million vectors and is used to measure the optimality gap of the heuristics; a trend-based search follows the direction that improved the objective most recently. The objective can be the total delay or the variance of delays across devices, for fairness. New algorithms plug in through a Python entry point, and the same optimiser can be rerun on any finished experiment from the command line or from the web application. The testbed exists to check the model, and the first use of it did exactly that.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 190" role="img" aria-label="Chapter 4 validation on four boards with AlexNet, 50 runs per policy: the delay model predicted 1042 ms under round-robin and the testbed measured 1087 plus or minus 34; under shortest-job-first 986 predicted against 1021 plus or minus 29" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><text x="176" y="34" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">round-robin</text><path d="M190,40 h343.3333333333333 a4,4 0 0 1 4,4 v10 a4,4 0 0 1 -4,4 h-343.3333333333333 z" fill="var(--fig-3, #6250d6)"/><text x="176" y="53" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">predicted</text><text x="545.3333333333333" y="53" font-size="10.5" text-anchor="start" fill="var(--fg, #121a2b)">1,042 ms</text><path d="M190,62 h358.3333333333333 a4,4 0 0 1 4,4 v10 a4,4 0 0 1 -4,4 h-358.3333333333333 z" fill="var(--fig-1, #0f8fa6)"/><text x="176" y="75" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">measured</text><line x1="541.0" y1="71" x2="563.6666666666666" y2="71" stroke="var(--fg, #121a2b)" stroke-width="1.4" stroke-linecap="round"/><line x1="541.0" y1="66" x2="541.0" y2="76" stroke="var(--fg, #121a2b)" stroke-width="1.4" stroke-linecap="round"/><line x1="563.6666666666666" y1="66" x2="563.6666666666666" y2="76" stroke="var(--fg, #121a2b)" stroke-width="1.4" stroke-linecap="round"/><text x="571.6666666666666" y="75" font-size="10.5" text-anchor="start" fill="var(--fg, #121a2b)">1,087 ± 34 ms  (+4.3%)</text><text x="176" y="104" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">shortest-job-first</text><path d="M190,110 h324.66666666666663 a4,4 0 0 1 4,4 v10 a4,4 0 0 1 -4,4 h-324.66666666666663 z" fill="var(--fig-3, #6250d6)"/><text x="176" y="123" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">predicted</text><text x="526.6666666666666" y="123" font-size="10.5" text-anchor="start" fill="var(--fg, #121a2b)">986 ms</text><path d="M190,132 h336.3333333333333 a4,4 0 0 1 4,4 v10 a4,4 0 0 1 -4,4 h-336.3333333333333 z" fill="var(--fig-1, #0f8fa6)"/><text x="176" y="145" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">measured</text><line x1="520.6666666666666" y1="141" x2="540.0" y2="141" stroke="var(--fg, #121a2b)" stroke-width="1.4" stroke-linecap="round"/><line x1="520.6666666666666" y1="136" x2="520.6666666666666" y2="146" stroke="var(--fg, #121a2b)" stroke-width="1.4" stroke-linecap="round"/><line x1="540.0" y1="136" x2="540.0" y2="146" stroke="var(--fg, #121a2b)" stroke-width="1.4" stroke-linecap="round"/><text x="548.0" y="145" font-size="10.5" text-anchor="start" fill="var(--fg, #121a2b)">1,021 ± 29 ms  (+3.6%)</text><line x1="190" y1="26" x2="190" y2="166" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><rect x="190" y="168" width="14" height="12" rx="2" fill="var(--fig-3, #6250d6)"/><text x="210" y="178" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">analytical delay model</text><rect x="380" y="168" width="14" height="12" rx="2" fill="var(--fig-1, #0f8fa6)"/><text x="400" y="178" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">testbed, mean ± std over 50 runs</text></svg><figcaption><b>Model against hardware.</b> AlexNet on four boards, 50 runs per policy. The testbed measured 4.3 percent above the round-robin prediction and 3.6 percent above the shortest-job-first prediction, with the measured variance within 7 percent of the predicted one.</figcaption></figure>

<h2>NNPM: the web application</h2>

<p>Every experiment can be run from the command line, but the normal way to operate the testbed is the Neural Network Architecture Partitioning manager, NNPM. It shows the nodes on a map, probes each one with three pings every two seconds, and turns a marker green only when all three answer. The map is not decoration: nodes can be added by clicking and dragged into place, and the distance between two markers becomes a synthetic link latency, five milliseconds plus one per kilometre, which tc-netem then injects. A topology drawn on a map of a city becomes a network with the delays that city would impose.</p>

<figure class="fig"><img src="../assets/nnpm-local-mode.jpg" alt="The NNPM web application in local mode: five simulated devices and the edge server on a map of Athens with links drawn between them, a completed benchmarking run and a running experiment in the side panel, and the merged live log of the devices in the terminal below." width="1400" height="924" loading="lazy" style="border:1px solid var(--line);border-radius:8px"><figcaption><b>NNPM during a run.</b> Five devices and the edge server on the map, one run completed and one running in the side panel, and the merged terminal streaming profiling output from every node. Map data from OpenStreetMap contributors.</figcaption></figure>

<p>Dialogs create scenarios field by field, with per-node network, load and hardware controls, and save them as versioned documents keyed by content hash, so a run always records exactly which version it executed. The Benchmarking dialog runs the profiling-only pipeline. The Optimise dialog reruns the search on a finished run under either scheduling policy. The Runtimes dialog checks each device's readiness for an inference runtime and performs maintenance actions such as installing it or distributing a checkpoint. A Stop button triggers the full teardown. A terminal shows the merged output of every device as it happens, tagged by node and stream, and a filter narrows it to one node.</p>

<figure class="fig"><img src="../assets/nnpm-run-summary.jpg" alt="The NNPM run summary page: the scenario name and version, the pipeline, runtime, model and devices, the measured edge server capacity, and download links for the three output files, the metadata, the summary, the journal and the merged log." width="1440" height="640" loading="lazy" style="border:1px solid var(--line);border-radius:8px"><figcaption><b>The run summary page.</b> Every run gets a page with its scenario version, its outputs, the three files the analytical models consume, the metadata recorded before any change, the journal and the merged log. Everything the page shows is also available over HTTP.</figcaption></figure>

<h2>Models and runtimes</h2>

<p>The models of the thesis are built in: AlexNet with the eleven partition stages of the DNN chapter, VGG-16 and ResNet-50 at the granularity of Keras layers and blocks, a GRU predictor of video quality, and GPT-2 and TinyLlama-1.1B at the granularity of decoder layers, with per-head, projection and feed-forward timing captured through forward hooks. Beyond those, a generic adapter runs any classifier from the Keras applications collection and finds the single-tensor cut points of its graph automatically, thinning them to stages of comparable work. A second adapter runs any Hugging Face decoder of the GPT-2, Llama, Mistral, Qwen2 or Gemma families and decomposes its attention head-wise from a family table. Model parameters, input sources and a partition-equivalence check, which verifies that the partitioned output matches the unpartitioned one, are fields of the scenario. Weights live in a model catalogue with checksums.</p>

<p>An inference runtime is a plug-in. The built-in runtime profiles layer by layer and runs partitioned inference against the edge server. An external runtime declares a scenario block as a JSON Schema, which the Create scenario dialog renders, plus readiness checks, maintenance actions and a results parser for the summary page. <a href="https://github.com/Dimitrios-Kafetzis/HeadSplit">HeadSplit</a>, my head-level runtime for distributed LLM inference, is the first: it is installed from the mirror at a pinned commit into its own environment, the Runtimes dialog builds its speculation profile on the edge server and distributes the TinyLlama checkpoint with checksums, and a run starts one controller and four workers over PyTorch RPC and collects their results into the same run directory as everything else.</p>

<h2>Reproducible by construction</h2>

<p>Every role has a hash-locked requirements file: edge server, device, device with LLM support, development and documentation, each generated with hashes and installed with hashes required. Provisioning installs boards from a local package mirror that holds the wheels per lock file and architecture, the Debian packages, a bare clone of HeadSplit and the model weights, all listed in a manifest with the SHA-256 of every file. A device can only install with no index. One script talks to the internet to build the mirror; everything else talks to the mirror. Random seeds are set on every node from the scenario, and the metadata of a run records the software versions and hardware facts of every node before the run changes anything.</p>

<p>The whole testbed also runs without hardware. In local mode every node becomes a directory on one machine, device commands run as local subprocesses, the privileged tools run in dry-run mode or against a fake <code>/sys</code> tree, the inference service listens on loopback, and a fake model with configurable stages stands in for a real network. The complete pipeline, the web application, the HeadSplit integration against stub scripts and the provisioning scripts in dry-run mode all run this way in continuous integration on every push, alongside 330 fast tests, 15 slow tests that build the real TensorFlow and PyTorch models, strict type checking, shell linting, and a test that asserts every file, symbol and output name the thesis cites exists in the repository. What local mode cannot verify is written down as a hardware validation checklist, each item with the command to run and the value to expect.</p>

<h2>What it has carried, and what comes next</h2>

<p>Chapter 4 of the thesis used four boards to validate the DNN partitioning model under both scheduling policies, with the result in the chart above. Chapter 5 used the boards as device profiles for a decoder layer, measuring a single attention head at 2.4 to 6.5 ms per token against a feed-forward block at 0.23 to 0.62 s, over a link measured at 19.6 Mbps; the LLM partitioning algorithm plans against those profiles. Chapter 6 ran HeadSplit with one 8 GB controller and four 4 GB workers, and used the shaping and heterogeneity tools to sweep added round-trip time from 0 to 50 ms and to slow selected workers to 1.2 or 0.6 GHz, which is how the operating envelope of head-level partitioning was mapped. The earlier form of the platform was demonstrated at ACM MobiHoc 2024.</p>

<p>The testbed is now a general instrument rather than a thesis appendix. The next additions are more runtimes behind the plug-in interface, so that other distributed-inference systems can be compared on the same boards under the same conditions, and energy as a first-class objective of the optimiser now that every run can carry a power trace. The repository, the documentation site and the citation file are at <a href="https://github.com/Dimitrios-Kafetzis/Distributed-Inference-Testbed">github.com/Dimitrios-Kafetzis/Distributed-Inference-Testbed</a>. If you run it on your own boards, I would like to hear what you measure.</p>
]]></content:encoded>
<category>Testbed</category>
<category>Edge AI</category>
<category>Raspberry Pi</category>
<category>Reproducibility</category>
<category>PhD</category>
</item>
<item>
<title>Ten patches into Zephyr: what I learned about upstream contribution</title>
<link>https://dimitrios-kafetzis.github.io/blog/ten-patches-into-zephyr.html</link>
<guid isPermaLink="true">https://dimitrios-kafetzis.github.io/blog/ten-patches-into-zephyr.html</guid>
<pubDate>Sun, 18 Oct 2026 09:00:00 +0000</pubDate>
<description>How I picked my first issues, what maintainers asked for, and the habits that stuck after ten pull requests to a large RTOS project.</description>
<content:encoded><![CDATA[<h2>Why I started</h2>

<p>My own RTOS project, SynapticOS, runs on Zephyr, and it was pinned to Zephyr 3.7. Moving it forward would surface bugs on my board, the NXP FRDM-MCXN947. The right place to fix those is upstream. The board is supported in-tree, down to an NPU sample that CI only builds and never runs on silicon. And Zephyr 4.6, due in April 2027, is the next LTS. A fix that lands now ships for years.</p>

<p>The timing shaped the work. In early October 2026 Zephyr 4.5 was in release candidates, with the hard freeze on 12 October. Between RC1 and the release only bug fixes merge. So I went looking for bugs I could reproduce on my desk, not features I wanted to add. I opened the first four pull requests on 4 October.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 354" role="img" aria-label="Ten pull requests to Zephyr opened between 4 and 11 October 2026; five merged between 8 and 14 October, five still open on 18 October" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><line x1="300.0" y1="36" x2="300.0" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="300.0" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">4 Oct</text><line x1="361.42857142857144" y1="36" x2="361.42857142857144" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="361.42857142857144" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">6 Oct</text><line x1="422.85714285714283" y1="36" x2="422.85714285714283" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="422.85714285714283" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">8 Oct</text><line x1="484.2857142857143" y1="36" x2="484.2857142857143" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="484.2857142857143" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">10 Oct</text><line x1="545.7142857142857" y1="36" x2="545.7142857142857" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="545.7142857142857" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">12 Oct</text><line x1="607.1428571428571" y1="36" x2="607.1428571428571" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="607.1428571428571" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">14 Oct</text><line x1="668.5714285714286" y1="36" x2="668.5714285714286" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="668.5714285714286" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">16 Oct</text><line x1="730.0" y1="36" x2="730.0" y2="304" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="730.0" y="30" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">18 Oct</text><line x1="545.7142857142857" y1="36" x2="545.7142857142857" y2="304" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2" stroke-linecap="round" stroke-dasharray="4 3"/><text x="549.7142857142857" y="320" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">4.5 hard freeze</text><text x="14" y="59" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121224</text><text x="78" y="59" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">dts: nxp mcxn flash ranges</text><rect x="300.0" y="48" width="430.0" height="16" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1" stroke-dasharray="3 2"/><text x="724.0" y="60" font-size="10" text-anchor="end" fill="var(--fg, #121a2b)">open</text><text x="14" y="85" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121225</text><text x="78" y="85" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">boards: nxp mcxn USB1 host clocks</text><rect x="300.0" y="74" width="430.0" height="16" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1" stroke-dasharray="3 2"/><text x="724.0" y="86" font-size="10" text-anchor="end" fill="var(--fg, #121a2b)">open</text><text x="14" y="111" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121226</text><text x="78" y="111" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">cbprintf: bound packaged strings</text><rect x="300.0" y="100" width="430.0" height="16" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1" stroke-dasharray="3 2"/><text x="724.0" y="112" font-size="10" text-anchor="end" fill="var(--fg, #121a2b)">open</text><text x="14" y="137" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121250</text><text x="78" y="137" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">usb cdc_acm: logging guard</text><rect x="300.0" y="126" width="430.0" height="16" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1" stroke-dasharray="3 2"/><text x="724.0" y="138" font-size="10" text-anchor="end" fill="var(--fg, #121a2b)">open</text><text x="14" y="163" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121251</text><text x="78" y="163" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">cmake: gperf check at configure</text><path d="M300.0,152 h118.85714285714283 a4,4 0 0 1 4,4 v8 a4,4 0 0 1 -4,4 h-118.85714285714283 z" fill="var(--fig-1, #0f8fa6)"/><text x="429.85714285714283" y="164" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">merged</text><text x="14" y="189" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121369</text><text x="78" y="189" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">cmake: APP_BUILD_VERSION via sysbuild</text><path d="M330.7142857142857,178 h118.85714285714283 a4,4 0 0 1 4,4 v8 a4,4 0 0 1 -4,4 h-118.85714285714283 z" fill="var(--fig-1, #0f8fa6)"/><text x="460.57142857142856" y="190" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">merged</text><text x="14" y="215" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121370</text><text x="78" y="215" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">boards: frdm_mcxa577 CONFIG_MUX</text><path d="M330.7142857142857,204 h241.71428571428572 a4,4 0 0 1 4,4 v8 a4,4 0 0 1 -4,4 h-241.71428571428572 z" fill="var(--fig-1, #0f8fa6)"/><text x="583.4285714285714" y="216" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">merged</text><text x="14" y="241" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121602</text><text x="78" y="241" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">soc: opentitan PLIC irq count</text><rect x="422.85714285714283" y="230" width="307.14285714285717" height="16" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1" stroke-dasharray="3 2"/><text x="724.0" y="242" font-size="10" text-anchor="end" fill="var(--fg, #121a2b)">open</text><text x="14" y="267" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121728</text><text x="78" y="267" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">tests: uart async_api RX wait</text><path d="M453.57142857142856,256 h149.57142857142856 a4,4 0 0 1 4,4 v8 a4,4 0 0 1 -4,4 h-149.57142857142856 z" fill="var(--fig-1, #0f8fa6)"/><text x="614.1428571428571" y="268" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">merged</text><text x="14" y="293" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">#121835</text><text x="78" y="293" font-size="10.5" text-anchor="start" fill="var(--muted, #5b6579)">flash_hp_ra: blank check default</text><path d="M515.0,282 h88.14285714285711 a4,4 0 0 1 4,4 v8 a4,4 0 0 1 -4,4 h-88.14285714285711 z" fill="var(--fig-1, #0f8fa6)"/><text x="614.1428571428571" y="294" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">merged</text><rect x="300" y="328" width="14" height="12" rx="2" fill="var(--fig-1, #0f8fa6)"/><text x="320" y="338" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">opened to merged</text><rect x="440" y="328" width="14" height="12" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1" stroke-dasharray="3 2"/><text x="460" y="338" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">still open on 18 Oct</text></svg><figcaption><b>Ten pull requests in two weeks.</b> Opened between 4 and 11 October 2026; five merged between 8 and 14 October, the rest still waiting for review on 18 October. The dashed line is the 4.5 hard freeze, after which only bug fixes merge.</figcaption></figure>

<h2>How I picked the first issues</h2>

<p>I wrote down four conditions before I touched any code. The issue is open, and nobody has a PR for it or has said in the last two weeks that they are on it. I can reproduce or verify it here, on the board, on native_sim or in QEMU. The likely fix is small and local. And it can merge in the current phase of the release cycle. Then I ranked by what could merge now and what I could test on hardware that few others have.</p>

<p>The best source turned out to be NXP's own CI engineers. They file failures on the FRDM-MCXN947 and its sibling boards every week. A contributor who can reproduce those on real silicon and send a small tested fix is rare, and those reports welcomed one. The second source was abandoned work. Issue #114976 had a fix in a PR its author had closed. I picked the idea up, added a unit test that fails without the fix, and opened <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121226">#121226</a>.</p>

<p>Most candidates failed the first condition: in two days of scanning I dropped dozens because the reporter already had a PR up. Before coding I wrote a root-cause note, marked each statement verified or inferred, and posted the cause and reproduction on the issue before opening the PR.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 206" role="img" aria-label="The contribution loop: scout an unclaimed issue, write a root-cause note, claim it on the issue, fix and build a test matrix, open the PR with a Testing and Not-tested section, answer reviews by rebasing, then merge" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="zephyr-loop-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="zephyr-loop-arr-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-2, #c47a0a)"/></marker></defs><rect x="19" y="60" width="98" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="68.0" y="82" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">scout</text><text x="68.0" y="100" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">unclaimed, repro</text><line x1="117" y1="86" x2="122" y2="86" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#zephyr-loop-arr-a)"/><rect x="123" y="60" width="98" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="172.0" y="82" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">root-cause note</text><text x="172.0" y="100" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">verified / inferred</text><line x1="221" y1="86" x2="226" y2="86" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#zephyr-loop-arr-a)"/><rect x="227" y="60" width="98" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="276.0" y="82" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">claim on issue</text><text x="276.0" y="100" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">cause + repro</text><line x1="325" y1="86" x2="330" y2="86" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#zephyr-loop-arr-a)"/><rect x="331" y="60" width="98" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="380.0" y="82" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">fix + tests</text><text x="380.0" y="100" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">sim, QEMU, board</text><line x1="429" y1="86" x2="434" y2="86" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#zephyr-loop-arr-a)"/><rect x="435" y="60" width="98" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="484.0" y="82" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">pull request</text><text x="484.0" y="100" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">Testing, Not tested</text><line x1="533" y1="86" x2="538" y2="86" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#zephyr-loop-arr-a)"/><rect x="539" y="60" width="98" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="588.0" y="82" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">review</text><text x="588.0" y="100" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">rebase, force-push</text><line x1="637" y1="86" x2="642" y2="86" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#zephyr-loop-arr-a)"/><rect x="643" y="60" width="98" height="52" rx="6" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="692.0" y="91" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">merged</text><path d="M589.0,112 v24 H381.0 v-18" fill="none" stroke="var(--fig-2, #c47a0a)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#zephyr-loop-arr-r)"/><text x="485.0" y="152" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">changes requested: smaller fix, shorter commit message</text><text x="380" y="176" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">one nudge after seven days of silence, then stop</text><text x="380" y="194" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">4 Oct to 18 Oct 2026: 10 PRs opened, 5 merged, 24 lines added by the merged ones</text></svg><figcaption><b>The loop each target goes through.</b> The expensive steps are before the pull request: a root-cause note that separates what was verified from what was inferred, and a test matrix that states what did not run.</figcaption></figure>

<h2>The commit message lesson</h2>

<p>My first PR, <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121251">#121251</a>, made a userspace build fail at configure time when gperf is missing, instead of dying late with "GPERF-NOTFOUND". The build system maintainer asked for a different design the next day: move the lookup to FindTargetTools.cmake, which runs after Kconfig, and mark it REQUIRED when CONFIG_USERSPACE is on. I did that the same day. Then the next review comment said: "A very long commit message of how it used to work. Please just describe in a few sentences what this commit does, and cut all the nonsense." The same comment added that "Fixes #issue" belongs in the PR description, not the commit. I rewrote it in twenty minutes and the PR merged on 8 October, my first, which also unlocked automatic CI for later PRs.</p>

<p>Three days later the same reviewer said the same thing on <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121369">#121369</a>, a one-line sysbuild fix that already had three approvals. I reworded it within half an hour. The reply was "much better", and the PR merged that afternoon. The rule I adopted: the commit body says what the change does and why in a few sentences, plus one line on what was tested and what was not. The history, the root-cause narrative and the test matrix go in the PR description. I rewrote four unopened branches the same way.</p>

<h2>The devicetree lesson</h2>

<p><a href="https://github.com/zephyrproject-rtos/zephyr/pull/121224">#121224</a> fixes a 4.5 regression where MCUboot could not find its slot on the FRDM-MCXN947. A refactor had moved a secure-alias <code>ranges</code> from the flash controller to the flash node, and a node's <code>ranges</code> only translates its children, so every partition landed at an address 0x10000000 too large. My fix moved the controller under <code>/soc</code>, as a sibling SoC had done, and NXP's test engineer confirmed it on their board. Then the reviewer NXP pointed to as the expert on mapped partitions requested changes: "missing ranges" on one node, and an empty <code>ranges;</code> that was "not valid, use the proper ranges with values". After the second version the reviewer found the secure and non-secure files "very confusing", and that the controller's <code>ranges</code> must also cover the UID node. An NXP maintainer sketched one <code>ranges</code> entry per child. The first reviewer answered "that'll work". I pushed a third version on 16 October.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 300" role="img" aria-label="Before the fix the secure-alias ranges sat on the flash node and only translated its children, so every partition resolved 0x10000000 too high; after it the controller lives under /soc with one ranges entry per child" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="zephyr-ranges-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker></defs><text x="190" y="24" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">before (4.5 regression)</text><rect x="40" y="40" width="300" height="40" rx="5" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="52" y="57" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">flash controller</text><text x="52" y="72" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">no ranges</text><line x1="190" y1="80" x2="190" y2="92" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#zephyr-ranges-arr-a)"/><rect x="40" y="94" width="300" height="40" rx="5" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="52" y="111" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">flash@0</text><text x="52" y="126" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">ranges = <0x0 0x10000000 ...>  (secure alias)</text><line x1="190" y1="134" x2="190" y2="146" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#zephyr-ranges-arr-a)"/><rect x="40" y="148" width="300" height="40" rx="5" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="52" y="165" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">partitions</text><text x="52" y="180" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">reg = <0x0 ...>  children of flash</text><line x1="190" y1="188" x2="190" y2="200" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#zephyr-ranges-arr-a)"/><rect x="40" y="202" width="300" height="40" rx="5" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="52" y="219" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">slot0_partition</text><text x="52" y="234" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">resolves to 0x1000_0000 + offset</text><text x="190" y="264" font-size="10.5" text-anchor="middle" fill="var(--fig-2, #c47a0a)">MCUboot: "not same sector layout"</text><text x="570" y="24" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">after (#121224)</text><rect x="420" y="40" width="300" height="40" rx="5" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="432" y="57" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">/soc/flash-controller</text><text x="432" y="72" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">ranges = one entry per child</text><line x1="570" y1="80" x2="570" y2="92" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#zephyr-ranges-arr-a)"/><rect x="420" y="94" width="300" height="40" rx="5" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="432" y="111" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">flash@0</text><text x="432" y="126" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">no ranges needed</text><line x1="570" y1="134" x2="570" y2="146" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#zephyr-ranges-arr-a)"/><rect x="420" y="148" width="300" height="40" rx="5" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="432" y="165" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">partitions</text><text x="432" y="180" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">reg = <0x0 ...>  children of flash</text><line x1="570" y1="188" x2="570" y2="200" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#zephyr-ranges-arr-a)"/><rect x="420" y="202" width="300" height="40" rx="5" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="432" y="219" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">slot0_partition</text><text x="432" y="234" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">resolves to 0x0000_0000 + offset</text><text x="570" y="264" font-size="10.5" text-anchor="middle" fill="var(--fig-1, #0f8fa6)">generated DT identical on 15 MCXN targets</text><text x="380" y="290" font-size="10.5" text-anchor="middle" fill="var(--muted, #5b6579)">a node's ranges translates only its own children</text></svg><figcaption><b>Why the partitions moved.</b> A <code>ranges</code> property translates the addresses of its own children only. With the secure-alias translation on the flash node, the partitions underneath inherited the 0x10000000 offset. Moving the controller under <code>/soc</code> with one entry per child restores the secure flash base, and the generated devicetree is byte-identical across all fifteen MCXN targets.</figcaption></figure>

<p>For each version I checked that the generated devicetree was identical on all fifteen MCXN targets and re-ran the MCUboot, flash and hwinfo tests on the board. The code had worked since version one. What changed was how clearly the devicetree said what it meant. When the two reviewers pulled in different directions, I tried the alternative layout, posted a table of the resulting addresses, and offered to rework. As I write this it has one approval and waits for a re-review.</p>

<h2>The scope lesson</h2>

<p><a href="https://github.com/zephyrproject-rtos/zephyr/pull/121225">#121225</a> started as a few lines in three board files. The USB host driver hard-faulted on MCX N boards because the clock setup only ran for the device driver. A reviewer asked me to add the host_uvc sample for the board, so I did, and another contributor ran it with a camera. Then a Nordic reviewer asked whether overriding <code>compatible</code> on the <code>usb1</code> node was right, and it was not. I added a separate disabled <code>usbh1</code> node the way the RT7xx SoCs do. Then the USB maintainer wrote "No board/platform specific overlays" on the sample files. I dropped the sample commit entirely and put the host label in the board devicetree. The final version is smaller than the second one.</p>

<p><a href="https://github.com/zephyrproject-rtos/zephyr/pull/121250">#121250</a> went the same way. I had taught a logging guard in the CDC ACM class about one more chosen node. The USB maintainer answered that chasing chosen nodes from a class driver "does not scale" and the guard should go. I proposed removing it and demoting the per-transfer messages to debug level so the old recursion cannot return, and when no answer came in ten days I did that. The fix the maintainer wants is usually smaller than the one you drafted.</p>

<h2>Habits that stuck</h2>

<p>Every PR body I write now has a Testing section with the exact target, what passed, and a line that starts with "Not tested". That line is what let <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121835">#121835</a> merge. It fixes NVS on Renesas RA boards, which I do not own. I posted the diff on the issue first, the reporter confirmed it on an EK-RA4M2, and the PR cited that run and said plainly that nothing ran on RA hardware here. Four approvals in three days.</p>

<p>I verify other people's PRs on my board. Six fixes I had ready were overtaken by someone else's PR. When I could, I tested theirs on the FRDM-MCXN947 and posted the result instead. <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121728">#121728</a> came from the same CI stream: a UART test that disabled RX before the last byte had looped back.</p>

<p>I answer reviewers within hours and nudge once after seven days, then stop. The nudge on #121226 got the issue reopened after a stale bot closed it under the open PR.</p>

<p>I used Claude Code for the analysis, the builds and the drafts. Zephyr's policy on that is explicit and workable: one <code>Assisted-by</code> trailer per commit, no co-author trailers, and the human reviews, tests, signs the DCO and posts. I approve every commit and every comment before it goes out under my name.</p>

<p>The queue is not under my control; the work is. A two-line defconfig fix, <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121370">#121370</a>, waited eight days for a second approval and then merged. A three-line fix for OpenTitan, <a href="https://github.com/zephyrproject-rtos/zephyr/pull/121602">#121602</a>, has had no review in ten days. Ten PRs in eight days, five merged within ten, and the merged ones add twenty-four lines between them. Small is fine. Tested and honest is what merges.</p>
]]></content:encoded>
<category>Zephyr</category>
<category>Open source</category>
<category>Embedded</category>
</item>
<item>
<title>Why I&#x27;m building an RTOS where inference is the primary workload</title>
<link>https://dimitrios-kafetzis.github.io/blog/inference-first-rtos.html</link>
<guid isPermaLink="true">https://dimitrios-kafetzis.github.io/blog/inference-first-rtos.html</guid>
<pubDate>Sat, 12 Sep 2026 09:00:00 +0000</pubDate>
<description>What goes wrong when a neural network is bolted onto a scheduler built for control loops, and the design decisions behind SynapticOS.</description>
<content:encoded><![CDATA[<h2>What goes wrong when inference is a library</h2>

<p>Most microcontroller AI stacks look the same. An RTOS schedules threads. A TensorFlow Lite Micro interpreter sits inside one of them. The NPU driver sits under that. Inference is an application concern. The OS does not know it is happening.</p>

<p>This works until you have more than one model, a deadline, or a second core. Then the seams show. The heap fragments under tensors that are allocated and freed in lockstep every frame. The NPU sits idle while the CPU copies a frame that DMA could have landed. Nobody owns accelerator state, so two threads can both believe they hold the NPU. A model update is a flash write the scheduler knows nothing about. Every project solves these again, differently, in application code.</p>

<p>I started <a href="https://github.com/Dimitrios-Kafetzis/SynapticOS">SynapticOS</a> to find out what changes when the inference pipeline is the thing the OS is built around. It runs on Zephyr 3.7 and targets the NXP FRDM-MCXN947: two Cortex-M33 cores at 150 MHz, an eIQ Neutron NPU, 512 KB of SRAM and 2 MB of flash, on a board that costs about fifteen dollars. It is Apache 2.0. The same code builds for the board and for qemu_cortex_m3, so every change passes the test suite in emulation before it touches hardware.</p>

<h2>Memory that matches how a network allocates</h2>

<p>The first decision was to stop using a general heap for tensors. A network allocates in a very regular pattern. Weights live for the whole session. Activations live for one frame. Scratch lives for one layer. A bump allocator fits that pattern exactly, and it cannot fragment.</p>

<p>The arena has a persistent region growing up from the base, an ephemeral region after it, and a scratch pool at the top:</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 204" role="img" aria-label="The SynapticOS tensor arena: persistent weights grow from the base, ephemeral activations after them, scratch at the top; the ephemeral pointer is reset every frame" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="syn-arena-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="syn-arena-arr-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-2, #c47a0a)"/></marker></defs><rect x="40" y="60" width="680" height="54" rx="4" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><rect x="40" y="60" width="250" height="54" fill="var(--fig-1-soft, rgba(15,143,166,0.14))"/><rect x="290" y="60" width="190" height="54" fill="var(--fig-2-soft, rgba(196,122,10,0.16))"/><rect x="600" y="60" width="120" height="54" fill="var(--fig-3-soft, rgba(98,80,214,0.14))"/><line x1="290" y1="60" x2="290" y2="114" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round"/><line x1="480" y1="60" x2="480" y2="114" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" stroke-dasharray="3 3"/><line x1="600" y1="60" x2="600" y2="114" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round"/><text x="165.0" y="84" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">persistent</text><text x="165.0" y="100" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">weights, lifetime of the model</text><text x="385.0" y="84" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">ephemeral</text><text x="385.0" y="100" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">activations, one frame</text><text x="540.0" y="91" font-size="11" text-anchor="middle" fill="var(--muted, #5b6579)">free</text><text x="660.0" y="84" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">scratch pool</text><text x="660.0" y="100" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">one layer</text><text x="40" y="32" font-size="11" text-anchor="start" fill="var(--muted, #5b6579)">base</text><line x1="41" y1="42" x2="41" y2="56" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" marker-end="url(#syn-arena-arr-a)"/><text x="290" y="32" font-size="11" text-anchor="middle" fill="var(--muted, #5b6579)">persistent boundary</text><line x1="290" y1="42" x2="290" y2="56" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" marker-end="url(#syn-arena-arr-a)"/><text x="480" y="32" font-size="11" text-anchor="middle" fill="var(--muted, #5b6579)">ephemeral pointer</text><line x1="480" y1="42" x2="480" y2="56" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" marker-end="url(#syn-arena-arr-a)"/><text x="720" y="32" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">base + total</text><line x1="719" y1="42" x2="719" y2="56" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" marker-end="url(#syn-arena-arr-a)"/><path d="M480,124 v18 H292 v-18" fill="none" stroke="var(--fig-2, #c47a0a)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#syn-arena-arr-r)"/><text x="720" y="164" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">syn_mem_reset_ephemeral() every frame: O(1), peak returns to zero</text><text x="40" y="184" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">16-byte aligned, ~154 cycles per allocation, zero fragmentation by construction</text></svg><figcaption><b>The tensor arena.</b> Weights grow from the base and live for the session, activations follow and live for one frame, scratch sits at the top and lives for one layer. Resetting the ephemeral pointer is a single store, so a streaming workload has a constant footprint.</figcaption></figure>

<p>Every tensor pointer is 16-byte aligned because the NPU and the DMA engine need it. An allocation costs about 154 cycles regardless of tensor size. Between frames the ephemeral pointer is reset to the persistent boundary. The face-detection sample does this every frame, and its arena peak returns to zero each time. In the 11,106-job soak test at the end of Phase 5, the arena high-water mark did not move once.</p>

<p>Phase 5 added a planner on top. For DAG models with skip connections, buffer lifetimes run from the defining layer to the last consumer, and a first-fit pass lays them into one plan area. On the 8-layer demo DAG the planned peak is 43 percent below the all-live baseline, and the plan takes 18 microseconds on the board. The plan is deterministic, so a suspended job can replan on resume and get identical offsets.</p>

<h2>The job, not the thread, is the unit of scheduling</h2>

<p>Zephyr threads still exist underneath, but the API submits inference jobs. A job carries a priority class (realtime, normal, best-effort), an optional deadline, and a preemptible flag. A dedicated scheduler thread dispatches by priority first, then earliest deadline within a priority, then FIFO. Deadline misses are counted and visible from the shell.</p>

<p>The reason to schedule jobs instead of threads is layer boundaries. An NPU runs one layer at a time and hands control back between layers. That is a natural preemption point that a thread scheduler cannot see. In SynapticOS a preemptible layered job checks for queued higher-priority work between layers, parks its context in a suspension slot, and resumes bit-exactly later. On the board the context save measures 10 microseconds. That number carries a label: it was measured with the software NPU stub and synthetic layered models, because the Neutron SDK does not expose per-layer invocation. Real preemption granularity on Neutron will be per delegated segment.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 230" role="img" aria-label="A preemptible NORMAL job yields at a layer boundary to a REALTIME job with a deadline, then resumes bit-exactly" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="syn-sched-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="syn-sched-arr-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-2, #c47a0a)"/></marker></defs><text x="136" y="87" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">REALTIME job</text><line x1="150" y1="98" x2="730" y2="98" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="136" y="147" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">NORMAL job</text><line x1="150" y1="158" x2="730" y2="158" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><line x1="150" y1="190" x2="730" y2="190" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#syn-sched-arr-a)"/><text x="730" y="208" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">time</text><rect x="150" y="130" width="110" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="205.0" y="147" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">layer 1</text><rect x="262" y="130" width="110" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="317.0" y="147" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">layer 2</text><rect x="550" y="130" width="86" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="593.0" y="147" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">layer 3</text><rect x="638" y="130" width="86" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="681.0" y="147" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">layer 4</text><rect x="380" y="70" width="182" height="26" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="471.0" y="87" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">realtime job, deadline met</text><line x1="380" y1="48" x2="380" y2="66" stroke="var(--fig-2, #c47a0a)" stroke-width="1.4" stroke-linecap="round" marker-end="url(#syn-sched-arr-r)"/><text x="380" y="42" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">submitted</text><line x1="372" y1="143" x2="372" y2="120" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" stroke-dasharray="2 2"/><text x="372" y="114" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">layer boundary: park context, 10 µs</text><line x1="550" y1="143" x2="550" y2="120" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" stroke-dasharray="2 2"/><text x="550" y="114" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">resume, bit-exact</text><text x="460" y="147" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">suspended</text><text x="150" y="208" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">dispatch: priority, then earliest deadline, then FIFO</text></svg><figcaption><b>Layer-boundary preemption.</b> A preemptible job checks for higher-priority work between layers, parks its context in a suspension slot and resumes bit-exactly. The 10 microsecond context save was measured with the software NPU stub.</figcaption></figure>

<p>The pipeline is an OS object too. It is drawn from a static pool, validated against a canonical pre-process, model, post-process order, and its stage buffers are sized from tensor geometry at build time. There is no heap on the inference path.</p>

<h2>The second core is a client, and the model is a file with a rollback</h2>

<p>The MCXN947's second core has no FPU, no DSP extension, no TrustZone and no MPU. I treated that as a design input. CPU0 owns the models, the NPU and the scheduler. CPU1 runs the application and reaches inference through a message-based service over shared SRAM. The transport is two lock-free single-producer single-consumer rings, one writer per index, ordered by data memory barriers alone, because the platform has no cross-core atomics worth trusting. Round trips measure 15 microseconds typical and 81 worst case on the board. Tensors stage zero-copy in a shared slot; a 27 KB frame cannot exist twice in 64 KB of RAM. Protection is one-directional. CPU0's MPU guards CPU1's RAM and aborts only the offending thread, but nothing can guard the other way.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 284" role="img" aria-label="CPU0 owns the AI runtime and the NPU; CPU1 reaches inference only through two single-producer single-consumer rings in shared SRAM; the MPU guard protects CPU1's RAM in one direction" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="syn-dualcore-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="syn-dualcore-arr-f" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="syn-dualcore-arr-g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-2, #c47a0a)"/></marker></defs><rect x="30" y="30" width="300" height="210" rx="8" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.4"/><text x="45" y="54" font-size="12" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">CPU0  Cortex-M33 with FPU, DSP, MPU</text><text x="45" y="72" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">owns the AI runtime</text><rect x="45" y="88" width="270" height="28" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="180" y="106" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">job scheduler</text><rect x="45" y="124" width="270" height="28" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="180" y="142" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">model store + OTA</text><rect x="45" y="160" width="270" height="28" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="180" y="178" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">NPU / DSP HAL</text><rect x="45" y="196" width="270" height="28" rx="4" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="180" y="214" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">tensor arena</text><rect x="560" y="30" width="170" height="210" rx="8" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.4"/><text x="575" y="54" font-size="12" text-anchor="start" fill="var(--fg, #121a2b)" font-weight="600">CPU1  Cortex-M33</text><text x="575" y="72" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">no FPU, DSP, MPU or TZ</text><rect x="575" y="88" width="140" height="28" rx="4" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1"/><text x="645" y="106" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">application</text><rect x="575" y="124" width="140" height="28" rx="4" fill="var(--fig-3-soft, rgba(98,80,214,0.14))" stroke="var(--fig-3, #6250d6)" stroke-width="1"/><text x="645" y="142" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)">inference client</text><text x="645" y="180" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">64 KB RAM</text><rect x="360" y="60" width="170" height="150" rx="6" fill="var(--surface-2, #eaeef4)" stroke="var(--line, #d8dee8)" stroke-width="1"/><text x="445" y="80" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">shared SRAM</text><line x1="330" y1="112" x2="560" y2="112" stroke="var(--fg, #121a2b)" stroke-width="1.6" stroke-linecap="round" marker-end="url(#syn-dualcore-arr-f)"/><text x="445" y="104" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">request ring (SPSC)</text><line x1="560" y1="150" x2="330" y2="150" stroke="var(--fg, #121a2b)" stroke-width="1.6" stroke-linecap="round" marker-end="url(#syn-dualcore-arr-f)"/><text x="445" y="166" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">response ring (SPSC)</text><rect x="370" y="180" width="150" height="20" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1"/><text x="445" y="194" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">tensor slot, zero copy</text><path d="M180,240 v14 H645 v-14" fill="none" stroke="var(--fig-2, #c47a0a)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#syn-dualcore-arr-g)" stroke-dasharray="4 3"/><text x="412" y="276" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">CPU0 MPU region 7 maps CPU1's RAM read-only; nothing guards the other way</text><text x="445" y="40" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">15 µs typical, 81 µs max round trip</text></svg><figcaption><b>Inference as a cross-core service.</b> CPU1 never touches the NPU or the models. It writes a request into a single-producer single-consumer ring, stages the tensor in a shared slot, and reads the response from the second ring. CPU0's MPU guards CPU1's RAM read-only; the asymmetric silicon offers no guard in the other direction.</figcaption></figure>

<p>Models are persistent objects in flash. A ping-pong registry pair with a generation counter and CRC32 gives atomic, power-loss-safe commits, and the newest valid copy wins at boot. Two 440 KB model slots are A/B: whichever slot the active registry does not reference is the staging target for the next over-the-air update. Every load passes a CRC gate. Registry commits take 1.9 to 2.6 ms and the boot scan 23 to 189 microseconds, measured on the board. A reset injected at 81 percent of a raw transfer left the store intact and the old model serving after reboot.</p>

<h2>What the board taught me</h2>

<p>QEMU passes everything. The board does not, and that is the point of owning one.</p>

<p>Releasing CPU1 into erased flash wedges the whole chip, debug port included. Erased flash raises ECC bus errors, and CPU1's vector fetch stalls the flash system CPU0 executes from. Recovery is ISP only. The runtime now blank-checks the bank through the ROM API before releasing the core.</p>

<p>A single ROM-API erase spanning 55 sectors wedged the chip the same way, while every one-sector erase before and after was fine. Erases are now sector-wise with 1 ms breathers.</p>

<p>A placeholder limit became a field bug. The stub NPU HAL had an arbitrary 256 KB cap, and it refused a valid, stored, activated 432 KB model. Rollback restored service in one shell command, an involuntary demo of why rollback exists.</p>

<p>Zephyr 3.7's eDMA driver never issues the software START that memory-to-memory transfers need on eDMA v4. Secure-alias pointers bus-error inside the DMA. The tensor arena itself turned out not to be DMA-reachable under the current bus security attributes, and because the SoC shares one no-error IRQ, a faulted transfer is a silent timeout. The ingest benchmark moved to DMA-reachable static buffers, where DMA lands 8 KB frames at 131 microseconds each against 460 for a CPU copy. Arena DMA visibility is still open.</p>

<p>And a preempting submitter must outrank the scheduler thread, or it can never inject mid-job. QEMU's cooperative test threads had hidden that completely.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 200" role="img" aria-label="Measured on the FRDM-MCXN947: DMA ingest lands an 8 KB frame in 131 microseconds against 460 for a CPU copy; PowerQuad computes a 256-point FFT in 132 microseconds against 403 in software" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><text x="216" y="44" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">8 KB frame ingest</text><path d="M230,52 h428.4 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-428.4 z" fill="var(--fig-1, #0f8fa6)"/><text x="216" y="66" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">CPU copy</text><text x="670.4" y="66" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">460 µs</text><path d="M230,80 h119.13999999999999 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-119.13999999999999 z" fill="var(--fig-2, #c47a0a)"/><text x="216" y="94" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">eDMA</text><text x="361.14" y="94" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">131 µs</text><text x="216" y="130" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">256-point FFT</text><path d="M230,138 h374.82 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-374.82 z" fill="var(--fig-1, #0f8fa6)"/><text x="216" y="152" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">software, hard FPU</text><text x="616.8199999999999" y="152" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">403 µs</text><path d="M230,166 h120.08 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-120.08 z" fill="var(--fig-2, #c47a0a)"/><text x="216" y="180" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">PowerQuad</text><text x="362.08" y="180" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">132 µs</text><line x1="230" y1="36" x2="230" y2="176" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/></svg><figcaption><b>Two offloads measured on the board.</b> eDMA ingest of 8 KB frames against a CPU copy, and the PowerQuad FFT against a software kernel using the hard FPU. The FFT gain is real but far from the 10x the plan asked for.</figcaption></figure>

<p>The README records each of these, with the miss next to the fix. The PowerQuad DSP offload reaches 3.03x on a 256-point FFT and 1.72x on a Q15 matmul against hard-FPU software kernels, short of the 10x the plan asked for. The 500 KB in 10 seconds OTA target cannot be met at 115200 baud; the transport already runs at 98.7 percent of line rate, so the fix is a faster link, not more software.</p>

<h2>What is still open</h2>

<p>The biggest caveat is the stub label. Through Phase 5, every inference latency was measured on a deterministic software NPU stub, because the Neutron driver ships as closed binaries under NXP's licence and cannot be vendored into an Apache 2.0 tree. Phase 6 integrates it as an optional module, and the first real Neutron inference is live: a 32x32x3 INT8 classifier at 6.6 ms when resident, 17.1 ms including the on-demand swap the single-residency NPU needs. The stub-labelled figures stay labelled until a real 96x96 model is converted.</p>

<p>Also open: arena DMA visibility, a quiesce that drains only the running job and not suspended ones, multi-model NPU residency, power management, and the real camera path. The three arXiv papers (<a href="https://arxiv.org/abs/2607.12606">2607.12606</a>, <a href="https://arxiv.org/abs/2607.12614">2607.12614</a> and <a href="https://arxiv.org/abs/2607.12620">2607.12620</a>) document Phases 1 through 3 with the measured numbers and the deviations from plan. If you work on MCU inference, I would like to know what breaks on your board.</p>
]]></content:encoded>
<category>SynapticOS</category>
<category>Zephyr</category>
<category>NPU</category>
<category>Embedded</category>
</item>
<item>
<title>What splitting an LLM across Raspberry Pis taught me</title>
<link>https://dimitrios-kafetzis.github.io/blog/splitting-an-llm-across-raspberry-pis.html</link>
<guid isPermaLink="true">https://dimitrios-kafetzis.github.io/blog/splitting-an-llm-across-raspberry-pis.html</guid>
<pubDate>Sun, 05 Jul 2026 09:00:00 +0000</pubDate>
<description>A small language model does not fit one edge device. Lessons from a WiFi testbed, a simulator and two papers on partitioning inference.</description>
<content:encoded><![CDATA[<p>A small language model does not fit on a Raspberry Pi. TinyLlama-1.1B needs about 4.4 GB of memory. A Raspberry Pi 4 has 4 GB, and the operating system takes some of it. So before I could ask anything about latency, I had to answer a simpler question: how do you run a model that no single device can hold? You split it across several devices and pay for it over the network. This article is about what that payment costs and how I lowered it.</p>

<p>The setup was five Raspberry Pi 4 Model B boards, 4 GB of RAM each, a Cortex-A72 at 1.8 GHz, on 802.11ac WiFi through a dedicated access point. One board is the controller. Four are workers. Measured round-trip time between boards was 8 to 12 ms.</p>

<h2>Where the time goes</h2>

<p>Before LLMs I built a testbed for ordinary DNN partitioning: AlexNet-class models on CIFAR-10, split at layer boundaries across Raspberry Pis talking to a base station over WiFi. It profiled per-layer latency, CPU and memory on every device, measured link latencies, and fed all of it to a few optimisers. It also had <code>tc</code>-based latency injection and INA260 power sensors. We demonstrated it at ACM MobiHoc 2024.</p>

<p>That testbed taught me one thing above all: you cannot reason about a partition without measured link latencies. A layer's compute profile is half the story. The other half is how long an activation takes to cross from one Pi to the next, and on WiFi that number moves.</p>

<p>The second lesson came from simulation. Decoder-only transformers generate one token at a time, and each token appends keys and values to a per-head KV cache. Memory grows for as long as you generate. For our WiOpt 2025 paper we built a discrete-event simulator of a decoder layer across 25 heterogeneous devices. By 1000 tokens, a layer-based EdgeShard-style placement passed 1000 seconds of latency and Galaxy sat around 400 to 600 seconds, while head-level placement with dynamic migration stayed under 200. With 3 to 5 devices, our heuristic was within 15 to 20 percent of an exact solver.</p>

<p>I want to be honest about those numbers. The simulator assumed 1 to 10 Gbps links. WiFi between Pis is nothing like that. The simulation said head-level partitioning was worth pursuing. It did not say what I would get on hardware.</p>

<h2>Splitting by heads instead of layers</h2>

<p>The standard split is layer-wise: layer 1 on device 1, layer 2 on device 2, and so on. It is simple. It also means one device computes while the others wait. And if one layer does not fit the smallest device, it does not help at all.</p>

<p>Attention heads give another cut. The heads in a layer are independent, and each owns its slice of the KV cache. So each head can be a unit of placement, and every device can work on the same layer at once. Head outputs are gathered at the device holding the projection, and the result is broadcast to the next layer's devices.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 230" role="img" aria-label="One controller holds embeddings, the LM head, placement and the token loop; four workers hold attention heads, projections and FFN blocks and exchange activations over PyTorch RPC on WiFi" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="llm-system-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="llm-system-arr-g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-1, #0f8fa6)"/></marker></defs><rect x="270" y="20" width="220" height="60" rx="8" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.3"/><text x="380" y="42" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">controller (Raspberry Pi 4)</text><text x="380" y="60" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">embeddings, LM head, placement,</text><text x="380" y="72" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">speculation decisions, token loop</text><rect x="40" y="140" width="140" height="60" rx="8" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.3"/><text x="110" y="160" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">worker 1</text><text x="110" y="177" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">heads, projection, FFN</text><text x="110" y="190" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">4 GB, Cortex-A72</text><line x1="380" y1="80" x2="110" y2="140" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#llm-system-arr-a)"/><rect x="220" y="140" width="140" height="60" rx="8" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.3"/><text x="290" y="160" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">worker 2</text><text x="290" y="177" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">heads, projection, FFN</text><text x="290" y="190" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">4 GB, Cortex-A72</text><line x1="380" y1="80" x2="290" y2="140" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#llm-system-arr-a)"/><rect x="400" y="140" width="140" height="60" rx="8" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.3"/><text x="470" y="160" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">worker 3</text><text x="470" y="177" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">heads, projection, FFN</text><text x="470" y="190" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">4 GB, Cortex-A72</text><line x1="380" y1="80" x2="470" y2="140" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#llm-system-arr-a)"/><rect x="580" y="140" width="140" height="60" rx="8" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.3"/><text x="650" y="160" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">worker 4</text><text x="650" y="177" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">heads, projection, FFN</text><text x="650" y="190" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">4 GB, Cortex-A72</text><line x1="380" y1="80" x2="650" y2="140" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" marker-end="url(#llm-system-arr-a)"/><rect x="200" y="106" width="360" height="18" rx="4" fill="var(--surface, #ffffff)"/><text x="380" y="119" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">PyTorch RPC over 802.11ac, 8 to 12 ms RTT, retries for jitter</text><path d="M180,215 H580" fill="none" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#llm-system-arr-g)" stroke-dasharray="4 3"/><text x="380" y="226" font-size="9.5" text-anchor="middle" fill="var(--muted, #5b6579)">head outputs gathered at the projection device, result broadcast to the next layer's devices</text></svg><figcaption><b>The testbed.</b> Five Raspberry Pi 4 boards on a dedicated 802.11ac access point. The controller owns the token loop and the placement; the workers hold attention heads, projections and FFN blocks and never load the full model.</figcaption></figure>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 282" role="img" aria-label="Layer-wise partitioning keeps one device busy at a time; head-level partitioning runs every layer's attention on all four devices at once, leaving only the FFN on a single device" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="llm-partition-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker></defs><text x="190" y="22" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">layer-wise</text><text x="76" y="60" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 1</text><line x1="84" y1="70" x2="360" y2="70" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="76" y="96" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 2</text><line x1="84" y1="106" x2="360" y2="106" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="76" y="132" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 3</text><line x1="84" y1="142" x2="360" y2="142" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="76" y="168" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 4</text><line x1="84" y1="178" x2="360" y2="178" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><rect x="84" y="44" width="66" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="117.0" y="60" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">layer 1</text><text x="117.0" y="96" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="117.0" y="132" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="117.0" y="168" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><rect x="152" y="80" width="66" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="185.0" y="96" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">layer 2</text><text x="185.0" y="60" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="185.0" y="132" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="185.0" y="168" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><rect x="220" y="116" width="66" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="253.0" y="132" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">layer 3</text><text x="253.0" y="60" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="253.0" y="96" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="253.0" y="168" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><rect x="288" y="152" width="66" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><text x="321.0" y="168" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">layer 4</text><text x="321.0" y="60" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="321.0" y="96" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><text x="321.0" y="132" font-size="9" text-anchor="middle" fill="var(--muted, #5b6579)">idle</text><line x1="84" y1="190" x2="360" y2="190" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#llm-partition-arr-a)"/><text x="360" y="206" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">time</text><text x="570" y="22" font-size="12" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">head-level</text><text x="456" y="60" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 1</text><line x1="464" y1="70" x2="740" y2="70" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="456" y="96" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 2</text><line x1="464" y1="106" x2="740" y2="106" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="456" y="132" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 3</text><line x1="464" y1="142" x2="740" y2="142" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="456" y="168" font-size="10.5" text-anchor="end" fill="var(--muted, #5b6579)">device 4</text><line x1="464" y1="178" x2="740" y2="178" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><rect x="464" y="44" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="464" y="80" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="464" y="116" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="464" y="152" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="490" y="44" width="40" height="24" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1"/><text x="510.0" y="60" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">FFN</text><rect x="534" y="44" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="534" y="80" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="534" y="116" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="534" y="152" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="560" y="80" width="40" height="24" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1"/><text x="580.0" y="96" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">FFN</text><rect x="604" y="44" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="604" y="80" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="604" y="116" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="604" y="152" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="630" y="116" width="40" height="24" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1"/><text x="650.0" y="132" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">FFN</text><rect x="674" y="44" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="674" y="80" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="674" y="116" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="674" y="152" width="24" height="24" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1"/><rect x="700" y="152" width="40" height="24" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1"/><text x="720.0" y="168" font-size="10" text-anchor="middle" fill="var(--fg, #121a2b)">FFN</text><line x1="464" y1="190" x2="740" y2="190" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#llm-partition-arr-a)"/><text x="740" y="206" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">time</text><rect x="20" y="240" width="14" height="12" rx="2" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)"/><text x="40" y="250" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">attention heads (layer-wise: whole layer)</text><rect x="300" y="240" width="14" height="12" rx="2" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)"/><text x="320" y="250" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">feed-forward network, one device per layer</text><text x="20" y="268" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">per-layer attention for TinyLlama: 38 ms on one device, 14 ms across four</text></svg><figcaption><b>Layer-wise versus head-level.</b> With one layer per device, three of four devices idle at any moment. With heads spread across devices every layer's attention runs in parallel, and only the FFN remains on a single device.</figcaption></figure>

<p>On the testbed, per-layer attention for TinyLlama dropped from 38 ms on one device to 14 ms across four. End to end, head-level partitioning alone gave 1.24 to 1.31 times speedup over layer-wise.</p>

<p>Then the bottleneck moved. Layer-wise, attention took about 55 percent of per-layer time and the feed-forward network 35 percent. After distributing heads, attention fell to 24 percent and the FFN rose to 48 percent. The FFN still ran on one device while three waited. Parallelising the fast part made the slow part obvious.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 170" role="img" aria-label="Share of per-layer time: layer-wise spends 55 percent in attention and 35 in the FFN; after head-level partitioning attention falls to 24 percent and the FFN rises to 48" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><text x="136" y="45" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">layer-wise</text><rect x="150" y="30" width="306.0" height="22" fill="var(--fig-1, #0f8fa6)"/><text x="303.0" y="45" font-size="10.5" text-anchor="middle" fill="#ffffff" font-weight="600">attention 55%</text><rect x="458.0" y="30" width="194.0" height="22" fill="var(--fig-2, #c47a0a)"/><text x="555.0" y="45" font-size="10.5" text-anchor="middle" fill="#ffffff" font-weight="600">FFN 35%</text><rect x="654.0" y="30" width="54.0" height="22" fill="var(--line, #d8dee8)"/><text x="136" y="83" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">head-level</text><rect x="150" y="68" width="132.4" height="22" fill="var(--fig-1, #0f8fa6)"/><text x="216.2" y="83" font-size="10.5" text-anchor="middle" fill="#ffffff" font-weight="600">attention 24%</text><rect x="284.4" y="68" width="266.8" height="22" fill="var(--fig-2, #c47a0a)"/><text x="417.79999999999995" y="83" font-size="10.5" text-anchor="middle" fill="#ffffff" font-weight="600">FFN 48%</text><rect x="553.2" y="68" width="154.8" height="22" fill="var(--line, #d8dee8)"/><rect x="150" y="120" width="14" height="12" rx="2" fill="var(--fig-1, #0f8fa6)"/><text x="170" y="130" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">attention</text><rect x="340" y="120" width="14" height="12" rx="2" fill="var(--fig-2, #c47a0a)"/><text x="360" y="130" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">feed-forward network</text><rect x="530" y="120" width="14" height="12" rx="2" fill="var(--line, #d8dee8)"/><text x="550" y="130" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">communication and other</text><text x="150" y="156" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">parallelising attention moved the bottleneck to the FFN, which speculation then overlaps</text></svg><figcaption><b>Where the time went.</b> Spreading heads cut attention from 55 to 24 percent of per-layer time, which made the FFN the largest share. The next step had to address the FFN.</figcaption></figure>

<h2>Speculating across layers</h2>

<p>The fix came from the residual structure. After attention and projection, a layer has an intermediate output Z. The final output is X = Z + FFN(Z). The FFN term is additive and usually small, so Z already approximates X well.</p>

<p>HeadSplit forwards Z to the next layer's devices as soon as it exists. They start the next attention on it while the FFN runs elsewhere. When the FFN finishes, the controller compares the FFN output norm to the norm of Z. Below a tolerance of 0.05, the speculative attention is kept. Otherwise it is re-executed with the exact input, so the output never drifts. With tolerance zero the speculative path is bit-for-bit identical to exact execution.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 240" role="img" aria-label="Inter-layer speculation: once layer l has its intermediate output Z, the next layer's attention starts on Z while the FFN finishes; the result is kept if the FFN contribution is small, otherwise attention is re-run on the exact input" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="llm-speculation-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="llm-speculation-arr-z" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-1, #0f8fa6)"/></marker></defs><text x="136" y="78" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">layer l</text><line x1="150" y1="90" x2="730" y2="90" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="136" y="148" font-size="12" text-anchor="end" fill="var(--fg, #121a2b)" font-weight="600">layer l+1</text><line x1="150" y1="160" x2="730" y2="160" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><rect x="150" y="62" width="120" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="210.0" y="79" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)">attention</text><rect x="272" y="62" width="70" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="307.0" y="79" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)">projection</text><rect x="344" y="62" width="190" height="26" rx="3" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="439.0" y="79" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)">FFN(Z) on device A</text><line x1="342" y1="46" x2="342" y2="62" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round"/><text x="342" y="40" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">Z = X_prev + proj(A) available</text><path d="M342,90 v18 h12" fill="none" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#llm-speculation-arr-z)"/><rect x="356" y="132" width="186" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2" stroke-dasharray="4 3"/><text x="449.0" y="149" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)">attention on Z (speculative)</text><rect x="544" y="132" width="70" height="26" rx="3" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2" stroke-dasharray="4 3"/><text x="579.0" y="149" font-size="10.5" text-anchor="middle" fill="var(--fg, #121a2b)">projection</text><line x1="534" y1="62" x2="534" y2="58" stroke="var(--fg, #121a2b)" stroke-width="1" stroke-linecap="round" stroke-dasharray="2 2"/><text x="542" y="22" font-size="10" text-anchor="start" fill="var(--fg, #121a2b)">FFN done: ||FFN(Z)||/||Z|| < 0.05 ?</text><text x="542" y="36" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">yes: keep (84% of transitions)</text><text x="542" y="50" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">no: re-run attention on exact X</text><path d="M356,170 v8 H534 v-8" fill="none" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/><text x="445.0" y="194" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">overlap hides 70 to 85% of the FFN time</text><line x1="150" y1="215" x2="730" y2="215" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#llm-speculation-arr-a)"/><text x="730" y="232" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">time within one token</text></svg><figcaption><b>Inter-layer speculation inside one token.</b> Z is available before the FFN runs. The next layer starts its attention on Z on the other devices while the FFN finishes; the controller then checks the FFN contribution against Z and re-runs attention on the exact input only when the check fails.</figcaption></figure>

<p>This is not speculative decoding. There is no draft model and no guessing of tokens. It pipelines consecutive layers inside a single token's forward pass, on compute that would otherwise sit idle.</p>

<p>Not every layer deserves it. We profile offline on WikiText-103 and record how often the test passes at each layer transition. A layer speculates only if its acceptance probability is at least 0.7 and the expected saving is positive. For TinyLlama, layers 1 to 20 had acceptance of at least 0.82, average 0.87. Layers 21 and 22 were below 0.60, because the final layers' FFN contributions are larger, so they run exactly. Overall acceptance was 84 percent.</p>

<p>The threshold is a trade-off, not a knob to tighten. At 0.02, acceptance rose to 91 percent but speedup fell to 1.48 times because of re-execution. At 0.10 it fell to 1.51 times. We kept 0.05.</p>

<p>The two ideas are coupled. Head partitioning creates the idle window that speculation fills. Speculation changes the placement target: a speculating layer's attention only needs to finish within the previous layer's FFN time, so heads can be spread for memory balance instead of minimum attention time. The greedy placement in our SPAWC 2026 paper places FFN and projection first, then heads, in under 5 ms. It matters: 256 tokens took 40.0 s with greedy placement, 44.8 s with round-robin and 62.4 s layer-wise. The full system reached 1.54 to 1.67 times speedup over layer-wise, and 1.42 times on GPT-2 Small.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 176" role="img" aria-label="Time to generate 256 tokens with TinyLlama-1.1B on four Raspberry Pi 4 workers: 62.4 seconds layer-wise, 48.2 with random head placement, 44.8 round-robin, 40.0 with the greedy placement" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><path d="M300,24 h352.57142857142856 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-352.57142857142856 z" fill="var(--fig-3, #6250d6)"/><text x="288" y="38" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">layer-wise (baseline)</text><text x="664.5714285714286" y="38" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">62.4 s</text><path d="M300,54 h271.42857142857144 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-271.42857142857144 z" fill="var(--fig-1, #0f8fa6)"/><text x="288" y="68" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">heads + speculation, random placement</text><text x="583.4285714285714" y="68" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">48.2 s</text><path d="M300,84 h252.0 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-252.0 z" fill="var(--fig-1, #0f8fa6)"/><text x="288" y="98" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">heads + speculation, round-robin</text><text x="564.0" y="98" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">44.8 s</text><path d="M300,114 h224.57142857142858 a4,4 0 0 1 4,4 v12 a4,4 0 0 1 -4,4 h-224.57142857142858 z" fill="var(--fig-1, #0f8fa6)"/><text x="288" y="128" font-size="11" text-anchor="end" fill="var(--muted, #5b6579)">heads + speculation, greedy placement</text><text x="536.5714285714286" y="128" font-size="11" text-anchor="start" fill="var(--fg, #121a2b)">40.0 s</text><line x1="300" y1="20" x2="300" y2="144" stroke="var(--line, #d8dee8)" stroke-width="1" stroke-linecap="round"/><text x="300" y="166" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">seconds for 256 tokens, lower is better; greedy is 1.56x the baseline</text></svg><figcaption><b>Placement matters.</b> Time to generate 256 tokens with TinyLlama-1.1B on four workers. The same head-level system with speculation varies by a fifth depending on where the blocks land; the greedy placement is computed in under 5 ms.</figcaption></figure>

<h2>What the speedup hides</h2>

<p>Head-level partitioning moves four times more data than layer-wise: 52.8 MB versus 13.2 MB for 256 tokens. Per-device power rose 26 percent, from 3.8 W idle to 4.8 to 5.0 W active. Total energy still fell 6 percent because the job finished sooner, and the energy-delay product improved 47 percent. I would not have believed that without the INA260 boards on every Pi.</p>

<p>Memory needs active management. At 256 tokens the KV cache is about 88 MB across 22 layers. At 512 it doubles. The controller re-runs placement whenever a device crosses 85 percent of memory, about 1.4 times per 512-token run, each in under 5 ms. Without it, a static placement ran out of memory beyond roughly 400 tokens.</p>

<p>WiFi needs margins. We measure round-trip times at startup, add 15 percent, and retry RPCs when a link stalls. None of that is clever. All of it is necessary.</p>

<h2>What still does not work well</h2>

<p>The absolute numbers are slow. At 256 tokens we went from 4.1 to 6.4 tokens per second. That is useful for an on-device assistant, not a replacement for a GPU.</p>

<p>The approach needs at least three devices and round-trip time under about 25 ms. Beyond that, gather and broadcast cost more than the parallelism saves, and layer-wise is the better choice. Initial measurements kept at least 1.35 times speedup up to 15 ms of added RTT, but the full robustness study is still ahead of us.</p>

<p>Grouped-query attention is a problem. Llama-3-style models share one KV head among several query heads, leaving fewer independent units to spread. Quantised models change the compute-to-communication ratio and we have not measured them. Per-layer speculation decisions are fixed at profiling time; adapting them at runtime is open work.</p>

<p>What I keep coming back to is simple. On constrained hardware, the model's structure is the only free resource you have. Layers hide the parallelism inside them. Heads expose it. Residual connections let you start early and check later. Everything else is measuring where the time goes and refusing to guess.</p>

<p>The code is on GitHub: <a href="https://github.com/Dimitrios-Kafetzis/HeadSplit">HeadSplit</a>, the <a href="https://github.com/Dimitrios-Kafetzis/Distributed-Transformer-Inference-Simulator">simulator</a> and the <a href="https://github.com/Dimitrios-Kafetzis/DNN-Partitioning-and-Offloading-Testbed">DNN testbed</a>. The papers are on the <a href="../publications.html">publications page</a>.</p>
]]></content:encoded>
<category>LLM</category>
<category>Distributed inference</category>
<category>Edge AI</category>
<category>Research</category>
</item>
<item>
<title>Zero-allocation inference on Cortex-M: the design decisions behind EdgeNN</title>
<link>https://dimitrios-kafetzis.github.io/blog/zero-allocation-inference-on-cortex-m.html</link>
<guid isPermaLink="true">https://dimitrios-kafetzis.github.io/blog/zero-allocation-inference-on-cortex-m.html</guid>
<pubDate>Sat, 14 Mar 2026 09:00:00 +0000</pubDate>
<description>Why a microcontroller inference library should never touch the heap, and how a static arena, INT8 arithmetic and a graph runtime fit in under 50 KB.</description>
<content:encoded><![CDATA[<h2>The failure mode I wanted to rule out</h2>

<p>A microcontroller running a neural network has a small set of ways to fail badly. One of the worst is a heap that slowly falls apart. You allocate a tensor, free it, allocate a slightly larger one. After a few thousand inferences the heap is fragmented. The next allocation fails even though the total free memory is larger than the request. Nothing in the model changed. The device just stops.</p>

<p>The second failure is quieter. Each malloc and free takes a variable amount of time that depends on the heap state, and the variance shows up directly in your inference latency. If the inference sits inside a control loop with a fixed period, you cannot budget for it. You can only hope.</p>

<p>I wanted an inference library where neither of these things could happen. Not because of a coding rule, but because of the design. That constraint became <a href="https://github.com/Dimitrios-Kafetzis/EdgeNN">EdgeNN</a>: a C11 library for Dense, Conv, LSTM, GRU and Transformer inference on ARM Cortex-M and Cortex-A, with no heap allocation anywhere in the inference path. This article is about the decisions behind it.</p>

<h2>Two arenas and a bump pointer</h2>

<p>All memory in EdgeNN comes from arenas that the application owns. An arena is a buffer, a capacity, an offset and a peak counter. Allocation aligns the offset up (16 bytes by default) and bumps it. It is O(1). It cannot fragment. If the request does not fit, it returns <code>EDGENN_ERR_OUT_OF_MEMORY</code> and leaves the arena untouched.</p>

<pre><code>typedef struct {
    uint8_t     *base;
    size_t       capacity;
    size_t       offset;
    size_t       peak;
    uint32_t     alloc_count;
} edgenn_arena_t;

size_t edgenn_arena_save(const edgenn_arena_t *arena);
void   edgenn_arena_restore(edgenn_arena_t *arena, size_t state);</code></pre>

<p>At runtime there are two arenas. The weight arena is filled once at model load and never freed. The scratch arena holds activations and operator temporaries. The pattern every operator follows is save, allocate, compute, restore. An LSTM cell saves the scratch offset, allocates its 4H gate buffer, runs the step, and restores. Over a 200-step sequence the scratch usage stays flat. The graph executor does the same thing around every layer, so scratch consumption is bounded by the single most expensive operator, not the sum of all of them.</p>

<p>The arena API also includes a ping-pong manager: two scratch regions that alternate roles as input and output between layers. The consumed input region is reset and becomes the next output. Peak activation memory is then two times the largest layer, instead of the sum of every layer. I should be honest here. The ping-pong buffer is implemented and tested, but the current graph executor uses the simpler save and restore scheme on a single scratch arena. Wiring ping-pong into the executor is on the list.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 230" role="img" aria-label="EdgeNN's scratch arena usage over five layers: each operator saves the offset, allocates, computes and restores, so usage returns to the activation baseline after every layer and the peak is the single most expensive layer, not the sum" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="edgenn-arena-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker></defs><line x1="90" y1="180" x2="720" y2="180" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#edgenn-arena-arr-a)"/><line x1="90" y1="180" x2="90" y2="30" stroke="var(--fg, #121a2b)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#edgenn-arena-arr-a)"/><text x="720" y="198" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">layers</text><text x="82" y="34" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">scratch</text><text x="82" y="46" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">offset</text><text x="149.0" y="196" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">layer 1</text><text x="267.0" y="196" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">layer 2</text><text x="385.0" y="196" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">layer 3</text><text x="503.0" y="196" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">layer 4</text><text x="621.0" y="196" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">layer 5</text><path d="M90,150 H96.0 V110 H202.0 V150 H214.0 V70 H320.0 V150 H332.0 V125 H438.0 V150 H450.0 V95 H556.0 V150 H568.0 V60 H674.0 V150 H90 z" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="none"/><path d="M90,150 H96.0 V110 H202.0 V150 H214.0 V70 H320.0 V150 H332.0 V125 H438.0 V150 H450.0 V95 H556.0 V150 H568.0 V60 H674.0 V150" fill="none" stroke="var(--fig-1, #0f8fa6)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="90" y1="60" x2="700" y2="60" stroke="var(--fig-2, #c47a0a)" stroke-width="1" stroke-linecap="round" stroke-dasharray="4 3"/><text x="98" y="53" font-size="10" text-anchor="start" fill="var(--fig-2, #c47a0a)">edgenn_arena_peak(): the SRAM to reserve</text><text x="385.0" y="170" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">each layer: save, alloc, compute, restore</text><line x1="90" y1="150" x2="700" y2="150" stroke="var(--muted, #5b6579)" stroke-width="1" stroke-linecap="round" stroke-dasharray="2 3"/><text x="700" y="144" font-size="9.5" text-anchor="end" fill="var(--muted, #5b6579)">activation tensors</text><text x="90" y="212" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">weight arena: filled once at load, never freed, zero-copy from flash</text></svg><figcaption><b>Scratch usage across a forward pass.</b> Every operator restores the arena before it returns, so usage falls back to the activation baseline after each layer. The reserved SRAM is the highest single peak, not the sum of all layers.</figcaption></figure>

<p>The peak counter matters more than it looks. You run your model once on the host, read <code>edgenn_arena_peak()</code>, and you know exactly how much SRAM to reserve on the target. No guessing, no padding by a factor of two.</p>

<h2>INT8 first, floats only at load time</h2>

<p>Many Cortex-M parts have no FPU, and the Cortex-M4's optional FPU is single precision only. So I made INT8 the primary path and FP32 the reference path, rather than the reverse.</p>

<p>The scheme is symmetric INT8 for weights, with the zero point fixed at zero. That removes a subtraction from the inner loop. Weights are quantised per output channel for Dense and Conv. Activations are per tensor. Accumulation is INT32, with INT32 biases. The step that usually brings floats back in is requantisation: scaling the INT32 accumulator down to INT8. EdgeNN precomputes a fixed-point multiplier and shift at load time using <code>frexpf</code>, so the runtime does one multiply-high and one shift. On Cortex-M7 that compiles down to SMMUL plus an arithmetic shift.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 210" role="img" aria-label="The INT8 path: int8 inputs and per-channel int8 weights multiply into an int32 accumulator with an int32 bias, then a fixed-point multiplier and shift computed at load time requantise to int8, saturate, and a 256-entry lookup table applies the activation; no float anywhere on this path" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="edgenn-int8-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="edgenn-int8-arr-l" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-2, #c47a0a)"/></marker></defs><rect x="19" y="70" width="96" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="67.0" y="92" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">int8 x</text><text x="67.0" y="110" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">per-tensor scale</text><line x1="115" y1="96" x2="122" y2="96" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-a)"/><rect x="123" y="70" width="96" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="171.0" y="92" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">int8 w</text><text x="171.0" y="110" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">per-channel scale</text><line x1="219" y1="96" x2="226" y2="96" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-a)"/><rect x="227" y="70" width="96" height="52" rx="6" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="275.0" y="92" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">int32 acc</text><text x="275.0" y="110" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">MAC + int32 bias</text><line x1="323" y1="96" x2="330" y2="96" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-a)"/><rect x="331" y="70" width="96" height="52" rx="6" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="379.0" y="92" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">requantise</text><text x="379.0" y="110" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">(acc * M) >> shift</text><line x1="427" y1="96" x2="434" y2="96" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-a)"/><rect x="435" y="70" width="96" height="52" rx="6" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="483.0" y="92" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">saturate</text><text x="483.0" y="110" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">[-128, 127]</text><line x1="531" y1="96" x2="538" y2="96" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-a)"/><rect x="539" y="70" width="96" height="52" rx="6" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="587.0" y="92" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">LUT</text><text x="587.0" y="110" font-size="8" text-anchor="middle" fill="var(--muted, #5b6579)">256-entry table</text><line x1="635" y1="96" x2="642" y2="96" stroke="var(--fg, #121a2b)" stroke-width="1.3" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-a)"/><rect x="643" y="70" width="96" height="52" rx="6" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="691.0" y="101" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">int8 y</text><path d="M228,134 v8 H636 v-8" fill="none" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/><text x="432.0" y="158" font-size="10" text-anchor="middle" fill="var(--fig-1, #0f8fa6)">runtime: integer only, SMMUL + shift on Cortex-M7</text><text x="380.0" y="24" font-size="10" text-anchor="middle" fill="var(--fig-2, #c47a0a)">load time (floats allowed once)</text><text x="380.0" y="38" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">M, shift = frexpf(S_in * S_w / S_out)</text><line x1="380.0" y1="44" x2="380.0" y2="66" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-l)"/><text x="588.0" y="38" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">table = f(scale, zero point)</text><line x1="588.0" y1="44" x2="588.0" y2="66" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2" stroke-linecap="round" marker-end="url(#edgenn-int8-arr-l)"/><text x="380" y="190" font-size="10" text-anchor="middle" fill="var(--muted, #5b6579)">symmetric weights (zero point 0) remove one subtraction per multiply-accumulate</text></svg><figcaption><b>The integer path.</b> Floats appear only at load time, when the multiplier and shift are derived with <code>frexpf</code> and the activation tables are built. From the accumulator to the output byte, everything is integer arithmetic.</figcaption></figure>

<p>The convention in the codebase is simple to state: no <code>float</code> in the INT8 inference path. Floats are allowed when the model loads, when scales are converted to multipliers and when lookup tables are built. They are not allowed inside a kernel.</p>

<p>Sigmoid, tanh and GELU are the other place where floats creep in. For INT8 I use 256-entry lookup tables that map every possible input byte to an output byte. Building a table costs 256 float evaluations at load time and 256 bytes of memory. Applying it is one indexed load per element. The error is bounded by the output quantisation step, because the table is exact on the grid.</p>

<p>Two places need more than 8 bits. The LSTM cell state integrates across time steps, so its parameter struct carries separate quantisation parameters for an INT16 cell state. Attention scores go through a softmax, where small differences matter, so the attention params carry INT16 score quantisation too. Both are declared in the headers. Today the RNN and Transformer operators ship as FP32, with INT8 planned.</p>

<h2>A graph runtime, not just kernels</h2>

<p>CMSIS-NN is excellent and I plan to use it as a backend. But it is operator-level only. You assemble the model by hand, you manage every buffer by hand, and you redo that work for every model. TFLite Micro has a runtime, but it is C++, carries roughly 100 KB of interpreter, and does not cover attention.</p>

<p>EdgeNN sits in between. A model is a linear array of layer descriptors, each holding an operator type, a params pointer and indices into a shared tensor table. Dispatch is a <code>switch</code> on the operator enum. There is no registration table and no function-pointer table. Each operator lives in its own translation unit, so with LTO and <code>--gc-sections</code> the linker drops everything your model does not use. The target is under 20 KB of Flash for a DNN-only model and under 50 KB for all 18 operators. Those are targets from the design documents, not measurements yet.</p>

<p>The binary model format follows the same thinking. A 44-byte header, layer records, tensor records, then a 16-byte aligned weight blob. Weight tensors get a pointer straight into the blob. If the model lives in Flash, the weights are never copied to RAM.</p>

<figure class="fig"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" role="img" aria-label="The .edgenn binary: a 44-byte header, then layer records, tensor records and a 16-byte aligned weight blob; a weight tensor's data pointer points straight into the blob, so weights in flash are never copied" font-family="'IBM Plex Mono', Menlo, Consolas, monospace"><defs><marker id="edgenn-format-arr-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fg, #121a2b)"/></marker><marker id="edgenn-format-arr-p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--fig-1, #0f8fa6)"/></marker></defs><rect x="30" y="50" width="120" height="46" rx="0" fill="var(--fig-2-soft, rgba(196,122,10,0.16))" stroke="var(--fig-2, #c47a0a)" stroke-width="1.2"/><text x="90.0" y="69" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">header</text><text x="90.0" y="85" font-size="8.5" text-anchor="middle" fill="var(--muted, #5b6579)">44 B, magic ENNE</text><rect x="150" y="50" width="150" height="46" rx="0" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="225.0" y="69" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">layer records</text><text x="225.0" y="85" font-size="8.5" text-anchor="middle" fill="var(--muted, #5b6579)">op, in, out, aux</text><rect x="300" y="50" width="180" height="46" rx="0" fill="var(--surface, #ffffff)" stroke="var(--fg, #121a2b)" stroke-width="1.2"/><text x="390.0" y="69" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">tensor records</text><text x="390.0" y="85" font-size="8.5" text-anchor="middle" fill="var(--muted, #5b6579)">shape, dtype, data_offset</text><rect x="480" y="50" width="250" height="46" rx="0" fill="var(--fig-1-soft, rgba(15,143,166,0.14))" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.2"/><text x="605.0" y="69" font-size="11" text-anchor="middle" fill="var(--fg, #121a2b)" font-weight="600">weight blob</text><text x="605.0" y="85" font-size="8.5" text-anchor="middle" fill="var(--muted, #5b6579)">16-byte aligned, in flash</text><path d="M360,96 v30 H570 v-28" fill="none" stroke="var(--fig-1, #0f8fa6)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#edgenn-format-arr-p)"/><text x="465.0" y="140" font-size="10" text-anchor="middle" fill="var(--fig-1, #0f8fa6)">tensor->data = blob + data_offset  (zero copy)</text><text x="30" y="158" font-size="9.5" text-anchor="start" fill="var(--muted, #5b6579)">data_offset = 0xFFFFFFFF marks an activation tensor, which is allocated from the arena</text><text x="30" y="36" font-size="10" text-anchor="start" fill="var(--muted, #5b6579)">offset 0</text><text x="730" y="36" font-size="10" text-anchor="end" fill="var(--muted, #5b6579)">end of file</text></svg><figcaption><b>The model file.</b> Records describe the graph; the weight blob is mapped in place. A tensor record's data_offset becomes a pointer into flash, so loading a model costs a header parse and no copies.</figcaption></figure>

<p>Tensors themselves are descriptors, not objects. About 80 bytes, no ownership, no reference counting. The data pointer can reference the arena, Flash or a user buffer. A descriptor carries its full quantisation metadata, which is what makes it possible to cut a model at a layer boundary and hand the intermediate tensor to another device without ambiguity. That matters to me because partitioned inference across devices is the other half of my research.</p>

<h2>What is planned, and what is not done</h2>

<p>Phases 1 to 5 are complete: foundation, DNN operators, RNN operators, Transformer operators and the graph runtime, with 118 unit tests across five suites, run under GCC and Clang with AddressSanitizer and UndefinedBehaviorSanitizer in CI. Phase 6 is optimisation. That means CMSIS-NN backends for Dense, Conv2D, depthwise Conv2D and pooling behind <code>#ifdef</code> guards, Helium/MVE kernels for Cortex-M55, NEON kernels for Cortex-A53 and A72, operator fusion and INT4 weight-only quantisation. Phase 7 is benchmarks on STM32H7 and platform testing.</p>

<p>The honest limits are these. The only HAL today is the generic C fallback. The loader parses graphs, but a full model converter is still to come, so layer parameters are set up by the application. The KV cache struct exists but the attention kernel does not use it yet. Standalone BatchNorm is FP32 only; the INT8 route is to fold it into the preceding convolution. Nothing has been measured on target hardware yet, and I would rather the benchmark tables say "to be measured" than show something I cannot reproduce.</p>

<p>What I can say is what the design guarantees. No malloc. No fragmentation. Bounded scratch. The same input gives the same output, every run. For the systems I work on, that is the property I needed first. Everything else is speed, and speed can be added behind the HAL without touching the contract.</p>
]]></content:encoded>
<category>EdgeNN</category>
<category>TinyML</category>
<category>Cortex-M</category>
<category>C</category>
</item>
</channel>
</rss>
