feat(patterns): render functionally for a fused torch backend - #8
Merged
Conversation
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.
|
🎉 This PR is included in version 0.2.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
render functionally for a fused torch backend
Changes
docs
style
test
perf
fix
refactor
Increment type: minor
Target branch: main
Status: 👀
flywheel:needs-review—featis not in auto_merge list formainQuality checks: see required status checks for live state.