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).
428 lines
15 KiB
Python
428 lines
15 KiB
Python
"""Standalone generation microbenchmark: vLLM vs transformers CB vs Unsloth.
|
|
|
|
Backends (one per process; all engines are GPU-greedy):
|
|
- `vllm` : Unsloth `fast_inference=True` (vLLM colocated).
|
|
- `tpaged` : `model.generate_batch` on paged HF + `--attn_impl`.
|
|
- `unsloth_fi_false` : Unsloth `fast_inference=False` with the custom HF
|
|
inference kernels (cached fp16 LoRA).
|
|
|
|
LoRA: pass `--lora_adapter PATH` to activate a PEFT-style rank-32 adapter on
|
|
both vLLM (`LoRARequest`) and the HF paths (`peft.PeftModel.from_pretrained`,
|
|
or for `unsloth_fi_false` `FastLanguageModel.get_peft_model` pointed at the
|
|
same weights).
|
|
|
|
Equivalence-friendly sampling defaults (`--temperature 0.1 --top_p 0.97
|
|
--min_p 0.5 --top_k 5`) keep rollouts comparable across backends for the KL /
|
|
reward diff checks done in Phase 2.
|
|
|
|
Usage:
|
|
CUDA_VISIBLE_DEVICES=6 python scripts/benchmarks/cb_vs_vllm_generation.py \
|
|
--backend vllm --stats_path logs/lora_vllm_gen.json \
|
|
--lora_adapter outputs/lora_rank32_fresh
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
|
|
import torch # noqa: E402
|
|
|
|
# FA4 shim for the `tpaged` backend with paged_attention. No-op for vLLM /
|
|
# unsloth_fi_false.
|
|
import flash_attn_fa4_shim # noqa: E402
|
|
|
|
flash_attn_fa4_shim.apply()
|
|
|
|
|
|
def build_prompts(tokenizer, n_prompts):
|
|
from unsloth_grpo_common import (
|
|
apply_chat_template_to_tokenizer,
|
|
SYSTEM_PROMPT,
|
|
)
|
|
from datasets import load_dataset
|
|
|
|
apply_chat_template_to_tokenizer(tokenizer)
|
|
ds = load_dataset("open-r1/DAPO-Math-17k-Processed", "en", split="train")
|
|
ds = ds.shuffle(seed=3407).select(range(n_prompts))
|
|
messages = [
|
|
[
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": x["prompt"]},
|
|
]
|
|
for x in ds
|
|
]
|
|
prompts_text = [
|
|
tokenizer.apply_chat_template(m, add_generation_prompt=True, tokenize=False)
|
|
for m in messages
|
|
]
|
|
prompt_ids = [
|
|
tokenizer.apply_chat_template(m, add_generation_prompt=True, tokenize=True)
|
|
for m in messages
|
|
]
|
|
return prompts_text, prompt_ids
|
|
|
|
|
|
def run_vllm(args):
|
|
os.environ.setdefault("UNSLOTH_VLLM_STANDBY", "1")
|
|
from unsloth import FastLanguageModel
|
|
|
|
model, tokenizer = FastLanguageModel.from_pretrained(
|
|
model_name=args.model_name,
|
|
max_seq_length=args.max_seq_length,
|
|
load_in_4bit=False,
|
|
fast_inference=True,
|
|
max_lora_rank=32,
|
|
gpu_memory_utilization=args.gpu_memory_utilization,
|
|
)
|
|
prompts_text, prompt_ids = build_prompts(tokenizer, args.n_prompts)
|
|
|
|
lora_request = None
|
|
if args.lora_adapter:
|
|
from vllm.lora.request import LoRARequest
|
|
lora_request = LoRARequest("fresh", 1, str(Path(args.lora_adapter).resolve()))
|
|
|
|
from vllm import SamplingParams
|
|
sp = SamplingParams(
|
|
temperature=args.temperature,
|
|
top_p=args.top_p,
|
|
min_p=args.min_p,
|
|
top_k=args.top_k,
|
|
seed=3407,
|
|
max_tokens=args.max_new_tokens,
|
|
stop=[tokenizer.eos_token],
|
|
include_stop_str_in_output=True,
|
|
)
|
|
|
|
# Warmup on 16 prompts then discard.
|
|
warmup_text = prompts_text[:16]
|
|
_ = model.fast_generate(warmup_text, sampling_params=sp, lora_request=lora_request)
|
|
torch.cuda.synchronize()
|
|
|
|
n_prompt_tokens = sum(len(p) for p in prompt_ids)
|
|
wall_times = []
|
|
total_decoded = None
|
|
last_outputs = None
|
|
for _ in range(args.n_rounds):
|
|
torch.cuda.synchronize()
|
|
t0 = time.perf_counter()
|
|
outputs = model.fast_generate(
|
|
prompts_text, sampling_params=sp, lora_request=lora_request
|
|
)
|
|
torch.cuda.synchronize()
|
|
wall_times.append(time.perf_counter() - t0)
|
|
total_decoded = sum(len(o.outputs[0].token_ids) for o in outputs)
|
|
last_outputs = outputs
|
|
|
|
med = sorted(wall_times)[len(wall_times) // 2]
|
|
sample_texts = [o.outputs[0].text[:200] for o in (last_outputs[:3] or [])] if last_outputs else []
|
|
return {
|
|
"backend": "vllm",
|
|
"lora_adapter": args.lora_adapter,
|
|
"n_prompts": args.n_prompts,
|
|
"n_prompt_tokens": n_prompt_tokens,
|
|
"n_decoded_tokens": total_decoded,
|
|
"wall_times_s": wall_times,
|
|
"median_wall_s": med,
|
|
"prompt_tps": n_prompt_tokens / med,
|
|
"decode_tps": (total_decoded or 0) / med,
|
|
"max_new_tokens": args.max_new_tokens,
|
|
"sample_completions": sample_texts,
|
|
}
|
|
|
|
|
|
def run_tpaged(args):
|
|
"""Vanilla HF + paged cache.
|
|
|
|
Unsloth's Qwen3Attention monkey-patch does not compose with the
|
|
`paged|<impl>` functional attention interface, so we use plain HF.
|
|
"""
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
|
|
|
|
tokenizer = AutoTokenizer.from_pretrained(args.model_name)
|
|
if tokenizer.pad_token is None:
|
|
tokenizer.pad_token = tokenizer.eos_token
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
args.model_name,
|
|
dtype=torch.bfloat16,
|
|
attn_implementation=args.attn_impl,
|
|
).to("cuda")
|
|
model.eval()
|
|
|
|
if args.lora_adapter:
|
|
from peft import PeftModel
|
|
# NOTE: no merge_adapter -- we measure LoRA-active inference.
|
|
model = PeftModel.from_pretrained(
|
|
model, str(Path(args.lora_adapter).resolve()), is_trainable=False
|
|
)
|
|
model.eval()
|
|
|
|
if args.persistent_cb:
|
|
from persistent_cb import install_for_model # noqa: WPS433
|
|
prompts_text, prompt_ids = build_prompts(tokenizer, args.n_prompts)
|
|
|
|
gen_config = GenerationConfig(
|
|
max_new_tokens=args.max_new_tokens,
|
|
do_sample=True,
|
|
temperature=args.temperature,
|
|
top_p=args.top_p,
|
|
min_p=args.min_p,
|
|
top_k=args.top_k,
|
|
pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
|
|
bos_token_id=tokenizer.bos_token_id,
|
|
eos_token_id=tokenizer.eos_token_id,
|
|
use_cache=True,
|
|
)
|
|
gen_config.max_batch_tokens = args.max_batch_tokens
|
|
gen_config.num_blocks = args.num_blocks
|
|
|
|
if args.persistent_cb:
|
|
install_for_model(model, gen_config)
|
|
|
|
warmup_ids = prompt_ids[:16]
|
|
with torch.inference_mode():
|
|
_ = model.generate_batch(warmup_ids, generation_config=gen_config, progress_bar=False)
|
|
torch.cuda.synchronize()
|
|
|
|
n_prompt_tokens = sum(len(p) for p in prompt_ids)
|
|
wall_times = []
|
|
total_decoded = None
|
|
last_outputs = None
|
|
for _ in range(args.n_rounds):
|
|
torch.cuda.synchronize()
|
|
t0 = time.perf_counter()
|
|
with torch.inference_mode():
|
|
outputs = model.generate_batch(
|
|
prompt_ids, generation_config=gen_config, progress_bar=False
|
|
)
|
|
torch.cuda.synchronize()
|
|
wall_times.append(time.perf_counter() - t0)
|
|
total_decoded = sum(len(v.generated_tokens) for v in outputs.values())
|
|
last_outputs = outputs
|
|
|
|
# Sample completions for coherence sanity check.
|
|
sample_texts = []
|
|
if last_outputs is not None:
|
|
for k in list(last_outputs.keys())[:3]:
|
|
toks = last_outputs[k].generated_tokens
|
|
sample_texts.append(tokenizer.decode(toks, skip_special_tokens=False)[:200])
|
|
|
|
med = sorted(wall_times)[len(wall_times) // 2]
|
|
return {
|
|
"backend": "tpaged",
|
|
"lora_adapter": args.lora_adapter,
|
|
"attn_impl": args.attn_impl,
|
|
"persistent_cb": args.persistent_cb,
|
|
"n_prompts": args.n_prompts,
|
|
"n_prompt_tokens": n_prompt_tokens,
|
|
"n_decoded_tokens": total_decoded,
|
|
"wall_times_s": wall_times,
|
|
"median_wall_s": med,
|
|
"prompt_tps": n_prompt_tokens / med,
|
|
"decode_tps": (total_decoded or 0) / med,
|
|
"max_new_tokens": args.max_new_tokens,
|
|
"sample_completions": sample_texts,
|
|
}
|
|
|
|
|
|
def run_unsloth_fi_false(args):
|
|
"""Unsloth `fast_inference=False` path with custom HF inference kernels.
|
|
|
|
This is the path that backs regular Unsloth training's sampling loop
|
|
(Triton RMSNorm/RoPE, cached fp16 LoRA copies in `fast_linear_forward`).
|
|
Previously only exercised through full GRPO runs -- isolating it lets us
|
|
compare it head-to-head against vLLM on the same workload.
|
|
|
|
LoRA is attached via `FastLanguageModel.get_peft_model`; if a PEFT adapter
|
|
path is provided we re-load its weights into the Unsloth-wrapped model so
|
|
every backend uses the *same* weights.
|
|
"""
|
|
os.environ.setdefault("UNSLOTH_VLLM_STANDBY", "1")
|
|
from unsloth import FastLanguageModel
|
|
|
|
model, tokenizer = FastLanguageModel.from_pretrained(
|
|
model_name=args.model_name,
|
|
max_seq_length=args.max_seq_length,
|
|
load_in_4bit=False,
|
|
fast_inference=False,
|
|
max_lora_rank=32,
|
|
)
|
|
# Attach LoRA rank 32 the same way the GRPO notebook does.
|
|
model = FastLanguageModel.get_peft_model(
|
|
model,
|
|
r=32,
|
|
target_modules=[
|
|
"q_proj", "k_proj", "v_proj", "o_proj",
|
|
"gate_proj", "up_proj", "down_proj",
|
|
],
|
|
lora_alpha=64,
|
|
use_gradient_checkpointing="unsloth",
|
|
random_state=3407,
|
|
)
|
|
|
|
# Optional: overlay a shared adapter so weights match other backends.
|
|
if args.lora_adapter:
|
|
from safetensors import safe_open
|
|
adapter_file = Path(args.lora_adapter).resolve() / "adapter_model.safetensors"
|
|
loaded_tensors = {}
|
|
with safe_open(str(adapter_file), framework="pt") as f:
|
|
for key in f.keys():
|
|
loaded_tensors[key] = f.get_tensor(key)
|
|
# Both PEFT and Unsloth's `get_peft_model` produce parameter names with
|
|
# `base_model.model.` prefix plus `.lora_{A,B}.default.weight`. Build a
|
|
# normalized (core-path) -> param map, then match by core path only.
|
|
def _core(name: str) -> str:
|
|
n = name
|
|
for pref in ("base_model.model.", "model."):
|
|
if n.startswith(pref):
|
|
n = n[len(pref):]
|
|
n = n.replace(".lora_A.default.", ".lora_A.").replace(
|
|
".lora_B.default.", ".lora_B.")
|
|
return n
|
|
own_by_core = {}
|
|
for n, p in model.named_parameters():
|
|
if "lora_" in n:
|
|
own_by_core.setdefault(_core(n), []).append(p)
|
|
matched = 0
|
|
with torch.no_grad():
|
|
for name, tensor in loaded_tensors.items():
|
|
core = _core(name)
|
|
for own in own_by_core.get(core, []):
|
|
if own.shape == tensor.shape:
|
|
own.data.copy_(tensor.to(own.device, own.dtype))
|
|
matched += 1
|
|
break
|
|
print(f"[unsloth_fi_false] LoRA weight sync matched {matched} tensors "
|
|
f"(out of {len(loaded_tensors)} adapter entries).")
|
|
|
|
FastLanguageModel.for_inference(model)
|
|
|
|
prompts_text, prompt_ids = build_prompts(tokenizer, args.n_prompts)
|
|
|
|
# `model.generate` accepts batched input_ids; pad to max length.
|
|
from transformers import GenerationConfig
|
|
if tokenizer.padding_side != "left":
|
|
tokenizer.padding_side = "left" # decoder needs left padding
|
|
if tokenizer.pad_token_id is None:
|
|
tokenizer.pad_token = tokenizer.eos_token
|
|
|
|
gen_config = GenerationConfig(
|
|
max_new_tokens=args.max_new_tokens,
|
|
do_sample=True,
|
|
temperature=args.temperature,
|
|
top_p=args.top_p,
|
|
min_p=args.min_p,
|
|
top_k=args.top_k,
|
|
pad_token_id=tokenizer.pad_token_id,
|
|
bos_token_id=tokenizer.bos_token_id,
|
|
eos_token_id=tokenizer.eos_token_id,
|
|
use_cache=True,
|
|
)
|
|
|
|
def _batched_generate(texts):
|
|
batch = tokenizer(texts, return_tensors="pt", padding=True).to("cuda")
|
|
with torch.inference_mode():
|
|
out = model.generate(**batch, generation_config=gen_config)
|
|
prompt_len = batch["input_ids"].shape[1]
|
|
return out, prompt_len
|
|
|
|
# Warmup on 16 prompts.
|
|
_ = _batched_generate(prompts_text[:16])
|
|
torch.cuda.synchronize()
|
|
|
|
n_prompt_tokens = sum(len(p) for p in prompt_ids)
|
|
wall_times = []
|
|
total_decoded = None
|
|
last_out_ids = None
|
|
last_prompt_len = None
|
|
for _ in range(args.n_rounds):
|
|
torch.cuda.synchronize()
|
|
t0 = time.perf_counter()
|
|
out_ids, prompt_len = _batched_generate(prompts_text)
|
|
torch.cuda.synchronize()
|
|
wall_times.append(time.perf_counter() - t0)
|
|
# Count generated tokens past prompt_len per sequence (subtract any
|
|
# trailing pad-only tail by comparing against EOS).
|
|
total_decoded = int((out_ids[:, prompt_len:] != tokenizer.pad_token_id).sum().item())
|
|
last_out_ids = out_ids
|
|
last_prompt_len = prompt_len
|
|
|
|
med = sorted(wall_times)[len(wall_times) // 2]
|
|
sample_texts = []
|
|
if last_out_ids is not None:
|
|
for i in range(min(3, last_out_ids.shape[0])):
|
|
sample_texts.append(tokenizer.decode(
|
|
last_out_ids[i, last_prompt_len:], skip_special_tokens=False)[:200])
|
|
|
|
return {
|
|
"backend": "unsloth_fi_false",
|
|
"lora_adapter": args.lora_adapter,
|
|
"n_prompts": args.n_prompts,
|
|
"n_prompt_tokens": n_prompt_tokens,
|
|
"n_decoded_tokens": total_decoded,
|
|
"wall_times_s": wall_times,
|
|
"median_wall_s": med,
|
|
"prompt_tps": n_prompt_tokens / med,
|
|
"decode_tps": (total_decoded or 0) / med,
|
|
"max_new_tokens": args.max_new_tokens,
|
|
"sample_completions": sample_texts,
|
|
}
|
|
|
|
|
|
def parse_args():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--backend", choices=["vllm", "tpaged", "unsloth_fi_false"], required=True)
|
|
p.add_argument("--model_name", default="unsloth/Qwen3-4B-Base")
|
|
p.add_argument("--max_seq_length", type=int, default=2048)
|
|
p.add_argument("--n_prompts", type=int, default=32)
|
|
p.add_argument("--n_rounds", type=int, default=2)
|
|
p.add_argument("--max_new_tokens", type=int, default=512)
|
|
p.add_argument("--gpu_memory_utilization", type=float, default=0.8)
|
|
p.add_argument("--attn_impl", default="sdpa")
|
|
p.add_argument("--max_batch_tokens", type=int, default=8192)
|
|
p.add_argument("--num_blocks", type=int, default=16384)
|
|
p.add_argument("--persistent_cb", action="store_true")
|
|
p.add_argument("--lora_adapter", default=None,
|
|
help="Path to a PEFT adapter (rank 32) applied in every backend.")
|
|
p.add_argument("--temperature", type=float, default=0.1)
|
|
p.add_argument("--top_p", type=float, default=0.97)
|
|
p.add_argument("--min_p", type=float, default=0.5)
|
|
p.add_argument("--top_k", type=int, default=5)
|
|
p.add_argument("--stats_path", required=True)
|
|
return p.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.stats_path)) or ".", exist_ok=True)
|
|
|
|
torch.cuda.reset_peak_memory_stats()
|
|
if args.backend == "vllm":
|
|
out = run_vllm(args)
|
|
elif args.backend == "unsloth_fi_false":
|
|
out = run_unsloth_fi_false(args)
|
|
else:
|
|
out = run_tpaged(args)
|
|
|
|
out["peak_memory_gb"] = torch.cuda.max_memory_allocated() / 1024**3
|
|
out["sampling"] = {
|
|
"temperature": args.temperature,
|
|
"top_p": args.top_p,
|
|
"min_p": args.min_p,
|
|
"top_k": args.top_k,
|
|
}
|
|
with open(args.stats_path, "w") as f:
|
|
json.dump(out, f, indent=2)
|
|
print(json.dumps(out, indent=2))
|
|
os._exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|