Skip to content

Demo runs + performance improvements - #343

Closed
droot wants to merge 19 commits into
openrl-dev:mainfrom
droot:feat/cold-start-measure
Closed

droot wants to merge 19 commits into
openrl-dev:mainfrom
droot:feat/cold-start-measure

Conversation

@droot

@droot droot commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Faster cold starts, safe weight updates on small GPUs, and a seat limit for shared claims

This branch comes out of two weeks of measuring OpenRL under load on GKE: first one
job at a time, then 12 and 24 jobs at once on a mixed H100 and L4 fleet. Each change
below fixes one thing the measurements pointed at. The write-up with every drill is in
runs/2026-10-17_demo_baselines/notes.md; the cold-start study is in
runs/2026-10-15_cold_start/REPORT.md.

What a cold job paid before, and what it pays now

One job alone, from the client's start to the end of its first step. H100 unless noted.

Phase Before After Change
Worker image pull on a node without the tag 92-169 s 0 s pre-pull DaemonSet
Trainer weights load, Qwen3-8B, cold Filestore 903 s 13 s warm, 71 s cold parallel prefetch before from_pretrained
vLLM torch.compile, gemma-4-e2b 100 s 1 s compile cache on the shared volume
CUDA graph capture, gemma-4-e2b 53 s 22-30 s power-of-two capture sizes
Sampler engine init, gemma-4-e2b 199 s 57 s the two rows above
Training per step, Qwen3-0.6B, 64 sequences 20 s 4 s automatic token budget

What 12 jobs at once got, same fleet, same jobs

Jobs that finished 3 steps within 180 s of kickoff. Six H100s; the L4s hurt every mix and
are out of these rows.

Drill Setting Result
3 binpack as on main 3 of 12
4 binpack with a seat limit of 2 7 of 12
7 the same, with the LoRA runtimes already up 8 of 12

The changes

Trainer and sampler start

  • training/prefetch.py: reads a checkpoint with several threads and large blocks
    before from_pretrained. The mmap path read 260 KiB at a time with two reads in
    flight and was latency bound on NFS. OPEN_RL_PREFETCH=auto|always|off.
  • The trainer picks its token budget from the model's vocabulary and the free device
    memory. The cluster never set OPEN_RL_TRAIN_TOKEN_BUDGET, so it ran one sequence
    per pass. A fixed 65536 ran gemma out of memory: 262k vocabulary, 22 GiB of logits.
  • Ready records: the trainer's and sampler's ready keys carry start, load, and engine
    timestamps, and openrl_status shows them.

