Meet Dis-Tract 0.1.0 - #2482
Conversation
|
Some directions this could go, roughly in the order I'd argue for them. Most came out of building it; a couple are already designed and measured in Correctness — both now fixed in this PR
What that check is still only as good as: the budget itself. A node's Making the premise trueTODO The coordinator materialises the whole model to plan. It loads it only to count layers and build a per-layer weight profile, then drops it — but that means it needs a box that fits the model, which is exactly what the too-big-for-one-node case does not have. So the headline claim is blocked by the one component that does not need the weights at all. The profile could come from the graph AST, which TODO Then actually run it on two machines. Everything so far is two processes on one box over loopback, on a model that fits it. Two 16 GB machines and Qwen3-32B (~17.6 GB) is the experiment that earns the word "distributed", and it would also put a real number on the per-token wire cost that loopback hides. ThroughputTODO Micro-batching. With a single stream only one stage is ever busy: stage 1 idles while stage 0 computes token n. Each node is ~50% idle in a 2-stage split and ~1/N for N stages, and that does not improve with more nodes. Keeping several sequences in flight is what makes pipeline parallelism pay, and it is the difference between a latency demo and a serving system. Single-stream latency is already fine (splitting costs ~1%). TODO Drop the double optimise on the load path. The worker optimises a clone of its shard purely to classify I/O slots, discards it, then hands the raw model to TODO A profile-guided planner. Placement is memory-proportional only: a Metal node and a CPU node with equal budgets get equal layers, and since every token traverses both, the pipeline runs at the slower one's pace. Today you bias it by lying about budgets. Weighting by measured throughput and link cost is the obvious next step. DeploymentTODO Ship shards to workers, cache them per node. Today
with the source as a resolution order — cache, then a shared path, then the wire — so a LAN with a NAS never touches the wire and cross-site nodes need no mount, from one config. Not implemented. TODO An NNEF directory instead of a Further outTensor parallelism (the step channel already carries a generic tensor vector, so a collective needs no new message type); multi-turn context (the KV reset per prompt is a deliberate PoC choice, not a constraint); sampling beyond greedy (argmax is what makes the token-parity check against a single-machine reference possible, so it would want to stay available). |
|
Sorry for being so long. Here is a couple of thoughts, focusing on API usabillity for now, more than actual evaluation of performance of the distribution. I do not know yet if "dis-tract" should be a tract consumer, or a tract optional runtime, or something else. But it's a good exercise to figure out the API. And I hear you, it's not there yet. Let me summarize the main snags you hitted in my own words:
As a next step, are you interested in looking at the lazy loading story ? I think I'll spend a bit more time on recovering the various performance drift the bench refactoring has surfaced, so I'd prefer not to add another dis-traction. |
Splitting a model by layer had no primitive: callers had to hand-roll set_input_outlets/select_output_outlets/translate_model/compact and get the boundary validation right themselves. These compose those steps and reject an output cone that reaches an undeclared Source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Model cannot be split, measured, or inspected through the public API, so a consumer that needs the graph has no way to reach it. Adds doc(hidden) typed_ref/into_typed_model accessors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tract runs a whole model in one process, so a model too big for one machine cannot run at all. This adds Dis-tract: each worker loads only its own contiguous slice of layers (pruning the NNEF graph and reading just that shard's tensors) and keeps that slice's KV resident, so only the residual activation crosses the wire; workers are discovered and driven over zenoh and may each run a different backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t API full_io_roles and partition_stages both take the model's regular-output count, but the test still called them with the old arity and loaded the model without unfold-kv-cache, so it did not compile. Load through load_model, which returns that count and applies the same transforms the binaries use. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thinking was suppressed by appending a /no_think switch to the message, but that switch is ordinary text in the user's turn, so the model reads it as content and a one-character prompt is dominated by it. Control tokens typed into a message were also parsed as real, letting it close its own turn and forge another role's. Prefill an empty thinking block on the assistant turn instead, and assemble the turn structure from ids with the message encoded as literal text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The crate sat at the workspace root, where it read as a first-class tract crate and, being in default-members, made a bare `cargo build` pull its zenoh tree in. It is a sample: move it to examples/dis-tract and register it in members only, as causal_llm is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The crate's header still described shards being shipped to workers as NNEF over HTTP, which neither the zenoh transport nor the per-shard loader has done for a while, and the README presented running a model too big for one machine as a result rather than a goal: every run so far is two processes on one box over loopback, on a model that fits it. Say what is exercised and what is not, and escape the usage strings rustdoc was reading as links and HTML tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsport Readers keep hitting the same questions: why the cluster's tok/s is about half a node's, why a shard can be slow on Metal and identical on CPU, why the KV never crosses the wire, and what has not been tested. Answer them in docs/FAQ.md. LoadMeta and config_key described a coordinator that shipped model bytes to workers over HTTP; nothing has used either since the worker started building its own shard over zenoh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ement The FAQ did not say whether the cluster serves one request or many, whether it is usable off a LAN, or what decides which node gets which layers — all of which have blunt answers worth stating: strictly one stream with no batching, so every node sits ~1/N busy; LAN only, since the per-token residual is latency-bound and nothing is authenticated; and shards assigned in proportion to advertised memory alone, with no performance term and no check that a shard fits. Also says why zenoh, including the router-star and loopback-multicast constraints it imposes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er node A worker is handed a model path, so every node needs the whole 4.29GB export on its own disk and decompresses all of it to reach the 1.8GB it owns; a bare node cannot join, and a model too big for a node's disk cannot run even when the only real constraint is its RAM. Records how the coordinator could serve just what a shard needs and how a worker would cache and verify it, with the measurements that decide the shape: the graph is under 1MB so it ships whole, .dat bytes are already a wire format so nothing is re-serialized, and SHA-256 verifies a shard in 0.9s against MD5's 3.3s because ARM accelerates it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsors The shard-cache design assumed a tensor could be served per key, but the model's embedding is 334MB and nothing had established what zenoh does with a payload that size. It carries it in one reply, intact, so chunk-per-tensor holds; a whole 1.8GB shard does not, as replies stop arriving just under 1GiB and throughput already halves past 768MB where the payload is buffered whole at both ends. distract-wiretest records the sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Serving tensors from the coordinator is the wrong default for a LAN with shared storage: it puts 1.8GB per worker on the coordinator's uplink when the bytes are one mount away, and a mount is no help at all across sites. Since the manifest gives every tensor a SHA-256, the source stops mattering — anything hashing to the manifest is valid — so a worker resolves cache, then a shared path, then the wire, and the same check covers a corrupt NAS copy that a bare path loads silently today. Notes that a .tgz on a mount is the worst case, since gzip forces a worker to pull the whole export to reach its own shard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Which component does what, and when, was only legible by reading four binaries at once: that the coordinator loads the whole model before any worker exists, that it wires the stage chain via next_hop rather than workers discovering each other, that a worker builds its shard from its own filesystem and that this is ~40s of the ~49s startup, and that a token traverses every stage in turn so step times add. Records the startup, per-token and node-death sequences with the timings from a real run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A stage that died mid-token took its distract/result publication with it, and the coordinator waited on that reply with no timeout and no idea the node was gone, so it blocked forever; because the generate loop is serial it then accepted no further request, and a restarted worker rejoined a cluster that would never speak to it again. Watch the liveliness tokens the workers already declare, so a Delete for a stage of this run ends the wait at once, and bound the wait anyway for a stage that is merely stuck. The generation is abandoned with its partial tokens and an error rather than retried: the sequence is unrecoverable, but the next prompt resets every stage's KV, so the cluster keeps serving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The planner divides weights in proportion to advertised budgets and never compared the result against them, so a cluster short of memory still planned: the node found out at load time, ~40s in, after every worker had built its shard. Check each stage against its node's budget and fail the plan instead, naming what it would hold and what it advertised. Weights are a lower bound — KV and runtime measured 1.2-1.6x on an 8B model — so this catches a node that cannot fit its shard, not one that merely might not. The single-node path returned before any split and so was never checked at all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hat a budget is worth The FAQ and the event sequence still described a planner that assigns a shard without checking it fits, which it now refuses to do. Says what the check really covers instead — weights are a lower bound, so it catches a node that cannot possibly hold its shard, not one that merely might not — and what the check is only as good as: a budget is sampled at join and never reserved, co-located workers count the same host RAM twice under the default fraction, and it is system RAM whatever the backend, which is roughly right on unified memory and wrong on a discrete GPU. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The coordinator built the whole TypedModel only to count layers and weigh them, so a model too big for one node was also too big for the coordinator meant to split it across nodes. Read the graph text and the .dat sizes straight from the archive or directory, and attribute each variable to the earliest layer whose cache it feeds, which yields the same per-layer profile without materialising anything. A profile whose weights have no tensor file is refused rather than planned from, since an all-zero profile is a valid-looking input to the planner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Declutter renames nodes, so identifying the KV cache slots by node name worked only until a pass touched them. Read the outlet label instead and fall back to the node name, which keeps the cache depth — and therefore the layer a cut lands on — stable across the passes that run before a shard is built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stage held one resident KV, so only one generation could be in flight and every prompt had to clear it. Key the caches by sequence id, carry that id on each step so a stage picks the matching one, and reset or free a single sequence rather than all of them. A cache the coordinator never frees — it died, or dropped the sequence — is reclaimed once it goes idle, so a stage's memory stays bounded by what is actually being served. A step whose sequence cannot be read is dropped rather than folded into another sequence's cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every token traversed every stage before the next one started, so in a 2-stage split each node sat idle roughly half the time and adding nodes made that worse, not better. Give the coordinator a scheduler that keeps one sequence per stage in flight and matches results back by the sequence id they carry, so a stage that has handed its step on picks up another sequence instead of waiting. A sequence's own latency is unchanged — its steps are still strictly ordered — and the ceiling is the slowest stage, so an unbalanced split caps the gain. Prompts can be fed in chunks (--prefill-chunk) to bound how long one long prefill holds a stage, and dispatch is least-served-first so a narrow pipeline cannot starve a sequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…measures The docs described a strictly serial server, a coordinator that materialised the model, and a CPU NaN needing an unmerged PR — none of which is still true. Rewrite the serving sequence around interleaved sequences, record what interleaving is measured to buy and why the split rather than the scheduler sets that ceiling, and note that the memory-weighted planner optimises the wrong quantity when stages sit on backends of different speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3c018fb to
a457f0b
Compare
|
Rebased on main and pushed. All three framings were useful; two were directly actionable. 1. Lazy weights. Found the stale PR — #1386 So: have the provider yield a 2. Edge labels. Tried, works — cut points now come from cache depth over 3. transformers into core. Small exposure: one call site ( One question. Micro-batching is in (per-sequence KV, interleaved steps; concurrent output
Both at the pipeline ceiling, so nothing is left in the scheduler. Still a PoC, still two processes on one box. #2495 means the CPU path no longer NaNs, so the |
…them Placement asked cache depth which block a node belongs to, but depth flows along the residual, so the projections a block computes from the residual its predecessor produced took the predecessor's depth. They were computed in the previous stage and sent across the wire to the stage that needed them, adding the query, key and value tensors to a boundary that should carry only the residual. Segment by the residual instead: a node belongs to the last block whose incoming residual reaches it. Models whose blocks cannot be recognised stay on cache depth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed a fix for something the README was simply wrong about. It claims only the residual Placement asked cache depth which block a node belongs to. Depth flows along the residual, Per decode step on Qwen2.5-7B, cut at layer 14: 7 wire tensors -> 4, constant payload Two notes. The larger effect may not be the bytes at all — this moves ~9 MB of matmul off Still crossing, and the more interesting one: the attention mask, in f32, sized |
…ding them The RoPE angles and the causal mask were computed once and forwarded across the boundary, so a stage was sent tables it could have built itself. The mask is the reason to care: it is as wide as the context, so the wire grew every token while the residual stayed a fixed 7 KB, and at 32k context the tables were most of what crossed. Their cones reach no Source at all — constants and arithmetic over the sequence dimensions, which every stage has — so keep them off the frontier and let each stage's extraction pull the cone in. Only the residual crosses now, whatever the context length. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Follow-up to the last one: the wire is now a single tensor. I had assumed the shared tables could not be rebuilt per stage, because they read the So there was nothing to normalise. Keeping them off the frontier is enough: each stage's Per decode step on Qwen2.5-7B, cut at layer 14:
The mask was f32 and Parity is unchanged: prefill cosine 1.000000, and greedy decode token-identical to the
The README, FAQ and event trace all claimed only the residual crossed. That was wrong when |
Dis-tract splits a model by layer across workers, aiming at running a model too big for one box: each worker loads only its own contiguous slice of layers plus that slice's weights, keeps that slice's KV resident, and only the residual crosses the wire. Opening this as a draft to try and to settle one design question (below) — it is not proposed for merge in this shape, and the too-big-for-one-box payoff is not yet demonstrated (see the next section).
Follow-up to #2465.
It lives in
examples/dis-tract, next tocausal_llm, and is inmembersbut notdefault-members— so a barecargo builddoes not pull its zenoh tree in.What has actually been tested — read this before the numbers
Everything below is two worker processes on ONE 16 GB Apple-silicon machine, over loopback. It has never run on two physical machines. That matters more than it sounds:
tcp/127.0.0.1:7447endpoint. Zenoh scouting — the thing that would find peers on a real LAN — is therefore untested here.So: the sharding, the KV residency, the wire protocol, and the parity are real and exercised. The distributed claim is not yet earned. Two 16 GB machines and a model that needs both (Qwen3-32B, ~17.6 GB) is the experiment that would earn it, and I have not run it.
The result worth reporting: the split is free (on one machine)
A 2-stage split runs at whole-model speed. Qwen3-8B-q40ef16, M-series, Metal, measured with
distract-shardbenchin a single process, so the only variable is how the model was built:load_model)Token-identical across all three; splitting costs ~1% in-process, with no wire between the stages. (Min rather than mean: the means carry occasional scheduler outliers on a laptop, e.g. the unsplit shard's mean swings 48-58 ms across runs while its min is stable.) A live 2-worker cluster — both workers on the same machine, zenoh over loopback — does 21-22 tok/s, TTFT ~190 ms, each worker holding 2.2 GB of a 4.29 GB model.
That number was not free. The 2-shard chain was originally 8.4 tok/s and I assumed the pipeline bubble or the KV round-trip. Both were wrong: an unsplit full-range shard was equally slow, which pointed at the shard builder, not the split.
load_shardbuilt its model with the low-levelnnef().translate(&proto, ..), which — unlike theapi/rsloader (model_for_path(p)?.into_decluttered()?) — does not declutter. Undecluttered, the graph was 5364 nodes instead of 1690;MetalTransform's rewrite rules only match decluttered patterns, so 73MultiBroadcastTostayed on the host instead of becomingGpuMultiBroadcastTo, plus 146 stray hostSlices. CPU was unaffected (271 vs 306 ms), so a CPU-only A/B hides it completely. One.into_decluttered()?fixed it.That
translate()silently returns an undecluttered model — no error, no warning, ~2.8x on GPU only — may be worth a docs note or a guard at the GPU transforms; happy to file separately if useful.How to try it
Models are the q40ef16 NNEF exports CI publishes. The coordinator is the zenoh router, so start it first; workers and dashboard are clients and retry until it is up.
--backend cpuworks identically; a heterogeneouscpu+metalcluster was the original demo. CPU currently needs #2477 or Qwen decodes NaN; Metal is fine on main (#2476, #2472, #2428 merged).Reproduce the table with
distract-shardbench <model> metal, and check device-vs-host op placement withdistract-metalaudit <model> <n_layers>.Docs
examples/dis-tract/README.md— what it is, how to run it, what is and is not tested.examples/dis-tract/docs/FAQ.md— why the cluster's tok/s is half a node's, why a shard can be slow on Metal and identical on CPU, why the KV never crosses the wire, how shards are assigned (memory only, and why a budget is a claim rather than a reservation), whether it serves one request or many (one), whether it works off a LAN (no).examples/dis-tract/docs/shard-cache.md— a design, not implemented: how a worker could get only the tensors it needs, from a shared path or over the wire, and cache them verified. Includes the measurements that shape it — zenoh carries the 334 MB embedding in one payload but nothing at 1 GiB, and SHA-256 verifies a shard in 0.9 s vs MD5's 3.3 s because ARM accelerates it.examples/dis-tract/docs/upstream-partition-api.md— the partition primitive.The design question
You suggested on #2465 that the split be a registered
ModelTransform. I could not make that work, and I think the conflict is real rather than a matter of effort: aModelTransformoperates on an already-loadedTypedModel, so the full model must be materialised first — which is precisely what a too-big-for-one-machine split has to avoid. This crate instead prunes the NNEF graph AST and reads only the shard's.dattensors (EXO's approach), which needs loader internalsapi/rsdoes not expose.So I suspect the upstreamable primitive is not the splitter but a public partial-load API for NNEF — load a graph subset plus only its tensors. Given that, Dis-tract becomes an ordinary
causal_llm-style example on the public API and most of what is objectionable below disappears. Which way would you like it to go?Where it stands — honest limits
tract_core/tract_nnef/tract_transformersimport sites, againstcausal_llm's singleuse tract::. So it does not meet the bar the examples set. That is the blocker the design question decides.shard_graph.rshardcodes torch2nnef naming (model_model__{N}_inputLayernorm_...). Fine for the published q40ef16 Qwen/Llama exports; not general.api/rsescape hatch: this branch addsModel::typed_ref/into_typed_model(#[doc(hidden)]), because aModelcannot otherwise be split or measured. Smallest thing that unblocks it — reshape as you prefer.available_memory() * --mem-frac, or--mem-mb) sampled at join, whatever the backend. On Apple silicon that is roughly right, since unified memory means a Metal buffer really does come from the same pool. On a discrete GPU it is simply wrong — a box with 128 GB of RAM and a 16 GB card advertises ~100 GB, the plan fits it, and the shard then has to land in VRAM. Two co-located workers also double-count the same host RAM under the default--mem-frac; passing--mem-mbside-steps it, which is why the launch script always does. The planner's fit check compares against these numbers, so it is only as good as they are.Runtime::check()but is unexercised on Apple hardware — the budget issue above is one concrete consequence.llm,worker,dashboard,gen). The other 9 are diagnostics that earned their keep finding the declutter bug (shardbench,metalaudit,probe,shard-run,cmpconst,dump,eval,shard,trace); say the word and I will cut them down to the two worth keeping.Transport is zenoh (scouting + pub/sub + a queryable for assignment) — the same stack EXO uses, on crates.io, Apache-2.0. Tensor parallelism is a documented hook, not implemented.
🍍