Skip to content

server /train: max_slowdown_ppm bounds her decode slowdown, measured - #36

Merged
joelteply merged 1 commit into
feat/props-weight-residencyfrom
train/decode-rate-controller
Oct 6, 2026
Merged

joelteply merged 1 commit into
feat/props-weight-residencyfrom
train/decode-rate-controller

Conversation

@joelteply

Copy link
Copy Markdown

The share paced HOW OFTEN a window ran, not what it cost her: measured on Kimi's lane, her
decode fell from 52 to 23 tok/s while a 25% share trained. The caller means a bound on her
slowdown (Joel: "negligible impact on inference latency ... a slowdown if clever can be
unnoticed"), so the engine now takes that bound and measures against it.

  • serving publishes the tokens it has decoded (one atomic beside n_busy_slots) and how long
    its slots have been idle; the trainer reads them as one serving_view.
  • after a window of d ms the yield owed is closed form on her measured rates:
    y = d * (r0 * (1 - T) - r_w) / (r0 * T), 0 when the window costs her less than T, the worst
    case (r_w = 0) before r0 has a sample. No loop gains to tune.
  • r0 (no window running) is sampled between consecutive 10 ms looks that both saw a slot
    working, so bursty turns still measure it; r_w over windows that started and ended busy.
    Recent evidence (60 s time constant) so a load change moves the bound.
  • a bound that stops yielding stops measuring r0: when it has gone 30 s without a probe's
    worth (500 ms) of samples, 500 ms is owed (about 1.6% of the trainer's time).
  • idle releases a yield only after 250 ms: a turn ending is not serving going idle. Measured:
    two lockstep requests ended together and 8.6 s windows were taken in the gap, holding the
    next turns behind them; the same gap exists between an agent's acts.
  • status: decode_tps_no_window / _in_window (run), *_recent (what the bound acts on),
    slowdown_ppm_realized (the receipt). share_ppm stays for callers that send it; sending both
    is refused.

Measured on Qwen3.5-0.8B Q8_0, Metal (M5, beside a live 27B lane), 2 slots decoding, T = 10%:
r0 93-120 tok/s, r_w 40-58, client throughput 92.1 tok/s before training and 94.4 during;
the engine's realized figure over the run was 17% (one 13.5 s window, her rate moving), and
the trainer still held about 20% of the lane.

Core side (sends max_slowdown_ppm, reads the rates into training.run.share) follows as a continuum PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

… instead of a time share

The share paced HOW OFTEN a window ran, not what it cost her: measured on Kimi's lane, her
decode fell from 52 to 23 tok/s while a 25% share trained. The caller means a bound on her
slowdown (Joel: "negligible impact on inference latency ... a slowdown if clever can be
unnoticed"), so the engine now takes that bound and measures against it.

- serving publishes the tokens it has decoded (one atomic beside n_busy_slots) and how long
  its slots have been idle; the trainer reads them as one serving_view.
- after a window of d ms the yield owed is closed form on her measured rates:
  y = d * (r0 * (1 - T) - r_w) / (r0 * T), 0 when the window costs her less than T, the worst
  case (r_w = 0) before r0 has a sample. No loop gains to tune.
- r0 (no window running) is sampled between consecutive 10 ms looks that both saw a slot
  working, so bursty turns still measure it; r_w over windows that started and ended busy.
  Recent evidence (60 s time constant) so a load change moves the bound.
- a bound that stops yielding stops measuring r0: when it has gone 30 s without a probe's
  worth (500 ms) of samples, 500 ms is owed (about 1.6% of the trainer's time).
- idle releases a yield only after 250 ms: a turn ending is not serving going idle. Measured:
  two lockstep requests ended together and 8.6 s windows were taken in the gap, holding the
  next turns behind them; the same gap exists between an agent's acts.
- status: decode_tps_no_window / _in_window (run), *_recent (what the bound acts on),
  slowdown_ppm_realized (the receipt). share_ppm stays for callers that send it; sending both
  is refused.

Measured on Qwen3.5-0.8B Q8_0, Metal (M5, beside a live 27B lane), 2 slots decoding, T = 10%:
r0 93-120 tok/s, r_w 40-58, client throughput 92.1 tok/s before training and 94.4 during;
the engine's realized figure over the run was 17% (one 13.5 s window, her rate moving), and
the trainer still held about 20% of the lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply

Copy link
Copy Markdown
Author

APPROVED at 000152b. The yield is the right algebra: holding the cycle's average rate at r0(1-T) gives y = d(r0(1-T) - r_w)/(r0 T), which is yield_for_slowdown. The three measured fixes are real: an inter-request gap isn't idle (the 250 ms release), a bound that stops yielding still gets a no-window probe every 30 s, and the first look doesn't cancel the probe. The status reports the realized figure (17% here vs a 10% target) rather than the target, which is the honest receipt. One note for the NEXT step, not this PR: T bounds the AVERAGE over a cycle. Joel's bar ('brief ... unnoticed') is per turn. A turn arriving at a window's start still runs at the in-window rate (here 40-58 vs 93-120 tok/s) for the whole window, up to 13.5 s in your run. So the worst turn is bounded by window LENGTH, which you already named. Capping a window's duration (chunk size, or a ms ceiling the engine enforces per window) and reporting p95 turn slowdown beside the average would make both bounds hold.

@joelteply
joelteply merged commit 98f8df4 into feat/props-weight-residency Oct 6, 2026
6 of 25 checks passed
joelteply added a commit to CambrianTech/continuum that referenced this pull request Oct 6, 2026
…al training restored (#38) (#4821)

* fix(engine): pin 98f8df4ba — the decode-rate controller (llama.cpp #36)

Fable's CambrianTech/llama.cpp#36 (approved by Cormac), on top of #35 already
pinned by #4819. Training dreams in downtime: idle slots run windows back to back
at full speed (after a 250 ms idle grace, so the gap between two of a citizen's
acts is not downtime), and a busy lane yields to keep her measured decode rate
within max_slowdown_ppm (continuum #4820 sends it). Measured on the 0.8B beside
Kimi's live lane: client throughput 92.1 tok/s before training, 94.4 during.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc

* fix(engine): pin 68c2ec729 — #36 plus #38 (refuse only a node no backend can run: Metal trains again)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request Oct 6, 2026
…d a bare lambda)

#36 (000152b) changed server_trainer's ctor to take a serving_view, and
test-chat.cpp still passed the busy_slots lambda directly, which broke test-chat on
every PR on feat/props-weight-residency (Cormac). The lambda is now serving_view's
busy_slots; idle_ms and tokens_generated are optional and left empty, which keeps
what the test drives. test-chat compiles (CPU build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant