Skip to content

[WIP] Feat/gpt oss example#63

Draft
ymwangg wants to merge 29 commits into
mainfrom
feat/gpt-oss-example
Draft

[WIP] Feat/gpt oss example#63
ymwangg wants to merge 29 commits into
mainfrom
feat/gpt-oss-example

Conversation

@ymwangg

@ymwangg ymwangg commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Add p-eagle gpt-oss-20b example.

ymwangg and others added 7 commits June 25, 2026 13:38
Add a NKIPy example for OpenAI's gpt-oss MoE models (gpt-oss-20b / 120b),
mirroring the qwen3 example structure. The implementation is fully
config-driven, so both sizes share one codebase.

gpt-oss-specific handling:
- MXFP4 experts dequantized to bf16 at prep time
- interleaved gate/up de-interleaved at prep time
- clamped SwiGLU with gate_up/down biases
- per-head attention sinks + QKV/O biases (no QK-norm)
- alternating sliding-window / full attention (one kernel per type)
- YaRN RoPE (inv_freq precomputed from HF config)
- router with top-k-then-softmax and router bias

Validated against HF on trn2 (TP=4): every generated token matches HF's
argmax or a bf16-resolution tie.
Implements parallel-drafting P-EAGLE (arXiv 2602.01469) on top of the
gpt-oss base model for speculative decoding on Trainium.

Components added (examples/models/gpt_oss/eagle/):
- config.py: EagleConfig for the 4-layer P-EAGLE drafter (llama3 RoPE,
  fc fusion, mask_hidden/ptd_token_id, d2t vocab map)
- tensor_preparation.py: convert P-EAGLE checkpoint to x@W form (replicated)
- kernels/drafter.py: parallel-drafting forward - K tokens in one pass via
  NTP (real hidden) + MTP (mask_hidden) positions with cross-depth mask
- kernels/drafter_layer.py: EAGLE-3 fusion midlayer + plain Llama layers
- kernels/verify.py: multi-position greedy argmax for verification
- drafter_model.py: device-side drafter model + compile
- speculate.py: full speculation loop (prefill → draft → verify → accept)

Base model changes:
- config.py: added aux_layers config + default_aux_layers() for EAGLE-3 taps
- gpt_oss.py: run_prefill() now optionally captures pre-layer hidden states
  at the 3 EAGLE-3 tap layers (2, L/2, L-3)
- kernels/attention.py: generalized decode path to support seq_len>1 (for
  the multi-token verify pass) via query_pos = start_pos + arange(seq_len)

Status: functionally correct (lossless greedy output verified against HF).
Acceptance length is below the paper's reported ~3.3 — under investigation
(likely a hidden-state position/timing issue in the draft-verify loop seeding).
…yers

Switch aux capture to post-layer (output of tap layers 2/12/21) based on
HF validation showing the drafter predicts correctly with HF's hidden
states at hs[3]/hs[13]/hs[22] (output of layers 2/12/21).

Note: acceptance length remains low (~1.0) due to numerical divergence
between nkipy's Neuron-compiled target and the HF CPU reference the
drafter was trained against. The drafter kernel is mathematically correct
(validated against independent torch reference) and correctly predicts
the target when fed exact HF hidden states. The gap is an
implementation-coupling issue inherent to EAGLE-style speculation.
Key findings from the P-EAGLE paper (Figure 2, Figure 3, Section 3):

1. The drafter maintains its own KV cache across the full context
   (prompt + all accepted tokens). At each draft step, K positions
   attend to the FULL accumulated cache.

2. The attention mask is GROUP-CAUSAL: all K positions see the full
   cache (group 0), but within the K positions the NTP (group 1)
   and MTP (group 2+) positions use cross-depth causality — MTP
   positions cannot attend to positions at the same or later depth.

3. The NTP pair is (emb(t_n), hidden_after_processing_t_{n-1}),
   predicting t_{n+1}. The hidden is one step behind the embedding.

This commit adds:
- drafter_cpu.py: CPU reference drafter with full KV cache and
  standard causal attention (working infrastructure, mask needs
  the group-causal refinement for MTP positions)
- Fixes hidden state capture to post-layer (output of tap layers)
- Adds peagle_aux_layers config method

