Worklog: Post training Qwen2.5 for math reasoning
It’s been a while since I wanted to write a worklog like this, where I walk through the actual process of training models for xyz task. So in this blog, we are gonna train a model for high school maths. And we picked Qwen2.5-0.5B as our candidate model, deliberately for two reasons: 1. It’s pretty small, smallest in category of LLMs. 2. It was never trained to reason.
So throughout this worklog, we train this smol model to reason, and solve maths problems.
And one more thing, it is not gonna be another tutorial where I just give you bunch of lines of code to train a model, and expect model to be magically SoTA, because in reality it doesn’t work like that, haha. So, in case if you are new to RL, you will get a taste of how does training this models look like.
This worklog uses code from this huggingface tutorial, except system prompt is little different.
Alright, let’s formalize the problem. Let’s say for a question: Tobias is buying a new pair of shoes that costs $95. He has been saving up his money each month for the past three months. He gets a $5 allowance a month. He also mows lawns and shovels driveways. He charges $15 to mow a lawn and $7 to shovel. After buying the shoes, he has $15 in change. If he mows 4 lawns, how many driveways did he shovel?
I want my model to respond like this:
<start_working_out>
He saved up $110 total because 95 + 15 = <<95+15=110>>110
He saved $15 from his allowance because 3 x 5 = <<3*5=15>>15
He earned $60 mowing lawns because 4 x 15 = <<4*15=60>>60
He earned $35 shoveling driveways because 110 - 60 - 15 = <<110-60-15=35>>35
He shoveled 5 driveways because 35 / 7 = <<35/7=5>>5
<end_working_out>
<SOLUTION>5</SOLUTION>
Baselines first
Before training anything, I wanted to know where the model starts. So I wrote the system prompt that every run in this worklog uses:
You are a mathematical reasoning assistant.
When given a math problem:
1. Show the calculations and step by step solution between <start_working_out> and <end_working_out>
2. Give the final answer between <SOLUTION> and </SOLUTION>
Do not use \boxed{}. Do not write anything after </SOLUTION>.
Example:
<start_working_out>
Shelf one has 35 books and shelf two has 18.
35 + 18 = 53
<end_working_out>
<SOLUTION>53</SOLUTION>
The worked example and the “no \boxed{}” line are there for a reason. My first version of this prompt had neither, and when I tried it on a Qwen3 model, it got 80% of the math right but wrapped 97% of its answers in \boxed{} instead of the tags. A strict parser scored it 4%. One example fixed that.
Then I ran Qwen2.5-0.5B-Instruct on the full GSM8K test set (1,319 questions) with greedy decoding, and my benchmark script printed this:
accuracy (mean@1): 0.0000 (strict: script's answer format)
accuracy lenient: 0.3776 (any \boxed / #### / last number)
Zero. For a second I thought the model couldn’t follow the format at all. It turned out my benchmark script’s “strict” check was looking for a #### <number> line (from an older prompt), not the <SOLUTION> tags. So I re-scored the same 1,319 answers with the checks I was actually going to train with:
| Checks | Qwen2.5-0.5B-Instruct |
|---|---|
| right number anywhere in the answer | 37.7% |
right number inside <SOLUTION> |
17.5% |
| exact tag format | 27.3% |
| right answer and exact format | 11.5% |
I also benchmarked Qwen3-0.6B on the test set before training. It scored 36%.
So the model can do the math on more than a third of the problems, but less than half of those right answers land where a parser can find them. Less than half of its answers (43%) even had a <SOLUTION> block. That gap between “can do the math” and “writes it down the way I asked” kept coming back through this whole worklog.
Getting the first run to train at all
The cookbook is written for a single consumer GPU, so it loads the model in 4-bit and lets device_map="auto" place it. I was on a node with 8 H100s, and my very first run crashed before step 1:
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 49.99 GiB.
50 GB, for a 0.5B model. It turns out the loss computes fp32 scores over Qwen’s whole 152k-token vocabulary, for every token of every completion. With completions allowed up to 2,048 tokens and 8 of them per GPU, that one tensor is huge. GSM8K answers are about 200 tokens long, so I cut the limit to 512.
I also dropped 4-bit (at 0.5B parameters it buys nothing on an H100) and device_map="auto", which fights accelerate when you want one copy of the model per GPU. The final setup: vLLM on GPU 0 generating the rollouts, the other 7 GPUs training, LoRA on the cookbook’s q_proj and v_proj, and the cookbook’s learning rate (5e-6) and gradient clipping (0.1). I kept TRL’s default loss_type="dapo" for the GRPO runs.
Naive rewards: correctness and format
My first real run, jr8dtkxv, used the two most obvious rewards:
- correctness: 1 if the number inside
<SOLUTION>matches the answer, else 0. - format: 1 if the whole completion has the exact tag structure, else 0.
Writing even these two, I shipped two bugs on the first try. The reference answers in GSM8K end with #### 72, but I was parsing them with the same function that looks for <SOLUTION>, so every reference answer came out as None. And TRL wants one reward per completion, but my function returned a single number for the whole batch. Fun.
With those fixed, I trained on the full training set (7,473 questions), 8 samples per question, for 300 steps. And… nothing.
Correctness started at 8.1% and ended at 7.6%. On the test set it sat at 6.8%. (Test numbers during training are sampled at temperature 1.0, so they come in lower than the greedy 17.5% from the baseline.) Format hovered around 20%.
So I opened the rollout table in W&B, the one TRL fills with every prompt, completion and reward. 91% of the rollouts were wrong, and 76% of the wrong ones had no <SOLUTION> block at all. A lot of them looked like this one:
Q: Pat’s Pool Supply has three times as many swimming pools as Pat’s Ark & Athletic Wear store. If Pat’s Ark & Athletic Wear store has 200 pools, how many pools do they have in total?
<start_working_out> Let's break this down step by step: ... 3. So, Pat's Pool Supply has 3 * 200 = 600 swimming pools. 4. Now, we add the number of pools both stores have: 200 + 600 = 800 pools. That means they have 800 pools altogether.
The answer is 800. The model got it right, then never closed the working-out block and never wrote <SOLUTION>, so it scored zero.
The same table showed a second problem. GRPO learns by comparing the samples within a group, so a question where all 8 samples score the same gives it nothing to learn from. 57.5% of the groups were 0/8, and exactly one group out of 2,114 was 8/8.
GRPO doesn’t update toward an answer just because it got a reward. It compares each answer’s reward with the other seven, using an advantage like this in the original GRPO formulation:
\[ \hat A_i = \frac{r_i - \bar r}{s_r + \epsilon} \]
Here, \(r_i\) is one answer’s reward; \(\bar r\) and \(s_r\) are the mean and standard deviation across the eight answers. For the correctness reward, a 0/8 group has eight zeros, so every reward equals the mean. An 8/8 group has eight ones, and again every reward equals the mean. Either way, every advantage is zero. The update is multiplied by that advantage, so correctness gives the model no direction: it can’t tell which answer to make more likely. The same thing happens any time all eight answers get the same total reward. I also had a format reward, though, so an all-wrong group could still give a signal if some answers followed the format and others didn’t.
Filtering the 0/8 and 8/8 questions
If most questions teach nothing, why train on them? I wrote a script that downloads a run’s rollout tables from W&B, groups them per question, and drops the questions that came out 0/8 or 8/8. jr8dtkxv had seen 2,100 questions: 1,206 were all-wrong and 1 all-right, so 1,207 went out. I also cut the group size from 8 to 4, to cover more questions per step, and launched dszbz193.
Flat again. Correctness went from 10.9% to 10.8%, test correctness stayed at 7%. This time I looked at the optimizer instead of the rewards:
Three things were stacked against me, all straight from the cookbook’s single-GPU defaults:
- Learning rate 5e-6. That’s a full fine-tuning learning rate. LoRA usually needs about 10× more.
- Gradient clipping at 0.1. The gradient norm averaged 0.13, so 79% of the steps were clipped.
- LoRA only on
q_projandv_proj. Very few trainable weights.
I went to 3e-5, clipping at 1.0, and LoRA on every linear layer. I also set lora_dropout to 0, after the terminal showed an importance-sampling ratio of 0.16 during a test run. More on that ratio later, because it turned out to be the biggest problem in this whole worklog.
Presampling: pass@k before training
Filtering from a run’s logs had a hole: it only knew about the questions that run happened to sample, 2,100 of 7,473. So instead of mining old rollouts, I wrote presampling script. Before training even starts, it generates samples for every training question (with vLLM, on all 8 GPUs, about a minute), scores them, and drops the ones with no learnable signal. The point of RL is to amplify the signal the model already has, and a question it always gets right, or never gets right, has none.
For this first presample I used 4 samples per question, and it gave me a scare: 5,330 of the 7,473 questions came out 0/4. But I had seen this movie already. Most “wrong” answers were format failures, not math failures. So I also scored them leniently, counting the answer if the right number was the last number anywhere in the text. 45% of the “0/4” questions turned out to be right math in the wrong place. With lenient scoring, 2,950 questions were 0/4 and 426 were 4/4, which left 4,097 to train on.
The model was struggling with both format and correctness, and the two all-or-nothing rewards gave it no credit for being close on either. So for this run I added three rewards:
tag_check(weight 1.0): 0.125 for each of the four tags that appears exactly once, max 0.5. Partial credit for getting the format partly right. A tag repeated twice earns nothing, so it can’t be farmed.lenient_correctness_check(weight 0.5): right number anywhere. Right math in the wrong place should beat wrong math.exact_check(weight 2.0): right answer and exact format. The thing I actually want gets the biggest reward.
That was iql9rc9t.
The total reward went up (0.75 to 0.90), but mostly from the partial rewards. Format went from 22% to 30%, correctness on training batches from 11.8% to 14.4%. On the test set: 6.1% to 6.9% exact. Something was moving, but barely.
One panel did change completely: the share of groups with no learning signal dropped from about 35% to 1%. Almost every group now had samples with different scores. So the model had plenty of signal, and it still wasn’t learning much from it.
More partial rewards
Looking at the rollouts again, two kinds of failure were left.
First, on hard questions the model got the final answer wrong but was sometimes halfway there, and nothing gave it credit for that. GSM8K’s reference solutions mark every calculation, like <<48/2=24>>. So step_check takes those intermediate results (skipping the final answer and anything already in the question) and gives credit for the fraction that show up in the reasoning. If a completion contains way more numbers than a real solution would, the credit shrinks, so dumping numbers doesn’t pay. Before using it I checked it on saved rollouts: in 56% of groups every sample had the same correctness, and step_check still gave different scores within 85% of those.
Second, the <SOLUTION> tag sometimes held things like $10 + $2. instead of a number, which breaks the parser even when the math is right. So solution_number_check gives a small reward when the tag holds just a number.
That made the full set, max 5.25:
| Reward | Gives 1 when | Weight | Why I added it |
|---|---|---|---|
exact_check |
right answer and exact format | 2.0 | the actual target |
correctness_check |
right number inside <SOLUTION> |
1.0 | the original signal |
lenient_correctness_check |
right number anywhere | 0.5 | credit the math even when the format fails |
format_check |
exact tag structure | 0.5 | the original format signal |
tag_check |
0.125 per tag present exactly once (max 0.5) | 1.0 | credit a partly right format |
step_check |
fraction of the reference’s intermediate results reached | 0.5 | credit being halfway there |
solution_number_check |
<SOLUTION> holds just a number |
0.25 | keep the answer parseable |
I also redid the presample with 8 samples per question, to match the group size in training: 2,009 questions were 0/8 and 84 were 8/8, leaving 5,380.
The first launch crashed on step 1, with step_check complaining it never got its steps column. The datasets library had cached my preprocessed dataset from before I added that column. It recognizes a function it has seen by its name, not its code, so my edit never took effect. One load_from_cache_file=False later, it trained: cr6hatom.
Reward 1.18 to 1.25. Test exact accuracy 5.6% to 6.3% over 444 steps. Seven rewards, every group with a learning signal, and the model had barely moved.
Same recipe, Qwen3-0.6B
At this point I had two suspects: my rewards, or the model. So at the same time, I kicked off the exact same setup with Qwen3-0.6B (thinking mode off): same prompt, same seven rewards, same hyperparameters, its own 8-sample presample (1,336 questions 0/8, 912 8/8, 5,225 left). That’s 9ue7k08w. Qwen3 had scored 36% on the test set before training, so I had a baseline for this comparison too.
And it just… worked. Reward climbed from 3.47 to 4.45 out of 5.25. Exact accuracy on training batches went from 55% to 82%, format from 86% to 98%. On the test set, exact accuracy was 64.8% at step 100 and 67.2% at step 200. The share of groups with no signal climbed from 7% to 36%, the good kind of rising: the model was starting to get whole groups right.
So the rewards were fine. Now I wanted the same thing from Qwen2.5.
SFT warm-up
Qwen2.5 was clearly struggling with the format, the RL reward was flat, and piling on small partial rewards hadn’t fixed it. So before RL, I decided to just show it the format with supervised fine-tuning.
For data, I used the 2,093 questions the presample had eliminated (2,009 always wrong, 84 always right). RL wasn’t using them anyway, and this way the SFT data can’t contaminate the RL data. I rewrote their GSM8K reference solutions into my tag format and checked that all 7,473 rewritten solutions score 1.0 on my format and correctness rewards, so the model learns exactly what RL would reward.
The first SFT run looked healthy for 30 steps, then the gradient norm turned into NaN, the loss dropped to exactly 0, and it quietly saved a model whose every single weight was NaN:
My first guess was precision, so I kept fp32 master weights and added a guard that stops the run on the first NaN. It stopped at step 30 again, same step. That was the hint: the data order is seeded, so step 30 always gets the same batch. Each of the 2,093 examples was fine on its own. But replaying TRL’s batching on one GPU, batch 232 gave a finite loss and NaN gradients, and only through PyTorch’s SDPA attention kernel in bf16. The same batch with SDPA in fp32, or with eager attention, was fine. I switched to eager attention and SFT went through cleanly (rylnmvbk).
After SFT, the model is a different model, so I presampled again with the same scoring as before: 1,705 questions 0/8 and 274 8/8. I dropped those, plus the 2,093 SFT questions, leaving 4,380 for RL. Then 6hqxh39p:
Immediate jump. The reward started at 3.0 instead of 1.2, and format was basically solved: 99% on training batches, 99.3% on the test set. Completions got shorter too, about 105 tokens instead of 180. Correctness, though, only moved a little: 42% to 48% on training batches, and 22.8% exact on the test set. Up from 6%, but RL on top was flat again.
OPD warm-up
SFT was one warm-up. For a separate experiment, I wanted to try something that could teach the reasoning too, so I used on-policy distillation (OPD) instead. The student writes its own answers, a bigger teacher scores every token of them, and the student learns to move toward the teacher’s choices, token by token.
OPD and SFT give dense feedback on every token, where RL gives one number per answer.
The teacher was Qwen2.5-7B-Instruct. With my prompt it gets 91.7% of the training questions right and 77% in exact format, so it pulls the student toward exactly what my rewards want. (One snag: its vocabulary has 152,064 rows and the student’s 151,936, and TRL refuses to distill across different sizes. Both use the same tokenizer with 151,665 real tokens, so the extra rows are unused padding. Trimming them left the teacher’s probabilities unchanged.)
Same 2,093 questions as the SFT, 3fjg9zef:
The presample after OPD looked very different: only 861 questions 0/8, and 1,497 already 8/8. After dropping those and the OPD questions, 3,763 were left for RL. c0uc1sfx:
More gains: exact accuracy on training batches around 57–59%, and 40.8% on the test set, almost double the SFT warm-up. But look at the test accuracy across the whole run: 40.8%, 40.4%, 40.3%, 40.8%, 40.0%, 40.5%, 40.9%, 41.1%. Eight hundred steps of RL, and nothing.
And this is where I finally noticed the panel I had been ignoring since the very first run: the importance-sampling ratio.
The importance-sampling ratio
In this setup there are two copies of the model. vLLM generates the rollouts, the trainer computes the gradients, and after every update the trainer pushes its new weights to vLLM. In theory both copies are the same model, so they should give every token the same probability. In practice their code is different (different kernels, different rounding), so TRL checks: for every sampled token, it compares vLLM’s probability with the trainer’s, and multiplies those ratios over the whole completion. With its default setting, sequence_mask, a completion whose ratio drifts too far from 1 is simply dropped from the loss.
Written out, it’s just the product of the token ratios:
\[ \rho_{\text{seq}} = \prod_{t=1}^{T} \frac{p_{\text{trainer},t}}{p_{\text{vLLM},t}} = \exp\left(\sum_{t=1}^{T} \left[\log p_{\text{trainer},t} - \log p_{\text{vLLM},t}\right] \right) \]
sequence_mask uses that one ratio for the whole completion. If it falls outside the configured range, TRL drops the whole completion (docs). That’s why a small mismatch at each token can add up, especially on a long answer.
So this number decides how much of each batch the model actually learns from. The mean in the plot isn’t itself the fraction of completions kept: with sequence_mask, TRL makes that decision one completion at a time. But as the copies drift apart, longer answers have more chances for those token-level differences to compound and push the sequence ratio out of range.
Once I knew what to look for, it was everywhere:
It had been there since jr8dtkxv (0.92 falling to 0.36). Qwen3 started at 1.0 and never went below 0.6. Every Qwen2.5 run fell to somewhere between 0.3 and 0.55, most of them within the first 100 steps.
So why Qwen2.5 is worse at this and not Qwen3
The first thing I checked: is Qwen2.5 just more sensitive to bf16 rounding? No.
The base model’s bf16 and fp32 versions differ by 0.043 nats per token, and Qwen3’s by 0.039. Basically the same.
The difference was in which tokens they sample. Rounding noise is tiny on tokens the model is sure about and much bigger on unlikely ones, because a small error in a small probability is a big error in its logarithm space. I measured it on 24 real vLLM-sampled completions per model:
| Probability of the sampled token | Noise per token | Share of tokens, Qwen2.5 | Share of tokens, Qwen3 |
|---|---|---|---|
| above 0.9 (sure) | ~0.001 | 57% | 69% |
| 0.5 – 0.9 | ~0.015–0.019 | 20% | 16% |
| 0.1 – 0.5 | ~0.04 | 14% | 11% |
| below 0.1 (unlikely picks) | ~0.05–0.06 | 9.5% | 4.5% |
Same noise curve for both models, but Qwen2.5 is less sure of itself on this task, so it picks about twice as many unlikely tokens. Its answers are also longer (210 tokens against 155 in this sample). Summed over a completion, the mismatch came out 2.88 for Qwen2.5 against 1.66 for Qwen3. Multiply that over a whole answer, and sequence_mask drops it.
Attempt 1: stop dropping whole answers
The obvious fix was TRL’s token_truncate mode: weight each token by its own ratio, capped at 3, instead of dropping entire completions. 6oyljev3:
For about 80 steps the ratio sat near 1, very stable, unlike any run before. But underneath, the mismatch was climbing the whole time (look at the log scale): from 0.02 nats per token at the start to 10 around step 100, and 4.8 by the end. Once it got that big, the ratio collapsed too. The gradient norm went from 0.2 to 1.4 and entropy collapsed from 0.28 to 0.09.
With sequence_mask, the samples where the two copies disagreed had been getting thrown away.
With token_truncate, the model kept training on them, and the two copies drifted further apart with every step. The rewards didn’t move either way.
Attempt 2: vLLM was running out of memory
The vLLM server’s log had lines like this every few syncs:
memory allocation failed with OOM on device 0 while trying to allocate 989855744 bytes
989,855,744 bytes is exactly Qwen2.5-0.5B in bf16. By default trl vllm-serve reserves 90% of the GPU for itself, and every weight sync tries to allocate a buffer the size of the whole model on top of that. The sync still returned 200 OK. So I gave vLLM 50% of the GPU instead and relaunched, 46in4jdn:
Same story: stable for about 100 steps, then the mismatch ran away to 4.9. The OOM was a problem, but it wasn’t the cause.
Attempt 3: fp32 everywhere
Then I started measuring precision directly, and found a few real problems:
- The OPD checkpoint hates bf16. Converted to bf16, it drifts from its own fp32 version by 0.43 nats per token, against 0.047 for the base model. OPD trained it in fp32, but RL ran it in bf16, so RL started from a damaged version of it.
- The weight sync rounds away most of the update. At every sync, TRL merges the LoRA update into the base weights and sends the result to vLLM in bf16. On my saved adapters, 49 - 62% of the weights didn’t change at all after the merge: the update was smaller than bf16’s resolution and rounded right back to the original value. The effect on probabilities was small though, about 0.013–0.018 nats per token.
- Merging and unmerging in bf16 leaks. After 10 merge/unmerge round trips, the trainer’s own base weights had drifted by 2.5e-4 (relative). In fp32: 4e-9.
So I ran the whole thing in fp32, trainer and vLLM both, and went back to sequence_mask: i7yh9cby.
The start was much better, because the OPD model finally ran the way it was trained: 67% exact on training batches at step 0 instead of about 57%, and 47.9% exact on the test set, the best Qwen2.5 number in this worklog. The runaway was gone too.
But RL on top was still flat (47.9%, 48.4%, 48.6%), and the ratio still fell to about 0.42. So I looked at the first few steps, where the two copies should be identical:
At step 1 the mismatch was 0.00056, so vLLM and the trainer agreed almost perfectly. Then it grew by about 0.003 with every step: 0.006, 0.009, 0.012, 0.016… Qwen3 held flat over the same steps.
A mismatch that starts at zero and grows with every update means the two copies stop agreeing because of training. So I took the adapter checkpoints i7yh9cby had saved and tested every piece of the weight sync on its own, in fp32:
| Test | vLLM vs correct model (nats/token) |
|---|---|
| vLLM vs Hugging Face, same tokens, no training | 0.0007 for Qwen2.5, 0.0008 for Qwen3 |
| TRL-style padded batches, train mode, gradient checkpointing | 0.0007 |
| trainer’s unmerged LoRA vs merged weights | 0.00000 |
| one sync through vLLM’s weight loader (eager and compiled) | 0.0008 |
three back-to-back syncs into a live trl vllm-serve |
0.0008, no growth |
Every piece checked out. So I tried the things only the live run has:
- One GPU instead of seven (blyv9d73), in case the seven training processes were drifting apart. Still grew, the same way.
- Less memory for vLLM. During that run vLLM swung between 44 and 70 GB on a 40 GB budget, and the OOM was back (545,259,520 bytes, exactly the embedding matrix in fp32). So I cut it to 25% (cpgrs28e). Still grew: 0.0006 to about 0.02 in 20 steps.
Where this leaves me
Every piece of the weight sync works when I test it alone, and the live training loop still drifts, only for Qwen2.5. I haven’t found why yet. While it’s there, sequence_mask throws away a big share of every batch. That is my best guess for why RL added so little on top of each warm-up, even though every group had a learning signal and the warm-ups kept raising the starting point.
I tried the two warm-ups separately, then ran RL after each. Here’s where each attempt landed on the test set:
| Experiment | Test exact accuracy |
|---|---|
| Qwen2.5-0.5B, full reward set, no warm-up (cr6hatom) | 6.3% |
| SFT warm-up, then RL (6hqxh39p) | 22.5% |
| OPD warm-up, then RL (c0uc1sfx) | 41.0% |
| OPD warm-up, then RL in fp32 (i7yh9cby) | 48.6% |
| Qwen3-0.6B, same recipe, no warm-up (9ue7k08w) | 67.2% |
If you’re doing GRPO with a separate vLLM server, the one thing I’d tell you is: put sampling/sampling_logp_difference and sampling/importance_sampling_ratio on your dashboard from step 1. I spent most of this worklog tuning rewards, while the number that decided how much of each batch the model was learning from sat in a panel I never opened.
Runs referenced
| Run | What it was |
|---|---|
| jr8dtkxv | correctness + format, full training set |
| dszbz193 | 0/8 and 8/8 questions from jr8dtkxv’s rollouts removed, group size 4 |
| iql9rc9t | 4-sample presample, + tag, lenient and exact rewards, fixed optimizer |
| cr6hatom | 8-sample presample, full seven-reward set, Qwen2.5-0.5B |
| 9ue7k08w | same recipe on Qwen3-0.6B |
| cazlulmo | SFT warm-up, went NaN at step 30 |
| xque1787 | SFT warm-up, stopped by the NaN guard at step 30 |
| rylnmvbk | SFT warm-up, eager attention (the one used) |
| 6hqxh39p | RL after SFT warm-up |
| 3fjg9zef | OPD warm-up, Qwen2.5-7B-Instruct teacher |
| c0uc1sfx | RL after OPD warm-up (bf16, sequence_mask) |
| 6oyljev3 | RL after OPD, token_truncate |
| 46in4jdn | RL after OPD, token_truncate, vLLM at 50% memory |
| i7yh9cby | RL after OPD, fp32 end to end |
| blyv9d73 | fp32, single GPU |
| cpgrs28e | fp32, single GPU, vLLM at 25% memory |