[REA-5060] Give a model a way to run on more than one GPU - #134
[REA-5060] Give a model a way to run on more than one GPU#134Orion-Zheng wants to merge 4 commits into
Conversation
The standalone runtime has no multi-GPU story: a model that needs several GPUs has nowhere to put the per-device half of itself, so the abstraction that solved this in the previous runtime is brought across. This commit is only the move. The six files land byte-identical, so this commit is the baseline every later commit in the stack is reviewed against — it proves nothing was lost in transit, and it separates "the code arrived" from "the code was adapted". The adaptations this repo requires (its docstring style, its typing strictness, no references to systems an outside reader cannot see) all follow as their own commits on top, where they can be read as deliberate changes rather than hidden inside a 750-line paste. Nothing imports the package yet and nothing re-exports it, so this commit does not change behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Andy <andy@reactor.inc>
The move landed the code unchanged; this makes it belong here. Four kinds of change, none of them behavioural — the ten protocol tests that came with the package pass untouched, which is what makes that claim checkable. Style and provenance. The copyright headers go, since this repo carries none. References to systems an outside reader cannot see go too, with their content kept where it was load-bearing: "a standing watchdog is not implemented" says everything a reader needs without naming a tracker, and the dangling pointer to an example that was never published is simply dropped. Docstrings gain the one-line imperative summary the linter wants; the Sphinx roles stay, because this repo already mixes them with Google sections. Logging. This is the one seam that would have failed at runtime. get_logger has the same name in both runtimes but returns a different object here: a structured logger whose level methods take fields as keywords and reject positional args. Two calls passed them, and both sit on failure paths — the worst place to learn that a logging call raises. Every call now uses a static message plus fields, and a spawned worker installs the runtime's own formatter instead of a bare one, so its lines share the runtime's shape and carry the rank as a field rather than a string prefix. Surface. The five public names are re-exported from the top-level package, because the supported surface here is what that package exports; a name reachable only through a submodule reads as unsupported. What makes this safe is that torch is imported lazily, inside the functions that use it, so importing the runtime still pulls in nothing heavier than numpy. That rests on discipline rather than on structure, so a test now pins it: a subprocess imports the package and asserts torch never entered sys.modules. Typing. Only two things needed saying. torch is an optional dependency the checker cannot see, so its lazy imports carry a narrow suppression with the reason attached. And a concrete setup() override necessarily narrows the base's **setup_kwargs, which reads as an incompatible override — a property of the abstraction that every model's worker will meet, so the base class now documents it and the test suppresses it the same way a model would. Signed-off-by: Andy <andy@reactor.inc> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Andy <andy@reactor.inc>
The README is the front page, and it described a runtime where a model runs on one device. Multi-GPU changes what an author can build with it, so it belongs here rather than only in the API docs. One line in Highlights, and a paragraph in How it works placed after the single-GPU example — because the point worth making is that the example does not change. The model keeps its event loop, its session, and its output stream, and gains a handle it drives from the same run() loop. Framed that way rather than as a feature list, since the reason to reach for this is a model that outgrew one device, not a wish to write distributed code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Andy <andy@reactor.inc>
|
The engine is a good approach. Failure taxonomy, the max-end-row trick, the shared-memory path, liveness, the teardown ladder, the ten protocol tests are solid. The changes I'd like to see are the author surface. We need to be careful as once the names are importable they are minimally an implicit contract and likely to be taken as an explicit contract. Today multi-GPU is a second way to write a model ( Distributed by default, single-GPU is world_size=1:
The base class is the paved road that covers essentially every model. The raw primitives stay available as experimental internals for the rare model that genuinely needs to hand-drive. That demotes them from "the API" to "the escape hatch" instead of removing the capability. Think about DX ergonomics when deciding to either keep The README documents This is moving the driving into a base class and thinning the surface. If my recommendations are wildly out of left field or otherwise would pose too many ergonomic challenges, please let me know. |
The abstraction and its tests. End-to-end validation against a real multi-GPU
model happens against a published release rather than in this PR, since a model
pins a runtime version rather than a branch.
Why
Some models do not fit on one GPU, or fit and miss their latency target. The
usual answer is SPMD: run N copies of the program, one per device, and let them
split the forward pass between themselves over a process group. The usual
launcher for that is
torchrun, and it assumes your program is a script thatruns
main()and exits.A model here is not a script. It is a long-lived server with one event loop, one
WebRTC connection per client, a session lifecycle, inbound commands that mutate
state mid-stream, and an output stream paced to a frame rate. You cannot start
four copies of that: you would get four servers contending for one port, four
sessions, and four sets of frames racing to the same client.
So there was no way to write a multi-GPU model against this runtime at all. This
adds one, by splitting the process into two roles rather than replicating it: the
model you already write stays single, owns the network and the session, and
touches no CUDA; underneath it sits a plain SPMD world of worker processes, each
pinned to one device, all running identical code.
The design is deliberately thin, and the two acts of restraint are the point.
The framework issues no collectives of its own — the process group is created
for you and then belongs entirely to the model, because collectives must be
entered by every rank in the same order and a framework that interleaved its own
would need to understand your parallelism strategy to stay out of the way. What
synchronises the ranks instead is that collecting one reply from every rank is
the only synchronization mechanism: when a call has heard from all N ranks, all
N have finished, including their writes. And frames never cross the command
channel — pickling a chunk of video through a queue costs tens of milliseconds,
so there is one shared-memory buffer that workers write into in place, and what
travels over the queue is an integer.
What Changed
Two commits. The first moves six modules in byte-identical, so it is the baseline
the rest is reviewed against and "nothing was lost in transit" is checkable
rather than asserted. The second adapts them to this repo. Reviewing them in that
order is much easier than reading the result cold.
A model subclasses
DistributedWorkerand implementssetup/warmup/start_session/generate_chunk/end_session; the framework runs oneinstance per GPU in its own process with
rank,world_size,deviceand aframeshandle already populated, and the process group already initialized. Themodel holds a
WorkerGroup, built inload(), and drives it from its own loop.Every method broadcasts one verb to every rank and waits for the full reply set,
so a command succeeds only if it succeeded everywhere, and every blocking wait
polls liveness — a rank that dies silently surfaces as
WorkerCrashedin secondsinstead of hanging in a collective.
generate_chunkmay return an array of frames, anintend row, orNone, andthe chunk's frame count is the maximum end row across ranks. That one choice is
what makes three different write patterns work with no coordination between
ranks: a leader assembling the whole chunk, ranks sharding the frames axis, or
every rank writing its own pixel band. Taking the maximum, all three come out
right, and no rank needs to know what the others did.
The two failure types differ in whether the worker managed to say anything.
WorkerErrormeans a rank reported; it is recoverable only fromstart_session, where all ranks rendezvous on the outcome and stay alive for aretry.
WorkerCrashedmeans a process died without a word, and is neverrecoverable. Teardown follows from that: a healthy group gets the exit verb and
runs a clean barrier-and-destroy, while a group already broken by a fail-fast
failure skips the clean path entirely — a survivor entering the exit barrier
would wait forever on its dead peer — and goes straight to termination, with
anything still alive after its grace period terminated and then killed.
generate()blocks the caller for the chunk's compute time. That is deliberate,not an oversight: it is what lets the emission rate follow measured throughput.
The practical consequence for an author is to keep each turn short — yield while
waiting, and do not park a long
awaitinside one turn.Adapting to this repo turned up one thing worth naming, because it would have
failed at runtime rather than in review:
get_loggerhere returns a structuredlogger whose level methods take fields as keywords and reject positional
arguments, and two calls passed them — both on failure paths, which is the worst
place to learn that a logging call raises. Those now use a static message plus
fields, and a spawned worker installs the runtime's own formatter so its lines
share the runtime's shape and carry
rankas a field rather than a stringprefix.
torchstays an optional dependency. It is imported lazily, inside the functionsthat need it, so importing the runtime pulls in nothing heavier than numpy and a
model image without torch is unaffected. That rests on discipline rather than
structure, so a test now pins it.
API surface
Five names, re-exported from the package root:
SharedFrameBuffer,WorkerErrorandWorkerCrashedcome along for the authorswho write slices themselves or catch the failures.
Nothing needs a process group to be useful in tests:
init_process_group=Falsegives the same protocol with no torch involved.
A model is free to carve its own sub-groups — tensor-, sequence-, or
context-parallel — by calling
new_grouporinit_device_meshinsidesetup,where every rank reaches the call in the same order. The framework never touches
the group after creating it, so there is nothing for a model's own collectives to
interleave with.
Verification
The ten protocol tests that came with the abstraction are ported unchanged, and
they are the reason "no behavioural change" is a claim rather than a hope. They
drive the real machinery — real spawned processes, real queues, real POSIX shared
memory — at
world_size=2with no torch installed, and cover every failure path:retryable session init, fail-fast chunk failure, exactly one error posted per
failure,
SIGKILLsurfacing asWorkerCrashed, the shutdown escalation ladder,and all three frame-write patterns. Green on 3.12 and 3.13 alongside the rest of
the suite.
Those tests all pass
init_process_group=False, so they never form a group. Thatgap is closed separately, since it cannot live in this suite: a probe forms a
real group and runs over gloo (torch present, CUDA absent) and then NCCL
on 4×B200, at
world_size2 and 4. Atworld_size=4split into two halves, aworld
all_reduceof ones returns 4 while the sub-group's returns 2 — a pair ofvalues only two genuinely distinct groups produce, which is what makes
"a model can build its own mesh on this" a demonstrated claim. The same run
confirms a thread created inside
generate_chunkallocates on its own rank'sdevice, that host-memory pinning actually succeeds rather than silently degrading
to pageable copies, and that the clean-exit barrier and destroy complete without
hanging.
One thing surfaced there and is left alone on purpose: torch warns that
init_process_groupis called withoutdevice_id, so the clean-exit barrierinfers its device from the ambient context. It is a warning, and passing
device_idwould change what this code asks of torch, which is a decision ratherthan something to slip into a move.