Skip to content

feat(patterns): render functionally for a fused torch backend - #8

Merged
repentsinner merged 18 commits into
mainfrom
feat/patterns-fused-backends
Aug 30, 2026
Merged

feat(patterns): render functionally for a fused torch backend#8
repentsinner merged 18 commits into
mainfrom
feat/patterns-fused-backends

Conversation

@repentsinner

@repentsinner repentsinner commented Aug 30, 2026

Copy link
Copy Markdown
Member

Summary

render functionally for a fused torch backend

Changes

docs

  • name the runtime by role, not by repository (33f8ba5)
  • state backend portability as a catalog property (eb3e6b4)
  • decompose backend portability into four workstreams (2533d53)
  • correct the dtype rationale against measured behaviour (157a8b6)
  • match the verify block to measured backend behaviour (499adfa)
  • merge corrected spec and roadmap (63634aa)
  • drop the link to a private repository (76616d1)
  • merge private-link removal (4e229ee)
  • merge private-link removal (d59d194)
  • merge private-link removal (587bd3b)
  • state on the public surface what the caller must choose (5b5ff57)
  • name the upstream as a renderer, nothing narrower (3902a19)

style

  • reflow the paragraphs the rename rewrapped (2871ef5)

test

  • make the torch leg a gate instead of a skip (223f073)

perf

  • render the checkerboard functionally, at a caller's dtype (f8ae0f3)
  • extract bits arithmetically, from an array index (d7dc20e)

fix

  • widen the frame index annotation to array data (0d34786)

refactor

  • index the blank row alike in both render bodies (9b2af2b)

Increment type: minor
Target branch: main
Status: 👀 flywheel:needs-reviewfeat is not in auto_merge list for main
Quality checks: see required status checks for live state.

This repository is public. The frame-counter panel's upstream is a
private repository, and REQUIREMENTS.md, SPEC.md, the counter-panel
module and its test all named it — three of them citing a `§spec:`
slug that points into it.

Name the role instead ("a real-time LED render runtime"), and drop the
cross-repo slug citations rather than point them at a repository no
reader of this one can open.

The name is already published and this does not unpublish it: clones,
forks, and the public event stream carry the earlier revisions. This
stops the reference spreading further and keeps the counter panel's
provenance readable without it.
The rendering model already says patterns render through a
caller-supplied namespace, and §req:success-criteria already claims
numpy and torch agree. Nothing says what makes that true, and three
things currently make it false.

Render bodies allocate and then write strided slices. A scatter is a
materialized intermediate no compiler fuses away, and mutation rules
out immutable-array backends outright — so the torch path is eager and
bandwidth-bound where it should be one kernel.

The counter panel takes its frame index as a Python integer and shifts
it per bit in Python. To a tracing compiler that integer is a
compile-time constant, so a consumer compiling its frame loop
recompiles the pattern on every frame; the fused path would cost more
than the eager one it replaced.

Checkerboard hard-returns uint16, which does not work on MPS — the
backend the consumers of this library develop against.

The new section states the end state for all three, plus where
verification lives: torch's CPU build as a development dependency so
the equivalence tests stop skipping silently, and opt-in markers for
CUDA and MPS where the hardware is. The rendering model drops to
in progress because its dtype sentence is superseded.
§spec:backend-portability lands as one section in build-dependency
order. The torch gate goes first because it is what keeps the other
three honest: until torch's CPU build is a dev dependency the
equivalence tests skip, and a functional rewrite would land with
nothing checking it renders the same arrays.

The two rewrites are separate workstreams because they are separate
files with separate risk — the fill also changes its return dtype
contract, the counter also changes what type its frame index accepts.

The last workstream is the surface: a consumer on MPS has no way to
discover it must pass a dtype unless the README and the entry-point
docstrings say so, which makes documentation the integration point
for this section rather than an afterthought to it.
The backend-equivalence tests called `pytest.importorskip("torch")`
against an environment that never installed torch, so both legs
skipped on every run and in CI. The claim that numpy and torch render
the same arrays was asserted by nothing.

Torch's CPU build joins the dev group. It stays out of the package's
dependencies — the namespace is duck-typed, and a numpy-only consumer
must keep paying nothing for it. On Linux it resolves from PyTorch's
CPU index, because PyPI's Linux wheel bundles a CUDA runtime CI has no
use for; macOS resolves from PyPI, whose arm64 wheel is the one
carrying MPS.

Device legs are opt-in behind `cuda` and `mps` markers, deselected by
default: CI asserts the backend contract, hardware asserts the device.

Running the gate for the first time immediately found a bug it was
meant to find. A single-colour fill expanded through `broadcast_to`,
whose result is a read-only view, and torch warns that it cannot back
a tensor with non-writable memory. Copy the expansion instead.
The section claimed uint16 "does not work on MPS". Probing torch 2.13
on an M-series host shows that is false in eager mode: allocation,
strided assignment, `where`, and host readback all succeed at uint16.

What is true is narrower and lands on the path that matters. Inductor's
Metal code generator has no uint16 entry in its dtype table, so a
uint16 output raises `KeyError: torch.uint16` the moment a consumer
wraps the render in `torch.compile`. The dtype parameter is still the
right decision — it is the fused path it unblocks, not the eager one.

The functional-body rationale gains its measurement: 2160p on MPS,
3.5 ms strided against 0.7 ms functional, before any compilation.
Two changes to one render body.

The body no longer allocates a frame and writes four strided slices
into it. A scatter is a materialized intermediate no compiler fuses
away, and mutation put immutable-array backends out of contract. A
frame has only three kinds of row — even tile row, odd tile row, and
blank — so the raster is now three rows built at row width plus one
per-row gather that selects among them.

That also folds the region of interest into the gather. Masking it
afterwards costs a second full-frame pass, which measured an order of
magnitude more than the gather itself. 2160p on numpy goes from 44.4 ms
to 2.9 ms, which is the first time this pattern fits a 60 Hz budget;
on MPS, 3.5 ms to 2.4 ms eager and 0.30 ms compiled.

Output dtype becomes a parameter, defaulting to uint16 so every
existing caller is unaffected. A dtype that cannot carry the stated bit
depth exactly is refused, which is what preserves exactness now that
the type is not fixed — including float dtypes, whose exact-integer
range comes from the epsilon both namespaces expose. The reason this
matters is the fused path: uint16 has no Metal code-generation mapping,
so a uint16 output cannot be compiled on MPS at all.

Output is bit-identical to the strided body across 160 combinations of
palette size, region origin and parity, region overhang, and frame
size. Two of those cases are pinned as tests: parity registers to the
region's origin, and a region reaching past the frame clips.
The panel looped over bit positions in Python and shifted a Python
integer per bit. To dynamo that integer is a compile-time constant, so
a consumer compiling its frame loop got a distinct graph per frame —
measured, twelve frames produced eight graphs and then hit the
recompile limit, after which dynamo abandons compilation for the call
site entirely. A consumer's fused path silently became an eager one.

The frame index is now array data and the bits come out with
bitwise_right_shift over a per-column shift array. One hundred frames
now produce one graph. The counter is bounded at 31 bits so the
arithmetic stays in the signed 32-bit integers every backend supports;
at 60 Hz that is over a year of counting.

The raster follows the fills: two rows built at row width — panel band
and blank — and one per-row gather.

Cost, stated plainly: on numpy a 2160p panel goes from 4.9 ms to
6.5 ms. A functional body writes every row, where the old one wrote
only the panel band into pages calloc had already zeroed; that is
inherent to producing a dense array without mutation, not an oversight.
Both sit inside a 60 Hz budget, and the path a GPU-resident consumer
actually runs improves far more: 0.62 ms compiled on MPS, against a
path that previously could not be compiled at all.

Output is bit-identical to the previous body across 640 combinations of
frame size, counter width, and frame index, each still round-tripping
through decode_counter.
The README linked color-wrangler by URL and SPEC.md named it twice.
That repository is private and this one is public, so the link
published both the name and the fact of it.

The README's "Related" line now points at the public siblings that
measure, report on, and document surface characterization, which is
what a reader of this repository can actually open. The spec names the
umbrella by role where its ownership boundary still needs stating.

Checked the rest: ocio-display-gen, pydecklink, bmd-signal-gen,
display-measure, display-report and methodology are all public and stay
named.
Two of the decisions in this section are invisible from the API: that a
frame index should arrive as array data, and that the default uint16
output cannot be compiled on MPS. A consumer hits both as a silent
performance cliff — dynamo quietly falling back to eager, or an
Inductor KeyError — with nothing in the signature to suggest why.

The README gains a Backends section carrying both, with the code to do
it right, and the catalog's module docstring carries the same two
points where a reader lands from the import.

Marks §spec:backend-portability complete and removes the four shipped
workstreams from the roadmap. The rendering model returns to complete:
its dtype sentence now describes what the code does.
"A real-time LED render runtime" was meant as a role description, but
it is specific enough to work as an identifier: real-time, LED, and a
render runtime together describe a very small set. That defeats the
point of not naming the repository.

"A renderer" carries what a reader of this repository actually needs —
that the counter-panel math came from a consumer with a frame loop, not
from this library's own design — and narrows to nothing.
@repentsinner repentsinner changed the title Feat/patterns fused backends feat(patterns): render functionally for a fused torch backend Aug 30, 2026
@repentsinner
repentsinner merged commit b01a856 into main Aug 30, 2026
13 of 14 checks passed
@repentsinner
repentsinner deleted the feat/patterns-fused-backends branch August 30, 2026 19:33
@ode-flywheel-build

Copy link
Copy Markdown

🎉 This PR is included in version 0.2.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant