Activation placement under SRAM contention on STM32U585
laxity source
I measured whether the address of a neural network's activation arena changes its inference time on an STM32U585. With no other bus master active, moving the arena between the three on-chip SRAM regions changed the median by three cycles out of 320,898. With GPDMA traffic active, placement changed the median by as much as 31,189 cycles. Both measurements come from one firmware image, and the telemetry path that records them costs 118 cycles per record outside the measured inference interval.
The uncontended result is measurable and operationally useless. The contended result reaches 9.7 percent and depends strongly on which SRAM region the competing master uses. SRAM3 also produces a directional cross-region effect: traffic there penalizes inference using SRAM1 and SRAM2 by about 4.7 percent, while traffic in SRAM1 or SRAM2 has almost no reciprocal effect on inference placed in SRAM3. The measurements establish that asymmetry; they do not yet identify the internal arbitration point that causes it.
Platform and software stack
Laxity is split between a C firmware target and host-side experiment tooling. The embedded side runs on a B-U585I-IOT02A with an STM32U585 Cortex-M33 at 160 MHz. Application code runs under ThreadX and uses the STM32 HAL/BSP around the board peripherals. Neural inference is generated with ST Edge AI Core 4.0.1 from a human-activity-recognition model trained on the WISDM accelerometer dataset. The network's activation arena is 2,944 bytes.
The host side is deliberately separate from the measurement path. Shell entry points handle toolchain checks, generation, building, flashing, capture, and monitoring. Python is used for telemetry parsing and offline analysis. The interactive monitor is a Rust program built with Ratatui, crossterm, and serialport. The Rust parser follows the same framing, CRC, and resynchronisation rules as the Python parser so live inspection and offline analysis do not become two different interpretations of the capture.
| Layer | Implementation used in Laxity |
|---|---|
| Target firmware | C on STM32U585, ThreadX, STM32 HAL/BSP |
| Inference | ST Edge AI Core 4.0.1 generated network, 2,944-byte activation arena |
| Contention source | GPDMA1 memory-to-memory traffic through a circular linked list |
| Timing and telemetry | Cortex-M33 DWT cycle counter, fixed 32-byte records, SPSC ring, CRC-framed transport |
| Live monitor | Rust: Ratatui, crossterm, serialport |
| Offline analysis | Python: telemetry_parse.py and analyse.py |
| Experiment automation | Shell: doctor.sh, generate.sh, generate-model.sh, build.sh, flash.sh, capture.sh, monitor.sh |
| Diagrams | Mermaid sources under docs/diagrams/src, generated SVG committed beside them |
The live view
The TUI is not the source of truth for the experiment; the stored telemetry capture is. The interface is a live or replayed projection of that capture. It shows the selected arena and DMA target, current and rolling cycle counts, the uncontended reference for each placement, CRC and sequence-gap counters, and the board's ASCII recovery log. The same decode path is used for serial, UDP, and file replay.
laxity-tui replaying a contention sweep. The display compares each placement against
its own uncontended median while keeping CRC rejects, sequence gaps, target drops, and the
raw board log visible.
System map before the measurements
The diagrams below are the shortest way to explain what is being measured. The architecture separates target firmware, host software, and experiment tooling. The experiment flow shows how a run moves from toolchain checks to a persisted telemetry capture. The memory diagram isolates the actual contention question: one Cortex-M33 inference stream and one independent GPDMA requester, each selectable across SRAM1, SRAM2, and SRAM3.
doctor.sh and model generation through build, flash,
capture, replay, and analysis. The persisted telemetry file sits between the target and the
host-side interpretation.
| Measurement | Observed value |
|---|---|
| Target | STM32U585, Cortex-M33 at 160 MHz |
| Activation arena | 2,944 bytes |
| Timing source | DWT cycle counter |
| Competing master | GPDMA1 memory-to-memory traffic |
| Uncontended placement spread | 3 cycles out of 320,898 (9.3 ppm) |
| Largest contended increase | 31,189 cycles (9.7%) |
| Main capture | 9,062 records over 180 seconds, 0 drops, 0 sequence gaps, 0 CRC rejects |
The scheduling problem
Real-time scheduling assigns limited resources to work with timing constraints. Laxity, also called slack, is the time a job can still lose before missing its deadline:
laxity = deadline - now - estimated_remaining_execution
A least laxity first scheduler gives processor time to the job with the smallest remaining slack. That model is useful when execution cost belongs mostly to the task and the processor assigned to it. I was interested in a different resource. A converted neural network needs an activation arena for intermediate tensors, and embedded deployment usually treats that arena as a size constraint. If the arena fits in SRAM, its exact address is normally left to the linker. That is fine if memory access cost stays effectively constant. It becomes less obvious once another bus master moves data at the same time.
I had already seen the larger version of this problem in platform work. Kubernetes can place pods according to CPU and memory capacity, while cgroups regulate processor use. Two workloads can still interfere through shared cache and memory bandwidth while staying inside those limits. CPU pinning, NUMA-aware placement, and Intel RDT through resctrl are ways to control parts of that shared path, although RDT itself is an operating system mechanism rather than a Kubernetes primitive. The STM32U585 has a much smaller machine, but the same question appears in another form. ThreadX schedules software threads on one Cortex-M33 core. GPDMA, sensors, and other peripherals can move data independently through the memory system.
There is no cgroup or RDT interface to configure here. The run-time choice I could actually control was the address of the activation arena. I named the project Laxity because I wanted to know whether that placement could recover enough execution time to become useful to a scheduler. I did not know whether the effect would be measurable when I started.
How I split the work
I used coding agents throughout the project, but I did not want one agent producing a repository I could run without being able to explain it. I split the work into conceptual blocks, usually around two per day. Each block had a concrete question, a done condition, and things that were explicitly outside its scope. One agent changed the repository, another inspected the current state and prepared the next block, and a third reread the repository after changes so its model of the system stayed current. I read every diff. This made the project slower. I kept the process because I had to debug the system and present it alone, and I wanted every layer to remain something I could reason about without asking the agent that wrote it.
The first toolchain failure also gave me a rule that stayed useful for the rest of the project:
Exit status lies. Establish success from output and artefacts.
Toolchain failures
STM32CubeMX can print KO and still exit with status zero. I saw the same behaviour around its software-management commands and parts of the ST Edge AI installation flow. After that, my scripts stopped treating $? as sufficient evidence. They checked the emitted text and the files that should have appeared. The first broken script was versions.sh. It captured an ANSI-coloured banner as the programmer version. I changed the parser to strip escape sequences before matching. If parsing still fails, the pin file contains the literal value unparsed rather than a plausible-looking string. doctor.sh produced a different false failure. It reported no ST-LINK while the board was connected.
The board and programmer were both fine. The problem was this combination:
| Part | What mattered here |
|---|---|
grep -q |
Stops reading as soon as a match is found. |
pipefail |
Propagates a non-zero producer status, including SIGPIPE, to the pipeline. |
grep -q exits as soon as it finds a match. The process feeding it can then receive SIGPIPE and exit with status 141. Under pipefail, that turns the complete pipeline into a failure. I changed the check so the consumer reads the complete input. I also misunderstood the relationship between X-CUBE-AI and ST Edge AI Core at first. I had treated them as alternatives. The pack I installed through CubeMX was only the integration layer and did not contain the command line generator, inference headers, or runtime archive I needed. The actual generator came from ST Edge AI Core, installed separately. I found part of that command interface by intentionally sending CubeMX an invalid command and letting it print the supported command list.
Model selection and licensing
The course provides a neural network, but I wanted the repository to stand on a model whose provenance I could document. I used a human activity recognition network from ST's model zoo, trained on the WISDM accelerometer dataset. The model accepts 24 samples across three axes and returns four class scores. Its generated activation arena is 2,944 bytes. My first note said the model was safe to commit because it was publicly downloadable. That was wrong. The model uses SLA0044. It is not an OSI licence, and availability on a public website does not imply redistribution rights. I removed that assumption from the repository design.
toolchain.toml now pins the model name, source, SHA-256, and generator version. generate-model.sh checks those values before generating code and refuses to continue on a mismatch. I keep one model-derived test artefact in the tree: a golden input and its reference output. The synthetic input is described by its formula, while the output comes from ST's generator. The firmware uses that pair as a known-good inference check. The network itself is not committed.
First firmware bring-up
The first firmware I flashed produced no output. There was no visible fault either. The board appeared to do nothing. The ThreadX byte pool was configured for 1,024 bytes, while one thread requested a 1,024-byte stack. App_ThreadX_Init returned an error, and the generated failure path entered an infinite loop. That failure changed how I treated later measurements. A system that never started and a system that started but produced no interesting result can look identical from outside. I started checking the mechanism before interpreting the result. CubeMX gave me a related problem during configuration. Several keys were accepted, echoed as OK, and preserved through a save and reload, but disappeared when code was generated.
After four separate commands behaved that way, I stopped treating the configuration database as the final state. Generated code became the evidence that a setting had actually survived.
Cycle counting
Inference timing uses the Cortex-M33 DWT cycle counter. The counter is 32 bits wide. At 160 MHz it wraps after about 26.8 seconds, which is shorter than a normal capture session. I therefore detect wrap and mark it in telemetry instead of assuming each timing interval lives inside one counter epoch. I kept the counter implementation independent of STM32 vendor headers. Time in milliseconds is supplied through a function pointer. I did that because I wanted the measurement port to remain movable to another Cortex-M part without carrying the rest of the STM32 platform layer with it.
Telemetry format
Each completed inference produces a fixed 32-byte record. The record contains a sequence number, release timestamp, execution interval, arena identity, and competing-master identity. Field offsets are asserted at compile time, and the implementation rejects big-endian builds instead of silently changing the wire representation. Records enter a single producer single consumer ring using free-running indices. Occupancy is:
occupancy = head - tail
The producer therefore does not need modulo arithmetic to decide whether the ring is full. Sequence numbers advance when a record is dropped rather than when it is transmitted. I wanted loss to remain visible after transport, so a gap at the host directly identifies a missing record without relying on another status counter. Batches leave the target inside framed messages. Each frame has the magic bytes 0x4C 0x58, a version, type, payload length, and CRC-16/CCITT-FALSE over the payload.
The same UART also carries ASCII diagnostics. When the parser does not find a valid frame at the current byte, it advances by one byte and keeps looking. This lets the binary and text streams share one transport and lets the parser recover after partial or corrupted data. The CRC implementation is bitwise rather than table-driven. It runs outside the inference timing interval, so I had no measured reason to spend 512 bytes of flash on a lookup table.
Measuring telemetry overhead
I measured the instrumentation before using it to make timing claims. A null probe exercises the telemetry path without running inference. Reading the ring costs 14 cycles at both the median and p99. Pushing one record costs 118 cycles. A normal inference is around 320,900 cycles, so the push cost is about 0.037 percent of that interval. The push also happens after the inference timing interval has ended. The probe stores samples in static arrays. I had already seen an undersized stack turn into a silent target, so I did not want the instrument validation to depend on another large stack allocation.
It also resets the telemetry ring every 32 pushes. Without that reset, later samples would eventually measure the full-ring path rather than the successful push path I wanted to characterize. I used insertion sort for the small fixed sample set rather than pulling qsort from newlib into the firmware image. Those choices came from failures I had already seen rather than from a generic benchmark design.
Uncontended placement
I allocate three activation arenas, one in each SRAM region, with identical alignment. All three exist at once. A pointer selects the active arena at run time. I needed the firmware image to remain identical across placement cells because an earlier relink of unchanged source moved the observed median by 85 cycles. That was already much larger than the uncontended effect I eventually measured. A build-per-placement experiment would therefore have mixed placement with image layout. The three uncontended medians were:
| Placement | Median inference time |
|---|---|
| SRAM1 | 320,898 cycles |
| SRAM2 | 320,901 cycles |
| SRAM3 | 320,899 cycles |
| Same-region control pair | Identical medians; 0-cycle spread |
The full spread is three cycles out of 320,898, or 9.3 parts per million. I also measured SRAM1 twice under two different labels while keeping the same address. The two medians were identical, so the control spread was zero cycles at this sample count. The timer resolves the placement effect. The effect is still too small to act on. One confound remained in the first experiment. Each region contributed one arena address, so I could not initially separate "this region" from "this address inside the region." I later added a second address inside SRAM2 and reproduced the primary SRAM2 median. That reduces the address-confound concern for SRAM2; I do not generalize it to every address on all three SRAMs.
Firmware identity
While testing whether I could restore a known image before overwriting it, I found that the ELF itself was not reproducible. A clean rebuild of identical source reordered .debug_str and moved the section headers after it. The ELF changed by 48 bytes even though every allocated section, every symbol, and the programmed image were identical. My capture metadata used elf_sha256. That pinned a container whose irrelevant debug layout changed between clean builds. Captures now store image_sha256 as well. This is the hash of the binary bytes that actually run on the target, and the analysis requires that value. My first implementation of that hash was:
The broken command was arm-none-eabi-objcopy -O binary "$ELF" /dev/stdout | shasum -a 256.
It was wrong. objcopy seeks in its output. With /dev/stdout connected to a pipe, it emitted no binary data and still exited successfully. shasum therefore calculated the SHA-256 of an empty file. Every image would have received the same constant hash. The script now writes the binary to a temporary file and hashes the file afterward.
DMA as the competing master
A second ThreadX thread looked like the obvious way to generate memory pressure. It would have measured the wrong thing. The Cortex-M33 has one core and no simultaneous multithreading. Two software threads cannot execute simultaneously. If an aggressor thread preempts inference, its own execution time becomes part of the measured wall-cycle interval. Increasing its activity would then produce a latency increase containing scheduler time. I would not know how much came from memory interference. I needed another master that could generate requests while the Cortex-M33 continued executing. GPDMA1 provides that.
The aggressor performs memory-to-memory transfers inside a selected SRAM region using a circular linked list. I run the channel at high priority, with the source and destination assigned to different matrix ports. It does not interrupt the core during the measured interval. I poll the completion counter afterward. I avoided a periodic DMA interrupt because one interrupt every 32 ms would overlap roughly 6 percent of inference windows. Those windows would contain interrupt-service time in addition to bus contention. Internal SRAM on the STM32U585 is not covered by the data cache, so activation accesses from the Cortex-M33 reach the on-chip memory system directly. Instruction fetches come from flash with the instruction cache enabled.
That keeps the experiment focused on data traffic between the core, DMA, and internal SRAM. CubeMX did not configure the DMA channel correctly from script mode. Settings taken from ST examples disappeared during regeneration, and one of the commands returned:
| CubeMX script command | Result |
|---|---|
set mode GPDMA1 Channel 0 - 2 Words Internal FIFO | KO |
I configure the channel directly with a small HAL sequence instead. I also recorded hashes of the three generated files that could have been disturbed by later CubeMX regeneration. They stayed unchanged.
Contention results
The main capture contains 9,062 records over 180 seconds. Each experiment cell contains between 217 and 225 samples. The capture had zero dropped records, zero sequence gaps, and zero CRC rejections. The table shows median inference cycles. Values in parentheses are relative to the same arena's uncontended median.
| Arena | No aggressor | DMA in SRAM1 | DMA in SRAM2 | DMA in SRAM3 |
|---|---|---|---|---|
| SRAM1, 192 KiB | 320,898 | 337,877 (+16,979) | 320,927 (+29) | 335,843 (+14,945) |
| SRAM2, 64 KiB | 320,901 | 320,960 (+59) | 337,845 (+16,944) | 335,847 (+14,946) |
| SRAM3, 512 KiB | 320,899 | 320,967 (+68) | 320,937 (+38) | 352,088 (+31,189) |
When inference and DMA share SRAM1, the median increases by 16,979 cycles. SRAM2 gives almost the same result at 16,944 cycles. Both are about 5.3 percent. SRAM1 and SRAM2 barely affect each other when the arena and DMA buffer are separated. SRAM2 traffic adds 29 cycles to an SRAM1 arena. SRAM1 traffic adds 59 cycles to an SRAM2 arena. Those changes are around 0.01 percent. SRAM3 is different. Sharing SRAM3 costs 31,189 cycles, or 9.7 percent. The experiment therefore gave me two very different placement regimes. With an idle memory system, the full placement range was three cycles. With another bus master active, the range reached 31,189 cycles.
That is the measurement I would use if I later build a placement policy.
What the contention cells look like live
The screenshots below are rolling TUI views, not replacements for the final capture table. Their sample counts and rolling medians change while replay is running. They are useful because they expose the topology of a cell directly: where inference is placed, where GPDMA is writing, which uncontended median is being used as the reference, and which alternate placement is currently cheapest under the same traffic.
SRAM3 asymmetry
SRAM3 also creates a cross-region effect. When the DMA aggressor runs in SRAM3, an arena in SRAM1 gains 14,945 cycles and an arena in SRAM2 gains 14,946. Both are about 4.7 percent. The reverse direction is almost absent. DMA in SRAM1 adds 68 cycles to inference in SRAM3. DMA in SRAM2 adds 38. The result follows the region used by the aggressor rather than a symmetric SRAM pair. I do not have the mechanism pinned down.
One possibility is that SRAM3 sits behind a matrix path shared with traffic toward the other SRAMs. Another is that an SRAM3 transaction occupies a shared resource for longer, which could explain both the cross-region effect and the larger same-region penalty. Shared arbitration upstream of the SRAM slaves would also fit the measurements. The DMA completion counter advances at the same rate in all three regions. That rules out a simple explanation based on the aggressor doing less work in one region, but it does not distinguish between the remaining mechanisms.
The next step for this result is the reference manual, not another blind sweep. I need to map the SRAM slave ports and matrix arbitration closely enough to see which of those mechanisms the part can actually implement. I also swept the DMA working set over 1, 4, 8, and 16 KiB. Within a cell, the median moves by about 420 to 570 cycles, which is well above the zero-cycle control spread. The direction depends on the DMA region. SRAM1 and SRAM2 aggressors cause slightly less interference as the working set grows. An SRAM3 aggressor causes more. When both inference and DMA use SRAM3, the response is nearly flat. Averaging all of those cells produces a small downward trend. I do not use that aggregate because it combines trends with opposite signs.
| Arena | DMA region | 1 KiB | 4 KiB | 8 KiB | 16 KiB |
|---|---|---|---|---|---|
| SRAM1 | SRAM1 | 338,414 | 337,981 | 337,898 | 337,877 |
| SRAM1 | SRAM3 | 335,420 | 335,746 | 335,816 | 335,843 |
| SRAM2 | SRAM2 | 338,391 | 337,951 | 337,869 | 337,845 |
| SRAM2 | SRAM3 | 335,421 | 335,747 | 335,819 | 335,847 |
| SRAM3 | SRAM3 | 351,997 | 352,052 | 352,066 | 352,088 |
The working-set sweep matters because it stops the contention cost from looking like one constant per SRAM pair. Same-region SRAM1 and SRAM2 get slightly cheaper as the aggressor grows from 1 to 16 KiB, while an SRAM3 aggressor becomes slightly more expensive against SRAM1 and SRAM2. The SRAM3/SRAM3 cell is almost flat across the same range.
Sensor integration
The course required two sensors, so I replaced the synthetic inference input with data from the inertial sensor. The environmental sensor and microphone also run beside it. The golden vector remains available as an explicit mode. I kept it because falling back silently from a live sensor to synthetic data would make a broken input path look like a working inference path. The microphone uses the digital filter peripheral. Its buffer counter gave me a useful independent check on the audio clock. The firmware prints one status block per second, and the counter advanced by 31 to 32 half-buffers between consecutive reports.
Each half-buffer contains 512 samples:
512 × 31.7 = 16,230 samples/s
The configured clock chain predicts 16,233 samples/s. Seeing buffer movement would only tell me that data exists. The measured rate told me that the clock chain was also behaving close to the configured value.
DMA channel collision
The microphone later collided with the synthetic aggressor. The board support package assigns GPDMA1 channel 0 to the microphone inside a static function. I could not redirect that channel from the outside. My synthetic memory aggressor also used channel 0. I moved the aggressor to channel 1 and left its other settings unchanged. The earlier captures were all collected on channel 0. Equal-priority DMA arbitration is round robin, so I do not expect the numerical channel index to affect the measured contention at equal priority. I have not measured that assumption directly, so I keep it as an assumption in the results rather than treating it as established behaviour.
The collision was also a useful example of the same resource problem appearing above the actual experiment. A real peripheral driver constrained where the synthetic master could be placed before either one generated bus traffic.
Wi-Fi bring-up
The B-U585I-IOT02A includes an MXCHIP module connected through SPI2. ST provides a NetX Duo driver for it. The network stack builds, links, and executes. Adding it increased the text size by 68 KiB, and I verified that 31 driver entry points remained in the final image. I checked the symbols because an earlier build had linked successfully while garbage collection removed the complete driver. The module initially reported firmware V2.1.11, while the driver requires V2.3.4. The driver checks the version against a fixed tuple and executes MX_ASSERT(false) when the firmware is too old. In the ThreadX binding, that macro becomes an infinite loop.
The target therefore continues booting and streaming telemetry while the network side stops progressing without explaining the reason. ST distributes the module updater as a binary intended for the ST-LINK mass-storage volume. That volume did not mount on my Mac. The debugger enumerated, but macOS created no block device. The updater eventually has to be programmed into target flash anyway, so I wrote it through SWD. Before programming, the updater's read-only check found 743 differing bytes in the first kilobyte. Afterward it reported zero differences across all 686,672 bytes.
ST also publishes MD5 hashes for the updater payloads. Both payload hashes matched. One payload appears verbatim inside the updater binary at a fixed offset, so 95.6 percent of the programmed file is covered by the published vendor hash. The board later printed the same offset in its own console output. The Wi-Fi connection itself is still not demonstrated. The credentials, network configuration, and host receiver are implemented. I am waiting for a DHCP-assigned address from the board before calling association successful.
Terminal monitor
The telemetry transport mixes binary frames and ASCII status output. Opening the port directly in a terminal produces mostly binary noise. My original workflow was to store the stream and inspect it later with a Python parser. That worked for analysis and gave me poor visibility during a running experiment. I wrote a host TUI in Rust using Ratatui, crossterm, and serialport. I cared more about keeping the parser behaviour identical than about how the interface looked. The Rust frame parser therefore follows the Python parser's CRC handling and byte-by-byte resynchronisation rules. I replayed every stored capture through both implementations and compared their record, drop, and sequence-gap counts.
The TUI supports UDP, serial, and file replay through the same decode path. Serial stays available as the demonstration fallback if network transport is unavailable. I did not want that path to use a separate parser that was exercised only when something else had already failed. Incoming bytes can also be written unchanged to a capture file. The existing Python parser accepts those files directly. I treat the stored capture as the measurement record. The interface is a live view over it. The idle state needed some care as well. The header reports the selected source, time since the last received byte, time since the last valid frame, CRC rejection count, and sequence-gap count.
Those fields let me distinguish a silent source, wrong serial port, and malformed frame stream without attaching another debugger. The first implementation had three display problems. One pane truncated placeholder text halfway through a word. Long source paths consumed the following field rather than being elided. Headless mode also omitted two fields available in the Python output, including the cycle-counter wrap flag. I fixed all three.
Two implementation choices also became problematic during long sessions. Each cell stored samples in a sorted vector through insertion, giving unbounded storage and quadratic insertion work. The experiment projection was also recalculated twice for each rendered frame, even though telemetry changes much less often than the renderer runs. Both were changed. The retained sample-window size is shown in the interface now because the displayed percentile belongs to that window rather than an unbounded history.
Host-side verification
I built the Rust crate on Linux as a second environment. That gave me another build of the parser and checked that the host implementation had not accidentally picked up a macOS dependency. Thirty-three tests pass there, along with clippy with warnings treated as errors and the formatting check. I then replayed the complete main capture through headless mode and compared it with the Python parser. Both reported:
| Parser result | Count |
|---|---|
| Frames | 18,124 |
| Records | 9,062 |
| Drops | 0 |
| Sequence gaps | 0 |
| False syncs | 0 |
I also checked whether every byte in the input belonged to the same category in both implementations:
| Input category | Bytes |
|---|---|
| 18,124 frame headers × 8 bytes | 144,992 |
| 9,062 batch payloads × 32 bytes | 289,984 |
| 9,062 header payloads × 140 bytes | 1,268,680 |
| Skipped ASCII bytes | 65,843 |
| Total / file size | 1,769,499 |
Nothing is left over. For screenshots, I ran the binary inside a pseudo-terminal and passed its output through a terminal-emulator library. That produced the final character grid and attributes, which I then rendered with a monospace font. My first renderer made one panel border look broken. I initially suspected the TUI. The terminal characters were correct. My renderer used a cell width that did not match the font's horizontal advance, so adjacent box-drawing glyphs did not touch. The rendering bug was mine.
How the diagrams are generated
The repository contains five Mermaid sources in docs/diagrams/src, with generated SVG files committed beside them. I wanted the diagrams to remain editable as text because the project structure was still changing while I was documenting it. When a dependency changes, I can update the graph and review the change in Git instead of opening a drawing tool. Rasterising the SVGs exposed one Mermaid detail. Node labels can be stored inside <foreignObject> elements containing HTML. CairoSVG rendered the shapes and edges but omitted those labels because it does not implement the embedded HTML path Mermaid relies on.
I switched the rasterisation step to headless Chromium, which renders the SVG through a browser engine and preserves the text. One diagram had a different problem. Its generated aspect ratio was around 0.29, so fitting it on a 16:9 slide made the content unreadable. For the presentation I split the flow at its natural section boundaries and laid the three stages out horizontally. The source diagram remains vertical because that version is easier to read as documentation.
Limits
There is no placement planner in Laxity. The runtime measures per-region costs that such a policy could use later. It does not currently choose a placement from those measurements. The activation arena is also fixed at 2,944 bytes because that size belongs to the generated network. Giving the runtime a larger buffer does not make the network touch more memory, so this experiment does not say how the interference scales with activation footprint. All reported inference measurements were collected at optimisation level zero. That lengthens the inference interval and can reduce the relative contribution of any fixed-cost interference.
The complete sensing pipeline cannot currently be disabled at run time. Its cost therefore appears as a shift in the uncontended baseline rather than as an isolated timing result for each sensor path. SRAM3 is still the unresolved part of the result. DMA traffic there adds roughly 4.7 percent to inference using SRAM1 or SRAM2, while traffic in those two regions has almost no reciprocal effect on inference in SRAM3. I have measurements for the asymmetry. I do not yet have the bus-level mechanism that explains it.
repository and measurements are here.