Skip to content

perf(qwen4): overlap streamed PLE decode without changing sampled AR (stacked on #475) - #501

Open
davidtai wants to merge 13 commits into
youssofal:mainfrom
davidtai:perf/qwen38-streamed-ar-pipeline
Open

davidtai wants to merge 13 commits into
youssofal:mainfrom
davidtai:perf/qwen38-streamed-ar-pipeline

Conversation

@davidtai

Copy link
Copy Markdown
Contributor

This AR change is stacked on MTPLX #475, whose tested parent head is cd6023ecf5b78bfd75733fae0b0ad8004161e20b. This upstream PR targets main, so GitHub also displays the parent changes while #475 remains open. Review the focused AR child PR or its AR-only compare for the actual new diff. After #475 merges, this PR can be rebased on upstream main to show only the AR change.

The problem

The existing sampled-AR pipeline overlaps host graph construction by making the next token part of the lazy MLX graph. That removes a host bubble, but it also replaces the request's established sampler:

  • normal AR calls _sample_from_logits, applies MTPLX's existing filtering order, and draws from the request's numpy.random.Generator;
  • the pipelined path called _mx_lazy_sample and drew with mx.random.categorical.

The same request and seed therefore produced a different continuation. A regression test catches the divergence at token 3 under temperature 1 / top-p 0.95 sampling. This is a correctness failure even though both streams are individually deterministic.

The pipeline also required a resident 32 GB n-gram table. On the 128 GB Qwen3.8 serving configuration, the PLE table is streamed from its SafeTensors sidecar, so constructing a forward on an unknown token would otherwise force a host synchronization.

The resulting schedule

This PR keeps the original sampler and overlaps only work that is independent of its result:

GPU:   forward(token k) ------------------------- logits k
Host:              build forward(deferred token k+1)
CPU:                                             sample logits k with the original RNG
CPU:                                             fill token + exact packed PLE rows
GPU:                                             submit prebuilt forward(k+1)

For every decode step:

  1. Allocate a fresh MLX-owned int64[1] token leaf initialized to zero.
  2. Build the next forward graph against that leaf while the current forward is executing.
  3. Materialize the current logits through _sample_from_logits with the same NumPy generator used by ordinary AR.
  4. Fill the token leaf with that sampled token.
  5. Compute the 16 exact PLE row IDs from the concrete token and prior history, copy the existing packed Q4 weights/scales/biases into fresh MLX-owned leaves, and advance the PLE history.
  6. Submit the already-built consumer graph.

The model still dequantizes the same packed sidecar bytes through the ordinary MLX operations. There is no alternate arithmetic, token prediction, speculative rollback, or cache repair.

Construction-time contract

The optimized lane resolves its invariants once when it is installed or activated:

  • the native extension ABI must expose both deferred-row and deferred-token factories;
  • all PLE routes are selected before model state is changed;
  • token creation and each layer's PLE flush/discard callable are prebound;
  • enabled decode calls those bound functions directly, without per-token metadata walks, environment reads, eligibility checks, proof counters, or stock fallback branches;
  • a sidecar read/fill error poisons the route, preserves the original exception, and prevents the next activation until the model is reloaded.

The existing request-level MTPLX_AR_PIPELINE eligibility gate remains unchanged. If the model cannot install the complete route, it declines before entering the pipeline and uses the explicit classic AR route.

The public stats envelope now includes ar_pipeline_active and ar_pipeline_variant, allowing a benchmark to prove that variant=streamed actually ran.

Correctness gates

The focused test battery covers:

  • a consumer graph built before token/PLE fill reading the exact filled bytes at evaluation;
  • independent fresh native leaves across steps;
  • streamed PLE history and exact packed-plane publication;
  • activation-bound routing and failure poisoning;
  • same-seed equality between classic and pipelined temperature-1 AR;
  • public engagement telemetry.

Results:

  • 24 CPU protocol/static tests passed;
  • 16 guarded MLX/native tests passed after rebuilding the extension from the final commit;
  • the production service was restored to the same model after every guarded run.

The full-model parity gate used the unchanged #475 head as control and final commit 775697bb40a8a19c8ecd06cea73b32cd3ae5a8a0 as candidate:

Setting Value
Model Qwen3.8 Flash-Next MTPLX Optimized-Speed
Input 16,384 tokens, cold cache bypass
Output 128 tokens
Generation target AR; MTPLX_AR_PIPELINE=0 on #475 control, =1 on candidate
Sampling temperature 1, top-p 0.95, top-k 20
Reasoning xhigh
Seeds 20260829, 20260830, 20260831
Thermal max fans; each sample started at or below 40 C

All three pairs matched on request-body hash, prompt hash, completion count, finish reason, visible-text hash, and reasoning-text hash. Every candidate response reported ar_pipeline_active=true and ar_pipeline_variant=streamed.

Seed #475 control tok/s Candidate tok/s Output parity Streamed route
20260829 48.81 55.78 yes yes
20260830 49.83 56.65 yes yes
20260831 48.97 55.81 yes yes
median 48.97 55.81 3/3 3/3

That is a 13.97% median decode-throughput gain with the same 85.08 GB peak-memory reading.

Sustained A/B/A result

The final performance confirmation used the same artifact, prompt, sampler, reasoning mode, seeds, cache policy, fan policy, and 40 C start gate, with a 1,024-token output cap. It ran control A, candidate B, then control A2. Seeds 20260829 and 20260830 reached 1,024 tokens; seed 20260831 emitted the same stop at token 666 in all three arms.

Arm Median decode tok/s Median prefill tok/s Median TTFT Median wall time Peak memory
#475 control A 46.28 1,630.21 10.267 s 32.371 s 85.080 GB
Candidate B 52.46 1,630.99 10.282 s 29.717 s 85.080 GB
#475 control A2 46.18 1,631.52 10.270 s 32.411 s 85.080 GB

Across the six surrounding control samples, the candidate improves median decode throughput by 13.47% and reduces median end-to-end wall time by 8.26%. Prefill, TTFT, and peak memory are unchanged within run noise. All A/B/A output hashes match for every seed, and all candidate records prove the streamed route was active.

Scope

This changes only the Qwen4/Qwen3.8 sampled target-AR lane layered on #475's native cached-PLE extension. MTP verification, draft sampling, greedy AR, constrained decoding, penalty-bearing sampling, prefill arithmetic, and default-off behavior are unchanged.

davidtai and others added 13 commits September 7, 2026 07:36
… 2.11.2

MTPLX 2.11.2 re-landed nine of PR 391's twelve Flash-Next decode keys; this
ports three of the unlanded five onto upstream's re-landed structure, wired
into the fixed-M4 auto-arm block with the MTPLX_QWEN4_*/MTPLX_QSA_* namespace
and a per-key =0 opt-out (the old MTPLX_FABLE_* names kept as aliases).

- HC_M4 (MTPLX_QWEN4_HC_M4): the verify-width (2..8 row) hyper-connection read
  run as one multi-threadgroup GEMV (kernels/qwen4_m4_hyper_read). Reader in
  runtime_options read once at import; GatedResidual gains the geometry
  eligibility check, pack validation and the fused read; install validation
  runs after the M4-stage3 install and reports at /health
  qwen4_install_reports.hc_m4. Rounding-class.
- prefill causal-mask fuse (MTPLX_QWEN4_PREFILL_MASK_FUSE): the dense QSA
  prefill chunk goes through MLX's fused SDPA instead of a materialized score
  tensor MLX's head-dim-256 heuristic declines; a per-shape-class capability
  cache keeps a verify step MLX refuses from disarming a wide chunk.
  Rounding-class (exact visible set).
- QSA prefill query tile (MTPLX_QSA_PREFILL_QUERY_TILE): tiles only the dense
  QSA attention query rows so a wider prefill chunk keeps the narrow chunk's
  attention peak and cost. Value companion, default 2048 (inert at the
  production 2,048 chunk width). Rounding-class (exact visible set).

Each is default-on for a served fixed-M4 Flash-Next pack (server auto-arm
lane_defaults, gated on the fixed-M4 config predicate) with a per-key kill
switch through the existing pop loop, and registered in the boot-time
runtime-env validator. All three are rounding-class, so quality-gated on
HumanEval.

Two of the five remain and are documented in docs/perf/qwen38-391-remainder.md:
the QSA sparse split-K decode (a native kernel whose build and parity probe
need the GPU) and the graph-build overlap (its prefix/suffix split of the
fixed-M4 verify has no substrate on 2.11.2's single-graph verify).

CPU tests (venv mlx 0.32.2, no GPU): tests/test_qwen4_hc_m4.py 53 passed,
tests/test_qwen4_prefill_mask_fuse.py 40 passed.
The fourth of PR 391's five unlanded lanes: MTPLX_QSA_SPARSE_DECODE, the native
split-K sparse-GQA attention for the M=4 fixed verify. It reads the selected KV
rows of the fixed QSA cache once per verify cycle instead of materializing a
gathered [1,2,4,2052,256] K/V pair per layer, which is where the shipped lane's
bytes are. Rounding class: fp32 online softmax over the exact visible set.

- kernels/qsa_sparse_decode.py + native_extensions/qsa_sparse_gqa (package
  mtplx_native_qsa: the split-K Metal kernel, steel headers and a nanobind
  binding); mtplx/native loads it, runtime_options reads MTPLX_QSA_SPARSE_DECODE
  (+_TILE 128:32, +_SPLITS 17); the old MTPLX_FABLE_* names are honoured as
  aliases when the new key is unset.
- graphbank.TensorOffsetQSACache validates the lane ONCE at cache install (a real
  parity probe, outside any mx.compile trace); the twin re-promotion sites and
  the compiled verify_step carry it, and the verify body asserts the lane is in
  the traced graph. models/qwen4_exp routes the fixed-capacity verify width to
  the kernel (QSAIndexer._sparse_decode_route) or declines to stock for a request
  shape it cannot serve.
- Server auto-arm: default ON for the fixed-M4 pack ONLY when the native
  extension is built; a wheel without mtplx_native_qsa declines to stock with a
  logged verdict and still serves. An explicit MTPLX_QSA_SPARSE_DECODE=1 reaches
  the fail-closed install (armed and unbuilt raises). Registered in the boot-time
  runtime-env validator; kill switch through the existing pop loop.
- scripts/bundle_native_runtime_wheel.py signs and packages mtplx_native_qsa
  alongside mtplx_qsa_kernels (Developer ID, hardened runtime, secure timestamp),
  with tests.
- The mask-fuse refusal test now accepts either MLX build's native wording: the
  lane logs a version-independent per-class line and never raises under default
  arming (it falls to the stock dense SDPA).

Load-time parity on stock mlx 0.32.2 with the native kernel built: vs the fp32
reference worst rel_l2 3.1e-05 with the top-1 token identical, vs the stock
gather path rel_l2 4.6e-03 (rounding class), across the 4093 and 2052 probe
cells that stand in for the 16K and 261,120 serving regimes.

CPU tests (venv mlx 0.32.2): tests/test_qsa_sparse_decode.py,
tests/test_qsa_sparse_decode_wiring.py, tests/test_qsa_sparse_gqa_native.py and
tests/test_bundle_native_runtime_wheel.py all green.
Served via `mtplx serve` (cli-resolved Turbo, no lane flags), the QSA split-K
decode lane did not engage: qsa_sparse_decode_enabled() read the environment at
IMPORT and cached the default (False), but the fixed-M4 auto-arm stamps
MTPLX_QSA_SPARSE_DECODE (native-gated) into the environment AFTER
runtime_options is imported, so the cache froze the default before the stamp
landed -- the lane was absent from /health with neither an "armed:" nor a
"declined to stock" line. hc_m4 escaped only because its reader is read on a
path where the module was imported after the stamp.

Resolve the flag lazily on the FIRST read (which is the graphbank cache
install, after the overrides are applied), then cache; the _QSA_SPARSE_DECODE
module global stays (tests force it to a bool) and the native-gated default in
the server auto-arm is unchanged. The env is frozen once serving starts, so a
lazy first read is still a single cached bool on the hot path.

Regression tests, the shape that would have caught this:
- the reader picks up a stamp applied AFTER import (an import-frozen reader
  fails it),
- the fixed-M4 auto-arm block stamps the lane when the native extension is
  built, or prints the declined-to-stock verdict and leaves it unstamped when
  it is not.
Second arming failure (battery, 2026-09-07): served as `mtplx serve` launches
it, hc_m4 was OFF for the same reason the QSA decode lane was in commit 3 --
its reader froze the environment at import (default off) while the fixed-M4
auto-arm stamps the lane keys into the environment AFTER runtime_options is
imported. The earlier claim that hc_m4 read on a post-stamp path did not hold
for the served path.

Resolve every remainder-lane flag at USE (the install / route path, which runs
after the overrides are applied), never at import:
- runtime_options: qwen4_hc_m4_enabled, qsa_sparse_decode_tile and
  qsa_sparse_decode_splits (qsa_sparse_decode_enabled was fixed in commit 3);
  each keeps its module global as a test override (None = read env).
- models/qwen4_exp: _prefill_mask_fuse_enabled drops @lru_cache (its body
  already reads os.environ), so a stamp landing after import is seen.
- qwen4_prefill_chunk.resolve_query_tile_rows already read at use.
The native-gated default and the MTPLX_FABLE_* aliases are unchanged. Upstream's
own MTPLX_QWEN4_OPDIET / MTPLX_QWEN4_VERIFY_GLUE readers are left as-is (not part
of this remainder set).

Test reproducing the served order (tests/test_qwen4_remainder_arming.py):
import mtplx.runtime + mtplx.server.openai FIRST, assert all four readers off,
run _server_runtime_env_overrides for the fixed-M4 pack, apply it to
os.environ, then assert all four arm -- the decode lane armed when the native
extension is built, else an explicit declined-to-stock verdict, never silent
absence. The hc_m4 read-once test is rewritten to assert read-at-use, and the
mask-fuse test drops its now-defunct cache_clear() calls.
Arming audit (battery, 2026-09-07): `mtplx serve` imports generation / runtime /
model modules before parse_args stamps the auto-arm env, so any flag whose
reader resolves at module import freezes its default before the stamp lands and
the auto-arm's setdefault is a silent no-op as launched. A column-0 scan of
every module holding a stamped key's reader found exactly four such readers
among the ~31 auto-armed keys; every other stamped key reads the environment at
use or is consumed from config.json at model load. All four are decode-verify
lanes, so the release control (71.17 tok/s at 16K) ran without them -- a
plausible slice of the 71->81 gap.

Resolve all four at use (read the environment each call; the module global stays
a test/force override; the env is frozen once serving starts, so two traces of
one graph still read the same value):
- MTPLX_QWEN4_DRAFT_K20_PRESCATTER: qwen4_draft_k20_prescatter._ENABLED, and
  generation.py's cached _QWEN4_DRAFT_K20_PRESCATTER (removed; the one draft
  consult site calls the reader).
- MTPLX_QWEN4_BLOCK_VERIFY: qwen4_block_verify._ENABLED, and generation.py's
  cached _QWEN4_BLOCK_VERIFY (removed; the accept-loop consult calls the reader).
- MTPLX_QWEN4_OPDIET (+ _ITEMS): runtime_options.
- MTPLX_QWEN4_VERIFY_GLUE (+ _ITEMS): runtime_options (reset hook kept, now
  forcing the globals).
No default value changed; keys that already read at use are untouched. Upstream's
STRICT_CLAIMS and BATCH_PAGED_OFFSETS are also import-frozen but are not
auto-armed (operator sets them pre-launch), so they are left as-is.

Tests: tests/test_qwen4_remainder_arming.py extended to assert all four arm in
the served order (import first, stamp, read) and that the fixed-M4 auto-arm
stamps OPDIET / BLOCK_VERIFY / VERIFY_GLUE and their readers then arm. The
block-verify and draft-k20 source-inspection tests and the opdiet read-once test
are rewritten to assert read-at-use.
The arming audit's lesson: gate on the install verdict, not the env. Three
decode-verify lanes had no per-window observable in
/health qwen4_install_reports -- draft_k20_prescatter, block_verify, opdiet --
so a served window could not confirm they engaged. Add read-only reports (no
behaviour change, no defaults touched):
- draft_k20_prescatter: {armed (read at use), engaged (first-use latch set when
  claim_draft_route installs the route), receipt (the last install receipt)}.
- block_verify: {armed, engaged (latched when a block verifier is built for the
  accept loop)} plus a one-shot "[mtplx] MTPLX_QWEN4_BLOCK_VERIFY armed:" log.
- opdiet: {armed, items (configured selection), applied (first-use latch of the
  items that actually ran at a gated site)}.
Each appears only when ARMED (read at use, gate-able without a request), so an
unarmed lane stays absent (== off) like the other lanes; the engaged/applied
latch rides inside the armed report.

CPU test: tests/test_qwen4_remainder_arming.py asserts the three reports are
absent when off / =0 and present with armed True under a served-order stamp.
… 391 remainder port

Rebase of PR youssofal#475 (cached async PLE + pooled-key rowsel) onto the PR 391
remainder port head (perf/qwen38-391-remainder-main = upstream main 2.11.2 + the
four remainder lanes: HC_M4, prefill mask fuse, QSA query tile, and the QSA split-K
sparse-GQA decode extension. The two lanes here add to the same fixed-M4
lane_defaults / _QWEN4_PORT_KEYS block, and their native loader (ple_cpu_rows) and
wheel-signer entry union with the QSA sparse-decode extension's (mtplx_native_qsa),
all cleanly additive).
PR 391 is closed and its Fable delivery stack (mtplx/full_stack_env.py, the
turbo-full-stack profile, mtplx/native's QSA sparse-GQA loader) was never
merged, but the maintainer independently re-landed the Flash-Next stack under
the MTPLX_QWEN4_*/MTPLX_QSA_* namespace, so both lanes' base contracts are
present upstream and both apply: the fixed-M4 compiled-verify auxiliary plane
(mtplx/qwen4_fixed_verify.py) and the QSA indexer pooled-key kernel
(mtplx/kernels/qsa_indexer_prepare._pool_keys_kernel). The work here is
re-siting the arming and native loading off the absent full_stack_env onto
upstream's own machinery.

Lanes (armed by default for a served fixed-M4 Flash-Next pack, per-lane opt-out):
- ple_cached_aux: a native CPU-stream provider stages the fixed 64 M4 n-gram
  rows and the auxiliary embedding plane is produced with mx.async_eval outside
  the compiled verifier. The stock owner-side row cache is preserved; declines
  to stock with a printed reason when the ple_cpu_rows extension is not built.
- qsa_pooled_rowsel: the twelve QSA indexers' pooled-key preparation binds the
  pool kernel metadata once per indexer and shares one inv_freq object.

Rebase changes vs the closed-PR commit:
- mtplx/qwen4_aux_lanes.py rewritten off full_stack_env: primary keys are
  MTPLX_QWEN4_PLE_CACHED_AUX / MTPLX_QSA_POOLED_ROWSEL, the PR 391 MTPLX_FABLE_*
  names kept as aliases (primary wins when both set).
- mtplx/server/openai.py: the two keys join the fixed-M4 lane_defaults and
  _QWEN4_PORT_KEYS (so the existing pop-loop kill-switch honours KEY=0), with an
  alias pre-step mirroring an operator's MTPLX_FABLE_* export onto the primary.
- mtplx/qsa_pooled_rowsel.py: op-diet contract re-pointed from the absent
  fable_opdiet_enabled to upstream's qwen4_opdiet_enabled (MTPLX_QWEN4_OPDIET).
- mtplx/runtime.py: the two installs run after the fixed-M4 verify install,
  logging instead of the removed _print_install_receipt.
- mtplx/profiles.py: the two keys added to MODEL_RUNTIME_ENV_OVERRIDE_KEYS so
  normalize_runtime_env_overrides accepts the server-stamped values.
- mtplx/native/__init__.py: a minimal PLE-only loader (load_ple_cpu_rows_extension
  / ple_cpu_rows_unavailable_reason); PR 391's qsa_sparse_gqa loader is not
  reproduced (upstream loads native QSA via kernels/qsa_prefill_direct.py).
- scripts/bundle_native_runtime_wheel.py: accepts and Developer-ID signs the new
  mtplx_native_ple_cpu_rows extension alongside mtplx_qsa_kernels, so a notarized
  release wheel carries a signed ple_cpu_rows Mach-O.
- scripts/fable/setup_over100_venv.sh: builds only ple_cpu_rows.

772f5be's opt-in interleaved n-gram row cache (MTPLX_NGRAM_ROW_FILE, default
off) touches the same _SidecarGather rows but at the disk-layout layer; it is
orthogonal to this runtime-scheduling lane and does not subsume it.

CPU tests (venv mlx 0.32.2, no GPU): 84 passed across the six lane test files
plus the wheel-bundler test.
…senses of "exact"

Against pr-bodies/GLOSSARY.md, the same pass the pull request bodies had.

The arm table gave an acceptance mode for D and E only, so A and C read as
though they had none. Both run with typical off, and their rows now say so.

"Exact" appears once here, in the optimization-class sense: an exact
optimization is byte-for-byte the stock path, against a rounding-class one that
differs only by floating-point rounding. Nothing in the file bound that, so it
was indistinguishable from the exact acceptance LAW that youssofal#478 and youssofal#485 use the
same word for. The paragraph now binds it and states that it never labels an
arm, since arms A and C are rounding-class builds overall.

The prose mixed short context labels with exact counts; prose now gives the
counts and the short form is left to table row labels.
…verflow

The long-context verdict table blamed a QSA-indexer prefill transient for the
261,120-token OOM on all four arms. The prefill completes on every arm; the
overflow is the first decode step's speculative-verify KV-cache write, where
TensorOffsetKVCache.update_and_fetch used the functional mx.slice_update and
reallocated the full per-layer KV buffers (about 6.4 GB in one command
buffer). The probes W1 and W2 targeted prefill and so could not have fit. This
matches the youssofal#475 body's Section 1.5 and the youssofal#482 fix arm, which fits 261,120
on all three cold seeds at 100.82 GB.
@davidtai

Copy link
Copy Markdown
Contributor Author

It should be noted that there are several fixes that need to go in and this is just one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant