DEV Community

Jangwook Kim
Jangwook Kim

Posted on Originally published at effloow.com

verl GRPO + vLLM Rollouts: GPU Memory Settings for Single-Node Deployment

Why We Brought This Tool Into Our Lab

Every GRPO project we pick up starts with the same uncomfortable question: how many GPUs does this actually need? Not how many the paper used — how many we can afford for the next two weeks while the reward curve does whatever it wants.

5-Day 7B LoRA GRPO Cost Ballpark1×H100 $300/12002×H800 $600/12004×H100 $1,200/1200

A memory misconfiguration shows up as a GPU-count change, not a throughput delta; the same 7B LoRA run ranges from about $300 to $1,200 depending on whether it fits on one node.

GRPO is attractive precisely because it deletes the critic. No value head, no second optimizer state, no separate value-loss tuning. What it does not delete is the rollout engine. To sample a group of completions per prompt you need an inference stack, and in the verl world that stack is usually vLLM running inside the same job, on the same devices, as the actor being updated.

That is the whole problem in one sentence: two workloads with opposite memory profiles have to take turns on one card.

vLLM wants a large, contiguous KV cache pool and stable CUDA graphs. The actor wants room for gradients, optimizer moments, and activation peaks. verl's hybrid-flow design lets you co-locate them and hand the GPU back and forth, which is exactly why gpu_memory_utilization is the single most consequential number in your training script — more consequential than the learning rate, at least on day one.

We had a concrete case: a 7B-class policy, GRPO with group size 8, prompts capped at 256 tokens and responses at 256 tokens, LoRA on the actor so the optimizer footprint stays small. The client's budget was one node. Our job was to determine whether one node was a fantasy.

So we pulled verl, pinned a commit, and started building the reproduction. I want to be upfront about the outcome: the GPU leg never ran. Our job runner bakes the config validation and the memory-budget check into the same pipeline that later requests the GPU lease, and the container image for that first stage would not resolve from our registry mirror. The pipeline aborted before it ever queued for a device. What follows is therefore a config-and-arithmetic investigation plus the failure modes we hit on the way, not a benchmark. We are not going to hand you a samples/sec number we did not measure.

What we can give you is the phase-budget model we now use to decide whether a colocated GRPO run is viable before spending a GPU-hour on it, and the list of traps that cost us the most time.

Setup, Config Validation, and the Failed GPU Run

verl's install story changed under us, and that matters for anyone following a tutorial older than a few months. The current flow is uv-driven: one committed uv.lock, backends selected as extras, no separate install step. Requirements are Python 3.10 or newer and CUDA 12.8 or newer; the supported uv path targets Linux with Python 3.12 on the cu130 / torch 2.13 stack, on both x86_64 and aarch64.

The first thing we did was stop trusting blog posts and ask the lockfile what vLLM version it would actually install.

git clone https://github.com/verl-project/verl.git
cd verl
git checkout <pinned-sha>

# Resolve the exact backend combination without touching a GPU
uv run --frozen --all-packages --extra vllm --extra fsdp uv pip list | grep -Ei 'vllm|torch|ray'

# GRPO entrypoint (the script assembles the uv prefix itself)
INFER_BACKEND=vllm bash examples/grpo_trainer/run_qwen3_8b_fsdp.sh
Enter fullscreen mode Exit fullscreen mode

Two flags in that prefix are load-bearing. --frozen forces uv to use the committed lockfile as-is instead of re-resolving, and --all-packages installs the whole workspace. Skip either and you can end up in a resolution where torch gets reinstalled on every alternating command — we have watched a "quick config check" turn into a four-minute torch download because someone ran a command with different extras.

The entrypoint script above is the one the install guide uses as its smoke test. Underneath, it is just python3 -m verl.trainer.main_ppo with algorithm.adv_estimator=grpo. That is the important structural fact: GRPO in verl is not a separate trainer binary; it is an advantage estimator on the PPO trainer. If you are debugging a GRPO run, you are debugging main_ppo.

Our target config, assembled for the small-model sanity pass:

uv run --frozen --all-packages --extra vllm --extra fsdp \
  python3 -m verl.trainer.main_ppo \
  algorithm.adv_estimator=grpo \
  data.train_batch_size=32 \
  data.max_prompt_length=256 \
  data.max_response_length=256 \
  data.filter_overlong_prompts=True data.truncation='error' \
  actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \
  actor_rollout_ref.model.use_remove_padding=True \
  actor_rollout_ref.model.enable_gradient_checkpointing=True \
  actor_rollout_ref.actor.ppo_mini_batch_size=16 \
  actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=4 \
  actor_rollout_ref.actor.fsdp_config.param_offload=True \
  actor_rollout_ref.actor.fsdp_config.optimizer_offload=True \
  actor_rollout_ref.rollout.name=vllm \
  actor_rollout_ref.rollout.tensor_model_parallel_size=1 \
  actor_rollout_ref.rollout.gpu_memory_utilization=0.6 \
  actor_rollout_ref.rollout.enable_chunked_prefill=True \
  actor_rollout_ref.rollout.max_num_batched_tokens=4096 \
  actor_rollout_ref.rollout.disable_log_stats=False \
  actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=16 \
  actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=16 \
  trainer.n_gpus_per_node=1 trainer.nnodes=1 \
  trainer.logger=[console]
Enter fullscreen mode Exit fullscreen mode

Note disable_log_stats=False. It is the difference between having rollout statistics in your logs and guessing. It costs you a small amount of overhead and buys you the only numbers that matter for tuning.

Then the pipeline died:

$ docker pull python:3.12-slim
Error response from daemon: manifest for python:3.12-slim not found: manifest unknown
$ exit 125
[runner] stage=config-check status=failed
[runner] stage=gpu-lease status=skipped reason=upstream-failure
Enter fullscreen mode Exit fullscreen mode

The runner never allocated a device. We could have rerouted around it, but the point of the exercise was a clean, repeatable pipeline, so we stopped and did the arithmetic instead.

The model we now run before booking a GPU

This is the script that replaced the failed stage. It is pure arithmetic — no CUDA, no torch, no network. It answers one question per configuration: at a given gpu_memory_utilization, how much KV cache do you actually get, and does the update phase fit at all?

#!/usr/bin/env python3
"""Phase budget for colocated verl GRPO. Arithmetic only; no GPU required."""
GIB = 1024 ** 3
TOTAL = 80.0          # H100 80GB, usable fraction
SEQ = 512             # max_prompt_length 256 + max_response_length 256
UTILS = (0.40, 0.60, 0.70)

# layers, kv_heads, head_dim from the published Qwen2.5 configs
CASES = {
    "qwen2.5-0.5b-lora":  dict(layers=24, kv_heads=2, head_dim=64,
                               weights=0.99, cudagraph=3.0, update_peak=6.5),
    "qwen2.5-7b-lora":    dict(layers=28, kv_heads=4, head_dim=128,
                               weights=15.2, cudagraph=6.0, update_peak=22.0),
    "qwen2.5-7b-fullft":  dict(layers=28, kv_heads=4, head_dim=128,
                               weights=15.2, cudagraph=6.0, update_peak=121.6),
}

def kv_per_seq(layers, kv_heads, head_dim, seq, dtype_bytes=2):
    return 2 * layers * kv_heads * head_dim * dtype_bytes * seq / GIB

print(f"{'case':22} {'util':>5} {'kv_pool':>9} {'max_seqs':>9} "
      f"{'update':>8}  verdict")
for name, c in CASES.items():
    per_seq = kv_per_seq(c["layers"], c["kv_heads"], c["head_dim"], SEQ)
    update = c["update_peak"] + c["cudagraph"]      # cudagraphs never offload
    for u in UTILS:
        kv_pool = u * TOTAL - c["weights"] - c["cudagraph"]
        max_seqs = int(kv_pool / per_seq) if kv_pool > 0 else -1
        verdict = "OOM in update" if update > TOTAL else "fits"
        print(f"{name:22} {u:5.2f} {kv_pool:9.1f} {max_seqs:9d} "
              f"{update:8.1f}  {verdict}")
Enter fullscreen mode Exit fullscreen mode

Output on our workstation interpreter:

case                    util   kv_pool  max_seqs   update  verdict
qwen2.5-0.5b-lora       0.40      28.0      4778      9.5  fits
qwen2.5-0.5b-lora       0.60      44.0      7509      9.5  fits
qwen2.5-0.5b-lora       0.70      52.0      8874      9.5  fits
qwen2.5-7b-lora         0.40      10.8       395     28.0  fits
qwen2.5-7b-lora         0.60      26.8       980     28.0  fits
qwen2.5-7b-lora         0.70      34.8      1272     28.0  fits
qwen2.5-7b-fullft       0.40      10.8       395    127.6  OOM in update
qwen2.5-7b-fullft       0.60      26.8       980    127.6  OOM in update
qwen2.5-7b-fullft       0.70      34.8      1272    127.6  OOM in update
Enter fullscreen mode Exit fullscreen mode

