Meet Dis-Tract 0.1.0#2482
Conversation
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>
|
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, before anything elseTODO A worker dying deadlocks the coordinator. The worker side is fine — liveliness evicts it, a restart with the same The fix is small and the pattern already exists a few lines above it (the reset-ack): bound the wait with a TODO The planner has no fit check. It splits weights proportionally to advertised budgets and never verifies a shard fits the node it lands on — an oversubscribed node fails at load time rather than plan time. That was survivable while the weight figures were themselves ~8x wrong (they counted block-quant weights at dequantized size); now that they are accurate, the check is cheap. 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). |
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>
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, no fit check), 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.Runtime::check()but is unexercised on Apple hardware.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.
🍍