File size: 18,766 Bytes
31dc8dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# Online Disaggregation Roadmap

Live disaggregated training: a rollout pool runs the **frozen target** model autoregressively
over a fixed prompt set and streams its hidden states across the network into a separate trainer
pool. This folds PR #618's online roadmap into the consolidated tree and applies the shared
decisions: **train-with-decode only** (the online target is frozen, so the streamed data is
independent of draft weights β€” there is no staleness and nothing to re-sync, and the whole
weight-sync / hot-update / on-policy axis is dropped here, kept only as the explicit out-of-scope
note below); and **Ray is OPEN/undecided** for the O2 scale-out layer β€” presented as a candidate
with a decision gate against a lighter torchrun/`rcli` alternative, not as a commitment.

Substrate is canonical and shared with the sibling docs: `SampleRef` (control plane, tensor-free)
+ `FeatureStore` (tensors: Local / SharedDir / Mooncake) + `FeatureDataLoader` β†’ `TrainBatch`.
There is no separate hidden-state stream source of truth β€” `FeatureDataLoader` over
`SampleRef` + `FeatureStore` *is* the stream; online/offline/disagg vary only in the ref source +
the `FeatureStore` backend, shielded from training. See [domain-refactor](./domain-refactor.md)
for the domain `Trainer`/managers that wrap the runtime training seam and for Phase B's
`TargetEngine` that O1.3 depends on, [eval-and-breadth](./eval-and-breadth.md) for the orthogonal
eval/algorithm track, and the top-level [plan](../../plan.md).

---

## What we keep (do not rebuild)

The data plane is competitive β€” arguably ahead of the comparables on rigor (three backends behind
one contract, contract-tested, with a durability seam). The gap is the **orchestration +
live-engine + async control loop**. Reuse, do not rewrite:

- **Data plane** β€” `MooncakeFeatureStore` zero-copy (`data_plane/mooncake_store.py`) with hard-pin
  replication and per-generation key sets; `LocalFeatureStore` / `SharedDirFeatureStore` behind the
  same `FeatureStore` contract (`data_plane/feature_store.py`).
- **Control-plane vocabulary** β€” `SampleRef` (tensor-free; `assert_no_tensors` in `contracts.py`);
  `SampleRefQueue` with lease/ack and the reserved partition seam (`data_plane/sample_ref_queue.py`);
  `DataFlowController` commit/lease/ack + `reconcile_on_restart` (`control_plane/controller.py`);
  the `MetadataStore` ABC with `InMemoryMetadataStore` / `SQLiteMetadataStore`
  (`control_plane/metadata_store.py`).
- **Streaming seam** β€” `StreamingRefChannel` / `StreamingRefQueue`
  (`data_plane/streaming_ref_channel.py`) already carry committed refs cross-process with
  in-flight accounting and a close-and-drain protocol.
- **Trainer seam** β€” `TrainerCore` / `DraftTrainStrategy` / `TrainingBackend` + `StepContext`
  (`training/`), wrapped (not replaced) by the domain `Trainer`.

## Comparables (from #618)

| Axis | SpecForge (today) | TorchSpec | DeepSpec |
|---|---|---|---|
| Data plane (Mooncake zero-copy, tensor-free metadata) | **At parity / cleaner** β€” 3 backends, one contract, contract-tested, durability seam | `EagleMooncakeStore` (per-tensor keys) | disk cache, no streaming |
| Orchestration / scale-out | **GAP** β€” library + composable builders, controller in-process | Ray actors + placement groups + async controllers + `EnginePool` | single 8-GPU node, no Ray |
| Live generation (train-with-decode) | **GAP** β€” in-process generator stub today; no server | live autoregressive rollout streaming | offline regen-to-disk |
| Cross-pool backpressure | policy exists, off by default | pool-byte feedback + capacity await | n/a |
| Online weight sync / staleness | n/a β€” out of scope (frozen target) | **also absent** (not needed) | absent |
| Fault tolerance | `reconcile_on_restart` + checkpoint resume | checkpoint resume (β‰ˆ parity) | n/a |

**Read:** the data plane is at or ahead of parity; the competitive gap is orchestration + the
live engine + the async control loop. DeepSpec's separate edge (breadth + eval) is orthogonal and
lives in [eval-and-breadth](./eval-and-breadth.md).

---

### O1.1 β€” Shared cross-process control plane Β· size M Β· CPU Β· status: in-review
- **Goal** Producer and consumer attach to **one** durable control plane instead of each building
  its own `InMemoryMetadataStore`, so commit/lease/ack/dedup and restart-reconcile work across
  processes (~PR #624).
- **Target state** A separate producer and consumer share commit/lease/ack via a durable store;
  the "single-host in-process index" caveat is gone for the shared-store path; `reconcile_on_restart`
  is wired into the launcher and `resume=True` re-streams the committed-but-unacked tail.
- **Implementation**
  - `control_plane/metadata_store.py` β€” `SQLiteMetadataStore` is the end-to-end dev/single-host
    durable tier behind the existing `MetadataStore` ABC; a thin `RedisMetadataStore` is a later
    subclass behind the same interface (chosen at O2 if multi-node demands it).
  - `data_plane/sample_ref_queue.py` β€” `SampleRefQueue` operates over the shared store rather than
    purely in-process.
  - `control_plane/controller.py` β€” `DataFlowController(metadata_store=...)`; the producer's
    `commit_samples` and the consumer's `lease_train_refs` / `ack_train_refs` land in the same
    durable store; `reconcile_on_restart` resolves committed-but-unacked refs on attach.
  - `launch.py` β€” `build_disagg_online_producer` / `build_disagg_online_consumer` already accept
    `metadata_store` / `metadata_db_path` (O1.1 wiring); `_resolve_metadata_store` points both
    halves at the same durable path, and the consumer's `resume=True` reconciles before training.
- **Tests / gates** Two controllers over one store: producer commits, consumer leases/acks across
  processes; restart reconcile hands already-trained ids back as `skip_ids` and re-streams the tail;
  at-least-once dedup. `tests/test_runtime/test_disagg_online_launch.py`,
  `tests/test_runtime/test_disagg_online.py`.
- **Done when** A separate producer and consumer share commit/lease/ack via the durable store, and
  the single-host-in-process-index caveat is removed for the shared-store path.

### O1.2 β€” Async streaming loop + one-process builder Β· size M Β· CPU / small-GPU Β· status: in-review
- **Goal** Replace synchronous drain-then-fit with an async streaming loop, using the **existing
  in-process generator** as a stub so this milestone carries no engine risk (~PR #625).
- **Target state** End-to-end live 1+1 run with in-process generation: producer pulls prompts β†’
  generates β†’ `put` + commit ref; consumer leases β†’ `get` β†’ trains β†’ acks at the optimizer step;
  a bounded sample pool throttles via the channel's in-flight watermark.
- **Implementation**
  - `launch.py` β€” the composable builders already exist today:
    `build_disagg_online_producer` returns `(workers, drive_producer)`;
    `build_disagg_online_consumer` returns `(trainer, loader)`;
    `run_disagg_online_interleaved` runs the producer on a background thread while `trainer.fit`
    consumes on the main thread, with symmetric hang-free shutdown (trainer-done sets `should_stop`;
    producer-done closes the channel; producer-raise closes the channel then re-raises).
    `build_disagg_online_runtime` (aliased `build_disagg_online_eagle3_runtime`) composes the two
    halves and dispatches to `run_disagg_online_interleaved`.
  - The producer's `drive_producer(should_stop=...)` applies channel backpressure (pauses while
    `channel.in_flight_remote()` exceeds `in_flight_high_watermark`) and closes the channel on exit.
  - In-process generator stub: `SGLangAdapter.generate_features` over the SpecForge
    `Eagle3TargetModel.generate_eagle3_data` path (`inference/sglang_adapter.py`), driven by
    `RolloutWorker` (`inference/rollout_worker.py`).
- **Tests / gates** Tiny-model 1+1 online run: trainer consumes a **streamed** (not precomputed)
  ref set; metrics align with the offline baseline on the same prompts/seed. Interleaved
  shutdown is hang-free in all three orderings. `tests/test_runtime/test_disagg_online_launch.py`,
  `tests/test_runtime/test_streaming_ref_channel.py`.
- **Done when** An end-to-end live 1+1 run drives training from an in-process generator over the
  streaming channel, with no precomputed features.

### O1.3 β€” Live SGLang-server hidden-state capture Β· size L Β· GPU + engine Β· status: in review (PR #641, reforward transport; spike findings + upstream proposal in ./o13-capture-spike.md)
> πŸ”΄ **GATING RISK β€” run the capture spike first.** The unknown is how far
> `Eagle3TargetModel.generate_eagle3_data` is from server-backed capture. The spike is the
> difference between O1.3 being days vs. a quarter; SpecForge is SGLang's own project so reuse is
> plausible (TorchSpec needed an SGLang codebase patch). Run it before/with O1.1.
- **Goal** Replace the in-process generator with a real SGLang **server** emitting aux + final
  hidden states straight into `MooncakeFeatureStore`.
- **Target state** A live SGLang server feeds training with **zero** precomputed features: server
  generates β†’ hidden states land in the store β†’ consumer trains; acceptance metrics match the
  offline baseline.
- **Implementation**
  - Depends on [domain-refactor](./domain-refactor.md) Phase B's `TargetEngine` abstraction
    (`SGLangServerEngine`) β€” O1.3 is the online consumer of that engine.
  - `inference/sglang_adapter.py` β€” a server-backed `FeatureSource` (launch/attach an SGLang
    server) alongside the existing in-process `SGLangAdapter`; the capture hook is engine-side,
    writing aux + final hidden states to the `FeatureStore`. Reuse the `CaptureConfig` /
    `verify_capture` contract (`inference/capture.py`) so an aux-layer-id / width / target-dim
    mismatch fails loudly. See `inference/sglang_patch_inventory.md` for the patch surface.
  - `inference/rollout_worker.py` β€” the producer uses the server-backed source; committed
    `SampleRef`s flow through the same `StreamingRefChannel`.
- **Tests / gates** (gated GPU) Server generates β†’ hidden states resident in the store β†’ consumer
  trains; capture verification passes; acceptance vs. offline. `tests/test_runtime/test_mooncake_store.py`
  for the store path.
- **Done when** A live SGLang server feeds training with zero precomputed features and acceptance
  metrics match the in-process / offline baseline.

### O2 β€” Scale-out orchestration (Ray = OPEN) Β· size L Β· infra Β· status: later
- **Goal** Move from 1+1 to N producers + M trainers with per-DP-rank leasing, cross-pool
  backpressure, and a managed streaming-pool lifetime β€” and **decide** the orchestration layer.
- **Target state** Independent N producers and M trainers scale without re-leasing or dropping
  samples; a slow trainer throttles the producer (bounded memory, not OOM); the online
  `MooncakeFeatureStore` bugs that become reachable at scale are closed; the orchestration layer is
  chosen against an explicit gate.

#### Orchestration: Ray candidate vs. lighter alternative (decision gate)

Ray is a **candidate** for the scale-out orchestration layer, not a commitment and not a non-goal.
It is likely necessary for multi-node N-producer/M-trainer scale-out and high-bandwidth
isolated-pool runs (its actor/placement model maps cleanly onto independent rollout and trainer
pools with separate GPU counts/sharding, matching the comparables' shape). The lighter alternative
is **torchrun + the existing `rcli` multi-node launch** driving the already-composable
`build_disagg_online_producer` / `build_disagg_online_consumer` builders across nodes, with the
shared durable `MetadataStore` (O1.1) as the only cross-process coordination point β€” no actor
framework.

- **Trade-off** Ray buys dynamic placement, actor restart/health, and a uniform multi-pool control
  surface, at the cost of a heavy runtime dependency, a new failure surface, and packaging/operability
  overhead on the GPU boxes. torchrun + `rcli` keeps the dependency surface minimal and reuses the
  existing launch path, but leaves placement, fan-out, and restart as bespoke launcher logic.
- **Decision gate β€” adopt Ray only if** the N+M / multi-node runs demonstrably need *dynamic*
  placement or per-actor restart that the static torchrun + `rcli` + shared-store path cannot
  deliver (e.g. heterogeneous pool sizes that must reshard at runtime, or actor-level fault recovery
  that bash-level relaunch cannot meet the availability target for). If a static launch over the
  shared store carries N+M with acceptable restart behaviour, stay on the lighter path. Re-evaluate
  at the first true multi-node scale-out run.

#### dp_partition leasing
- **Implementation** `data_plane/sample_ref_queue.py` β€” the partition seam exists today:
  `dp_partition(sample_id, num_partitions)` computes a stable DP-rank assignment, and
  `lease(..., partition=(index, num_partitions))` restricts a lease to a DP shard via
  `_lease_partition_locked`. The reserved `partition_key` producer-side routing hint is
  accepted-and-ignored today; turn on per-DP-rank leasing so every sample is leased exactly once
  across a reshard. `control_plane/controller.py` gains multi-trainer lease dispatch.
- **Tests / gates** N+M run; every sample leased exactly once across a reshard; no double-train;
  changing `num_partitions` re-distributes the same committed pool without re-leasing or dropping.

#### Cross-pool backpressure
- **Implementation** `control_plane/backpressure.py` exists and is **off by default**:
  `BackpressureController` with `should_pause_prompts` / `cap_prompt_grant` / `cap_train_lease` and a
  high/low watermark `BackpressureConfig`. Wire the controller into the dispatch loop so it returns
  pool-size feedback and blocks granting when the pool is full; the producer throttles instead of
  OOM-ing the store. `control_plane/controller.py` reports capacity.
- **Tests / gates** A slow trainer makes the producer throttle (bounded store memory) rather than
  OOM.

#### Streaming-pool lifetime + known online MooncakeFeatureStore bugs
- **Implementation** `data_plane/mooncake_store.py` β€” add consume-then-GC / TTL lifetime for the
  streaming pool, and close the two online-only bugs that become reachable at scale, both of which
  the shared metadata index (O1.1) unblocks:
  - **release-pending re-put gc-clobber** β€” a re-put bumps the per-sample generation and removes the
    prior key set; a parked failed-free in `_release_pending` plus `gc()` can clobber the fresh
    blob. Needs a tombstone-then-free protocol tied to the shared index.
  - **cross-process abort tombstone** β€” under a lease-deferred remove, `producer.abort` marks only
    its own `_freed`, so the bytes linger and a *separate* consumer (empty `_freed`) still resolves
    the aborted ref β†’ stale. This is the `expectedFailure`
    `test_cross_process_abort_under_lease_defer_is_known_gap` in
    `tests/test_runtime/test_mooncake_store.py:445`; flip it to a hard assertion once the shared
    index lands.
- **Tests / gates** Re-put-while-pending no longer deletes the fresh blob; cross-process abort
  blocks a separate consumer (the `expectedFailure` becomes a real assertion); consume-once
  streaming is correct cross-process.
- **Done when** N producers and M trainers scale without re-leasing/dropping; trainer-behind-rollout
  is a bounded-memory backpressure signal; the streaming-pool bugs are closed; the orchestration
  decision gate has been evaluated against a real N+M run.

### O3 β€” Hardening Β· size L Β· infra Β· status: later
- **Goal** Make the live disaggregated path production-operable.
- **Target state** RDMA path runs without per-op buffer churn; actor/process restart and health are
  handled; the run is observable.
- **Implementation**
  - RDMA buffer-pool registration in `data_plane/mooncake_store.py` β€” replace the per-op
    `register_buffer` / `unregister_buffer` around `_store_put_tensor` / `_store_get_tensor` with a
    registered pool before flipping `DEFAULT` `protocol` from `tcp` to `rdma`.
  - Actor / process restart + health: restart the rollout/trainer/controller processes (under
    whichever orchestration O2 selects) with `reconcile_on_restart` driving recovery from the shared
    store; health endpoints on `RolloutWorker.health` / `SGLangAdapter.health`.
  - Observability: pool depth, in-flight, lease/ack rates, backpressure snapshots
    (`BackpressureController.snapshot`), capture-verification counts.
- **Tests / gates** Kill-and-restore a producer/trainer mid-run and confirm no lost or
  double-trained samples; RDMA path registers buffers once; metrics emitted.
- **Done when** The live disaggregated run survives a process restart with no data loss and runs the
  RDMA path with one-time buffer registration.

---

## Provenance, not weight sync

`draft_weight_version` is retained **only as provenance metadata** on each committed sample β€” it is
recorded by `RolloutWorker._put_metadata` / `_offline_metadata` (`inference/rollout_worker.py`),
threaded through `contracts.py`, the metadata store, the controller, and every `FeatureStore`
backend's metadata round-trip. It exists for debugging/audit. There is **no** staleness gate, no
version registry, and no consumer-side check keyed on it. Because the online target is frozen, the
streamed hidden states are independent of draft weights.

## Dependency graph

```
                          capture spike (gates O1.3)
                                   β”‚
O1.1 ──▢ O1.2 ──▢ O1.3 ──▢ O2 ──▢ O3
 β”‚                  β”‚
 β”‚                  └─ depends on domain Phase B TargetEngine (SGLangServerEngine)
 β”‚
 └─ shared MetadataStore unblocks the O2 online-store bug fixes (re-put gc-clobber,
    cross-process abort tombstone)

eval-and-breadth track ── independent (orthogonal to disaggregation)
```

## Explicitly out of scope

- **Draft-weight sync, weight-version registry, hot draft-update, staleness gate,
  `WeightPublisher` / `WeightVersion` / serving accept-length gate / `ServingTrafficStream` /
  serving-traffic capture** β€” not needed for train-with-decode. The online target is frozen; the
  draft is not in the generation loop; the data is independent of draft weights, so there is no
  staleness and nothing to re-sync. `draft_weight_version` survives as provenance only (above).
- **True on-policy / draft-coupled training** (draft proposes β†’ target verifies β†’ train on realized
  acceptance, requiring live draft-weight sync) β€” a separate research bet, not addressed here. No
  public framework ships it.
- **Elastic / dynamic engine scaling at runtime** β€” engines are fixed at init; the comparables do
  the same.