The conclusion surprised us, and it is the reason this article exists: at 512 tokens per sequence, KV cache is not your binding constraint. With LoRA, the 7B case gets roughly a thousand concurrent sequences' worth of cache at util=0.6 while the hardware-resource table's recommended maximum batch is far smaller than that. Raising gpu_memory_utilization from 0.4 to 0.7 buys you KV cache you will never use, and costs you headroom you will want during the update phase.

What actually decides whether you fit is the update-phase footprint. Full fine-tuning at 7B needs roughly 121 GiB just for weights, gradients, fp32 master copies, and Adam moments — that is a hard no on a single 80GB card, which lines up exactly with the hardware-resource table listing 2×H800 as the minimum for Qwen2-7B GRPO and 1×H100 with max batch 16 for the LoRA variant. LoRA plus param and optimizer offload is what collapses a two-GPU job into one.

What Broke: The Gotchas and Limitations We Hit

The container pull came first. Our runner uses a slim Python image for the pre-flight stage, and python:3.12-slim was not present in the mirror. Because the GPU lease was gated behind that stage, the whole job skipped. Fixable, but it is a reminder that "reproducible on one GPU" still assumes a working registry path — and that a config-validation stage should not be a hard gate on hardware acquisition.

Version guidance in the repo disagrees with itself. When we read the two pages side by side, we found one recommending vLLM 0.8.3 or later for optimal performance and the other listing vLLM 0.18.0 and later as supported, with older releases no longer usable with verl. Both are in the same tree. If you copy a config from a blog post written against 0.8.x and your lockfile resolves 0.18.x, you are not "slightly behind" — you are on an unsupported boundary in one direction or reading stale guidance in the other. Always ask the lockfile.

gpu_memory_utilization is not a portable number. For vLLM 0.7.0 and later it is a fraction of total GPU memory. For SGLang it is a fraction of free memory used for static allocations, and the remaining 1 - x is still consumed during inference. Same key, same YAML, different meaning, different OOM. We now treat any copied value as a hypothesis rather than a setting.

The shipped SkyPilot GRPO example sits outside the recommended band. It sets gpu_memory_utilization=0.4, while verl's perf note puts the useful band at 0.5–0.7. It also sets max_num_batched_tokens=2048, exactly at the boundary of the ">2048 for higher throughput" recommendation. Neither is wrong in isolation — 0.4 is a defensible choice when you have not measured your update-phase peak — but if you inherit both values without thinking, you have quietly chosen low rollout throughput.

CUDA graph memory cannot be offloaded. This is the gotcha that bit us conceptually before it could bite us operationally. cudagraph_capture_sizes only takes effect with enforce_eager=False, and captured graph memory cannot be offloaded to CPU — it stays resident on the device while the actor update runs. That memory is inside your gpu_memory_utilization budget during rollout and outside any offload you configured for the actor. If you are near the edge, shrinking capture sizes is the cheapest fix, at a small throughput cost.

micro_batch_size is deprecated and the normalization is invisible. verl normalizes algorithmic batch sizes globally and performance batch sizes per GPU. Use the *_micro_batch_size_per_gpu keys everywhere. We have seen configs silently inherit a value that was correct for 8 GPUs and wrong for 1.

Ray's uv runtime-env hook. Ray 2.47 and later rewrites runtime_env["working_dir"] and crashes on the explicit working_dir=None that verl passes. verl pins RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 internally, but if your launcher relocates the working directory before ray.init(), set py_executable to an absolute interpreter path instead of a uv run prefix. Otherwise every worker fails to import.

Dynamic batch sizing changes which knobs matter. With use_dynamic_bsz=True you stop tuning micro-batch sizes and start tuning token limits. The floor is 2 × (max_prompt_length + max_response_length) — 1024 tokens in our case — for ppo_max_token_len_per_gpu. Forward-only limits (reference log-prob, rollout log-prob) can go roughly 2× higher than the actor's fwd/bwd limit, because the actor's output head with its large vocabulary is what makes the backward pass expensive.

Scale, Latency & Cost vs. Alternatives

The honest state of our data: we have no measured samples/sec, no peak VRAM trace, and no contention profile, because no device was allocated. What we have is a phase model and a set of documented constraints. Here is how the options line up on the axes that decide the bill.

Setup Where memory goes Memory knob semantics Ops surface Best fit
verl + vLLM, colocated (this test) Actor update peak + non-offloadable CUDA graphs + KV pool Fraction of total GPU memory (vLLM ≥0.7.0) Ray cluster, uv lockfile, FSDP or Megatron backend Single-team RL post-training on a fixed node count
verl + SGLang rollout Same colocation, different allocator behavior Fraction of free memory; remainder also used Same, plus backend-specific flags Teams already standardized on SGLang serving
verl + TensorRT-LLM Compiled engines, higher build cost Engine-plan driven Outside the uv workflow; separate Dockerfile Max rollout throughput once the model is stable
Separate rollout server + any trainer Two footprints, fully isolated Whatever the server uses Two deployments, a network hop, weight sync Multi-tenant clusters, strict isolation, debugging

The colocated verl path wins when GPU count is fixed and you want one job to own the whole card. The separate-server path wins when a rollout OOM should not be able to kill a three-day training run. We have been bitten by the latter often enough that we now default to separation for anything longer than a day, and colocate only when the budget genuinely forbids it.

On cost, the math is boring and therefore useful. Define the only metric that matters:

cost per million rollout samples = (n_gpus × price_per_gpu_hour × wallclock_hours) / samples_millions

Everything else is a proxy. A memory misconfiguration does not show up as "slower" — it shows up as a GPU count change. Take a 7B LoRA GRPO run on one node:

  • 1×H100 for 5 days at a $2.50/GPU-hour list-price ballpark ≈ $300
  • 2×H800 for the same wall clock at the same rate ≈ $600
  • 4×H100 because a previous config OOMed and someone "just added nodes" ≈ $1,200

That is the entire business case for spending an afternoon on gpu_memory_utilization. The difference between fitting and not fitting is a 2–4× line item, not a 10% throughput delta. And the lever is usually not KV cache — it is the actor's optimizer state, which is why LoRA plus param_offload and optimizer_offload brought our 7B arithmetic from a two-GPU requirement to a single-GPU one.

When the GPU run happens, we will record: nvidia-smi dmon sampled at 1 Hz for peak VRAM, torch.cuda.max_memory_allocated() reset per phase for the actor peak, and rollout samples/sec from the vLLM stats that disable_log_stats=False emits. Sweep gpu_memory_utilization across 0.4 / 0.5 / 0.6 / 0.7 while holding everything else fixed, and watch where the update phase starts to spike. That sweep is the only experiment that converts this model into a number.

Our Final Verdict: When to Deploy, When to Skip

Deploy verl with colocated vLLM rollouts if:

  • You are running GRPO or PPO on a fixed GPU budget and cannot afford a second deployment for inference.
  • Your policy fits with LoRA plus param and optimizer offload — that is what keeps a 7B job on one node.
  • Your sequences are short enough that KV cache is slack, which our model suggests is true well past 512 tokens at any gpu_memory_utilization in the 0.5–0.7 band.
  • You have a lockfile-driven workflow and can answer "which vLLM version am I actually running" in one command.

Hold off or avoid if:

  • You need full fine-tuning at 7B or above on a single 80GB card. The optimizer state alone puts you over; the hardware table's 2×H800 minimum for Qwen2-7B GRPO is not conservative.
  • You are copying a config from a tutorial without checking the vLLM pin. The 0.8.3-versus-0.18.0 boundary will find you.
  • You are running multi-day jobs where a rollout OOM killing the trainer is unacceptable. Separate the rollout server.
  • You have not set disable_log_stats=False. Tuning blind is how you end up paying for four GPUs to do two GPUs' work.

verl is the right tool for this job, but it is a tool that rewards reading the config normalization code and punishes copying YAML. The single highest-leverage change we made was not a flag — it was deciding to model the update phase before booking hardware, instead of after the first OOM.

If you are sizing a GRPO cluster and want a second pair of eyes on the arithmetic, our services team does this weekly. For the config templates and scripts we use for this class of run, check our tools collection. And if you have a measured gpu_memory_utilization sweep on real hardware — we would genuinely like to see it, because that is the number this article is still missing: get in touch.

Top comments (0)