Sampler start on every node

  • k8s/deploy/base/worker-prepull.yaml: a DaemonSet that pulls the worker image on
    every enabled node at deploy time. Workers default to imagePullPolicy: IfNotPresent.
  • vLLM's compile cache lives on the shared volume (OPEN_RL_VLLM_CACHE_DIR, bundle
    default) or on a hostPath (OPEN_RL_VLLM_CACHE_HOSTPATH). The first sampler of a
    model on a GPU type compiles; every later one loads the artifact from any node. The
    cache key covers model, GPU type, and engine config, so LoRA and FFT samplers warm
    separately.
  • CUDA graphs are captured for power-of-two batch sizes up to max_num_seqs
    (VLLM_CUDAGRAPH_CAPTURE_SIZES, default restores vLLM's list).

Weight updates on small devices

  • A gemma-4-e2b FFT sampler died at its first weight update on an L4. vLLM's patch
    loader stages each patched tensor densely, then builds a mask and a merged copy: 2.5x
    the tensor, 11 GiB for gemma's per-layer embedding, while vLLM's share left 2 GiB.
  • The sampler now wakes only the weights when it takes the GPU, applies the update, then
    wakes the KV cache (vLLM's documented order for RL updates). The headroom it keeps is
    2.5x the largest checkpoint tensor plus a margin, read from the safetensors headers,
    never below what the resident weights need (OPEN_RL_WEIGHT_UPDATE_HEADROOM_GIB
    overrides).
  • The estimator adds the same room to the FFT sampler's placement claim. gemma FFT's
    claim grew from 11.7 to 23.6 GiB, so it no longer fits a 24 GB device and lands on the
    80 GB tier by its own size.

Scheduler

  • --max-seats-per-claim (OPEN_RL_MAX_SEATS_PER_CLAIM, default 0 for no limit). A
    shared claim at the limit is not a candidate; the ledger's CAS re-checks it; a worker
    that finds every claim full cuts its own and waits Pending until a GPU is added or a
    seat frees. Before this, six FFT samplers time-sliced one H100 while other GPUs idled.
  • kubectl get workloads shows the status reason, which names the seat limit while a
    worker waits.

Demo and drill tooling (scripts/)

  • demo_jobs.py renders N in-cluster client Jobs from kind:model:count specs, with a
    per-client Hugging Face cache seeded from the shared one (twelve clients locking one
    NFS directory lost a client to a stale file handle) and optional session tags.
  • demo_report.py, demo_watch.py, demo_rehearsal.sh: per-job and per-step timings
    from metrics.jsonl, a one-screen live view, and a one-command rehearsal with an
    idle check, volume trim, and an optional warm-up of the LoRA runtimes.
  • cold_start_*.py|sh: the instrumented single-job measurement used for the report.
  • cell_up.sh, cell_seed_hf.sh, cell_down.sh: an OpenRL cell in any GCP region.

What the drills still say

  • A shared LoRA runtime started cold needs about 165 s for its first step with three
    jobs waiting. Started warm, the same three jobs finish three steps in about 90 s.
  • Six H100s hold two LoRA runtimes on four GPUs and leave one pair for every FFT job.
    The FFT half queues. More large GPUs, not more policy, is what moves it.
  • With small devices in the pool, binpack places by fit, and a gemma runtime that fits an
    L4 goes there and runs three times slower. This branch does not change that.

Tests

make test (260 unit tests), make -C scheduler/controller fmt-check vet manifests-check test, and lint on every tracked file. New tests cover the prefetch,
the token budget, the ready records, the capture sizes, the headroom and its floor, the
wake order, the estimator's update room, the seat limit in selection and in the ledger
CAS, and the demo tooling's defaults.

droot added 19 commits October 17, 2026 07:48
…them

transformers' from_pretrained memory-maps safetensors and faults them in a
page at a time. On NFS that is one ~260 KiB read in flight at ~28 ms each:
Qwen3-8B took 903 s to load in the trainer on Filestore Basic HDD, while
vLLM 0.30 loaded the same files from the same instance in 102 s because it
prefetches them into the page cache with eight threads and 16 MiB reads
(runs/2026-10-15_cold_start). training.prefetch does the same before every
trainer load; OPEN_RL_PREFETCH=auto|always|off and the thread and block
sizes are environment knobs.

The ready keys now carry a phase record (design 016, A6): process start,
prefetch result, load start and end for trainers; engine build start and
end for samplers. The model view shows it as ready_at and phases.
…er prefetch

Measurement tooling: scripts/cold_start_client.py stamps the demo's client
phases; cold_start_timeline.py records Workloads, pods, events, worker logs,
the Redis ready keys and the API server log and joins them into a timeline;
cold_start_case.sh runs one case; cold_start_fio.sh, cold_start_monitoring.py
and cold_start_resident.py check the storage from the node, Cloud Monitoring
and the page cache. runs/2026-10-15_cold_start holds the records and REPORT.md.

Headline: the 8B trainer load took 903 s on a cold Filestore because
from_pretrained reads 260 KiB at a time with two in flight; vLLM read the same
files in 102 s. Latency-bound, not bandwidth-bound. With the prefetch the
trainer loads in 12-13.5 s on a warm server cache (39.9 s before) and reads
cold disk at 115 MiB/s (10.8-17 before).
…device memory

The cluster deployments never set OPEN_RL_TRAIN_TOKEN_BUDGET, so every
forward_backward ran one sequence per pass: 64 passes for a 64-rollout GRPO
step, 20 s of training per step for a 0.6B model on an H100. One fleet-wide
number does not work either: 65536 tokens of gemma-4-e2b logits (262k
vocabulary) is 22 GiB and OOMs an H100. The trainer now computes the budget
from the model's vocab_size and the free memory on its device, and caps an
explicit setting the same way. Measured: 0.6B training 20 s -> 4 s, gemma
31-35 s -> 8-10 s per step on H100 (runs/2026-10-17_demo_baselines).
…cache on the node

A worker pull is 92 s on an H100 node and 169 s on an L4 node, paid twice by
a cold job (runs/2026-10-15_cold_start). The open-rl-worker-prepull DaemonSet
in the base bundle pulls the image at deploy time on every openrl.io/enabled
node, and workers now default to imagePullPolicy IfNotPresent so the pulled
image is what runs.

vLLM compiles the model on every sampler start, 28-37 s on an H100 and 122 s
on an L4 for gemma-4-e2b, and kept the result in the container. Samplers now
mount a hostPath (/var/lib/openrl/vllm-cache, OPEN_RL_VLLM_CACHE_HOSTPATH)
as VLLM_CACHE_ROOT, so the second sampler of a model on a node skips it.
Measured on open-rl-dra: a sampler of a model already compiled on any node
loads the AOT artifact in 1.1 s instead of compiling for 56-67 s, and its
engine initializes in 20 s instead of 88-106 s. The cache is 61 MB per model
and GPU type. The per-node hostPath remains for installs without a shared
volume (unset OPEN_RL_VLLM_CACHE_DIR).
vLLM's default list captured dozens of sizes on every sampler start, 12-40 s
of it (runs/2026-10-17_demo_baselines). GRPO sends a few shapes that pad to
the next power of two; VLLM_CUDAGRAPH_CAPTURE_SIZES overrides the list or
keeps vLLM's with "default". The deployment can now also forward
VLLM_ENABLE_V1_MULTIPROCESSING to try the in-process engine.
An FFT sampler applies each sparse delta through vLLM's checkpoint patch
loader, which stages a dense copy of every patched checkpoint tensor on
the device. gemma-4-e2b's per-layer input embedding is 4.38 GiB of that,
and vLLM's default share leaves 2.2 GiB free on an L4: the sampler died at
its first update (runs/2026-10-17_demo_baselines, solo gemma probe).

The sampler now reads the largest tensor from the checkpoint's safetensors
headers and takes that much, plus a margin, off vLLM's share of the
device, never below a floor; OPEN_RL_WEIGHT_UPDATE_HEADROOM_GIB overrides
it. The estimator adds the same room to the FFT sampler's placement claim
from a per-model table, so the claim and the runtime agree. LoRA samplers
never update base weights and are unchanged.
Twelve clients taking filelock locks in one cache directory on NFS lost a
client to a stale file handle (drill 2). An init container now seeds an
emptyDir per client with the dataset and the model's tokenizer and config
files, skipping weight files; 38 MB for gemma-4-e2b. Also clears two lint
findings in demo_report.py.
…te's room at its peak

vLLM's patch loader stages each patched checkpoint tensor densely, then its
NaN-masked copy_ builds a bool mask and the merged result: 2.5x the tensor,
all live at once. For gemma-4-e2b's per-layer embedding that is 11 GiB.
Measured on an L4 (runs/2026-10-17_demo_baselines): 1x headroom still died
at the 2.19 GiB mask; the update went through with 10.7 GiB free.

The sampler now wakes only the weights when it takes the GPU and wakes the
KV cache after ensure_weights, vLLM's documented order for RL updates, so
the parked KV cache is not in the way. The runtime headroom is 2.5x the
largest tensor plus a margin, and vLLM's share never drops below what the
resident weights need. The estimator's claim uses the same factor: a gemma
FFT sampler no longer fits a 24 GB device and goes to the 80 GB tier by its
own size.
Host memory bounds how many workers park on a node, but says nothing about
time: six FFT samplers on one H100 ran each step six times slower while
other GPUs sat idle (runs/2026-10-17_demo_baselines, drill 3).

--max-seats-per-claim (OPEN_RL_MAX_SEATS_PER_CLAIM, default 0 for no
limit) is a hard policy limit. A ledger at the limit is not a selection
candidate, and the ledger CAS loop refuses a join past it so two joiners
on one snapshot cannot both take the last seat. A worker that finds every
eligible ledger full cuts its own claim; with no free device it stays
Pending, the reason names the limit, and it waits until a node arrives or
a seat frees. Under binpack this fills one claim to the limit and moves
to the next free device.
--lora-tags and --fft-tags set TINKER_TAGS on the client, e.g.
openrl.sampler_replicas=2, so a drill can give the shared LoRA runtimes a
second sampler without touching the recipe.
A waiting worker's reason, for example the seat limit, was only visible
in describe. It is the column to watch during a drill.
cell_up.sh brings up an OpenRL cell (cluster, Spot GPU pool, drivers,
bundle) in a chosen region, reports the pool's quota or stockout errors
instead of hanging, and leaves the caller's kubectl context alone;
cell_seed_hf.sh copies the model cache in through a bucket; cell_down.sh
removes the cell. demo_rehearsal.sh checks the fleet is idle, trims the
shared volume, optionally warms the LoRA runtimes, kicks off the job set
and watches.
… notes

The cold-start archive kept 243 raw files (pod logs, event dumps, fio
output) next to its report. The reports and timelines stay; the raw files
go, they are reproducible with the scripts. Adds the fleet demo notes
(drills 1 to 7 with the scorecard and what each change did) and the fleet
request with the quota findings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant