Commit graph

26 commits

Author SHA1 Message Date
Daniel Han
06a1007c6c flex: double-copy LoRA rollout to avoid bf16 merge/unmerge drift
PEFT's merge/unmerge pair is asymmetric at bf16 and leaks ~1 ULP per
cycle onto base_layer.weight. Across hundreds of GRPO refreshes the
base drifts, so the adapter trains against a moving target.

Keep a pristine base_model on GPU and a deep-copied inference_model
wrapped by PEFT. Before each rollout, restore the inference copy's
LoRA-target base_layer weights in-place from pristine and call
merge_adapter fresh. Never call unmerge_adapter.

Adds --verify_no_drift which hashes base params before/after N
perturb+refresh cycles and asserts bit-identical, and checks that the
merged inference state is deterministic after restoring the LoRA.

Update flex_vs_vllm.md with the double-copy row and memory cost.
2026-04-21 04:52:49 +00:00
Daniel Han
61c2e5c105 flex: switch to merge_adapter (reversible) + reframe writeup
Prior default was `peft_model.merge_and_unload()` which bakes LoRA into
the base and destroys the adapter. Same inference speed, but the adapter
is unrecoverable so you can't train on it for the next rollout -- which
GRPO explicitly needs.

Switch default to `peft_model.merge_adapter()`, which:
- Folds LoRA into `base_layer.weight` non-destructively.
- Keeps `lora_A` / `lora_B` parameters intact.
- Flips a `merged` flag inside each `LoraLayer` so its forward
  short-circuits to just `base_layer(x)`, giving identical inference speed
  to the destructive merge.
- Is fully reversible via `unmerge_adapter()` (bf16 round-trip error ~6e-5).

Measured end-to-end at batch 64 + LoRA rank 32:
  - merge_adapter: 5785 tok/s best (was 5744 with merge_and_unload)
  - merge+unmerge cycle: ~48 ms total for the 36-layer 7-target adapter,
    which is <1 % of a ~5-7 s rollout -- fully amortizable per iteration.

This is *the* rollout path GRPO should use. vLLM's LoRARequest achieves
the same outcome via double-copy (pristine base + materialized base+LoRA
copy) or Punica-style fused kernels, but from a throughput standpoint
both get you to "near-merged speed with adapter separable for training".

Reframes the writeup: removes the previous panic correction that claimed
flex was 35 % of vLLM. The 35 % row is what you'd get with a naive PEFT
wrapper (3 matmuls per projection) -- a path nobody should actually
use. The real headline is still flex reaches 74 % of vLLM at 3.5 x less
memory under proper LoRA semantics.

`--no_merge_lora` flag preserved for the unmerged-PEFT path; documented as
reference only. 4-bit still uses the unmerged path (bnb merging is
unsupported).
2026-04-21 03:05:28 +00:00
Daniel Han
ab37acd5e0 flex: fair comparison -- benchmark LoRA active, not merged
Previous bf16 + LoRA rank 32 runs called `peft_model.merge_and_unload()`,
which bakes LoRA into the base weights and destroys the adapter. Every
subsequent forward is then plain bf16 with no LoRA-active cost -- one
matmul per projection. vLLM's LoRARequest path keeps LoRA dynamic (base
matmul + rank-r adapter matmuls + add), which is what GRPO actually needs
because the adapter has to be updateable between rollouts and training
steps.

Adds `--no_merge_lora` flag and runs the honest comparison:

| Backend                        | tok/s best | vs vLLM |
|--------------------------------|-----------:|--------:|
| vLLM (LoRARequest)             |       7775 |   100 % |
| flex -- LoRA merged (prior)    |       5744 |    74 % |
| flex -- LoRA active (no merge) |       2683 |    35 % |

The 74 % number in the earlier writeup was only meaningful if you can eat
the merge/unmerge cost between rollouts and training steps (which is not
free). The real flex-vs-vLLM gap under GRPO semantics is ~35 %, not 72 %.

vLLM wins its dynamic-LoRA number via Punica-style fused kernels that
avoid the extra matmul roundtrip. flex has no equivalent and runs
base + LoRA_A + LoRA_B as three separate matmuls per projection.

