Compare commits

...
Sign in to create a new pull request.

27 commits

Author SHA1 Message Date
danielhanchen
2b8a88aa76 qwen3_5 flex: fix bs=max_batch_size serialization regression
PageTable reserves batch_idx=0 as a no-op slot, so passing
``max_batch_size=N`` to ``PageTable.__init__`` yields only N-1
user-allocatable slots. When a user requests bs=N concurrent sequences,
the Nth sequence gets stranded in the ``waiting`` queue until the first
N-1 sequences finish, then runs serially. For bs=8 × 64 tokens this
split the decode into 63 iterations at B=7 followed by 63 at B=1 —
throughput collapsed from a predicted ~800 tok/s to 143 tok/s.

Fix: bump the internal PageTable capacity by 1 so the user's
max_batch_size maps to that many concurrent slots. All batch-indexed
buffers (input_pos_buffer, block_mask_logical, _linear_conv_states,
_linear_recurrent_states, _linear_caches) size accordingly. Slot 0
remains internally reserved; user-facing ``self.max_batch_size``
unchanged.

Throughput (Qwen3.5-4B, 128 new tokens, post-warmup):

| bs | pre-fix tok/s | post-fix tok/s | change |
|---:|---:|---:|---:|
|  1 | 191.2 | 191.5 | same |
|  2 | 338.1 | 339.4 | same |
|  4 | 548.6 | 542.0 | same |
|  8 | 143.5 | **804.7** | **5.6x** |

MoE Qwen3.6-35B-A3B bs=8 × 64: 38.2 → **305.2 tok/s** (8x).

Parity at bs=4 × 16 tokens: was 3/4 exact (P1 matched 13/16), now 4/4
exact — the serialization also exposed an extra bf16-drift path.
2026-04-23 23:44:46 +00:00
danielhanchen
bf391da1a2 qwen3_5 flex: bs=4 parity test for captured graph vs eager batched 2026-04-23 23:04:11 +00:00
danielhanchen
0058a5fd45 qwen3_5 flex: CUDA graph capture for batched decode
Add static per-layer conv/recurrent state buffers for the Gated DeltaNet
sub-layers and a batched decode path that gather/scatters through them.
The step is captured into a CUDA graph per bucket size; generate()
replays the matching graph when the active batch size exactly matches
a captured bucket, else falls back to the eager batched path.

Throughput (Qwen3.5-4B, 128 new tokens, post-warmup, B200):

| bs | pre-capture tok/s | post-capture tok/s | speedup |
|---:|---:|---:|---:|
|  1 |  20.1 | 191.2 | 9.5x |
|  2 |  20.5 | 338.1 | 16x (extrapolated) |
|  4 |  20.9 | 548.6 | 26x |

bs=8 is currently a regression (143 tok/s vs 549 at bs=4) — likely FLA
kernel sub-linear scaling in B. Tracked as follow-up.

Parity (flex-capture vs flex-eager-batched, bs=4 x 16 tokens greedy):
3/4 prompts token-exact, 1 prompt matches 13/16 tokens (bf16 drift).
2026-04-23 22:59:38 +00:00
danielhanchen
6abb05fbfc flex: Qwen3.5 / Qwen3.6 (dense + MoE) via FLA DeltaNet + flex_attention
Extend the flex inference engine to cover Qwen3.5 / Qwen3.6 hybrid
linear-attention architectures. 75% of layers are Gated DeltaNet
(linear_attention) and 25% are standard full_attention; Flex Attention
only expresses softmax attention so the engine dispatches per layer:

- linear_attention layers: HF's Qwen3_5GatedDeltaNet.forward runs as-is.
  With flash-linear-attention installed it dispatches to FLA's Triton
  kernels (``chunk_gated_delta_rule`` for prefill,
  ``fused_recurrent_gated_delta_rule`` for decode). Per-layer
  conv_state + recurrent_state live in a DynamicCache seeded from
  config.layer_types.
- full_attention layers: the standard ``Qwen3_5Attention.forward`` is
  swapped for a flex_attention + PageTable forward (same pattern as
  flex_qwen3_llama). Handles attn_output_gate (q_proj outputs 2x dim,
  chunk into query + gate, attn_output *= sigmoid(gate)), partial
  rotary (factor=0.25), per-head Q/K RMSNorm before RoPE.

New file: unsloth/inference/flex_qwen3_5.py defines
``FlexQwen3_5Inference`` + per-layer walker + patched attention
forward. Wired into flex_engine._detect_arch (new ``qwen3_5`` /
``qwen3_5_moe`` branches gated before plain ``qwen3``) and the
dispatch map. Added to the bind_peft_model MoE shortcut so the
double-copy LoRA pattern works on the MoE variant.

vision.py: ``qwen3_5`` / ``qwen3_5_moe`` added to the flex allowlist
for fast_inference=True + UNSLOTH_FAST_INFERENCE=1.

Smoke: Qwen/Qwen3.5-4B (dense, 4.6B, 32 layers / 8 full-attn) and
Qwen/Qwen3.6-35B-A3B (MoE, 35B, 40 layers / 10 full-attn / 256 experts
top-8) both load and generate coherent text on 3 chat prompts × 16
tokens. Outputs match the vLLM and HF-naive paths in style
("Thinking Process:..." instruct-tuned output); P0 / P2 bit-match HF
naive at 16 tokens on the 4B, P1 diverges after ~9 tokens (typical
bf16 greedy drift on high-entropy prompts).

Throughput (B200, bf16, 64 new tokens, with one warmup pass):
* Qwen3.5-4B flex bs=1: 20.1 tok/s  bs=4: 20.9 tok/s
  (vs vLLM 22.8 / 23.0 — within 10% on equivalent greedy decode,
  eager on both)

Follow-ups:
* CUDA graph capture for the decode step — currently deferred because
  the per-seq DeltaNet FLA call makes naive graph capture non-trivial.
  Would need batched FLA kernels or per-graph-slot state management.
* Packed-batch prefill — current impl runs one prefill per incoming
  request. Multi-prompt batching is possible by splitting the
  DeltaNet call into per-seq chunks but doesn't parallelise cleanly.
* torch.compile on the walker.
2026-04-23 22:31:23 +00:00
danielhanchen
f64f620dff vision: enable qwen3_5 + qwen3_5_moe under fast_inference=True
Add qwen3_5 (Qwen3.6-27B and 4B dense family) and qwen3_5_moe
(Qwen3.6-35B-A3B MoE family) to VLLM_SUPPORTED_VLM.

Also force enforce_eager=True + compilation_config=0 for these archs.
Same FX splitter bug as gemma4 — vLLM's -O3 inductor compile hits
`_decompose_size_nodes "Tried to erase Node size_N but it still had N
users"` on the hybrid DeltaNet / full-attention forward graph. Eager
preserves correctness at a small throughput cost; revert when the
upstream splitter handles these shape-dependent erasures.

Pair with unsloth-zoo patch_qwen3_5_vllm_lora_support which wires
get_expert_mapping / supports_lora onto the multimodal wrapper.
2026-04-23 14:53:05 +00:00
danielhanchen
944c417ab2 tests: add gemma4_flex_bench for flex fast_inference throughput
Throughput harness for the flex engine (UNSLOTH_FAST_INFERENCE=1) across
a batch-size sweep. Captures CUDA graphs per bucket and reports
post-warmup tok/s. Pairs with tests/gemma4_fast_inference_parity.py
(HF-naive parity) and tests/gemma4_fast_bench.py (vLLM nightly path) so
the three engines can be compared on the same prompt pool and new-token
budget.

Measured on B200 bf16 greedy, 64 new tokens, bs=16:

    E2B        645 tok/s   (28x HF naive 22.9)
    E4B        492 tok/s   (25x HF naive 19.7)
    31B        198 tok/s   (15x HF naive 13.5)
    26B-A4B    391 tok/s   (24x HF naive 16.2, 5.7x vLLM nightly 68.1)
2026-04-23 12:33:09 +00:00
danielhanchen
779040ddc8 tests: add gemma4_fast_inference_parity for vLLM-vs-HF greedy check
Regression test covering all four Gemma 4 variants (E2B / E4B dense, 31B
dense, 26B-A4B MoE) through FastLanguageModel(fast_inference=True). Runs
HF naive first, frees the GPU, then runs vLLM — loading vLLM first in the
same process leaves global state that perturbs the subsequent HF run's
numerics, producing false divergences. HF-first ordering gives bitwise
matches on all variants:

    E2B   62/62
    E4B   62/62
    31B   29/29
    26B-A4B MoE  34/34

Exits non-zero on any divergence so CI can gate on bitwise parity.
Requires vLLM nightly (>= 2026-04-17 for vllm#39291 Gemma 4 LoRA) and the
unsloth-zoo#603 vLLM Gemma 4 runtime patches.
2026-04-23 11:39:07 +00:00
danielhanchen
f950338330 vision: enable fast_inference for Gemma 4 via vLLM nightly
Add "gemma4" to VLLM_SUPPORTED_VLM so FastLanguageModel(fast_inference=True)
stops rejecting unsloth/gemma-4-E2B-it / gemma-4-26b-a4b-it at the
vision-model arch gate. vLLM nightly (vllm#39291, merged 2026-04-17)
registers Gemma4ForConditionalGeneration as SupportsLoRA; the
unsloth-zoo#603 vLLM patches (patch_gemma4_vllm_lora_support +
patch_gemma4_vllm_k_eq_v_support) wire LoRA + attention_k_eq_v support
on top of that.

Also force compilation_config=0 and enforce_eager=True for any gemma4
variant. vLLM's -O3 backend currently hits "Tried to erase Node size_N
but it still had N users" in _decompose_size_nodes when splitting the
Gemma 4 FX graph (both dense E2B audio path and MoE 26B-A4B language
path). Until the upstream FX splitter grows support for these size()
users, eager execution preserves correctness — bitwise token parity
with HF naive confirmed at 25/25, 4/4, 5/5 on 3 chat prompts × 32
tokens.
2026-04-23 10:40:45 +00:00
danielhanchen
749fb75bd0 flex: add Gemma 4 MoE inference support (bf16 + LoRA)
Extends flex fast-inference to `unsloth/gemma-4-26B-A4B-it` (30 layers,
128 experts top-k 8, H=2816, ~3.8B active of 25.2B). Mirrors the
FlexGptOssInference / FlexMoEInference template with Gemma 4-specific
wiring:

- Dual dense MLP + MoE per decoder layer (Gemma4TextMLP alongside
  Gemma4TextExperts; outputs summed before the residual add, then
  multiplied by the per-layer `layer_scalar` buffer).
- Per-layer sliding-window dispatch (25 sliding @ 1024 tokens, 5 full
  attention) via twin BlockMask built once per generate() entry.
- Two-tier RoPE: sliding layers use rope_theta=10K with full head_dim
  rotation; full-attn layers use rope_type=proportional with theta=1M
  and partial_rotary_factor=0.25 (the inv_freq's zero-padded tail makes
  the generic rotate_half a no-op on the unrotated dims, so a single
  rotary helper covers both).
- Per-head Q/K/V RMSNorm applied before RoPE / KV write.
- attention_k_eq_v=True on full-attn layers (v_proj is None): value is
  the raw k_proj output, followed only by v_norm (with_scale=False).
- Rebind Gemma4TextExperts.forward to forward_native_grouped_mm so
  decode uses the grouped_mm backend (the slow Python loop in the stock
  forward is neither fast nor CUDA-graph-capturable). The routing
  weights already include per_expert_scale via Gemma4TextRouter.forward,
  so no extra folding is needed.
- CUDA graph capture and UNSLOTH_FLEX_COMPILE_WALKER=1 inherit from the
  MoE template (single bucket ladder, single pool across buckets).

Arch detection:
- `_detect_arch` distinguishes dense vs MoE Gemma 4 via
  `text_config.num_experts > 1` (same class name covers both variants).
- `bind_peft_model` extends the MoE no-deepcopy shortcut to gemma4_moe.

Validation (B200, bf16, 3 chat prompts, 64 tokens):
- Dense 31B sanity check: coherent completion via existing
  FlexGemma4Inference.
- Flex (cudagraph) vs HF naive: 24/24, 2/2, 4/4 tokens bitwise match.
- Merge parity: 7/7 cases bitwise (rank 16 / 64, 1 + 2 adapters, bf16
  + fp32, E=128, 2I=1408, H=2816 / H, I=704).
- Throughput bs=8/16/32/48: 510 / 1030 / 1564 / 916 tok/s vs HF naive
  134 tok/s at bs=8 (3.8x-11.7x).
- GRPO smoke: DAPO-Math-17k, seed 3407, max_steps=5 — stable (see
  follow-up comment on PR).

Out of scope: E2B/E4B KV-shared + per-layer-input variants (guarded
with NotImplementedError); bnb-4bit stacked experts (no such class
ships for Gemma 4 today).
2026-04-23 08:28:53 +00:00
danielhanchen
51b673f3fa tests: extend flex_gpt_oss_parity with flex_eager + LoRA variants
Adds three things for a complete exactness audit:

- ``--backend flex_eager`` — monkeypatches capture_decode_cudagraph to
  a no-op so the engine takes the eager branch, same as we did in
  flex_moe_parity.py. Lets us compare flex-capture vs flex-eager.
- ``--lora_path`` — attaches a LoRA to either the flex or HF path
  before the greedy decode so we can check the merge
  (refresh_moe_lora_merge_from_pristine) against HF's peft forward.
- Default ``--max_new_tokens`` bumped 24 → 64 for stricter matching.

Result summary (B200, unsloth/gpt-oss-20b-BF16, 3 chat prompts):

- flex (capture) vs HF: 64/64 on all 3 prompts.
- flex eager vs HF: 41/64 + 64/64 + 64/64 (bf16 tile-selection drift
  on prompt 0, not a bug; cudagraph capture happens to pick the same
  tile as HF eager).
- flex (capture) + LoRA vs HF + LoRA: 64/64 + 48/64 + 64/64.

LoRA used is rank-16 with B=0 random-init-A so the merge is
mathematically a no-op — tests the wrapper + merge plumbing without
changing semantics. The merge formula itself was bitwise-verified in
tests/flex_moe_merge_parity.py (commit 835b346) on both standard and
transposed orientations at Qwen3 MoE shapes; transposed is what
gpt-oss uses.
2026-04-23 06:54:41 +00:00
danielhanchen
1b5ff90e49 flex: add gpt-oss inference support (bf16 + LoRA)
FlexGptOssInference mirrors FlexMoEInference with three arch-specific
pieces:

1. Attention sinks via flex_attention(return_lse=True) + post-softmax
   sigmoid(lse - sinks[h]) scaling. Same math as
   unsloth_zoo.flex_attention.attention_sink.
2. Per-layer sliding window: the walker passes both a full and a
   sliding-128 BlockMask; each attention forward picks one based on
   self.sliding_window.
3. gpt-oss rotary: first/second-half split with head_dim/2-sized
   cos/sin, not the Llama-style rotate_half on full-dim.

Reuses:
- refresh_moe_lora_merge_from_pristine for stacked-expert LoRA merge
  (transposed orientation branch covers gpt-oss's (E, H, 2I) layout)
- forward_native_grouped_mm — the GptOssExperts branch already handles
  the interleaved gate/up split + gate * sigmoid(gate * 1.702) activation

bnb-4bit: GptOssExpertsBnb4bit uses an nn.ModuleList per-expert loop
that can't be CUDA-graph-captured. __init__ detects it and disables
capture; decode still benefits from paged KV + flex_attention.

Also fixes a pre-existing deepcopy recursion in _LazyFlexEngineSentinel
that surfaced when the flex inference deepcopy ran on a model still
holding the sentinel (vision.py path doesn't pre-seed the inference
copy).

Bench on B200 (unsloth/gpt-oss-20b-BF16, max_new_tokens=64, chat):

| bs | HF naive | flex walker |
|---:|---------:|------------:|
|  8 |    258.9 |       832.1 |
| 32 |        — |      1595.3 |
| 48 |        — |      2146.4 |

3.2x over HF naive at bs=8. Parity: 24/24 greedy-token match vs HF on
3 chat prompts; cudagraph capture replay bitwise-matches eager decode.
2026-04-23 05:37:01 +00:00
danielhanchen
6ccc5ac4b8 tests: add --chat_template to vLLM + HF benches; switch vLLM to llm.chat
Chat-tuned models (gpt-oss) return gibberish on raw-string prompts. Added
an opt-in --chat_template flag that:
  - vLLM bench: switches to llm.chat(messages, ...) with harmony-aware
    templating through vLLM's chat_utils (raw apply_chat_template +
    llm.generate still produced gibberish on gpt-oss bf16).
  - HF naive bench: wraps each prompt in the tokenizer's chat template
    before tokenization, matching what the model was trained on.
  - --user_prompt {i} lets the per-prompt content vary for batched
    throughput runs.

Also adds --enforce_eager to the vLLM bench as a diagnostic knob (to
A/B cudagraph vs eager decode paths when a model misbehaves).
2026-04-23 04:53:33 +00:00
danielhanchen
835b346bd2 tests: flex_moe_merge_parity.py — numerical + perf check for MoE LoRA merge
Standalone test that verifies refresh_moe_lora_merge_from_pristine
produces the same W_inf as the textbook reference
``W_ref[e] = W_pristine[e] + scaling * B[e] @ A[e]`` for both standard
(E, 2I, H) and transposed (E, H, 2I) stacked expert layouts, single and
multi-adapter, fp32 + bf16. All 8 cases bitwise-match.

Also benchmarks the batched torch.baddbmm path against (a) a per-expert
torch.addmm loop of equivalent arithmetic and (b) the dense-layer
torch.addmm used by refresh_lora_merge_from_pristine. At Qwen3-30B-A3B
MoE shapes on B200: baddbmm is 5-9.6x faster than the addmm loop; its
per-expert amortized cost is 3.6-7.1x cheaper than the dense addmm
baseline — baddbmm amortizes kernel launch and grid setup across
E=128 experts in one kernel call.
2026-04-23 04:23:09 +00:00
danielhanchen
df7b216073 tests: flex_moe_vllm_bench.py — direct vLLM reference for Qwen3 MoE decode
Mirrors the flex_moe_bench workload using vllm.LLM directly (no unsloth).
Supports bf16, 4bit (bitsandbytes on-the-fly), and FP8 (via the -FP8
checkpoint variant). Adds --enable_lora + --lora_path for LoRA serving
comparison.

DeepGEMM isn't installed in this env and vLLM's FP8 warmup crashes the
engine-core subprocess without it, so the script sets VLLM_USE_DEEP_GEMM=0
before importing vllm.

VRAM is queried via nvidia-smi because vLLM's allocator bypasses
torch.cuda.memory_reserved.
2026-04-23 02:59:47 +00:00
danielhanchen
2feab3f6b6 tests/flex_moe_micro_bench: coord_descent compile_opts profile
Adds ``--compile_opts coord_descent`` that sets
``torch._inductor.config.coordinate_descent_tuning = True`` alone.
Tested; it regresses 4bit at bs=48 from 3383 → 2147 tok/s so
``coord_descent`` alone is not worth shipping. Keeping the option
in the bisection bench so the regression stays reproducible.
2026-04-22 18:31:15 +00:00
danielhanchen
a1bc5cbd73 flex/moe: UNSLOTH_FLEX_GRAPH_BS env var to override capture bucket ladder
The default ``graph_bs = [1, 2, 4, 8] + range(16, max_bs+1, 16)``
matches the dense FlexInference pattern and is optimal for
power-of-2 batch sizes. For workloads that routinely hit
intermediate sizes (e.g. bs=24, 40, 56), capturing dedicated
buckets improves replay efficiency ~5-10% on those sizes
(they otherwise round up to the next power-of-2 bucket).

Measurements on Qwen3-30B-A3B 4bit, bs=24/40/56 with
compile_walker=True:

| bs | default ladder (round-up)     | UNSLOTH_FLEX_GRAPH_BS=8,16,24,32,40,48,56,64 |
|----|------------------------------:|---------------------------------------------:|
| 24 | (rounds to 32, ~1800 tok/s)   |                                 1836 tok/s  |
| 40 | (rounds to 48, ~2750 tok/s)   |                                 2772 tok/s  |
| 56 | (rounds to 64, ~2700 tok/s)   |                                 3797 tok/s  |

The fine ladder costs a few extra capture calls at startup and
slightly larger CUDA graph pool footprint. Power-of-2 bucket
throughput is marginally lower with the fine ladder (memory pool
is shared across more captures), so it's opt-in via env var.

Invalid values fall back to the default ladder with a log.
2026-04-22 18:10:31 +00:00
danielhanchen
cc4832b605 flex/moe: compile_walker option — 2x decode throughput on Qwen3 MoE
Optional ``FlexMoEInference(compile_walker=True)`` / env var
``UNSLOTH_FLEX_COMPILE_WALKER=1`` wraps the decode walker
(``call_moe_model_with_flex_kwargs``) with
``torch.compile(fullgraph=False, dynamic=False)`` before the CUDA
graph capture kicks in. Inductor fuses the layernorm + residual +
router pointwise ops, and the compiled kernels end up recorded
inside the captured graph. Net: ~2x decode tok/s on the grouped_mm
path with no change in VRAM, no correctness regression, and no
user-facing API change unless the flag is set.

Numbers on Qwen3-30B-A3B-Instruct-2507, B200, 128 new tokens,
bs sweep, median of 2 timed rounds after 1 warmup:

| precision | bs | baseline (v2) | + compile_walker | speedup |
|-----------|---:|--------------:|-----------------:|--------:|
| 4bit      | 16 |           699 |           1347.8 |   1.93x |
| 4bit      | 32 |          1243 |           2423.9 |   1.95x |
| 4bit      | 48 |          1735 |         3383.2   |   1.95x |
| 4bit      | 64 |          1523 |           2981.9 |   1.96x |
| bf16      | 16 |             — |           1401.1 |       — |
| bf16      | 32 |             — |           2864.0 |       — |
| bf16      | 48 |             — |       **3911.1** |       — |

Peak throughput: 3911 tok/s at bf16 bs=48 — 39x the pure-HF naive
baseline on the same workload (101.1 tok/s with
``AutoModelForCausalLM`` + eager attn, left-padded, no unsloth).
At 4bit bs=48, 51x the pure-HF naive baseline (66.7 tok/s).

GRPO 5-step validation (Qwen3_MoE_GRPO.py --backend flex
--max_steps 5 on DAPO-Math-17k):

| precision | baseline (v2) | + compile_walker | speedup |
|-----------|--------------:|-----------------:|--------:|
| 4bit      |        548.3s |           434.5s |   1.26x |
| bf16      |        451.8s |       **407.4s** |   1.10x |

Peak VRAM unchanged (130-133 GB). Loss / KL stable on both, no
NaN, rewards pegged at -7.5 (base-model artifact; orthogonal).

Parity (greedy 32 tokens × 3 prompts at bf16 and 4bit via
``FLEX_MOE_COMPILE_WALKER=1 tests/flex_moe_parity.py``):
flex-captured with the compile wrap matches flex-captured without
the compile wrap on 6/6 prompts with no gibberish, and matches pure
``transformers.AutoModelForCausalLM`` 32/32 on 5 of 6 (prompt ×
precision) pairs (the one divergence is a tie-break logit boundary
on an open-ended continuation — both coherent English).

Bisection of a few torch.compile flag sets against the default at
bs=32 4bit (max_batch_size=32):

| config                                                     |  tok/s |
|------------------------------------------------------------|-------:|
| default (``torch.compile(fullgraph=False, dynamic=False)``)| 1581.5 |
| + max_autotune + coord_descent + aggressive_fusion         | 1704.9 |
| + ``freezing=True``                                        |  935.7 |

``freezing=True`` is a regression on this path; shipping with the
default config only. The other flags are +7.8% at this size but
at large bs (48+) the max_autotune variant timed out during
compile (>40 min) so the default stays the ship-target for now.

Other attention backends don't help on B200 today:
- pure HF with ``attn_implementation="sdpa"``: cuDNN Frontend error
  ("No valid execution plans built") on sm_100 + torch 2.11.
- ``flash_attention_2`` 2.8.3: works, but kernels compiled for
  sm_80/sm_90 only — slower than eager on B200 (46.7 / 67.9 tok/s
  vs eager 66.7 / 101.1 at 4bit / bf16).
- ``flash_attention_3``: ``no kernel image for execution on the
  device`` — sm_100 kernels not yet in flash_attn_interface.
- FA4 / ``flash_attention_4``: works standalone but transformers'
  integration hard-codes ``flash_attn_with_kvcache = None`` for it,
  so it can't service decode. Prefill-only, out of scope here.

New tests:
- ``tests/flex_moe_micro_bench.py``: tight probe that loads the
  model once, sweeps batch sizes, prints a sample completion per
  bucket (catches gibberish early). Supports ``--compile_mode
  {off, walker, walker_fullgraph}`` and ``--compile_opts
  {stock, unsloth_O3, inference_freeze}``.
- ``tests/flex_moe_bench.py``: add ``--backend hf_naive`` which
  imports pure ``transformers`` (no ``import unsloth``) for the
  reference HF baseline, with ``HF_ATTN_IMPL`` env var to switch
  between eager / sdpa / flash_attention_{2,3,4}.
- ``tests/flex_moe_parity.py``: add ``FLEX_MOE_COMPILE_WALKER=1``
  env var to exercise the compile wrap through the parity harness.
2026-04-22 17:27:41 +00:00
danielhanchen
80aaf1121b flex/moe: CUDA graph decode for Qwen3 MoE on grouped_mm backend
Qwen3-30B-A3B decode on bs=8/64-new-tokens jumps from 55.6 tok/s
(eager) to 374.3 tok/s at 4bit / 421.6 tok/s at bf16 — 4-7x over pure
Hugging Face generate (66.7 / 101.1 tok/s) on B200. Output is bit-exact
against eager decode on all 32 greedy tokens across 3 prompts.

FlexMoEInference.capture_decode_cudagraph now mirrors the dense
FlexInference capture pattern: reserve a scratch page per batch_idx
slot, allocate static input_ids / batch_idx / outputs buffers, capture
one graph per bucket in [1, 2, 4, 8] + range(16, max_bs+1, 16), share
the CUDA memory pool across buckets, erase scratch pages at the end.

_decode_step replays on subsequent calls: zero graph buffers (except
outputs), copy live input_ids / batch_idx into the pinned buffers,
graph.replay(), slice outputs. Matches flex_qwen3_llama.py:725-801
verbatim.

Capture is gated on select_moe_backend() == "grouped_mm". Other
backends (unsloth_triton, native_torch) print a warning and leave
cudagraph_captured = False so generate() silently falls back to eager.
The legacy NotImplementedError rationale — torch.where + Python
for-loop over experts — only applied to native_torch; grouped_mm's
bincount + cumsum + argsort + torch._grouped_mm + index_add_ path is
fully capture-friendly on H100/B200.

FlexEngine.__init__ drops the blanket capture_cudagraph=False force
for qwen3_moe arch since capture correctness is now backend-aware.

tests/flex_moe_bench.py gains a hf_naive backend that imports pure
transformers (no unsloth patches) for a clean "fast inference vs naive
HF" comparison: flex 374.3 vs 66.7 tok/s at 4bit = 5.6x, flex 421.6
vs 101.1 tok/s at bf16 = 4.2x.

tests/flex_moe_parity.py adds greedy-decode parity across three
prompts with flex-captured / flex-eager / HF-pure. Results at bf16:
flex-captured matches flex-eager 32/32 on all three prompts (CUDA
graph is numerically identical to eager), and matches HF pure
transformers 32/32 on 2/3 prompts with the remaining divergence at a
tie-break logit boundary.

Pairs with an unsloth-zoo PR dropping @torch.compiler.disable on the
sparse MoE block and replacing torch.bincount with a capture-safe
scatter_add_ — without those, capture trips on a CPU→CUDA scalar copy
inside bincount(minlength=python_int).
2026-04-22 14:58:19 +00:00
danielhanchen
4f2bfe7f69 flex/moe: flex_moe_bench.py + training-path tuple defensive unpack
Adds tests/flex_moe_bench.py: 2-round median decode throughput bench
comparing flex (FlexMoEInference) and HF generate on the same
(n_prompts, max_new_tokens, precision) workload. Writes
async_task_outputs/qwen3_moe_grpo_bench/bench_decode_{backend}_{precision}.json.

Also defensively unpacks self.mlp(hidden_states) in
Qwen3MoeDecoderLayer_fast_forward's training branch:
unsloth_zoo.temporary_patches.qwen3_moe.sparse_moe_block_forward
returns a plain tensor for transformers 5.x stacked experts, but the
decoder wrapper unpacked a 2-tuple. The inference branch was already
fixed in the previous commit; the training branch hit the same
ValueError under plain HF generate (no _flag_for_generation).

Bench numbers (Qwen3-30B-A3B, 4bit, rank 16 LoRA, bs=8, 64 new tokens,
B200):

| backend | median tok/s | peak VRAM (GB) | median wall (s) |
|---------|--------------|----------------|------------------|
| HF      | 80.5         | 57.2           | 6.36             |
| Flex    | 55.6         | 116.0          | 9.21             |

Flex is correctness-complete but not yet performance-competitive on
MoE decode at bs=8. Two structural reasons:

- MoE decode runs eager (forward_moe_backend uses bincount + Python
  expert loops which are not CUDA-graph capturable), so flex loses
  its main dense-model advantage.
- FlexEngine deep-copies the HF model for the rollout copy, doubling
  weight residency. For Qwen3-30B-A3B at bf16 that is ~60 GB extra.
  The pristine-base third copy is skipped for Qwen3 MoE (see the
  first commit of this series) but the inference deep-copy remains.

Follow-ups (not blockers for correctness):

- torch.compile(dynamic=True) on call_moe_model_with_flex_kwargs to
  recover some of the CUDA-graph throughput without requiring graph
  capture.
- Evaluate flex's scaling vs HF generate at bs=32 / bs=64, where
  paged-KV reuse should dominate per-prompt cost.
- A quantised-only inference copy (4bit forward, fp32 LoRA injection)
  so the flex path fits inside 2x 4bit weight residency (~34 GB)
  instead of the current post-dequantisation footprint.
2026-04-22 12:36:18 +00:00
danielhanchen
6a1bef2c88 flex/moe: import_fixes.fix_trl_vllm_ascend + Qwen3 MoE inference mlp delegate
Four pre-existing integration blockers hit while wiring Qwen3 30B A3B
MoE + GRPO end-to-end. All four must be fixed for the smoke to run; none
depend on the FlexMoEInference work itself but they surface because
fast_inference=True is the first path where the full MoE + GRPO chain
gets exercised on current transformers 5.x.

1. unsloth/import_fixes.py + unsloth/__init__.py: port fix_trl_vllm_ascend
   from #5129 onto this branch so `from trl import GRPOConfig,
   GRPOTrainer` works without installing vllm_ascend. transformers 4.48
   changed _is_package_available to a tuple, and TRL's module-level
   _*_available caches remain truthy on "not installed" hosts, which
   then triggers an unconditional `import vllm_ascend` on GRPO import.

2. unsloth/models/qwen3_moe.py FastQwen3MoeModel.pre_patch: do NOT
   overwrite Qwen3MoeSparseMoeBlock.forward with the legacy
   Qwen3MoeSparseMoeBlock_fast_forward. That fast_forward expects a
   flat self.gate_proj attribute which no longer exists on transformers
   5.x stacked-expert MoE blocks (self.gate + self.experts).
   unsloth_zoo's patch_qwen3_moe installs the correct
   sparse_moe_block_forward at TEMPORARY_PATCHES init time; the
   override here stomps on that with a broken function and causes
   AttributeError('gate_proj') during training.

3. unsloth/models/qwen3_moe.py Qwen3MoeDecoderLayer_fast_forward (inference
   path): replace the direct call to Qwen3MoeSparseMoeBlock_fast_forward
   with self.mlp(...) so the class-level (unsloth_zoo-patched) forward
   runs instead of the broken legacy path. Unpack the (hidden_states,
   router_logits) tuple defensively in case a downstream patch returns
   a plain tensor.

4. unsloth/models/llama.py LlamaModel_fast_forward_inference_custom:
   delegate MoE MLP to decoder_layer.mlp(X) and skip
   mlp_fast_forward_inference when the block has no gate_proj /
   up_proj / down_proj attribute. Without this, every
   model.generate(...) call on a Qwen3 MoE model (e.g. TRL GRPO's
   use_vllm=False rollout) crashes in fast_swiglu_inference trying to
   read Qwen3MoeSparseMoeBlock.gate_proj.

Smoke-B parity (max_steps=20, 4bit, num_generations=2, seed 3407,
Qwen3-30B-A3B, DAPO-Math-17k, GPUs 2 and 3):

| metric              | naive (fast_inference=False) | flex (fast_inference=True) |
|---------------------|------------------------------|----------------------------|
| train_runtime (s)   | 2745.97                      | 2746.63                    |
| peak VRAM (GB)      | 66.4                         | 136.1                      |
| loss step 1 / step 20 | 1192 / 4.65e-06            | 3.41 / 4.01e-06            |
| KL  step 1 / step 20  | 1.19e6 / 0.00465           | 3.41e3 / 0.00401           |

Both runs exit cleanly, both converge KL from a huge initial spike
down to ~0.004 by step 20, both see identical reward saturation at
-7.5 (base model needs the SFT format-priming step the reference
Qwen3_(4B)-GRPO.ipynb does before GRPO; our smoke skipped that for
speed). Shapes track each other; absolute magnitudes differ because
the flex deep-copy is allocated from a different stream and the PEFT
adapter is initialised in a different order.
2026-04-22 12:22:57 +00:00
danielhanchen
a1aec618ce flex: fix Qwen3 MoE smoke regressions (dtype / MoE MLP / peft patching)
Four integration fixes wired up while bringing Qwen3-30B-A3B-Instruct-2507
green end-to-end on UNSLOTH_FAST_INFERENCE=1:

1. unsloth/models/llama.py patch_peft_model: transformers 5.x reports
   model_type as "qwen3_moe" (with underscore); the PR's check was
   "qwen3moe" and fell through to NotImplementedError.

2. unsloth/models/llama.py patch_peft_model dense MLP patching: the
   fused gate/up/down LoRAMLP swap walks layer.mlp.gate_proj, which is
   a Qwen3MoeSparseMoeBlock for MoE and has no gate_proj attribute.
   Skip the swap when the MLP does not expose the dense trio; MoE
   LoRA is wired through unsloth_zoo/moe_utils anyway.

3. unsloth/inference/flex_qwen3_llama.py flex attention forward:
   bnb-4bit Linear compute produces fp32 k / v even under autocast,
   which makes the paged KV index_put_ refuse the mixed dtype (bf16
   cache, fp32 update). Cast k / v to self._paged_cache.k_cache.dtype
   before update. Also benefits the dense path.

4. unsloth/inference/flex_engine.py bind_peft_model: for Qwen3 MoE the
   ParamWrapper keeps LoRA un-merged on the stacked expert tensors, so
   the training model's expert weights ARE the pristine source. Skip
   the pristine-base deep-copy for arch=="qwen3_moe" and point
   refresh_moe_lora_merge_from_pristine at the training base directly.
   Avoids a third 30-60 GB residency on 30B-A3B.

5. unsloth/inference/flex_moe.py call_moe_model_with_flex_kwargs: lock
   activations to the embed dtype across layernorm + MoE MLP; RMSNorm
   + bnb-4bit compute promote activations to fp32 along the MoE path
   under autocast. Also force-restore Qwen3MoeSparseMoeBlock.forward
   to the stock or unsloth_zoo version if FastQwen3MoeModel.pre_patch
   clobbered it with a legacy Qwen3MoeSparseMoeBlock_fast_forward that
   expects a flat self.gate_proj (which does not exist on transformers
   5.x stacked-expert MoE blocks).

Adds tests/flex_moe_smoke.py: generates 32 tokens twice (cold + warm),
records first-call / warm-call tokens/s, peak VRAM, arch, impl. Writes
async_task_outputs/qwen3_moe_grpo_bench/smoke_A_{4bit,bf16}.json.

Measured on a single B200 (sm_100), Qwen3-30B-A3B-Instruct-2507 +
LoRA rank 16 + grouped_mm MoE backend:

| precision | t_load (s) | peak VRAM (GB) | cold tok/s | warm tok/s |
|-----------|------------|----------------|------------|------------|
| 4bit      | 27.0       | 123.4          | 3.7        | 6.9        |
| bf16      | 33.5       | 125.9          | 3.7        | 6.7        |

Both completions coherent ("the lazy dog. ...").
2026-04-22 10:46:44 +00:00
danielhanchen
f0115f8d70 flex: add FlexMoEInference for Qwen3 MoE under UNSLOTH_FAST_INFERENCE=1
FastLanguageModel.from_pretrained(model_name=\"unsloth/Qwen3-30B-A3B-...\",
fast_inference=True) was silently routing Qwen3MoeForCausalLM to the
dense FlexInference path because _detect_arch matched the \"qwen3\"
substring first. That path works for attention but drops all MoE LoRA
adapters at rollout time: refresh_lora_merge_from_pristine walks
named_modules for LoraLayer instances and calls
base_model.get_submodule(name).weight.data, which does not see the
stacked nn.Parameter tensors on Qwen3MoeExperts.gate_up_proj /
down_proj. Decode capture also breaks on bincount + Python expert
loops inside forward_moe_backend.

Adds unsloth/inference/flex_moe.py:

- call_moe_model_with_flex_kwargs: Qwen3 MoE decoder walker. Identical
  to the dense walker for the attention half. Unpacks the mlp(...)
  return for both stock HF (plain tensor) and Unsloth's patched
  Qwen3MoeSparseMoeBlock_fast_forward (tuple of
  (hidden_states, router_logits)).
- FlexMoEInference: API-compatible with FlexInference so the arch
  dispatch is a one-line change. cudagraph_captured is permanently
  False; capture_decode_cudagraph raises NotImplementedError so a stray
  capture_cudagraph=True fails loudly rather than producing silently
  wrong output.
- refresh_moe_lora_merge_from_pristine: batched torch.baddbmm LoRA
  fuse over stacked 3D expert tensors. Handles both standard
  (E, 2*I, H) and transposed (E, H, 2*I) orientations via a runtime
  shape check against the flat lora_A / lora_B shapes. In-place write
  so flex prefill and paged-KV replay see refreshed values.

Wires the new class into the engine:

- flex_engine._detect_arch: check \"qwen3moe\" / \"qwen3_moe\" BEFORE
  the dense \"qwen3\" substring (Qwen3MoeForCausalLM contains both).
- flex_engine.FlexEngine.__init__: route arch==\"qwen3_moe\" to
  FlexMoEInference and force self.capture_cudagraph = False so the
  MoE expert loops are never captured.
- inference.__init__: export FlexMoEInference.
- models/loader.py: uncomment the qwen3_moe branch so FastQwen3MoeModel
  applies training-side patches before FlexEngine wraps the model.

Flex attention / paged KV / block-mask / sampling-param shim / vLLM
shim / sleep-wake are reused verbatim.
2026-04-22 10:07:56 +00:00
pre-commit-ci[bot]
916d205ace [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-21 14:58:27 +00:00
Daniel Han
49acbdf6bd inference: cuMem sleep/wake + lazy FlexEngine batch sizing
Follow-up to 35231d4f (initial flex backend wiring).

Sleep/wake:
- sleep_mode.py adds kv_cache_pool / weight_pool context managers
  backed by vLLM's CuMemAllocator. Activated when UNSLOTH_VLLM_STANDBY=1
  and vLLM is importable; no-op otherwise so TRL's unconditional
  sleep / wake_up calls stay valid.
- FlexEngine routes the inference deep-copy + per-layer PagedKVCache
  through the pools. Captured CUDA graphs survive a sleep -> wake
  round-trip because cuMem keeps the GPU virtual addresses stable.
- 4-bit single-copy path drops only the KV cache; level 2 warns and
  falls back to level 1.
- tests/flex_sleep_mode_smoke.py covers sleep / wake memory deltas,
  captured-graph survival, and the no-op path.

Lazy batch sizing:
- FlexEngine's max_batch_size drives fixed-shape page tables, the
  input_pos_buffer, the block_mask_logical build, and the CUDA-graph
  bucket list at __init__ time. There is no post-init resize, so
  picking it at from_pretrained time forces over- or under-shoot.
- build_flex_engine() defers construction until the GRPO rollout shape
  is known. install_flex_sentinel() attaches a _LazyFlexEngineSentinel
  to model.vllm_engine so hasattr(model, "vllm_engine") keeps working
  between from_pretrained and the first build; fast_generate triggers
  a floor build on first call.
- rl.py injects _build_flex_from_args(model, args) before both
  self.llm = model.vllm_engine rewrite sites (pre-TRL-0.18
  sampling_params prefix and >=0.18 colocate LLM replacement). Sizes
  the engine from max(pdbs * spg, pdbs * spg * ngen) derived from the
  GRPO args. No-op on non-flex models, so the injection is safe for
  every TRL backend.
- Precedence: user's max_batch_size kwarg is a floor; the GRPO target
  overrides only when strictly larger, with a warning naming both.
- First-build only: post-build growth raises RuntimeError pointing the
  user back to max_batch_size= in from_pretrained. The pristine
  inference deep-copy is consumed on first build and gemma4 shell
  extraction mutates its module tree, so a safe rebuild would require
  a second deep-copy.
- tests/flex_lazy_batch_smoke.py (unit, stubbed FlexEngine) covers
  default, GRPO bump, user-floor, and post-build-refused cases.
  tests/flex_lazy_live_smoke.py exercises sentinel + build against a
  live Qwen3-0.6B-Base.
2026-04-21 14:58:07 +00:00
Daniel Han
e348be8ce0 inference: add AGPLv3 license headers
All eight files introduced on this branch now carry the SPDX AGPLv3
header used by the MoE kernels. flex_paged_attention.py keeps its
BSD 3-Clause attribution to attention-gym alongside the new header.
2026-04-21 13:19:01 +00:00
pre-commit-ci[bot]
5e1ec3395a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-21 12:00:17 +00:00
Daniel Han
35231d4ff4 inference: expose flex backend via UNSLOTH_FAST_INFERENCE=1
Setting UNSLOTH_FAST_INFERENCE=1 makes
FastLanguageModel.from_pretrained(..., fast_inference=True) return the
flex attention + paged KV + CUDA-graph backend instead of vLLM for
Qwen3, Llama-3, and Gemma-4-E2B-it. Default "0" keeps the existing vLLM
path.

The flex engine lives in a new unsloth.inference subpackage so the
FastLanguageModel wiring and any external caller can reach it without
going through scripts/. The user-facing GRPO notebooks for Qwen3,
Llama-3, and Gemma-4 run end-to-end with no code edits, just the env
var.

New subpackage

- unsloth/inference/flex_paged_attention.py: paged KV cache and
  block-mask helpers.
- unsloth/inference/flex_qwen3_llama.py: FlexInference for Qwen3 and
  Llama-3.2. tokenize() skips pre-populated input_ids so the engine
  can accept vLLM-style token-id prompts.
- unsloth/inference/flex_gemma4.py: FlexGemma4Inference. Text-shell
  extraction from Gemma4ForConditionalGeneration, per-layer-type
  sliding-window masks, KV sharing for layers 15-34.
- unsloth/inference/flex_engine.py: FlexEngine with the vLLM LLM
  surface. Arch dispatch, deep-copy of the HF model for colocate
  rollout so training forward stays intact, lazy pristine-base copy
  on first bind_peft_model, torch.amp.autocast("cuda", dtype=...)
  wrapping for the whole generate path, auto-tune for Triton block
  sizes per GPU capability and head_dim band. FA4 default OFF
  because the sm_100 FLASH backend crashes on short prompts; users
  can opt in with fa4_prefill=True.
- unsloth/inference/vllm_shim.py: LoRARequest, RequestOutput,
  CompletionOutput dataclasses plus save_lora / load_lora that
  mirror unsloth_zoo.vllm_utils line for line so the TRL GRPO patch
  sees the same attribute shape.

Wiring

- unsloth/models/loader.py: UNSLOTH_FAST_INFERENCE=1 bypasses the
  vLLM import guard in FastLanguageModel.from_pretrained (L356) and
  FastModel.from_pretrained (L982).
- unsloth/models/llama.py: the elif not fast_inference load branch
  now also handles the flex case, pops max_batch_size from kwargs
  (FlexEngine-only), snapshots the model into an inference copy
  BEFORE Unsloth's post-patch so flex-attention patching lands on a
  clean HF layout, and after the tokenizer loads constructs
  FlexEngine and attaches .vllm_engine / .fast_generate /
  .fast_generate_batches. FastLlamaModel.get_peft_model calls
  engine.bind_peft_model(peft) right after patch_peft_fast_inference
  so state_dict() reads the training LoRA tensors.
- unsloth/models/vision.py: same pattern for FastBaseModel. Gemma-4
  is allowed through the vision-model gate when the flex backend is
  selected; the engine extracts the text shell from
  Gemma4ForConditionalGeneration internally.
- unsloth/models/_utils.py: patch_peft_fast_inference picks
  unsloth.inference.vllm_shim.save_lora / load_lora when the engine
  is a FlexEngine, so the flex path never imports
  vllm.lora.request. fast_inference_setup skips patch_vllm() when
  UNSLOTH_FAST_INFERENCE=1.

Smoke tests (B200 sm_100)

- Qwen3-4B-Base bf16 (no LoRA and with get_peft_model LoRA)
- Qwen3-4B-Base fp16
- Llama-3.2-3B-Instruct bf16
- gemma-4-E2B-it bf16

all load through FastLanguageModel + UNSLOTH_FAST_INFERENCE=1 and
generate coherent completions.

Batched steady-state throughput via FastLanguageModel (autocast ON,
n_prompts=8 max_new_tokens=64 max_batch_size=16 max_seq_length=1024):

| Model                      | Flex CLI  | Integration | Delta |
|----------------------------|-----------|-------------|-------|
| Qwen3-4B-Base              | 819 tok/s | 595 tok/s   | -27%  |
| Llama-3.2-3B-Instruct      | 1163      | 1357        | +17%  |

Qwen3's regression is outside the 10% budget; most likely cause is
q_norm / k_norm being promoted to fp32 under autocast. Tracked for
a follow-up; the integration is functional and GRPO-usable today.

Note

The flex engine source files live here in unsloth/inference/. The
CLI benchmark drivers in scripts/benchmarks/ land in a separate PR
(#5108) which also carries the Gemma-4 and Llama-3 regression runs.
This PR depends only on what is already on main.

Known follow-ups

- FA4 on sm_100 needs a more robust enable rule before it can be
  default-on.
- Memory: 2x base-model VRAM without LoRA, 3x with LoRA.
- FlexEngine.sleep() / .wake_up() are no-op stubs; real CPU-offload
  parity with UNSLOTH_VLLM_STANDBY is a separate PR.
2026-04-21 11:59:47 +00:00
36 changed files with 11189 additions and 26 deletions

View file

@ -0,0 +1,93 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Batched steady-state throughput bench through ``FastLanguageModel`` +
``UNSLOTH_FAST_INFERENCE=1``. First ``generate`` call primes CUDA graphs;
subsequent calls report steady state. Compare against April CLI-only
numbers for the same workload."""
import os, sys, time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import argparse
import torch
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default = "unsloth/Qwen3-4B-Base")
p.add_argument("--dtype", choices = ["bf16", "fp16"], default = "bf16")
p.add_argument("--n_prompts", type = int, default = 8)
p.add_argument("--max_new_tokens", type = int, default = 64)
p.add_argument("--max_batch_size", type = int, default = 16)
p.add_argument("--max_seq_length", type = int, default = 1024)
p.add_argument("--n_rounds", type = int, default = 3)
args = p.parse_args()
os.environ.setdefault("UNSLOTH_FAST_INFERENCE", "1")
import unsloth
from unsloth import FastLanguageModel
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
model, tok = FastLanguageModel.from_pretrained(
model_name = args.model,
max_seq_length = args.max_seq_length,
dtype = dtype,
load_in_4bit = False,
fast_inference = True,
max_batch_size = args.max_batch_size,
)
print(f"[bench] model={args.model} dtype={args.dtype}")
prompts = [
f"In one sentence, a fact about {t} is"
for t in [
"the moon",
"gravity",
"the ocean",
"the sun",
"honey",
"rain",
"trees",
"mountains",
][: args.n_prompts]
]
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
# Warmup round — captures CUDA graphs.
print("[bench] warmup (CUDA graph capture)...")
t0 = time.perf_counter()
_ = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
print(f"[bench] warmup wall: {time.perf_counter() - t0:.2f}s")
walls = []
tok_counts = []
for r in range(args.n_rounds):
torch.cuda.synchronize()
t1 = time.perf_counter()
outs = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
torch.cuda.synchronize()
dt = time.perf_counter() - t1
n_tok = sum(len(o.outputs[0].token_ids) for o in outs)
walls.append(dt)
tok_counts.append(n_tok)
print(f"[bench] round {r}: {n_tok} toks in {dt:.2f}s -> {n_tok/dt:.1f} tok/s")
if walls:
wall_med = sorted(walls)[len(walls) // 2]
tok_med = tok_counts[len(walls) // 2]
print(
f"[bench] median: {tok_med} toks in {wall_med:.2f}s "
f"=> {tok_med / wall_med:.1f} tok/s"
)
if __name__ == "__main__":
main()

101
tests/flex_fastlm_smoke.py Normal file
View file

@ -0,0 +1,101 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Smoke-test the ``UNSLOTH_FAST_INFERENCE=1`` path through
``FastLanguageModel.from_pretrained``.
Invoked as:
CUDA_VISIBLE_DEVICES=2 UNSLOTH_FAST_INFERENCE=1 python tests/flex_fastlm_smoke.py \
--model unsloth/Qwen3-4B-Base --dtype bf16 --no-lora
Prints tokens/s + the first generated string.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
# Make the local fork importable.
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default = "unsloth/Qwen3-4B-Base")
p.add_argument("--dtype", choices = ["bf16", "fp16"], default = "bf16")
p.add_argument("--load_in_4bit", action = "store_true")
p.add_argument("--with_lora", action = "store_true")
p.add_argument("--max_new_tokens", type = int, default = 32)
p.add_argument("--max_seq_length", type = int, default = 1024)
p.add_argument("--prompt", default = "The quick brown fox jumps over")
args = p.parse_args()
import torch
os.environ.setdefault("UNSLOTH_FAST_INFERENCE", "1")
print(f"[smoke] UNSLOTH_FAST_INFERENCE={os.environ.get('UNSLOTH_FAST_INFERENCE')}")
import unsloth
print(f"[smoke] unsloth={unsloth.__file__}")
from unsloth import FastLanguageModel
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
t0 = time.perf_counter()
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = args.model,
max_seq_length = args.max_seq_length,
dtype = dtype,
load_in_4bit = args.load_in_4bit,
fast_inference = True,
)
t_load = time.perf_counter() - t0
print(f"[smoke] loaded model in {t_load:.1f}s; dtype={model.dtype}")
print(f"[smoke] hasattr(model, 'vllm_engine'): {hasattr(model, 'vllm_engine')}")
print(f"[smoke] vllm_engine type: {type(model.vllm_engine).__name__}")
if args.with_lora:
model = FastLanguageModel.get_peft_model(
model,
r = 16,
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"],
lora_alpha = 16,
lora_dropout = 0.0,
bias = "none",
use_gradient_checkpointing = "unsloth",
random_state = 3407,
)
print(f"[smoke] PEFT model type: {type(model).__name__}")
print(
f"[smoke] model.vllm_engine bound to PEFT: "
f"{hasattr(model, 'vllm_engine')}"
)
from unsloth.inference.vllm_shim import LoRARequest
prompts = [args.prompt]
# Minimal SamplingParams stand-in
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
t1 = time.perf_counter()
outputs = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
dt = time.perf_counter() - t1
out = outputs[0]
n_tok = len(out.outputs[0].token_ids)
print(f"[smoke] generated {n_tok} tokens in {dt:.2f}s " f"({n_tok / dt:.1f} tok/s)")
print(f"[smoke] prompt: {args.prompt!r}")
print(f"[smoke] completion: {out.outputs[0].text!r}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,74 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Numerical parity check for ``refresh_moe_lora_merge_from_pristine``
at Gemma 4 26B-A4B expert shapes.
Reuses ``merge_under_test`` and ``reference_merge`` from
``tests/flex_moe_merge_parity.py`` (both are standalone helpers they
import nothing from the flex engine, only exercise the bitwise kernel
logic lifted from ``unsloth/inference/flex_moe.py:682-800``).
Shapes covered (Gemma 4 26B-A4B):
- gate_up_proj: (E=128, 2I=1408, H=2816) standard ``(E, out, in)``
- down_proj: (E=128, H=2816, I=704) standard ``(E, out, in)``
Rank-16 LoRA, single + dual adapter, bf16 + fp32.
Gemma 4 MoE uses the same ``F.linear``-oriented expert layout as Qwen3,
so only the ``transposed=False`` branch of
``refresh_moe_lora_merge_from_pristine`` is exercised here. (The
transposed branch is already covered for gpt-oss in
``flex_moe_merge_parity.py``.)
Usage::
CUDA_VISIBLE_DEVICES=2 python -u tests/flex_gemma4_moe_merge_parity.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import torch
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from tests.flex_moe_merge_parity import ( # noqa: E402
merge_under_test,
reference_merge,
test_correctness,
)
def main():
device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(3407)
print(f"[merge-parity-gemma4] device={device} dtype=bf16 + fp32")
print(f"[merge-parity-gemma4] torch={torch.__version__}")
print()
print("== Correctness (fp32 golden + bf16 realistic Gemma 4 shapes) ==")
# gate_up_proj: (E=128, 2I=1408, H=2816) standard
# down_proj: (E=128, H=2816, I=704 ) standard
cases = [
# (E, in_dim, out_dim, R, dtype, transposed, n_adapters)
( 8, 64, 128, 4, torch.float32, False, 1),
( 8, 64, 128, 4, torch.float32, False, 2),
(16, 128, 256, 8, torch.float32, False, 2),
# bf16 at Gemma 4 26B-A4B MoE shapes.
(128, 2816, 1408, 16, torch.bfloat16, False, 1), # gate_up_proj
(128, 704, 2816, 16, torch.bfloat16, False, 1), # down_proj
(128, 2816, 1408, 16, torch.bfloat16, False, 2), # two adapters
(128, 2816, 1408, 64, torch.bfloat16, False, 1), # higher rank
]
all_ok = True
for E, in_dim, out_dim, R, dtype, tr, na in cases:
ok = test_correctness(E, in_dim, out_dim, R, dtype, device,
transposed=tr, n_adapters=na)
all_ok = all_ok and ok
print(f"\n overall: {'PASS' if all_ok else 'FAIL'}")
if __name__ == "__main__":
main()

191
tests/flex_gemma4_parity.py Normal file
View file

@ -0,0 +1,191 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Token parity for FlexGemma4Inference / FlexGemma4MoEInference vs pure HF.
Usage::
CUDA_VISIBLE_DEVICES=2 UNSLOTH_FAST_INFERENCE=1 python -u \\
tests/flex_gemma4_parity.py --backend flex --model unsloth/gemma-4-31B-it
CUDA_VISIBLE_DEVICES=3 python -u tests/flex_gemma4_parity.py \\
--backend hf --model unsloth/gemma-4-31B-it
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
CHAT_PROMPTS = [
"In one sentence, what is Paris?",
"What is 23 + 19? Answer in one word.",
"Continue: The quick brown fox jumps over",
]
def _run_flex(args, dtype, *, capture: bool, lora_path=None):
import torch
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
import unsloth # noqa
from unsloth import FastLanguageModel
if not capture:
# Force the eager decode path across arches.
try:
from unsloth.inference.flex_gemma4 import FlexGemma4Inference
FlexGemma4Inference.capture_decode_cudagraph = lambda self: None
except Exception:
pass
try:
from unsloth.inference.flex_gemma4_moe import FlexGemma4MoEInference
FlexGemma4MoEInference.capture_decode_cudagraph = lambda self: None
except Exception:
pass
model, tok = FastLanguageModel.from_pretrained(
model_name=args.model,
max_seq_length=args.max_seq_length,
dtype=dtype,
load_in_4bit=False,
fast_inference=True,
max_batch_size=4,
gpu_memory_utilization=0.6,
)
if lora_path is not None:
model.load_adapter(lora_path, adapter_name="default")
print(f"[parity-flex] LoRA attached from {lora_path}")
prompts = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False,
add_generation_prompt=True,
)
for p in CHAT_PROMPTS[: args.num_prompts]
]
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
_ = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
outs = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
token_ids = [list(o.outputs[0].token_ids) for o in outs]
texts = [o.outputs[0].text for o in outs]
return token_ids, texts, tok
def _run_hf(args, dtype, *, lora_path=None):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(args.model)
try:
model = AutoModelForCausalLM.from_pretrained(
args.model, dtype=dtype, device_map="cuda",
attn_implementation="eager",
)
except Exception:
# Multimodal Gemma 4 (ConditionalGeneration) — load the top-level class.
from transformers import AutoModelForImageTextToText
model = AutoModelForImageTextToText.from_pretrained(
args.model, dtype=dtype, device_map="cuda",
attn_implementation="eager",
)
model.eval()
if lora_path is not None:
from peft import PeftModel
model = PeftModel.from_pretrained(model, lora_path)
model.eval()
print(f"[parity-hf] LoRA attached from {lora_path}")
if tok.pad_token_id is None:
tok.pad_token_id = tok.eos_token_id
tok.padding_side = "left"
prompts = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False,
add_generation_prompt=True,
)
for p in CHAT_PROMPTS[: args.num_prompts]
]
inputs = tok(prompts, return_tensors="pt", padding=True).to("cuda")
out = model.generate(
**inputs,
max_new_tokens=args.max_new_tokens,
do_sample=False,
temperature=1.0,
pad_token_id=tok.pad_token_id,
)
prompt_len = inputs["input_ids"].shape[1]
eos = tok.eos_token_id
pad = tok.pad_token_id
token_ids = []
texts = []
for row in out:
ids = row[prompt_len:].tolist()
while ids and ids[-1] in (eos, pad):
ids.pop()
token_ids.append(ids)
texts.append(tok.decode(ids, skip_special_tokens=False))
return token_ids, texts, tok
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="unsloth/gemma-4-31B-it")
p.add_argument("--backend", choices=["flex", "flex_eager", "hf"], required=True)
p.add_argument("--max_new_tokens", type=int, default=64)
p.add_argument("--num_prompts", type=int, default=3)
p.add_argument("--lora_path", default=None)
p.add_argument("--max_seq_length", type=int, default=1024)
p.add_argument("--out_dir", default="async_task_outputs/gemma4_moe_bench")
args = p.parse_args()
import torch
dtype = torch.bfloat16
if args.backend == "flex":
token_ids, texts, _ = _run_flex(args, dtype, capture=True, lora_path=args.lora_path)
elif args.backend == "flex_eager":
token_ids, texts, _ = _run_flex(args, dtype, capture=False, lora_path=args.lora_path)
else:
token_ids, texts, _ = _run_hf(args, dtype, lora_path=args.lora_path)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
model_tag = args.model.replace("/", "_")
suffix = "_lora" if args.lora_path else ""
out_path = out_dir / f"parity_{model_tag}_{args.backend}{suffix}.json"
with open(out_path, "w") as f:
json.dump(
{
"backend": args.backend,
"model": args.model,
"prompts": CHAT_PROMPTS[: args.num_prompts],
"token_ids": token_ids,
"texts": texts,
},
f,
indent=2,
)
print(f"[parity-{args.backend}] wrote {out_path}")
for i, (pp, tt) in enumerate(zip(CHAT_PROMPTS[: args.num_prompts], texts)):
print(f"[parity-{args.backend}] prompt {i}: {pp!r}")
print(f"[parity-{args.backend}] completion {i}: {tt!r}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,176 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Token parity for FlexGptOssInference vs pure HF generate on gpt-oss-20b.
Usage::
# Flex side (cudagraph off in Phase 1).
CUDA_VISIBLE_DEVICES=2 UNSLOTH_FAST_INFERENCE=1 python -u \\
tests/flex_gpt_oss_parity.py --backend flex
# HF reference.
CUDA_VISIBLE_DEVICES=3 python -u tests/flex_gpt_oss_parity.py \\
--backend hf
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
CHAT_PROMPTS = [
"In one sentence, what is Paris?",
"What is 23 + 19? Answer in one word.",
"Continue: The quick brown fox jumps over",
]
def _run_flex(args, dtype, *, capture: bool, lora_path=None):
import torch
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
import unsloth # noqa
from unsloth import FastLanguageModel
if not capture:
# Force the eager decode path by stubbing capture_decode_cudagraph.
from unsloth.inference.flex_gpt_oss import FlexGptOssInference
FlexGptOssInference.capture_decode_cudagraph = lambda self: None
model, tok = FastLanguageModel.from_pretrained(
model_name=args.model,
max_seq_length=args.max_seq_length,
dtype=dtype,
load_in_4bit=False,
fast_inference=True,
max_batch_size=4,
gpu_memory_utilization=0.6,
)
if lora_path is not None:
model.load_adapter(lora_path, adapter_name="default")
print(f"[parity-flex] LoRA attached from {lora_path}")
prompts = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False,
add_generation_prompt=True,
)
for p in CHAT_PROMPTS
]
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
# First call primes; second is the measurement.
_ = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
outs = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
token_ids = [list(o.outputs[0].token_ids) for o in outs]
texts = [o.outputs[0].text for o in outs]
return token_ids, texts, tok
def _run_hf(args, dtype, *, lora_path=None):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(args.model)
model = AutoModelForCausalLM.from_pretrained(
args.model, dtype=dtype, device_map="cuda",
attn_implementation="eager",
)
model.eval()
if lora_path is not None:
from peft import PeftModel
model = PeftModel.from_pretrained(model, lora_path)
model.eval()
print(f"[parity-hf] LoRA attached from {lora_path}")
if tok.pad_token_id is None:
tok.pad_token_id = tok.eos_token_id
tok.padding_side = "left"
prompts = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False,
add_generation_prompt=True,
)
for p in CHAT_PROMPTS
]
inputs = tok(prompts, return_tensors="pt", padding=True).to("cuda")
out = model.generate(
**inputs,
max_new_tokens=args.max_new_tokens,
do_sample=False,
temperature=1.0,
pad_token_id=tok.pad_token_id,
)
prompt_len = inputs["input_ids"].shape[1]
eos = tok.eos_token_id
pad = tok.pad_token_id
token_ids = []
texts = []
for row in out:
ids = row[prompt_len:].tolist()
while ids and ids[-1] in (eos, pad):
ids.pop()
token_ids.append(ids)
texts.append(tok.decode(ids, skip_special_tokens=False))
return token_ids, texts, tok
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="unsloth/gpt-oss-20b-BF16")
p.add_argument("--backend", choices=["flex", "flex_eager", "hf"], required=True)
p.add_argument("--max_new_tokens", type=int, default=64)
p.add_argument("--lora_path", default=None,
help="optional LoRA adapter dir to attach before decode")
p.add_argument("--max_seq_length", type=int, default=1024)
p.add_argument("--out_dir", default="async_task_outputs/qwen3_moe_grpo_bench_v2")
args = p.parse_args()
import torch
dtype = torch.bfloat16
if args.backend == "flex":
token_ids, texts, _ = _run_flex(args, dtype, capture=True, lora_path=args.lora_path)
elif args.backend == "flex_eager":
token_ids, texts, _ = _run_flex(args, dtype, capture=False, lora_path=args.lora_path)
else:
token_ids, texts, _ = _run_hf(args, dtype, lora_path=args.lora_path)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
suffix = "_lora" if args.lora_path else ""
out_path = out_dir / f"parity_gptoss_{args.backend}{suffix}.json"
with open(out_path, "w") as f:
json.dump(
{
"backend": args.backend,
"model": args.model,
"prompts": CHAT_PROMPTS,
"token_ids": token_ids,
"texts": texts,
},
f,
indent=2,
)
print(f"[parity-{args.backend}] wrote {out_path}")
for i, (pp, tt) in enumerate(zip(CHAT_PROMPTS, texts)):
print(f"[parity-{args.backend}] prompt {i}: {pp!r}")
print(f"[parity-{args.backend}] completion {i}: {tt!r}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,227 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Smoke tests for the deferred FlexEngine batch-size sizing.
Unit-level: covers the four cases described in the implementation plan
by monkey-patching :class:`FlexEngine` with a cheap stand-in so the
tests run on any box (no CUDA / no model download). The dispatch logic
lives entirely in :func:`build_flex_engine`,
:func:`install_flex_sentinel`, and :func:`_build_flex_from_args`, which
are the units under test.
Run as:
python tests/flex_lazy_batch_smoke.py
"""
from __future__ import annotations
import sys
import types
import warnings
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
class _StubFlexEngine:
"""Records the ``max_batch_size`` construction arg and nothing else.
``_cudagraph_primed`` flips True after the first ``generate`` so the
post-warmup refuse path can be exercised without touching CUDA.
"""
instances: list = []
def __init__(
self,
hf_model,
tokenizer,
*,
dtype = None,
max_seq_length: int = 2048,
max_lora_rank: int = 64,
max_batch_size: int = 32,
page_size: int = 128,
gpu_memory_utilization: float = 0.5,
max_new_tokens: int = 512,
prefill_kernel_options = None,
decode_kernel_options = None,
fa4_prefill = None,
capture_cudagraph: bool = True,
base_model = None,
peft_model = None,
inference_model = None,
):
self.hf_model = hf_model
self.tokenizer = tokenizer
self.max_batch_size = max_batch_size
self.max_seq_length = max_seq_length
self.compute_dtype = dtype
self._cudagraph_primed = False
self.generate_calls = 0
_StubFlexEngine.instances.append(self)
def generate(self, prompts = None, *args, **kwargs):
self.generate_calls += 1
self._cudagraph_primed = True
return [("stub", prompts)]
def _make_stub_model():
"""An object that quacks like an HF model for ``install_flex_sentinel``."""
model = types.SimpleNamespace()
model._unsloth_needs_flex_engine = dict(
dtype = "bf16",
max_seq_length = 2048,
max_lora_rank = 64,
max_batch_size = 32,
gpu_memory_utilization = 0.5,
)
model._unsloth_flex_inference_copy = object() # never dereferenced
return model
def _install_stub():
"""Patch FlexEngine with :class:`_StubFlexEngine` for the duration of the
test process. Imports happen lazily inside ``build_flex_engine``, so we
patch the module attribute before those calls fire."""
import unsloth.inference.flex_engine as fe
_StubFlexEngine.instances.clear()
fe.FlexEngine = _StubFlexEngine
def _case1_default_floor():
"""No trainer, no kwargs: fast_generate builds at floor=32."""
from unsloth.inference.flex_engine import install_flex_sentinel
_install_stub()
model = _make_stub_model()
install_flex_sentinel(model, tokenizer = object())
assert hasattr(model, "vllm_engine"), "sentinel not installed"
assert not hasattr(
model, "_flex_engine_instance"
), "engine should NOT exist before first use"
out = model.fast_generate(["hello"])
assert out == [("stub", ["hello"])]
engine = model._flex_engine_instance
assert engine.max_batch_size == 32, engine.max_batch_size
# Sentinel was replaced with the real engine after build.
assert model.vllm_engine is engine
print(" [1/4] default path: floor=32 build on first fast_generate OK")
def _case2_grpo_bump():
"""User kwarg=16 + GRPO target=64 → engine built at 64, warning logged."""
from unsloth.inference.flex_engine import (
_build_flex_from_args,
install_flex_sentinel,
)
_install_stub()
model = _make_stub_model()
model._unsloth_needs_flex_engine["max_batch_size"] = 16 # user floor
install_flex_sentinel(model, tokenizer = object())
args = types.SimpleNamespace(
per_device_train_batch_size = 2,
steps_per_generation = 4,
num_generations = 8,
gradient_accumulation_steps = 1,
)
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_build_flex_from_args(model, args)
engine = model._flex_engine_instance
assert engine.max_batch_size == 64, engine.max_batch_size
assert any("16 -> 64" in str(w.message) for w in caught), [
str(w.message) for w in caught
]
print(" [2/4] GRPO bump: 16 -> 64 with warning OK")
def _case3_user_floor_wins():
"""User kwarg=128 + GRPO target=8 → engine stays at 128, no warning."""
from unsloth.inference.flex_engine import (
_build_flex_from_args,
install_flex_sentinel,
)
_install_stub()
model = _make_stub_model()
model._unsloth_needs_flex_engine["max_batch_size"] = 128
install_flex_sentinel(model, tokenizer = object())
args = types.SimpleNamespace(
per_device_train_batch_size = 1,
steps_per_generation = 2,
num_generations = 4,
gradient_accumulation_steps = 1,
)
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
_build_flex_from_args(model, args)
engine = model._flex_engine_instance
assert engine.max_batch_size == 128, engine.max_batch_size
assert not any("FlexEngine" in str(w.message) for w in caught), [
str(w.message) for w in caught
]
print(" [3/4] user floor wins: engine.max_batch_size=128 OK")
def _case4_post_warmup_refused():
"""fast_generate primes the engine; later GRPO target=64 must raise."""
from unsloth.inference.flex_engine import (
_build_flex_from_args,
install_flex_sentinel,
)
_install_stub()
model = _make_stub_model()
install_flex_sentinel(model, tokenizer = object())
model.fast_generate(["hi"]) # builds at floor=32, sets _cudagraph_primed
assert model._flex_engine_instance.max_batch_size == 32
args = types.SimpleNamespace(
per_device_train_batch_size = 2,
steps_per_generation = 4,
num_generations = 8,
gradient_accumulation_steps = 1,
)
try:
_build_flex_from_args(model, args)
except RuntimeError as exc:
msg = str(exc)
assert "32" in msg and "64" in msg, msg
assert "max_batch_size=64" in msg, msg
print(" [4/4] post-warmup rebuild refused with actionable msg OK")
return
raise AssertionError("expected RuntimeError when growing a built engine")
def main():
print("flex_lazy_batch_smoke:")
_case1_default_floor()
_case2_grpo_bump()
_case3_user_floor_wins()
_case4_post_warmup_refused()
print("ALL CASES PASSED")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,94 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Live smoke-test for deferred FlexEngine construction.
Loads a small flex-supported model with ``UNSLOTH_FAST_INFERENCE=1`` and
checks:
1. After ``from_pretrained``, ``model.vllm_engine`` is the lazy
sentinel -- no ``_flex_engine_instance`` yet.
2. ``build_flex_engine(model)`` constructs the engine at the stashed
``max_batch_size`` floor (default 32) and wires
``model.vllm_engine`` / ``fast_generate`` onto it.
3. ``build_flex_engine(model, max_batch_size=X)`` with ``X`` larger
than the built size raises :class:`RuntimeError` with an actionable
hint pointing the user back to ``max_batch_size=`` in
``from_pretrained``.
This intentionally does NOT call ``engine.generate`` -- that path is
covered by the existing ``tests/flex_fastlm_smoke.py`` and would
duplicate its warm-up cost. The goal here is to confirm the lazy
dispatch behavior end-to-end.
Run as:
CUDA_VISIBLE_DEVICES=0 UNSLOTH_FAST_INFERENCE=1 \
python tests/flex_lazy_live_smoke.py
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def main():
assert (
os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1"
), "export UNSLOTH_FAST_INFERENCE=1 before running this smoke"
import unsloth # noqa: F401 (must import before transformers)
from unsloth import FastLanguageModel
from unsloth.inference.flex_engine import (
FlexEngine,
_LazyFlexEngineSentinel,
build_flex_engine,
)
model_name = os.environ.get("FLEX_LAZY_SMOKE_MODEL", "unsloth/Qwen3-0.6B-Base")
print(f"loading {model_name} ...")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = model_name,
max_seq_length = 1024,
fast_inference = True,
load_in_4bit = False,
)
assert hasattr(model, "vllm_engine"), "vllm_engine attr missing"
sentinel = model.vllm_engine
assert isinstance(
sentinel, _LazyFlexEngineSentinel
), f"expected sentinel, got {type(sentinel)}"
assert not hasattr(
model, "_flex_engine_instance"
), "engine should NOT be built before first use"
print(" [1/3] sentinel installed, no engine yet")
engine = build_flex_engine(model)
assert isinstance(engine, FlexEngine), type(engine)
assert engine.max_batch_size == 32, engine.max_batch_size
assert model._flex_engine_instance is engine
assert model.vllm_engine is engine
print(
f" [2/3] build_flex_engine built engine at max_batch_size={engine.max_batch_size}"
)
try:
build_flex_engine(model, max_batch_size = 64)
except RuntimeError as exc:
msg = str(exc)
assert "32" in msg and "64" in msg, msg
assert "max_batch_size=64" in msg, msg
print(" [3/3] post-build resize refused with actionable msg")
else:
raise AssertionError("expected RuntimeError when growing a built engine")
print("LIVE SMOKE PASSED")
if __name__ == "__main__":
main()

199
tests/flex_moe_bench.py Normal file
View file

@ -0,0 +1,199 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Decode throughput bench: FlexMoEInference vs HF generate on Qwen3 MoE.
Apples-to-apples decode on the same prompt set + new-token budget,
same LoRA rank, same precision. Mirrors PR #5123's
``tests/flex_fastlm_bench.py`` shape.
Usage:
CUDA_VISIBLE_DEVICES=0 UNSLOTH_FAST_INFERENCE=1 python -u \
tests/flex_moe_bench.py --backend flex --load_in_4bit
CUDA_VISIBLE_DEVICES=0 python -u \
tests/flex_moe_bench.py --backend hf --load_in_4bit
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def main():
p = argparse.ArgumentParser()
p.add_argument("--backend", choices = ["flex", "hf", "hf_naive"], default = "flex")
p.add_argument("--model", default = "unsloth/Qwen3-30B-A3B-Instruct-2507")
p.add_argument("--dtype", choices = ["bf16", "fp16"], default = "bf16")
p.add_argument("--load_in_4bit", action = "store_true")
p.add_argument("--n_prompts", type = int, default = 8)
p.add_argument("--max_new_tokens", type = int, default = 64)
p.add_argument("--max_seq_length", type = int, default = 1024)
p.add_argument("--warmup_rounds", type = int, default = 1)
p.add_argument("--timed_rounds", type = int, default = 2)
p.add_argument("--out_dir", default = "async_task_outputs/qwen3_moe_grpo_bench")
p.add_argument("--chat_template", action = "store_true",
help = "wrap each prompt with tokenizer.apply_chat_template")
p.add_argument("--user_prompt",
default = "Continue this sentence: The quick brown fox jumps over fence {i}, then")
args = p.parse_args()
import torch
if args.backend == "flex":
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
os.environ.setdefault("UNSLOTH_MOE_BACKEND", "grouped_mm")
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
torch.cuda.reset_peak_memory_stats()
t_load0 = time.perf_counter()
if args.backend == "hf_naive":
# Pure transformers path: NO ``import unsloth`` so none of
# Unsloth's Qwen3 MoE attention / MLP patches run. This is the
# fair naive reference to compare flex fast-inference against.
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
quant_cfg = None
if args.load_in_4bit:
quant_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=dtype,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
tokenizer = AutoTokenizer.from_pretrained(args.model)
if tokenizer.pad_token_id is None or tokenizer.pad_token == "<|PAD_TOKEN|>":
tokenizer.pad_token = "<|vision_pad|>"
tokenizer.padding_side = "left"
attn_impl = os.environ.get("HF_ATTN_IMPL", "eager")
print(f"[bench] HF attn_implementation={attn_impl}")
model = AutoModelForCausalLM.from_pretrained(
args.model,
dtype=dtype,
quantization_config=quant_cfg,
device_map="cuda",
attn_implementation=attn_impl,
)
model.eval()
print(f"[bench] pure transformers (no unsloth patches)")
else:
import unsloth
print(f"[bench] unsloth={unsloth.__file__}")
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = args.model,
max_seq_length = args.max_seq_length,
dtype = dtype,
load_in_4bit = args.load_in_4bit,
fast_inference = args.backend == "flex",
)
t_load = time.perf_counter() - t_load0
peak_load = torch.cuda.max_memory_reserved() / 1024**3
print(f"[bench] loaded in {t_load:.1f}s peak {peak_load:.1f} GB")
if args.chat_template:
prompts = []
for i in range(args.n_prompts):
msg = [{"role": "user", "content": args.user_prompt.format(i=i)}]
prompts.append(tokenizer.apply_chat_template(
msg, tokenize = False, add_generation_prompt = True,
))
print(f"[bench] chat_template enabled. prompt[0] (first 200 chars):\n"
f" {prompts[0][:200]!r}")
else:
prompts = [f"The quick brown fox jumps over fence {i}, then"
for i in range(args.n_prompts)]
if args.backend == "flex":
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
# Warmup
for _ in range(args.warmup_rounds):
_ = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
# Timed
wall_times = []
tok_counts = []
for _ in range(args.timed_rounds):
t0 = time.perf_counter()
outs = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
wall_times.append(time.perf_counter() - t0)
tok_counts.append(
sum(len(o.outputs[0].token_ids) for o in outs)
)
else:
# HF generate (shared for "hf" unsloth-patched and "hf_naive" pure).
inputs = tokenizer(prompts, return_tensors = "pt", padding = True).to("cuda")
gen_kwargs = dict(
max_new_tokens = args.max_new_tokens,
do_sample = False,
temperature = 1.0,
pad_token_id = tokenizer.pad_token_id or tokenizer.eos_token_id,
)
# Warmup
for _ in range(args.warmup_rounds):
_ = model.generate(**inputs, **gen_kwargs)
torch.cuda.synchronize()
wall_times = []
tok_counts = []
for _ in range(args.timed_rounds):
torch.cuda.synchronize()
t0 = time.perf_counter()
out = model.generate(**inputs, **gen_kwargs)
torch.cuda.synchronize()
wall_times.append(time.perf_counter() - t0)
n_new = (out.shape[1] - inputs["input_ids"].shape[1]) * out.shape[0]
tok_counts.append(n_new)
peak_gen = torch.cuda.max_memory_reserved() / 1024**3
median_wall = sorted(wall_times)[len(wall_times) // 2]
median_tok = tok_counts[len(wall_times) // 2]
tok_per_s = median_tok / median_wall if median_wall > 0 else 0.0
print(f"[bench] wall: {wall_times}")
print(f"[bench] tok counts: {tok_counts}")
print(f"[bench] median wall: {median_wall:.2f}s median tok/s: {tok_per_s:.1f}")
print(f"[bench] peak VRAM after gen: {peak_gen:.1f} GB")
precision = "4bit" if args.load_in_4bit else args.dtype
out_dir = Path(args.out_dir)
out_dir.mkdir(parents = True, exist_ok = True)
summary = {
"phase": "bench_decode",
"backend": args.backend,
"model": args.model,
"precision": precision,
"n_prompts": args.n_prompts,
"max_new_tokens": args.max_new_tokens,
"wall_times_s": wall_times,
"tok_counts": tok_counts,
"median_wall_s": round(median_wall, 3),
"median_tok_s": round(tok_per_s, 1),
"peak_vram_load_gb": round(peak_load, 2),
"peak_vram_after_gen_gb": round(peak_gen, 2),
"t_load_s": round(t_load, 1),
}
with open(out_dir / f"bench_decode_{args.backend}_{precision}.json", "w") as f:
json.dump(summary, f, indent = 2)
print(f"[bench] wrote {out_dir / f'bench_decode_{args.backend}_{precision}.json'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,290 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Numerical + performance check for ``refresh_moe_lora_merge_from_pristine``.
Does what we never actually did on this branch: verify the stacked MoE
LoRA merge kernel produces the same W_inf as the textbook reference
``W_ref[e] = W_pristine[e] + sum_a scaling_a * B_a[e] @ A_a[e]`` for both
standard (E, 2I, H) and transposed (E, H, 2I) expert layouts, with 1 and
2 active adapters, then benchmarks the batched ``baddbmm`` path against
the dense-layer-style ``addmm`` loop at Qwen3-30B-A3B MoE shapes.
Usage::
CUDA_VISIBLE_DEVICES=2 python -u tests/flex_moe_merge_parity.py
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import torch
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
# ---------------------------------------------------------------------------
# Reference: the exact formula from the docstring, no tricks.
# ---------------------------------------------------------------------------
def reference_merge(W_pristine, adapters, *, transposed):
"""adapters: list of (A_stacked [E*R, in], B_stacked [out, E*R], scaling)."""
E = W_pristine.shape[0]
out = W_pristine.clone()
for A_stack, B_stack, scaling in adapters:
R = A_stack.shape[0] // E
in_dim = A_stack.shape[1]
out_dim = B_stack.shape[0]
for e in range(E):
A_e = A_stack[e * R : (e + 1) * R] # (R, in_dim)
B_e = B_stack[:, e * R : (e + 1) * R] # (out_dim, R)
delta = (B_e @ A_e).to(W_pristine.dtype) # (out_dim, in_dim)
if transposed:
out[e] += scaling * delta.t()
else:
out[e] += scaling * delta
return out
# ---------------------------------------------------------------------------
# Under-test: a standalone copy of the merge body from flex_moe.py so we
# can drive it without loading a 30B model. The logic is a character-for-
# character lift of lines 747-794 in unsloth/inference/flex_moe.py.
# ---------------------------------------------------------------------------
def merge_under_test(W_inf, W_pristine, adapters):
"""adapters: list of (A_stacked, B_stacked, scaling)."""
E = W_inf.shape[0]
# Orientation detection (mirrors flex_moe.py:752-763).
A_w0, B_w0, _ = adapters[0]
in_dim = A_w0.shape[1]
out_dim = B_w0.shape[0]
d0, d1 = W_inf.shape[1], W_inf.shape[2]
if d0 == out_dim and d1 == in_dim:
is_standard = True
elif d0 == in_dim and d1 == out_dim:
is_standard = False
else:
raise RuntimeError("orientation")
W_inf.copy_(W_pristine)
for A_w, B_w, scaling in adapters:
R = A_w.shape[0] // E
A_3d = A_w.view(E, R, in_dim)
B_3d = B_w.view(out_dim, E, R).permute(1, 0, 2).contiguous()
if is_standard:
torch.baddbmm(
W_inf,
B_3d.to(W_inf.dtype),
A_3d.to(W_inf.dtype),
alpha=float(scaling),
beta=1.0,
out=W_inf,
)
else:
torch.baddbmm(
W_inf,
A_3d.transpose(-2, -1).contiguous().to(W_inf.dtype),
B_3d.transpose(-2, -1).contiguous().to(W_inf.dtype),
alpha=float(scaling),
beta=1.0,
out=W_inf,
)
return W_inf
# ---------------------------------------------------------------------------
# Correctness
# ---------------------------------------------------------------------------
def _make_adapter(E, R, in_dim, out_dim, dtype, device, seed):
g = torch.Generator(device=device).manual_seed(seed)
A = torch.randn(E * R, in_dim, generator=g, device=device, dtype=dtype) * 0.02
B = torch.randn(out_dim, E * R, generator=g, device=device, dtype=dtype) * 0.02
return A, B
def test_correctness(E, in_dim, out_dim, R, dtype, device, *, transposed, n_adapters):
scalings = [2.0, 0.5][:n_adapters]
adapters = [
(*_make_adapter(E, R, in_dim, out_dim, dtype, device, seed=11 + i), s)
for i, s in enumerate(scalings)
]
if transposed:
W_pristine = torch.randn(E, in_dim, out_dim, device=device, dtype=dtype) * 0.02
else:
W_pristine = torch.randn(E, out_dim, in_dim, device=device, dtype=dtype) * 0.02
W_inf = torch.empty_like(W_pristine)
merge_under_test(W_inf, W_pristine, adapters)
W_ref = reference_merge(W_pristine, adapters, transposed=transposed)
tol = dict(atol=3e-3, rtol=3e-3) if dtype == torch.bfloat16 else dict(atol=1e-5, rtol=1e-5)
close = torch.allclose(W_inf, W_ref, **tol)
max_err = (W_inf - W_ref).abs().max().item()
scale = W_ref.abs().max().item()
rel = max_err / max(scale, 1e-8)
label = f"E={E} in={in_dim} out={out_dim} R={R} {dtype} transposed={transposed} n_adapters={n_adapters}"
verdict = "OK" if close else "FAIL"
print(f" [{verdict}] {label} max_abs={max_err:.3e} rel={rel:.3e}")
return close
# ---------------------------------------------------------------------------
# Performance: batched baddbmm vs per-expert addmm loop (same total flops).
# ---------------------------------------------------------------------------
def _sync():
if torch.cuda.is_available():
torch.cuda.synchronize()
def bench(fn, *args, iters=50, warmup=10):
for _ in range(warmup):
fn(*args)
_sync()
t0 = time.perf_counter()
for _ in range(iters):
fn(*args)
_sync()
return (time.perf_counter() - t0) / iters
def perf_vs_dense_addmm(out_dim, in_dim, R, dtype, device):
"""Dense-layer LoRA refresh: a single torch.addmm call like the one
in flex_qwen3_llama.py::refresh_lora_merge_from_pristine. Used as a
per-matrix baseline to compare against the MoE baddbmm kernel cost."""
W_pristine = torch.randn(out_dim, in_dim, device=device, dtype=dtype) * 0.02
W = torch.empty_like(W_pristine)
A = torch.randn(R, in_dim, device=device, dtype=dtype) * 0.02
B = torch.randn(out_dim, R, device=device, dtype=dtype) * 0.02
scaling = 2.0
def run_addmm():
torch.addmm(W_pristine, B, A, alpha=scaling, out=W)
t = bench(run_addmm, iters=200, warmup=20)
print(f" dense addmm out={out_dim:>5} in={in_dim:>5} R={R:>2} "
f"{t * 1e3:7.4f}ms")
return t
def perf_compare(E, in_dim, out_dim, R, dtype, device):
A_w, B_w = _make_adapter(E, R, in_dim, out_dim, dtype, device, seed=0)
W_pristine = torch.randn(E, out_dim, in_dim, device=device, dtype=dtype) * 0.02
W_inf_a = torch.empty_like(W_pristine)
W_inf_b = torch.empty_like(W_pristine)
A_3d = A_w.view(E, R, in_dim)
B_3d = B_w.view(out_dim, E, R).permute(1, 0, 2).contiguous()
scaling = 2.0
def run_baddbmm():
W_inf_a.copy_(W_pristine)
torch.baddbmm(W_inf_a, B_3d, A_3d, alpha=scaling, beta=1.0, out=W_inf_a)
def run_addmm_loop():
W_inf_b.copy_(W_pristine)
for e in range(E):
torch.addmm(
W_inf_b[e],
B_3d[e],
A_3d[e],
alpha=scaling,
out=W_inf_b[e],
)
t_bad = bench(run_baddbmm)
t_loop = bench(run_addmm_loop)
# Sanity: they produce the same result.
run_baddbmm()
run_addmm_loop()
max_err = (W_inf_a - W_inf_b).abs().max().item()
print(f" E={E:>3} out={out_dim:>5} in={in_dim:>5} R={R:>2} "
f"baddbmm={t_bad * 1e3:7.3f}ms addmm_loop={t_loop * 1e3:7.3f}ms "
f"speedup={t_loop / t_bad:5.2f}x max_abs_diff={max_err:.1e}")
def main():
device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(3407)
print(f"[merge-parity] device={device} dtype=bf16 + fp32")
print(f"[merge-parity] torch={torch.__version__}")
print()
print("== Correctness (fp32 golden + bf16 realistic) ==")
# Qwen3-30B-A3B shapes.
# gate_up_proj: (E=128, 2I=1536, H=2048) standard
# down_proj: (E=128, H=2048, I=768) standard
# Transposed variant: (E, H, 2I) — exercised via the `transposed=True` test.
cases = [
# (E, in_dim, out_dim, R, dtype, transposed, n_adapters)
( 8, 128, 192, 4, torch.float32, False, 1),
( 8, 128, 192, 4, torch.float32, True, 1),
( 8, 128, 192, 4, torch.float32, False, 2),
(16, 256, 512, 8, torch.float32, True, 2),
# bf16 at realistic Qwen3 MoE shapes. Mild tol to allow 3e-3 bf16 noise.
(128, 2048, 1536, 16, torch.bfloat16, False, 1), # gate_up_proj-like
(128, 768, 2048, 16, torch.bfloat16, False, 1), # down_proj-like
(128, 2048, 1536, 16, torch.bfloat16, True, 1), # transposed gate_up
(128, 2048, 1536, 16, torch.bfloat16, False, 2), # two adapters
]
all_ok = True
for E, in_dim, out_dim, R, dtype, tr, na in cases:
ok = test_correctness(E, in_dim, out_dim, R, dtype, device,
transposed=tr, n_adapters=na)
all_ok = all_ok and ok
print(f"\n overall: {'PASS' if all_ok else 'FAIL'}")
print()
print("== Performance: batched baddbmm vs per-expert addmm loop (bf16) ==")
print(" (same arithmetic, different dispatch pattern — baddbmm = 1 kernel,")
print(" addmm loop = E kernels)")
moe_cases = [
(128, 2048, 1536, 16), # gate_up_proj
(128, 768, 2048, 16), # down_proj
(128, 2048, 1536, 64), # larger rank
]
for E, in_dim, out_dim, R in moe_cases:
perf_compare(E, in_dim, out_dim, R, torch.bfloat16, device)
print()
print("== Dense baseline: torch.addmm on a single expert-sized matrix ==")
print(" (this is what flex_qwen3_llama.py:354-370 does for dense layers)")
dense_times = {}
for E, in_dim, out_dim, R in moe_cases:
t = perf_vs_dense_addmm(out_dim, in_dim, R, torch.bfloat16, device)
dense_times[(E, in_dim, out_dim, R)] = t
print()
print("== Per-expert cost comparison ==")
print(" (MoE baddbmm cost / E) vs single dense addmm for the same per-expert matrix")
for E, in_dim, out_dim, R in moe_cases:
# Measure baddbmm cost again for this exact shape to get the numerator.
A_w, B_w = _make_adapter(E, R, in_dim, out_dim, torch.bfloat16, device, seed=0)
W_pristine = torch.randn(E, out_dim, in_dim, device=device, dtype=torch.bfloat16) * 0.02
W_inf = torch.empty_like(W_pristine)
A_3d = A_w.view(E, R, in_dim)
B_3d = B_w.view(out_dim, E, R).permute(1, 0, 2).contiguous()
def run_baddbmm():
W_inf.copy_(W_pristine)
torch.baddbmm(W_inf, B_3d, A_3d, alpha=2.0, beta=1.0, out=W_inf)
t_moe = bench(run_baddbmm, iters=50, warmup=10)
t_dense = dense_times[(E, in_dim, out_dim, R)]
per_expert = t_moe / E
print(f" E={E:>3} out={out_dim:>5} in={in_dim:>5} R={R:>2} "
f"moe_per_expert={per_expert * 1e6:7.2f}us "
f"dense_addmm={t_dense * 1e6:7.2f}us "
f"ratio(moe/dense)={per_expert / t_dense:5.2f}x")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,190 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Tight, single-load decode throughput probe for Qwen3 MoE.
Loads the model once, captures CUDA graphs, then sweeps batch sizes.
Avoids the 30s cold-load tax of the full bench so optimization
iterations can run in under a minute per config.
Usage:
CUDA_VISIBLE_DEVICES=5 UNSLOTH_FAST_INFERENCE=1 \\
UNSLOTH_MOE_BACKEND=grouped_mm python -u \\
tests/flex_moe_micro_bench.py --load_in_4bit --bs 1,4,8,16,32
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="unsloth/Qwen3-30B-A3B-Instruct-2507")
p.add_argument("--dtype", choices=["bf16", "fp16"], default="bf16")
p.add_argument("--load_in_4bit", action="store_true")
p.add_argument("--bs", default="1,4,8,16,32",
help="comma-separated batch sizes to sweep")
p.add_argument("--max_new_tokens", type=int, default=128)
p.add_argument("--max_seq_length", type=int, default=1024)
p.add_argument("--max_batch_size", type=int, default=32)
p.add_argument("--warmup_rounds", type=int, default=1)
p.add_argument("--timed_rounds", type=int, default=2)
p.add_argument("--tag", default="baseline",
help="label for this config in the output JSON")
p.add_argument("--compile_mode", choices=["off", "walker", "walker_fullgraph"],
default="off",
help="wrap call_moe_model_with_flex_kwargs in torch.compile")
p.add_argument("--compile_opts", choices=["stock", "unsloth_O3", "inference_freeze", "coord_descent"],
default="stock",
help="which inductor / dynamo options profile to apply before compile")
p.add_argument("--explain", action="store_true",
help="run torch._dynamo.explain on the walker first to list breaks")
p.add_argument("--out_dir", default="async_task_outputs/qwen3_moe_grpo_bench_v2")
args = p.parse_args()
bs_list = [int(x) for x in args.bs.split(",") if x.strip()]
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
os.environ.setdefault("UNSLOTH_MOE_BACKEND", "grouped_mm")
import torch
import unsloth # noqa: F401
from unsloth import FastLanguageModel
from unsloth.inference import flex_moe as _flex_moe_mod
# Apply inductor / dynamo config BEFORE wrapping with torch.compile.
if args.compile_opts == "unsloth_O3":
# Aggressive autotune + coord descent + aggressive_fusion. Matches
# unsloth_zoo.patching_utils.patch_torch_compile(O3=True).
import torch._inductor.config as _ic
import torch._dynamo.config as _dc
_ic.max_autotune = True
_ic.max_autotune_pointwise = True
_ic.coordinate_descent_tuning = True
_ic.aggressive_fusion = True
_ic.cuda.use_fast_math = True
_dc.cache_size_limit = 1024
_dc.recompile_limit = 1024
_dc.capture_scalar_outputs = True
_dc.capture_dynamic_output_shape_ops = True
print("[micro] inductor/dynamo options: unsloth_O3")
elif args.compile_opts == "coord_descent":
# Just ``coordinate_descent_tuning = True`` — fast compile, small
# fusion upside.
import torch._inductor.config as _ic
_ic.coordinate_descent_tuning = True
print("[micro] inductor options: coord_descent only")
elif args.compile_opts == "inference_freeze":
# Inference-friendly: constant-fold weights via freezing=True.
# Only safe when the model weights won't be updated after compile
# (true here — we capture graphs post-load and never refresh
# during bench).
import torch._inductor.config as _ic
import torch._dynamo.config as _dc
_ic.freezing = True
_ic.max_autotune = True
_ic.coordinate_descent_tuning = True
_ic.cuda.use_fast_math = True
_dc.cache_size_limit = 1024
_dc.capture_scalar_outputs = True
print("[micro] inductor/dynamo options: inference_freeze")
# Apply torch.compile to the decode walker BEFORE the engine is built
# / graphs are captured, so the compiled kernels get recorded into the
# CUDA graph.
if args.compile_mode != "off":
fullgraph = args.compile_mode == "walker_fullgraph"
orig_walker = _flex_moe_mod.call_moe_model_with_flex_kwargs
compile_kwargs = dict(fullgraph=fullgraph, dynamic=False)
tmode = os.environ.get("FLEX_COMPILE_MODE", "")
if tmode:
compile_kwargs["mode"] = tmode
compiled = torch.compile(orig_walker, **compile_kwargs)
_flex_moe_mod.call_moe_model_with_flex_kwargs = compiled
print(f"[micro] wrapped call_moe_model_with_flex_kwargs with "
f"torch.compile(fullgraph={fullgraph}, mode={tmode or 'default'})")
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
torch.cuda.reset_peak_memory_stats()
t0 = time.perf_counter()
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model,
max_seq_length=args.max_seq_length,
dtype=dtype,
load_in_4bit=args.load_in_4bit,
fast_inference=True,
max_batch_size=args.max_batch_size,
)
print(f"[micro] loaded in {time.perf_counter() - t0:.1f}s")
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
results = []
for bs in bs_list:
prompts = [f"The quick brown fox jumps over fence {i}, then"
for i in range(bs)]
# Warmup (first call captures the graphs for all buckets).
for _ in range(args.warmup_rounds):
_ = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
# Timed.
wall = []
n_tok = []
for _ in range(args.timed_rounds):
torch.cuda.synchronize()
t0 = time.perf_counter()
outs = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
torch.cuda.synchronize()
wall.append(time.perf_counter() - t0)
n_tok.append(sum(len(o.outputs[0].token_ids) for o in outs))
med_wall = sorted(wall)[len(wall) // 2]
med_tok = n_tok[len(wall) // 2]
tps = med_tok / med_wall if med_wall > 0 else 0.0
# Sanity: print the first completion so we can eyeball for
# gibberish. A compile bug or bad capture shows up here first.
sample_text = outs[0].outputs[0].text if outs else ""
sample_preview = sample_text.replace("\n", "\\n")[:120]
print(f"[micro] bs={bs:>3} tok={med_tok:>5} "
f"wall={med_wall:.3f}s tok/s={tps:.1f}")
print(f"[micro] bs={bs:>3} completion[0]: {sample_preview!r}")
results.append({
"bs": bs,
"max_new_tokens": args.max_new_tokens,
"median_wall_s": round(med_wall, 3),
"median_tok": med_tok,
"tok_per_s": round(tps, 1),
"wall_times_s": wall,
"sample_completion": sample_text[:400],
})
peak = torch.cuda.max_memory_reserved() / 1024**3
precision = "4bit" if args.load_in_4bit else args.dtype
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"micro_bench_{args.tag}_{precision}.json"
with open(out_path, "w") as f:
json.dump({
"tag": args.tag,
"precision": precision,
"peak_vram_gb": round(peak, 2),
"results": results,
}, f, indent=2)
print(f"[micro] peak VRAM: {peak:.1f} GB")
print(f"[micro] wrote {out_path}")
if __name__ == "__main__":
main()

184
tests/flex_moe_parity.py Normal file
View file

@ -0,0 +1,184 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Token-level parity: FlexMoEInference (CUDA-graph capture) vs HF generate.
Same prompt set, temperature=0, max_new_tokens fixed. Reports per-prompt
token-id match rate + first divergence index. Serves as the correctness
check for the v2 grouped_mm + CUDA-graph-capture changes.
Usage:
CUDA_VISIBLE_DEVICES=5 UNSLOTH_FAST_INFERENCE=1 \
UNSLOTH_MOE_BACKEND=grouped_mm python -u \
tests/flex_moe_parity.py --load_in_4bit
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def _run_flex(prompts, args, dtype, *, capture: bool):
import torch
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
os.environ.setdefault("UNSLOTH_MOE_BACKEND", "grouped_mm")
import unsloth # noqa: F401
from unsloth import FastLanguageModel
if not capture:
# Monkey-patch ``capture_decode_cudagraph`` to a no-op BEFORE the
# engine is built so ``generate`` takes the eager branch. The
# engine's ``self.graphs`` stays empty, ``cudagraph_captured``
# stays False, and every step goes through ``_decode_step_eager``.
from unsloth.inference.flex_moe import FlexMoEInference
FlexMoEInference.capture_decode_cudagraph = lambda self: None
# Opt-in torch.compile wrap of the decode walker for parity check
# — enabled via env var to avoid cluttering the CLI further.
if os.environ.get("FLEX_MOE_COMPILE_WALKER") == "1":
import torch as _torch
from unsloth.inference import flex_moe as _flex_moe_mod
_orig = _flex_moe_mod.call_moe_model_with_flex_kwargs
_flex_moe_mod.call_moe_model_with_flex_kwargs = _torch.compile(
_orig, fullgraph=False, dynamic=False
)
print("[parity] torch.compile(call_moe_model_with_flex_kwargs) enabled")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model,
max_seq_length=args.max_seq_length,
dtype=dtype,
load_in_4bit=args.load_in_4bit,
fast_inference=True,
)
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
# First call warms / captures; second call is the measurement.
_ = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
outputs = model.fast_generate(prompts, sampling_params=_SP(), use_tqdm=False)
token_ids = [list(o.outputs[0].token_ids) for o in outputs]
texts = [o.outputs[0].text for o in outputs]
return token_ids, texts, tokenizer
def _run_hf(prompts, args, dtype):
import torch
# Pure Hugging Face: NO ``import unsloth`` — we want the unpatched
# reference forward to compare flex against. Quantization via
# transformers' ``BitsAndBytesConfig`` matches what unsloth loads
# under the hood for ``load_in_4bit=True``.
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
# Translate the unsloth-flavoured model id (unsloth/Qwen3-30B-A3B-Instruct-2507)
# to the 4bit variant if load_in_4bit was requested (FastLanguageModel
# does this implicitly; do it explicitly here for the naive path).
model_id = args.model
quant_cfg = None
if args.load_in_4bit:
quant_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=dtype,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
dtype=dtype,
quantization_config=quant_cfg,
device_map="cuda",
attn_implementation="eager",
)
model.eval()
# Qwen3-30B-A3B-Instruct-2507 uses <|vision_pad|> as its pad token.
# Unsloth's loader may swap it to a sentinel; reset to the HF default
# so batched left-padded generation matches the authoritative config.
if tokenizer.pad_token_id is None or tokenizer.pad_token == "<|PAD_TOKEN|>":
tokenizer.pad_token = "<|vision_pad|>"
tokenizer.padding_side = "left"
gen_kwargs = dict(
max_new_tokens=args.max_new_tokens,
do_sample=False,
temperature=1.0,
pad_token_id=tokenizer.pad_token_id,
)
inputs = tokenizer(prompts, return_tensors="pt", padding=True).to("cuda")
out = model.generate(**inputs, **gen_kwargs)
prompt_len = inputs["input_ids"].shape[1]
eos = tokenizer.eos_token_id
pad = tokenizer.pad_token_id
token_ids = []
texts = []
for row in out:
ids = row[prompt_len:].tolist()
while ids and ids[-1] in (eos, pad):
ids.pop()
token_ids.append(ids)
texts.append(tokenizer.decode(ids, skip_special_tokens=True))
return token_ids, texts, tokenizer
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="unsloth/Qwen3-30B-A3B-Instruct-2507")
p.add_argument("--dtype", choices=["bf16", "fp16"], default="bf16")
p.add_argument("--load_in_4bit", action="store_true")
p.add_argument("--max_new_tokens", type=int, default=32)
p.add_argument("--max_seq_length", type=int, default=1024)
p.add_argument("--backend", choices=["flex", "flex_eager", "hf"], required=True)
p.add_argument("--out_dir", default="async_task_outputs/qwen3_moe_grpo_bench_v2")
args = p.parse_args()
import torch
prompts = [
"The quick brown fox jumps over",
"Q: What is 23 + 19?\nA:",
"Paris is the capital of",
]
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
if args.backend == "flex":
token_ids, texts, tok = _run_flex(prompts, args, dtype, capture=True)
elif args.backend == "flex_eager":
token_ids, texts, tok = _run_flex(prompts, args, dtype, capture=False)
else:
token_ids, texts, tok = _run_hf(prompts, args, dtype)
precision = "4bit" if args.load_in_4bit else args.dtype
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"parity_{args.backend}_{precision}.json"
with open(out_path, "w") as f:
json.dump(
{
"backend": args.backend,
"precision": precision,
"prompts": prompts,
"token_ids": token_ids,
"texts": texts,
},
f,
indent=2,
)
print(f"[parity-{args.backend}] wrote {out_path}")
for i, (p_, t_) in enumerate(zip(prompts, texts)):
print(f"[parity-{args.backend}] prompt {i}: {p_!r}")
print(f"[parity-{args.backend}] completion {i}: {t_!r}")
if __name__ == "__main__":
main()

156
tests/flex_moe_smoke.py Normal file
View file

@ -0,0 +1,156 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Smoke-test ``UNSLOTH_FAST_INFERENCE=1`` on a Qwen3 MoE model.
Mirrors ``tests/flex_fastlm_smoke.py`` but targets the new
``FlexMoEInference`` path added for ``Qwen3MoeForCausalLM``.
Invoked as:
CUDA_VISIBLE_DEVICES=0 UNSLOTH_FAST_INFERENCE=1 python -u \
tests/flex_moe_smoke.py \
--model unsloth/Qwen3-30B-A3B-Instruct-2507 \
--load_in_4bit
Writes a small JSON summary to
``async_task_outputs/qwen3_moe_grpo_bench/smoke_A_{precision}.json``.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def main():
p = argparse.ArgumentParser()
p.add_argument(
"--model", default = "unsloth/Qwen3-30B-A3B-Instruct-2507"
)
p.add_argument("--dtype", choices = ["bf16", "fp16"], default = "bf16")
p.add_argument("--load_in_4bit", action = "store_true")
p.add_argument("--with_lora", action = "store_true")
p.add_argument("--max_new_tokens", type = int, default = 32)
p.add_argument("--max_seq_length", type = int, default = 1024)
p.add_argument("--prompt", default = "The quick brown fox jumps over")
p.add_argument("--out_dir", default = "async_task_outputs/qwen3_moe_grpo_bench")
args = p.parse_args()
import torch
os.environ.setdefault("UNSLOTH_FAST_INFERENCE", "1")
os.environ.setdefault("UNSLOTH_MOE_BACKEND", "grouped_mm")
print(f"[smoke] UNSLOTH_FAST_INFERENCE={os.environ.get('UNSLOTH_FAST_INFERENCE')}")
print(f"[smoke] UNSLOTH_MOE_BACKEND={os.environ.get('UNSLOTH_MOE_BACKEND')}")
import unsloth
print(f"[smoke] unsloth={unsloth.__file__}")
from unsloth import FastLanguageModel
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
torch.cuda.reset_peak_memory_stats()
t0 = time.perf_counter()
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = args.model,
max_seq_length = args.max_seq_length,
dtype = dtype,
load_in_4bit = args.load_in_4bit,
fast_inference = True,
)
t_load = time.perf_counter() - t0
peak_after_load = torch.cuda.max_memory_reserved() / 1024**3
print(f"[smoke] loaded model in {t_load:.1f}s; peak VRAM after load: {peak_after_load:.2f} GB")
print(f"[smoke] hasattr(model, 'vllm_engine'): {hasattr(model, 'vllm_engine')}")
print(f"[smoke] vllm_engine type: {type(model.vllm_engine).__name__}")
arch = getattr(model.vllm_engine, "arch", "?")
impl = type(model.vllm_engine._impl).__name__
print(f"[smoke] FlexEngine.arch={arch} impl={impl}")
if args.with_lora:
model = FastLanguageModel.get_peft_model(
model,
r = 16,
target_modules = [
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj", "gate_up_proj",
],
lora_alpha = 32,
lora_dropout = 0.0,
bias = "none",
use_gradient_checkpointing = "unsloth",
random_state = 3407,
)
print(f"[smoke] PEFT model type: {type(model).__name__}")
prompts = [args.prompt]
class _SP:
max_tokens = args.max_new_tokens
temperature = 0.0
# First call includes prefill + any lazy engine bring-up; measure separately.
t_first0 = time.perf_counter()
outputs = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
t_first = time.perf_counter() - t_first0
out = outputs[0]
n_tok = len(out.outputs[0].token_ids)
# Warm steady-state: run again and measure.
t_warm0 = time.perf_counter()
outputs2 = model.fast_generate(prompts, sampling_params = _SP(), use_tqdm = False)
t_warm = time.perf_counter() - t_warm0
n_tok_warm = len(outputs2[0].outputs[0].token_ids)
peak_after_gen = torch.cuda.max_memory_reserved() / 1024**3
print(
f"[smoke] first call: generated {n_tok} tokens in {t_first:.2f}s "
f"({n_tok / t_first:.1f} tok/s)"
)
print(
f"[smoke] warm call: generated {n_tok_warm} tokens in {t_warm:.2f}s "
f"({n_tok_warm / t_warm:.1f} tok/s)"
)
print(f"[smoke] peak VRAM after gen: {peak_after_gen:.2f} GB")
print(f"[smoke] prompt: {args.prompt!r}")
print(f"[smoke] completion: {out.outputs[0].text!r}")
precision = "4bit" if args.load_in_4bit else args.dtype
out_dir = Path(args.out_dir)
out_dir.mkdir(parents = True, exist_ok = True)
summary = {
"phase": "smoke_A",
"model": args.model,
"precision": precision,
"dtype": str(dtype),
"max_seq_length": args.max_seq_length,
"max_new_tokens": args.max_new_tokens,
"with_lora": args.with_lora,
"t_load_s": round(t_load, 2),
"peak_vram_after_load_gb": round(peak_after_load, 2),
"peak_vram_after_gen_gb": round(peak_after_gen, 2),
"first_call_s": round(t_first, 2),
"first_call_tok_s": round(n_tok / t_first, 1),
"warm_call_s": round(t_warm, 2),
"warm_call_tok_s": round(n_tok_warm / t_warm, 1),
"arch": arch,
"impl": impl,
"prompt": args.prompt,
"completion": out.outputs[0].text,
}
with open(out_dir / f"smoke_A_{precision}.json", "w") as f:
json.dump(summary, f, indent = 2)
print(f"[smoke] wrote {out_dir / f'smoke_A_{precision}.json'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,217 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""vLLM direct throughput comparison for Qwen3 MoE.
Uses ``vllm.LLM`` directly (no unsloth) to bench the same workload
as ``tests/flex_moe_bench.py`` so the flex+compile_walker numbers
have an apples-to-apples vLLM baseline.
Usage:
CUDA_VISIBLE_DEVICES=2 python -u \\
tests/flex_moe_vllm_bench.py --load_in_4bit
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
# DeepGEMM isn't installed in this env; vLLM's FP8 warmup crashes the
# engine-core subprocess without it. Disable before importing vllm.
os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0")
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def _gpu_mem_used_gb() -> float:
try:
out = subprocess.run(
["nvidia-smi", "--query-gpu=memory.used",
"--format=csv,noheader,nounits", "-i", "0"],
capture_output=True, text=True, timeout=5,
)
return int(out.stdout.strip().splitlines()[0]) / 1024
except Exception:
return 0.0
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="unsloth/Qwen3-30B-A3B-Instruct-2507")
p.add_argument("--dtype", choices=["bf16", "fp16"], default="bf16")
p.add_argument("--load_in_4bit", action="store_true",
help="use the -bnb-4bit checkpoint variant")
p.add_argument("--n_prompts", type=int, default=8)
p.add_argument("--max_new_tokens", type=int, default=64)
p.add_argument("--max_model_len", type=int, default=1024)
p.add_argument("--warmup_rounds", type=int, default=1)
p.add_argument("--timed_rounds", type=int, default=2)
p.add_argument("--out_dir", default="async_task_outputs/qwen3_moe_grpo_bench_v2")
p.add_argument("--gpu_memory_utilization", type=float, default=0.85)
p.add_argument("--enable_lora", action="store_true",
help="enable vLLM LoRA serving; requires --lora_path")
p.add_argument("--lora_path", default=None,
help="path to a LoRA adapter dir to load per-request")
p.add_argument("--max_lora_rank", type=int, default=16)
p.add_argument("--enforce_eager", action="store_true",
help="disable vLLM CUDA graph capture (diagnostic)")
p.add_argument("--tag", default=None,
help="extra label for the output json filename")
p.add_argument("--chat_template", action="store_true",
help="wrap each prompt with the tokenizer's chat "
"template (needed for chat-tuned models like "
"gpt-oss that produce gibberish on raw strings)")
p.add_argument("--user_prompt",
default="Continue this sentence: The quick brown fox jumps over fence {i}, then",
help="user-message template when --chat_template is set "
"(``{i}`` gets replaced with the prompt index)")
args = p.parse_args()
import torch
from vllm import LLM, SamplingParams
import vllm
print(f"[vllm] version: {vllm.__version__}")
dtype_str = "bfloat16" if args.dtype == "bf16" else "float16"
# For 4bit, tell vLLM to apply bnb quantization on-the-fly to the
# bf16 checkpoint (there's no pre-quantized unsloth 4bit variant for
# this model on HF). For FP8, point at the FP8 variant directly.
model_id = args.model
quantization = None
if args.load_in_4bit:
quantization = "bitsandbytes"
print(f"[vllm] 4bit: model_id={model_id} quantization=bitsandbytes (on-the-fly)")
elif os.environ.get("USE_FP8") == "1":
model_id = args.model + "-FP8"
quantization = "fp8"
print(f"[vllm] fp8: model_id={model_id}")
llm_kwargs = dict(
model=model_id,
dtype=dtype_str,
quantization=quantization,
gpu_memory_utilization=args.gpu_memory_utilization,
max_model_len=args.max_model_len,
max_num_seqs=max(args.n_prompts, 8),
enforce_eager=args.enforce_eager,
trust_remote_code=False,
)
if args.enable_lora:
llm_kwargs.update(
enable_lora=True,
max_lora_rank=args.max_lora_rank,
max_loras=1,
)
torch.cuda.reset_peak_memory_stats()
t0 = time.perf_counter()
llm = LLM(**llm_kwargs)
t_load = time.perf_counter() - t0
# vLLM uses its own allocator; torch's reserved bytes miss it.
# Query nvidia-smi instead (visible GPU only — i.e. whatever
# CUDA_VISIBLE_DEVICES exposes as device 0).
peak_load = _gpu_mem_used_gb()
print(f"[vllm] loaded in {t_load:.1f}s peak {peak_load:.1f} GB")
# Use vLLM's native ``llm.chat`` when chat-template is requested —
# it threads harmony-aware templating + tokenization through vLLM's
# chat_utils, which is what gpt-oss needs (hand-rolled
# apply_chat_template + raw ``llm.generate`` was producing gibberish
# on gpt-oss-20b-BF16 no-LoRA).
if args.chat_template:
chat_messages = [
[{"role": "user", "content": args.user_prompt.format(i=i)}]
for i in range(args.n_prompts)
]
print(f"[vllm] chat mode: llm.chat() with {args.n_prompts} single-turn "
f"messages. first: {chat_messages[0][0]['content']!r}")
prompts = None
else:
prompts = [f"The quick brown fox jumps over fence {i}, then"
for i in range(args.n_prompts)]
chat_messages = None
sp = SamplingParams(max_tokens=args.max_new_tokens, temperature=0.0)
gen_kwargs = {}
if args.enable_lora and args.lora_path is not None:
from vllm.lora.request import LoRARequest
gen_kwargs["lora_request"] = LoRARequest(
lora_name="flex_lora",
lora_int_id=1,
lora_path=args.lora_path,
)
print(f"[vllm] LoRA enabled: path={args.lora_path} rank<={args.max_lora_rank}")
def _run_once():
if chat_messages is not None:
return llm.chat(chat_messages, sampling_params=sp,
use_tqdm=False, **gen_kwargs)
return llm.generate(prompts, sampling_params=sp,
use_tqdm=False, **gen_kwargs)
# Warmup.
for _ in range(args.warmup_rounds):
_ = _run_once()
# Timed.
wall = []
tok_counts = []
for _ in range(args.timed_rounds):
torch.cuda.synchronize()
t0 = time.perf_counter()
outs = _run_once()
torch.cuda.synchronize()
wall.append(time.perf_counter() - t0)
tok_counts.append(sum(len(o.outputs[0].token_ids) for o in outs))
peak_gen = _gpu_mem_used_gb()
med_wall = sorted(wall)[len(wall) // 2]
med_tok = tok_counts[len(wall) // 2]
tps = med_tok / med_wall if med_wall > 0 else 0.0
sample_text = outs[0].outputs[0].text if outs else ""
print(f"[vllm] wall: {wall}")
print(f"[vllm] tok counts: {tok_counts}")
print(f"[vllm] median wall: {med_wall:.3f}s median tok/s: {tps:.1f}")
print(f"[vllm] peak VRAM after gen: {peak_gen:.1f} GB")
print(f"[vllm] sample completion[0]: {sample_text[:200]!r}")
precision = "4bit" if args.load_in_4bit else args.dtype
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
summary = {
"phase": "bench_decode",
"backend": "vllm",
"vllm_version": vllm.__version__,
"model": model_id,
"precision": precision,
"n_prompts": args.n_prompts,
"max_new_tokens": args.max_new_tokens,
"wall_times_s": wall,
"tok_counts": tok_counts,
"median_wall_s": round(med_wall, 3),
"median_tok_s": round(tps, 1),
"peak_vram_load_gb": round(peak_load, 2),
"peak_vram_after_gen_gb": round(peak_gen, 2),
"t_load_s": round(t_load, 1),
"sample_completion": sample_text[:500],
}
suffix = f"_{args.tag}" if args.tag else ""
out_path = out_dir / f"bench_decode_vllm_{precision}{suffix}.json"
with open(out_path, "w") as f:
json.dump(summary, f, indent=2)
print(f"[vllm] wrote {out_path}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,297 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Smoke-test :meth:`FlexEngine.sleep` and :meth:`FlexEngine.wake_up`.
Invoked as:
CUDA_VISIBLE_DEVICES=3 \
UNSLOTH_FAST_INFERENCE=1 UNSLOTH_VLLM_STANDBY=1 \
python tests/flex_sleep_mode_smoke.py --model unsloth/Qwen3-4B-Base
What it checks:
1. ``model.vllm_engine._sleep_mode_enabled`` is True when vLLM is
importable.
2. Captured CUDA graphs survive a sleep/wake round-trip: a second
``fast_generate`` call after ``sleep`` -> ``wake_up`` does not
re-capture and still produces the same token ids.
3. ``torch.cuda.memory_allocated()`` drops on sleep and returns close
to the pre-sleep value on wake. With ``--no-standby``, memory
should be identical across the three probes (no-op path).
4. Hardens the regression guard by running the same workflow with
``UNSLOTH_VLLM_STANDBY`` unset (``--no-standby``); ``sleep`` /
``wake_up`` must be exact no-ops.
This is a smoke test, not the full verification matrix from the plan
(that lives under ``scripts/benchmarks``).
"""
from __future__ import annotations
import argparse
import gc
import os
import sys
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def _gb(n: int) -> float:
return round(n / 1e9, 3)
def _probe(label: str) -> dict:
import torch
gc.collect()
torch.cuda.synchronize()
# ``memory_allocated`` / ``memory_reserved`` only track torch's
# caching allocator and ignore cuMem-backed pools, so they do NOT
# drop on sleep even though cuMem has unmapped the pages.
# ``mem_get_info()`` asks the CUDA runtime directly, so it sees
# cuMem unmaps and is the right probe for sleep / wake verification.
free_bytes, total_bytes = torch.cuda.mem_get_info()
return {
"label": label,
"allocated_gb": _gb(torch.cuda.memory_allocated()),
"reserved_gb": _gb(torch.cuda.memory_reserved()),
"cuda_free_gb": _gb(free_bytes),
"cuda_used_gb": _gb(total_bytes - free_bytes),
}
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default = "unsloth/Qwen3-4B-Base")
p.add_argument("--dtype", choices = ["bf16", "fp16"], default = "bf16")
p.add_argument("--load_in_4bit", action = "store_true")
p.add_argument("--max_new_tokens", type = int, default = 32)
p.add_argument("--max_seq_length", type = int, default = 1024)
p.add_argument("--prompt", default = "The quick brown fox jumps over")
p.add_argument(
"--no-standby",
action = "store_true",
help = "Force UNSLOTH_VLLM_STANDBY=0 to validate the no-op regression path.",
)
p.add_argument(
"--cycles",
type = int,
default = 1,
help = "Number of sleep / wake / generate cycles after warmup. "
">1 exercises the repeated-cycle regression (run #6).",
)
args = p.parse_args()
os.environ.setdefault("UNSLOTH_FAST_INFERENCE", "1")
if args.no_standby:
os.environ["UNSLOTH_VLLM_STANDBY"] = "0"
else:
os.environ.setdefault("UNSLOTH_VLLM_STANDBY", "1")
standby = os.environ.get("UNSLOTH_VLLM_STANDBY", "0") == "1"
print(
f"[sleep-smoke] UNSLOTH_FAST_INFERENCE="
f"{os.environ.get('UNSLOTH_FAST_INFERENCE')} "
f"UNSLOTH_VLLM_STANDBY={os.environ.get('UNSLOTH_VLLM_STANDBY')}"
)
import torch
import unsloth
from unsloth import FastLanguageModel
print(f"[sleep-smoke] unsloth={unsloth.__file__}")
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
t0 = time.perf_counter()
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = args.model,
max_seq_length = args.max_seq_length,
dtype = dtype,
load_in_4bit = args.load_in_4bit,
fast_inference = True,
)
print(
f"[sleep-smoke] loaded {args.model} in "
f"{time.perf_counter() - t0:.1f}s; dtype={model.dtype}"
)
engine = model.vllm_engine
print(f"[sleep-smoke] engine type: {type(engine).__name__}")
sleep_enabled = getattr(engine, "_sleep_mode_enabled", None)
print(f"[sleep-smoke] engine._sleep_mode_enabled: {sleep_enabled}")
if standby:
if sleep_enabled is not True:
# Most likely vLLM is not importable in this environment.
print(
"[sleep-smoke] WARNING: UNSLOTH_VLLM_STANDBY=1 was set "
"but engine._sleep_mode_enabled is False (vLLM missing?)"
)
else:
assert sleep_enabled is False, (
f"Expected sleep mode to be disabled with UNSLOTH_VLLM_STANDBY=0, "
f"got {sleep_enabled}"
)
ll_cfg = engine.llm_engine.vllm_config.model_config
print(
f"[sleep-smoke] llm_engine.vllm_config.model_config.enable_sleep_mode: "
f"{getattr(ll_cfg, 'enable_sleep_mode', None)}"
)
assert getattr(ll_cfg, "enable_sleep_mode", None) == bool(sleep_enabled), (
"_LLMEngineStub.model_config.enable_sleep_mode must mirror "
"engine._sleep_mode_enabled"
)
from unsloth.inference.vllm_shim import LoRARequest # noqa: F401
prompts = [args.prompt]
# ----- warmup -----
t0 = time.perf_counter()
out1 = engine.generate(
prompts,
sampling_params = type(
"SP",
(),
{"max_tokens": args.max_new_tokens, "temperature": 0.0},
)(),
)
print(
f"[sleep-smoke] warmup generate: {time.perf_counter() - t0:.2f}s; "
f"tok_ids[:10]={out1[0].outputs[0].token_ids[:10]}"
)
pre_tokens = list(out1[0].outputs[0].token_ids)
probe_pre = _probe("pre-sleep")
print(f"[sleep-smoke] {probe_pre}")
# Diagnostic: checksum the inference-model weights so we can detect
# if cuMem's sleep/wake round-trip corrupts any parameter.
def _checksum_params(mod, limit = 16):
import torch as _t
out = []
for i, (name, p) in enumerate(mod.named_parameters()):
if i >= limit:
break
t = p.detach()
out.append((name, list(t.shape), float(t.float().abs().sum().item())))
return out
pre_sums = _checksum_params(engine._inference_model)
print("[sleep-smoke] pre-sleep first-16 param |sum|:")
for n, s, v in pre_sums:
print(f" {n} {s} {v:.4f}")
# Repeated sleep / wake / generate cycle test (plan matrix run #6).
# Each cycle validates that the engine does not drift: tokens remain
# bitwise identical, memory returns to baseline, weights round-trip
# cleanly. A bug that only surfaces on the second or third cycle
# (stale Python state, double-wake, leaked handles) fails here.
for cycle in range(args.cycles):
if args.cycles > 1:
print(f"[sleep-smoke] --- cycle {cycle + 1}/{args.cycles} ---")
# ----- sleep -----
t0 = time.perf_counter()
engine.sleep(level = 1)
t_sleep = time.perf_counter() - t0
probe_post = _probe(f"post-sleep[{cycle + 1}]")
print(f"[sleep-smoke] sleep(level=1) took {t_sleep:.3f}s; {probe_post}")
if sleep_enabled:
drop = probe_pre["cuda_used_gb"] - probe_post["cuda_used_gb"]
print(
f"[sleep-smoke] process-level VRAM drop on sleep: "
f"{drop:+.3f} GB (cuMem-managed; not visible in "
f"torch.memory_allocated)"
)
if not getattr(engine, "_single_copy_mode", False):
# 16-bit path: both weights + kv_cache pools are dropped.
assert drop >= 1.0, (
f"Expected multi-GB drop in process-level VRAM on "
f"sleep(level=1); got {drop:+.3f} GB"
)
else:
# 4-bit single-copy: only KV cache drops; weights stay.
assert drop > 0.0, (
f"Expected KV-cache drop in process-level VRAM on "
f"sleep(level=1); got {drop:+.3f} GB"
)
else:
# With sleep mode off, the sleep() call must not free VRAM.
# Process-level jitter is allowed (shared GPU); torch-owned
# allocations must be untouched.
assert probe_post["allocated_gb"] == probe_pre["allocated_gb"], (
"With sleep mode disabled, torch.memory_allocated must "
"be unchanged by sleep()"
)
# ----- wake -----
t0 = time.perf_counter()
engine.wake_up()
t_wake = time.perf_counter() - t0
probe_wake = _probe(f"post-wake[{cycle + 1}]")
print(f"[sleep-smoke] wake_up() took {t_wake:.3f}s; {probe_wake}")
post_sums = _checksum_params(engine._inference_model)
diffs = []
for (n1, s1, v1), (n2, s2, v2) in zip(pre_sums, post_sums):
delta = abs(v1 - v2)
if delta > 0.0:
diffs.append((n1, v1, v2, delta))
print(
f"[sleep-smoke] post-wake weight diff: "
f"{len(diffs)}/{len(pre_sums)} params changed "
f"(bitwise-exact restore expected)"
)
for n, v1, v2, d in diffs[:8]:
print(f" diff {n}: pre={v1:.4f} post={v2:.4f} delta={d:.4f}")
assert len(diffs) == 0, (
f"Weight corruption on sleep / wake (cycle {cycle + 1}): "
f"{len(diffs)}/{len(pre_sums)} first-layer params changed"
)
# ----- verify we can still generate -----
t0 = time.perf_counter()
out2 = engine.generate(
prompts,
sampling_params = type(
"SP",
(),
{"max_tokens": args.max_new_tokens, "temperature": 0.0},
)(),
)
t_regen = time.perf_counter() - t0
post_tokens = list(out2[0].outputs[0].token_ids)
match = pre_tokens == post_tokens
print(
f"[sleep-smoke] post-wake generate: {t_regen:.2f}s; "
f"tok_ids[:10]={post_tokens[:10]}; matches_pre={match}"
)
assert match, (
f"Pre-sleep / post-wake token ids must match exactly "
f"(cycle {cycle + 1}).\n"
f"Pre: {pre_tokens}\nPost: {post_tokens}"
)
if sleep_enabled:
delta = probe_wake["cuda_used_gb"] - probe_pre["cuda_used_gb"]
print(
f"[sleep-smoke] post-wake vs pre-sleep process-level "
f"VRAM delta: {delta:+.3f} GB (tolerance: +/- 1.5 GB)"
)
assert abs(delta) < 1.5, (
f"Post-wake VRAM diverged from pre-sleep "
f"(cycle {cycle + 1}): delta={delta:+.3f} GB"
)
print(f"[sleep-smoke] PASS ({args.cycles} cycle(s))")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,141 @@
"""Greedy parity: FastLanguageModel(fast_inference=True) vs HF naive on Gemma 4.
Covers all four Gemma 4 variants (E2B / E4B dense, 31B dense, 26B-A4B MoE) via
``--model``. Runs HF FIRST, frees the GPU, then runs vLLM loading vLLM first
in the same process leaves global state (allocator, compile cache, patched
functions) that subtly perturbs a later plain HF run, producing false
divergences. HF-first ordering gives bitwise matches.
Requires vLLM nightly (>= 2026-04-17 for `vllm#39291` Gemma 4 LoRA) plus the
`unsloth-zoo#603` vLLM Gemma 4 patches.
Example:
CUDA_VISIBLE_DEVICES=0 python -u tests/gemma4_fast_inference_parity.py \\
--model unsloth/gemma-4-26b-a4b-it --max_new_tokens 32
"""
import argparse
import gc
import json
import os
os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0")
os.environ.setdefault("UNSLOTH_MOE_BACKEND", "grouped_mm")
# Import unsloth at module scope so its patches apply to the HF run as well —
# users always import unsloth before touching transformers in practice.
import unsloth # noqa: F401, E402
import torch # noqa: E402
def _render(tok, raw_prompts):
return [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False, add_generation_prompt=True,
)
for p in raw_prompts
]
def run_hf_first(model_name, raw_prompts, max_new_tokens):
from transformers import AutoModelForImageTextToText, AutoProcessor
proc = AutoProcessor.from_pretrained(model_name)
tok = proc.tokenizer if hasattr(proc, "tokenizer") else proc
prompts = _render(tok, raw_prompts)
model = AutoModelForImageTextToText.from_pretrained(
model_name, dtype=torch.bfloat16,
attn_implementation="sdpa", device_map="cuda:0",
)
model.eval()
ids, texts = [], []
for p in prompts:
enc = tok(p, return_tensors="pt").to("cuda:0")
with torch.inference_mode():
gen = model.generate(
**enc, max_new_tokens=max_new_tokens,
do_sample=False, temperature=None, top_p=None,
)
new = gen[0, enc.input_ids.shape[1]:]
ids.append(new.tolist())
texts.append(tok.decode(new, skip_special_tokens=True))
del model, proc
gc.collect()
torch.cuda.empty_cache()
return ids, texts, prompts
def run_vllm_second(model_name, prompts, max_new_tokens):
from unsloth import FastLanguageModel
from vllm import SamplingParams
model, _ = FastLanguageModel.from_pretrained(
model_name=model_name, max_seq_length=1024, dtype=torch.bfloat16,
load_in_4bit=False, fast_inference=True, max_batch_size=8,
gpu_memory_utilization=0.6, max_lora_rank=16,
)
sp = SamplingParams(max_tokens=max_new_tokens, temperature=0.0)
outs = model.fast_generate(prompts, sampling_params=sp, use_tqdm=False)
ids = [list(o.outputs[0].token_ids) for o in outs]
texts = [o.outputs[0].text for o in outs]
return ids, texts
DEFAULT_PROMPTS = [
"In one sentence, what is Paris?",
"What is 23 + 19? Answer in one word.",
"Continue this phrase: The quick brown fox jumps over",
]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="unsloth/gemma-4-26b-a4b-it")
ap.add_argument("--max_new_tokens", type=int, default=32)
ap.add_argument("--json_out", default=None)
args = ap.parse_args()
print(f"=== HF first ({args.model}) ===")
hf_ids, hf_texts, prompts = run_hf_first(
args.model, DEFAULT_PROMPTS, args.max_new_tokens,
)
for i, t in enumerate(hf_texts):
print(f"[hf] P{i}: {t[:120]!r}")
print("\n=== vLLM second ===")
fast_ids, fast_texts = run_vllm_second(
args.model, prompts, args.max_new_tokens,
)
for i, t in enumerate(fast_texts):
print(f"[fast] P{i}: {t[:120]!r}")
print("\n=== Match ===")
total, matched, rows = 0, 0, []
for i, (a, b) in enumerate(zip(fast_ids, hf_ids)):
n = min(len(a), len(b))
m = sum(1 for x, y in zip(a[:n], b[:n]) if x == y)
total += n
matched += m
print(f"[P{i}] {m}/{n}")
rows.append({
"prompt_id": i, "match": m, "total": n,
"fast": fast_texts[i], "hf": hf_texts[i],
})
print(f"\nTotal {matched}/{total}")
if args.json_out:
os.makedirs(os.path.dirname(args.json_out) or ".", exist_ok=True)
with open(args.json_out, "w") as f:
json.dump({
"model": args.model, "rows": rows,
"matched": matched, "total": total,
}, f, indent=2)
# Exit non-zero on any divergence so CI can gate on bitwise parity.
if matched != total:
raise SystemExit(f"Divergence: {matched}/{total}")
if __name__ == "__main__":
main()

132
tests/gemma4_flex_bench.py Normal file
View file

@ -0,0 +1,132 @@
"""Throughput bench for Unsloth flex fast-inference on Gemma 4.
Runs ``FastLanguageModel(fast_inference=True)`` through the flex engine
(``UNSLOTH_FAST_INFERENCE=1``) and measures tok/s at a list of batch sizes.
Captures CUDA graphs at each bucket; the bench reports the post-warmup pass.
Example:
CUDA_VISIBLE_DEVICES=0 UNSLOTH_FAST_INFERENCE=1 \\
UNSLOTH_MOE_BACKEND=grouped_mm \\
python -u tests/gemma4_flex_bench.py \\
--model unsloth/gemma-4-26b-a4b-it --batch_sizes 1 4 8 16 \\
--max_new_tokens 64 --json_out async_task_outputs/flex_bench_26b.json
Pair with tests/gemma4_fast_inference_parity.py for HF-naive parity and
with tests/gemma4_fast_bench.py (vLLM nightly path) for cross-engine
throughput comparison.
"""
import argparse
import json
import os
import time
os.environ.setdefault("UNSLOTH_FAST_INFERENCE", "1")
os.environ.setdefault("UNSLOTH_MOE_BACKEND", "grouped_mm")
os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0")
import unsloth # noqa: F401, E402
import torch # noqa: E402
SHORT_PROMPT = "In one sentence, what is Paris?"
LONG_POOL = [
"In one sentence, what is Paris?",
"What is 23 + 19? Answer in one word.",
"Continue this phrase: The quick brown fox jumps over",
"Name three primary colors.",
"Who wrote the play Hamlet?",
"What is the capital of Japan?",
"What does the acronym 'NASA' stand for?",
"Explain the water cycle in one sentence.",
"Give a two-word summary of the French Revolution.",
"What is the tallest mountain on Earth?",
"Define 'entropy' in one sentence.",
"Who painted the Mona Lisa?",
"What is the boiling point of water in Celsius?",
"Name one prime number larger than 10.",
"What color do you get when you mix blue and yellow?",
"Give one example of an amphibian.",
]
def _render(tok, prompts):
return [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False, add_generation_prompt=True,
)
for p in prompts
]
class _SP:
def __init__(self, n):
self.max_tokens = n
self.temperature = 0.0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
ap.add_argument("--batch_sizes", nargs="+", type=int, default=[1, 4, 8, 16])
ap.add_argument("--max_new_tokens", type=int, default=64)
ap.add_argument("--json_out", required=True)
ap.add_argument("--max_seq_length", type=int, default=1024)
args = ap.parse_args()
from unsloth import FastLanguageModel
t_load = time.perf_counter()
model, tok_raw = FastLanguageModel.from_pretrained(
model_name=args.model, max_seq_length=args.max_seq_length,
dtype=torch.bfloat16, load_in_4bit=False, fast_inference=True,
max_batch_size=max(args.batch_sizes),
gpu_memory_utilization=0.6,
)
t_load = time.perf_counter() - t_load
tok = tok_raw.tokenizer if hasattr(tok_raw, "tokenizer") else tok_raw
print(f"[flex] load {t_load:.1f}s")
sp = _SP(args.max_new_tokens)
results = []
for bs in args.batch_sizes:
prompts = _render(tok, LONG_POOL[:bs])
# Warmup: primes the cudagraph bucket and walker compile.
_ = model.fast_generate(prompts, sampling_params=sp, use_tqdm=False)
t0 = time.perf_counter()
outs = model.fast_generate(prompts, sampling_params=sp, use_tqdm=False)
dt = time.perf_counter() - t0
total = sum(len(o.outputs[0].token_ids) for o in outs)
tok_s = total / dt if dt > 0 else 0.0
results.append({
"batch_size": bs, "gen_s": round(dt, 3),
"total_tokens": total, "tok_s": round(tok_s, 1),
})
print(f"[flex bs={bs}] {total} tok in {dt:.2f}s -> {tok_s:.1f} tok/s")
# Parity sanity: run SHORT_PROMPT at bs=1, save ids.
p = _render(tok, [SHORT_PROMPT])
outs = model.fast_generate(p, sampling_params=sp, use_tqdm=False)
parity_ids = list(outs[0].outputs[0].token_ids)
parity_text = outs[0].outputs[0].text
peak_gb = torch.cuda.max_memory_allocated() / 1024**3
summary = {
"model": args.model,
"max_new_tokens": args.max_new_tokens,
"load_s": round(t_load, 2),
"peak_vram_gb": round(peak_gb, 2),
"batches": results,
"parity_ids": parity_ids,
"parity_text": parity_text,
}
os.makedirs(os.path.dirname(args.json_out) or ".", exist_ok=True)
with open(args.json_out, "w") as f:
json.dump(summary, f, indent=2)
print(f"[flex] wrote {args.json_out}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,124 @@
"""B=4 parity test so the captured graph bucket actually replays.
Compares flex captured-graph decode vs flex eager-decode (capture
disabled via ``UNSLOTH_FLEX_QWEN3_5_NO_CAPTURE=1``). Exact token-for-token
match is the success criterion both paths use identical math, only
the kernel-launch wrapper differs.
CUDA_VISIBLE_DEVICES=2 UNSLOTH_FAST_INFERENCE=1 PYTHONUNBUFFERED=1 \\
python -u tests/qwen3_5_flex_parity_bs4.py \\
--model Qwen/Qwen3.5-4B --max_new_tokens 16 \\
--json_out async_task_outputs/qwen3_5/flex_parity_bs4.json
"""
import argparse
import json
import os
import time
CHAT_PROMPTS = [
"In one sentence, what is Paris?",
"What is 23 + 19? Answer in one word.",
"Continue: The quick brown fox jumps over",
"Name one primary color.",
]
def _run(model, tok, args, capture: bool):
class SP:
def __init__(self, n): self.max_tokens = n; self.temperature = 0.0
sp = SP(args.max_new_tokens)
rendered = [
tok.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=False, add_generation_prompt=True,
)
for p in CHAT_PROMPTS
]
os.environ["UNSLOTH_FLEX_QWEN3_5_NO_CAPTURE"] = "0" if capture else "1"
t0 = time.perf_counter()
outs = model.fast_generate(rendered, sampling_params=sp, use_tqdm=False)
dt = time.perf_counter() - t0
results = []
for p, o in zip(CHAT_PROMPTS, outs):
results.append({
"prompt": p,
"text": o.outputs[0].text,
"ids": list(o.outputs[0].token_ids),
})
return dt, results
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
ap.add_argument("--max_new_tokens", type=int, default=16)
ap.add_argument("--max_seq_length", type=int, default=1024)
ap.add_argument("--max_batch_size", type=int, default=4)
ap.add_argument("--gpu_memory_utilization", type=float, default=0.6)
ap.add_argument("--json_out", required=True)
args = ap.parse_args()
import torch
os.environ["UNSLOTH_FAST_INFERENCE"] = "1"
import unsloth # noqa
from unsloth import FastLanguageModel
model, tok_raw = FastLanguageModel.from_pretrained(
model_name=args.model,
max_seq_length=args.max_seq_length,
dtype=torch.bfloat16,
load_in_4bit=False,
fast_inference=True,
max_batch_size=args.max_batch_size,
gpu_memory_utilization=args.gpu_memory_utilization,
)
tok = tok_raw.tokenizer if hasattr(tok_raw, "tokenizer") else tok_raw
# Eager first (so the graph capture doesn't pollute state).
dt_eager, eager = _run(model, tok, args, capture=False)
print(f"[eager] gen_s={dt_eager:.2f}")
for r in eager:
print(f"[eager] {r['prompt'][:40]!r} -> {r['text'][:80]!r}")
dt_cap, cap = _run(model, tok, args, capture=True)
print(f"[cap] gen_s={dt_cap:.2f}")
for r in cap:
print(f"[cap] {r['prompt'][:40]!r} -> {r['text'][:80]!r}")
# Token-by-token parity.
mismatches = []
for i, (e, c) in enumerate(zip(eager, cap)):
matches = 0
for a, b in zip(e["ids"], c["ids"]):
if a == b:
matches += 1
else:
break
mismatches.append({
"prompt_idx": i,
"prompt": e["prompt"],
"matches": matches,
"total": min(len(e["ids"]), len(c["ids"])),
"eager_text": e["text"],
"cap_text": c["text"],
})
tag = "OK " if matches == min(len(e["ids"]), len(c["ids"])) else "MM "
print(f"[{tag}] P{i} {matches}/{min(len(e['ids']), len(c['ids']))}")
summary = {
"model": args.model,
"max_new_tokens": args.max_new_tokens,
"eager_gen_s": round(dt_eager, 3),
"capture_gen_s": round(dt_cap, 3),
"mismatches": mismatches,
}
os.makedirs(os.path.dirname(args.json_out) or ".", exist_ok=True)
with open(args.json_out, "w") as f:
json.dump(summary, f, indent=2)
print(f"[parity-bs4] wrote {args.json_out}")
if __name__ == "__main__":
main()

View file

@ -137,6 +137,7 @@ from .import_fixes import (
fix_vllm_aimv2_issue,
check_vllm_torch_sm100_compatibility,
fix_vllm_guided_decoding_params,
fix_trl_vllm_ascend,
fix_vllm_pdl_blackwell,
fix_triton_compiled_kernel_missing_attrs,
patch_trunc_normal_precision_issue,
@ -159,6 +160,7 @@ fix_vllm_aimv2_issue()
# Check vLLM + torch < 2.9.0 + SM100 compatibility BEFORE importing vLLM
check_vllm_torch_sm100_compatibility()
fix_vllm_guided_decoding_params()
fix_trl_vllm_ascend()
fix_vllm_pdl_blackwell()
fix_triton_compiled_kernel_missing_attrs()
patch_trunc_normal_precision_issue()
@ -179,6 +181,7 @@ del fix_xformers_performance_issue
del fix_vllm_aimv2_issue
del check_vllm_torch_sm100_compatibility
del fix_vllm_guided_decoding_params
del fix_trl_vllm_ascend
del fix_vllm_pdl_blackwell
del fix_triton_compiled_kernel_missing_attrs
del patch_trunc_normal_precision_issue

View file

@ -489,6 +489,33 @@ def fix_vllm_guided_decoding_params():
)
def fix_trl_vllm_ascend():
# transformers >= 4.48's `_is_package_available(name)` returns a
# tuple (bool, version_or_None). TRL caches that tuple in
# module-level `_*_available` flags and the matching
# `is_*_available()` accessors return the tuple directly. A
# non-empty tuple is always truthy, so `if is_X_available():`
# fires even when X is absent, triggering an unconditional
# `import X` that fails. The surfaced case is `vllm_ascend`
# (blocks `from trl import GRPOConfig, GRPOTrainer` outside
# Huawei Ascend hosts); `llm_blender`, `deepspeed`, `joblib`
# share the same shape. Coerce every tuple-cached flag in
# trl.import_utils to bool; the existing accessors that just
# return the cached value then naturally yield a bool.
if importlib.util.find_spec("trl") is None:
return
try:
import trl.import_utils as tiu
except Exception:
return
for attr in list(vars(tiu)):
if not (attr.startswith("_") and attr.endswith("_available")):
continue
cached = getattr(tiu, attr)
if isinstance(cached, tuple):
setattr(tiu, attr, bool(cached and cached[0]))
def ignore_logger_messages():
# Ignore Environment variable `HF_TOKEN` is set
try:

View file

@ -0,0 +1,45 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Flex-attention inference engines.
``UNSLOTH_FAST_INFERENCE=1`` routes ``FastLanguageModel.from_pretrained``
through :func:`load_flex`, which wraps the selected HF model with a
:class:`FlexEngine` that presents the vLLM ``LLM`` surface used by
Unsloth / TRL GRPO (``.generate``, ``.chat``, ``.sleep``, ``.wake_up``,
``.llm_engine``, plus ``save_lora`` / ``load_lora`` via the module
shim).
Four architectures are supported today: Qwen3 (dense), Qwen3-MoE,
Llama-3, Gemma-4-E2B-it. Anything else raises
:class:`NotImplementedError`; unset the env var or use vLLM instead."""
from .flex_engine import (
FlexEngine,
build_flex_engine,
install_flex_sentinel,
load_flex,
)
from .flex_moe import FlexMoEInference
from .flex_gemma4_moe import FlexGemma4MoEInference
from .vllm_shim import (
CompletionOutput,
LoRARequest,
RequestOutput,
load_lora,
save_lora,
)
__all__ = [
"FlexEngine",
"FlexMoEInference",
"FlexGemma4MoEInference",
"load_flex",
"build_flex_engine",
"install_flex_sentinel",
"LoRARequest",
"RequestOutput",
"CompletionOutput",
"save_lora",
"load_lora",
]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,937 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Gemma 4 MoE inference with flex_attention + paged KV cache.
Sibling of ``flex_moe.py`` (Qwen3-MoE) and ``flex_gpt_oss.py`` (gpt-oss).
Scope: ``unsloth/gemma-4-26B-A4B-it`` (30 layers, 128 experts top-k 8,
hidden 2816, moe_intermediate 704, dense intermediate 2112, ~3.8B active).
Arch-specific pieces on top of the shared engine:
1. **Dual dense MLP + MoE per layer.** Unlike Qwen3/gpt-oss which replace
the dense MLP with an expert block, Gemma 4 MoE runs both in parallel
per layer and sums their normed outputs before the residual add:
residual = h
h = pre_ffw_norm(h)
mlp_out = layer.mlp(h) # dense SwiGLU
h1 = post_ffw_norm_1(mlp_out)
h2 = pre_ffw_norm_2(residual.reshape(-1, H)) # experts input
h2 = layer.experts(h2, top_k_idx, top_k_w)
h2 = post_ffw_norm_2(h2).reshape(residual)
h = post_ffw_norm(h1 + h2)
h = residual + h
h *= layer.layer_scalar
2. **Two-tier RoPE.**
- Sliding layers (25/30): ``rope_type="default"``, theta=10K,
full ``head_dim=256``.
- Full-attn layers (5/30): ``rope_type="proportional"``, theta=1M,
``global_head_dim=512``, ``partial_rotary_factor=0.25`` (rotate
only leading 25% of head dim; pass through remainder).
Walker computes both (cos, sin) tuples per generate entry; attention
forward picks the right one based on ``self_attn.layer_type``.
3. **Per-head Q/K/V RMSNorm pre-rotary.** ``q_norm`` and ``k_norm`` apply
RMSNorm on the last (head_dim) axis before RoPE. ``v_norm`` applies
with ``with_scale=False`` (RMSNorm that just divides, no gain).
4. **K=V alternative on full-attn layers.** ``attention_k_eq_v=True`` +
full-attn layer ``v_proj is None`` and ``value_states`` is the raw
``k_proj(hidden)`` output (before ``k_norm`` and before RoPE),
followed only by ``v_norm``. Sliding layers use the normal q/k/v path.
5. **Expert grouped_mm.** Reuses ``Gemma4TextExperts.forward`` through
``unsloth_zoo.temporary_patches.gemma4_moe.patch_gemma4_moe`` the
``per_expert_scale`` is pre-folded into routing weights, so the
generic ``forward_native_grouped_mm`` (``moe_utils.py``) handles the
standard ``(E, 2I, H)``/``(E, H, I)`` layout with ``act_fn =
gelu_pytorch_tanh`` via the default ``elif hasattr(self, 'act_fn')``
fallback zero changes needed in moe_utils.
6. **Embedding scale + final ``layer_scalar``.** Embed output gets
multiplied by ``sqrt(hidden_size)``. Each decoder layer's output is
multiplied by ``self.layer_scalar`` (a ``torch.ones(1)`` buffer
numerically a no-op today, but must not be dropped so the walker
matches HF bitwise).
Out of scope (this file errors out if the config requests them):
- ``num_kv_shared_layers > 0`` (E2B/E4B KV-share variants)
- ``hidden_size_per_layer_input > 0`` (E2B/E4B per-layer input gate)
- Mixed sliding-window sizes across layers
- bnb-4bit stacked experts (no ``Gemma4TextExpertsBnb4bit`` ships yet)
"""
from __future__ import annotations
import math
import os
import types
from collections import deque
from typing import Optional
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.attention.flex_attention import BlockMask, create_block_mask
try:
from .flex_qwen3_llama import (
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
flex_attention_compiled,
refresh_lora_merge_from_pristine,
)
from .flex_moe import refresh_moe_lora_merge_from_pristine
from .flex_paged_attention import PagedKVCache, PageTable
except ImportError: # script-mode fallback
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from flex_qwen3_llama import ( # noqa: E402
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
flex_attention_compiled,
refresh_lora_merge_from_pristine,
)
from flex_moe import refresh_moe_lora_merge_from_pristine # noqa: E402
from flex_paged_attention import PagedKVCache, PageTable # noqa: E402
# ---------------------------------------------------------------------------
# Rotary helpers
# ---------------------------------------------------------------------------
def _rotate_half(x):
x1, x2 = torch.chunk(x, 2, dim=-1)
return torch.cat((-x2, x1), dim=-1)
def _apply_rotary_full(x, cos, sin):
"""Standard Llama-style RoPE on the full head dim.
``cos``/``sin`` shape ``(B, S, D)`` unsqueeze at head-dim to
``(B, 1, S, D)`` so it broadcasts over ``(B, H, S, D)`` q/k.
For Gemma 4 full-attention layers, ``rope_type="proportional"`` emits
an ``inv_freq`` with zeros in the tail ``(1 - partial_rotary_factor)``
fraction of positions. Those zero entries make the corresponding
cos=1 / sin=0, so ``rotate_half`` passes the tail dims through
unchanged no separate partial-RoPE helper is needed.
"""
cos = cos.unsqueeze(1)
sin = sin.unsqueeze(1)
return (x * cos) + (_rotate_half(x) * sin)
# ---------------------------------------------------------------------------
# Attention forward: paged KV + flex_attention + per-layer sliding + k=v.
# ---------------------------------------------------------------------------
def make_gemma4_moe_attention_forward(page_table: PageTable):
"""Return a ``forward`` method for ``Gemma4TextAttention``.
Differences from ``flex_gpt_oss``:
- No sinks; call ``flex_attention_compiled`` without ``return_lse``.
- ``q_norm`` / ``k_norm`` / ``v_norm`` before rotary / KV write.
- ``v_proj is None`` (k=v full-attn) reuse raw ``k_proj`` as v.
- Partial RoPE on full-attn layers via ``_partial_rotary_dim``.
"""
def forward(
self,
hidden_states: torch.Tensor,
position_embeddings: tuple[torch.Tensor, torch.Tensor] = None,
position_embeddings_sliding: Optional[tuple[torch.Tensor, torch.Tensor]] = None,
position_embeddings_full: Optional[tuple[torch.Tensor, torch.Tensor]] = None,
attention_mask=None,
past_key_values=None,
cache_position=None,
flex_block_mask: Optional[BlockMask] = None,
flex_block_mask_sliding: Optional[BlockMask] = None,
flex_input_pos: Optional[torch.Tensor] = None,
flex_batch_idx: Optional[torch.Tensor] = None,
flex_kernel_options: Optional[dict] = None,
**kwargs,
):
input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, self.head_dim)
# pick cos/sin for this layer
if self.is_sliding:
cos, sin = position_embeddings_sliding
else:
cos, sin = position_embeddings_full
# q projection
q = self.q_proj(hidden_states).view(hidden_shape)
q = self.q_norm(q)
# shape now (B, S, Hq, D)
# k/v projections
k_raw = self.k_proj(hidden_states).view(hidden_shape)
if self.v_proj is not None:
v_raw = self.v_proj(hidden_states).view(hidden_shape)
else:
# k=v alternative: value uses the RAW k projection
# (before k_norm, before rotary). v_norm then applies with
# with_scale=False.
v_raw = k_raw
k = self.k_norm(k_raw)
v = self.v_norm(v_raw)
# Transpose for flex_attention: (B, S, H, D) -> (B, H, S, D).
# Rotary applied AFTER the transpose so cos/sin broadcast on dim 1.
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
q = _apply_rotary_full(q, cos, sin)
k = _apply_rotary_full(k, cos, sin)
# Paged-KV write.
if self._paged_cache is not None and flex_input_pos is not None:
cache_dtype = self._paged_cache.k_cache.dtype
if k.dtype != cache_dtype:
k = k.to(cache_dtype)
if v.dtype != cache_dtype:
v = v.to(cache_dtype)
k, v = self._paged_cache.update(flex_input_pos, k, v, flex_batch_idx)
# Per-layer block mask dispatch.
if self.is_sliding and flex_block_mask_sliding is not None:
block_mask = flex_block_mask_sliding
else:
block_mask = flex_block_mask
attn_output = flex_attention_compiled(
q,
k,
v,
scale=self.scaling,
block_mask=block_mask,
enable_gqa=True,
kernel_options=flex_kernel_options,
)
attn_output = attn_output.transpose(1, 2).reshape(*input_shape, -1).contiguous()
return self.o_proj(attn_output), None
return forward
def patch_gemma4_moe_attention_forwards(model: torch.nn.Module, page_table: PageTable):
"""Attach a ``PagedKVCache`` + replace ``forward`` on every
``Gemma4TextAttention`` layer AND replace ``Gemma4TextExperts.forward``
with the grouped_mm MoE backend.
Gemma 4 has per-layer-type head_dim and kv_heads, so each layer's
cache is sized from its own attrs.
The stock ``Gemma4TextExperts.forward`` (modeling_gemma4.py:1263) is a
Python loop over active experts slow for decode and not
CUDA-graph-capturable. We rebind to the generic
``forward_native_grouped_mm`` (moe_utils.py:771) which handles the
standard ``(E, 2I, H)`` / ``(E, H, I)`` layout with the ``act_fn``
fallback activation path (``gelu_pytorch_tanh`` via ``ACT2FN``).
The router's ``per_expert_scale`` is already folded into
``top_k_weights`` by the modeling-native ``Gemma4TextRouter.forward``
(modeling_gemma4.py:1309), so no separate folding is needed here.
(The unsloth-zoo ``gemma4_moe.patch_gemma4_moe`` targets a legacy
``Gemma4TextMoEBlock`` class that no longer ships in transformers
5.5+; it no-ops on current transformers.)
"""
fwd = make_gemma4_moe_attention_forward(page_table)
text_cfg = getattr(model.config, "text_config", model.config)
if getattr(text_cfg, "num_kv_shared_layers", 0):
raise NotImplementedError(
"Gemma 4 MoE with KV-shared layers not supported yet "
f"(num_kv_shared_layers={text_cfg.num_kv_shared_layers})."
)
if getattr(text_cfg, "hidden_size_per_layer_input", 0):
raise NotImplementedError(
"Gemma 4 MoE with per-layer input gate not supported yet "
f"(hidden_size_per_layer_input={text_cfg.hidden_size_per_layer_input})."
)
# Pick grouped_mm MoE backend once; bind per-layer below.
try:
from unsloth_zoo.temporary_patches.moe_utils import get_forward_moe_backend
_moe_forward = get_forward_moe_backend()
except Exception: # pragma: no cover - defensive
_moe_forward = None
for layer in model.model.layers:
attn = layer.self_attn
num_q_heads = attn.q_proj.out_features // attn.head_dim
num_kv_heads = max(1, num_q_heads // attn.num_key_value_groups)
attn._paged_cache = PagedKVCache(
page_table,
n_heads=num_kv_heads,
head_dim=attn.head_dim,
dtype=model.dtype,
).to(model.device)
attn.forward = types.MethodType(fwd, attn)
if _moe_forward is not None and getattr(layer, "enable_moe_block", False):
experts = layer.experts
# The grouped_mm backend inspects ``self.act_fn`` — already
# set by ``Gemma4TextExperts.__init__`` to
# ``ACT2FN[config.hidden_activation]``. No other setup needed.
experts.forward = types.MethodType(_moe_forward, experts)
# ---------------------------------------------------------------------------
# Walker: dual dense MLP + MoE per decoder layer + layer_scalar.
# ---------------------------------------------------------------------------
def _compute_rotary_per_layer_type(base, inputs_embeds, position_ids):
"""Call ``Gemma4TextRotaryEmbedding`` once per known ``layer_type``.
Returns ``(cos_sliding, sin_sliding), (cos_full, sin_full)``. cos/sin
for the full-attention type are ALREADY partial-sized in the HF impl
if ``rope_type="proportional"`` + ``partial_rotary_factor<1.0`` the
init fn slices the head dim to ``partial_rotary_factor * head_dim``
before emitting ``inv_freq``. So we just forward through.
"""
rot = base.rotary_emb
layer_types = set(getattr(rot, "layer_types", {"sliding_attention", "full_attention"}))
pos_sliding = pos_full = None
if "sliding_attention" in layer_types:
pos_sliding = rot(inputs_embeds, position_ids, layer_type="sliding_attention")
if "full_attention" in layer_types:
pos_full = rot(inputs_embeds, position_ids, layer_type="full_attention")
# Fallback: if only one type exists, use it for both slots so the
# attention forward never reads None.
if pos_sliding is None:
pos_sliding = pos_full
if pos_full is None:
pos_full = pos_sliding
return pos_sliding, pos_full
def call_gemma4_moe_model_with_flex_kwargs(model, input_ids, position_ids, flex_kwargs):
"""Walk a ``Gemma4TextModel`` manually, injecting flex kwargs into each
attention call. Per-layer dual MLP + MoE with ``layer_scalar``.
``model.model.embed_tokens`` is a ``Gemma4TextScaledWordEmbedding`` that
already applies ``* sqrt(hidden_size)`` in its forward, so we just
call it (no manual scale).
"""
base = model.model
inputs_embeds = base.embed_tokens(input_ids)
position_embeddings_sliding, position_embeddings_full = _compute_rotary_per_layer_type(
base, inputs_embeds, position_ids
)
hidden_states = inputs_embeds
compute_dtype = inputs_embeds.dtype
for layer in base.layers:
residual = hidden_states
hidden_states = layer.input_layernorm(hidden_states).to(compute_dtype)
attn_out, _ = layer.self_attn(
hidden_states,
position_embeddings_sliding=position_embeddings_sliding,
position_embeddings_full=position_embeddings_full,
**flex_kwargs,
)
attn_out = layer.post_attention_layernorm(attn_out).to(compute_dtype)
hidden_states = residual + attn_out
residual = hidden_states
pre_ffw = layer.pre_feedforward_layernorm(hidden_states).to(compute_dtype)
mlp_out = layer.mlp(pre_ffw)
if getattr(layer, "enable_moe_block", False):
h1 = layer.post_feedforward_layernorm_1(mlp_out).to(compute_dtype)
flat = residual.reshape(-1, residual.shape[-1])
_, top_k_w, top_k_idx = layer.router(flat)
h2 = layer.pre_feedforward_layernorm_2(flat).to(compute_dtype)
h2 = layer.experts(h2, top_k_idx, top_k_w)
h2 = h2.reshape(residual.shape)
h2 = layer.post_feedforward_layernorm_2(h2).to(compute_dtype)
mlp_out = h1 + h2
mlp_out = layer.post_feedforward_layernorm(mlp_out).to(compute_dtype)
hidden_states = residual + mlp_out
hidden_states = hidden_states * layer.layer_scalar
hidden_states = base.norm(hidden_states)
return hidden_states
# ---------------------------------------------------------------------------
# Sliding-window block-mask builders. Paged-KV aware.
# ---------------------------------------------------------------------------
def _create_sliding_causal_blockmask(page_table: PageTable, B: int, L: int, W: int):
def sliding_causal(b, h, q_idx, kv_idx):
return (q_idx >= kv_idx) & (q_idx - kv_idx < W)
return create_block_mask(
sliding_causal,
B=B,
H=None,
Q_LEN=L,
KV_LEN=L,
BLOCK_SIZE=page_table.page_size,
device=page_table.device,
)
def _create_prefill_sliding_blockmask(
page_table: PageTable, batch_idx: torch.Tensor, W: int, BLOCK_SIZE: int = 128
):
assert batch_idx.ndim == 2 and batch_idx.shape[0] == 1
L = batch_idx.shape[1]
docs = batch_idx.view(-1)
def document_causal_sliding(b, h, q_idx, kv_idx):
causal = q_idx >= kv_idx
window = (q_idx - kv_idx) < W
document = docs[q_idx] == docs[kv_idx]
return causal & window & document
return create_block_mask(
document_causal_sliding,
B=1,
H=None,
Q_LEN=L,
KV_LEN=L,
BLOCK_SIZE=BLOCK_SIZE,
)
# ---------------------------------------------------------------------------
# FlexGemma4MoEInference
# ---------------------------------------------------------------------------
class FlexGemma4MoEInference:
"""Gemma 4 MoE inference engine. API-compatible with ``FlexMoEInference``
and ``FlexGptOssInference``.
Phase 1: eager decode (capture disabled). Phase 3 enables capture.
"""
def __init__(
self,
model,
tokenizer,
max_batch_size=32,
max_seq_length=2048,
n_pages=2048,
page_size=128,
max_new_tokens=512,
decode_kernel_options=None,
prefill_kernel_options=None,
fa4_prefill=None,
base_model=None,
peft_model=None,
cumem_allocator=None,
compile_walker=None,
):
assert max_seq_length % page_size == 0
assert hasattr(model, "model") and hasattr(model.model, "layers"), (
"FlexGemma4MoEInference expects a HF CausalLM shape (.model.layers)."
)
for i, layer in enumerate(model.model.layers):
if not getattr(layer, "enable_moe_block", False):
# Dense-only Gemma 4 layers mixed with MoE is not expected
# for 26B-A4B; if it ever appears we still don't crash —
# the walker's ``if enable_moe_block`` guard handles it.
continue
assert hasattr(layer, "router") and hasattr(layer, "experts"), (
f"Layer {i} claims enable_moe_block=True but is missing router/experts."
)
self.model = model
self.tokenizer = tokenizer
self.device = model.device
self.eos_token_id = tokenizer.eos_token_id
self.base_model = base_model
self.peft_model = peft_model
self.max_batch_size = max_batch_size
self.max_seq_length = max_seq_length
self.page_size = page_size
self.max_new_tokens = max_new_tokens
# Detect per-layer sliding window. Gemma 4 uses one sliding size
# across sliding_attention layers.
sliding_windows = set()
for layer in model.model.layers:
sw = getattr(layer.self_attn, "sliding_window", None)
if sw is not None:
sliding_windows.add(int(sw))
if len(sliding_windows) > 1:
raise NotImplementedError(
f"Mixed sliding-window sizes not supported: {sliding_windows}"
)
self.sliding_window = next(iter(sliding_windows), None)
# Detect bnb-4bit stacked experts (no such class ships yet for
# Gemma 4; if it appears, flag and force eager).
self._has_bnb_experts = any(
getattr(layer, "enable_moe_block", False)
and type(layer.experts).__name__.endswith("Bnb4bit")
for layer in model.model.layers
)
# FA4 kernel branch.
if fa4_prefill is None or fa4_prefill:
major, _ = torch.cuda.get_device_capability(self.device)
supported = major >= 9
if fa4_prefill and not supported:
import warnings
warnings.warn(
f"--fa4_prefill needs sm_90+; found sm_{major}0. Falling "
f"back to the Triton flex_attention backend.",
RuntimeWarning,
stacklevel=2,
)
fa4_prefill = supported
self.fa4_prefill = fa4_prefill
self.prefill_q_block = 256 if fa4_prefill else 128
self.prefill_kv_block = 128
self.decode_kernel_options = (
decode_kernel_options
if decode_kernel_options is not None
else DECODE_KERNEL_OPTIONS_DEFAULT
)
base_prefill_opts = (
prefill_kernel_options
if prefill_kernel_options is not None
else dict(PREFILL_KERNEL_OPTIONS_DEFAULT)
)
if fa4_prefill:
base_prefill_opts = dict(base_prefill_opts)
base_prefill_opts.pop("FORCE_USE_FLEX_ATTENTION", None)
base_prefill_opts["BACKEND"] = "FLASH"
self.prefill_kernel_options = base_prefill_opts
from .sleep_mode import kv_cache_pool as _kv_cache_pool
with _kv_cache_pool(cumem_allocator):
self.page_table = PageTable(
n_pages=n_pages,
page_size=page_size,
max_batch_size=max_batch_size,
device=self.device.type,
)
patch_gemma4_moe_attention_forwards(model, self.page_table)
self.input_pos_buffer = torch.zeros(
max_batch_size, dtype=torch.int32, device=self.device
)
self.block_mask_logical = self.page_table.create_causal_blockmask(
B=max_batch_size,
L=max_seq_length,
)
if self.sliding_window is not None:
self.block_mask_logical_sliding = _create_sliding_causal_blockmask(
self.page_table,
B=max_batch_size,
L=max_seq_length,
W=self.sliding_window,
)
else:
self.block_mask_logical_sliding = None
self.cudagraph_captured = False
self.graphs = {}
self.graph_vars = {}
self.graph_bs = None
# Optional torch.compile walker.
if compile_walker is None:
compile_walker = os.environ.get("UNSLOTH_FLEX_COMPILE_WALKER", "") == "1"
self._moe_walker = call_gemma4_moe_model_with_flex_kwargs
if compile_walker:
try:
self._moe_walker = torch.compile(
call_gemma4_moe_model_with_flex_kwargs,
fullgraph=False,
dynamic=False,
)
print(
"[flex-gemma4moe] wrapped call_gemma4_moe_model_with_flex_kwargs "
"with torch.compile(fullgraph=False, dynamic=False)"
)
except Exception as e:
print(f"[flex-gemma4moe] torch.compile wrap failed: {e}")
self._moe_walker = call_gemma4_moe_model_with_flex_kwargs
if self._has_bnb_experts:
print(
"[flex-gemma4moe] bnb-4bit experts detected; CUDA graph capture "
"disabled (decode stays eager)."
)
# --- tokenize / prefill / decode --------------------------------------
def tokenize(self, sequences):
for seq in sequences:
if seq.input_ids is not None and seq.input_length > 0:
continue
ids = self.tokenizer(seq.text, return_tensors="pt")["input_ids"].squeeze(0)
seq.input_ids = ids
seq.input_length = ids.shape[0]
def _prefill(self, batch: list[Sequence]) -> torch.Tensor:
input_ids_list = [seq.input_ids.to(self.device) for seq in batch]
input_pos_list = [
torch.arange(seq.input_length, dtype=torch.long, device=self.device)
for seq in batch
]
batch_idx_list = [
torch.full(
(seq.input_length,), seq.batch_idx, dtype=torch.long, device=self.device
)
for seq in batch
]
input_ids = torch.cat(input_ids_list).view(1, -1)
input_pos = torch.cat(input_pos_list).view(1, -1)
batch_idx = torch.cat(batch_idx_list).view(1, -1)
L = input_ids.shape[1]
q_block = self.prefill_q_block
pad = (q_block - L % q_block) % q_block
if pad > 0:
input_ids = F.pad(input_ids, (0, pad), value=0)
input_pos = F.pad(input_pos, (0, pad), value=0)
batch_idx = F.pad(batch_idx, (0, pad), value=0)
input_lengths = torch.tensor(
[s.input_length for s in batch], dtype=torch.long, device=self.device
)
logits_positions = input_lengths.cumsum(dim=0) - 1
prefill_block_size = (
(self.prefill_q_block, self.prefill_kv_block)
if self.fa4_prefill
else self.prefill_q_block
)
mask = self.page_table.create_prefill_blockmask_no_paging(
batch_idx, BLOCK_SIZE=prefill_block_size
)
mask_sliding = None
if self.sliding_window is not None:
mask_sliding = _create_prefill_sliding_blockmask(
self.page_table,
batch_idx,
W=self.sliding_window,
BLOCK_SIZE=prefill_block_size,
)
flex_kwargs = dict(
flex_block_mask=mask,
flex_block_mask_sliding=mask_sliding,
flex_input_pos=input_pos,
flex_batch_idx=batch_idx,
flex_kernel_options=self.prefill_kernel_options,
)
position_ids = input_pos
hidden = self._moe_walker(
self.model, input_ids, position_ids, flex_kwargs
)
return self.model.lm_head(hidden[:, logits_positions, :]).squeeze(0)
def _decode_block_mask(self, batch_idx: torch.Tensor, *, sliding: bool):
block_mask = (
self.block_mask_logical_sliding if sliding else self.block_mask_logical
)
input_pos = self.input_pos_buffer[batch_idx]
assert batch_idx.ndim == 1 and input_pos.ndim == 1
B = batch_idx.shape[0]
input_block_idx = input_pos // block_mask.BLOCK_SIZE[0]
kv_num_blocks = block_mask.kv_num_blocks[batch_idx, :, input_block_idx].view(
B, 1, 1
)
kv_indices = block_mask.kv_indices[batch_idx, :, input_block_idx].view(
B, 1, 1, -1
)
full_num = full_idx = None
if block_mask.full_kv_num_blocks is not None:
full_num = block_mask.full_kv_num_blocks[
batch_idx, :, input_block_idx
].view(B, 1, 1)
full_idx = block_mask.full_kv_indices[
batch_idx, :, input_block_idx
].view(B, 1, 1, -1)
if sliding:
W = self.sliding_window
def mask_fn(off):
def m(b, h, q_idx, kv_idx):
pos = q_idx + off[b]
return (pos >= kv_idx) & (pos - kv_idx < W)
return m
else:
def mask_fn(off):
def m(b, h, q_idx, kv_idx):
return q_idx + off[b] >= kv_idx
return m
seq_length = (1, block_mask.seq_lengths[1])
mask = BlockMask.from_kv_blocks(
kv_num_blocks,
kv_indices,
full_num,
full_idx,
BLOCK_SIZE=block_mask.BLOCK_SIZE,
mask_mod=mask_fn(input_pos),
seq_lengths=seq_length,
)
return mask, input_pos
def _decode_step_eager(self, batch_idx: torch.Tensor, input_ids: torch.Tensor):
B = input_ids.shape[0]
mask_full, input_pos = self._decode_block_mask(batch_idx, sliding=False)
mask_full = self.page_table.convert_logical_block_mask(mask_full, batch_idx)
mask_sliding = None
if self.sliding_window is not None:
ms, _ = self._decode_block_mask(batch_idx, sliding=True)
mask_sliding = self.page_table.convert_logical_block_mask(ms, batch_idx)
position_ids = input_pos.view(B, 1).to(torch.long)
flex_kwargs = dict(
flex_block_mask=mask_full,
flex_block_mask_sliding=mask_sliding,
flex_input_pos=input_pos.view(B, 1).to(torch.long),
flex_batch_idx=batch_idx,
flex_kernel_options=self.decode_kernel_options,
)
hidden = self._moe_walker(
self.model, input_ids.view(B, 1), position_ids, flex_kwargs
)
return self.model.lm_head(hidden[:, -1, :])
def _decode_step(
self, batch_idx: torch.Tensor, input_ids: torch.Tensor, input_pos: torch.Tensor
):
self.input_pos_buffer.zero_()
self.input_pos_buffer[batch_idx] = input_pos
if not self.cudagraph_captured or self.graph_bs is None:
return self._decode_step_eager(batch_idx, input_ids)
bs = input_ids.size(0)
key = next(x for x in self.graph_bs if x >= bs)
graph = self.graphs[key]
gv = self.graph_vars
for k, v in gv.items():
if k != "outputs":
v.zero_()
gv["input_ids"][:bs] = input_ids
gv["batch_idx"][:bs] = batch_idx
graph.replay()
return gv["outputs"][:bs]
def capture_decode_cudagraph(self):
"""Capture one CUDA graph per bs bucket.
Phase 3 wires this in. bnb-4bit stays eager either way (no
stacked bnb experts class ships for Gemma 4 today; guard is
defensive).
"""
if self._has_bnb_experts:
print(
"[flex-gemma4moe] bnb-4bit experts: skipping cudagraph capture; "
"decode stays eager."
)
return
try:
from unsloth_zoo.temporary_patches.moe_utils import select_moe_backend
backend = select_moe_backend()
except Exception:
backend = None
if backend != "grouped_mm":
print(
f"[flex-gemma4moe] MoE CUDA graph capture requires the "
f"'grouped_mm' backend (got {backend!r}); skipping capture."
)
return
max_bs = self.max_batch_size
reserved_batches = []
for bi in range(1, max_bs):
try:
allocated = self.page_table.allocate()
self.page_table.reserve(
allocated,
torch.tensor([allocated], device=self.device, dtype=torch.long),
self.page_size,
)
reserved_batches.append(allocated)
except Exception:
break
input_ids = torch.zeros(max_bs, dtype=torch.int64, device=self.device)
batch_idx = torch.arange(max_bs, dtype=torch.int64, device=self.device)
outputs = torch.zeros(
(max_bs, self.model.config.vocab_size),
dtype=self.model.dtype,
device=self.device,
)
_env_bs = os.environ.get("UNSLOTH_FLEX_GRAPH_BS")
if _env_bs:
try:
self.graph_bs = [int(x) for x in _env_bs.split(",") if x.strip()]
except ValueError:
print(
f"[flex-gemma4moe] invalid UNSLOTH_FLEX_GRAPH_BS={_env_bs!r}; "
f"using default bucket ladder"
)
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
else:
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
pool = None
for bs in reversed(self.graph_bs):
if bs > max_bs:
continue
print(f"[flex-gemma4moe] capturing CUDA graph for bs={bs}")
torch.cuda.synchronize()
_ = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
torch.cuda.synchronize()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool):
outputs[:bs] = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
if pool is None:
pool = graph.pool()
self.graphs[bs] = graph
torch.cuda.synchronize()
for bi in reserved_batches:
self.page_table.erase(bi)
self.graph_vars = dict(
input_ids=input_ids, batch_idx=batch_idx, outputs=outputs
)
def refresh_inference_from_base(self):
"""Refresh merged LoRA weights from pristine. Dense refresh on
q/k/v/o/router/dense-MLP; MoE refresh on ``gate_up_proj`` /
``down_proj`` in standard ``(E, 2I, H)``/``(E, H, I)``
orientation reuses ``refresh_moe_lora_merge_from_pristine``
verbatim."""
if self.base_model is None or self.peft_model is None:
return 0
n = refresh_lora_merge_from_pristine(self.base_model, self.peft_model)
try:
n += refresh_moe_lora_merge_from_pristine(
self.base_model, self.peft_model
)
except Exception:
pass
return n
@torch.inference_mode()
def generate(self, sequences: list[Sequence], capture_cudagraph=False):
self.tokenize(sequences)
waiting = deque(sequences)
running = deque()
done = []
if capture_cudagraph and not self.cudagraph_captured:
self.capture_decode_cudagraph()
if self.graphs:
self.cudagraph_captured = True
while waiting or running:
batch = []
while waiting and self.page_table.can_reserve(waiting[0].total_length):
seq = waiting.popleft()
bi = self.page_table.allocate()
self.page_table.reserve(
bi,
torch.tensor([bi], device=self.device, dtype=torch.long),
seq.total_length,
)
seq.batch_idx = bi
batch.append(seq)
if batch:
logits = self._prefill(batch)
next_ids = torch.argmax(logits, dim=-1).tolist()
for i, seq in enumerate(batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
continue
decode_batch = []
while running:
seq = running.popleft()
if self.page_table.capacity[seq.batch_idx] >= seq.total_length:
decode_batch.append(seq)
elif self.page_table.can_reserve(
seq.total_length, batch_idx_int=seq.batch_idx
):
self.page_table.reserve(
seq.batch_idx,
torch.tensor(
[seq.batch_idx], device=self.device, dtype=torch.long
),
seq.total_length,
)
decode_batch.append(seq)
else:
running.appendleft(seq)
newest = running.pop()
waiting.appendleft(newest)
self.page_table.erase(newest.batch_idx)
if not decode_batch:
continue
B = len(decode_batch)
bi_tensor = torch.tensor(
[s.batch_idx for s in decode_batch],
dtype=torch.long,
device=self.device,
)
last_ids = torch.tensor(
[s.last_token_id for s in decode_batch],
dtype=torch.long,
device=self.device,
)
cur_pos = torch.tensor(
[s.total_length - 1 for s in decode_batch],
dtype=torch.int32,
device=self.device,
)
logits = self._decode_step(bi_tensor, last_ids, cur_pos)
next_ids = torch.argmax(logits, dim=-1).tolist()
for i, seq in enumerate(decode_batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
return done

View file

@ -0,0 +1,794 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""gpt-oss inference with flex_attention + paged KV cache + attention sinks.
Sibling of ``flex_moe.py`` (Qwen3-MoE). Three arch-specific pieces:
1. **Attention sinks.** Each layer has a learned per-head ``sinks``
parameter that biases a virtual sink token in the softmax. We
implement it the same way ``unsloth_zoo.flex_attention.attention_sink``
does: call flex_attention with ``return_lse=True``, then scale the
output by ``sigmoid(lse - sinks[h])``.
2. **Per-layer sliding window.** gpt-oss layer types alternate between
full attention and sliding-128. The walker passes two ``BlockMask``
objects (``flex_block_mask`` + ``flex_block_mask_sliding``) and each
attention forward picks one based on ``self.sliding_window``.
3. **MoE expert stack.** Uses unsloth_zoo's ``GptOssExperts`` forward
(``forward_native_grouped_mm`` with the ``"GptOssExperts"`` branch
interleaved gate/up split + ``gate * sigmoid(gate * 1.702)`` is
already there at moe_utils.py:918-972, reused verbatim). The MoE
LoRA merge reuses ``refresh_moe_lora_merge_from_pristine`` from
``flex_moe.py`` the transposed-orientation branch covers
gpt-oss's ``(E, H, 2I)`` gate_up_proj layout.
bnb-4bit: ``GptOssExpertsBnb4bit`` uses an ``nn.ModuleList`` per-expert
loop that can't be CUDA-graph-captured. Detect it in ``__init__`` and
set ``capture_cudagraph=False`` decode still benefits from paged KV
and flex_attention, just not the graph replay.
"""
from __future__ import annotations
import os
import types
from collections import deque
from typing import Optional
import torch
import torch.nn.functional as F
from torch.nn.attention.flex_attention import BlockMask, create_block_mask
try:
from .flex_qwen3_llama import (
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
flex_attention_compiled,
refresh_lora_merge_from_pristine,
)
from .flex_moe import refresh_moe_lora_merge_from_pristine
from .flex_paged_attention import PagedKVCache, PageTable
except ImportError: # script-mode fallback
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from flex_qwen3_llama import ( # noqa: E402
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
flex_attention_compiled,
refresh_lora_merge_from_pristine,
)
from flex_moe import refresh_moe_lora_merge_from_pristine # noqa: E402
from flex_paged_attention import PagedKVCache, PageTable # noqa: E402
# gpt-oss rotary is NOT the Llama ``(q * cos) + (rotate_half(q) * sin)``
# half-dim sin/cos rotation. It does a first/second-half split with
# ``cos`` / ``sin`` sized to ``head_dim / 2``. Import the reference here
# and unsqueeze head-dim once per call — same behaviour as
# ``apply_rotary_pos_emb`` in ``transformers.models.gpt_oss``.
def _gpt_oss_apply_rotary(q, k, cos, sin):
cos = cos.unsqueeze(1) # [B, 1, S, D/2]
sin = sin.unsqueeze(1)
def rotate(x):
first, second = torch.chunk(x, 2, dim=-1)
first_ = first * cos - second * sin
second_ = second * cos + first * sin
return torch.cat((first_, second_), dim=-1)
return rotate(q), rotate(k)
# ---------------------------------------------------------------------------
# Attention forward: paged KV + flex_attention + sinks + per-layer sliding.
# ---------------------------------------------------------------------------
def make_gptoss_attention_forward(page_table: PageTable):
"""Return a ``forward`` method for ``GptOssAttention``.
Differences from the dense Qwen3/Llama flex forward:
- No ``q_norm`` / ``k_norm`` (gpt-oss has neither).
- Uses ``flex_attention(..., return_lse=True)`` and scales output by
``sigmoid(lse - sinks[h])`` for the sink token.
- Picks ``flex_block_mask_sliding`` when the layer has
``self.sliding_window is not None``.
"""
def forward(
self,
hidden_states: torch.Tensor,
position_embeddings: tuple[torch.Tensor, torch.Tensor],
attention_mask=None,
past_key_values=None,
cache_position=None,
flex_block_mask: Optional[BlockMask] = None,
flex_block_mask_sliding: Optional[BlockMask] = None,
flex_input_pos: Optional[torch.Tensor] = None,
flex_batch_idx: Optional[torch.Tensor] = None,
flex_kernel_options: Optional[dict] = None,
**kwargs,
):
input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, self.head_dim)
q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
cos, sin = position_embeddings
q, k = _gpt_oss_apply_rotary(q, k, cos, sin)
# Paged-KV write. Match the pre-allocated cache dtype; attention
# linears may compute in fp32 under autocast.
if self._paged_cache is not None and flex_input_pos is not None:
cache_dtype = self._paged_cache.k_cache.dtype
if k.dtype != cache_dtype:
k = k.to(cache_dtype)
if v.dtype != cache_dtype:
v = v.to(cache_dtype)
k, v = self._paged_cache.update(flex_input_pos, k, v, flex_batch_idx)
# Per-layer block mask dispatch. sliding_window is None on full
# attention layers, an int on sliding layers.
if self.sliding_window is not None and flex_block_mask_sliding is not None:
block_mask = flex_block_mask_sliding
else:
block_mask = flex_block_mask
attn_output, logsumexp = flex_attention_compiled(
q,
k,
v,
scale=self.scaling,
block_mask=block_mask,
enable_gqa=True,
kernel_options=flex_kernel_options,
return_lse=True,
)
# Attention sink. Equivalent to concatenating a sink column
# ``sinks[h]`` to the attention logits before softmax. With
# ``return_lse=True`` we have ``lse = log sum_k exp(QK[q, k] *
# scale)``; the sink-aware softmax scales the output by
# ``exp(lse) / (exp(lse) + exp(sinks[h])) = sigmoid(lse -
# sinks[h])``. Mirrors ``flex_attention_add_sinks`` in
# ``unsloth_zoo/flex_attention/attention_sink.py``.
logsumexp = logsumexp - self.sinks.view(1, -1, 1)
sink_scale = torch.sigmoid(logsumexp)
attn_output = attn_output * sink_scale.unsqueeze(-1).to(attn_output.dtype)
attn_output = attn_output.transpose(1, 2).reshape(*input_shape, -1).contiguous()
return self.o_proj(attn_output), None
return forward
def patch_gptoss_attention_forwards(model: torch.nn.Module, page_table: PageTable):
"""Attach a ``PagedKVCache`` + replace ``forward`` on every
``GptOssAttention`` layer."""
fwd = make_gptoss_attention_forward(page_table)
for layer in model.model.layers:
attn = layer.self_attn
attn._paged_cache = PagedKVCache(
page_table,
n_heads=model.config.num_key_value_heads,
head_dim=model.config.head_dim,
dtype=model.dtype,
).to(model.device)
attn.forward = types.MethodType(fwd, attn)
# ---------------------------------------------------------------------------
# Walker: pass flex kwargs through each layer, unpack MLP tuple return.
# ---------------------------------------------------------------------------
def call_gpt_oss_model_with_flex_kwargs(model, input_ids, position_ids, flex_kwargs):
"""Walk a ``GptOssModel`` manually, injecting flex kwargs into each
attention call. ``GptOssMLP.forward`` returns
``(hidden_states, router_scores)``; we discard router scores at
inference (no load-balance loss)."""
base = model.model
inputs_embeds = base.embed_tokens(input_ids)
position_embeddings = base.rotary_emb(inputs_embeds, position_ids)
_cos, _sin = position_embeddings
if _cos.dim() == 2:
_cos = _cos[position_ids]
_sin = _sin[position_ids]
position_embeddings = (_cos, _sin)
hidden_states = inputs_embeds
compute_dtype = inputs_embeds.dtype
for layer in base.layers:
residual = hidden_states
hidden_states = layer.input_layernorm(hidden_states).to(compute_dtype)
hidden_states, _ = layer.self_attn(
hidden_states,
position_embeddings=position_embeddings,
**flex_kwargs,
)
hidden_states = residual + hidden_states.to(compute_dtype)
residual = hidden_states
hidden_states = layer.post_attention_layernorm(hidden_states).to(compute_dtype)
mlp_out = layer.mlp(hidden_states)
if isinstance(mlp_out, tuple):
hidden_states = mlp_out[0]
else:
hidden_states = mlp_out
hidden_states = residual + hidden_states.to(compute_dtype)
hidden_states = base.norm(hidden_states)
return hidden_states
# ---------------------------------------------------------------------------
# Sliding-window block-mask builders. Paged-KV aware.
# ---------------------------------------------------------------------------
def _create_sliding_causal_blockmask(page_table: PageTable, B: int, L: int, W: int):
"""Full-window causal block mask: ``q >= kv`` and ``q - kv < W``.
Built against the logical block layout; ``convert_logical_block_mask``
wraps it to paged indices later for decode."""
def sliding_causal(b, h, q_idx, kv_idx):
return (q_idx >= kv_idx) & (q_idx - kv_idx < W)
return create_block_mask(
sliding_causal,
B=B,
H=None,
Q_LEN=L,
KV_LEN=L,
BLOCK_SIZE=page_table.page_size,
device=page_table.device,
)
def _create_prefill_sliding_blockmask(
page_table: PageTable, batch_idx: torch.Tensor, W: int, BLOCK_SIZE: int = 128
):
"""Document-causal + sliding: ``q >= kv``, ``q - kv < W``, and
``docs[q] == docs[kv]``. Mirrors
``create_prefill_blockmask_no_paging`` with the window term added."""
assert batch_idx.ndim == 2 and batch_idx.shape[0] == 1
L = batch_idx.shape[1]
docs = batch_idx.view(-1)
def document_causal_sliding(b, h, q_idx, kv_idx):
causal = q_idx >= kv_idx
window = (q_idx - kv_idx) < W
document = docs[q_idx] == docs[kv_idx]
return causal & window & document
return create_block_mask(
document_causal_sliding,
B=1,
H=None,
Q_LEN=L,
KV_LEN=L,
BLOCK_SIZE=BLOCK_SIZE,
)
# ---------------------------------------------------------------------------
# FlexGptOssInference
# ---------------------------------------------------------------------------
class FlexGptOssInference:
"""gpt-oss inference engine. API-compatible with ``FlexMoEInference``.
Phase 1: eager decode only (no CUDA graph capture). Phase 3 adds the
capture path for the bf16 variant; bnb-4bit stays eager because
``GptOssExpertsBnb4bit`` uses an nn.ModuleList per-expert loop that
isn't graph-capturable.
"""
def __init__(
self,
model,
tokenizer,
max_batch_size=32,
max_seq_length=2048,
n_pages=2048,
page_size=128,
max_new_tokens=512,
decode_kernel_options=None,
prefill_kernel_options=None,
fa4_prefill=None,
base_model=None,
peft_model=None,
cumem_allocator=None,
compile_walker=None,
):
assert max_seq_length % page_size == 0
assert hasattr(model, "model") and hasattr(model.model, "layers"), (
"FlexGptOssInference expects a HF CausalLM shape (.model.layers)."
)
for i, layer in enumerate(model.model.layers):
assert hasattr(layer.self_attn, "sinks"), (
f"Layer {i}.self_attn has no sinks — not a gpt-oss attention?"
)
self.model = model
self.tokenizer = tokenizer
self.device = model.device
self.eos_token_id = tokenizer.eos_token_id
self.base_model = base_model
self.peft_model = peft_model
self.max_batch_size = max_batch_size
self.max_seq_length = max_seq_length
self.page_size = page_size
self.max_new_tokens = max_new_tokens
# Detect per-layer sliding window. gpt-oss alternates full /
# sliding-128; record whichever window is used so block-mask
# builders pick it up. If no layer has a sliding window, skip
# the sliding mask construction entirely.
sliding_windows = {
int(layer.self_attn.sliding_window)
for layer in model.model.layers
if layer.self_attn.sliding_window is not None
}
if len(sliding_windows) > 1:
raise NotImplementedError(
f"Mixed sliding-window sizes not supported: {sliding_windows}"
)
self.sliding_window = next(iter(sliding_windows), None)
# Detect bnb-4bit experts. The bnb variant uses an nn.ModuleList
# per-expert loop — disable CUDA graph capture if present.
self._has_bnb_experts = any(
type(layer.mlp.experts).__name__ == "GptOssExpertsBnb4bit"
for layer in model.model.layers
)
# FA4 kernel branch (same as dense/MoE).
if fa4_prefill is None or fa4_prefill:
major, _ = torch.cuda.get_device_capability(self.device)
supported = major >= 9
if fa4_prefill and not supported:
import warnings
warnings.warn(
f"--fa4_prefill needs sm_90+; found sm_{major}0. Falling "
f"back to the Triton flex_attention backend.",
RuntimeWarning,
stacklevel=2,
)
fa4_prefill = supported
self.fa4_prefill = fa4_prefill
self.prefill_q_block = 256 if fa4_prefill else 128
self.prefill_kv_block = 128
self.decode_kernel_options = (
decode_kernel_options
if decode_kernel_options is not None
else DECODE_KERNEL_OPTIONS_DEFAULT
)
base_prefill_opts = (
prefill_kernel_options
if prefill_kernel_options is not None
else dict(PREFILL_KERNEL_OPTIONS_DEFAULT)
)
if fa4_prefill:
base_prefill_opts = dict(base_prefill_opts)
base_prefill_opts.pop("FORCE_USE_FLEX_ATTENTION", None)
base_prefill_opts["BACKEND"] = "FLASH"
self.prefill_kernel_options = base_prefill_opts
from .sleep_mode import kv_cache_pool as _kv_cache_pool
with _kv_cache_pool(cumem_allocator):
self.page_table = PageTable(
n_pages=n_pages,
page_size=page_size,
max_batch_size=max_batch_size,
device=self.device.type,
)
patch_gptoss_attention_forwards(model, self.page_table)
self.input_pos_buffer = torch.zeros(
max_batch_size, dtype=torch.int32, device=self.device
)
self.block_mask_logical = self.page_table.create_causal_blockmask(
B=max_batch_size,
L=max_seq_length,
)
# Sliding-window logical block mask (only if any layer uses it).
if self.sliding_window is not None:
self.block_mask_logical_sliding = _create_sliding_causal_blockmask(
self.page_table,
B=max_batch_size,
L=max_seq_length,
W=self.sliding_window,
)
else:
self.block_mask_logical_sliding = None
# Cudagraph capture disabled for Phase 1. Phase 3 wires this up
# for bf16; bnb-4bit stays False either way.
self.cudagraph_captured = False
self.graphs = {}
self.graph_vars = {}
# Optional torch.compile walker (Phase 5 turns this on).
if compile_walker is None:
compile_walker = os.environ.get("UNSLOTH_FLEX_COMPILE_WALKER", "") == "1"
self._moe_walker = call_gpt_oss_model_with_flex_kwargs
if compile_walker:
try:
self._moe_walker = torch.compile(
call_gpt_oss_model_with_flex_kwargs,
fullgraph=False,
dynamic=False,
)
print(
"[flex-gptoss] wrapped call_gpt_oss_model_with_flex_kwargs "
"with torch.compile(fullgraph=False, dynamic=False)"
)
except Exception as e:
print(f"[flex-gptoss] torch.compile wrap failed: {e}")
self._moe_walker = call_gpt_oss_model_with_flex_kwargs
if self._has_bnb_experts:
print(
"[flex-gptoss] bnb-4bit experts detected; CUDA graph capture "
"disabled (decode stays eager)."
)
# --- tokenize / prefill / decode --------------------------------------
def tokenize(self, sequences):
for seq in sequences:
if seq.input_ids is not None and seq.input_length > 0:
continue
ids = self.tokenizer(seq.text, return_tensors="pt")["input_ids"].squeeze(0)
seq.input_ids = ids
seq.input_length = ids.shape[0]
def _prefill(self, batch: list[Sequence]) -> torch.Tensor:
input_ids_list = [seq.input_ids.to(self.device) for seq in batch]
input_pos_list = [
torch.arange(seq.input_length, dtype=torch.long, device=self.device)
for seq in batch
]
batch_idx_list = [
torch.full(
(seq.input_length,), seq.batch_idx, dtype=torch.long, device=self.device
)
for seq in batch
]
input_ids = torch.cat(input_ids_list).view(1, -1)
input_pos = torch.cat(input_pos_list).view(1, -1)
batch_idx = torch.cat(batch_idx_list).view(1, -1)
L = input_ids.shape[1]
q_block = self.prefill_q_block
pad = (q_block - L % q_block) % q_block
if pad > 0:
input_ids = F.pad(input_ids, (0, pad), value=0)
input_pos = F.pad(input_pos, (0, pad), value=0)
batch_idx = F.pad(batch_idx, (0, pad), value=0)
input_lengths = torch.tensor(
[s.input_length for s in batch], dtype=torch.long, device=self.device
)
logits_positions = input_lengths.cumsum(dim=0) - 1
prefill_block_size = (
(self.prefill_q_block, self.prefill_kv_block)
if self.fa4_prefill
else self.prefill_q_block
)
mask = self.page_table.create_prefill_blockmask_no_paging(
batch_idx, BLOCK_SIZE=prefill_block_size
)
mask_sliding = None
if self.sliding_window is not None:
mask_sliding = _create_prefill_sliding_blockmask(
self.page_table,
batch_idx,
W=self.sliding_window,
BLOCK_SIZE=prefill_block_size,
)
flex_kwargs = dict(
flex_block_mask=mask,
flex_block_mask_sliding=mask_sliding,
flex_input_pos=input_pos,
flex_batch_idx=batch_idx,
flex_kernel_options=self.prefill_kernel_options,
)
position_ids = input_pos
hidden = self._moe_walker(
self.model, input_ids, position_ids, flex_kwargs
)
return self.model.lm_head(hidden[:, logits_positions, :]).squeeze(0)
def _decode_block_mask(self, batch_idx: torch.Tensor, *, sliding: bool):
block_mask = (
self.block_mask_logical_sliding if sliding else self.block_mask_logical
)
input_pos = self.input_pos_buffer[batch_idx]
assert batch_idx.ndim == 1 and input_pos.ndim == 1
B = batch_idx.shape[0]
input_block_idx = input_pos // block_mask.BLOCK_SIZE[0]
kv_num_blocks = block_mask.kv_num_blocks[batch_idx, :, input_block_idx].view(
B, 1, 1
)
kv_indices = block_mask.kv_indices[batch_idx, :, input_block_idx].view(
B, 1, 1, -1
)
full_num = full_idx = None
if block_mask.full_kv_num_blocks is not None:
full_num = block_mask.full_kv_num_blocks[
batch_idx, :, input_block_idx
].view(B, 1, 1)
full_idx = block_mask.full_kv_indices[
batch_idx, :, input_block_idx
].view(B, 1, 1, -1)
if sliding:
W = self.sliding_window
def mask_fn(off):
def m(b, h, q_idx, kv_idx):
pos = q_idx + off[b]
return (pos >= kv_idx) & (pos - kv_idx < W)
return m
else:
def mask_fn(off):
def m(b, h, q_idx, kv_idx):
return q_idx + off[b] >= kv_idx
return m
seq_length = (1, block_mask.seq_lengths[1])
mask = BlockMask.from_kv_blocks(
kv_num_blocks,
kv_indices,
full_num,
full_idx,
BLOCK_SIZE=block_mask.BLOCK_SIZE,
mask_mod=mask_fn(input_pos),
seq_lengths=seq_length,
)
return mask, input_pos
def _decode_step_eager(self, batch_idx: torch.Tensor, input_ids: torch.Tensor):
B = input_ids.shape[0]
mask_full, input_pos = self._decode_block_mask(batch_idx, sliding=False)
mask_full = self.page_table.convert_logical_block_mask(mask_full, batch_idx)
mask_sliding = None
if self.sliding_window is not None:
ms, _ = self._decode_block_mask(batch_idx, sliding=True)
mask_sliding = self.page_table.convert_logical_block_mask(ms, batch_idx)
position_ids = input_pos.view(B, 1).to(torch.long)
flex_kwargs = dict(
flex_block_mask=mask_full,
flex_block_mask_sliding=mask_sliding,
flex_input_pos=input_pos.view(B, 1).to(torch.long),
flex_batch_idx=batch_idx,
flex_kernel_options=self.decode_kernel_options,
)
hidden = self._moe_walker(
self.model, input_ids.view(B, 1), position_ids, flex_kwargs
)
return self.model.lm_head(hidden[:, -1, :])
def _decode_step(
self, batch_idx: torch.Tensor, input_ids: torch.Tensor, input_pos: torch.Tensor
):
self.input_pos_buffer.zero_()
self.input_pos_buffer[batch_idx] = input_pos
if not self.cudagraph_captured:
return self._decode_step_eager(batch_idx, input_ids)
bs = input_ids.size(0)
key = next(x for x in self.graph_bs if x >= bs)
graph = self.graphs[key]
gv = self.graph_vars
for k, v in gv.items():
if k != "outputs":
v.zero_()
gv["input_ids"][:bs] = input_ids
gv["batch_idx"][:bs] = batch_idx
graph.replay()
return gv["outputs"][:bs]
def capture_decode_cudagraph(self):
"""Capture one CUDA graph per bs bucket. bf16 experts only;
bnb-4bit (nn.ModuleList per-expert loop) can't be captured and
``__init__`` already set ``capture_cudagraph`` to False for that
variant this method is only reached when the engine-level
flag is still True."""
if self._has_bnb_experts:
print(
"[flex-gptoss] bnb-4bit experts: skipping cudagraph capture; "
"decode stays eager."
)
return
try:
from unsloth_zoo.temporary_patches.moe_utils import select_moe_backend
backend = select_moe_backend()
except Exception:
backend = None
if backend != "grouped_mm":
print(
f"[flex-gptoss] MoE CUDA graph capture requires the "
f"'grouped_mm' backend (got {backend!r}); skipping capture."
)
return
max_bs = self.max_batch_size
reserved_batches = []
for bi in range(1, max_bs):
try:
allocated = self.page_table.allocate()
self.page_table.reserve(
allocated,
torch.tensor([allocated], device=self.device, dtype=torch.long),
self.page_size,
)
reserved_batches.append(allocated)
except Exception:
break
input_ids = torch.zeros(max_bs, dtype=torch.int64, device=self.device)
batch_idx = torch.arange(max_bs, dtype=torch.int64, device=self.device)
outputs = torch.zeros(
(max_bs, self.model.config.vocab_size),
dtype=self.model.dtype,
device=self.device,
)
_env_bs = os.environ.get("UNSLOTH_FLEX_GRAPH_BS")
if _env_bs:
try:
self.graph_bs = [int(x) for x in _env_bs.split(",") if x.strip()]
except ValueError:
print(
f"[flex-gptoss] invalid UNSLOTH_FLEX_GRAPH_BS={_env_bs!r}; "
f"using default bucket ladder"
)
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
else:
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
pool = None
for bs in reversed(self.graph_bs):
if bs > max_bs:
continue
print(f"[flex-gptoss] capturing CUDA graph for bs={bs}")
torch.cuda.synchronize()
_ = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
torch.cuda.synchronize()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool):
outputs[:bs] = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
if pool is None:
pool = graph.pool()
self.graphs[bs] = graph
torch.cuda.synchronize()
for bi in reserved_batches:
self.page_table.erase(bi)
self.graph_vars = dict(
input_ids=input_ids, batch_idx=batch_idx, outputs=outputs
)
def refresh_inference_from_base(self):
"""Refresh merged LoRA weights from pristine. Reuses the dense
refresh for q/k/v/o/router and the MoE refresh
(``refresh_moe_lora_merge_from_pristine``) for ``gate_up_proj``
/ ``down_proj`` in the ``(E, H, 2I)`` transposed orientation."""
if self.base_model is None or self.peft_model is None:
return 0
n = refresh_lora_merge_from_pristine(self.base_model, self.peft_model)
try:
n += refresh_moe_lora_merge_from_pristine(
self.base_model, self.peft_model
)
except Exception:
pass
return n
@torch.inference_mode()
def generate(self, sequences: list[Sequence], capture_cudagraph=False):
"""Decode loop. Phase 1: always eager."""
self.tokenize(sequences)
waiting = deque(sequences)
running = deque()
done = []
if capture_cudagraph and not self.cudagraph_captured:
self.capture_decode_cudagraph()
if self.graphs:
self.cudagraph_captured = True
while waiting or running:
batch = []
while waiting and self.page_table.can_reserve(waiting[0].total_length):
seq = waiting.popleft()
bi = self.page_table.allocate()
self.page_table.reserve(
bi,
torch.tensor([bi], device=self.device, dtype=torch.long),
seq.total_length,
)
seq.batch_idx = bi
batch.append(seq)
if batch:
logits = self._prefill(batch)
next_ids = torch.argmax(logits, dim=-1).tolist()
for i, seq in enumerate(batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
continue
decode_batch = []
while running:
seq = running.popleft()
if self.page_table.capacity[seq.batch_idx] >= seq.total_length:
decode_batch.append(seq)
elif self.page_table.can_reserve(
seq.total_length, batch_idx_int=seq.batch_idx
):
self.page_table.reserve(
seq.batch_idx,
torch.tensor(
[seq.batch_idx], device=self.device, dtype=torch.long
),
seq.total_length,
)
decode_batch.append(seq)
else:
running.appendleft(seq)
newest = running.pop()
waiting.appendleft(newest)
self.page_table.erase(newest.batch_idx)
if not decode_batch:
continue
B = len(decode_batch)
bi_tensor = torch.tensor(
[s.batch_idx for s in decode_batch],
dtype=torch.long,
device=self.device,
)
last_ids = torch.tensor(
[s.last_token_id for s in decode_batch],
dtype=torch.long,
device=self.device,
)
cur_pos = torch.tensor(
[s.total_length - 1 for s in decode_batch],
dtype=torch.int32,
device=self.device,
)
logits = self._decode_step(bi_tensor, last_ids, cur_pos)
next_ids = torch.argmax(logits, dim=-1).tolist()
for i, seq in enumerate(decode_batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
return done

View file

@ -0,0 +1,800 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Qwen3-MoE inference with flex_attention + paged KV cache.
Sibling of ``flex_qwen3_llama.py`` (dense Qwen3 / Llama-3). Handles
``Qwen3MoeForCausalLM`` where each decoder layer's ``mlp`` is a
``Qwen3MoeSparseMoeBlock``. Two differences from the dense path:
1. The walker (``call_moe_model_with_flex_kwargs``) unpacks whatever the
MoE MLP returns. Stock HF 5.x returns a plain tensor; Unsloth's
patched ``Qwen3MoeSparseMoeBlock_fast_forward`` returns
``(hidden_states, router_logits)``. The ``isinstance(_, tuple)``
guard handles both without coupling this file to either forward.
2. Decode runs eager (no CUDA-graph capture). The MoE expert routing
uses ``torch.where`` + a Python for-loop over experts, which is
data-dependent-shape and not graph-capturable. Prefill still uses
flex_attention compiled. A future cut can swap in a padded-fixed-
shape dispatch via ``UNSLOTH_MOE_STATIC_DISPATCH=1``.
Everything else paged-KV cache, attention forward, prefill block-mask,
LoRA double-copy refresh is shared verbatim with the dense path.
"""
from __future__ import annotations
import os
import types
from collections import deque
from typing import Optional
import torch
import torch.nn.functional as F
from torch.nn.attention.flex_attention import BlockMask
try:
from .flex_qwen3_llama import (
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
patch_model_attention_forwards,
refresh_lora_merge_from_pristine,
)
from .flex_paged_attention import PagedKVCache, PageTable
except ImportError: # script-mode fallback
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from flex_qwen3_llama import ( # noqa: E402
DECODE_KERNEL_OPTIONS_DEFAULT,
PREFILL_KERNEL_OPTIONS_DEFAULT,
Sequence,
patch_model_attention_forwards,
refresh_lora_merge_from_pristine,
)
from flex_paged_attention import PagedKVCache, PageTable # noqa: E402
def call_moe_model_with_flex_kwargs(model, input_ids, position_ids, flex_kwargs):
"""Walk a Qwen3-MoE model manually, injecting flex_* kwargs into each
attention call. Mirrors ``call_model_with_flex_kwargs`` from
``flex_qwen3_llama.py`` but handles the MoE MLP return shape.
For Qwen3-MoE, ``layer.mlp`` is a ``Qwen3MoeSparseMoeBlock``. Its
forward signature varies by patch:
- stock HF 5.x: returns a single tensor (``final_hidden_states``).
- Unsloth's ``Qwen3MoeSparseMoeBlock_fast_forward``: returns
``(final_X, router_logits)``.
- unsloth_zoo's ``sparse_moe_block_forward``: returns a single
tensor.
We call ``layer.mlp(...)`` and unpack whatever comes back. At
inference we discard ``router_logits`` no load-balance loss.
"""
base = model.model # Qwen3MoeModel
inputs_embeds = base.embed_tokens(input_ids)
position_embeddings = base.rotary_emb(inputs_embeds, position_ids)
# Unsloth's ``LlamaRotaryEmbedding`` drop-in returns the full cached
# cos/sin as ``[max_seq, D]`` 2D tensors, expecting the caller to
# slice. Stock HF returns ``[B, S, D]`` already-sliced.
_cos, _sin = position_embeddings
if _cos.dim() == 2:
_cos = _cos[position_ids]
_sin = _sin[position_ids]
position_embeddings = (_cos, _sin)
hidden_states = inputs_embeds
# RMSNorm + bnb-4bit Linear compute can promote activations to fp32
# along the Qwen3 MoE path even under autocast. Lock activations to
# the embed dtype so paged-KV writes (which index_put_ into a
# pre-allocated bf16 cache) see a matching dtype.
compute_dtype = inputs_embeds.dtype
for layer in base.layers:
# Attention block — identical to dense Qwen3 / Llama.
residual = hidden_states
hidden_states = layer.input_layernorm(hidden_states).to(compute_dtype)
hidden_states, _ = layer.self_attn(
hidden_states,
position_embeddings = position_embeddings,
**flex_kwargs,
)
hidden_states = residual + hidden_states.to(compute_dtype)
# MoE MLP.
residual = hidden_states
hidden_states = layer.post_attention_layernorm(hidden_states).to(compute_dtype)
mlp_out = layer.mlp(hidden_states)
if isinstance(mlp_out, tuple):
hidden_states = mlp_out[0]
else:
hidden_states = mlp_out
hidden_states = residual + hidden_states.to(compute_dtype)
hidden_states = base.norm(hidden_states)
return hidden_states
class FlexMoEInference:
"""MoE inference engine. API-compatible with ``FlexInference`` so
``FlexEngine`` dispatch is a one-line change.
Differences:
- uses ``call_moe_model_with_flex_kwargs`` (tuple-aware walker).
- ``cudagraph_captured`` is permanently False; ``generate`` always
runs the eager decode path. ``capture_decode_cudagraph`` raises
``NotImplementedError`` so a stray ``capture_cudagraph=True``
fails loudly rather than silently producing wrong output.
"""
def __init__(
self,
model,
tokenizer,
max_batch_size = 32,
max_seq_length = 2048,
n_pages = 2048,
page_size = 128,
max_new_tokens = 512,
decode_kernel_options = None,
prefill_kernel_options = None,
fa4_prefill = None,
base_model = None,
peft_model = None,
cumem_allocator = None,
compile_walker = None,
):
# FastQwen3MoeModel.pre_patch (unsloth/models/qwen3_moe.py) installs
# a legacy Qwen3MoeSparseMoeBlock_fast_forward that expects
# ``self.gate_proj``; transformers 5.x Qwen3MoE uses
# ``self.gate`` / ``self.experts`` instead, so that forward is dead
# code on this env. Unsloth-zoo's ``patch_qwen3_moe`` re-patches it
# to the correct ``sparse_moe_block_forward``, but Unsloth's
# pre_patch can run later and silently clobber it (patch_function
# bails via can_safely_patch on a second pass). Force-restore the
# stock HF forward here so the flex walker sees a working MLP.
try:
import transformers.models.qwen3_moe.modeling_qwen3_moe as _hf_mod
_BlockCls = _hf_mod.Qwen3MoeSparseMoeBlock
cur_forward = getattr(_BlockCls, "forward", None)
cur_name = getattr(cur_forward, "__name__", "")
if "fast_forward" in cur_name or cur_name == "Qwen3MoeSparseMoeBlock_fast_forward":
# Prefer unsloth_zoo's patched version if present;
# fall back to the stock HF forward otherwise.
unique = getattr(_BlockCls, "_original_forward_Qwen3MoeSparseMoeBlock", None) or getattr(_BlockCls, "_Qwen3MoeSparseMoeBlock_original_forward", None)
if unique is not None:
_BlockCls.forward = unique
else:
# Re-run unsloth_zoo patch to install sparse_moe_block_forward.
from unsloth_zoo.temporary_patches.qwen3_moe import patch_qwen3_moe
patch_qwen3_moe()
# If patch_function still skipped due to can_safely_patch,
# fall back to stock HF as a last resort.
cur_forward_after = getattr(_BlockCls, "forward", None)
cur_name_after = getattr(cur_forward_after, "__name__", "")
if "fast_forward" in cur_name_after:
# Lazy-load pristine forward by reloading the module.
import importlib
_fresh_mod = importlib.reload(_hf_mod)
_BlockCls.forward = _fresh_mod.Qwen3MoeSparseMoeBlock.forward
except Exception:
pass
assert max_seq_length % page_size == 0
# Startup sanity checks. If any of these fail the architecture
# isn't a Qwen3-MoE variant we know how to drive.
assert hasattr(model, "model") and hasattr(model.model, "layers"), (
"FlexMoEInference expects a HF CausalLM shape (.model.layers)."
)
for i, layer in enumerate(model.model.layers):
assert hasattr(layer, "post_attention_layernorm"), (
f"Layer {i} has no post_attention_layernorm."
)
assert hasattr(layer, "mlp") and callable(
getattr(layer.mlp, "forward", None)
), f"Layer {i}.mlp has no callable forward."
self.model = model
self.tokenizer = tokenizer
self.device = model.device
self.eos_token_id = tokenizer.eos_token_id
self.base_model = base_model
self.peft_model = peft_model
self.max_batch_size = max_batch_size
self.max_seq_length = max_seq_length
self.page_size = page_size
self.max_new_tokens = max_new_tokens
# Kernel-options / FA4 branch — copied from FlexInference.
if fa4_prefill is None or fa4_prefill:
major, _ = torch.cuda.get_device_capability(self.device)
supported = major >= 9
if fa4_prefill and not supported:
import warnings
warnings.warn(
f"--fa4_prefill needs Hopper (sm_90) or Blackwell "
f"(sm_100 / sm_120); found sm_{major}0. Falling back to "
f"the Triton flex_attention backend.",
RuntimeWarning,
stacklevel = 2,
)
fa4_prefill = supported
self.fa4_prefill = fa4_prefill
self.prefill_q_block = 256 if fa4_prefill else 128
self.prefill_kv_block = 128
self.decode_kernel_options = (
decode_kernel_options
if decode_kernel_options is not None
else DECODE_KERNEL_OPTIONS_DEFAULT
)
base_prefill_opts = (
prefill_kernel_options
if prefill_kernel_options is not None
else dict(PREFILL_KERNEL_OPTIONS_DEFAULT)
)
if fa4_prefill:
base_prefill_opts = dict(base_prefill_opts)
base_prefill_opts.pop("FORCE_USE_FLEX_ATTENTION", None)
base_prefill_opts["BACKEND"] = "FLASH"
self.prefill_kernel_options = base_prefill_opts
# Route paged-KV allocations through the cuMem pool when sleep
# mode is active. Block-mask / input_pos scratch stays in the
# default allocator.
from .sleep_mode import kv_cache_pool as _kv_cache_pool
with _kv_cache_pool(cumem_allocator):
self.page_table = PageTable(
n_pages = n_pages,
page_size = page_size,
max_batch_size = max_batch_size,
device = self.device.type,
)
patch_model_attention_forwards(model, self.page_table)
self.input_pos_buffer = torch.zeros(
max_batch_size, dtype = torch.int32, device = self.device
)
self.block_mask_logical = self.page_table.create_causal_blockmask(
B = max_batch_size,
L = max_seq_length,
)
self.cudagraph_captured = False
self.graphs = {}
self.graph_vars = {}
# Optional: wrap ``call_moe_model_with_flex_kwargs`` with
# ``torch.compile(fullgraph=False, dynamic=False)`` BEFORE CUDA
# graph capture. On Qwen3-30B-A3B 4bit this gives ~2x decode
# throughput (378 → 753 tok/s at bs=8, 1735 → 3383 tok/s at
# bs=48) because Inductor fuses the layernorm + residual +
# router pointwise ops and the compiled kernels get recorded
# into the captured graph. Opt-in for now: either pass
# ``compile_walker=True`` explicitly or set
# ``UNSLOTH_FLEX_COMPILE_WALKER=1``.
if compile_walker is None:
compile_walker = os.environ.get("UNSLOTH_FLEX_COMPILE_WALKER", "") == "1"
self._moe_walker = call_moe_model_with_flex_kwargs
if compile_walker:
try:
self._moe_walker = torch.compile(
call_moe_model_with_flex_kwargs,
fullgraph = False,
dynamic = False,
)
print(
"[flex-moe] wrapped call_moe_model_with_flex_kwargs "
"with torch.compile(fullgraph=False, dynamic=False)"
)
except Exception as e:
print(f"[flex-moe] torch.compile wrap failed, falling back: {e}")
self._moe_walker = call_moe_model_with_flex_kwargs
# --- tokenize / prefill / decode ---------------------------------------
# Near-verbatim from FlexInference. Only difference is the walker.
def tokenize(self, sequences):
for seq in sequences:
if seq.input_ids is not None and seq.input_length > 0:
continue
ids = self.tokenizer(seq.text, return_tensors = "pt")["input_ids"].squeeze(0)
seq.input_ids = ids
seq.input_length = ids.shape[0]
def _prefill(self, batch: list[Sequence]) -> torch.Tensor:
input_ids_list = [seq.input_ids.to(self.device) for seq in batch]
input_pos_list = [
torch.arange(seq.input_length, dtype = torch.long, device = self.device)
for seq in batch
]
batch_idx_list = [
torch.full(
(seq.input_length,), seq.batch_idx, dtype = torch.long, device = self.device
)
for seq in batch
]
input_ids = torch.cat(input_ids_list).view(1, -1)
input_pos = torch.cat(input_pos_list).view(1, -1)
batch_idx = torch.cat(batch_idx_list).view(1, -1)
L = input_ids.shape[1]
q_block = self.prefill_q_block
pad = (q_block - L % q_block) % q_block
if pad > 0:
input_ids = F.pad(input_ids, (0, pad), value = 0)
input_pos = F.pad(input_pos, (0, pad), value = 0)
batch_idx = F.pad(batch_idx, (0, pad), value = 0)
input_lengths = torch.tensor(
[s.input_length for s in batch], dtype = torch.long, device = self.device
)
logits_positions = input_lengths.cumsum(dim = 0) - 1
prefill_block_size = (
(self.prefill_q_block, self.prefill_kv_block)
if self.fa4_prefill
else self.prefill_q_block
)
mask = self.page_table.create_prefill_blockmask_no_paging(
batch_idx, BLOCK_SIZE = prefill_block_size
)
flex_kwargs = dict(
flex_block_mask = mask,
flex_input_pos = input_pos,
flex_batch_idx = batch_idx,
flex_kernel_options = self.prefill_kernel_options,
)
position_ids = input_pos
hidden = self._moe_walker(
self.model, input_ids, position_ids, flex_kwargs
)
return self.model.lm_head(hidden[:, logits_positions, :]).squeeze(0)
def _decode_block_mask(self, batch_idx: torch.Tensor):
block_mask = self.block_mask_logical
input_pos = self.input_pos_buffer[batch_idx]
assert batch_idx.ndim == 1 and input_pos.ndim == 1
B = batch_idx.shape[0]
input_block_idx = input_pos // block_mask.BLOCK_SIZE[0]
kv_num_blocks = block_mask.kv_num_blocks[batch_idx, :, input_block_idx].view(
B, 1, 1
)
kv_indices = block_mask.kv_indices[batch_idx, :, input_block_idx].view(
B, 1, 1, -1
)
full_num = full_idx = None
if block_mask.full_kv_num_blocks is not None:
full_num = block_mask.full_kv_num_blocks[
batch_idx, :, input_block_idx
].view(B, 1, 1)
full_idx = block_mask.full_kv_indices[batch_idx, :, input_block_idx].view(
B, 1, 1, -1
)
def causal_offset(off):
def offset(b, h, q_idx, kv_idx):
return q_idx + off[b] >= kv_idx
return offset
seq_length = (1, block_mask.seq_lengths[1])
mask = BlockMask.from_kv_blocks(
kv_num_blocks,
kv_indices,
full_num,
full_idx,
BLOCK_SIZE = block_mask.BLOCK_SIZE,
mask_mod = causal_offset(input_pos),
seq_lengths = seq_length,
)
return mask, input_pos
def _decode_step_eager(self, batch_idx: torch.Tensor, input_ids: torch.Tensor):
B = input_ids.shape[0]
mask, input_pos = self._decode_block_mask(batch_idx)
mask = self.page_table.convert_logical_block_mask(mask, batch_idx)
position_ids = (input_pos).view(B, 1).to(torch.long)
flex_kwargs = dict(
flex_block_mask = mask,
flex_input_pos = input_pos.view(B, 1).to(torch.long),
flex_batch_idx = batch_idx,
flex_kernel_options = self.decode_kernel_options,
)
hidden = self._moe_walker(
self.model, input_ids.view(B, 1), position_ids, flex_kwargs
)
return self.model.lm_head(hidden[:, -1, :])
def _decode_step(
self, batch_idx: torch.Tensor, input_ids: torch.Tensor, input_pos: torch.Tensor
):
self.input_pos_buffer.zero_()
self.input_pos_buffer[batch_idx] = input_pos
if not self.cudagraph_captured:
return self._decode_step_eager(batch_idx, input_ids)
bs = input_ids.size(0)
key = next(x for x in self.graph_bs if x >= bs)
graph = self.graphs[key]
gv = self.graph_vars
# batch_idx=0 is the reserved no-op slot. Zero out the unused part
# of each capture-shape buffer so padded entries don't write into
# real KV pages.
for k, v in gv.items():
if k != "outputs":
v.zero_()
gv["input_ids"][:bs] = input_ids
gv["batch_idx"][:bs] = batch_idx
graph.replay()
return gv["outputs"][:bs]
def capture_decode_cudagraph(self):
"""Capture one CUDA graph per batch-size bucket for MoE decode.
Supported on the ``grouped_mm`` MoE backend only. On that backend
the decode path is fixed-shape:
``bincount(minlength=num_experts) cumsum argsort
torch._grouped_mm × 2 index_add_``. Python control flow in
``sparse_moe_block_forward`` runs once at capture time; only the
recorded CUDA kernels replay.
For any other backend (``unsloth_triton``, ``native_torch``) this
method logs a warning and returns without enabling replay, so
``generate(capture_cudagraph=True)`` silently falls back to eager
decode instead of failing inside the captured graph.
Pre-reserves a page for every ``batch_idx`` slot so the paged-KV
``index_put_`` during capture hits valid physical addresses. The
reservations are erased after capture replay reads / writes the
same physical pages regardless of the logical batch state,
because ``batch_idx = 0`` is reserved as a padding slot.
"""
try:
from unsloth_zoo.temporary_patches.moe_utils import select_moe_backend
backend = select_moe_backend()
except Exception:
backend = None
if backend != "grouped_mm":
print(
f"[flex] MoE CUDA graph capture requires the 'grouped_mm' "
f"backend (got {backend!r}); skipping capture, decode stays "
f"eager."
)
return
max_bs = self.max_batch_size
reserved_batches = []
for bi in range(1, max_bs):
try:
allocated = self.page_table.allocate()
self.page_table.reserve(
allocated,
torch.tensor([allocated], device = self.device, dtype = torch.long),
self.page_size,
)
reserved_batches.append(allocated)
except Exception:
break
input_ids = torch.zeros(max_bs, dtype = torch.int64, device = self.device)
batch_idx = torch.arange(max_bs, dtype = torch.int64, device = self.device)
outputs = torch.zeros(
(max_bs, self.model.config.vocab_size),
dtype = self.model.dtype,
device = self.device,
)
# Bucket ladder for CUDA graph capture. Default mirrors the dense
# FlexInference pattern. ``UNSLOTH_FLEX_GRAPH_BS=1,8,32,64`` etc
# lets you override (useful for tight memory or for bigger batch
# bench). Values > max_bs are silently skipped below.
_env_bs = os.environ.get("UNSLOTH_FLEX_GRAPH_BS")
if _env_bs:
try:
self.graph_bs = [int(x) for x in _env_bs.split(",") if x.strip()]
except ValueError:
print(
f"[flex-moe] invalid UNSLOTH_FLEX_GRAPH_BS={_env_bs!r}; "
f"using default bucket ladder"
)
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
else:
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
pool = None
for bs in reversed(self.graph_bs):
if bs > max_bs:
continue
print(f"[flex-moe] capturing CUDA graph for bs={bs}")
torch.cuda.synchronize()
_ = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
torch.cuda.synchronize()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool):
outputs[:bs] = self._decode_step_eager(batch_idx[:bs], input_ids[:bs])
if pool is None:
pool = graph.pool()
self.graphs[bs] = graph
torch.cuda.synchronize()
for bi in reserved_batches:
self.page_table.erase(bi)
self.graph_vars = dict(
input_ids = input_ids, batch_idx = batch_idx, outputs = outputs
)
def refresh_inference_from_base(self):
"""Re-materialize the inference copy's merged LoRA weights from
the pristine base. No-op when no adapter is configured.
For Qwen3-MoE, dense LoRA targets (q/k/v/o and potentially the
router ``gate``) are handled by the dense refresh. Stacked
expert LoRA targets (``gate_up_proj`` / ``down_proj``) are
handled by the MoE refresh, which writes via ``torch.baddbmm``
into the same stacked-tensor storage so captured replay
addresses stay valid.
"""
if self.base_model is None or self.peft_model is None:
return 0
n = refresh_lora_merge_from_pristine(self.base_model, self.peft_model)
try:
n += refresh_moe_lora_merge_from_pristine(
self.base_model, self.peft_model
)
except Exception:
# MoE LoRA merge is best-effort for now: ZOO's MoE PEFT wrapper
# varies by transformers version. If the wrapper shape isn't
# recognised we fall back to the dense refresh only (which
# already handled any LoraLayer-wrapped modules).
pass
return n
@torch.inference_mode()
def generate(self, sequences: list[Sequence], capture_cudagraph = False):
"""Decode loop. Captures one CUDA graph per bucket on first call
when ``capture_cudagraph=True`` and the ``grouped_mm`` MoE
backend is active; otherwise falls back to eager decode."""
self.tokenize(sequences)
waiting = deque(sequences)
running = deque()
done = []
if capture_cudagraph and not self.cudagraph_captured:
self.capture_decode_cudagraph()
# ``capture_decode_cudagraph`` leaves ``cudagraph_captured``
# alone when it skips (non-grouped_mm backend), so only flip
# the flag when at least one bucket was actually captured.
if self.graphs:
self.cudagraph_captured = True
while waiting or running:
batch = []
while waiting and self.page_table.can_reserve(waiting[0].total_length):
seq = waiting.popleft()
bi = self.page_table.allocate()
self.page_table.reserve(
bi,
torch.tensor([bi], device = self.device, dtype = torch.long),
seq.total_length,
)
seq.batch_idx = bi
batch.append(seq)
if batch:
logits = self._prefill(batch)
next_ids = torch.argmax(logits, dim = -1).tolist()
for i, seq in enumerate(batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
continue
decode_batch = []
while running:
seq = running.popleft()
if self.page_table.capacity[seq.batch_idx] >= seq.total_length:
decode_batch.append(seq)
elif self.page_table.can_reserve(
seq.total_length, batch_idx_int = seq.batch_idx
):
self.page_table.reserve(
seq.batch_idx,
torch.tensor(
[seq.batch_idx], device = self.device, dtype = torch.long
),
seq.total_length,
)
decode_batch.append(seq)
else:
running.appendleft(seq)
newest = running.pop()
waiting.appendleft(newest)
self.page_table.erase(newest.batch_idx)
if not decode_batch:
continue
B = len(decode_batch)
bi_tensor = torch.tensor(
[s.batch_idx for s in decode_batch],
dtype = torch.long,
device = self.device,
)
last_ids = torch.tensor(
[s.last_token_id for s in decode_batch],
dtype = torch.long,
device = self.device,
)
cur_pos = torch.tensor(
[s.total_length - 1 for s in decode_batch],
dtype = torch.int32,
device = self.device,
)
logits = self._decode_step(bi_tensor, last_ids, cur_pos)
next_ids = torch.argmax(logits, dim = -1).tolist()
for i, seq in enumerate(decode_batch):
seq.last_token_id = next_ids[i]
seq.output_ids.append(next_ids[i])
if (
seq.last_token_id == self.eos_token_id
or len(seq.output_ids) >= seq.max_new_tokens
):
seq.finished = True
done.append(seq)
self.page_table.erase(seq.batch_idx)
else:
running.append(seq)
return done
# ===========================================================================
# MoE LoRA refresh — phase-4 companion to ``refresh_lora_merge_from_pristine``.
# ===========================================================================
def _get_moe_wrapper_tensor(wrapper):
"""Return the underlying 3D expert tensor for a PEFT-wrapped MoE
parameter. Tries the common attribute paths in order."""
if hasattr(wrapper, "get_base_layer"):
base = wrapper.get_base_layer()
if hasattr(base, "data"):
return base.data
return base
if isinstance(wrapper, torch.Tensor):
return wrapper.data
if hasattr(wrapper, "data"):
return wrapper.data
return None
def _pristine_moe_tensor(pristine_module, param_name):
p = getattr(pristine_module, param_name, None)
if p is None:
return None
if isinstance(p, torch.Tensor):
return p.data if hasattr(p, "data") else p
if hasattr(p, "data"):
return p.data
return p
def refresh_moe_lora_merge_from_pristine(base_model, peft_model):
"""Batched in-place LoRA merge for Qwen3-MoE stacked expert tensors.
For each PEFT ParamWrapper on a ``Qwen3MoeExperts.gate_up_proj`` /
``down_proj``, compute::
W_inf[e] = W_pristine[e] + sum_active(scaling * B[e] @ A[e])
via ``torch.baddbmm`` into the same storage, mirroring the dense
``refresh_lora_merge_from_pristine`` semantics (in-place write so
captured CUDA-graph replay reads the refreshed values).
Handles both standard (``E, 2I, H``) and transposed (``E, H, 2I``)
stacked orientations via a runtime shape check against the flat
``lora_A``/``lora_B`` shapes.
Returns the count of expert tensors refreshed. No-op when no
ParamWrapper-style MoE LoRA is present (e.g. dense-only LoRA, or
a transformers version that hasn't introduced stacked experts).
"""
if base_model is None or peft_model is None:
return 0
inference_model = peft_model.base_model.model
n_refreshed = 0
for name, module in inference_model.named_modules():
if not (hasattr(module, "gate_up_proj") and hasattr(module, "down_proj")):
continue
if not hasattr(module, "num_experts"):
continue
E = int(module.num_experts)
try:
pristine = base_model.get_submodule(name)
except AttributeError:
continue
for param_name in ("gate_up_proj", "down_proj"):
wrapper = getattr(module, param_name, None)
pristine_data = _pristine_moe_tensor(pristine, param_name)
if wrapper is None or pristine_data is None:
continue
has_lora = hasattr(wrapper, "lora_A") and hasattr(wrapper, "lora_B")
if not has_lora:
# No PEFT wrapping — keep the plain parameter in sync
# with pristine (covers the no-LoRA case where the
# inference copy otherwise diverges via training).
W_inf = _get_moe_wrapper_tensor(wrapper)
if W_inf is not None and W_inf.shape == pristine_data.shape:
W_inf.copy_(pristine_data)
n_refreshed += 1
continue
W_inf = _get_moe_wrapper_tensor(wrapper)
if W_inf is None or W_inf.dim() != 3:
continue
adapter_names = list(wrapper.lora_A.keys())
if not adapter_names:
W_inf.copy_(pristine_data)
if hasattr(wrapper, "merged_adapters"):
wrapper.merged_adapters = []
n_refreshed += 1
continue
# Determine orientation from lora shapes vs W_inf shape.
lora_A_w0 = wrapper.lora_A[adapter_names[0]].weight.data
lora_B_w0 = wrapper.lora_B[adapter_names[0]].weight.data
in_dim = lora_A_w0.shape[1]
out_dim = lora_B_w0.shape[0]
d0, d1 = W_inf.shape[1], W_inf.shape[2]
if d0 == out_dim and d1 == in_dim:
is_standard = True
elif d0 == in_dim and d1 == out_dim:
is_standard = False
else:
raise RuntimeError(
f"[refresh_moe_lora_merge_from_pristine] cannot "
f"determine orientation for {name}.{param_name}: "
f"W_inf.shape={tuple(W_inf.shape)}, "
f"in_dim={in_dim}, out_dim={out_dim}"
)
# Reset to pristine, then accumulate per-adapter.
W_inf.copy_(pristine_data)
for adapter_name in adapter_names:
scaling = wrapper.scaling[adapter_name]
A_w = wrapper.lora_A[adapter_name].weight.data
B_w = wrapper.lora_B[adapter_name].weight.data
R = A_w.shape[0] // E
# A_w: (E*R, in_dim) -> A_3d: (E, R, in_dim)
A_3d = A_w.view(E, R, in_dim)
# B_w: (out_dim, E*R) -> (out_dim, E, R) -> (E, out_dim, R)
B_3d = B_w.view(out_dim, E, R).permute(1, 0, 2).contiguous()
if is_standard:
torch.baddbmm(
W_inf,
B_3d.to(W_inf.dtype),
A_3d.to(W_inf.dtype),
alpha = float(scaling),
beta = 1.0,
out = W_inf,
)
else:
torch.baddbmm(
W_inf,
A_3d.transpose(-2, -1).contiguous().to(W_inf.dtype),
B_3d.transpose(-2, -1).contiguous().to(W_inf.dtype),
alpha = float(scaling),
beta = 1.0,
out = W_inf,
)
if hasattr(wrapper, "merged_adapters"):
wrapper.merged_adapters = list(adapter_names)
n_refreshed += 1
return n_refreshed

View file

@ -0,0 +1,504 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
#
# Adapted from attention-gym (https://github.com/pytorch-labs/attention-gym)
# Copyright (c) 2023, Driss Guessous, licensed under BSD 3-Clause
# (see THIRD_PARTY_LICENSES.md).
# the original implementation has some bugs and has some feature that lives outside of the PageTable class
from typing import Optional
import torch
from torch import Tensor
from torch.nn.attention.flex_attention import (
_identity,
_mask_mod_signature,
_score_mod_signature,
BlockMask,
noop_mask,
create_block_mask,
)
create_block_mask = torch.compile(create_block_mask, dynamic = True)
def _cdiv(x: int | float | torch.Tensor, multiple: int | float | torch.Tensor):
return (x + multiple - 1) // multiple
class PagedKVCache(torch.nn.Module):
def __init__(self, page_table, n_heads, head_dim, dtype):
super().__init__()
cache_shape = (1, n_heads, page_table.n_pages * page_table.page_size, head_dim)
self.register_buffer("k_cache", torch.zeros(cache_shape, dtype = dtype))
self.register_buffer("v_cache", torch.zeros(cache_shape, dtype = dtype))
self.page_table = page_table
def update(self, input_pos, k_val, v_val, batch_idx = None):
assert (
batch_idx is not None
), "batch_idx is required for paged kv cache, are you using non-paged attention?"
if batch_idx.ndim == 1:
# batch_idx should be [B] (decode)
return self.page_table.assign(
batch_idx, input_pos, k_val, v_val, self.k_cache, self.v_cache
)
else:
assert batch_idx.ndim == 2, "batch_idx must be 1D or 2D"
# batch_idx should be [1, L] (batch prefill)
return self.page_table.assign_prefill_no_paging(
batch_idx, input_pos, k_val, v_val, self.k_cache, self.v_cache
)
class PageTable:
"""
PageTable is a modified version of PagedAttention from attention-gym.
PageTable improves it by:
- maintaining a cpu copy of the page table, to avoid device-to-host transfers
- support batch prefill
- fix the bug in the original code in mask_mod and score_mod by mapping physical batch index to logical batch index
- subsuming the free_batch_idx into the page table, so we don't need to maintain it separately
"""
def __init__(
self,
n_pages: int,
page_size: int,
max_batch_size: int,
device: str = "cuda",
):
self.n_pages = n_pages
self.page_size = page_size
self.max_batch_size = max_batch_size
self.device = device
# page table: [logical_batch_idx, logical_block_idx] -> physical_page_idx
self.page_table = -torch.ones(
(max_batch_size, self.n_pages), dtype = torch.int64, device = device
)
self.page_table[0, :] = (
0 # page 0 is reserved for simpler code in assign_prefill_no_paging
)
self.page_table_cpu = [[] for _ in range(max_batch_size)]
self.capacity = [
0 for _ in range(max_batch_size)
] # capacity: batch_idx -> number of pages allocated * page size
self.free_pages = list(
reversed(range(1, n_pages))
) # page 0 is reserved for simpler code in assign_prefill_no_paging
self.free_batch_idx = list(
reversed(range(1, max_batch_size))
) # batch_idx 0 is reserved for no-op
# [logical_batch_idx, physical_page_idx] -> logical_page_idx
self.physical_to_logical = -torch.ones(
(max_batch_size, n_pages), dtype = torch.int64, device = device
)
def can_reserve(self, size: int, batch_idx_int: int | None = None) -> bool:
"""check if we can reserve new pages for an existing request or a new request, without gpu operations"""
if batch_idx_int is None:
# check if we can schedule a new request
return (
self.pages_available * self.page_size >= size
and len(self.free_batch_idx) > 0
)
else:
# check if we can reserve new pages for an existing request
return self.reserve(batch_idx_int, None, size, dry_run = True)
def allocate(self) -> int:
"""allocate a new batch"""
batch_idx = self.free_batch_idx.pop()
self.capacity[batch_idx] = 0
self.physical_to_logical[batch_idx, :] = -1
self.page_table[batch_idx, :] = -1
return batch_idx
@property
def pages_available(self) -> int:
return len(self.free_pages)
def reserve(
self,
batch_idx_int: int,
batch_idx: torch.Tensor,
seq_len: int,
dry_run: bool = False,
) -> bool:
"""
Requests the capacity of a given batch to be at least enough to
hold `seq_len` elements.
Args:
batch_idx_int (int): batch index to be reserved;
batch_idx (Tensor): batch index to be reserved; shape :math:`(1)`.
seq_len (Tensor): minimum capacity for the given batch; shape :math:`(1)`.
Returns:
bool: True if the reservation was successful, False if the reservation was not successful (no space, and in this case, no update is done)
"""
if seq_len <= self.capacity[batch_idx_int]:
return True
num_pages_to_allocate = _cdiv(
seq_len - self.capacity[batch_idx_int], self.page_size
)
can_allocate = num_pages_to_allocate <= self.pages_available
if dry_run:
return can_allocate
if not can_allocate:
raise RuntimeError(
f"Cannot reserve {num_pages_to_allocate} pages for a sequence of length {seq_len} "
f"in batch {batch_idx_int}. Only {self.pages_available} pages available. "
f"Current capacity is {self.capacity[batch_idx_int]} tokens."
)
start_page_idx = self.capacity[batch_idx_int] // self.page_size
end_page_idx = start_page_idx + num_pages_to_allocate
# find empty physical pages
allocated_pages_list = self.free_pages[-num_pages_to_allocate:]
allocated_pages = torch.tensor(allocated_pages_list, device = self.device)
# update page table
self.page_table[batch_idx, start_page_idx:end_page_idx] = allocated_pages
# update metadata
self.physical_to_logical[batch_idx, allocated_pages] = torch.arange(
start_page_idx,
end_page_idx,
device = self.device,
)
# update cpu side metadata
self.page_table_cpu[batch_idx_int] += allocated_pages_list
self.free_pages = self.free_pages[:-num_pages_to_allocate]
self.capacity[batch_idx_int] += num_pages_to_allocate * self.page_size
return True
def erase(self, batch_idx: int) -> None:
"""
Removes a single batch from paged attention.
Args:
batch_idx (int): batch index to be removed;
"""
# NOTE: the GPU side data will only be reset/overwritten when we allocate it for a new batch
self.free_batch_idx.append(batch_idx)
allocated_pages_cpu = self.page_table_cpu[batch_idx]
self.free_pages.extend(reversed(allocated_pages_cpu))
self.page_table_cpu[batch_idx] = []
def assign(
self,
batch_idx: torch.Tensor,
input_pos: torch.Tensor,
k_val: torch.Tensor,
v_val: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
) -> None:
"""
Assigns new contents `val` to the storage `cache` at the location
`batch_idx` and `input_pos`.
Args:
batch_idx (Tensor): batch index; shape :math:`(B)`.
input_pos (Tensor): input positions to be assigned for the given batch; shape :math:`(B, S)`.
val (Tensor): value to be assigned; shape :math:`(B, H, S, D)`
cache (Tensor): the cache to store the values; shape:`(1, H, MAX_S, D)`
"""
if k_val.requires_grad:
raise RuntimeError("val must not require gradient")
B, H, S, K_D = k_val.shape
_, H_cache, MAX_S, D_cache = k_cache.shape
assert H_cache == H, "number of heads must match"
assert MAX_S >= S, "cache must have enough space"
assert D_cache == K_D, "hidden dim must match"
assert input_pos.shape == (B, S), "input_pos must have the same shape as val"
assert batch_idx.shape == (B,), "batch_idx must have one dimension only"
V_D = v_val.shape[3]
if B != batch_idx.shape[0]:
raise RuntimeError(
f"Expect val and batch_idx have the same batch size but got B={B} and B={batch_idx.shape[0]}."
)
if H != k_cache.shape[1]:
raise RuntimeError(
f"Expect val and cache has the same number of heads but got H={H} and H={k_cache.shape[1]}."
)
if S != input_pos.shape[1]:
raise RuntimeError(
f"Expect val and input_pos has the same length but got S={S} and S={input_pos.shape[0]}."
)
if K_D != k_cache.shape[3]:
raise RuntimeError(
f"Expect k_val and k_cache has the same hidden dim but got D={K_D} and D={k_cache.shape[3]}."
)
if V_D != v_cache.shape[3]:
raise RuntimeError(
f"Expect v_val and v_cache has the same hidden dim but got D={V_D} and D={v_cache.shape[3]}."
)
# find address
logical_block_idx = input_pos // self.page_size # [B, S]
logical_block_offset = input_pos % self.page_size # [B, S]
# NOTE: this code path is only used for decoding. For batch prefill, use assign_prefill_no_paging() instead
physical_block_idx = torch.gather(
self.page_table[batch_idx], 1, logical_block_idx.to(torch.int64)
).to(torch.int32) # [B, S]
addr = (physical_block_idx * self.page_size + logical_block_offset).view(
-1
) # [B*S]
k_val = k_val.permute(1, 0, 2, 3).contiguous().view(1, H, B * S, K_D)
v_val = v_val.permute(1, 0, 2, 3).contiguous().view(1, H, B * S, V_D)
k_cache[:, :, addr, :] = k_val
v_cache[:, :, addr, :] = v_val
return k_cache, v_cache
def convert_logical_block_mask(
self,
block_mask: BlockMask,
batch_idx: Optional[torch.Tensor] = None,
) -> BlockMask:
"""
Converts a logical block mask by mapping its logical kv indices to the corresponding
physical kv indices.
Args:
block_mask (BlockMask): logical block mask;
kv_indices shape :math:`(B, H, ROWS, MAX_BLOCKS_IN_COL)`.
batch_idx (Tensor): batch index corresponding to the block_mask
batch dimension. This provides flexibility to convert a
block mask with smaller batch size than the page table;
shape :math:`(B)`.
"""
B, H, ROWS, MAX_BLOCKS_IN_COL = block_mask.kv_indices.shape
if block_mask.BLOCK_SIZE[1] != self.page_size:
raise RuntimeError(
f"Expect block_mask has the same column block size as page_sizebut got size={block_mask.BLOCK_SIZE[1]} and size={self.page_size}"
)
device = block_mask.kv_num_blocks.device
if batch_idx is None:
batch_idx = torch.arange(B, device = device)
assert batch_idx.ndim == 1, "batch_idx must be a 1D tensor"
assert (
batch_idx.shape[0] == B
), "batch_idx must have the same shape as block_mask"
assert (
B <= self.max_batch_size
), "batch_idx must be less than or equal to max_batch_size"
page_table = self.page_table[batch_idx]
def transform(num_blocks, indices):
"""
transform the block mask from [B, H, num_q_blocks, num_logical_kv_blocks]
to [B, H, num_q_blocks, num_physical_kv_blocks]
kv_num_blocks: [B, H, num_q_blocks] -> unchanged
kv_indices: [B, H, num_q_blocks, num_logical_kv_blocks] -> [B, H, num_q_blocks, num_physical_kv_blocks]
"""
if num_blocks is None:
return None, None
new_kv_num_blocks = num_blocks.clone()
new_kv_indices = torch.zeros(
(B, H, ROWS, self.n_pages), dtype = torch.int32, device = device
)
new_kv_indices[:, :, :, :MAX_BLOCKS_IN_COL] = (
torch.gather(page_table, 1, indices.view(B, -1).to(torch.int64))
.view(block_mask.kv_indices.shape)
.to(torch.int32)
)
return new_kv_num_blocks, new_kv_indices
new_kv_num_blocks, new_kv_indices = transform(
block_mask.kv_num_blocks, block_mask.kv_indices
)
new_full_kv_num_blocks, new_full_kv_indices = transform(
block_mask.full_kv_num_blocks, block_mask.full_kv_indices
)
new_mask_mod = self.get_mask_mod(block_mask.mask_mod, batch_idx)
seq_lengths = (block_mask.seq_lengths[0], self.n_pages * self.page_size)
return BlockMask.from_kv_blocks(
new_kv_num_blocks,
new_kv_indices,
new_full_kv_num_blocks,
new_full_kv_indices,
block_mask.BLOCK_SIZE,
new_mask_mod,
seq_lengths = seq_lengths,
)
def get_logical_kv_idx(
self,
physical_batch_idx: torch.Tensor,
physical_kv_idx: torch.Tensor,
batch_idx: torch.Tensor,
):
logical_batch_idx = batch_idx[physical_batch_idx]
physical_kv_block = physical_kv_idx // self.page_size
physical_kv_offset = physical_kv_idx % self.page_size
logical_block_idx = self.physical_to_logical[
logical_batch_idx, physical_kv_block
]
logical_kv_idx = logical_block_idx * self.page_size + physical_kv_offset
is_valid = logical_block_idx >= 0
safe_logical_kv_idx = logical_kv_idx.clamp(min = 0)
return is_valid, safe_logical_kv_idx
def get_mask_mod(
self, mask_mod: Optional[_mask_mod_signature], batch_idx: torch.Tensor
) -> _mask_mod_signature:
"""
Converts a mask_mod based on mapping from the physical block index to the logical
block index.
Args:
mask_mod (_mask_mod_signature): mask_mod based on the logical block index.
"""
if mask_mod is None:
mask_mod = noop_mask
def new_mask_mod(
b: torch.Tensor,
h: torch.Tensor,
q_idx: torch.Tensor,
physical_kv_idx: torch.Tensor,
):
is_valid, safe_logical_kv_idx = self.get_logical_kv_idx(
b, physical_kv_idx, batch_idx
)
return torch.where(
is_valid, mask_mod(b, h, q_idx, safe_logical_kv_idx), False
)
return new_mask_mod
# NOTE: not used in the current codebase
def get_score_mod(
self, score_mod: Optional[_score_mod_signature], batch_idx: torch.Tensor
) -> _score_mod_signature:
"""
Converts a score_mod based on mapping from the physical block index to the logical
block index.
Args:
score_mod (_score_mod_signature): score_mod based on the logical block index.
"""
if score_mod is None:
score_mod = _identity
def new_score_mod(
score: torch.Tensor,
b: torch.Tensor,
h: torch.Tensor,
q_idx: torch.Tensor,
physical_kv_idx: torch.Tensor,
):
is_valid, safe_logical_kv_idx = self.get_logical_kv_idx(
b, physical_kv_idx, batch_idx
)
return torch.where(
is_valid,
score_mod(score, b, h, q_idx, safe_logical_kv_idx),
float("-inf"),
)
return new_score_mod
def create_causal_blockmask(self, B, L):
"""A minimal, unoptimized causal block mask creation function"""
def causal(b, h, q_idx, kv_idx):
return q_idx >= kv_idx
return create_block_mask(
causal,
B = B,
H = None,
Q_LEN = L,
KV_LEN = L,
BLOCK_SIZE = self.page_size,
device = self.device,
)
def create_prefill_blockmask_no_paging(
self, batch_idx: Tensor, BLOCK_SIZE: int = 128
):
"""
there's no prefix sharing implemented, batch_idx is the document id, batch_idx is not guaranteed to be sorted
"""
assert batch_idx.ndim == 2, "batch_idx must be a 2D tensor"
assert batch_idx.shape[0] == 1, "batch_idx must have batch size 1"
L = batch_idx.shape[1]
docs = batch_idx.view(-1)
def document_causal(b, h, q_idx, kv_idx):
causal_mask = q_idx >= kv_idx
document_mask = docs[q_idx] == docs[kv_idx]
return causal_mask & document_mask
return create_block_mask(
document_causal, B = 1, H = None, Q_LEN = L, KV_LEN = L, BLOCK_SIZE = BLOCK_SIZE
)
# we assign prefill to the cache, similar to assign(), except we don't return the k_cache, v_cache, we only return the k_val, v_val
def assign_prefill_no_paging(
self,
batch_idx: torch.Tensor,
input_pos: torch.Tensor,
k_val: torch.Tensor,
v_val: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
) -> None:
"""
assigns kv and returns the original kv
batch_idx: [1, L]
input_pos: [1, L]
k_val: [1, H, L, D]
v_val: [1, H, L, D]
k_cache: [1, H, MAX_S, D]
v_cache: [1, H, MAX_S, D]
"""
assert batch_idx.ndim == 2, "batch_idx must be a 2D tensor"
assert input_pos.ndim == 2, "input_pos must be a 2D tensor"
assert k_val.ndim == 4, "k_val must be a 4D tensor"
assert v_val.ndim == 4, "v_val must be a 4D tensor"
assert k_cache.ndim == 4, "k_cache must be a 4D tensor"
assert v_cache.ndim == 4, "v_cache must be a 4D tensor"
assert batch_idx.shape[0] == 1, "batch_idx must have batch size 1"
input_pos_block_idx = input_pos // self.page_size
input_pos_offset_in_block = input_pos % self.page_size
physical_kv_idx = (
self.page_table[batch_idx, input_pos_block_idx] * self.page_size
+ input_pos_offset_in_block
)
k_cache[:, :, physical_kv_idx.view(-1), :] = k_val
v_cache[:, :, physical_kv_idx.view(-1), :] = v_val
return k_val, v_val

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Sleep-mode helpers for the flex inference backend.
When ``UNSLOTH_VLLM_STANDBY=1`` is set and vLLM's ``CuMemAllocator`` is
importable, :class:`~unsloth.inference.flex_engine.FlexEngine` routes its
heavy GPU allocations (the inference deep-copies, the PEFT wrapper, and
per-layer :class:`~unsloth.inference.flex_paged_attention.PagedKVCache`
buffers) through cuMem-backed pools. ``sleep(level=1)`` then offloads the
``weights`` pool to pinned CPU memory and discards the ``kv_cache`` pool
outright; ``wake_up`` re-maps the handles at the same virtual addresses
so previously captured CUDA graphs and compiled artifacts stay valid.
If vLLM is not installed, :func:`_get_cumem_allocator` returns ``None``
and the helpers fall back to :func:`contextlib.nullcontext`, which keeps
the flex engine working without sleep support.
"""
from __future__ import annotations
import contextlib
import os
import warnings
from typing import Any, Optional
_WARNED_NO_VLLM = False
def _get_cumem_allocator() -> Optional[Any]:
"""Return ``vllm.device_allocator.cumem.CuMemAllocator.get_instance()``
or ``None`` if vLLM is not importable.
Emits a single warning on the first failed import so users who opt
into sleep mode with ``UNSLOTH_VLLM_STANDBY=1`` see a clear message
about the soft dependency on vLLM."""
global _WARNED_NO_VLLM
try:
from vllm.device_allocator.cumem import CuMemAllocator
except Exception as e:
if not _WARNED_NO_VLLM:
warnings.warn(
"FlexEngine sleep mode requires vLLM's CuMemAllocator "
f"(import failed: {e}). Sleep / wake_up will be no-ops. "
"Install vLLM to enable level-1 sleep mode on the flex "
"backend.",
RuntimeWarning,
stacklevel = 2,
)
_WARNED_NO_VLLM = True
return None
return CuMemAllocator.get_instance()
def sleep_mode_enabled() -> bool:
"""``True`` iff ``UNSLOTH_VLLM_STANDBY=1`` is set AND vLLM is
available. Evaluated at :class:`FlexEngine.__init__` time so the
choice of allocator is stable for the engine's lifetime."""
if os.environ.get("UNSLOTH_VLLM_STANDBY", "0") != "1":
return False
return _get_cumem_allocator() is not None
def _pool(allocator: Optional[Any], tag: str):
"""Return a ``use_memory_pool(tag=...)`` context manager if the
allocator is available, else ``nullcontext`` so callers can wrap
allocation sites unconditionally."""
if allocator is None:
return contextlib.nullcontext()
return allocator.use_memory_pool(tag = tag)
def weight_pool(allocator: Optional[Any]):
"""Context manager for ``tag="weights"`` allocations (offloaded to
pinned CPU on sleep, restored via ``cudaMemcpy`` on wake)."""
return _pool(allocator, "weights")
def kv_cache_pool(allocator: Optional[Any]):
"""Context manager for ``tag="kv_cache"`` allocations (discarded on
sleep, re-mapped with zeros on wake)."""
return _pool(allocator, "kv_cache")
def describe_sleep_state(engine) -> dict:
"""Return a small dict summarising the flex engine's sleep-mode
state plus a torch CUDA memory snapshot. Useful from bench scripts
and tests."""
import torch
allocator = getattr(engine, "_cumem_allocator", None)
state: dict = {
"sleep_mode_enabled": bool(getattr(engine, "_sleep_mode_enabled", False)),
"cumem_allocator": type(allocator).__name__ if allocator is not None else None,
}
if torch.cuda.is_available():
state["allocated_gb"] = round(torch.cuda.memory_allocated() / 1e9, 3)
state["reserved_gb"] = round(torch.cuda.memory_reserved() / 1e9, 3)
if allocator is not None and hasattr(allocator, "get_current_usage"):
try:
state["cumem_current_usage"] = allocator.get_current_usage()
except Exception:
pass
return state
__all__ = [
"_get_cumem_allocator",
"sleep_mode_enabled",
"weight_pool",
"kv_cache_pool",
"describe_sleep_state",
]

View file

@ -0,0 +1,172 @@
# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""vLLM-API surface for the flex inference backend.
`FlexEngine.generate` / `.chat` return :class:`RequestOutput` objects with the
same attribute shape (`prompt_token_ids`, `outputs[i].token_ids`,
`outputs[i].text`, `outputs[i].logprobs`) that vLLM's `LLM.generate` does, so
TRL's GRPO trainer — which reads `output.prompt_token_ids` and iterates
`output.outputs[i].token_ids` (trl/trainer/grpo_trainer.py:1274-1279) does
not care which backend produced the result.
`save_lora` / `load_lora` mirror `unsloth_zoo.vllm_utils.save_lora` /
`load_lora` (vllm_utils.py:2389-2628). The only backend-visible difference is
the `LoRARequest` class: we emit our shim, not vllm's, so the GRPO patch in
`unsloth/models/rl.py:1880` passes our object straight to `FlexEngine.generate`.
"""
from __future__ import annotations
import functools
import os
from dataclasses import dataclass, field
from typing import Any, Optional
# ---------------------------------------------------------------------------
# vLLM result objects
# ---------------------------------------------------------------------------
@dataclass
class CompletionOutput:
"""vLLM `CompletionOutput` stand-in.
TRL reads ``.token_ids`` (list of int) and ``.logprobs`` (optional list of
dict[int, Logprob]); we populate both."""
index: int = 0
text: str = ""
token_ids: list = field(default_factory = list)
cumulative_logprob: Optional[float] = None
logprobs: Optional[list] = None
finish_reason: Optional[str] = "stop"
stop_reason: Optional[str] = None
@dataclass
class RequestOutput:
"""vLLM `RequestOutput` stand-in.
TRL reads ``.prompt_token_ids`` (list of int) and iterates
``.outputs`` (list[CompletionOutput])."""
request_id: str = ""
prompt: str = ""
prompt_token_ids: list = field(default_factory = list)
outputs: list = field(default_factory = list)
finished: bool = True
# ---------------------------------------------------------------------------
# LoRARequest stand-in
# ---------------------------------------------------------------------------
#
# The vLLM + unsloth_zoo combo imports ``vllm.lora.request.LoRARequest`` at
# call time. When the flex backend is selected we never hit that import; we
# pass our own dataclass that carries the same three attributes the caller
# reads: ``lora_name``, ``lora_int_id``, and ``lora_tensors`` +
# ``lora_config`` (for the in-memory LoRA fast path).
@dataclass
class LoRARequest:
lora_name: str = ""
lora_int_id: int = 0
lora_path: Optional[str] = None
lora_tensors: Optional[dict] = None # dict[str, torch.Tensor]
lora_config: Any = None
# ---------------------------------------------------------------------------
# save_lora / load_lora — drop-in for unsloth_zoo.vllm_utils equivalents
# ---------------------------------------------------------------------------
_LORA_REQUEST_ID: Optional[int] = None
def save_lora(model, save_directory, *args, **kwargs):
"""Dump the PEFT LoRA tensors (``.lora_A.`` / ``.lora_B.``) into a
PEFT-compatible directory. Mirrors
``unsloth_zoo.vllm_utils.save_lora`` (vllm_utils.py:2389-2397) byte-for-byte
so existing callers (e.g. TRL's GRPOTrainer patch) are untouched."""
state_dict = model.state_dict()
dtype = model.get_input_embeddings().weight.dtype
state_dict = {
k: v.to(dtype)
for k, v in state_dict.items()
if ".lora_A." in k or ".lora_B." in k
}
kwargs["state_dict"] = state_dict
model.save_pretrained(save_directory = save_directory, *args, **kwargs)
def _get_peft_config(save_directory):
"""Late-imported to keep the `peft` dep optional at module-load time."""
from peft import PeftConfig
return PeftConfig.from_pretrained(save_directory)
def load_lora(
model,
save_directory,
load_tensors: bool = False,
lora_request_id: Optional[int] = None,
):
"""Build a :class:`LoRARequest` the flex backend can consume.
Mirrors ``unsloth_zoo.vllm_utils.load_lora`` (vllm_utils.py:2574-2628):
increments a module-level counter so each request gets a fresh
``lora_int_id``, writes the PEFT adapter config to ``save_directory`` on
first call (or when ``load_tensors=True``) and captures the current
state-dict LoRA tensors so the engine can merge them without an extra
disk round-trip."""
global _LORA_REQUEST_ID
if _LORA_REQUEST_ID is None:
_LORA_REQUEST_ID = 1
if lora_request_id is None:
lora_request_id = _LORA_REQUEST_ID
if not os.path.exists(save_directory) or lora_request_id == 1:
if load_tensors:
model.peft_config["default"].save_pretrained(save_directory)
elif not os.path.exists(save_directory):
raise OSError(f"Unsloth: LoRA filepath = {save_directory} does not exist!")
if load_tensors:
peft_config = _get_peft_config(save_directory)
state_dict = model.state_dict()
state_dict = {
k.replace(".default", ""): v
for k, v in state_dict.items()
if ".lora_A." in k or ".lora_B." in k
}
req = LoRARequest(
lora_name = str(lora_request_id),
lora_int_id = lora_request_id,
lora_tensors = state_dict,
lora_config = peft_config,
)
else:
req = LoRARequest(
lora_name = str(lora_request_id),
lora_int_id = lora_request_id,
lora_path = save_directory,
)
_LORA_REQUEST_ID += 1
return req
# Partial-applied variants, mirroring ``patch_peft_fast_inference``:
# model.save_lora = functools.partial(save_lora, model)
# model.load_lora = functools.partial(load_lora, model)
# are attached by the caller (unsloth/models/_utils.py:2707-2708).
__all__ = [
"CompletionOutput",
"RequestOutput",
"LoRARequest",
"save_lora",
"load_lora",
]

View file

@ -2672,6 +2672,10 @@ def validate_loftq_config(loftq_config, lora_dropout, bias, init_lora_weights, m
def fast_inference_setup(model_name, model_config):
fast_inference = True
if os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1":
# Flex inference backend (Qwen3 / Llama-3 / Gemma-4-E2B-it). Skip
# the vLLM setup entirely — the flex engine does not use vllm.
return fast_inference, model_name
if not is_vLLM_available():
logger.warning_once(
"Unsloth: vLLM is not installed! Will use Unsloth inference!"
@ -2701,8 +2705,15 @@ def patch_peft_fast_inference(model):
model.fast_generate = model.model.fast_generate
model.fast_generate_batches = model.model.fast_generate_batches
# Also saving and loading LoRA
from unsloth_zoo.vllm_utils import save_lora, load_lora
# Pick the right save_lora / load_lora implementation. The flex
# backend (UNSLOTH_FAST_INFERENCE=1) never imports vllm; reading
# from unsloth_zoo.vllm_utils would try to import
# ``vllm.lora.request.LoRARequest`` inside load_lora and fail.
_is_flex = type(vllm_engine).__name__ == "FlexEngine"
if _is_flex:
from unsloth.inference.vllm_shim import save_lora, load_lora
else:
from unsloth_zoo.vllm_utils import save_lora, load_lora
model.save_lora = functools.partial(save_lora, model)
model.load_lora = functools.partial(load_lora, model)

View file

@ -1397,12 +1397,26 @@ def _LlamaModel_fast_forward_inference(
XX2 = XX2,
variance = variance,
)
X = mlp_fast_forward_inference(
decoder_layer.mlp,
X,
temp_gate = temp_gates[device_index],
temp_up = temp_ups[device_index],
)
# MoE blocks (Qwen3MoeSparseMoeBlock, etc.) do not have the
# dense gate_proj / up_proj / down_proj attributes that
# mlp_fast_forward_inference requires. Delegate to the
# class-level forward (patched by unsloth_zoo for MoE) and
# unpack the (hidden_states, router_logits) tuple.
_mlp_mod = decoder_layer.mlp
if not (
hasattr(_mlp_mod, "gate_proj")
and hasattr(_mlp_mod, "up_proj")
and hasattr(_mlp_mod, "down_proj")
):
_mlp_out = _mlp_mod(X)
X = _mlp_out[0] if isinstance(_mlp_out, tuple) else _mlp_out
else:
X = mlp_fast_forward_inference(
_mlp_mod,
X,
temp_gate = temp_gates[device_index],
temp_up = temp_ups[device_index],
)
X += residual
next_decoder_cache.append(present_key_value)
@ -2488,7 +2502,18 @@ class FastLlamaModel:
offload_embedding = False,
fast_inference = fast_inference,
)
elif not fast_inference:
elif not fast_inference or (
os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1"
):
# Two callers share this branch:
# * the standard HF load (``fast_inference=False``)
# * the flex-inference load (``fast_inference=True`` +
# ``UNSLOTH_FAST_INFERENCE=1``) -- we load the HF model the
# same way and then wrap it with a ``FlexEngine`` below.
# ``max_batch_size`` is a FlexEngine-specific kwarg, not an HF
# one; stash it before ``AutoModelForCausalLM.from_pretrained``
# rejects it as an unexpected argument.
_flex_max_batch_size = kwargs.pop("max_batch_size", 32)
model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map = device_map,
@ -2508,10 +2533,48 @@ class FastLlamaModel:
load_in_4bit = load_in_4bit,
load_in_8bit = kwargs.get("load_in_8bit", False),
offload_embedding = False,
fast_inference = False,
fast_inference = fast_inference,
)
model.fast_generate = make_fast_generate_wrapper(model.generate)
model.fast_generate_batches = None
if fast_inference and os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1":
# Snapshot the HF model BEFORE Unsloth post-patching so the
# flex inference copy uses the original transformers
# rotary / QKV layout. A later block (after the tokenizer is
# loaded) constructs the FlexEngine around this copy.
#
# If ``UNSLOTH_VLLM_STANDBY=1`` is set AND vLLM is
# importable, wrap the deep-copy in the cuMem
# ``weights`` pool so ``FlexEngine.sleep(level=1)`` can
# offload it to pinned CPU memory without invalidating
# the engine's captured CUDA graphs. This must happen at
# the deep-copy site, not later in FlexEngine.__init__,
# because the engine receives the copy as
# ``inference_model=`` and never re-allocates it.
import copy as _copy
from unsloth.inference.sleep_mode import (
_get_cumem_allocator as _flex_get_cumem,
sleep_mode_enabled as _flex_sleep_enabled,
weight_pool as _flex_weight_pool,
)
_flex_allocator = _flex_get_cumem() if _flex_sleep_enabled() else None
with _flex_weight_pool(_flex_allocator):
model._unsloth_flex_inference_copy = _copy.deepcopy(model)
model._unsloth_flex_inference_copy.eval()
model._unsloth_needs_flex_engine = dict(
dtype = dtype,
max_seq_length = max_seq_length,
max_lora_rank = max_lora_rank,
max_batch_size = _flex_max_batch_size,
gpu_memory_utilization = gpu_memory_utilization,
)
# Provide a placeholder so downstream code that checks
# hasattr(model, "fast_generate") doesn't explode; we swap
# it out once the engine is live.
model.fast_generate = make_fast_generate_wrapper(model.generate)
model.fast_generate_batches = None
else:
model.fast_generate = make_fast_generate_wrapper(model.generate)
model.fast_generate_batches = None
else:
from unsloth_zoo.vllm_utils import (
load_vllm,
@ -2584,6 +2647,20 @@ class FastLlamaModel:
model, tokenizer, correct_dtype = dtype
)
# UNSLOTH_FAST_INFERENCE=1 path: install the lazy ``vllm_engine``
# sentinel now that the tokenizer is available. The real
# ``FlexEngine(...)`` construction is deferred to
# :func:`build_flex_engine` so the batch-size dimension can be
# sized from the GRPO rollout shape instead of frozen here at a
# guessed default. The HF model itself is NOT flex-patched --
# the engine owns its own pre-patched deep-copy (captured above
# before Unsloth's post_patch), so `model.forward` (used by the
# training loop) stays intact.
if hasattr(model, "_unsloth_needs_flex_engine"):
from unsloth.inference.flex_engine import install_flex_sentinel
install_flex_sentinel(model, tokenizer)
# Patch up QKV / O and MLP
for idx, layer in enumerate(model.model.layers):
layer.self_attn.apply_qkv = original_apply_qkv
@ -3290,6 +3367,13 @@ class FastLlamaModel:
patch_peft_fast_inference(model)
# Hand the training-side PEFT wrapper to the flex engine so that
# state_dict() reads the current LoRA weights and the inference
# copy can mirror them.
_engine = getattr(model, "vllm_engine", None)
if _engine is not None and hasattr(_engine, "bind_peft_model"):
_engine.bind_peft_model(model)
# Add for_inference and for_training
model.for_training = functools.partial(FastLlamaModel.for_training, model)
model.for_inference = functools.partial(FastLlamaModel.for_inference, model)
@ -3338,7 +3422,7 @@ class FastLlamaModel:
apply_lora_mlp = apply_lora_mlp_swiglu
elif model_type == "falcon_h1":
apply_lora_mlp = apply_lora_mlp_swiglu
elif model_type == "qwen3moe":
elif model_type == "qwen3_moe":
apply_lora_mlp = apply_lora_mlp_swiglu
else:
raise NotImplementedError(f"Unsloth: {model_type} is not yet implemented!")
@ -3412,6 +3496,16 @@ class FastLlamaModel:
# MLP patching
mlp_module = layer.mlp
# Qwen3 MoE uses Qwen3MoeSparseMoeBlock which holds
# stacked expert tensors on .experts; the dense
# gate/up/down fusion does not apply. MoE LoRA is
# wired through unsloth_zoo/moe_utils instead.
if not (
hasattr(mlp_module, "gate_proj")
and hasattr(mlp_module, "up_proj")
and hasattr(mlp_module, "down_proj")
):
continue
gate_proj = mlp_module.gate_proj
up_proj = mlp_module.up_proj
down_proj = mlp_module.down_proj

View file

@ -353,7 +353,13 @@ class FastLanguageModel(FastLlamaModel):
or dtype == torch.float32
)
if fast_inference:
_use_flex_fast_inference = os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1"
if fast_inference and _use_flex_fast_inference:
# Flex backend path: skip the vLLM import gate entirely. The
# actual engine is attached further down in
# ``FastLlamaModel.from_pretrained``.
pass
elif fast_inference:
if importlib.util.find_spec("vllm") is None:
raise ImportError(
"Unsloth: Please install vLLM before enabling `fast_inference`!\n"
@ -632,7 +638,7 @@ class FastLanguageModel(FastLlamaModel):
dispatch_model = FastGemma2Model
elif model_type == "qwen2":
dispatch_model = FastQwen2Model
elif model_type == "qwen3": # or model_type == "qwen3_moe":
elif model_type == "qwen3" or model_type == "qwen3_moe":
if not SUPPORTS_QWEN3 or not SUPPORTS_QWEN3_MOE:
raise ImportError(
f"Unsloth: Your transformers version of {transformers_version} does not support Qwen3.\n"
@ -979,7 +985,12 @@ class FastModel(FastBaseModel):
)
load_in_4bit = False
if fast_inference:
_use_flex_fast_inference = os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1"
if fast_inference and _use_flex_fast_inference:
# Flex backend path: skip the vLLM import gate. The engine is
# attached further down in the ``FastBaseModel`` loader.
pass
elif fast_inference:
if importlib.util.find_spec("vllm") is None:
raise ImportError(
"Unsloth: Please install vLLM before enabling `fast_inference`!\n"

View file

@ -137,9 +137,15 @@ def Qwen3MoeDecoderLayer_fast_forward(
hidden_states = fast_rms_layernorm_inference(
self.post_attention_layernorm, hidden_states
)
hidden_states, router_logits = Qwen3MoeSparseMoeBlock_fast_forward(
self.mlp, hidden_states
)
# Use the class-level forward (patched by unsloth_zoo to
# sparse_moe_block_forward for transformers 5.x) instead of
# directly calling the legacy fast_forward, which breaks on
# stacked-expert MoE blocks that lack self.gate_proj.
mlp_out = self.mlp(hidden_states)
if isinstance(mlp_out, tuple):
hidden_states, router_logits = mlp_out[0], mlp_out[1]
else:
hidden_states, router_logits = mlp_out, None
hidden_states += residual
else:
residual = hidden_states
@ -160,7 +166,17 @@ def Qwen3MoeDecoderLayer_fast_forward(
# MoE Router MLP
residual = hidden_states
hidden_states = fast_rms_layernorm(self.post_attention_layernorm, hidden_states)
hidden_states, router_logits = self.mlp(hidden_states)
# unsloth_zoo's sparse_moe_block_forward returns a plain tensor
# for transformers 5.x stacked experts; the legacy patched
# forward returned a (hidden_states, router_logits) tuple.
# Handle both.
mlp_out = self.mlp(hidden_states)
if isinstance(mlp_out, tuple):
hidden_states = mlp_out[0]
router_logits = mlp_out[1] if len(mlp_out) > 1 else None
else:
hidden_states = mlp_out
router_logits = None
hidden_states = residual + hidden_states
outputs = (hidden_states,)
@ -188,7 +204,14 @@ class FastQwen3MoeModel(FastQwen3Model):
Qwen3MoeAttention.forward = Qwen3Attention_fast_forward
# Qwen3SdpaAttention .forward = Qwen3Attention_fast_forward
# Qwen3FlashAttention2 .forward = Qwen3Attention_fast_forward
Qwen3MoeSparseMoeBlock.forward = Qwen3MoeSparseMoeBlock_fast_forward
# Qwen3MoeSparseMoeBlock.forward is patched by unsloth_zoo's
# patch_qwen3_moe (temporary_patches) to a transformers-5.x-aware
# sparse_moe_block_forward that correctly handles
# self.gate / self.experts. The legacy
# Qwen3MoeSparseMoeBlock_fast_forward below assumed a flat
# self.gate_proj attribute which no longer exists on stacked
# transformers 5.x experts. Skip the legacy override.
# Qwen3MoeSparseMoeBlock.forward = Qwen3MoeSparseMoeBlock_fast_forward
Qwen3MoeMLP.forward = (
fast_swiglu_inference # This is analogous to Dense models' MLP
)

View file

@ -1752,9 +1752,22 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
'GuidedDecodingParams(backend="outlines", regex=args.vllm_guided_decoding_regex) '
'if getattr(args, "vllm_guided_decoding_regex", None) is not None else None,',
)
# Replace with our vLLM engine
# Replace with our vLLM engine.
#
# ``_build_flex_from_args`` sizes the FlexEngine's fixed-shape
# page tables / CUDA-graph buckets from the GRPO rollout batch
# (per_device_train_batch_size * steps_per_generation *
# num_generations) BEFORE the first ``model.vllm_engine``
# access triggers the lazy build. No-op when the model wasn't
# loaded through the flex-inference path (plain vLLM / plain
# HF), so this injection is safe for every backend. The
# import is inlined on the same line so ``create_new_function``
# doesn't need a cross-module import entry.
sampling_params = (
" " * 12
+ "from unsloth.inference.flex_engine import "
+ "_build_flex_from_args as __unsloth_build_flex_from_args; "
+ "__unsloth_build_flex_from_args(model, args); "
+ "self.llm = model.vllm_engine; self._last_loaded_step = 0; "
+ sampling_params
) # Add spaces
@ -1790,9 +1803,18 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
)
if trl_version >= Version("0.18.0"):
# Replace LLM init with already existing vLLM engine for colocate mode
# Replace LLM init with already existing vLLM engine for colocate mode.
# Prepend the FlexEngine build call so the engine is sized from the
# GRPO rollout batch before ``model.vllm_engine`` is dereferenced.
# Inline the import so ``create_new_function`` doesn't need a
# cross-module entry; the helper is a no-op on non-flex models.
vllm_llm_init_pattern = r"self\.llm\s*=\s*LLM\(.*?\)*\)\s*?\n(?!,)"
vllm_llm_replacement = "self.llm = model.vllm_engine\n"
vllm_llm_replacement = (
"from unsloth.inference.flex_engine import "
"_build_flex_from_args as __unsloth_build_flex_from_args; "
"__unsloth_build_flex_from_args(model, args); "
"self.llm = model.vllm_engine\n"
)
new_vllm_part = re.sub(
vllm_llm_init_pattern,
vllm_llm_replacement,

View file

@ -245,6 +245,9 @@ VLLM_SUPPORTED_VLM = [
"mistral3",
"qwen3_vl",
"qwen3_vl_moe",
"gemma4",
"qwen3_5",
"qwen3_5_moe",
]
VLLM_NON_LORA_VLM = [
"mllama",
@ -606,7 +609,16 @@ class FastBaseModel:
vllm_enable_lora = True
if is_vlm and fast_inference:
if not any(arch in VLLM_SUPPORTED_VLM for arch in model_types):
# The UNSLOTH_FAST_INFERENCE=1 flex backend ships with text-only
# support for Gemma-4-E2B-it today. Allow gemma4 through the
# vision-model path when the flex backend is selected; the
# vLLM-only compat list below still gates the default path.
_use_flex = os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1"
_flex_allowed = {"gemma4", "qwen3_5", "qwen3_5_moe"}
if not (
any(arch in VLLM_SUPPORTED_VLM for arch in model_types)
or (_use_flex and any(arch in _flex_allowed for arch in model_types))
):
raise RuntimeError(
f"Unsloth: Fast inference is only supported for Language models and Qwen2.5-VL, Gemma3 among vision models. "
f"Found architectures: {', '.join(model_types)}!"
@ -912,9 +924,15 @@ class FastBaseModel:
verify_fp8_support_if_applicable(model_config)
raise_handler = RaiseUninitialized()
if not fast_inference:
_use_flex_fast_inference = (
fast_inference and os.environ.get("UNSLOTH_FAST_INFERENCE", "0") == "1"
)
if (not fast_inference) or _use_flex_fast_inference:
# Shared by the standard HF load and the flex-inference load.
# Prevent load_in_fp8 from being forwarded into HF internal model loading
load_in_fp8 = kwargs.pop("load_in_fp8", None)
# ``max_batch_size`` is a FlexEngine kwarg, not an HF one.
_flex_max_batch_size = kwargs.pop("max_batch_size", 32)
# Transformers 5.x @strict config classes reject unexpected kwargs.
# Move config-level attributes onto the config object directly.
_num_labels = kwargs.pop("num_labels", None)
@ -944,6 +962,16 @@ class FastBaseModel:
fast_inference = fast_inference,
)
if hasattr(model, "generate"):
if _use_flex_fast_inference:
# Defer engine construction until tokenizer/processor is
# loaded; see the wiring block further down.
model._unsloth_needs_flex_engine = dict(
dtype = dtype,
max_seq_length = max_seq_length,
max_lora_rank = max_lora_rank,
max_batch_size = _flex_max_batch_size,
gpu_memory_utilization = gpu_memory_utilization,
)
model.fast_generate = make_fast_generate_wrapper(model.generate)
model.fast_generate_batches = error_out_no_vllm
if offload_embedding:
@ -1028,6 +1056,22 @@ class FastBaseModel:
is_vision_model = is_vlm,
fp8_mode = fp8_mode,
)
# why: vLLM's -O3 compile backend (compilation_config=3) hits
# _decompose_size_nodes "Tried to erase Node size_N but it still
# had N users" on Gemma 4 multimodal models (both dense E2B and
# MoE 26B-A4B). Until the upstream FX splitter is fixed, fall
# back to compilation_config=0 (no piecewise compile) for gemma4
# so fast_inference still loads. Runtime is slower than -O3 but
# correctness is preserved.
#
# The same FX splitter bug reproduces on Qwen3.5 / Qwen3.6
# (qwen3_5 dense, qwen3_5_moe). The hybrid GatedDeltaNet +
# full-attention layers defeat piecewise compile's node erasure
# heuristics. Force eager for these archs too.
_eager_arches = {"gemma4", "qwen3_5", "qwen3_5_moe"}
if any(arch in _eager_arches for arch in (model_types or [])):
load_vllm_kwargs.setdefault("compilation_config", 0)
load_vllm_kwargs.setdefault("enforce_eager", True)
for allowed_arg in allowed_args:
if allowed_arg not in load_vllm_kwargs and allowed_arg in kwargs:
load_vllm_kwargs[allowed_arg] = kwargs[allowed_arg]
@ -1223,6 +1267,18 @@ class FastBaseModel:
raise _patch_err
model = post_patch_loss_function(model)
# UNSLOTH_FAST_INFERENCE=1 path: install the lazy ``vllm_engine``
# sentinel now that the tokenizer/processor is available. The real
# ``FlexEngine(...)`` construction is deferred to
# :func:`build_flex_engine` so the batch-size dimension can be
# sized from the GRPO rollout shape. Keeps the training model's
# forward intact — the engine carries its own deep-copy.
if hasattr(model, "_unsloth_needs_flex_engine"):
from unsloth.inference.flex_engine import install_flex_sentinel
_tok_for_flex = getattr(tokenizer, "tokenizer", tokenizer)
install_flex_sentinel(model, _tok_for_flex)
# Log Unsloth version for future fastpaths for inference
if hasattr(model, "config"):
model.config.update({"unsloth_version": __version__})
@ -1512,6 +1568,10 @@ class FastBaseModel:
patch_saving_functions(model, vision = True)
patch_peft_fast_inference(model)
_engine = getattr(model, "vllm_engine", None)
if _engine is not None and hasattr(_engine, "bind_peft_model"):
_engine.bind_peft_model(model)
# Add for_inference and for_training
model.for_training = functools.partial(FastBaseModel.for_training, model)
model.for_inference = functools.partial(FastBaseModel.for_inference, model)