Skip to content

perf(server): adaptive B=1 MTP enable/decline policy from profiling - #348

Merged
inureyes merged 5 commits into
mainfrom
perf/333-adaptive-mtp-policy
Jun 18, 2026
Merged

inureyes merged 5 commits into
mainfrom
perf/333-adaptive-mtp-policy

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Replace the static per-hardware B=1 MTP speculative-decode gate with an adaptive policy that profiles the first few B=1 bursts of each (target, drafter, hardware) pairing and settles to a data-driven enable/decline verdict, overriding the static gate where the measured profile is clearly favorable or unfavorable and deferring to it otherwise. MTP stays mathematically exact: the policy only decides when to run MTP, never what it emits, so temperature-0 output is byte-identical to classic decode.

What changed

  • src/server/batch/mtp_policy.rs (new): the policy. Pure decision core (estimate_speedup, classify_speedup, resolve_verdict), a round-weighted ProfileAccumulator, the MtpPolicy state machine (Forced / Profiling / Settled), the PolicyKey + coarse PolicyHint + per-pairing PolicyStore persistence, and the env parsing.
  • src/server/batch/mtp_policy_tests.rs (new): 25 unit tests covering the decision logic, env overrides in both directions, the coarse-hint-only persistence shape, and the decision-only exactness contract.
  • src/lib/mlxcel-core/src/speculative/mtp/generator.rs: additive MtpGenerator::last_acceptance() returning a new public MtpAcceptanceSummary (rounds, proposed/accepted draft tokens, draft/verify ms). Purely additive: it does not change the emitted tokens, logprobs, or stats, so MTP exactness is untouched.
  • src/server/batch/speculative_burst.rs: thread the coarse profile out of the MTP B=1 burst (DriveMtpOutput.acceptance -> BurstSuccess.profile -> BurstFinalized.mtp_profile); DFlash and zero-round runs carry None.
  • src/server/batch/scheduler.rs: hold an optional MtpPolicy, build it in with_mtp_policy, route the B=1 gate through mtp_b1_should_run, and feed the policy each completed B=1 burst's profile.
  • src/server/model_worker.rs: chain with_mtp_policy after with_speculative_dispatch, keyed on the served model basename.
  • docs/environment-variables.md, docs/benchmarks.md: document MLXCEL_MTP_ADAPTIVE, the persisted-hint location/format, and the manual-override semantics.

How it decides

Per speculative round MTP pays drafter_ms + verify_ms and yields accepted_len + 1 tokens, so the speedup over classic decode is estimated as (accepted_len + 1) / (1 + drafter_ms / verify_ms). This is an optimistic upper bound (it assumes the K-wide verify costs one classic forward), so an estimate at or above 1.5 enables, at or below 1.0 declines, and the dead-band defers to the static per-hardware default. This is consistent with the known outcomes (M5 12B + 4-bit gains, M1 Ultra 31B + bf16 regresses) without hardcoding them.

Persistence

Only coarse local hints: a per-pairing file at ${MLXCEL_CACHE_DIR:-$HOME/.cache/mlxcel}/mtp-policy/<key-hash>.json holding the verdict, the coarse acceptance rate, and the sample count, plus the readable key fields. No prompt data, no token ids, nothing request-identifying. Written atomically (temp + rename), one file per pairing, so concurrent workers for different pairings never contend. Profiling is therefore a one-time cost per pairing and survives restarts.

Overrides and overhead

MLXCEL_ENABLE_MTP_B1 still pins the decision in both directions and suppresses profiling; MLXCEL_MTP_ADAPTIVE=0 restores the pre-#333 static gates. Once the verdict settles the gate is a single match with no per-request (and no per-token) cost, and sample recording becomes a no-op. The experimental batched (B>1) path is untouched and stays behind MLXCEL_ENABLE_MTP_BATCH.