Writeup updated.
2026-04-21 02:22:48 +00:00
Daniel Han
4717bce97e flex: support --load_in_4bit with PEFT adapter (bnb-4bit shard)
Adds --load_in_4bit (+ --model_name_4bit override) to both the flex
benchmark script and the vllm/tpaged benchmark script. When set, loads the
pre-quantized Unsloth bnb-4bit shard (e.g.
unsloth/Qwen3-4B-Base-unsloth-bnb-4bit) and keeps the LoRA adapter as a
PEFT wrapper instead of merging, because merging into 4-bit weights is not
supported.

Ties lm_head.weight to model.embed_tokens.weight post-load in both scripts,
because the bnb-4bit shards ship without an lm_head parameter even though
tie_word_embeddings is True in the config, so transformers leaves it
randomly initialised otherwise (garbage generations).

Results at batch 64 + LoRA rank 32:

| Backend                       | tok/s | peak mem | output    |
|-------------------------------|------:|---------:|-----------|
| Unsloth fast_inference (vLLM) |  4515 | 159 GB   | coherent  |
| flex (this PR)                |  1738 |  40.6 GB | coherent  |
| transformers CB (sdpa)        |   504 | 124 GB   | gibberish |

4-bit costs ~40 % throughput on the vLLM path vs bf16 and ~70 % on flex.
flex regresses worse because PEFT-without-merge doubles the matmuls per
projection (base + LoRA add) on top of bnb dequant, whereas bf16 flex
merges LoRA into the base. Peak memory barely moves for vLLM because KV
cache at gpu_memory_utilization=0.8 dominates regardless of base size.

transformers CB (generate_batch) at 4-bit + LoRA produces garbage even
with lm_head tied. Likely PEFT-over-bnb + batched CB interaction; not
debugged further -- it was always the 10 % reference path.

Writeup updated in scripts/benchmarks/results/flex_vs_vllm.md with a new
"Same workload at load_in_4bit=True" section.
2026-04-21 02:13:06 +00:00
pre-commit-ci[bot]
7441e6d72a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-21 00:25:27 +00:00
Daniel Han
cc033fee19 flex: test FA4 prefill + Inductor autotune replay (both regress)
Wired up two suggestions from the FlashAttention-4 blog + attention-gym:

1. `--fa4_prefill` flag: `BLOCK_SIZE=(256, 128)` + `BACKEND="FLASH"` on the
   prefill create_block_mask, pad to 256-row Q tile. Confirmed FA4 kernel
   fires on Blackwell (torch 2.11 + flash-attn CuTeDSL). Output is coherent
   but 4617 tok/s vs 5744 baseline at batch 64 + LoRA.

   Root cause: our prefill mask is document_causal, which evaluates
   `docs[q_idx] == docs[kv_idx]`. The FA4 CuTe kernel's known limitation
   (documented in attention-gym/examples/flex_flash_attention.py) is that
   "Indexing by kv_idx is a large perf hit". The doc mask hits that
   slow path directly. To benefit from FA4 on prefill we would need to
   refactor the mask so the per-kv lookup goes away, which is non-trivial
   given the document-boundary + causal combo.