Status: KV cache infrastructure correct, still needs the group-causal
mask refinement for the MTP positions within the K-wide draft window.
Root-caused the low acceptance length (~1.4 vs the card's 3.30-3.80 at K=7)
on GPU by running the identical checkpoint through vLLM's eagle3
parallel-drafting path, capturing its drafter I/O, and reproducing it with a
standalone PyTorch reference (cosine 0.9999, 100% draft-token match). Three
bugs plus a prompt-formatting issue:

1. Context-blind drafting (dominant): speculate.py drove DrafterModel
   (kernels/drafter.py), which runs only the K draft positions under a (K,K)
   cross-depth mask with no prefill and no KV cache, so the MTP slots never
   saw the prompt. Rewired speculate.py to use the KV-cached DrafterCPU:
   prefill the drafter on the prompt (EAGLE +1 shift), then each step roll the
   cache back to the last accepted position and run [newly-accepted tokens |
   K-1 ptd slots] in one parallel forward attending to the full context.

2. rollback() truncated the wrong axis: the cache is (B, n_kv, seq, head_dim)
   and rollback sliced dim 1 (n_kv) instead of dim 2 (seq), so rejected
   speculative KV was never discarded and corrupted later steps.

3. Aux tap off-by-one: vLLM's eagle3 default (2, n//2, n-3) captures the
   residual stream entering those layers; our post-layer capture must shift
   down one, so default_aux_layers now returns (1, 11, 20). Verified on GPU
   the drafter's 3 fc chunks equal target layer outputs (1, 11, 20) at cos 1.0.

4. Prompt formatting: the drafter is trained on chat data; raw prompts roughly
   halve acceptance (GPU, K=7: 3.65 chat vs 1.99 raw). speculate.py now applies
   the chat template by default (--raw-prompt to opt out).

Still produces all K draft tokens in a single forward pass (parallel drafting).
Adds test_drafter_cpu.py guarding the rollback/full-context invariants (skips
without the checkpoint). Validated against vLLM on GPU; not yet re-validated on
Trainium, and the on-device kernels/drafter.py KV-cache port remains follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ymwangg

ymwangg commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

gpt-oss + P-EAGLE — Implementation Status Report

Status as of the current branch feat/gpt-oss-example. Target: gpt-oss-20b on AWS
Trainium (TP=4) via NKIPy.

TL;DR

  • Base gpt-oss-20b: complete and working on Trainium (~70 tok/s decode).
  • P-EAGLE speculative decoding: numerically correct and fully on-device. The
    drafter port (the recent work) is done and validated — device drafter is
    byte-identical to the GPU-validated CPU reference and ~6× faster than it.
  • Open issue: P-EAGLE end-to-end is currently a net slowdown vs the base
    decode (~0.57×)
    despite accepting ~3.4 tokens/step, because the speculative
    loop is synchronous while the base decode is async/double-buffered. The drafter
    is no longer the bottleneck — the loop is.

Components

Component File(s) Status
Base MoE model (TP, MXFP4→bf16, sinks, sliding window, YaRN RoPE, double-buffered decode) gpt_oss.py, kernels/, tensor_preparation.py ✅ Working on hardware
Drafter — CPU reference (KV-cached full-context parallel drafting) eagle/drafter_cpu.py ✅ GPU-validated vs vLLM
Drafter — on-device (KV-cached forward + draft head) eagle/drafter_model.py, eagle/kernels/drafter*.py ✅ Ported + Trainium-validated
Speculation loop (prefill → draft → verify → accept) eagle/speculate.py ✅ Working; --device-drafter selects on-device path
Verify kernel (K+1 multi-token target pass + per-pos argmax) eagle/kernels/verify.py ✅ Working
Tests eagle/test_drafter_cpu.py, eagle/test_drafter_device.py ✅ Pass (real + synthetic)

What changed recently (this work)

The drafter had a working CPU reference but the on-device path was the old
context-blind kernel
(K positions under a static (K,K) cross-depth mask, no
prefill, no KV cache) and was unused. Three commits brought the device path up to
parity and tightened metrics:

Commit Summary
fdb13e0 fix: correct P-EAGLE drafter to full-context KV-cached parallel drafting (CPU reference + loop bookkeeping; GPU-validated vs vLLM)
e8da8b1 feat: port P-EAGLE drafter to on-device KV-cached parallel drafting (DrafterModel + kernels/drafter*.py; --device-drafter; test_drafter_device.py)
2cd9e2f fix: correct acceptance-length metric to count only emitted tokens (excludes the final step truncated by the token budget)

Port design note: the device drafter keeps static kernel shapes — it commits
newly-accepted tokens one at a time (S=1) and runs the K-wide draft window
(S=K) — so it avoids per-step recompiles. Causal attention makes the
one-at-a-time commits numerically identical to a single batched forward.

Also fixed along the way: speculate.py's chat-template branch crashed on recent
transformers (it returns a BatchEncoding, not an array); now extracts
["input_ids"]. The template ships separately as chat_template.jinja.

Validation

Check Result
CPU drafter math vs vLLM (GPU) cos 0.9999 prefill; 100% draft-token match
Device drafter vs CPU drafter (synthetic, Trainium) logit cos 0.9999 across prefill + KV-cached draft steps with rollback
Device vs CPU drafter, end-to-end (real checkpoints) byte-identical output
test_drafter_cpu.py (real checkpoint) 3/3 pass
test_drafter_device.py (Neuron-gated) pass

Under greedy verification emitted tokens are the target's argmax regardless of
the drafter, so identical output is expected by construction — the drafter only
affects speed. Acceptance length is the meaningful correctness signal (a broken /
context-blind drafter collapses toward ~1); we observe ~2.4–4.6.

Performance (5-prompt sweep, n=128, K=7, TP=4, chat template, identical env)

Config Mean decode tok/s vs base Mean acceptance
Base (no speculation) 69.8 1.00×
P-EAGLE, device drafter 39.8 0.57× 3.44
P-EAGLE, CPU drafter 6.6 0.10× 3.37
  • Device drafter is ~6× faster than the CPU drafter (the port's contribution).
  • Speedup tracks acceptance: 0.72× on high-acceptance prompts (haiku 4.59,
    binary-search 4.38) down to 0.44× on low-acceptance ones (transformer 2.44).
  • None exceed 1× — see the bottleneck below.

(Full per-prompt table in gpt-oss-peagle-results.md.)

Known issues / open work

  1. Speculation is a net slowdown vs base decode (primary issue). The base path
    is async / double-buffered (overlapped execution); the speculative loop is
    synchronous, paying every step for a K+1-wide verify pass plus host-side
    orchestration (accept/reject compare, embedding lookups, drafter call) that is
    not pipelined. To make P-EAGLE a net win:

    • async / double-buffered speculation (overlap verify + draft + sampling);
    • move per-step host orchestration on-device to cut host round-trips;
    • tune K per workload (lower K helps low-acceptance prompts).
  2. Acceptance below model card on some prompts. Chat formatting lifts
    acceptance (raw prompts ≈2.95 → chat 4.1–4.6 on favorable prompts), but
    reasoning-heavy prompts still sit ~2.4–2.5. Worth checking tap-layer choice and
    prompt distribution against the card's eval setup.

  3. Single-run benchmarks. Numbers are one run per (prompt, config) — good for
    relative comparison; average several runs before quoting as benchmarks.

ymwangg added 4 commits July 13, 2026 17:10
The device drafter was context-blind (no KV cache, no prefill): its MTP
slots never saw the prompt, collapsing acceptance to ~1.5 vs the CPU
reference's ~4.3. Port the CPU drafter's full-context KV-cache algorithm
to device, mirroring the base model's on-device cache pattern.

- drafter_layer.py: add _attention_cached + drafter_layer_cached
  (KV-cached attention, causal over absolute positions).
- kernels/drafter.py: add drafter_layer_kernel (per-layer cached device
  entry) + drafter_head_kernel.
- drafter_model.py: rewrite DrafterModel with per-layer KV caches and
  prefill()/draft() matching DrafterCPU; implicit rollback via greedy
  overwrite + causal mask.
- speculate.py: device drafter is now the default; --cpu-drafter selects
  the PyTorch reference. Also fix apply_chat_template crash on
  transformers 5.5.4 (return_dict=True).
- run_drafter_device.py: standalone harness; multi-step logit-level
  parity check vs CPU.

Validated on trn2 (TP=4, K=7): device matches CPU logits at cosine
0.9999 (token diffs are sub-noise bf16 ties); acceptance 4.19 vs 4.33,
decode 34.8 vs 4.8 tok/s (~7x faster, no per-step host<->device sync).
Profiling (SPEC_PROFILE=1) showed the device drafter spent ~30% of decode
time in the commit step: each accepted token was pushed through all 4
drafter layers as a separate width-1 kernel launch (~4 launches/step),
paying fixed dispatch overhead for near-zero compute.

Replace the commit loop + separate draft pass with ONE combined forward of
width W = C+K-1 over [commit_0..commit_{C-1} | ptd_0..ptd_{K-2}], matching
DrafterCPU.draft. Compile one kernel set per width (W in K..2K) upfront;
lazy in-loop compilation briefly regressed decode to 0.55 tok/s.

Measured on trn2 (TP=4, K=7): 34.5 -> 41.7 tok/s, acceptance 4.19 -> 4.33
(the single pass is more faithful than commit-then-draft). Drafter time
52 -> 37 ms/step; verify (67 ms) is now the dominant cost. Parity with CPU
preserved (logit cosine 0.9999). Profiling instrumentation is gated behind
SPEC_PROFILE=1 (zero overhead when unset).
Profiling (SPEC_PROFILE=1) showed the drafter's device compute was only
~9ms/step, but the per-layer host loop (4 kernel launches + separate head
+ host fc-fuse + host concat + 201k-vocab logit transfer) added ~24ms of
overhead, making the drafter cost as much per layer as the 24-layer target.

Replace the host-driven per-layer loop with drafter_fused_kernel: one
device call doing fc-fusion + [embed|hidden] assembly + all 4 layers (each
aliasing its own KV cache) + final norm + lm_head. DrafterModel now uploads
inputs once and invokes one kernel per step; fc-fuse moved on-device.

Measured on trn2 (TP=4, K=7): 40 -> 50 tok/s, drafter forward 33 -> 17
ms/step, acceptance held at 4.33. Parity with CPU preserved (logit cosine
0.9999; token diffs are sub-noise bf16 ties). Verify (66ms) is now the
dominant cost at ~78%; profiling confirms it is genuine device layer
compute, not the host MoE loop the README previously (wrongly) blamed.

Also remove the now-dead pre-fusion kernels (drafter_kernel/forward,
drafter_layer_kernel, drafter_head_kernel, non-cached drafter_layer/
_attention) and correct the README notes.
Debugged the verify step, which is the dominant per-step cost. Measured its
scaling by sweeping K on trn2 (TP=4): verify layer time is
~19 ms fixed + ~6.3 ms/candidate token. The fixed ~19 ms equals a single-token
forward (HBM weight streaming), amortized across the K+1 batch — so verify is
efficient per token and NOT dominated by per-token MoE weight re-gather as
previously assumed.

The real lever is K vs the acceptance plateau. This drafter saturates around
3.3 accepted tokens/step, so K>3 buys verify time for no extra accepted tokens:

  K=1 1.94acc 50.0 t/s | K=2 2.67 52.6 | K=3 3.31 ~60 | K=4 3.31 49.5 | K=7 4.33 50.4

K=3 is the throughput optimum at ~60-62 t/s (n=256), beating both the base
model (~52) and the old K=7 default (~50). Change default to K=3 and document
the sweep + scaling model in the README.
@ymwangg

ymwangg commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Progress update: P-EAGLE speculative decoding — working + optimized on trn2

Brought the on-device P-EAGLE drafter from broken → 62 tok/s (vs 52 tok/s for the base model), and profiled the verify step to a firm conclusion. All changes are on this branch (4 commits).

What changed

Commit Change
fcdb576b KV-cached on-device drafter (default). The device drafter was context-blind (no KV cache/prefill) → acceptance collapsed to ~1.5. Ported the CPU drafter's full-context KV-cache algorithm to device. Also fixed an apply_chat_template crash on transformers 5.5.4.
5fe2a70f Fold commit into the draft pass. Replaced 4 sequential width-1 "commit" kernel launches/step with one combined forward of width W=C+K-1.
e505269b Fuse the drafter into one kernel. fc-fusion + [embed|hidden] assembly + all 4 layers (each aliasing its KV cache) + norm + lm_head in a single device call, replacing the per-layer host loop.
a5d15b09 Default K=3 (throughput optimum) + documented verify scaling.

Parity held throughout: device drafter matches the CPU reference at logit cosine 0.9999 (token diffs are sub-noise bf16 ties). CPU drafter tests 3/3.

Throughput progression (trn2, TP=4, K=7, chat prompt)

Stage Acceptance Decode tok/s
Base model (no speculation) 52.4
CPU drafter (reference) 4.33 4.8
Device drafter, context-blind 1.56 20.5
+ KV cache 4.33 34.5
+ commit folded into draft 4.33 40.9
+ fused single kernel 4.33 50.4
+ K=3 (optimum) 3.31 ~62

Latency breakdown (per verify step, SPEC_PROFILE=1)

At the fused/optimized state, K=7 (~116 ms/step):

draft :  17.4 ms/step (21%)   1 fused kernel
verify:  66.1 ms/step (78%)   24 MoE layers over K+1 tokens
  layers:  63.4 ms   (aux copies 0.3)
  argmax:   2.3 ms   (head + argmax + host transfer)
other :   0.4 ms/step (0%)

Reference points:

  • Single-token base forward: ~19 ms (embed → 24 MoE layers → norm → lm_head → sample).
  • Verify scales as ~19 ms fixed + 6.3 ms/candidate token. The fixed ~19 ms == a single-token forward (weight streaming from HBM), amortized across the K+1 batch.

Choosing K — why 3, not 7

Verify cost grows ~6.3 ms/token, but this drafter's acceptance plateaus at ~3.3, so K>3 buys verify time for no extra accepted tokens. Sweep (n=96):

K Acceptance Decode tok/s
1 1.94 50.0
2 2.67 52.6
3 3.31 ~60–62
4 3.31 49.5
7 4.33 50.4

Can verify be sped up with a better kernel? Investigated → no (for this framework)

Verify is 78% of each step, so I profiled the MoE hard:

  • Ruled out attention-over-full-KV-cache (max_seq_len 4096→512 barely moved verify: 36 vs 38 ms) and HBM bandwidth (implied ~80 GB/s, well below peak).
  • Cost model: a single expert FFN is flat in row count (0.80 ms at S=1/4/8) → MoE is weight-load bound; layer cost ≈ (#expert-weight-loads) × ~1.4 ms.
  • The per-token loop does S×top_k loads (K=3→16, K=7→32); the batch only touches ~9.7 (K=3) / 13.4 (K=7) unique experts. Grouped-all-experts (32 loads, flat) only wins when S×top_k > 32ties at K=7, regresses at K=3. Deduping to unique experts would win but needs a dynamic expert loop the static-trace NKI kernel can't express.

Conclusion: verify is near-optimal on this framework — no worthwhile static MoE-kernel win. Grouped MoE would help large-L prefill/TTFT (L×top_k≈280 → 32 loads) if revisited.

Remaining levers (algorithmic, not kernel)

  • Stronger drafter to raise the ~3.3 acceptance plateau — would let larger K pay off again.
  • TTFT: the first prefill() compiles a full-prompt-width fused kernel inside the timed region; pre-compiling bucketed prompt widths would hide it.

Profiling is behind SPEC_PROFILE=1 (zero overhead when unset).

@ymwangg

ymwangg commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: verify latency breakdown — where the ~63 ms goes

Attributed the verify layer's cost to its components by microbenchmarking each at the real gpt-oss-20b sharded dims (TP=4, S=K+1=8 tokens), × 24 layers:

Component ms/layer × 24 layers share
Attention projections (QKV + O) 0.28 6.8 ~10%
MoE router (matmul + topk + softmax) 0.23 5.5 ~8%
MoE experts (top_k FFNs) 5.96 143 ~92%
Attention scores / softmax ~0 ruled out (seq-len-insensitive)

(Isolated components sum higher than the fused 63 ms because fusion overlaps DMA with compute and shares intermediates — the proportions are the point.)

Which kernel to focus on: the MoE expert FFN loop, and only that. Attention (~10%) and the router (~8%) aren't worth touching.

Important caveat

The per-expert matmul is not slow — it's already efficient. The cost is structural: the verify batch does S×top_k separate expert-weight loads (32 at K=7, ~12.4 MB each), and the MoE is weight-load bound (a single expert FFN is flat in row count: 0.80 ms at S=1/4/8). The only lever is loading fewer distinct experts:

  • Dedup to the ~13 unique experts the batch actually touches → the real win, but needs a dynamic expert loop the static-trace NKI kernel can't express (loop counts/shapes must be compile-time constants).
  • Grouped-all-experts (32 loads, flat) → ties at K=7, regresses at K=3. Not a win in the useful range.

So, to keep improving verify

  1. Framework-level — dynamic expert dispatch (gather/scatter over a variable unique-expert set). This is the one change that would actually cut the ~143 ms, but it's an NKIPy/compiler capability, not an example-level kernel edit.
  2. Algorithmic — a stronger drafter to raise the ~3.3 acceptance plateau, so the fixed per-step cost amortizes over more accepted tokens (and larger K pays off again).

Net: verify is near-optimal for what this static-trace framework can express today; the remaining wins are framework or drafter improvements, not a rewrite of the expert kernel.

ymwangg added 16 commits July 16, 2026 13:28
Rename examples/models/gpt_oss/eagle/ to peagle/ to reflect the
parallel-EAGLE (P-EAGLE) drafter it implements, updating the eagle.*
package imports and path references accordingly. Algorithm-name
mentions (eagle3, get_eagle_config) are left unchanged.
Adds moe_batched() to kernels/feedforward.py: expresses all N=B*L tokens x
top_k experts as a single gather (np.take) + batched matmul instead of the
per-(token,expert) Python loop. Numerically identical to the baseline
(max|delta|=0 in bf16).

Includes _moe_bench.py (isolated device-kernel A/B + equivalence check) and
_ntff_stats.py (device exec-time extraction from .ntff traces).

Finding: only ~1.09-1.13x on device across L=1/4/8. neuronx-cc lowers the
batched dot back into one distinct-weight GEMV per (token,expert) -- matmul
count still scales linearly with N*top_k -- so there is no PE-level fusion.
The ~10% gain is only from collapsing router/topk/reduce glue and slightly
less DMA. A real speedup needs a grouped/block-diagonal GEMM kernel (custom
NKI), which numpy-tracing cannot express. Not yet wired into transformer_layer.
The --dtype bf16 path silently ran in fp32: `bf16_array * 0.1` promotes the
array back to float32 (python-float scalar -> float64 wins the numpy
promotion), undoing the .astype(DT). Scale before the final cast, assert the
input dtype matches, and print it so this can't recur.

Honest bf16 numbers: batched MoE is 1.24x on device at L=4 (was a 1.13x fp32
artifact), correct within bf16 tol (rel 6.6e-3). Tracing the bf16 kernel also
shows weight DMA = 199.7MB = the 16 selected experts read exactly once, i.e.
neuronx-cc already fuses the gather into an indexed weight load -- the only
remaining headroom is GEMV->GEMM density (grouped matmul), not the gather.
Explores the GEMV-density headroom flagged in the batched MoE. Two new
variants in feedforward.py:

  moe_concat       - fuse a token's top_k experts into 2 matmuls (stack gate/up
                     on the output dim, down on the contraction dim so the
                     expert-sum falls out). Measures identical to batched: the
                     compiler already fuses the batched GEMV the same way, so
                     the explicit concat only adds a transpose that nets out.
  moe_dense_masked - run ALL 32 experts as one dense batched GEMM, mask by the
                     router. ~flat ~1185us regardless of N (weight-load bound).

_moe_bench.py reworked to sweep {baseline, batched, concat, dense} x N and
report device exec time + matmul count per variant.

Result (bf16, TP4-rank dims, device speedup vs baseline loop):
  N=1: batched/concat 1.25x, dense 0.23x
  N=4: batched/concat 1.23x, dense 0.79x
  N=8: batched/concat 1.22x, dense 1.55x
Dense crosses over batched at ~N=6. So: batched for decode / small verify
(K<=4), dense for large verify (K>=5). P-EAGLE default K=3 -> batched.
All variants numerically match baseline.
transformer_layer reads GPT_OSS_MOE_KERNEL (loop|batched|concat|dense) to pick
the MoE impl; loop is the default reference. peagle/_sweep.py runs speculate.py
across K x kernel, persists each combo to jsonl (resumable after a kill), and
tabulates end-to-end tok/s.

Sweep (TP4, n=160, raw prompt) tok/s:
  K   loop  batched  dense
  1   45.4   49.5    32.7
  3   44.9   47.8    42.1
  5   36.9   39.2    42.3
  7   33.1   35.3    40.0

batched beats loop at every K (~1.07-1.09x e2e); dense crosses over at K=5
(verify N=6) as the microbench predicted and wins clearly at K=7 (40.0 vs 35.3).
Best overall on this prompt: K=1 + batched, 49.5 tok/s. Practical rule: small K
-> batched, K>=5 -> dense. Acceptance (hence absolute tok/s) is prompt/chat-
template dependent; the per-K kernel ranking holds regardless.
Add a "Speedup over the base model" section: P-EAGLE K=3 is 1.18x (loop) /
1.27x (batched MoE) over the ~52 tok/s base, measured on the binary-search chat
prompt at acceptance ~3.2. Explain why the speedup is far below the acceptance
length (verify runs K+1 tokens through the full target; decode is dispatch-
bound) and that speedup scales ~linearly with acceptance, which is prompt-
dependent.

Add a "MoE kernel selection" section documenting GPT_OSS_MOE_KERNEL
(loop|batched|dense|concat), the end-to-end tok/s-by-K table, and the rule:
batched for K<=4, dense for K>=5.
The base model also honors GPT_OSS_MOE_KERNEL, so dividing spec-batched (66.2)
by base-loop (~52) overstated the win. Measured base decode with each kernel:
loop 52.0, batched 54.9. Kernel-matched speedup is 1.18x (loop) / 1.21x
(batched) -- batched lifts both base and spec, so it barely moves the ratio.
Drop the MoE microbenchmark and its ntff-stats helper; scrub the dangling
references in peagle/README.md and transformer_layer.py. The end-to-end
peagle/_sweep.py remains for measuring kernel choices.
Merge-readiness cleanup for the gpt-oss example:

- Replace dev-box-specific /home/ubuntu/models/... defaults with HF ids across
  speculate.py, _sweep.py, run_drafter_device.py, test_drafter_cpu.py:
  --model -> openai/gpt-oss-20b, --draft-model -> amazon/GPT-OSS-20B-P-EAGLE.
- Drop moe_concat: the sweep showed it measures identical to moe_batched (the
  compiler already fuses the batched GEMV), so it was dead weight. Selector is
  now {batched, dense}; default stays loop.
- Complete the peagle README Files table (drafter_cpu, run_drafter_device,
  test_drafter_cpu, _sweep) and remove the concat row from the MoE-kernel table.
- Remove a stray 26MB ntff.json trace dump from the working tree.
Benchmarked double-buffered vs baseline decode at batch=1: 52.2 vs 51.8 tok/s
(within noise). The optimization targets per-token host round-trip overhead,
but decode is layer-bound (~18ms/step) and the async runtime already overlaps
the token readback, so the fused-embedding + ping-pong-buffer machinery bought
nothing while adding real complexity.

Collapse generate() to the single (former baseline) path and drop the now-dead
_generate_double_buffered, the fused greedy_sampling_with_embedding kernel, the
on-device embedding table (tok_embedding_device), the two *_embed compiled
kernels, and the --no-double-buffering flag. Verified generation is unchanged
(greedy "Paris", 52.3 tok/s).
Extract the hand-written NKI RMSNorm (nki_rmsnorm_kernel + stream_shuffle_
broadcast helper) out of kernels/sampling.py into a dedicated kernels/nki/
package, so the low-level NKI code is separated from the numpy-traced kernels.
sampling.py now imports it from .nki.rmsnorm. Behavior unchanged (default
USE_NKI_RMSNORM=True); verified generation still produces "Paris" at ~53 tok/s.
Documented the new subfolder in the README Files table.
…loop->reference

Extract the inline per-(token,expert) Python-loop MoE out of transformer_layer
into moe_reference() in feedforward.py, so all three MoE implementations are
peer functions selected uniformly through _MOE_KERNELS (no more if/else special-
casing the default). transformer_layer now just dispatches the dict entry.

Rename the kernel from "loop" to "reference" (env value, dict key, docstrings,
sweep default, README tables) -- it names the role (the readable correctness
baseline) rather than implying it's simply unsophisticated, and it's the default.
Drop now-unused imports (tensor_apis, softmax_kernel, feedforward_kernel) from
transformer_layer. Behavior unchanged; verified generation still yields "Paris"
at ~53 tok/s on the default path.
Drop the standalone (K, MoE kernel) sweep harness, matching the earlier removal
of _moe_bench.py / _ntff_stats.py -- it only reproduced the perf tables and
nothing imports it. The README still documents the results and methodology, and
the sweep is trivially reproducible via GPT_OSS_MOE_KERNEL + speculate.py.
Scrubbed the two README references.
Remove drafter_cpu.py (DrafterCPU) and the two files that existed only to
compare against it: test_drafter_cpu.py (tested DrafterCPU exclusively) and
run_drafter_device.py (a device-vs-CPU logit parity harness). The on-device
DrafterModel is the sole drafter now.

speculate.py: drop the DrafterCPU import, the --cpu-drafter flag, and the
if/else branch; the device adapter is used unconditionally. Scrub DrafterCPU
mentions from drafter_model.py docstrings and the peagle README (drafter-
placement section, Files table, Validation table, root-cause narrative). The
GPU-vs-vLLM validation history is retained, just no longer attributed to a
shipped CPU file.

Verified P-EAGLE still runs: acceptance 3.20 at K=3, ~56 tok/s (unchanged).
Document what C = len(commit_token_ids) means -- the number of tokens the target
accepted in the previous verify (acceptance length, 1..K+1) -- why the step
commits them (they carry the target hiddens that only exist post-verify), and
why the fused width is C+K-1 rather than C+K (the NTP draft reuses the last
committed row, so only K-1 placeholder slots are appended).
Rename _dt -> _to_device, self.m/self.p -> fusion_weights/plain_weights,
and the _prepare_tensors weights param. Remove the unused cache_len field
(and the reset() that only zeroed it), the unused module-level BUILD_DIR,
and fold the cache-clearing rationale into the prefill() docstring. Add a
C <= K+1 assert in draft() so an out-of-range acceptance length fails at the
contract instead of via a bare KeyError. Fix the class docstring to reflect
that draft kernels compile at load time while prefill compiles lazily.

Verified on trn2 (TP=4, K=3): 60.97 tok/s, acceptance 3.20 -- unchanged.
ymwangg added 2 commits July 17, 2026 20:55
The RoPE cos/sin table (a compile-time constant) was recomputed and re-gathered
to the query positions inside every layer -- 4 identical materialize+gather pairs
per drafter forward, since all layers share the same rope params. Compute it once
in drafter_fused_kernel and thread the gathered (W, head_dim/2) freqs into all
layers. _attention_cached / drafter_layer_cached now take freqs_cos/freqs_sin
instead of raw rope params; the dead is_prefill branch is dropped (the fused
kernel always passes a real start_pos, 0 for prefill, so the decode-branch
gather/mask already subsume prefill).

Also document in draft() why a step re-feeds all C accepted tokens (not just the
newest): the prior step wrote their KV from ptd/mask placeholders, so they must
be overwritten with real embeds+hidden at their absolute positions.

Verified on trn2 (TP=4, K=3, n=256): 61.71 tok/s, acceptance 3.20 -- numerically
identical output, ~1% faster, no extra device memory.
The draft step's natural width W = C + K - 1 varies with the acceptance count
C (1..K+1), so we compiled K+1 shape-static kernels (W = K..2K) up front -- the
bulk of the drafter's cold-compile time. Instead compile ONE kernel at the max
width (W = 2K, C = K+1) and pad every call up to it: pad the ids with extra ptd
placeholders and pad target_hidden3 with the raw mask_hidden (which fc-fuses to
the same MTP hidden the kernel builds for its own MTP rows).

Padding is numerically exact, not approximate: the genuine draft rows [C-1 ..
C+K-2] get identical inputs and attention context, and the extra rows land at
absolute positions past this step's draft window, so the causal mask hides them
from every real row and their cache writes are overwritten by a later commit --
the same invariant that already lets speculative MTP rows be rewritten.

Verified on trn2 (TP=4, K=3, n=256): 62.28 tok/s, acceptance 3.24 -- unchanged
vs per-width. Drafter draft-kernel cold compile 180.8s -> 44.0s (~4x).
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