Skip to content

About

GRPO from scratch in JAX on one 8GB GPU: teaching a 0.5B code model to write Python as if it were Lisp. No trl, no peft, no vllm.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

rl-lisp-python

RL teaching a small code model to write Python as if it were Lisp, on one 8GB consumer GPU, with nothing borrowed.

GRPO plus decoupled clipping on Qwen2.5-Coder-0.5B over 104 LeetCode-easy tasks. The model is rewarded for solving the task and then, only if it solved it, for expressing the solution as a single return of one deeply nested expression.

Hand-written here, not imported: the Qwen2 forward pass in JAX, its KV cache, the LoRA adapters, the GRPO loss, the rollout sampler, the code sandbox, and the reward. No trl, no peft, no vllm, no RL framework. torch appears once, in validate_forward.py, to prove the hand-written forward matches HF's reference (it does, to 7.8e-5).

What I did

  • Reward: correctness is a hard gate (wrong is 0, no partial credit), then style is a bonus of equal weight on top. Style is static AST analysis: one-expression body, nesting depth, scalar answers wrapped as single-element lists, minus a penalty for extra lines and tabs. Depth counts comprehension generators and nested function application, because both are the same idea in Lisp.
  • Algorithm: plain GRPO (group mean and std as the baseline, no critic) with DAPO's decoupled clipping, eps_low=0.2 / eps_high=0.28. No KL term and no reference model.
  • Fit on the card: LoRA rank 16 (8.8M trainable), fp16 base, per-layer gradient checkpointing, and a vocab projection chunked over positions because one full logit tensor for a batch of 64 is 13GB.
  • Sandbox: every candidate runs in a subprocess with rlimits, an import whitelist and a per-case 0.5s timer, because a 0.5B writes while True constantly and an in-process exec cannot be interrupted.
  • Logged everything: groups that are all-correct produce no gradient, but they are rejection sampling for free, so they land in an SFT pool deduplicated by normalized code. One 600-step run left 679 unique solutions behind.

Results

600 steps, 4.5 hours on an RTX 2070 Super. Measured on 40 of the 104 tasks, 320 samples, same grader before and after. Note that those 40 tasks were also trained on; only the input instances are freshly seeded. There is no held-out task split, so these numbers measure improvement on the training distribution, not generalization to unseen tasks:

before after
pass rate 0.453 0.700
style (of correct samples) 0.346 0.535
mean reward 0.540 0.886
tasks solved at least once 34 30

Both objectives improved. Two things went the other way, and both are the interesting part.

It learned to game the style reward. Pass rate is flat from step 60 onward, but nesting depth keeps climbing the whole run, 2.12 to 3.28 among correct samples, with calls per answer 1.75 to 2.50 and the pure component dead flat. The model did not learn to write expressions, it could already do that. It learned to nest them deeper, and after the first ~120 steps the extra nesting bought style points and zero correctness.

It lost coverage while gaining average. Entropy collapsed from 0.138 to 0.035 and four tasks went from "solved at least once in 8 samples" to never solved. The average improved by becoming reliable on what it already knew, at the cost of the tail.

And plain GRPO wasted most of the compute. 193 of 600 steps had every group all-correct or all-wrong, so a full batch of 64 completions produced exactly zero gradient. That is the measured argument for DAPO's dynamic sampling.

Examples

What it was paid to do, working:

# base      reward 1.000  style 0.000  depth 0
def gcd(a: int, b: int) -> int:
    while b:
        a, b = b, a % b
    return a

# trained   reward 1.230  style 0.460  depth 1   pass 0.50 -> 1.00
def gcd(a: int, b: int) -> int:
    return a if not b else gcd(a, b - a)

Gaming the reward, still correct. .strip() cannot change a vowel count; it is free depth:

# base      reward 1.260  style 0.520  depth 2
def count_vowels(s: str) -> int:
    return len([char for char in s if char in 'aeiouAEIOU'])

# trained   reward 1.320  style 0.640  depth 4
def count_vowels(s: str) -> int:
    return sum(1 for char in s.strip().lower() if char in 'aeiou')

Gaming the reward and breaking it. It wrapped a generator in list() for one more depth point, scored higher style than the base model's correct answer, and got the semantics wrong:

# base      reward 1.371  style 0.743  depth 2  CORRECT
def flatten_one_level(nested: list) -> list:
    return [item for sublist in nested for item in sublist]

# trained   reward 0.000  style 0.829  depth 3  WRONG
def flatten_one_level(nested: list) -> list:
    return list(item for item in nested if not isinstance(item, list))

Use it

uv venv && uv pip install --python .venv/bin/python -r requirements.txt
HF_HUB_CACHE=hf_cache ./.venv/bin/python -c "
from huggingface_hub import snapshot_download
snapshot_download('Qwen/Qwen2.5-Coder-0.5B-Instruct')"
# tests: 22, CPU only, no weights needed, ~20s
JAX_PLATFORMS=cpu ./.venv/bin/python -m pytest tests/test_rl.py -q

# baseline: what the untrained model scores, on the grader the reward uses
N_TASKS=40 ./.venv/bin/python run_probe.py 0.5B

# train. defaults reproduce the run above; ~30s/step
STEPS=600 ./.venv/bin/python train_grpo.py

# ask the trained model for code, next to the base model, with rewards
LORA=runs/<run>/checkpoints/lora_step00600.npz ./.venv/bin/python query.py --task gcd

# score the adapter over the whole probe set
LORA=runs/<run>/checkpoints/lora_step00600.npz N_TASKS=40 ./.venv/bin/python run_probe.py 0.5B

scripts/run_experiment.sh chains baseline, training and the final probe unattended, and scripts/report_samples.sh <pid> regenerates SAMPLES.md when it finishes. Stop a run with pkill -f train_grpo.py; it checkpoints every 25 steps and saves on interrupt.

Layout

qwen2_jax.py          Qwen2 forward, KV cache, LoRA hooks, remat. Validated against HF
train_grpo.py         the training loop
run_probe.py          baseline / adapter scoring with the training grader
query.py              ask the model for code, base vs adapter, with rewards
rl/grpo.py            loss, advantages, chunked logprobs
rl/reward.py          gated reward and the AST style scorer
rl/generate.py        KV-cache sampler, left-padded prompt buckets
rl/sandbox.py         subprocess driver for candidate code (+ _sandbox_worker.py)
rl/lora.py            adapter init, save, load, merge
rl/compare.py         answer equality, dependency-free
rl/taskset.py         tasks to prompts and eval cases
rl/datalog.py         run logs, SFT pool, hard pool
tasks/lc_easy_tasks.py  104 tasks: spec, signature, reference, input generator
tests/test_rl.py      22 tests, CPU only

NOTES.md has the reward and algorithm in full, the config table, what a run writes to disk, how to read the metrics, the 8GB memory constraints, and known limitations. SAMPLES.md has the full before/after sample set.

About

GRPO from scratch in JAX on one 8GB GPU: teaching a 0.5B code model to write Python as if it were Lisp. No trl, no peft, no vllm.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages