Repository navigation
perf(server): adaptive B=1 MTP enable/decline policy from profiling - #348
Conversation
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.
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.
PR Finalization CompleteMEDIUM-1: block_size added to PolicyKey (correctness gap)Root cause: How K is threaded in: Schema changes:
LOW-3: load-degradation tests addedTwo new tests pin the
LOW-2: temp-file cleanup on rename failure
Standard finalization
Commits pushed
|
Real-model validation (orchestrator)Adaptive policy on M1 Ultra with the gemma-4-12b-it-4bit target + gemma-4-12b-it-assistant-4bit drafter (
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 ( 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 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 |
…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.
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-weightedProfileAccumulator, theMtpPolicystate machine (Forced / Profiling / Settled), thePolicyKey+ coarsePolicyHint+ per-pairingPolicyStorepersistence, 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: additiveMtpGenerator::last_acceptance()returning a new publicMtpAcceptanceSummary(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 carryNone.src/server/batch/scheduler.rs: hold an optionalMtpPolicy, build it inwith_mtp_policy, route the B=1 gate throughmtp_b1_should_run, and feed the policy each completed B=1 burst's profile.src/server/model_worker.rs: chainwith_mtp_policyafterwith_speculative_dispatch, keyed on the served model basename.docs/environment-variables.md,docs/benchmarks.md: documentMLXCEL_MTP_ADAPTIVE, the persisted-hint location/format, and the manual-override semantics.How it decides
Per speculative round MTP pays
drafter_ms + verify_msand yieldsaccepted_len + 1tokens, 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>.jsonholding 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_B1still pins the decision in both directions and suppresses profiling;MLXCEL_MTP_ADAPTIVE=0restores 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 behindMLXCEL_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) andscheduler(175 passed).cargo clippy -p mlxcel --lib --tests -- -D warningsandcargo clippy -p mlxcel-core --lib -- -D warningsclean;scripts/ci/check_cross_repo_refs.pyclean.Closes #333