Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
cda967a
Add Qwen3.6-35B-A3B text frontend
LiangSu8899 Jul 29, 2026
1db9b46
Harden Qwen3.6 runtime contracts
LiangSu8899 Jul 29, 2026
c92dc19
Add Qwen3.6-MoE edge checkpoint utilities
LiangSu8899 Jul 30, 2026
12e34a7
Split qwen3_5_moe kernels into portability tiers
LiangSu8899 Jul 30, 2026
1af9321
Simulate a two-tier expert cache in the route trace
LiangSu8899 Jul 30, 2026
7525d8f
Align routed-expert blocks for direct I/O
LiangSu8899 Jul 30, 2026
2a3c434
Decode packed E2M1 without requiring cuda_fp4.h
LiangSu8899 Jul 30, 2026
bab295a
Record which expert-cache policy actually wins
LiangSu8899 Jul 30, 2026
ed687c1
Score expert quantization against real routed activations
LiangSu8899 Jul 30, 2026
9447bd4
Give the INT4 expert block a two-level scale
LiangSu8899 Jul 30, 2026
c19d728
Model warm-started cache and cold prefill cost
LiangSu8899 Jul 30, 2026
eba1014
Validate the startup expert set on held-out prompts
LiangSu8899 Jul 30, 2026
153be4b
Add cross-architecture parity for the gated kernels
LiangSu8899 Jul 30, 2026
9db0327
Add a bounded streaming cache for routed-expert blocks
LiangSu8899 Jul 30, 2026
edc28ac
Make the page-cache bypass switchable so it can be measured
LiangSu8899 Jul 30, 2026
182fb85
Keep the router's order when a request is deduplicated
LiangSu8899 Jul 30, 2026
2e70e82
Let the validation persist its traces
LiangSu8899 Jul 30, 2026
a730abc
Expose a block's components and its global scales
LiangSu8899 Jul 30, 2026
ed77aef
Decode a streamed expert block into bf16
LiangSu8899 Jul 30, 2026
d50ad73
Serve the routed experts from storage
LiangSu8899 Jul 30, 2026
9f9fe5a
Load the tokenizer only when something asks for it
LiangSu8899 Jul 30, 2026
69e1252
Let a configuration state which kernels it calls
LiangSu8899 Jul 30, 2026
eabfc82
Pass stream_experts to the loader, and give lm_head a portable path
LiangSu8899 Jul 30, 2026
2ad746a
Fix three defects in the streamed-expert assembly
LiangSu8899 Jul 30, 2026
5efdb40
Check the vendored attention kernel actually computes, and fall back
LiangSu8899 Jul 30, 2026
00c7b74
Treat a missing FA2 as a fallback rather than an error
LiangSu8899 Jul 30, 2026
d2ebdcc
Document why attention differs between targets
LiangSu8899 Jul 30, 2026
7a12744
Let the text prefill run where the vendored FA2 is absent
LiangSu8899 Jul 30, 2026
ee73fca
Take the bank conflict out of the weight-only 4-bit GEMVs
LiangSu8899 Jul 30, 2026
c955c59
Give each warp several rows so the 4-bit GEMVs have loads in flight
LiangSu8899 Jul 30, 2026
6f60dc2
Tune rows per warp per entry point, and drop the KV broadcast copy
LiangSu8899 Jul 30, 2026
b206be1
Size the GEMV block to the part, not to the card it was written for
LiangSu8899 Jul 30, 2026
1ca7849
Load and run the multi-token-prediction head
LiangSu8899 Jul 30, 2026
2d61232
Draft, verify and rewind a speculative window
LiangSu8899 Jul 30, 2026
22cb219
Give the verify block a 4-bit path, and measure that it is not the pr…
LiangSu8899 Jul 30, 2026
0c6d31c
Capture the speculative window, and stop quantising the draft head
LiangSu8899 Jul 30, 2026
c2178cc
Take the rollback snapshots before the state they snapshot is overwri…
LiangSu8899 Jul 30, 2026
2a4c5ff
Group prefill's routed tokens by expert instead of re-reading the weight
LiangSu8899 Jul 30, 2026
35127ca
Hand prefill's dense projections to cuBLASLt, and hoist the gate out …
LiangSu8899 Jul 31, 2026
80d7009
Serve every routed expert of a prefill layer in one grouped GEMM
LiangSu8899 Jul 31, 2026
4c6cdda
Unpermute the grouped MoE without atomics
LiangSu8899 Jul 31, 2026
4a058b3
Stop forcing prefill attention onto the math backend
LiangSu8899 Jul 31, 2026
8dc4ac4
Take the atomics out of the grouped activation quantiser
LiangSu8899 Jul 31, 2026
d45a3d8
Gate and quantise the grouped MoE's intermediate in one pass
LiangSu8899 Jul 31, 2026
1d30ed8
Stop upcasting the KV cache to fp32 on every decode step
LiangSu8899 Jul 31, 2026
b19fc75
Record what chunked prefill's attention can and cannot use
LiangSu8899 Jul 31, 2026
cc662b6
Gather the routing permutation inside the quantiser, and cache what i…
LiangSu8899 Jul 31, 2026
7697283
Take the WY front matter off the host
LiangSu8899 Jul 31, 2026
faf08a9
Route a prefill's experts in kernels, not tensor ops
LiangSu8899 Jul 31, 2026
213adf7
Fuse the residual adds and the MoE tail into their kernels
LiangSu8899 Jul 31, 2026
5fd2967
Cover the prefill routing kernel
LiangSu8899 Jul 31, 2026
0364058
Scan the routing histogram across the block, not along one thread
LiangSu8899 Jul 31, 2026
54ff8db
Build FA2 on sm_110, where the chunked window needed it
LiangSu8899 Jul 31, 2026
b0167d5
Cover the rest of the prefill kernels
LiangSu8899 Jul 31, 2026
0c38caa
Read the conv1d input once instead of once per output
LiangSu8899 Jul 31, 2026
0dde39f
Cover the row-blocked conv1d
LiangSu8899 Jul 31, 2026
a61e874
Let the chunked conv read its history instead of concatenating it
LiangSu8899 Jul 31, 2026
bdf5ddb
Give the gate-and-quantise a lane per scale-factor group
LiangSu8899 Jul 31, 2026
772df15
Narrow the grouped GEMM's N tile to the shape a prefill routes
LiangSu8899 Aug 1, 2026
816ddca
Stop letting a timing loop choose the dense GEMM's algorithm
LiangSu8899 Aug 1, 2026
d07dcc4
Feed the draft head its two halves the right way round
LiangSu8899 Aug 1, 2026
50258b8
Verify a speculative window against the weights decode reads
LiangSu8899 Aug 1, 2026
4eb09e7
Seed the draft head's hidden state with the prompt's last position
LiangSu8899 Aug 1, 2026
fc894f3
Run a speculative window on the decode kernels, at window rows
LiangSu8899 Aug 1, 2026
69a532f
Sum a token's experts in a fixed order in every prefill path
LiangSu8899 Aug 1, 2026
3b17f98
Decline the K-row window wherever decode would compute something else
LiangSu8899 Aug 1, 2026
a3f6ef7
Derive the GDN gating constants once, not every step
LiangSu8899 Aug 1, 2026
2436b4f
Give decode the fused shared-gate combine, and make it exact first
LiangSu8899 Aug 1, 2026
45b767e
Stop the GDN recurrence spilling its state column to local memory
LiangSu8899 Aug 1, 2026
ec62002
Pick the router's top-8 in one warp instead of 24 barriers
LiangSu8899 Aug 1, 2026
900d115
Halve the W4A16 GEMV's loads in flight to buy back occupancy
LiangSu8899 Aug 1, 2026
d2149cc
Give the M-row GEMV the loads-in-flight the single-row one settled on
LiangSu8899 Aug 1, 2026
067389d
Fail loudly on an unsupported head dim, and make the numbers reproduc…
LiangSu8899 Aug 6, 2026
7231ad7
Scope the W4A16 loads-in-flight tuning to the target it was measured on
LiangSu8899 Aug 6, 2026
659b18e
Point the SM120 section at the same-shape measurement
LiangSu8899 Aug 6, 2026
b9c176f
Merge main into the Qwen3.6-35B-A3B edge runtime branch
LiangSu8899 Aug 6, 2026
b90830e
Put Qwen3.6-35B-A3B in the README performance tables
LiangSu8899 Aug 6, 2026
ae0a68c
Gate the qwen3_5_moe additions behind their own build tiers
LiangSu8899 Aug 6, 2026
4471c4a
Ask for the deterministic cuBLASLt pick per call, not per process
LiangSu8899 Aug 6, 2026
3b05ccc
Register Qwen3.6 for Thor and give speculation a supported API
LiangSu8899 Aug 6, 2026
36922e3
Make the docs say what the code does
LiangSu8899 Aug 6, 2026
50561bb
Check the speculative contract and the tier gates in the repository
LiangSu8899 Aug 6, 2026
8a2c1b4
Cover the shared path's defaults against a second model's changes
LiangSu8899 Aug 6, 2026
41cb547
Quote the Thor numbers with the protocol each was taken under
LiangSu8899 Aug 6, 2026
4515f6b
Say which tree each Thor decode figure was taken on
LiangSu8899 Aug 6, 2026
ff7795a
Make the window-vs-decode-step test exercise the window
LiangSu8899 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 208 additions & 37 deletions CMakeLists.txt

Large diffs are not rendered by default.

40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,32 @@ DGX Spark / GB10:
| NVFP4, 128 | **170.1 ms** | **40.42 tok/s** | [Qwen3.6 Spark](docs/qwen36_spark.md#performance) |
| NVFP4, 16 K | **8.545 s** | **54.94 tok/s** | [Qwen3.6 Spark](docs/qwen36_spark.md#performance) |

#### Qwen3.6-35B-A3B

RTX 5090:

| Mode | Prefill | Decode | Source |
|---|---:|---:|---|
| NVFP4, 64 | **40.42 ms** | **257.95 tok/s** | [Qwen3.6-MoE usage](docs/qwen36_moe_usage.md#validation) |

Jetson AGX Thor, against vLLM 0.26.0 on the same part and protocol:

| Mode | Prefill | Decode | Source |
|---|---:|---:|---|
| NVFP4, 20 | **89.5 ms** (vLLM 102.3) | | [Qwen3.6-MoE Thor](docs/qwen36_moe_usage.md#jetson-agx-thor-numbers) |
| NVFP4, 1 K | **216.0 ms** (vLLM 319.4) | **87.1 tok/s** (vLLM 31.6) | [Qwen3.6-MoE Thor](docs/qwen36_moe_usage.md#jetson-agx-thor-numbers) |
| NVFP4, 2 K | **379.6 ms** (vLLM 495.0) | **86.3 tok/s** (vLLM 31.5) | [Qwen3.6-MoE Thor](docs/qwen36_moe_usage.md#jetson-agx-thor-numbers) |
| NVFP4, 32 K | **7207.5 ms** (vLLM 7231.8) | | [Qwen3.6-MoE Thor](docs/qwen36_moe_usage.md#jetson-agx-thor-numbers) |

TTFT leads at every length from 20 to 32768 tokens; 128 K context reaches the
board at 2470 tok/s of prefill. The decode column was taken before a later
round that moved the steady step from 89.0 to 102.6 tok/s, and vLLM's side is
unaffected by it, so the ratios shown are lower bounds.

Speculative decode with the MTP head reaches **106.74 tok/s** against 100.35
plain in the same process, emitting the same tokens as greedy decoding. See
[speculative decode](docs/qwen36_moe_usage.md#speculative-decode).

#### Qwen3-8B

| Hardware | Mode | Prefill | Decode | Source |
Expand Down Expand Up @@ -732,7 +758,7 @@ extension modules:
| Artifact | Size | What it contains |
|---|---|---|
| `flash_rt/flash_rt_kernels.so` | ~3 MB | Hand-written memory-bound kernels (norm, activation, fusion, FP8 quant, cuBLASLt wrappers, Thor FMHA). **Always built.** |
| `flash_rt/flash_rt_fa2.so` | ~135 MB | Vendored Flash-Attention 2 v2.7.4.post1 fwd (fp16 + bf16, SM80/86/89/120). **Built only on RTX targets** Thor skips it and uses `fvk.attention_qkv_fp16` (cuBLAS-decomposed) for attention instead. |
| `flash_rt/flash_rt_fa2.so` | ~135 MB | Vendored Flash-Attention 2 v2.7.4.post1 fwd (fp16 + bf16, SM80/86/89/120). **Built automatically on RTX targets.** Thor skips it by default and uses `fvk.attention_qkv_fp16` (cuBLAS-decomposed) instead; `-DFLASHRT_ENABLE_THOR_FA2=ON` builds it there for the one model whose long prefill needs it (Qwen3.6, bf16 head_dim 256 — a single instantiation). |

**Crucially — no `pip install flash-attn` required.** The FA2 kernel
is vendored at source level and built into `flash_rt_fa2.so` during
Expand Down Expand Up @@ -862,18 +888,20 @@ CMake reads `nvidia-smi --query-gpu=compute_cap` to pick the target
arch. Override for cross-compilation or when auto-detect fails:

```bash
cmake -B build -S . -DGPU_ARCH=110 # Jetson AGX Thor (FA2 skipped, CUTLASS SM100 path ON)
cmake -B build -S . -DGPU_ARCH=110 # Jetson AGX Thor (FA2 opt-in, CUTLASS SM100 path ON)
cmake -B build -S . -DGPU_ARCH=121 # DGX Spark / GB10 (FA2 sm_121 AOT, NVFP4 ON)
cmake -B build -S . -DGPU_ARCH=120 # RTX 5090 (FA2 sm_120 AOT, NVFP4 ON)
cmake -B build -S . -DGPU_ARCH=89 # RTX 4090 (FA2 sm_80 AOT natively runs on Ada)
cmake -B build -S . -DGPU_ARCH=86 # RTX 3090 / A10 (FA2 sm_80 AOT)
cmake -B build -S . -DGPU_ARCH=80 # A100 (FA2 sm_80 AOT)
```

FA2 is enabled by CMake when `GPU_ARCH ∈ {80, 86, 89, 120, 121}`. Other
arches (notably Thor SM110 and SM90 Hopper) route attention through
the cuBLAS-decomposed `fvk.attention_qkv_fp16` path instead of FA2 —
`flash_rt_fa2.so` simply isn't built, and no runtime error results.
FA2 is enabled by CMake when `GPU_ARCH ∈ {80, 86, 89, 120, 121}`, and on
Thor SM110 when `-DFLASHRT_ENABLE_THOR_FA2=ON` is passed. Other arches
(notably SM90 Hopper, and Thor without that flag) route attention
through the cuBLAS-decomposed `fvk.attention_qkv_fp16` path instead of
FA2 — `flash_rt_fa2.so` simply isn't built, and no runtime error
results.

### Build timing (one-time)

Expand Down
139 changes: 139 additions & 0 deletions benchmarks/qwen36_moe_edge_decode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""First-light latency probe for the Qwen3.6-MoE (qwen3_5_moe) edge path.

Reports the numbers quoted in ``docs/qwen36_moe_usage.md``: weight load time,
resident and peak allocation, prefill latency, and decode throughput on the
eager and the captured-graph paths. The two decode paths are compared token for
token, because a throughput number for a path that emits different text is not
a throughput number for the same work.

Usage:

PYTHONPATH=. python benchmarks/qwen36_moe_edge_decode.py \\
--checkpoint /path/to/Qwen3.6-35B-A3B \\
--prompt-tokens 64 --max-new-tokens 32
"""

from __future__ import annotations

import argparse
import statistics
import time

import torch

GIB = 2 ** 30


def _sync(device: str) -> None:
torch.cuda.synchronize(device)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", required=True,
help="path to the BF16 checkpoint directory")
parser.add_argument("--prompt-tokens", type=int, default=64)
parser.add_argument("--max-new-tokens", type=int, default=64)
parser.add_argument("--prefill-reps", type=int, default=5)
parser.add_argument("--decode-reps", type=int, default=8)
parser.add_argument("--max-seq", type=int, default=512)
parser.add_argument("--device", default="cuda:0")
args = parser.parse_args()

from flash_rt.frontends.torch.qwen36_moe import Qwen36MoeTextFrontend

# Select and initialise the device before touching the memory stats: they
# are per-device counters and are not addressable until then.
torch.cuda.set_device(args.device)
torch.cuda.init()
torch.cuda.reset_peak_memory_stats(args.device)
t0 = time.perf_counter()
frontend = Qwen36MoeTextFrontend(
args.checkpoint, device=args.device, max_seq=args.max_seq)
_sync(args.device)
load_s = time.perf_counter() - t0

print(f"runtime weight load {load_s:8.2f} s")
print(f"resident allocated after load "
f"{torch.cuda.memory_allocated(args.device) / GIB:8.2f} GiB")
print(f"peak allocated during load "
f"{torch.cuda.max_memory_allocated(args.device) / GIB:8.2f} GiB")

base = frontend.tokenizer.encode(
"The quick brown fox jumps over the lazy dog. ")
ids = (base * (args.prompt_tokens // len(base) + 2))[:args.prompt_tokens]

# Prefill: the first call carries warmup and lazy weight packing, so it is
# reported separately rather than averaged into the steady-state figure.
frontend.set_prompt_ids(ids)
_sync(args.device)
t0 = time.perf_counter()
frontend.generate(max_new_tokens=1)
_sync(args.device)
first_ms = (time.perf_counter() - t0) * 1e3

warm = []
for _ in range(args.prefill_reps):
frontend.set_prompt_ids(ids)
_sync(args.device)
t0 = time.perf_counter()
frontend.generate(max_new_tokens=1)
_sync(args.device)
warm.append((time.perf_counter() - t0) * 1e3)

print(f"first prefill, including warmup {first_ms:8.2f} ms")
print(f"subsequent prefill "
f"{min(warm):8.2f}-{max(warm):.2f} ms")

def run(fn) -> tuple[list[float], list[int]]:
# Median and range over every repetition, not a best-of: a single best
# sample hides both contention and variance, and the baseline this is
# compared against reports the same shape.
rates, toks = [], None
for _ in range(args.decode_reps):
frontend.set_prompt_ids(ids)
_sync(args.device)
t0 = time.perf_counter()
out = fn()
_sync(args.device)
rates.append(args.max_new_tokens / (time.perf_counter() - t0))
toks = list(out)
return sorted(rates), toks

def report(label: str, rates: list[float]) -> None:
med = statistics.median(rates)
print(f"{label:<44}{med:8.2f} tok/s "
f"(range {rates[0]:.2f}-{rates[-1]:.2f} over {len(rates)} runs)")

state = frontend._decode_state
from flash_rt.frontends.torch import _nexn2_rtx_decode as dec

def eager():
t = torch.tensor(ids, dtype=torch.long, device=args.device)
with torch.no_grad():
return dec.generate_greedy(
state, t, args.max_new_tokens, frontend._fvk, args.device)

eager_rate, eager_toks = run(eager)
graph_rate, graph_toks = run(
lambda: frontend.generate(max_new_tokens=args.max_new_tokens))

report(f"{args.prompt_tokens}/{args.max_new_tokens} eager decode",
eager_rate)
report(f"{args.prompt_tokens}/{args.max_new_tokens} warm graph decode",
graph_rate)
free, total = torch.cuda.mem_get_info(args.device)
print(f"{'device free memory at exit':<44}{free / GIB:8.2f} GiB "
f"of {total / GIB:.2f} -- a shared device invalidates the timings")
same = eager_toks == graph_toks
print(f"eager and graph emit the same tokens {str(same):>8}"
f" ({sum(a == b for a, b in zip(eager_toks, graph_toks))}"
f"/{len(graph_toks)})")
if not same:
raise SystemExit("eager and captured decode disagree; "
"the throughput numbers are not comparable")


if __name__ == "__main__":
main()
Loading