2. flex_autotune_replay.py: new script that drives the pattern from
   attention-gym/examples/flex_autotune_replay.py -- sets
   `TORCHINDUCTOR_FLEX_ATTENTION_LOGGING_FILE` + runs with
   `mode="max-autotune-no-cudagraphs"`, parses the JSON log (handling
   symbolic dims like `s40`), picks the decode-shape entry (Q_LEN=1),
   and writes best fwd_* kernel options as JSON.

   Inductor's best for the decode shape: `fwd_num_warps=4, fwd_num_stages=3,
   fwd_BLOCK_M=64, fwd_BLOCK_N=64, fwd_USE_TMA=False`. Applied end-to-end:
   4827 tok/s vs 5744 manual baseline. The per-call time-minimum Inductor
   uses doesn't track the cumulative register-spill / L1 effects across
   the 36-layer stack.

Kept `--fa4_prefill` and flex_autotune_replay.py in-tree -- they are
useful scaffolding for anyone who wants to push further (refactor the mask,
run the 144-config exhaustive fwd sweep from attention-gym/examples/flex_grid_sweep.py,
etc.). Default config is unchanged.

Also documented the run-to-run variance: over 10 rounds at batch 64 + LoRA,
median 4192 and best 5660 tok/s; the spread is GPU clock throttling +
variable prompt-length distributions. The 5744 "baseline" we report is
best-of-N, matching the prior harness, but steady-state median is closer
to 75 % of that.

Writeup update in scripts/benchmarks/results/flex_vs_vllm.md.
2026-04-21 00:25:11 +00:00
pre-commit-ci[bot]
019107ac9f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 23:30:18 +00:00
Daniel Han
69723ee31c FlexKernelOptions sweep: flex reaches 72% of vLLM at batch 64 + LoRA
Summary of sweep (all with CUDA graph capture):

| Batch | flex tps | vLLM tps | flex / vLLM | flex mem |
|------:|---------:|---------:|------------:|---------:|
|    8  |     680  |   1900   |    35.8 %   |  44 GB   |
|   16  |    1626  |   3698   |    44.0 %   |  44 GB   |
|   32  |    3134  |   6318   |    49.6 %   |  44 GB   |
|   64  |    5474  |  10459   |  **52.3 %** |  44 GB   |
|  128  |    5565  |  14996   |    37.1 %   |  81 GB   |
|  256  |    5812  |  21170   |    27.5 %   | 154 GB   |

Canonical GRPO (batch 64 + LoRA rank 32):
- vLLM: 7775 tok/s / 156 GB
- flex: **5616 tok/s / 44 GB** = **72% of vLLM at 3.5x less memory**

Up from 9 % (transformers CB) at the start of this work.

Best FlexKernelOptions after sweep:
  decode: PRESCALE_QK, USE_TMA, BLOCKS_ARE_CONTIGUOUS, num_warps=8, num_stages=3
  prefill: FORCE_USE_FLEX_ATTENTION, PRESCALE_QK, USE_TMA

Biggest single win: `num_warps=8` (+28% at batch 64). Inductor's default
picks 4 on small Triton blocks; 8 is better for our decode shapes.
`BLOCKS_ARE_CONTIGUOUS` adds +10% (safe in our setup because
PageTable.reserve allocates pages sequentially on a fresh batch).
TMA adds 2-3%.

Items documented that broke correctness or didn't help:
- ROWS_GUARANTEED_SAFE=true NaNs the softmax on padded batch slots that
  only attend to reserved page 0 (mask returns False for every kv_idx).
- BACKEND="TRITON_DECODE" from the docs raises
  NameError('TRITON_DECODE is not defined') inside Inductor.
- USE_TMA + torch.compile(call_model_with_flex_kwargs) -> misaligned
  address at runtime (compile breaks TMA alignment assumptions).
- torch.compile(flex_attention, mode="max-autotune") nests cudagraph_trees
  inside our raw CUDA graph -> "Cannot prepare for replay during
  capturing stage". max-autotune-no-cudagraphs works but same throughput
  as default mode.
- compile on call_model_with_flex_kwargs: same as eager walker (CUDA
  graph capture already fuses every op in the walker).
- num_warps=4 / 16 both slower than num_warps=8.

CLI surface added to qwen3_flex_inference.py:
  --decode_kernel_options JSON   (FlexKernelOptions for decode)
  --prefill_kernel_options JSON  (same for prefill)
  --compile_model_forward MODE   (optional torch.compile on the walker)
2026-04-20 23:30:05 +00:00
pre-commit-ci[bot]
7f237ca57e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 16:17:33 +00:00
Daniel Han
816c5fb78d Batch-size sweep: flex@256 vs vLLM@256 + max-autotune check
Added the final two entries to results/flex_vs_vllm.md:

| Batch | LoRA | vLLM tok/s | flex tok/s | flex/vLLM |
|-------|------|-----------:|-----------:|----------:|
| 256   | no   |      21170 |       7074 |       33 %|

flex scales sub-linearly past batch ~128 (7074 @ 256 vs 6501 @ 128 is only a
9 % jump for 2x batch), while vLLM keeps climbing (14996 -> 21170). That's
expected: vLLM's chunked prefill + per-step kernel packing is more
efficient at huge batches. For GRPO's realistic batch range (4-64
concurrent seqs) the flex path sits at 30-55 % of vLLM.

Also tried `FLEX_COMPILE_MODE=max-autotune-no-cudagraphs` on the
flex_attention compile (gated via env var). Same throughput as default
compile (2186 vs 2189 at batch 32). max-autotune with cudagraphs crashes
because it nests its own cudagraph_trees inside our CUDA graph capture
and hits `Cannot prepare for replay during capturing stage`.
2026-04-20 16:17:19 +00:00
Daniel Han
520f548809 Flex+CUDA-graph closes gap to vLLM across batch sizes
Expanded benchmark sweep with the flex_attention + paged-KV path:

| Batch | LoRA | vLLM tok/s | flex tok/s | flex / vLLM |
|-------|------|-----------:|-----------:|------------:|
| 32    | no   |       7224 |       2189 |       30 %  |
| 32    | yes  |       4581 |       2334 |       51 %  |
| 64    | yes  |       7775 |       4279 |       55 %  |
| 128   | no   |      14996 |       6501 |       43 %  |

Before this PR, transformers CB topped out at 9.2 % of vLLM on the
reference (batch 32 + LoRA) workload. The flex path reaches 51 % on the
same config and 55 % at batch 64.

Details in scripts/benchmarks/results/flex_vs_vllm.md plus raw stats for
each run. Output coherence verified by sampling the first three
completions; see `sample_completions` in the stats JSONs.

qwen3_flex_inference.py: added sample_completions + decode_tps_best to
the output JSON so the PR writeup can cite both median and steady-state
numbers without rerunning.

Memory: flex uses 44-81 GB depending on batch, vs vLLM's 156 GB at every
configuration. That's half to a fifth of vLLM's footprint.

Remaining gap is kernel-level (vLLM uses FlashInfer / TRTLLM kernels
tuned for sm_100, flex uses Inductor-generated Triton) plus chunked
prefill (flex still does separate prefill passes per new batch). Closing
those is out of scope for this PR.
2026-04-20 16:09:47 +00:00
pre-commit-ci[bot]
298042bf85 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 15:57:55 +00:00
Daniel Han
cab1bcf576 Breakthrough: flex_attention + paged KV + CUDA graphs = 35-48% of vLLM
Instead of fighting transformers CB's Python-heavy dispatch, build a minimal
paged-attention decode loop on top of `torch.nn.attention.flex_attention`,
ported from Chang (2024) flex-nano-vllm and adapted for Qwen3.

### Numbers (B200, Qwen3-4B-Base, bf16, 32 prompts x 512 new tokens, no LoRA)

| Backend                          | Decode tok/s | % of vLLM |
|----------------------------------|--------------|-----------|
| vLLM (fast_inference)            | 4581         | 100 %     |
| **qwen3_flex + CUDA graphs**     | **1618-2192**| **35-48%**|
| qwen3_flex eager                 | 372-532      | 8-12 %    |
| unsloth_fi_false                 | 641          | 14 %      |
| CB paged+FA4 persistent          | 422          | 9.2 %     |
| CB sdpa_paged persistent         | 434          | 9.5 %     |

The plan's 30% target is met. Round 1 in particular hits 48% of vLLM (2191
tok/s vs 4581 tok/s) because the first measured round's wall includes the
tail of per-shape flex_attention Inductor compile, while round 2 is pure
graph replay.

### Why this works

Three architectural choices from flex-nano-vllm:

1. **Paged KV cache lives in a single contiguous `[1, H, num_pages*page_size, D]`
   tensor**, with a `PageTable` mapping `(logical_batch, logical_block) ->
   physical_page`. `flex_paged_attention.py` is copied verbatim from
   flex-nano-vllm (BSD-licensed) -- it's model-agnostic.

2. **flex_attention's `BlockMask` handles logical->physical page routing
   via `mask_mod` and `score_mod`**. The kernel sees physical pages; the
   mask enforces that queries only attend to valid logical positions.
   Crucially, flex_attention is designed for `torch.compile` so the whole
   attention forward traces cleanly.

3. **One CUDA graph per batch-size bucket** (1, 2, 4, 8, 16, 32 ...) captured
   during warmup. Decode dispatches to the nearest-greater-or-equal bucket
   and pads with `batch_idx=0` (reserved as a no-op slot, page_idx=0 also
   reserved). Graph replay is the lever that closes the gap to vLLM.

### Files

- `scripts/benchmarks/flex_paged_attention.py`: `PagedKVCache` + `PageTable`
  (verbatim from flex-nano-vllm, BSD-3 — see THIRD_PARTY_LICENSES.md of the
  source repo).
- `scripts/benchmarks/qwen3_flex_inference.py`: adapts to Qwen3-4B.
  Monkey-patches `Qwen3Attention.forward` to call `flex_attention` against
  the paged cache; walks the `Qwen3Model` layer stack manually so we can
  pass `flex_block_mask / flex_input_pos / flex_batch_idx` through without
  modifying `Qwen3ForCausalLM.forward`. `FlexInference.generate` owns the
  prefill/decode loop with optional `capture_cudagraph` that pre-reserves
  one page per batch slot so in-kernel `k_cache[addr] = k_val` writes hit
  valid physical addresses during capture (without this we got a
  `cudaErrorIllegalAddress` on the first graphed step).

### CB sync driver side-note

`scripts/benchmarks/cb_sync_driver.py` rewritten to (a) support reuse across
multiple `drive_until_empty()` calls so the paged cache stays warm, (b)
accept `--compile_mode` that wraps `model.forward` with torch.compile. Eager
mode measured 382-400 tok/s (close to threaded CB baseline of 422), but
`reduce-overhead` hit the same graph-break storm we saw in Phase 4 and
timed out at the 10-minute cap. The flex_attention path sidesteps that
entirely.

### Next steps

- Try LoRA rank 32 through the flex_attention path (PR's canonical workload).
- Scale to max_batch_size=64 to see if throughput keeps climbing.
- Integrate into TRL GRPO's rollout path for a full end-to-end speedup.
2026-04-20 15:57:41 +00:00
Daniel Han
2dd8339946 Phase 2: 30-step equivalence + pairwise diffs vs vLLM
30-step results:

| Backend        | Train wall | Median step | Peak mem | % of vLLM |
|----------------|-----------|-------------|----------|-----------|
| vLLM           | 215.9 s   | 5.14 s      | 159 GB   | 100 %     |
| fi_false       | 1165.4 s  | 41.30 s     | 10.7 GB  | 12.4 %    |
| cb_paged       | 1564.5 s  | 39.82 s     | 61.9 GB  | 12.9 %    |

Pairwise diff vs vLLM (30 steps, compare_grpo_runs.py):

| Pair                            | max |loss| | max |kl|   | max |reward| |
|---------------------------------|------------|------------|---------------|
| vLLM vs unsloth_fi_false        | 0.39       | **0.015**  | 9.25 (noisy)  |
| vLLM vs cb_paged                | 0.83       | (missing)  | 6.25 (noisy)  |

KL trajectory match between vLLM and unsloth_fi_false is the load-bearing
equivalence signal: both stay in [0, 0.015] across all 30 steps, so the
policy drift guardrail behaves the same. Reward diffs of ~3-9 are expected
because the rollout backends produce different completions even at
temperature=0.1 (kernel-level non-determinism).

Two caveats documented in results/grpo_equivalence.md:
- cb_paged's StatisticsCallback doesn't capture TRL's kl log entry because
  TRL emits kl on a separate log call that doesn't include loss.
- cb_paged's grad_norm (~200-900) is unclipped pre-optimizer, while vLLM's
  goes through Unsloth's internal max_grad_norm=1.0. Not a correctness bug,
  just not apples-to-apples until cb_paged sets max_grad_norm in GRPOConfig.

Also updates the report with Phase 3 + Phase 4 status (CB sync driver eager
works; CUDA graph capture hangs on output_ids slice pending fix; torch.compile
on training step incompatible with both unsloth_fi_false and cb_paged).
2026-04-20 15:03:32 +00:00
pre-commit-ci[bot]
1a6c5886fb [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 14:37:42 +00:00
Daniel Han
7852b3aed4 Phase 4 + pairwise GRPO equivalence helper
Phase 4 (torch.compile on training step) -- negative result documented:

- unsloth_fi_false + compile_mode=default: crashes immediately with
  `PeftModel_fast_forward() got multiple values for argument 'input_ids'`.
  Unsloth's monkey-patched forward and Dynamo's argument rebinding don't
  compose.

- cb_paged + compile_mode=default: Dynamo emits 700+ graph breaks /
  recompiles during the first optimizer step and never makes progress
  (killed after 10 minutes at step 0/10). Root cause trail:
  `Tensor.requires_grad_()` inside `modeling_utils.make_inputs_require_grads`
  triggers GB0125 (no Dynamo support), which propagates up through TRL's
  `_compute_loss`. Fixing this would require restructuring GRPO's LoRA
  gradient enablement path -- out of scope for this PR.

- vllm is excluded from Phase 4 because vLLM owns its own compile pipeline.

Net: torch.compile on the training step is not a quick win for the non-vLLM
paths in this stack. Phase 3 (CUDA graph on the rollout decode step) remains
the right lever for closing the CB <-> vLLM gap, and Phase 1 already
demonstrates that Unsloth's fast_inference=False path narrows the gap to
~14% of vLLM at 1/7th the peak memory without any compile.

Helper script `scripts/benchmarks/compare_grpo_runs.py`:
- Wraps `torch_debugging_utils.compare_training_runs` (loss/grad-norm diff)
- Adds reward/KL/time pairwise diffs with `max_abs` and `mean_abs`
- Reads the StatisticsCallback-emitted JSON written by
  `qwen3_grpo_unified.py`

Sample output on Phase 2 vibe (10 steps):

    vllm vs unsloth_fi_false:
      max_loss_diff = 0.40, max_kl_diff = 0.009 (both tiny), reward_diff
      mean 1.85 (different rollouts expected across backends at temp=0.1)
    vllm vs cb_paged:
      max_loss_diff = 0.29, max_grad_norm_diff = 715 (cb_paged has no
      gradient clipping on the vanilla-HF path; vLLM path is clipped to
      1.0 by Unsloth internally -- apples-to-oranges without matching
      clipping, tracked for the 30-step run).
2026-04-20 14:36:16 +00:00
pre-commit-ci[bot]
9771cdb5ad [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 14:24:24 +00:00
Daniel Han
5f3e1c98df Phase 2 vibe (10-step): vllm vs unsloth_fi_false vs cb_paged + Phase 3 fixes
Phase 2 results (`scripts/benchmarks/results/grpo_equivalence.md`):
- vLLM: 74.4s train, 4.14s median step, 158 GB peak (100%)
- unsloth_fi_false: 355s train, 23.95s median, 10.7 GB peak (17%)
- cb_paged (via sdpa_paged load): 466s train, 36s median, 55.6 GB peak (11.5%)

Coherence gate passes on all three backends: losses finite, rewards in the
expected early-GRPO range, KL trajectories qualitatively matched between
vLLM and unsloth_fi_false in [0, 0.015]. Memory story is striking:
unsloth_fi_false uses 15x less memory than vLLM.

qwen3_grpo_unified.py fixes:
- Auto-adjust per_device_train_batch_size -> num_generations for vanilla-HF
  backends (Unsloth's loader does this automatically; TRL on the HF path
  doesn't and crashes on the divisibility check).
- cb_paged now loads with sdpa_paged (not paged_attention). The FA4
  paged_attention kernel requires cu_seq_lens_q on every forward, but the
  GRPO training forward feeds a dense batch without them. sdpa_paged
  gracefully falls back to plain SDPA in that case and still exercises the
  paged path during the CB rollout.

cb_sync_driver.py fixes:
- FIFOScheduler no longer accepts manual_eviction in its signature; dropped.
- drive_until_empty used to check has_pending_requests() before calling
  prepare_next_batch(), which returned False at startup because nothing had
  yet been pulled from the input_queue. Now the loop drains the input queue
  first and exits only when both queues + scheduler are empty.

Smoke test on GPU 1 (8 prompts, 64 tokens): eager path produces 512 correct
tokens; CUDA-graph path hangs during first-step capture (PagedAttentionCache
probably allocates on first use). Tracked for the next commit.
2026-04-20 14:23:51 +00:00
pre-commit-ci[bot]
0557f9151c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 14:01:33 +00:00
Daniel Han
5907d1525c Phase 1+3: LoRA rollout benchmarks + CB sync driver + Phase 4 scaffold
Phase 1 results (`scripts/benchmarks/results/lora_rollout_baselines.md`):
- vLLM+LoRA: 4581 decode tok/s, 156 GB peak (gold, 100%)
- unsloth_fi_false+LoRA: 641 tok/s, 15.8 GB peak (14%, 7x lower mem)
- CB paged+FA4 persistent+LoRA: 422 tok/s (9.2%)
- CB sdpa_paged persistent+LoRA: 434 tok/s (9.5%)

Headline finding: Unsloth's `fast_inference=False` path (custom HF inference
kernels with cached fp16 LoRA in `fast_linear_forward`) is 1.5x faster than
CB at 1/7th the peak memory. Phase 2 will include it as a first-class backend.

cb_vs_vllm_generation.py:
- New --lora_adapter flag. vLLM uses LoRARequest; tpaged uses
  PeftModel.from_pretrained (no merge_adapter so we measure LoRA-active
  inference); unsloth_fi_false copies the adapter weights into Unsloth's
  get_peft_model wrapper (with key normalization so PEFT's base_model.model.
  prefix and Unsloth's .default. wrapper both match).
- New unsloth_fi_false backend: batched generate across all 32 prompts in
  a single call after FastLanguageModel.for_inference(model).
- Exposed sampling knobs (temp/top_p/min_p/top_k); defaults are the
  equivalence params.

cb_sync_driver.py (Phase 3 scaffold):
- SyncCBDriver owns PagedAttentionCache + ContinuousBatchProcessor +
  FIFOScheduler on the main thread. Never calls manager.start() so there is
  no background thread.
- slice_inputs=False => fixed-shape buffer views each step => CUDA graph
  replay is safe.
- use_cuda_graph=True path: 2-step eager warmup, then capture one decode
  step, then replay. `_is_pure_decode()` keeps prefill out of the graphed
  path since those have varying shapes.
- Greedy sampling only (CUDA-graph-safe); stochastic sanity checks stay in
  the non-graphed path.
- Standalone benchmark harness at the bottom.

qwen3_grpo_unified.py (Phase 4 scaffold):
- Single entrypoint for vllm / unsloth_fi_false / cb_paged / cb_sdpa /
  naive_trl backends sharing dataset, reward funcs, sampling, and the
  torch_debugging_utils StatisticsCallback.
- New --compile_mode {default,reduce-overhead,max-autotune-no-cudagraphs}
  that compiles `trainer.model.forward` and `trainer.ref_model.forward`
  after the trainer is built. CompileDebugger tracks graph breaks and
  recompiles. Skipped for vLLM since vLLM owns its own compile pipeline.
- Post-warmup median (skip first 3 steps) is computed and saved alongside
  the full per-step logs.

make_lora_adapter.py: writes a canonical PEFT adapter to
outputs/lora_rank32_fresh. Re-initializes lora_B with a tiny gaussian so
the adapter isn't a no-op (PEFT's default zero-init would let LoRA kernels
short-circuit).

qwen3_grpo_notebook.py (Phase 0): notebook-to-script port with
StatisticsCallback and equivalence sampling. 10-step reference reported in
scripts/benchmarks/results/notebook_ref_10.md (median step 5.80s,
peak 158.9 GB).
2026-04-20 14:01:16 +00:00
pre-commit-ci[bot]
c31533fc02 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 13:54:23 +00:00
Daniel Han
d118195c8b Add Phase 0+1 GRPO backend comparison scaffolding
Phase 0 (canonical reference):
- scripts/benchmarks/qwen3_grpo_notebook.py: notebook-to-script port of
  Qwen3_(4B)-GRPO.ipynb with StatisticsCallback from torch_debugging_utils
  and equivalence-friendly sampling (temp=0.1, top_p=0.97, min_p=0.5, top_k=5).
- scripts/benchmarks/results/notebook_ref_10.md: 10-step reference run table
  (median step post-warmup = 5.80s, peak 158.9 GB).
- scripts/benchmarks/results/stats/notebook_ref_10.json: full per-step logs
  for downstream compare_training_runs checks.

Phase 1 (rollout-only LoRA comparison scaffold):
- scripts/benchmarks/make_lora_adapter.py: one-shot that materializes a
  rank-32 LoRA at outputs/lora_rank32_fresh. Re-initializes lora_B with a
  tiny gaussian so the adapter isn't a no-op (otherwise LoRA kernels can
  short-circuit and we'd be measuring the base model).
- scripts/benchmarks/cb_vs_vllm_generation.py: extended with --lora_adapter
  for vLLM (LoRARequest) and tpaged (peft.PeftModel.from_pretrained,
  no merge_adapter), plus a new unsloth_fi_false backend that exercises the
  custom HF inference path (cached fp16 LoRA via fast_linear_forward).
  Sampling knobs are exposed and default to equivalence params.

Phase 2 scaffold:
- scripts/benchmarks/qwen3_grpo_unified.py: single entry point for all 5
  backends (vllm, unsloth_fi_false, cb_paged, cb_sdpa, naive_trl) sharing
  dataset, reward funcs, sampling, and StatisticsCallback. Skips the first
  3 steps when reporting median step wall.

No unsloth internals touched.
2026-04-20 13:54:06 +00:00
pre-commit-ci[bot]
57a7aefd94 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-20 01:38:38 +00:00
Daniel Han-Chen
8dfa076ee4 Add FA4 + persistent CB benchmarks and a naive TRL baseline
New scripts under scripts/benchmarks/:

- flash_attn_fa4_shim.py: monkey-patches that let transformers CB dispatch to
  Flash Attention 4 on Blackwell (sm_100). CB's ContinuousBatchProcessor
  otherwise emits a 4D paged attention mask for flash_attention_2 (which then
  breaks _flash_attention_forward's _upad_input branch), and passes
  max_seqlen_q instead of max_length_q. The shim skips the mask for FA and
  accepts both names.

- persistent_cb.py: replaces model.generate_batch with a version that reuses
  one ContinuousBatchingManager across calls, avoiding the per-step
  PagedAttentionCache realloc. Wired up behind --persistent_cb on the tpaged
  and standalone scripts.

- qwen3_grpo_naive.py: vanilla HF model.generate + TRL GRPOTrainer, no vLLM
  and no CB. Mirrors the TRL docs example. Useful as a third column in the
  comparison and also as a "will this at least converge" sanity check.

Adds --attn_impl and --persistent_cb flags to the existing generation and
training scripts. No changes to Unsloth internals.

Updated README.md with the FA install recipe (flash-attn-4==4.0.0b9, plus
a small site-packages shim that re-exports FA4's cute.* symbols under the
FA2 flash_attn namespace so transformers' is_flash_attn_2_available() and
_lazy_imports("flash_attention_2") succeed on B200).

Benchmark numbers on a single B200, Qwen3-4B-Base LoRA rank 32, bf16:

Generation microbenchmark (32 prompts, 512 new tokens):
  vLLM                                  7224 decode tok/s   (100%)
  CB paged|sdpa                          527 decode tok/s   ( 7.3%)
  CB paged|flash_attention_2 (FA4)       709 decode tok/s   ( 9.8%)
  CB paged|flash_attention_2 persistent  529 decode tok/s   ( 7.3%)

GRPO training (max_steps=20, num_generations=2, per_device_batch=2):
  vLLM colocated            136.6 s     peak 157 GB
  naive TRL (HF generate)   910.0 s     peak  15 GB
  CB SDPA                  1521.5 s     peak  98 GB  (prior run)
  CB FA4                   1470.7 s     peak  82 GB
  CB FA4 + persistent      1562.1 s     peak  87 GB
  CB FA4 + ng=4 persistent 1597.0 s     peak  94 GB

FA4 is a real ~1.4x improvement over SDPA for CB decode throughput but the
50% of vLLM target is still not reached. The remaining gap is driven by
CUDA graph capture (which ContinuousBatchingManager still NotImplementedErrors
on) and vLLM's scheduler being more efficient for decode-heavy GRPO rollouts.

Naive TRL generate is the honest small-rig baseline: 6.7x slower than vLLM
at 10% of the VRAM footprint, and ~1.7x faster than CB here.
2026-04-20 01:38:12 +00:00
pre-commit-ci[bot]
16d80d7378 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-19 14:45:48 +00:00
Daniel Han-Chen
07939bb025 Add Qwen3-4B GRPO rollout engine benchmarks
Adds reproducible scripts under scripts/benchmarks/ that compare vLLM
colocated rollouts against the transformers continuous batching API
(model.generate_batch, paged attention) for GRPO training on Qwen3-4B.

Contents:
- unsloth_grpo_common.py: shared dataset, reward functions, and GRPO
  hyperparameters so the two backends differ only in the rollout engine.
- qwen3_grpo_vllm.py: baseline training entry using fast_inference=True
  and TRL use_vllm=True, vllm_mode=colocate.
- qwen3_grpo_tpaged.py: candidate using a vanilla HF Qwen3 + PEFT LoRA
  with TRL use_transformers_paged=True.
- cb_vs_vllm_generation.py: standalone generation microbenchmark.
- README.md: integration notes, reproduction steps, and observed numbers.

On a single B200 with Unsloth Qwen3-4B-Base at LoRA rank 32 bf16,
transformers continuous batching reaches 7-9 percent of vLLM throughput
on this workload. The README documents the integration sharp edges
(top_k=-1, PagedAttentionCache default upper bounds, Unsloth's
Qwen3Attention_fast_forward bypassing the functional attention
interface, TRL importing GuidedDecodingParams from a newer vLLM that no
longer exports it, and UnslothGRPOTrainer expecting for_training /
for_inference hooks on the model).

The scripts are intentionally self-contained so they are easy to rerun
after either upstream change that could close the throughput gap
(flash-attn availability, CUDA graphs in ContinuousBatchingManager,
persistent paged caches across generate_batch calls, or a
paged-compatible Unsloth attention forward).
2026-04-19 14:45:03 +00:00