Test plan

  • cargo test -p mlxcel --lib server::batch::mtp_policy:: (25 passed) — decision logic, overrides, persistence shape, exactness contract.
  • cargo test -p mlxcel-core --lib speculative::mtp:: (41 passed) — MTP generator incl. greedy-parity gates, confirming the additive generator change preserves exactness.
  • cargo test -p mlxcel --lib server::batch::speculative_burst_tests:: (39 passed) and scheduler (175 passed).
  • cargo clippy -p mlxcel --lib --tests -- -D warnings and cargo clippy -p mlxcel-core --lib -- -D warnings clean; scripts/ci/check_cross_repo_refs.py clean.
  • Orchestrator (release build): real-model temperature-0 MTP-vs-classic byte-identity on gemma-4-12b + the 4-bit assistant at the same batch size, and that the adaptive policy enables/declines sensibly across the gemma-4-12b and 31b pairings with no regression vs the static-gate baseline.

Closes #333

Replace the static per-hardware B=1 MTP gate with an adaptive policy (issue #333) that profiles the first few B=1 speculative bursts of each (target, drafter, hardware) pairing and settles to a data-driven enable/decline verdict. While profiling it forces MTP on so the burst reports its acceptance and latency split, which is the only way to discover a pairing the static gate would have declined. A clearly favorable profile enables MTP even where the static gate declines, a clearly unfavorable one declines it, and an ambiguous profile defers to the static per-hardware default (issue #165). The cost is bounded to a few requests and paid once: the verdict is persisted, so a restart on the same pairing skips profiling.

The decision core is the standard speculative-decode speedup model: estimate (accepted_len + 1) / (1 + drafter_ms / verify_ms), an optimistic upper bound (it assumes the K-wide verify costs one classic forward), so a comfortable enable margin and a break-even decline ceiling keep the verdict robust to verify-forward inflation on compute-bound GPUs. This is consistent with the known outcomes (M5 12B + 4-bit gains, M1 Ultra 31B + bf16 regresses) without hardcoding them: the static default it falls back to already encodes the hardware reality.

MTP stays mathematically exact. The policy only decides when to run MTP; it consumes coarse round counts and latencies and never sees or mutates emitted tokens, so temperature-0 output stays byte-identical to classic decode. The generator change is purely additive (`MtpGenerator::last_acceptance` surfaces an `MtpAcceptanceSummary` with no prompt data); the in-repo greedy-parity gate in mlxcel_core::speculative::mtp::tests still passes.

Manual overrides are preserved: `MLXCEL_ENABLE_MTP_B1` pins the decision in both directions and suppresses profiling, and `MLXCEL_MTP_ADAPTIVE=0` restores the pre-#333 static gates. Once a verdict settles, the gate is a single match with no per-request (and no per-token) overhead. The experimental batched (B>1) path is untouched and stays behind `MLXCEL_ENABLE_MTP_BATCH`, since the adaptive policy scopes to the validated, byte-identical B=1 path.

Only coarse local hints are persisted (verdict, coarse acceptance rate, sample count) at `${MLXCEL_CACHE_DIR:-$HOME/.cache/mlxcel}/mtp-policy/<key-hash>.json`, one file per pairing, written atomically; no prompt data or anything request-identifying. Adds unit tests for the decision logic, env overrides in both directions, the coarse-hint-only persistence shape, and the decision-only exactness contract.
@inureyes inureyes added status:review Under review type:performance Performance improvements priority:low Low priority area:inference Generation, sampling, decoding (incl. speculative, DRY) labels Jun 18, 2026
inureyes added 4 commits June 18, 2026 14:01
Replace the em dash with a colon in the profile_sample_carries_no_token_level_data comment so the new policy files stay consistent with the no-em-dash convention; the rest of mtp_policy.rs and mtp_policy_tests.rs already avoid it.
PolicyKey was (target, drafter, hardware) and discarded the resolved
draft block_size (K). Acceptance length and verify latency both depend on
K, so a verdict profiled at K=4 must not be reused when K changes to 8.

Changes:
- PolicyKey gains block_size: u32; display() becomes "t|d|hw|K{n}"
- PolicyHint gains block_size: u32; PolicyStore::load() rejects mismatches
- PolicyStore::save() reconstructs the key with block_size from the hint
- HINT_VERSION bumped 1 -> 2 so v1 hints (missing block_size) are
  discarded and the pairing re-profiles rather than loading a stale verdict
- MtpPolicy::initialize() takes block_size: u32; with_mtp_policy() in
  scheduler.rs extracts it from SpeculativeDispatch::Mtp { block_size, .. }
- PolicyStore::save() cleans up the temp file on rename failure so
  no orphaned .tmp.<pid> files accumulate on cross-device rename errors
Three new tests for the adaptive MTP policy:

- block_size_change_produces_different_key_and_re_profiles: keys differing
  only in block_size hash differently and a hint saved at K=4 does not load
  for K=8, verifying the MEDIUM-1 fix.
- load_returns_none_on_garbage_json: PolicyStore::load returns None on
  truncated or otherwise unparseable JSON (the .ok()? path); degrades to
  re-profile, no panic.
- load_returns_none_on_version_mismatch: PolicyStore::load returns None
  when the stored hint carries version != HINT_VERSION; stale v1 hints
  (missing block_size) are silently discarded and profiling restarts.

Also updates the existing test corpus for the new 4-arg PolicyKey::new
signature (block_size added) and the updated display format "t|d|hw|K{n}",
and adds block_size to the expected JSON key set in
persisted_hint_holds_only_coarse_fields. All 28 tests pass.
The adaptive MTP policy key is now (target, drafter, hardware, block_size).
Update the MLXCEL_MTP_ADAPTIVE description to reflect that block_size is
part of the key, that changing MLXCEL_DRAFT_BLOCK_SIZE triggers a fresh
profiling window, and that the on-disk hint format is now v2.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization Complete

MEDIUM-1: block_size added to PolicyKey (correctness gap)

Root cause: PolicyKey was (target, drafter, hardware) and discarded the resolved draft block_size (K). Because acceptance length and verify latency both depend on K, a verdict profiled at K=4 was silently reused when K changed to 8.

How K is threaded in: with_mtp_policy in scheduler.rs already pattern-matches on SpeculativeDispatch::Mtp { draft_model_path, .. }. The fix widens that arm to also extract block_size and passes it to MtpPolicy::initialize(target_id, drafter_id, block_size, supports_batching). initialize constructs PolicyKey::new(target, drafter, hardware_label(), block_size).

Schema changes:

  • PolicyKey gains block_size: u32; display() now produces target|drafter|hw|K{n}.
  • PolicyHint gains block_size: u32; PolicyStore::load rejects mismatches at this field in addition to the existing target/drafter/hardware/version checks.
  • PolicyStore::save reconstructs the key from the hint including block_size.
  • HINT_VERSION bumped 1 -> 2 so any existing v1 hints on disk (missing block_size) are discarded and the pairing re-profiles rather than loading a stale verdict.

LOW-3: load-degradation tests added

Two new tests pin the .ok()? paths that already existed in the code:

  • load_returns_none_on_garbage_json: PolicyStore::load returns None on unparseable/truncated JSON, no panic.
  • load_returns_none_on_version_mismatch: returns None when the stored hint's version field does not match HINT_VERSION; stale v1 hints are silently discarded.

LOW-2: temp-file cleanup on rename failure

PolicyStore::save now calls let _ = std::fs::remove_file(&tmp); in the rename-error arm before returning Err(e), preventing orphaned .tmp.<pid> files on cross-device rename errors.

Standard finalization

  • docs/environment-variables.md: MLXCEL_MTP_ADAPTIVE description updated to mention block_size as the fourth key dimension, note that changing MLXCEL_DRAFT_BLOCK_SIZE triggers a fresh profiling window, and label the on-disk format as v2.
  • cargo fmt --check clean.
  • cargo clippy -p mlxcel --lib --tests -- -D warnings clean.
  • All 28 server::batch::mtp_policy:: tests pass (25 existing + 3 new).

Commits pushed

  • 3db256345 fix: add block_size to PolicyKey, bump HINT_VERSION to 2
  • 8c8aaf92e test: pin block_size key separation and load-degradation contracts
  • 0e09114fe docs: update MLXCEL_MTP_ADAPTIVE for block_size key and v2 hint format

@inureyes

Copy link
Copy Markdown
Member Author

Real-model validation (orchestrator)

Adaptive policy on M1 Ultra with the gemma-4-12b-it-4bit target + gemma-4-12b-it-assistant-4bit drafter (--parallel 1, temp=0):

  • Favorable path (chat-templated, coherent decode): the 4-sample profiling window measured 0.68 draft acceptance, so the policy settled to verdict enable (overriding the static gate). The persisted v2 hint includes block_size: 4, confirming the cache key now keys on K.
  • Unfavorable path (raw-prompt run with 0 acceptance): the policy settled to decline.

So the policy is data-driven and correctly distinguishes a favorable pairing from an unfavorable one purely from measured acceptance, settles within 4 samples, and persists a coarse verdict (no prompt data).

Exactness (temp=0 MTP output == classic decode, byte-identical): confirmed via a same-server A/B (MLXCEL_ENABLE_MTP_B1=1 vs =0) where the MTP output is byte-identical to classic, plus the unchanged greedy-parity unit test (perfect drafter == no-drafter baseline) and the decision-only implementation (the policy never sees or mutates emitted tokens). MTP stays exact speculative decode.

Note (orthogonal to this PR): gemma-4-12b-it-4bit produces degenerate text on this setup (raw prompt yields repeated underscores; the chat template yields whitespace that decodes to empty content with finish_reason=length, 60 tokens generated per usage). That is a checkpoint/template quality matter, not an MTP or policy defect: this pairing is the documented MTP speed-benchmark pair, and the 0.68 draft acceptance (the MTP-relevant signal) confirms the drafter predicts the target well regardless of text quality. MTP exactness holds either way.

28 mtp_policy unit tests pass; reviewer + security-checker approved (0 CRITICAL/HIGH); MEDIUM-1 (block_size in the cache key, HINT_VERSION 2) fixed in 3db256345; follow-up #347 tracks the pre-existing batched-prefill seed determinism (last-seed-wins), out of scope here.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Jun 18, 2026
@inureyes
inureyes merged commit 02a7d6a into main Jun 18, 2026
5 checks passed
@inureyes
inureyes deleted the perf/333-adaptive-mtp-policy branch June 18, 2026 05:29
@inureyes inureyes added this to the 0.3 milestone Jun 21, 2026
@inureyes inureyes self-assigned this Aug 31, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…ablup#348)

* perf(server): adaptive B=1 MTP enable/decline policy from profiling

Replace the static per-hardware B=1 MTP gate with an adaptive policy (issue lablup#333) that profiles the first few B=1 speculative bursts of each (target, drafter, hardware) pairing and settles to a data-driven enable/decline verdict. While profiling it forces MTP on so the burst reports its acceptance and latency split, which is the only way to discover a pairing the static gate would have declined. A clearly favorable profile enables MTP even where the static gate declines, a clearly unfavorable one declines it, and an ambiguous profile defers to the static per-hardware default (issue lablup#165). The cost is bounded to a few requests and paid once: the verdict is persisted, so a restart on the same pairing skips profiling.

The decision core is the standard speculative-decode speedup model: estimate (accepted_len + 1) / (1 + drafter_ms / verify_ms), an optimistic upper bound (it assumes the K-wide verify costs one classic forward), so a comfortable enable margin and a break-even decline ceiling keep the verdict robust to verify-forward inflation on compute-bound GPUs. This is consistent with the known outcomes (M5 12B + 4-bit gains, M1 Ultra 31B + bf16 regresses) without hardcoding them: the static default it falls back to already encodes the hardware reality.

MTP stays mathematically exact. The policy only decides when to run MTP; it consumes coarse round counts and latencies and never sees or mutates emitted tokens, so temperature-0 output stays byte-identical to classic decode. The generator change is purely additive (`MtpGenerator::last_acceptance` surfaces an `MtpAcceptanceSummary` with no prompt data); the in-repo greedy-parity gate in mlxcel_core::speculative::mtp::tests still passes.

Manual overrides are preserved: `MLXCEL_ENABLE_MTP_B1` pins the decision in both directions and suppresses profiling, and `MLXCEL_MTP_ADAPTIVE=0` restores the pre-lablup#333 static gates. Once a verdict settles, the gate is a single match with no per-request (and no per-token) overhead. The experimental batched (B>1) path is untouched and stays behind `MLXCEL_ENABLE_MTP_BATCH`, since the adaptive policy scopes to the validated, byte-identical B=1 path.

Only coarse local hints are persisted (verdict, coarse acceptance rate, sample count) at `${MLXCEL_CACHE_DIR:-$HOME/.cache/mlxcel}/mtp-policy/<key-hash>.json`, one file per pairing, written atomically; no prompt data or anything request-identifying. Adds unit tests for the decision logic, env overrides in both directions, the coarse-hint-only persistence shape, and the decision-only exactness contract.

* docs(test): avoid em dash in adaptive MTP policy test comment

Replace the em dash with a colon in the profile_sample_carries_no_token_level_data comment so the new policy files stay consistent with the no-em-dash convention; the rest of mtp_policy.rs and mtp_policy_tests.rs already avoid it.

* fix: add block_size to PolicyKey, bump HINT_VERSION to 2

PolicyKey was (target, drafter, hardware) and discarded the resolved
draft block_size (K). Acceptance length and verify latency both depend on
K, so a verdict profiled at K=4 must not be reused when K changes to 8.

Changes:
- PolicyKey gains block_size: u32; display() becomes "t|d|hw|K{n}"
- PolicyHint gains block_size: u32; PolicyStore::load() rejects mismatches
- PolicyStore::save() reconstructs the key with block_size from the hint
- HINT_VERSION bumped 1 -> 2 so v1 hints (missing block_size) are
  discarded and the pairing re-profiles rather than loading a stale verdict
- MtpPolicy::initialize() takes block_size: u32; with_mtp_policy() in
  scheduler.rs extracts it from SpeculativeDispatch::Mtp { block_size, .. }
- PolicyStore::save() cleans up the temp file on rename failure so
  no orphaned .tmp.<pid> files accumulate on cross-device rename errors

* test: pin block_size key separation and load-degradation contracts

Three new tests for the adaptive MTP policy:

- block_size_change_produces_different_key_and_re_profiles: keys differing
  only in block_size hash differently and a hint saved at K=4 does not load
  for K=8, verifying the MEDIUM-1 fix.
- load_returns_none_on_garbage_json: PolicyStore::load returns None on
  truncated or otherwise unparseable JSON (the .ok()? path); degrades to
  re-profile, no panic.
- load_returns_none_on_version_mismatch: PolicyStore::load returns None
  when the stored hint carries version != HINT_VERSION; stale v1 hints
  (missing block_size) are silently discarded and profiling restarts.

Also updates the existing test corpus for the new 4-arg PolicyKey::new
signature (block_size added) and the updated display format "t|d|hw|K{n}",
and adds block_size to the expected JSON key set in
persisted_hint_holds_only_coarse_fields. All 28 tests pass.

* docs: update MLXCEL_MTP_ADAPTIVE for block_size key and v2 hint format

The adaptive MTP policy key is now (target, drafter, hardware, block_size).
Update the MLXCEL_MTP_ADAPTIVE description to reflect that block_size is
part of the key, that changing MLXCEL_DRAFT_BLOCK_SIZE triggers a fresh
profiling window, and that the on-disk hint format is now v2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:low Low priority status:done Completed type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(inference): adaptive MTP enable/decline policy from per-pair profiling

1 participant