Extends the flex_attention + paged KV + CUDA graphs engine from Qwen3 and
Llama-3.2 to Gemma-4-E2B-it via a new standalone file that imports the
shared helpers (PagedKVCache, PageTable, Sequence, LoRA double-copy,
drift verification, flex_attention_compiled, _apply_rotary) from
qwen3_flex_inference.py. The Qwen3 / Llama path is not modified.
Gemma-4 diverges from Qwen3 / Llama in ways that cannot be folded into a
single hasattr guard:
- KV-sharing layers. E2B has 35 layers; the upper 20 lack k_proj / v_proj
/ k_norm / v_norm and consume the full prefix K/V produced by a store
layer further up the stack. We allocate a sidecar dict of
[max_batch, n_kv, max_seq, head_dim] buffers at fixed device addresses,
populated by store layers during prefill and read by shared layers
through eager SDPA (their layout does not match the paged cache's
block-mask shape).
- Dual attention regimes. full_attention (head_dim=512, rope_theta=1e6)
and sliding_attention (head_dim=256, sliding_window=512) coexist. We
precompute both (cos, sin) pairs via
Gemma4TextRotaryEmbedding(x, position_ids, layer_type) and dispatch on
self.layer_type inside the patched attention forward.
- Per-layer input embeddings. The walker threads the
[B, S, num_layers, hidden_size_per_layer_input] table from
get_per_layer_inputs + project_per_layer_inputs through each layer's
per_layer_input_gate / act_fn / mul / per_layer_projection /
post_per_layer_input_norm path.
- Four norms per block with double residuals. Attn (input_layernorm,
post_attention_layernorm) and MLP (pre_feedforward_layernorm,
post_feedforward_layernorm), plus the per-layer-input residual and
layer_scalar multiply.
- Final logit softcap. tanh(logits / 30.0) * 30.0 on the lm_head output.
Transformers>=5.5.0 is required for the gemma4 module. A _require_gemma4
guard at main() exits with a clear install hint when the module is
missing, so the workspace's Qwen3 / Llama path stays on the existing
transformers install.
The text-only path loads Gemma4ForConditionalGeneration, drops the
vision and audio towers, and moves the language_model into a
Gemma4ForCausalLM shell so LoRA, state-dict hashing, and the double-copy
refresh treat it like any other HF decoder model.
CLI mirrors qwen3_flex_inference.py: --model_name (default
unsloth/gemma-4-E2B-it), --lora_adapter, --load_in_4bit,
--capture_cudagraph, --verify_no_drift, --chat_template {auto,grpo,
native}, --fa4_prefill / --no-fa4_prefill, plus decode / prefill
kernel_options for Triton block tuning.
Smoke-tested on B200 (sm_100) with bf16, batch 2, max_new_tokens 16,
no-fa4_prefill + BLOCK_M=32 / BLOCK_N=32 (Gemma-4 head_dim=256 exceeds
FA4's 128-limit on sm_100): 52 tok/s cold, coherent completions.
scripts/benchmarks/README.md gains a paragraph covering the
transformers>=5.5 dependency, the head_dim=256 constraint, and the
recommended kernel_options for B200.
252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# Qwen3-4B GRPO rollout engine benchmarks
|
|
|
|
This directory holds the reproducible scripts behind the experiment
|
|
documented in the accompanying PR: can Hugging Face transformers'
|
|
continuous-batching API (`model.generate_batch`, backed by
|
|
`PagedAttentionCache`) serve as a drop-in replacement for vLLM during GRPO
|
|
rollouts on the Qwen3-4B notebook?
|
|
|
|
The short answer on a single NVIDIA B200 with Unsloth Qwen3-4B-Base, LoRA
|
|
rank 32, bf16: transformers continuous batching is functionally correct and
|
|
integrates with TRL's `use_transformers_paged=True` path, but end-to-end
|
|
throughput lands at around 7 to 10 percent of vLLM colocated even after
|
|
wiring in Flash Attention 4 on Blackwell. Full numbers and per-step timings
|
|
are in the PR description.
|
|
|
|
## Files
|
|
|
|
| File | Purpose |
|
|
|---|---|
|
|
| `unsloth_grpo_common.py` | Shared dataset loading, reward functions, and GRPO hyperparameters |
|
|
| `qwen3_grpo_vllm.py` | vLLM baseline training entry (`fast_inference=True`, `use_vllm=True`, `vllm_mode="colocate"`) |
|
|
| `qwen3_grpo_naive.py` | Naive TRL path (vanilla HF `model.generate`, no vLLM, no CB) matching https://huggingface.co/docs/trl/grpo_trainer |
|
|
| `qwen3_grpo_tpaged.py` | Continuous-batching candidate (`fast_inference=False`, `use_transformers_paged=True`, vanilla HF + PEFT). Supports `--persistent_cb` |
|
|
| `cb_vs_vllm_generation.py` | Standalone generation microbenchmark across both engines; supports `--attn_impl`, `--persistent_cb` |
|
|
| `flash_attn_fa4_shim.py` | Installs two monkey-patches that let CB dispatch to FA4 when `--attn_impl flash_attention_2` is selected |
|
|
| `persistent_cb.py` | Replaces `model.generate_batch` with a version that reuses a single `ContinuousBatchingManager` |
|
|
|
|
## Flash Attention 4 on Blackwell (sm_100)
|
|
|
|
The CB code path has three attention implementations: `eager_paged`,
|
|
`sdpa_paged`, `flash_attention_2`. The last one requires the legacy
|
|
`flash_attn` Python package, which does not install cleanly on B200 today:
|
|
|
|
- `flash_attn==2.8.3+cu12torch2.8cxx11abiTRUE-cp313` from the Dao-AILab
|
|
releases hits `undefined symbol: _ZNK3c106SymInt6sym_neERKS0_` on torch
|
|
2.9.1 (ABI drift between torch 2.8 and 2.9).
|
|
- `flash_attn_3-3.0.0-cp39-abi3-manylinux_2_28_x86_64.whl` from the PyTorch
|
|
wheel index installs but was built for sm_80 and sm_90a only. B200 is
|
|
sm_100. The kernel call fails with "no kernel image is available for
|
|
execution on the device".
|
|
- `flash-attn-4==4.0.0b9` (pure Python CuTeDSL, Dao-AILab) works on B200.
|
|
It exposes `flash_attn.cute.flash_attn_varlen_func`.
|
|
|
|
The recipe this repo uses:
|
|
|
|
```bash
|
|
uv pip install --no-deps flash-attn-4==4.0.0b9
|
|
```
|
|
|
|
plus a tiny site-packages shim that re-exports FA4 symbols under the
|
|
FA2 `flash_attn` namespace so transformers' `is_flash_attn_2_available()` and
|
|
`_lazy_imports("flash_attention_2")` succeed. The shim lives out of tree in
|
|
`lib/python3.13/site-packages/flash_attn/__init__.py` +
|
|
`flash_attn/bert_padding.py` + a `flash_attn-2.8.3.dist-info/` directory
|
|
with enough metadata to satisfy `importlib.metadata.version("flash_attn")`.
|
|
|
|
On top of that, `flash_attn_fa4_shim.py` monkey-patches two rough edges in
|
|
the CB to FA integration that are unrelated to which FA version you use:
|
|
|
|
1. `ContinuousBatchProcessor.return_attention_mask` returns `False` for
|
|
`flash_attention_2` / `flash_attention_3` so CB does not emit a 4-D paged
|
|
attention mask that breaks `_flash_attention_forward`'s `_upad_input`
|
|
branch.
|
|
2. `_flash_attention_forward` accepts `max_seqlen_q` / `max_seqlen_k`
|
|
as aliases for `max_length_q` / `max_length_k`. Without this rename CB's
|
|
model kwargs never bind and FA is called with `max_seqlen_q=None`.
|
|
|
|
## Install
|
|
|
|
Prereqs: `torch >= 2.5` for `torch.nn.attention.flex_attention`. The
|
|
Triton backend that flex_attention uses by default runs on Ampere,
|
|
Hopper, and Blackwell -- no separate install.
|
|
|
|
FA4 (CuTeDSL) targets Hopper and Blackwell only. `qwen3_flex_inference.py`
|
|
auto-enables FA4 on supported GPUs and falls back to the Triton
|
|
`flex_attention` backend elsewhere. `--fa4_prefill` forces on (warns +
|
|
falls back if the GPU does not support it); `--no-fa4_prefill` forces off.
|
|
|
|
CUDA 13 (recommended, used on B200 / RTX 50xx):
|
|
|
|
pip install --index-url https://download.pytorch.org/whl/cu130 torch
|
|
pip install "flash-attn-4[cu13]"
|
|
|
|
CUDA 12 (H100 boxes still on cu12):
|
|
|
|
pip install torch # default index is cu12
|
|
pip install flash-attn-4
|
|
|
|
Pin `flash-attn-4==4.0.0b9` to match this benchmark. The `[cu13]`
|
|
extra pulls in `nvidia-cutlass-dsl` built for CUDA 13.
|
|
|
|
`qwen3_flex_inference.py` runs on both Qwen3 and Llama-3.2 (the only
|
|
arch-specific branch is Qwen3's per-head QK RMSNorm; the rest of the
|
|
flex_attention + paged KV + CUDA graphs stack is identical). Pass
|
|
`--model_name unsloth/Llama-3.2-3B-Instruct` to target Llama, along with
|
|
`--chat_template native` to use Llama's shipped Instruct template instead
|
|
of the Qwen3 GRPO template.
|
|
|
|
`gemma4_flex_inference.py` extends the engine to `unsloth/gemma-4-E2B-it`.
|
|
Gemma-4 is not a drop-in: its text backbone has KV-sharing layers
|
|
(layers 15-34 consume full-sequence K/V produced by a store layer), two
|
|
attention regimes (`full_attention` with `head_dim=512` / rope_theta=1e6
|
|
and `sliding_attention` with `head_dim=256` / sliding_window=512),
|
|
per-layer input embeddings, four norms per block with double residuals,
|
|
and a final logit softcap. The new file keeps the shared helpers
|
|
(`PagedKVCache`, `PageTable`, LoRA double-copy, drift verification)
|
|
imported from `qwen3_flex_inference.py` and adds:
|
|
|
|
- a KV-sharing sidecar dict, sized `[max_batch, n_kv, max_seq, head_dim]`
|
|
per store layer, populated at prefill and read by the paired shared
|
|
layers through eager SDPA (shared layers don't fit the paged-cache
|
|
block-mask shape);
|
|
- dual RoPE precomputation — `rotary_emb(x, pos, layer_type)` called once
|
|
per unique layer type, indexed by `self.layer_type`;
|
|
- a walker that threads `per_layer_inputs` from
|
|
`get_per_layer_inputs` + `project_per_layer_inputs` into each layer
|
|
and applies the `layer_scalar` multiply at layer end;
|
|
- `tanh(logits / final_logit_softcapping) * final_logit_softcapping`
|
|
applied on the lm_head output.
|
|
|
|
Requires `transformers>=5.5.0` for the `gemma4` module; if absent the
|
|
script exits with a clear install hint. Gemma-4 head_dim=256 exceeds FA4
|
|
on sm_100 (B200), so pass `--no-fa4_prefill` and small Triton blocks:
|
|
|
|
```bash
|
|
CUDA_VISIBLE_DEVICES=6 python scripts/benchmarks/gemma4_flex_inference.py \
|
|
--model_name unsloth/gemma-4-E2B-it \
|
|
--n_prompts 64 --max_new_tokens 512 --capture_cudagraph \
|
|
--no-fa4_prefill \
|
|
--prefill_kernel_options '{"FORCE_USE_FLEX_ATTENTION": true, "BLOCK_M": 32, "BLOCK_N": 32}' \
|
|
--decode_kernel_options '{"BLOCK_M": 16, "BLOCK_N": 16}' \
|
|
--stats_path logs/flex_gemma4_bf16.json
|
|
```
|
|
|
|
| GPU | arch | sm | Auto FA4 | Triton flex_attention |
|
|
|--------------|-----------|-------|----------|------------------------|
|
|
| A100 | Ampere | sm_80 | off (uses Triton) | Works |
|
|
| H100 / H200 | Hopper | sm_90 | on | Works |
|
|
| RTX 50xx | Blackwell | sm_120 | on | Works |
|
|
| B200 / GB200 | Blackwell | sm_100 | on | Works |
|
|
|
|
The transformers continuous-batching path's FA4 wiring (the
|
|
`flash_attn_fa4_shim.py` monkey-patches and the
|
|
`site-packages/flash_attn/__init__.py` namespace shim that makes FA4
|
|
visible under the FA2 import name) is covered below under "Known
|
|
integration notes".
|
|
|
|
## Reproduce
|
|
|
|
```bash
|
|
pip install unsloth "transformers>=4.57" "trl>=0.25" peft vllm
|
|
|
|
# Generation microbenchmark (32 prompts, 512 new tokens each)
|
|
CUDA_VISIBLE_DEVICES=2 python scripts/benchmarks/cb_vs_vllm_generation.py \
|
|
--backend vllm --stats_path logs/vllm_gen.json \
|
|
--n_prompts 32 --n_rounds 2 --max_new_tokens 512 \
|
|
--gpu_memory_utilization 0.6
|
|
|
|
# CB variants
|
|
CUDA_VISIBLE_DEVICES=6 python scripts/benchmarks/cb_vs_vllm_generation.py \
|
|
--backend tpaged --attn_impl sdpa \
|
|
--stats_path logs/cb_gen_sdpa.json \
|
|
--n_prompts 32 --n_rounds 2 --max_new_tokens 512
|
|
|
|
CUDA_VISIBLE_DEVICES=6 python scripts/benchmarks/cb_vs_vllm_generation.py \
|
|
--backend tpaged --attn_impl flash_attention_2 \
|
|
--stats_path logs/cb_gen_fa.json \
|
|
--n_prompts 32 --n_rounds 2 --max_new_tokens 512
|
|
|
|
# Full GRPO training (20 steps)
|
|
CUDA_VISIBLE_DEVICES=2 python scripts/benchmarks/qwen3_grpo_vllm.py \
|
|
--max_steps 20 --num_generations 2 --per_device_train_batch_size 2 \
|
|
--output_dir outputs/grpo_vllm --stats_path logs/vllm_stats.json \
|
|
--gpu_memory_utilization 0.6
|
|
|
|
CUDA_VISIBLE_DEVICES=7 python scripts/benchmarks/qwen3_grpo_naive.py \
|
|
--max_steps 20 --num_generations 2 --per_device_train_batch_size 2 \
|
|
--output_dir outputs/grpo_naive --stats_path logs/naive_stats.json
|
|
|
|
CUDA_VISIBLE_DEVICES=6 python scripts/benchmarks/qwen3_grpo_tpaged.py \
|
|
--max_steps 20 --num_generations 2 --per_device_train_batch_size 2 \
|
|
--attn_impl flash_attention_2 \
|
|
--output_dir outputs/grpo_tpaged_fa --stats_path logs/tpaged_stats_fa.json \
|
|
--max_batch_tokens 16384 --num_blocks 16384
|
|
```
|
|
|
|
`qwen3_grpo_naive.py` and `qwen3_grpo_tpaged.py` accept an optional
|
|
`--compile_mode {default,reduce-overhead,max-autotune-no-cudagraphs}` flag.
|
|
When set, `trainer.model.forward` (and `trainer.ref_model.forward`, if present)
|
|
are wrapped with `torch.compile` after trainer construction. The vLLM driver
|
|
has no such flag because vLLM owns its own inference graph. `--compile_dynamic`
|
|
(default on) toggles dynamic-shape compilation.
|
|
|
|
## Known integration notes for transformers continuous batching + TRL + Unsloth
|
|
|
|
These are the sharp edges you hit going down the continuous-batching path and
|
|
how `qwen3_grpo_tpaged.py` handles them:
|
|
|
|
1. **`top_k=-1` is not a valid value for transformers.** vLLM treats `-1` as
|
|
"disabled", but `TopKLogitsWarper` raises
|
|
`ValueError: top_k has to be a strictly positive integer`. The script
|
|
rewrites `top_k=None` on the shared GRPOConfig before handing it to
|
|
`GRPOConfig(use_transformers_paged=True, ...)`.
|
|
|
|
2. **`PagedAttentionCache` default upper bounds are extremely conservative.**
|
|
`_upper_bound_max_batch_tokens=256` and `_upper_bound_num_blocks=4096`
|
|
choke decode throughput. The script passes
|
|
`generation_kwargs={"max_batch_tokens": 16384, "num_blocks": 16384}` which
|
|
TRL forwards to `GenerationConfig`, and the CB manager reads them when
|
|
sizing the paged cache.
|
|
|
|
3. **Unsloth's `Qwen3Attention_fast_forward` bypasses the functional
|
|
attention interface.** Calling `model.generate_batch` on an
|
|
Unsloth-patched Qwen3 model fails inside
|
|
`unsloth.utils.attention_dispatch.run_attention` because Unsloth routes
|
|
through its own dispatcher rather than reading
|
|
`config._attn_implementation`. The benchmark script works around this by
|
|
loading a vanilla HF Qwen3 with PEFT LoRA for the tpaged and naive paths.
|
|
This costs the Unsloth training kernels but keeps the comparison clean.
|
|
A proper upstream fix is to detect `config._attn_implementation` being
|
|
`flash_attention_2` / `sdpa_paged` / `eager_paged` and delegate to the
|
|
stock transformers forward.
|
|
|
|
4. **TRL imports `GuidedDecodingParams` from `vllm.sampling_params`.**
|
|
Newer vLLM releases (>= 0.13) have moved or removed that symbol, so
|
|
`trl.trainer.grpo_trainer` fails to import on a fresh vLLM install even
|
|
if you are not using vLLM. `qwen3_grpo_tpaged.py` installs a minimal
|
|
shim before importing TRL.
|
|
|
|
5. **`UnslothGRPOTrainer` calls `model.for_training()` /
|
|
`for_inference()`.** Importing `unsloth` replaces
|
|
`trl.GRPOTrainer` with `UnslothGRPOTrainer`, which assumes the model has
|
|
these hooks. A vanilla HF model does not, so
|
|
`qwen3_grpo_tpaged.py` does not `import unsloth` at all.
|
|
|
|
## Why continuous batching is still slower than vLLM on this workload
|
|
|
|
- `ContinuousBatchingManager` does not yet implement CUDA graphs
|
|
(`use_cuda_graph=True` raises `NotImplementedError`). vLLM captures 100+
|
|
mixed prefill-decode and decode graphs during warmup.
|
|
- CB re-allocates a fresh `PagedAttentionCache` on every `generate_batch`
|
|
call. For GRPO that is once per step. `--persistent_cb` (via
|
|
`persistent_cb.py`) keeps the cache warm across steps.
|
|
- FA4 is a CuTeDSL package: first call per shape pays a one-time
|
|
JIT-compile.
|
|
- vLLM uses its own colocated attention + FlashInfer / TRTLLM kernels
|
|
tuned for decode, which currently outperform everything a generic CB
|
|
path can do.
|
|
|
|
These are all upstream transformers issues, not Unsloth issues. The scripts
|
|
in this directory are intentionally simple so they are easy to port into a
|
|
future upstream fix.
|