Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions configs/ci/integration/reverse_text_sft/resume.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ resume_step = 5
[model]
name = "PrimeIntellect/Qwen3-0.6B"

# PrimeIntellect/Qwen3-0.6B ships its own chat template (distinct from Qwen3's)
[renderer]
name = "prime-qwen3"

[data]
name = "PrimeIntellect/Reverse-Text-SFT"
batch_size = 4
Expand Down
4 changes: 4 additions & 0 deletions configs/ci/integration/reverse_text_sft/start.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ max_steps = 5
[model]
name = "PrimeIntellect/Qwen3-0.6B"

# PrimeIntellect/Qwen3-0.6B ships its own chat template (distinct from Qwen3's)
[renderer]
name = "prime-qwen3"

[data]
name = "PrimeIntellect/Reverse-Text-SFT"
batch_size = 4
Expand Down
4 changes: 4 additions & 0 deletions configs/ci/integration/reverse_text_sft_lora/resume.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ save_adapter_separately = true
[model]
name = "PrimeIntellect/Qwen3-0.6B"

# PrimeIntellect/Qwen3-0.6B ships its own chat template (distinct from Qwen3's)
[renderer]
name = "prime-qwen3"

[model.lora]
rank = 8
target_modules = [
Expand Down
4 changes: 4 additions & 0 deletions configs/ci/integration/reverse_text_sft_lora/start.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ save_adapter_separately = true
[model]
name = "PrimeIntellect/Qwen3-0.6B"

# PrimeIntellect/Qwen3-0.6B ships its own chat template (distinct from Qwen3's)
[renderer]
name = "prime-qwen3"

[model.lora]
rank = 8
target_modules = [
Expand Down
16 changes: 12 additions & 4 deletions docs/training.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,20 @@ Two accepted layouts:

If both columns are present, `messages` takes precedence.

**Tool definitions.** For tool-use SFT, add a `tools` column (OpenAI function-calling format) or `tool_defs` ([`verifiers`](https://github.com/PrimeIntellect-ai/verifiers) rollout format). Each row's value can be either a list of dicts or a JSON-encoded string of a list — both are accepted, and `tool_defs` rows are auto-converted to OAI shape before being passed into the chat template's `tools=...` argument. The `chat_template_kwargs` column, if present, is forwarded verbatim into `apply_chat_template`.
**Tool definitions and renderer controls.** For tool-use SFT, add a `tools` column (OpenAI function-calling format) or `tool_defs` ([`verifiers`](https://github.com/PrimeIntellect-ai/verifiers) rollout format). Each row's value can be either a list of dicts or a JSON-encoded string of a list — both are accepted, and `tool_defs` rows are auto-converted to OAI shape before being passed into the renderer.

**Position-dependent chat templates.** Multi-turn SFT under the default tokenization path (`build_incremental_token_mask`) requires that tokenizing the first _k_ turns of a conversation be a strict prefix of tokenizing all _n ≥ k_ turns. Qwen3's upstream template _violates_ this — it strips past `<think>` blocks across user turns, silently corrupting the loss mask. Two fixes:
Renderer-backed SFT reads template controls from the typed `[renderer]` config in the SFT TOML. For example:

```toml
[renderer]
name = "qwen3"
enable_thinking = false
```

If a model needs another template control, add it to that model's renderer config in `renderers` (for example a new field on the relevant `*RendererConfig`) and consume it in the renderer implementation.

**Renderer-backed tokenization.** SFT tokenization is renderer-only. The [`renderers`](algorithms.md#renderers) package owns message-to-token conversion and loss attribution end-to-end, so position-dependent chat templates (for example templates that strip past `—` blocks across user turns) do not corrupt the loss mask. `[renderer]` defaults to `name = "auto"`; set a typed renderer config only when you need model-specific template controls. Hand-coded renderers ship for Qwen3, Qwen3.5, GLM-5, GLM-4.5, Kimi K2/K2.5, MiniMax M2, DeepSeek V3, Nemotron 3, GPT-OSS.

- **Enable the renderer** (set a typed `[renderer]` config, e.g. `name = "qwen3"`, recommended; defaults to `"auto"` for RL). The [`renderers`](algorithms.md#renderers) package owns tokenization end-to-end and is robust to position-dependent templates. Hand-coded renderers ship for Qwen3, Qwen3.5, GLM-5, GLM-4.5, Kimi K2/K2.5, MiniMax M2, DeepSeek V3, Nemotron 3, GPT-OSS. Not supported for VLMs.
- **Patched chat template** — the prime-rl–patched checkpoints (e.g. `PrimeIntellect/Qwen3-0.6B`, used in `examples/reverse_text/sft.toml`) ship a chat template that preserves thinking. Or supply your own.

See [Algorithms § Multi-Turn Trajectories](algorithms.md#multi-turn-trajectories) for the full picture.

Expand Down
4 changes: 4 additions & 0 deletions examples/reverse_text/sft.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ max_steps = 100
[model]
name = "PrimeIntellect/Qwen3-0.6B"

# PrimeIntellect/Qwen3-0.6B ships its own chat template (distinct from Qwen3's)
[renderer]
name = "prime-qwen3"
Comment thread
hubert-marek marked this conversation as resolved.
Outdated

[data]
name = "willcb/R1-reverse-wikipedia-paragraphs-v1-1000"
seq_len = 4096
Expand Down
7 changes: 2 additions & 5 deletions examples/wordle/rl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ max_completion_tokens = 1024
[inference.parallel]
dp = 6

# Qwen3 finetune with the standard PI template patch (byte-identical to
# PrimeIntellect/Qwen3-0.6B base); always re-emits prior <think> blocks.
# Match that with renderer thinking_retention.
# Fine-tune inherits the PrimeIntellect Qwen3 template byte-for-byte.
[orchestrator.renderer]
name = "qwen3"
thinking_retention = "all"
name = "prime-qwen3"
4 changes: 4 additions & 0 deletions examples/wordle/sft.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ max_steps = 20
[model]
name = "PrimeIntellect/Qwen3-1.7B"

# PrimeIntellect/Qwen3-1.7B ships its own chat template (distinct from Qwen3's)
[renderer]
name = "prime-qwen3"

[data]
name = "willcb/V3-wordle"
seq_len = 1024
Expand Down
50 changes: 37 additions & 13 deletions packages/prime-rl-configs/src/prime_rl/configs/sft.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from typing import Annotated, Literal, TypeAlias

from pydantic import Field, model_validator
from renderers import RendererConfig
from renderers import AutoRendererConfig, DefaultRendererConfig, RendererConfig
from renderers.base import MODEL_RENDERER_MAP

from prime_rl.configs.shared import (
EnvVars,
Expand All @@ -30,7 +31,7 @@ class BaseDataConfig(BaseConfig):
batch_size: int = Field(128, ge=1)
"""Global batch size."""

seq_len: int = Field(128, ge=1)
seq_len: int = Field(256, ge=1)
"""Sequence length."""

pack_function: Literal["cat", "stack"] = "cat"
Expand All @@ -51,6 +52,12 @@ def validate_batch_size(self):
class FakeDataConfig(BaseDataConfig):
type: Literal["fake"] = "fake"

seq_len: int = Field(128, ge=1)
"""Sequence length."""

pack_function: Literal["cat", "stack"] = "cat"
"""Sample packing strategy."""
Comment thread
hubert-marek marked this conversation as resolved.
Outdated

length: Literal["fixed", "variable"] = "fixed"
"""Use fixed-length samples or variable-length samples."""

Expand Down Expand Up @@ -175,13 +182,8 @@ class SFTConfig(BaseConfig):

tokenizer: TokenizerConfig = TokenizerConfig()

renderer: RendererConfig | None = None
"""Typed renderer config (``renderers.RendererConfig`` discriminated
union). When set, SFT tokenizes samples through the ``renderers``
library (single ``render()`` + ``message_indices`` mask) instead of
the default ``build_incremental_token_mask`` path. Required for chat
templates that render position-dependently (e.g. Qwen3, Qwen3.5).
``None`` (default) uses the legacy tokenization path."""
renderer: RendererConfig = AutoRendererConfig()
"""Renderer config. Defaults to auto-selecting from the tokenizer model name."""
Comment thread
hubert-marek marked this conversation as resolved.

data: DataConfig = SFTDataConfig()

Expand Down Expand Up @@ -225,8 +227,8 @@ class SFTConfig(BaseConfig):
dist_timeout_seconds: int = 3600
"""Timeout in seconds for torch distributed ops."""

loss_impl: Literal["liger", "torch", "liger_fused", "quack_fused"] = "torch"
"""Cross-entropy loss implementation. ``liger_fused`` fuses the lm_head projection with the CE loss to avoid materializing full logits. ``quack_fused`` uses quack-kernels for chunked linear + CE with CuTe DSL CUDA kernels."""
loss_impl: Literal["liger", "torch", "liger_fused", "quack_fused"] = "liger_fused"
"""Cross-entropy loss implementation. Defaults to fused Liger loss to avoid materializing full logits."""
Comment thread
hubert-marek marked this conversation as resolved.
Outdated

heartbeat: HeartbeatConfig | None = None
"""BetterStack heartbeat configuration for monitoring training progress."""
Expand Down Expand Up @@ -271,6 +273,28 @@ def validate_deployment(self):
raise ValueError("Must use SLURM for multi-node deployment.")
return self

@model_validator(mode="after")
def validate_typed_renderer(self):
"""Require a typed renderer whenever SFT renders real samples."""
if self.data.type == "fake" and self.val is None:
return self

model_id = self.tokenizer.name or self.model.name
if isinstance(self.renderer, AutoRendererConfig):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

in which case is it not instance of AutoRenderConfig ? shouldn;t it be just if self.renderer is not None ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It needs to resolve later by acutally checking if there is an renderer to be selected by auto

if model_id in MODEL_RENDERER_MAP:
return self
reason = f"no typed renderer is registered for {model_id!r}"
elif isinstance(self.renderer, DefaultRendererConfig):
reason = "renderer.name='default' selects DefaultRenderer"
else:
return self

raise ValueError(
f"SFT requires a typed renderer with sampled-token and content attribution, but {reason}. "
"Implement and register the renderer in the renderers package, or explicitly select an existing "
"typed renderer only when its template is verified to match."
)

@model_validator(mode="after")
def validate_pack_function(self):
if self.model.cp > 1:
Expand Down Expand Up @@ -319,9 +343,9 @@ def dont_do_massive_traces(self):

@model_validator(mode="after")
def validate_renderer_vs_vlm(self):
if self.renderer is not None and self.model.vlm is not None:
if self.model.vlm is not None:
Comment thread
hubert-marek marked this conversation as resolved.
raise ValueError(
"renderer is not supported for VLMs in SFT. The renderer tokenizes "
"renderer-only SFT does not support VLMs yet. The renderer tokenizes "
"text-only message dicts client-side and cannot handle image inputs."
)
return self
Expand Down
Loading
Loading