Skip to content

Add PiSSA initialization for Megatron LoRA adapters - #1

Closed
kalectory wants to merge 11 commits into
pissa-basefrom
neil/pissa
Closed

Add PiSSA initialization for Megatron LoRA adapters#1
kalectory wants to merge 11 commits into
pissa-basefrom
neil/pissa

Conversation

@kalectory

@kalectory kalectory commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Following Meng et al., we implement PiSSA, a reinitialization for low-rank adapters using singular value decomposition of the weight matrices.

  • Add exact PiSSA initialization for dense and per-expert grouped-MoE Megatron LoRA adapters, including TP, EP, and ETP layouts.
  • Add a dedicated offline producer that writes the residual Hugging Face model and a standard step-zero adapter/optimizer checkpoint for normal resume.
  • Match the dense and Qwen3-30B-A3B PiSSA launchers to their existing Megatron LoRA examples apart from loading the produced artifacts.

Follow-ups

  • Evaluate shared-expert MoE PiSSA as a separate algorithm variant.
  • Support LoRA/PiSSA targeting for Qwen3.5/3.6 GDN projections.
  • Add an FSDP PiSSA artifact producer.
  • Keep adapters in FP32 to reduce numerical error.

Testing

Numerical Error in PiSSA Initialization

We test the logprob numerical error in PiSSA initialization and find it to be on the same scale as LoRA initialization.

image

Training Qwen-3-0.6B with LoRA/PiSSA Results

We also test the algorithm via the SkyRL example provided in this PR, against the correspondign SkyRL example for Megatron LoRA on Qwen-3-0.6B.

image
uvx ruff check skyrl/backends/skyrl_train/workers/megatron/pissa_init_worker.py skyrl/train/entrypoints/pissa_init.py tests/backends/skyrl_train/gpu/gpu_ci/megatron/test_pissa_init_worker.py
bash -n examples/train/pissa/*.sh

Focused lint, compilation, shell syntax, and grouped-sharding tests pass locally. Dense and MoE B300 validation is in progress.

Comment thread tests/utils/test_pissa.py Outdated

@j316chuck j316chuck left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this!

Might be worth sharing some of the exciting numbers in the PR description as well alongside updating the README of what code paths is supported by PISSA (Megatron, Single-LORA, expermental, dense-only).

Comment thread tests/utils/test_pissa.py Outdated
Comment thread tests/utils/test_pissa.py Outdated
Comment thread tests/utils/test_pissa.py Outdated
Comment thread tests/utils/test_pissa.py Outdated
Comment thread tests/utils/test_pissa.py Outdated
Comment thread skyrl/tinker/extra/skyrl_train_inference_forwarding.py Outdated
Comment thread skyrl/backends/skyrl_train_backend.py Outdated
Comment thread skyrl/utils/pissa.py Outdated
Comment thread skyrl/backends/skyrl_train/workers/megatron/pissa_init.py Outdated
Comment thread skyrl/backends/skyrl_train/workers/megatron/pissa_init.py Outdated
avigyabb and others added 11 commits August 13, 2026 18:42
…SkyRL's lock in E2E CI (NovaSky-AI#2022)

## What does this PR do?

Fixes the nightly `gsm8k_tinker` / `gsm8k_tinker_fully_async` failures
that started 2026-08-12:

```
ValueError: Server returned a JSON payload for self.model_cls=<class 'tinker.SampleResponse'> ...,
which this SDK version only supports as proto.
```

**tinker 0.25.0 (released 2026-08-08) made its proto wire format
mandatory**: it submits `forward_backward` request bodies as protobuf
and rejects JSON `retrieve_future` results for
`SampleResponse`/`ForwardBackwardOutput`. The SkyRL tinker server only
speaks JSON today. The nightlies install the SDK unpinned into
tinker-cookbook's own environment (which ships no lockfile), so every
run floated to 0.25.0 and died at the first sample retrieval.

Per review feedback, the version constraint lives in `pyproject.toml`
rather than hardcoded in the CI scripts. Two commits:

1. **Cap the tinker SDK in the `tinker` extra** at `<=0.24.1` (the
newest release that negotiates down to JSON: it sends `Accept:
application/x-protobuf, application/json`, falls back to JSON responses,
and its proto request path stays off because it is gated by a
server-supplied `client_config` flag our server does not enable). The
lock keeps main's tinker 0.24.0, which satisfies the cap; only the
recorded requirement specifier changes. (Bumping the locked version to
0.24.1 requires a resolver run, which is currently blocked on main by an
unrelated py3.14 megatron-split conflict between `vllm 0.26.0+cu129` and
`mamba-ssm` over `apache-tvm-ffi` -- any full `uv lock` fails on main
today. `uv lock --check` validates the updated lock.)

2. **Make the nightlies resolve the SDK from SkyRL's lock.** The scripts
previously ran the cookbook client from `~/tinker-cookbook`'s own
project with an unpinned `--with tinker`, so SkyRL's `pyproject.toml`
never reached that environment. They now run the client from SkyRL's
project, overlaying the cookbook checkout with `--with-editable
"$COOKBOOK_DIR[math-rl,wandb]"`: the cookbook's `tinker>=0.9.0`
requirement is already satisfied by the base environment's locked SDK,
so uv does not fetch a newer one. The SDK version now has a single
source of truth and moves only when `pyproject.toml`/`uv.lock` move.

Once server-side proto support (NovaSky-AI#2021) lands, the cap can be lifted and
the nightlies will follow the lock to newer SDKs automatically.

## Testing

- Resolution verified locally at the pinned cookbook commit: the old
invocation (`cd ~/tinker-cookbook && uv run --extra math-rl --with
tinker ...`) resolves tinker 0.25.0; the new invocation resolves the
locked 0.24.0 from SkyRL's lock, and the `math_rl.train` recipe plus
wandb/datasets/torch all import cleanly in that environment.
- `tests/tinker/` CPU suite (68 tests on the rebased branch, including
the `test_api.py` integration tests that run the real SDK against a real
server subprocess) passes with the locked SDK.
- SDK 0.24.x's compatibility with the current JSON-only server was
verified manually with a full `forward_backward` -> `forward` ->
`optim_step` -> `save_weights_for_sampler` -> `sample` loop (JAX
backend); the same loop on 0.25.0 reproduces the nightly failure
exactly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add custom wheels for aarch64/GB200.

<img width="855" height="172" alt="image"
src="https://github.com/user-attachments/assets/01e4e01b-de00-48e4-95c9-f272310ef49b"
/>

Tested gsm8k runs for both megatron and fsdp backends.
…rom Astral (NovaSky-AI#2039)

# Upgrade Transformer Engine to 2.16 and source flash-attn + TE from
Astral

Follow-up to NovaSky-AI#2023. That PR moved `causal-conv1d` and `mamba-ssm` to
Astral's GPU
wheel index but had to leave `flash-attn` and `transformer-engine-torch`
on
hand-built fork wheels, because neither was usable with the Transformer
Engine
2.11.0 we pinned. This upgrades the TE stack to 2.16 and moves both to
Astral, so
**no fork-built wheels remain** — on x86_64 or aarch64.

## Why TE 2.16 is the unlock

Astral stamps every wheel with the (CUDA, torch) pair it was built
against as a
PEP 440 local version segment, e.g. `+cu.12.8.torch.2.11`. TE 2.11.0
could not
tolerate that, in two independent ways:

| Package | Why it failed on TE 2.11 | Why TE 2.16 fixes it |
| --- | --- | --- |
| `flash-attn` | PEP 440 makes `2.8.3+local > 2.8.3`, so TE's
`get_attention_backend` gate (`max_version="2.8.3"`) rejected the wheel
and **silently** fell back to the broken unfused attention path | TE
strips the local segment before comparing (`PkgVersion(...).public`),
added in 2.16 |
| `transformer-engine-torch` | TE's meta package asserts **raw string
equality** across `transformer-engine` / `-cu12` / `-torch` versions,
which Astral's differing local segments break | Astral's meta wheel
patches that check to compare base versions — a patch they ship only at
2.16 |

The second point is why the TE trio has to move to Astral **as a set**:
the
patch lives in Astral's meta wheel, not upstream's. Mixing Astral's
core/torch
wheels with the PyPI meta package fails the assert at import.

Verified in the built environment:

```
transformer-engine         2.16.0
transformer-engine-cu12    2.16.0+cu.12.8
transformer-engine-torch   2.16.0+cu.12.8.torch.2.11
flash-attn                 2.8.3+cu.12.8.torch.2.11
fa.version=2.8.3  max_version=2.8.3  is_installed=True
```

`is_installed=True` is the key line — that is TE accepting flash-attn
rather than
silently disabling it. There are also zero `"Supported flash-attn
versions are ..."`
warnings (TE's rejection message) anywhere in the megatron suite output.

## Also in this PR

- **Dead build config removed.** TE and flash-attn now always install as
prebuilt
  wheels and are never compiled, so their `no-build-isolation-package`,
`extra-build-dependencies`, and `extra-build-variables` entries are
dead.
Confirmed inert: removing them leaves `uv.lock` byte-identical, and both
  packages resolve to 8 wheels / 0 sdists.
- **`transformer-engine-cu13` override no longer needed.** Astral's 2.16
meta
package resolves its `core` extra to cu12 rather than cu13, so cu13
never enters
  the graph — it drops from 2 lock references to 0.
- **aarch64 fork wheels retired.** NovaSky-AI#1936 added hand-built aarch64
artifacts for
flash-attn and transformer-engine-torch for GB200. Astral publishes
cp310–cp314
for both x86_64 and aarch64, so a single `sys_platform == 'linux'`
marker covers
both arches and those artifacts are no longer referenced. **See the
caveat below.**

## Resolution delta

Diffing resolved package versions against `main` — exactly 5 change,
nothing else
moves:

```
flash-attn                 2.8.3    -> 2.8.3+cu.12.8.torch.2.11
transformer-engine         2.11.0   -> 2.16.0
transformer-engine-cu12    2.11.0   -> 2.16.0+cu.12.8
transformer-engine-torch   2.11.0   -> 2.16.0+cu.12.8.torch.2.11
nvdlfw-inspect             (new)    -> 0.2.2      # new TE 2.16 dependency
```

## Testing

All suites run on 4×H100:

| Suite | Result | Time |
| --- | --- | --- |
| `-m megatron` | 130 passed, 2 skipped | 2:01 |
| `-m megatron_models` | 3 passed, 4 skipped | 0:12 |
| H100 CI (megatron half) | 5 passed | 0:25 |
| H100 CI (fsdp half) | 2 passed | 0:11 |

All skips are pre-existing static markers (`h100`-gated tests, and the
existing
`qwen3.5-moe` correctness skip), not anything this change introduces.

Two things checked that turned out **not** to be regressions:
megatron-core's
`_SplitAlongDim` and `get_workspace` imports fail under TE 2.16 — but
they fail
identically under 2.11, so the `torch.split` fallbacks were already in
use before
this PR.

## Caveat for reviewers

The aarch64/GB200 path is **untested**. This PR replaces the sm_100
aarch64 fork
wheels from NovaSky-AI#1936 with Astral's aarch64 builds, and I could only test
x86_64/H100.
I have not confirmed Astral's aarch64 wheels include sm_100 support. If
you'd
rather not take that on here, the aarch64 fork entries can be kept and
this PR
scoped to x86_64 — say so and I'll split it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ovaSky-AI#2017)

## Problem

`retrieve_future` returns `result_data`, which for sample requests is a
large numeric blob — top-k logprobs for every prompt token run to a few
MB. On the way from the engine to the caller, that blob was converted
between Python objects and JSON text three extra times:

1. **Engine write** — `result.model_dump()` builds a `dict` of millions
of floats, which `json.dumps` then walks again to produce the text
SQLAlchemy stores.
2. **API read** — SQLAlchemy's JSON column calls `json.loads`,
re-materializing every one of those floats as a Python object.
3. **API response** — FastAPI walks the whole structure with
`jsonable_encoder`, then `json.dumps` it back to text.

None of that work is used: the payload comes out of Pydantic and goes to
the client unchanged. It's a few hundred ms of event-loop time per call,
and because it's synchronous CPU work in the event loop, every other
tenant queues behind it.

## Root cause

Every one of those conversions is imposed by the column type.
SQLAlchemy's `JSON` decodes on read and encodes on write by contract, so
the application cannot opt out while `result_data` is declared as JSON.
The fix is to stop declaring it as JSON.

## Changes

- **`FutureDB.result_data` is now a `TEXT` column** holding
pre-serialized JSON.
- **Writers hand it a typed model.** The engine and both
sample-forwarding clients serialize with `model_dump_json()`, which goes
from model to JSON text inside pydantic-core without materializing the
intermediate dict. The clients' failure paths build a
`types.ErrorResponse` instead of an equivalent hand-rolled dict, so
every write to the column goes through a typed model. Serialization sits
at the single DB-write site in each client, which also means a payload
for a row deleted mid-flight is never encoded at all.
- **`retrieve_future` returns the stored text directly** in a
`Response`, bypassing `jsonable_encoder` and a second `json.dumps`. The
failure path still decodes, since it reports the error field — but as a
`types.ErrorResponse` rather than by poking at a dict, which also turns
malformed JSON there into the intended 500 instead of an unhandled
`JSONDecodeError`.

## Performance

Measured on a 2.4 MB result (4 sequences × 512 tokens, 4096 prompt
tokens with top-20 prompt logprobs), best of 5:

| | write | read + respond | total |
|---|---|---|---|
| before | 61.7 ms | 260.7 ms | **322.4 ms** |
| after | 7.4 ms | 0.0 ms | **7.4 ms** |

Worth noting for anyone who would reach for a faster JSON library first:
`jsonable_encoder` alone was 168 ms of that 260 ms read path, and no
encoder swap touches it. Dropping in orjson while keeping the JSON
column lands at ~188 ms total. The win comes from not walking the
payload at all.

## Compatibility

Existing SQLite databases are unaffected — SQLAlchemy stores JSON
columns as TEXT there. A Postgres deployment predating this change would
need the column altered. There are no Alembic revisions in the tree
today; the schema comes from `SQLModel.metadata.create_all` at startup.

One narrow behavior change: a `FAILED` row whose payload has an `error`
but no `status` now returns 500 rather than 400. No writer produces that
shape.

## Testing

`tests/tinker/` — 48 passed / 19 skipped; `tests/tinker/test_api.py`
integration — 17 passed. The tests in `test_future_waiting.py` now store
and assert on real result types instead of ad-hoc dicts, and the
`retrieve_future` assertion is byte-exact so it actually catches a
re-encode rather than passing on any equivalent JSON.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes the core async result path and DB column type (Postgres may
need migration); behavior is low-risk for SQLite and correctness is
covered by updated tests, but mis-serialized rows could break clients.
> 
> **Overview**
> Large sample results (e.g. multi‑MB prompt logprobs) no longer
round-trip through Python dicts and FastAPI’s JSON encoder on every
`retrieve_future` call.
> 
> **`FutureDB.result_data`** is now a `TEXT` column holding
pre-serialized JSON. The engine and inference forwarding clients write
with `model_dump_json()`; failed paths use `types.ErrorResponse` instead
of ad-hoc dicts.
> 
> **`retrieve_future`** returns completed payloads via
`raw_json_response()` so the stored bytes go straight to the client.
`wait_for_future` / `poll_futures` keep `result_data` as JSON text
end-to-end. Failed futures still decode with
`ErrorResponse.model_validate_json` for 400 vs 500 handling.
> 
> Tests assert real output types and byte-exact response bodies so a
accidental re-encode would fail.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1f1d77f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Expose SkyRL's existing native GSPO implementation through the
Tinker-compatible API.
- Accept Tinker-style clipping thresholds and normalize them to SkyRL's
epsilon configuration.

## Future Work

- This path will fail on Jax, similar to the existing exposed DPPO and
PPO_CRITIC paths. We generalize the PPO_CRITIC check to fail loudly on
those paths too for now.

## Testing

<img width="1870" height="854" alt="image"
src="https://github.com/user-attachments/assets/9f0a526d-c0b6-4aaa-911d-4e5502429c1b"
/>

```
uv run --extra dev pre-commit run --files skyrl/backends/skyrl_train_backend.py skyrl/tinker/api.py skyrl/tinker/types.py
```

Verified formatting, lint, and secret checks on all changed files.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Changes are API surface and backend routing for a new loss_fn name;
JAX fails fast on unsupported losses. No auth or data-path changes;
training behavior on SkyRL-Train depends on existing native GSPO
implementation.
> 
> **Overview**
> Adds **`gspo`** as a Tinker-compatible `loss_fn` on forward/backward,
alongside existing PPO-style **`clip_low_threshold`** /
**`clip_high_threshold`** config validation in the API layer.
> 
> The SkyRL-Train backend now treats **`gspo`** like **`ppo`** for
threshold normalization (Tinker ratios → SkyRL epsilon fields) but maps
the internal loss name to **`gspo`** instead of **`regular`**.
> 
> On JAX, unsupported losses are rejected with a single check against
**`LOSS_TYPES`** (so **`gspo`**, **`dppo`**, and **`ppo_critic`** fail
loudly). Tests cover that behavior via **`JaxBackendImpl._model_pass`**.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efb4101. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Signed-off-by: Neil Kale <263453039+kalectory@users.noreply.github.com>
Co-authored-by: Neil Kale <263453039+kalectory@users.noreply.github.com>
…I#2050)

applying NVIDIA/TransformerEngine#3360 to TE
2.16

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes attention backend selection at Megatron init via `exec` on TE
internals; mitigated by SM guards, literal 2.16.0 source matching, and
idempotent no-ops on unaffected GPUs, but wrong TE versions or
arch-specific FA2 issues could still affect training memory or numerics.
> 
> **Overview**
> Backports **NVIDIA/TransformerEngine#3360** for the pinned
**transformer-engine 2.16.0** so Megatron training can select
**FlashAttention 2** for **head_dim 256** (e.g. Gemma 2/3) on GPUs
outside TE’s SM allowlist (notably **sm103** B300/GB300, also
sm86/sm89).
> 
> A runtime patch **recompiles and rebinds** TE’s
`get_attention_backend` to drop the erroneous `head_dim > 192` +
compute-capability gate, keeping only FA2’s real limits (`<= 256`, `% 8
== 0`). It **no-ops** on sm80/90/100/120, when TE isn’t importable, or
when TE source no longer matches 2.16.0, and clears TE’s memoized
`_attention_backends` after apply.
> 
> **Megatron only:** `patch_fa2_head_dim_allowlist()` runs in
`make_megatron_module()` before `provide_distributed_model()` (policy
and ref workers). A **verify script** and README document repro and
numerics vs unfused attention.
> 
> Remove this patch when the TE pin includes upstream #3360.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1d9d8c9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
… infra log on failure (NovaSky-AI#2051)

# Fix colocated tinker E2E nightly: vLLM startup OOM on L4

The `SkyRL-GPU-E2E-CI-Tinker` nightly has failed on every run since
2026-08-14 (the first nightly after the vLLM 0.26.0 bump in NovaSky-AI#1854) with:

```
RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}
```

raised from `VLLMServerActor.start()` at the first
`save_weights_for_sampler`. (Failures on 08-08..08-13 were the separate
unpinned tinker SDK break, fixed by NovaSky-AI#2022.)

## Root cause

Reproduced on a 1x L4 workspace with a scaled-down (1-GPU) version of
the CI backend config. The real error never reaches the job log (see
"Log visibility" below); it is a CUDA OOM during vLLM engine startup, at
the flashinfer sampler warmup that runs after CUDA graph capture:

```
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 594.00 MiB.
GPU 0 has a total capacity of 22.03 GiB of which 582.00 MiB is free.
Process 29529 has 796.00 MiB memory in use. ... this process has 20.67 GiB in use.
```

Two factors combine:

1. **Engine startup ordering.** The main trainer entrypoint starts
inference engines on empty GPUs before `build_models()`. The tinker
`SkyRLTrainBackend` does the reverse: FSDP workers initialize first, and
even after `offload_to_cpu()` each leaves ~800 MiB of unreclaimable CUDA
context on the GPU that vLLM later profiles on. `gpu_memory_utilization`
budgets a fraction of *total* (not free) memory, so vLLM still sizes its
KV cache as if it had the whole card.
2. **vLLM 0.26.0's larger startup peak.** FULL_AND_PIECEWISE cudagraph
capture plus the flashinfer sampler warmup transiently allocate ~3 GiB
beyond the utilization budget. At 0.8 on a 22 GiB L4 with the FSDP
context resident, this misses by ~12 MiB.

This explains why only this nightly regressed: the fully-async tinker
E2E is non-colocated (engines get empty GPUs), and the main colocated
E2E starts engines before building models.

## Changes

- `gsm8k_tinker.sh`: `gpu_memory_utilization` 0.8 → 0.7. Costs ~2 GiB of
KV cache, irrelevant for 512-token GSM8K rollouts.
- Both tinker E2E scripts: on any non-zero exit, the cleanup trap now
dumps the tails of `server.log` **and** the newest
`/tmp/skyrl-logs/infra-*.log`.

### Log visibility

`VLLMServerActor` calls `redirect_actor_output_to_file()`, which sends
actor output — including the vLLM engine's real traceback — to
`/tmp/skyrl-logs/infra-*.log` on the cluster. The scripts previously
only tailed `server.log`, and only when the server failed to boot, so a
client-visible failure like this one showed nothing but the opaque
re-raised `RayTaskError` (the empty `Failed core proc(s): {}` is a vLLM
race where the engine-core proc dies before its exit code is captured).

## Verification

On a 1x L4 with the 1-GPU equivalent of the CI backend config
(`colocate_all=true`, 1 engine):

- at `gpu_memory_utilization=0.8`,
`save_weights_and_get_sampling_client()` fails with the exact CI error
(OOM in flashinfer sampler warmup);
- at `0.7`, the same call succeeds: engine starts, weights sync, KV
cache wakes at 18.5/22 GiB.

## Note

Any tinker + `colocate_all` user on ~24 GB GPUs will hit this same OOM
at the default 0.8 utilization, since the lazy engine startup ordering
is inherent to the backend. A follow-up could account for the training
workers' CUDA context when sizing the KV budget, or start engines
eagerly once the LoRA config is known.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI-only shell script changes; no production trainer or auth paths
touched.
> 
> **Overview**
> Fixes the **colocated** tinker GSM8K nightly (`gsm8k_tinker.sh`) by
lowering vLLM **`gpu_memory_utilization` from 0.8 to 0.7**, with
comments explaining lazy engine startup after FSDP leaves CUDA context
and vLLM 0.26’s larger startup peak OOMing 22 GiB L4s. The fully-async
script keeps **0.8** (non-colocated engines).
> 
> **Both** tinker E2E scripts replace the simple EXIT trap with a
**`cleanup()`** that on any non-zero exit tails **`server.log`** and the
newest **`/tmp/skyrl-logs/infra-*.log`** (Ray/vLLM errors) before
tearing down the server process group—so CI logs show real
OOM/tracebacks instead of opaque `RayTaskError`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
77ff96c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ovaSky-AI#2021)

## What does this PR do?

Fixes the nightly tinker cookbook e2e failures that started 2026-08-12:

```
ValueError: Server returned a JSON payload for self.model_cls=<class 'tinker.SampleResponse'> ...,
which this SDK version only supports as proto.
```

The e2e scripts install the tinker SDK unpinned, and **tinker 0.25.0
(released 2026-08-08) made its proto wire paths mandatory**:

1. **Responses**: `retrieve_future` results for `sample` / `forward` /
`forward_backward` must be proto-serialized when the client sends
`Accept: application/x-protobuf`. Since 0.18.0 the SDK has sent `Accept:
application/x-protobuf, application/json` and deserialized whichever
format the server returned — JSON was a true client-side fallback, which
is why our JSON-only server worked. 0.25.0 removed that fallback for
these two response types. (SDKs <0.18.0 have no proto module at all and
speak pure JSON.)
2. **Requests**: `forward_backward` bodies are submitted as proto
(`Content-Type: application/x-protobuf`), and forward-only passes are
routed to `/api/v1/forward_backward` via the proto `forward_only` flag
instead of `/api/v1/forward`. Unlike the response path, this was never a
fallback arrangement before 0.25.0: the proto write path (present since
~0.22) was gated by the server-supplied `client_config` flag
`proto_write_fwdbwd` (default false), and our stub `client_config` never
opted in, so JSON requests were simply the default. 0.25.0 removed the
flag and made proto requests unconditional.

## Changes

- **`skyrl/tinker/proto_serialization.py`** (new): mirrors the SDK's
`tinker/proto/{request,response}_conv.py`. Serializers for
`SampleResponse` / `ForwardBackwardOutput` (NaN encodes undefined prompt
logprobs, `token_id=0 / logprob=-99999.0` sentinel fill for undefined
top-k entries, batched int64 byte offsets for per-datum tensors), plus a
parser for proto `ForwardBackwardRequest` bodies. The proto schema ships
inside the `tinker` package, which the `tinker` extra already depends on
-- no new dependency.
- **`skyrl/tinker/api.py`**: `retrieve_future` content-negotiates on the
`Accept` header (errors and all other result types stay JSON);
`forward_backward` accepts both wire formats and maps
`forward_only=true` to the `FORWARD` request type.
`poll_futures`/`wait_for_future` now carry `request_type` alongside
`(status, result_data)`.
- Old JSON paths (including `/api/v1/forward`) are unchanged, so SDKs
<=0.24.x keep working.

## Testing

- New `tests/tinker/test_proto_serialization.py` round-trips the server
encoding through the installed SDK's own proto codecs (12 tests), plus a
proto-path test in `test_future_waiting.py`.
- Manually verified a full loop (`forward_backward` -> `forward` ->
`optim_step` -> `save_weights_for_sampler` -> `sample`) against a local
JAX-backend server with **12 SDK versions spanning the supported range**
(`tinker>=0.3.0`) -- all pass with this change; before it, 0.25.0 failed
exactly like the nightly:

  | SDK versions | Wire behavior | Result |
  |---|---|---|
  | 0.3.0, 0.8.1, 0.13.1, 0.16.1 | pure JSON (no proto module) | ✅ |
| 0.18.2, 0.20.0, 0.21.0, 0.22.0, 0.22.4, 0.23.4, 0.24.1 | proto
responses (JSON-fallback), JSON requests | ✅ |
  | 0.25.0 | proto responses + proto requests, no fallback | ✅ |
- `tests/tinker/` CPU suite passes (`skyrl_train/` GPU-cluster tests
excluded -- they fail in my workspace with a pre-existing Ray version
mismatch unrelated to this change).

Notes for reviewers:
- SDK >=0.18.0 sends `Accept: application/x-protobuf, application/json`
on `retrieve_future` and understands proto responses, so the existing
`test_api.py` integration tests (locked SDK 0.22.4) now exercise the
proto response path end-to-end.
- The SDK's zstd request compression (`proto_compress_fwdbwd`) is
negotiated via `client_config`, which our server does not advertise, so
compressed bodies never arrive.
- I left the e2e scripts' `--with tinker` unpinned on purpose: catching
SDK/server incompatibilities is exactly what those nightlies are for.
Happy to pin to `tinker==0.25.0` instead if determinism is preferred.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes the Tinker API wire contract on hot training paths
(forward/backward and sampling) with binary encoding; mistakes could
break all 0.25.0 clients while JSON fallbacks must stay correct for
older SDKs.
> 
> **Overview**
> Fixes nightly cookbook failures against **tinker SDK 0.25.0**, which
requires protobuf for `sample` / `forward` / `forward_backward` results
on `retrieve_future` and sends `forward_backward` bodies as protobuf
(including forward-only via a `forward_only` flag).
> 
> A new **`proto_serialization`** module mirrors the SDK’s proto codecs:
it parses proto `ForwardBackwardRequest` bodies and serializes stored
JSON results into `SampleResponse` and `ForwardBackwardOutput` wire
bytes (tokens, logprobs, top-k sentinels, batched tensors).
> 
> **`retrieve_future`** negotiates on `Accept: application/x-protobuf`
for SAMPLE, EXTERNAL, FORWARD, and FORWARD_BACKWARD; errors and other
futures stay JSON. **`forward_backward`** reads JSON or protobuf and
maps `forward_only=true` to the FORWARD request type. Future polling now
passes **`request_type`** through waiters. JSON **`/forward`** and
legacy SDK paths are unchanged.
> 
> Round-trip tests use the installed tinker SDK deserializers;
**`EXTERNAL`** sample futures (external inference) serialize like
**`SAMPLE`**.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
bd8043b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

## GPU e2e validation (2026-08-17)

Both CI e2e tests were run as Anyscale jobs on this branch (4xL4, exact
CI configs, cookbook client installing the **unpinned** tinker SDK ->
0.25.0, the strict proto-only client):

- `gsm8k_tinker` (colocated, FSDP backend): **SUCCEEDED** (55m50s)
- `gsm8k_tinker_fully_async` (non-colocated): **SUCCEEDED** (56m28s)

The first attempt surfaced a real gap the local JAX-backend testing
missed: `/asample` stores futures as `request_type=EXTERNAL` (not
`SAMPLE`) whenever an external inference client is configured -- which
is how the fsdp/megatron backends run sampling -- and those results
bypassed the proto gate, reproducing the original failure. Fixed in
`7c72fff0` (EXTERNAL results serialize as `SampleResponse`; failures
still return JSON errors), with a regression unit test. Both jobs passed
on the fixed commit, including the wandb reward assertions.


## Performance

Microbenchmark of the response paths at CI-realistic payload shapes
(single CPU core, round-tripped through the SDK's own deserializers;
script: `serialize_result` vs the raw-JSON passthrough):

| Payload | Wire size (JSON -> proto) | Server cost per result | Client
parse (JSON -> proto) |
|---|---|---|---|
| sample, 4 seqs x 512 tokens (CI shape) | 51.5 KiB -> 16 KiB (3.2x) |
+0.78 ms | 0.57 ms -> 0.02 ms |
| sample + top-20 prompt logprobs, 2048-token prompt | 1.17 MiB -> 344
KiB (3.5x) | +34 ms | 21.9 ms -> 0.07 ms |
| fwd_bwd, 512 datums x 600 tokens x 2 float fields | 11.4 MiB -> 2.4
MiB (4.8x) | +170 ms | 144.9 ms -> 2.8 ms |

- **Client + wire: strictly faster.** Proto payloads are 3-5x smaller
and client-side deserialization is `np.frombuffer` over binary instead
of JSON-parsing decimal floats (~50x faster on large payloads; the JSON
column above is `json.loads` only, a lower bound -- the SDK adds model
construction on top).
- **Server: a bounded regression vs the current JSON path, which NovaSky-AI#2017
made a zero-copy passthrough.** The proto path pays `json.loads(stored
text) + validate + proto encode` per result: sub-millisecond for typical
samples, ~170 ms worst-case for a full 512-datum fwd_bwd result. ~85% of
that is the `json.loads` of the stored JSON text (a storage-format
artifact, not a proto cost); the pre-NovaSky-AI#2017 baseline (`jsonable_encoder`
+ `json.dumps`) was ~300 ms on the same payload class, so this is still
cheaper than the JSON path of two weeks ago. The SDK also chunks fwd_bwd
requests (~5 MB estimated per chunk), so single results near the worst
case are uncommon.
- **End-to-end: indistinguishable.** The GPU e2e runs are GPU-bound;
proto-client (0.25.0) and JSON-client (0.24.x) runs both complete in
55-56 min.

Possible follow-ups (not in this PR): offload `json.loads + serialize`
to a thread in `retrieve_future` so a worst-case result doesn't stall
the event loop; longer term, store proto bytes in `FutureDB.result_data`
for the four proto-serializable request types, which removes the parse
entirely and makes every SDK >= 0.18.0 (all of which prefer proto via
the Accept header) a zero-copy read.

---------

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
# What does this PR do?

Fixes CI failures for the `gsm8k_fully_async_ci`  fully async RL CI.

Thresholds have been rebaselined after the async codepath changes:
1. stale KV cache reuse on weight sync (NovaSky-AI#1798),
2.  policy_loss_type=rollout_is (NovaSky-AI#1850),
3. per-step cache salt (NovaSky-AI#1836). 

`loss/avg_final_rewards` in particular dropped from ~0.36 to ~0.24 once
the fully async scripts moved to rollout_is, while eval accuracy stayed
flat.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only threshold and comment updates; no production training or
inference logic changed.
> 
> **Overview**
> Updates **wandb metric gates** in `gsm8k_fully_async.sh` so the fully
async GSM8K GPU E2E job passes after recent async training changes,
without altering the training script invocation.
> 
> **Threshold shifts** (5% allowance from CI runs since Jul 2026): eval
min **0.56 → 0.50**, train reward min **0.32 → 0.198**, max tokens **283
→ 285**, rollout/train logprob diff max **0.040 → 0.0193**. Comments now
document the wider baseline window and tie the rebaseline to stale KV on
weight sync (NovaSky-AI#1798), `rollout_is` policy loss (NovaSky-AI#1850), and per-step
cache salt (NovaSky-AI#1836).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d589aac. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Signed-off-by: SumanthRH <sumanthrh@anyscale.com>
…g validation (NovaSky-AI#2029)

## What

Injects the client-requested LoRA rank/alpha into the override dict
passed to `from_cli_overrides` instead of assigning them onto the config
afterwards (client wins over backend_config, matching the previous
post-assignment semantics).

## Why

`create_lora_training_client` requests were rejected whenever the
backend config enables `trainer.policy.model.fake_int4_qat`:
`TrainerConfig.__post_init__` validated `fake_int4_qat` against the
default `lora.rank=0` and raised "fake_int4_qat requires LoRA" even for
LoRA clients, because the client's rank/alpha arrived after validation.

Part of the Kimi K2.x series (follow-up to NovaSky-AI#1862). Independent of the
other PRs in the series.

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
@kalectory

Copy link
Copy Markdown
Owner Author

Superseded by NovaSky-AI#2069, which targets current upstream main.

@kalectory kalectory closed this Aug 19, 2026
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.

7 participants