Skip to content

Add SFT workload support to Slime recipes - #517

Open
omkaark wants to merge 1 commit into
mainfrom
omkaark/sft
Open

Add SFT workload support to Slime recipes#517
omkaark wants to merge 1 commit into
mainfrom
omkaark/sft

Conversation

@omkaark

@omkaark omkaark commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Adds text SFT through the existing Slime model recipes: set workload_type="sft" and use SFTDataset with conversations or prompt/answer columns. RL remains the default. SFT reserves only actor GPUs and disables inference, critic, KL, and reward-based training work.

Includes data validation, workload identity in run summaries, tests, a one-GPU tutorial, and the three-PR release plan. colocate=False selects Slime's train-only execution path; it does not reserve an inference node.

Stack: #517#518#519. Review this first against main; #518 adds reliable SFT observability and #519 adds UI/UX. Each PR is based on its immediate parent. Rebased onto main 13f9e3ef before implementation. Review model/dataset semantics before generated documentation.

Validation: focused SFT, recipe identity, dashboard schema, and CLI tests pass; lint/compile and documentation generation pass. The combined stack passes 980 Python tests (one skipped) and six frontend tests/build. A Qwen3-0.6B one-H100 proof and ten-step smoke completed with actor=1/inference=0/critic=0, saved checkpoints, and all ten optimizer metrics. Full integration evidence and remaining release checks are in #519 and plan.md.

Text SFT is the initial supported scope; multimodal/tool-call SFT and Miles SFT are excluded. Broad GPU model-validation CI was deliberately cancelled before execution to honor the one-H100 budget; snapshot CI awaits an authorized environment reviewer. Do not treat the cancelled matrix as a passing all-model validation.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 6 potential issues.

Devin Review

Comment thread tests/test_sft.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Masking semantics lack coverage

Tests validate conversation shape but never exercise tokenizer masking. Multi-turn assistant spans, end tokens, and truncation behavior remain unverified.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread modal_training_gym/common/dataset.py Outdated
Comment on lines +253 to +273
class SFTDataset(HuggingFaceDataset):
"""A Hugging Face dataset formatted as complete conversations for SFT.

Args:
messages_column: Source column containing OpenAI-style messages. Use this
or `input_column` plus `output_column`, not both.
input_column: Source prompt column for constructing single-turn examples.
output_column: Source assistant-response column for constructing examples.
system_prompt: Optional system message for constructed examples.
prompt_template: Template applied to constructed user prompts.

Assistant turns are supervised targets. In-training evaluation is not
materialized because Slime's SFT rollout supports training data only.
"""

messages_column: str = ""
input_key: str = "messages"
label_key: str = "label"
apply_chat_template: bool = False
writes_eval_paths: bool = False
supports_sft: ClassVar[bool] = True

@devin-ai-integration devin-ai-integration Bot Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Custom SFT datasets reuse RL data

A custom dataset accepted for SFT keeps its RL cache path because only SFTDataset receives the SFT suffix. Existing RL data then bypasses preparation, so SFT can train on the wrong format.

Learn more

