Follow the pulse ring to 32 MiB

The ring and the device-tree pool that backs it are now the size the
factory firmware runs. Docs follow, and the hardware facts bank gains the
reserved-memory map: 511 MiB usable, 96 MiB reserved for DMA, ~454 MiB
left to Linux.

image.health reads the pool size and ring_mb back and fails if they
disagree, so a pool the parameter does not use cannot ship unnoticed, and
checks that free never exceeds the ring less its gap.
This commit is contained in:
ScottW514
2026-08-19 21:21:30 -04:00
parent 6e4a26ac61
commit 4300f7f4a8
4 changed files with 60 additions and 17 deletions
+22 -8
View File
@@ -26,7 +26,7 @@ Read together with:
hardware-validated.**
- **Platform bring-up: complete and hardware-verified.** SDMA + EPIT pulse
playback out of a 16 MiB reserved pool, live-fed during a run; laser PWM at
playback out of a reserved DMA pool, live-fed during a run; laser PWM at
39.98 kHz; `CONFIG_PREEMPT=y`; both OV5648 cameras on the mainline
imx-media pipeline with VPU JPEG encode; A/B slot install, signed `.fw`
releases and factory restore.
@@ -657,12 +657,22 @@ not a release.
`sdio write failed (-84)` and costs ~1 s of Wi-Fi (wlcore firmware recovery)
— see "Wi-Fi SDIO CRC watch" under Next work.
- **SDMA pulse engine**: ring size = the `ring_mb` module parameter (default
16 MiB; power of two, must fit the 16 MiB `cnc-pulsebuf` no-map DT pool).
Free = size − 32 KiB gap. Bench-verified at 16 MiB: 20 MB streamed at 100 kHz
through the wrapping ring, 0 ENOMEM, 0.4 ms max write latency, starve →
`underrun` per protocol. The ring caps legacy cloud-mode job length
(whole-file preload: ~1 MiB per 100 s of 10 kHz stream, so ~28 min); the
grblHAL live feed keeps only a few KB in flight. The playback script is
32 MiB, the factory ring size; power of two, must fit the 32 MiB
`cnc-pulsebuf` no-map DT pool). Free = size − 32 KiB gap, so 33,521,664 bytes.
Bench-verified on the 16 MiB ring the earlier images shipped, and the
mechanism is size-independent: 20 MB streamed at 100 kHz through the wrapping
ring, 0 ENOMEM, 0.4 ms max write latency, starve → `underrun` per protocol.
The ring caps legacy cloud-mode job length (whole-file preload: ~1 MiB per
100 s of 10 kHz stream, so ~56 min); the grblHAL live feed keeps only a few
KB in flight.
- **Reserved memory**: 511 MiB usable DRAM (`0x10000000`–`0x2fefffff`), of which
96 MiB is reserved for DMA: the 32 MiB `cnc-pulsebuf` no-map pool (dynamically
placed, `alignment = size`, so it lands at `0x2c000000`) plus 64 MiB of
reusable CMA for camera/IPU/VPU buffers. no-map means the pulse pool is gone
from the kernel's map whether a job uses it or not, which is what makes
`dma_alloc_coherent()` deterministic for a late-probing out-of-tree module.
MemTotal ~454 MiB; measured idle use in GRBL mode with the daemon and
controller up is ~100 MiB. The playback script is
relocated to SDMA channel 26 at `<26 0xF00>` (halfword 7680) with a pre-run
integrity guard; the probe lines to look for are `EPIT clock 66000000 Hz` and
`SDMA channel 26 reserved for pulse playback (script at halfword 7680)`.
@@ -1010,7 +1020,11 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
image built with the `<recipe>-pin.inc` layout: that image is a platform
change against everything recorded so far, unavoidably, and only from then
on does a component pin bump re-require just the tests covering that
component. `20260817124714` was built after the pin files landed and the
component. It now also carries the 32 MiB pulse ring (DT pool plus the
module default, batched into that flash), which is a platform change in its
own right and the first bench sighting of the larger ring: `image.health`
reads the pool and `ring_mb` back, and a preloaded cloud job past the old
28-minute cap is the capability proof. `20260817124714` was built after the pin files landed and the
parity tests were driven on it, but no full-campaign export is recorded for
it — confirm on the bench and write the record here. Also still owed:
exercising the ported bench tools from the page (they are registered and
+4 -4
View File
@@ -60,7 +60,7 @@ Two consequences worth knowing:
### The ring, and two ways to fill it
Pulse bytes go into a 16 MiB ring buffer in reserved memory. There are two ways
Pulse bytes go into a 32 MiB ring buffer in reserved memory, the same size the factory firmware uses. There are two ways
to use it, and the mode you run decides which:
- **Live streaming (GRBL mode).** The controller keeps only a small window of
@@ -70,8 +70,8 @@ to use it, and the mode you run decides which:
falls behind far enough to empty the ring, the machine enters an **underrun**
state: motion stops instantly, and position is no longer trusted.
- **Preloading (cloud mode).** The whole job is written into the ring before it
starts. Nothing can starve, but the ring size caps job length — roughly
1 MiB per 100 seconds at the cloud's 10 kHz tick, so about 28 minutes. A job
starts. Nothing can starve, but the ring size caps job length: roughly
1 MiB per 100 seconds at the cloud's 10 kHz tick, so about 56 minutes. A job
larger than the ring is rejected cleanly before it runs.
### Stopping and resuming at the hardware level
@@ -398,7 +398,7 @@ switch to cloud mode and back — re-home after switching.
| Input | G-code over TCP:23 | a downloaded pulse file |
| Ring use | live-streamed, small window | whole job preloaded |
| Machine tick | 28160 Hz default | 10 kHz (from the job header) |
| Job length limit | none | ~28 minutes (ring size) |
| Job length limit | none | ~56 minutes (ring size) |
| Needs internet | no | yes |
| Laser arming | button press per job | button press per job |
| Button mid-job | feed hold / cycle start | pause with backtrack / resume with lead |
+31 -2
View File
@@ -41,6 +41,16 @@ def kernel_config():
return cfg
def _dt_u32(path):
"""A device-tree cell as an int (big-endian), or None."""
try:
with open(path, "rb") as f:
raw = f.read(4)
except OSError:
return None
return int.from_bytes(raw, "big") if len(raw) == 4 else None
def fds_of(pid):
out = []
try:
@@ -61,8 +71,8 @@ def fds_of(pid):
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the "
"kernel options, the module, the daemon ownership, the init ordering, and the "
"file modes the release depends on.")
"kernel options, the module and the pulse ring it maps, the daemon ownership, "
"the init ordering, and the file modes the release depends on.")
def image_health(ctx):
ev = ctx.evidence
manifest = ctx.runner.manifest
@@ -98,6 +108,25 @@ def image_health(ctx):
ev["cnc_free"] = free
ctx.check(free is not None and free > 0, "cnc/free unreadable or zero (ring not mapped?)")
ctx.log("cnc/free: %s bytes", free)
# The ring the platform ships: the DT pool is the promise, ring_mb is what
# the module took. They must agree - a pool the parameter does not use is
# no-map RAM burned for nothing, and a parameter the pool cannot back would
# have failed the probe. free can only ever be size less the 32 KiB gap.
pool = _dt_u32("/proc/device-tree/reserved-memory/cnc-pulsebuf/size")
try:
ring = int((_read("/sys/module/glowforge/parameters/ring_mb", "") or "").strip()) << 20
except ValueError:
ring = None
ev["cnc_pool_bytes"] = pool
ev["cnc_ring_bytes"] = ring
ctx.log("cnc-pulsebuf pool: %s bytes, ring_mb: %s bytes", pool, ring)
ctx.check(pool is not None, "no cnc-pulsebuf reserved-memory node (the ring fell back to CMA?)")
ctx.check(ring is not None, "/sys/module/glowforge/parameters/ring_mb unreadable")
ctx.check(pool is None or ring is None or pool == ring,
"DT pool %s and ring_mb %s disagree", pool, ring)
ctx.check(ring is None or free is None or free <= ring - 32 * 1024,
"cnc/free %s exceeds the ring less its 32 KiB gap (%s)", free, ring)
ctx.check(hw.sysfs_read("cnc/interlock_circuit") is not None, "cnc/interlock_circuit unreadable")
# 4. forgectrl holds /dev/glowforge and supervises the controller
+3 -3
View File
@@ -255,9 +255,9 @@ Scarthgap, but the legacy (Dunfell/Gatesgarth) layers won't build clean until:
rides out worst-case scheduling latency with orders of magnitude to spare
(measured: 0.2 ms worst write latency under full CPU + I/O load; the
underrun bench ran 100 kHz for 120 s with zero underruns). The ring
itself is 16 MiB (the `ring_mb` module parameter, backed by the 16 MiB
reserved pool): ~84 s of stream at 200 kHz, ~28 min at the 10 kHz
cloud-mode tick — a capacity that matters for the whole-job preload of
itself is 32 MiB (the `ring_mb` module parameter, backed by the 32 MiB
reserved pool, matching the factory ring): ~168 s of stream at 200 kHz,
~56 min at the 10 kHz cloud-mode tick — a capacity that matters for the whole-job preload of
cloud mode, not for latency. Bounded queue depth + `SCHED_FIFO` for the
feeder is the design; revisit RT only if the underrun bench ever
contradicts this arithmetic.