Supervised Fine-Tuning on gsm8k
The SFT path is where the repository deliberately changes style. Pretraining in llm/ is written from scratch. Post-training in alignment/ uses the current large-model toolchain: Hugging Face for the trainable model, accelerate for device mapping, and vLLM for fast evaluation.
File-Level Responsibilities
The SFT workflow is spread across a small cluster of files:
alignment/dataset.pyturns GSM8K JSONL into prompt/completion pairsalignment/r1_prompt.pyowns the prompt and response formatting contractalignment/sft.pyowns training, checkpointing, and epoch-end evaluationalignment/evaluate.pyowns batch generation and metric aggregationalignment/drgrpo_grader.pyowns format checking and math-answer gradingalignment/util.pyowns device-map construction and vLLM initializationalignment/args.pyowns the CLI defaults
That split keeps the SFT script readable: data contract in one file, training loop in another, evaluation logic in another.
Dataset Construction
Gsm8kDataset reads JSONL lines from data/gsm8k/*.jsonl. For each record it:
- reads
question - reads
answer - splits
answeron#### - treats the prefix as reasoning text
- treats the suffix as the final answer
It stores three parallel arrays:
self.data: prompt stringsself.label: supervised response stringsself.ground_truth: final numeric/string answer
The prompt/response contract comes from R1PromptTemplate.
Prompt and Response Contract
R1PromptTemplate loads a template file from disk and exposes three helpers:
gen_prompt(question)gen_response(think, answer)gen_all_corpus(question, think, answer)
The important part is gen_response():
return think + "</think>" + " <answer>" + answer + " </answer>"
So the supervised target does not start from scratch. The prompt template already ends with Assistant: <think>, and the completion teaches the model how to finish the reasoning block and then emit a structured answer block.
That formatting contract is not cosmetic. The grader later requires the response to contain:
</think> <answer></answer>
Default Configuration
get_sft_parser() defines the default runtime shape:
- model:
Qwen/Qwen2.5-Math-1.5B - dtype:
float32 max_seq_len:1024- physical
batch_size:1 gradient_accumulation_steps:8- training devices:
cuda:0throughcuda:6 - evaluation device:
cuda:7 - checkpoint path:
checkpoints/math_sft
So the default script assumes a fairly large local multi-GPU machine, with one GPU reserved for vLLM evaluation.
Model Placement and vLLM Setup
alignment/util.py owns the device logic.
Trainable HF model
get_device_map() builds an automatic multi-GPU device map by:
- loading the model config
- creating an empty model under
init_empty_weights() - tying weights
- calling
infer_auto_device_map(...)
sft.py then loads the actual model with:
AutoModelForCausalLM.from_pretrained(
model_id,
device_map=device_map,
trust_remote_code=True,
)
Evaluation model
The evaluation model is a separate vLLM instance created with init_vllm(...). That helper monkey-patches:
torch.distributed.get_world_sizeto return1- a vLLM memory-footprint assertion
before constructing LLM(...).
That implementation detail matters because it lets vLLM coexist with the multi-GPU Hugging Face model inside the same overall workflow.
Completion-Only Loss Construction
The central implementation detail in sft.py is label masking.
For each batch:
- read
promptsandcompletions - concatenate
prompt + completion + eos - tokenize the full texts with padding and truncation
- tokenize the prompts separately with
add_special_tokens=False - compute prompt lengths in tokens
- clone
inputs.input_idsintolabels - set prompt-token positions in
labelsto-100 - set padding-token positions in
labelsto-100
In code, the masking boundary is:
for idx in range(len(prompts)):
prompt_len = prompt_lengths[idx]
labels[idx, :prompt_len] = -100
labels[labels == tokenizer.pad_token_id] = -100
That means optimization only applies to the completion region. The model is not trained to re-predict the prompt prefix.
Training Loop
The training loop itself is intentionally short:
- build the batch
- compute the masked causal-LM loss through the Hugging Face model
- divide the loss by
gradient_accumulation_steps - call
loss.backward() - step the optimizer every accumulation boundary
- zero gradients after the step
The optimizer is standard torch.optim.AdamW, configured from CLI arguments:
lrbeta1beta2weight_decay
There is no custom scheduler, mixed-precision scaler, or sequence packing layer in this script. The important thing is the data contract and the completion-only loss.
Epoch-End Evaluation
After each epoch, sft.py pushes the updated policy weights into the running vLLM instance through load_policy_into_vllm_instance().
That helper reaches inside the vLLM engine:
llm_model = llm.llm_engine.model_executor.driver_worker.model_runner.model
llm_model.load_weights(state_dict.items())
So evaluation reuses a live inference engine instead of recreating it from scratch every epoch.
evaluate_math() then:
- loads GSM8K test data
- rebuilds prompts with the same
R1PromptTemplate - generates outputs with vLLM
- scores each output with
r1_zero_reward_fn - aggregates three metrics:
avg_format_rewardsavg_answer_rewardsavg_all_rewards
The evaluation sampling config is intentionally simple:
temperature=1.0top_p=1.0max_tokens=1024
Reward Function and Metrics
r1_zero_reward_fn() is the real evaluation contract.
It first checks the response format:
- does the text contain
</think> <answer>? - does it contain
</answer>?
If not, all rewards are zero.
If the format is valid, the grader:
- extracts the answer span
- optionally unwraps
\boxed{...} - calls
grade(...), which uses string normalization, symbolic parsing, and math-equivalence checks
The returned dictionary is:
{
"format_reward": ...,
"answer_reward": ...,
"reward": ...,
}
So SFT is not judged only by next-token loss. It is judged by the same downstream structure and answer correctness that RLFT later optimizes more directly.
Checkpoint and Entrypoint Behavior
After evaluation, the script saves:
model.save_pretrained(args.checkpoint_path)tokenizer.save_pretrained(args.checkpoint_path)
The __main__ block then behaves as a small controller:
- if the checkpoint directory already exists and is non-empty, load it directly into vLLM and evaluate
- otherwise, run training first and then evaluate
So the same file acts as both trainer and “evaluate existing SFT checkpoint” entrypoint.
End-to-End Flow
flowchart LR
A["gsm8k JSONL"] --> B["Gsm8kDataset"]
B --> C["R1PromptTemplate"]
C --> D["HF causal LM with masked labels"]
D --> E["save_pretrained checkpoint"]
E --> F["load weights into vLLM"]
F --> G["r1_zero_reward_fn metrics"]
Practical Consequences
The current SFT implementation makes a few deliberate choices:
- it uses the same prompt format that RLFT will later reuse
- it computes the loss boundary in token space, not character space
- it separates training and evaluation models so evaluation does not reuse the training graph
- it saves full Hugging Face checkpoints rather than a custom lightweight format
That combination makes the file a good bridge between educational model code and the more pragmatic ecosystem used for current large-model adaptation.