Custom DatasetConfig implementations are explicitly accepted for SFT, but BaseTrainRecipe._resolve_data_paths only separates caches when the object is an SFTDataset. The resolver does not know the recipe workload, so a custom dataset used for both RL and SFT resolves to one file. The Slime launcher skips dataset.prepare when that file exists. Make the workload part of Slime data-path resolution for every SFT run, while preserving Miles and existing RL paths. Update both launcher preparation paths and CLI serialization consistently, then cover a custom dataset used by both workloads.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread modal_training_gym/common/dataset.py Outdated
Comment thread modal_training_gym/common/dataset.py Outdated
Comment on lines +362 to +374
def _from_pair(row: dict) -> dict:
for column in (in_col, out_col):
if not isinstance(row[column], str) or not row[column].strip():
raise TrainingGymConfigError(
f"SFT column {column!r} requires non-empty text"
)
messages = []
if sys_prompt:
messages.append({"role": "system", "content": sys_prompt})
messages.extend(
[
{"role": "user", "content": template.format(input=row[in_col])},
{"role": "assistant", "content": row[out_col]},

@devin-ai-integration devin-ai-integration Bot Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Rendered prompts bypass validation

With a blank prompt_template, pair inputs pass validation before rendering. The resulting conversation has an empty user turn, so training learns targets without their intended context.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +66 to +69
SFT starts only the Megatron trainer: no SGLang server or inference GPUs are
allocated. The model recipe's topology and memory settings are preserved, so
the same switch works for every existing model recipe. `num_rollout` is the
number of optimizer steps; choose an SFT learning rate for the model's scale.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Model-wide support remains unverified

Documentation promises SFT for every existing model recipe. Live evidence covers Qwen only, while specialized audio, vision, hybrid, and MoE presets remain untested.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread modal_training_gym/common/dataset.py Outdated

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 4 new potential issues.

Devin Review

Comment on lines 761 to 765
if self.training_type == "sft" and dataset.apply_chat_template:
raise TrainingGymConfigError(
f"{type(ds).__name__}: input_key and label_key are both "
f"{ds.input_key!r}; they must name distinct columns."
"SFT requires apply_chat_template=False so Slime can build its "
"loss mask from raw messages"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 SFT datasets run as RL

Pairing SFTDataset with the default RL recipe passes validate_dataset. Its complete conversations and blank labels then enter reward training instead of supervised training.

Prompt for agents
SlimeRecipe.validate_dataset enforces apply_chat_template=False when training_type is sft, but it does not enforce the inverse relationship. SFTDataset always writes complete conversations with an empty label, so using it with the default training_type='rl' sends SFT-shaped data into the reward-learning path. Reject SFTDataset unless training_type is 'sft'. Also consider rejecting SFTDataset in Miles, where SFT is unsupported, and add mismatch tests for both directions.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +308 to +318
for message in messages:
if not isinstance(message, dict):
raise TrainingGymConfigError("Every SFT message must be a dictionary")
if not isinstance(message.get("role"), str) or not message["role"].strip():
raise TrainingGymConfigError(
"Every SFT message requires a non-empty role string"
)
if not any(message["role"] == "assistant" for message in messages):
raise TrainingGymConfigError(
"Each SFT conversation requires at least one assistant turn"
)

@devin-ai-integration devin-ai-integration Bot Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Empty assistant targets pass validation

A conversation containing {"role": "assistant"} passes _validate_conversation. It has no supervised content, so tokenization can fail or produce an empty loss mask.

Learn more

SFTDataset._validate_conversation validates role presence but not whether assistant turns contain a usable target. Preserve supported text conversations and any intentionally supported structured message forms, but reject conversations where every assistant turn lacks supervised content. Validate other message content needed by the selected chat templates and add tests for missing, null, empty, and whitespace-only assistant content without tool calls.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread modal_training_gym/train_recipes/slime_recipe/recipe.py
rollout_batch_size: int = 8

# ── App identity ─────────────────────────────────────────────────────────
training_type: Literal["rl", "sft"] = "rl"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 SFT option name is inconsistent

The PR description advertises workload_type="sft", but the public recipe and tutorial use training_type="sft". Reconcile the release-facing contract.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

Devin Review

Comment thread modal_training_gym/train_recipes/base.py Outdated
Comment on lines +687 to +688
if self.training_type == "rl":
return self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 RL variants retain SFT behavior

Changing an SFT recipe to training_type="rl" leaves its normalized fields intact. TrainingGroup can label train-only SFT execution as RL.

Prompt for agents
Avoid destructive, irreversible normalization in SlimeRecipe's validator. Preserve the user-supplied or model-preset values separately, or derive effective SFT fields only during CLI and allocation resolution, so rebuilding a recipe after changing training_type restores RL behavior. Add a TrainingGroup regression test whose base recipe is SFT and whose grid selects training_type=rl.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +308 to +318
for message in messages:
if not isinstance(message, dict):
raise TrainingGymConfigError("Every SFT message must be a dictionary")
if not isinstance(message.get("role"), str) or not message["role"].strip():
raise TrainingGymConfigError(
"Every SFT message requires a non-empty role string"
)
if not any(message["role"] == "assistant" for message in messages):
raise TrainingGymConfigError(
"Each SFT conversation requires at least one assistant turn"
)

@devin-ai-integration devin-ai-integration Bot Sep 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Conversation contract exceeds stated scope

_validate_conversation accepts arbitrary roles and tool-call messages. Confirm Slime supports these shapes, or restrict validation to text SFT.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@andrewhinh andrewhinh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thanks for taking this on! it's badly needed.

plz address my and Devin's comments and lmk if you have any questions.

ds.to_json(path, orient="records", lines=True)


class SFTDataset(HuggingFaceDataset):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

wait are we only supporting SFT for HuggingFace datasets?

also, im not sure i understand what the difference between this and HuggingFaceDataset is.

Comment thread docs-next/public/llms.txt
- [HuggingFaceDataset](https://gym.modal.dev/reference/huggingfacedataset)
- [MultimodalDataset](https://gym.modal.dev/reference/multimodaldataset)
- [OnlineRollout](https://gym.modal.dev/reference/onlinerollout)
- [SFTDataset](https://gym.modal.dev/reference/sftdataset)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmm, i wonder if creating all these use-case specific dataset classes (i.e., this, OnlineRollout, MultimodalDataset) is good practice. as a user, i'd expect these to either be automatically inferred or exposed as flags.

btw this pertains to a latent issue rather than something this PR introduces.

SlimeRecipe._validate_custom_model_architecture(model)
SlimeRecipe._validate_datasets(dataset, eval_dataset)

if slime.training_type == "sft" and dataset.apply_chat_template():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

to my point above, this seems like a sharp edge for a user to have to experience.

Megatron actor nodes.
actor_num_gpus_per_node:
GPUs per actor node.
num_gpus_per_node:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmm, im not a big fan of having separate parameters like this. even more confusingly, although this is for slime, there is already a miles flag with this name. i wonder if we should instead make a SFTRecipe.

Comment thread tutorials/sft.py
@@ -0,0 +1,37 @@
# ---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i think this is better suited as a new section in the Dataset guide.

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.

2 participants