Samantha Finetune¶
Finetune a model on the Samantha dataset
What You'll Learn¶
This tutorial teaches you how to: - ✓ Fine-tune a 7B parameter language model on consumer GPUs (no LoRA/quantization!) - ✓ Use pipeline parallelism to distribute models across multiple GPUs - ✓ Train with packed-sequences and flex-attention - ✓ Scale training across multiple machines over standard Gigabit Ethernet - ✓ Convert models between HuggingFace and Forgather formats - ✓ Manage checkpoints and resume training - ✓ Serve your fine-tuned model via an inference API
Time required: ~1-2 hours (mostly waiting for downloads/training) Hardware requirements: 1-6 GPUs with 16-24GB VRAM each
The "Samantha" dataset was an experimental dataset created by Eric Hartford, where the model is taught to believe that she is sentient.
https://erichartford.com/meet-samantha
Minimum Hardware Requirements¶
The configurations have been written with the assumption of having a GPU which supports the bfloat16 data format and 24 GBs of VRAM (minimum).
There is an experimental config for a 16 GB GPU. The measured peak usage on a RTX 4090 is 16.23 GB, which may work, but I don't have a card to test this on.
We also have configurations for multi-GPU single node and multi-node training. You can mix-and-match GPUs, provided that they all support bfloat16, but the slowest GPU will be the bottleneck.
Setup¶
Download a Model¶
You will need a model to finetune. For our examples, we will use the base Mistral-7B-v0.1 model. This is a raw, pretrained, model, which has never been trained to interact in a chat context before. It will not take very long for this model to become "Samantha," who is a pro at interacting with the ChatML dialog format.
You should be able to use any 7B Llama flavor, with minimal changes to these instructions. For example, 'meta-llama--Llama-2-7b-hf' has also been tested.
# Download the model
MODELS_DIR="~/models" # Change this to where you store your models...
SRC_MODEL="${MODELS_DIR}/mistral_7b"
mkdir -p "${MODELS_DIR}"
hf download mistralai/Mistral-7B-v0.1 --local-dir "${SRC_MODEL}" \
--exclude "*.safetensors" "model.safetensors.index.json"
An alternative model, which has been tested with this tutorial, is Llama-3.2-1B-Instruct. There are project configurations defined specifically for this model. Downloading this model requires authorization from Meta, which can be obtained from the above linked page.
# Download Llama-3.2-1B-Instruct
hf download --exclude "original*" --local-dir Llama-3.2-1B-Instruct meta-llama/Llama-3.2-1B-Instruct
Convert the Model¶
Forgather's basic Trainer class works with HF models directly, but conversion to
Forgather's format is required for the Pipeline Parallel Trainer and unlocks
fused-cross-entropy, which significantly reduces peak memory. For base models
(those lacking a chat template), conversion also grafts on ChatML and ensures
the destination's generation_config.eos_token_id lists both the original
EOS and the new ChatML end-of-turn marker, so model.generate() halts cleanly
on either token during inference.
Set the output base directory once:
Base models -- graft on ChatML¶
Base models lack a chat template and use a single scalar EOS (</s> for Llama 2,
<|end_of_text|> for Llama 3). Apply the bundled ChatML add-tokens config and
chat template during conversion. From the repo root:
# Llama 2 7B (~13 GB on disk after conversion)
forgather convert --dtype bfloat16 \
--add-tokens add_tokens_config/chatml.yaml \
-t chat_templates/chatml.jinja \
"${MODELS_DIR}/meta-llama--Llama-2-7b-hf" \
"${MODELS_DIR}/fg_Llama-2-7b"
# Llama 3.2 1B base (~2.6 GB on disk after conversion)
forgather convert --dtype bfloat16 \
--add-tokens add_tokens_config/chatml.yaml \
-t chat_templates/chatml.jinja \
"${MODELS_DIR}/Llama-3.2-1B" \
"${MODELS_DIR}/fg_Llama-3.2-1B"
What add_tokens_config/chatml.yaml does:
- Promotes
<|im_end|>to the tokenizer'seos_token. If<|im_end|>was not already in the source vocab, a new row is added and copy-initialized from the original EOS row's weights so the new token starts off behaving like the old one. - Registers
<|im_start|>as an additional special token. - Adds
<|pad|>only if the source tokenizer defines no pad token (if_missing: true).
Verify the EOS provisioning landed correctly:
$ cat "${MODELS_DIR}/fg_Llama-2-7b/generation_config.json"
{
...
"eos_token_id": [2, 32000], # </s>=2 (original) + <|im_end|>=32000 (new)
...
}
$ cat "${MODELS_DIR}/fg_Llama-3.2-1B/generation_config.json"
{
...
"eos_token_id": [128001, 128256], # <|end_of_text|>=128001 + <|im_end|>=128256
...
}
The converted tokenizer's chat-template renders one <|im_end|> per assistant
turn -- there is no need to inject a chat template at training time anymore.
Instruction-tuned models -- convert as-is¶
Models that ship chat-tuned (e.g. Llama-3.2-1B-Instruct) already have a chat
template installed and a list-valued eos_token_id -- Llama 3 Instruct uses
[128001, 128008, 128009] (<|end_of_text|>, <|eom_id|>, <|eot_id|>).
Do not add ChatML on top: that would clobber the existing template and
replace the EOS the model was trained with. Just convert:
forgather convert --dtype bfloat16 \
"${MODELS_DIR}/Llama-3.2-1B-Instruct" \
"${MODELS_DIR}/fg_Llama-3.2-1B-Instruct"
Convert preserves the source's chat template and the full eos_token_id list:
$ cat "${MODELS_DIR}/fg_Llama-3.2-1B-Instruct/generation_config.json"
{
...
"eos_token_id": [128001, 128008, 128009], # all three preserved
...
}
By default convert adds a [PAD] token when the source defines none (Llama 3
Instruct does not define a pad), which grows the vocabulary by 1. Pass
--skip-default-tokens to leave the tokenizer untouched if you'd rather
manage padding yourself.
Choose which model the rest of this tutorial points at¶
Pick one of the converted models and set FG_MODEL so the training commands
below resolve to it:
# 7B examples in the rest of this README assume:
FG_MODEL="${MODELS_DIR}/fg_Llama-2-7b"
# 1B examples assume:
FG_MODEL_1B="${MODELS_DIR}/fg_Llama-3.2-1B" # or fg_Llama-3.2-1B-Instruct
Reverse: Forgather → HuggingFace¶
Preparing a From-Scratch Forgather Model¶
If your starting point is a model you pretrained with Forgather (rather than
an HF model you ran through forgather convert), the parallel tool is
forgather finalize. It grafts on a chat template and any additional tokens,
synthesizes a generation_config.json whose eos_token_id lists every stop
token (original + ChatML), and writes a clean handoff directory ready for
fine-tuning.
forgather finalize \
/path/to/my_pretrain/output_models/checkpoint_dir \
"${MODELS_DIR}/fg_my_pretrain_chat" \
--add-tokens add_tokens_config/chatml.yaml \
-t chat_templates/chatml.jinja
This is the same add_tokens_config/chatml.yaml and
chat_templates/chatml.jinja used by the convert flow above; the resulting
model has identical EOS / chat-template wiring. After finalize, point training
at the new directory:
Pass --keep-optimizer to also carry optimizer state from the source's
latest checkpoint into the destination, which can help avoid a rocky restart.
See docs/guides/finalize-model.md
and docs/guides/add-tokens-config.md
for the full reference.
Directory Structure Overview¶
This tutorial uses the following directory structure:
~/forgather/ # Forgather installation
├── examples/finetune/samantha/ # Tutorial project (working directory)
├── add_tokens_config/chatml.yaml # Bundled --add-tokens config for ChatML
└── chat_templates/chatml.jinja # Bundled ChatML chat template
~/models/ # Model store (you maintain this)
├── meta-llama--Llama-2-7b-hf/ # Downloaded HuggingFace model
└── fg_Llama-2-7b/ # Converted Forgather model
├── pytorch_model-*.bin # Model weights
├── config.json # Model architecture config
├── tokenizer*.json # Tokenizer (with chat template baked in)
├── generation_config.json # eos_token_id lists ALL stop tokens
├── *.py # Generated model source
├── checkpoints/ # Training checkpoints (created during training)
│ ├── checkpoint-100/
│ ├── checkpoint-200/
│ └── ...
└── runs/ # Training logs
└── run_2026-04-26.../
Important paths (referenced from the tutorial directory
examples/finetune/samantha/):
- Chat template:
../../../chat_templates/chatml.jinja - Add-tokens config:
../../../add_tokens_config/chatml.yaml
The forgather convert and forgather finalize commands above are run from
the repo root, where the add_tokens_config/ and chat_templates/
directories sit one level down. From inside examples/finetune/samantha/ use
the ../../../ prefixes shown here.
Configuration Tour (Optional)¶
Configuration Files¶
While not exhaustive, this is a sampling of the configurations used by this project.
Samantha Project - samantha.yaml -- Base project configuration
Llama2 7B Configurations - llama2_7b/1gpu_default.yaml -- Single GPU, conservative settings (basic trainer) - llama2_7b/1gpu_minimum.yaml -- Single GPU, 16 GB VRAM budget - llama2_7b/2gpu_pp.yaml -- 2 GPU Pipeline Parallel - llama2_7b/4gpu_pp.yaml -- 4 GPU Pipeline Parallel - llama2_7b/fsdp2.yaml -- FSDP2 (requires fast GPU interconnect for reasonable performance)
Llama3 1B Configurations - llama3_1b/1gpu_default.yaml -- Single GPU (basic trainer) - llama3_1b/ddp.yaml -- Multi-GPU Distributed Data Parallel - llama3_1b/ddp_adam4bit.yaml -- DDP with torchao 4-bit AdamW (stochastic-rounded) - llama3_1b/fsdp2.yaml -- Multi-GPU FSDP2 - llama3_1b/pp.yaml -- Multi-GPU Pipeline Parallel
Project Templates - projects/finetune_v2.yaml -- Base Finetune Project - projects/lm_training_project.yaml -- Base LM Training Project - LM Training Project Template Documentation for base template project
Samantha Dataset - samantha.yaml -- Samantha dataset definition - samantha-packed.yaml -- Packed Samantha dataset definition - src/samantha.py -- Dataset preprocessing implementation
Chat Template - chat_templates/chatml.jinja -- ChatML chat template definition
Interactive Forgather CLI¶
If you have not already installed the syntax-highlighting plugins for vim / VS Code, follow the instructions in "syntax_highlighting/" This will make the config files much more readable.
If running VS Code and not running in a VS Code terminal, you can integrate the terminal with the VS Code editor like this:
# From a VS code terminal
dinalt@hal9000:~/ai_assets/forgather$ env | grep VSCODE_IPC
VSCODE_IPC_HOOK_CLI=/tmp/vscode-ipc-1e7b4a5b-9efe-4481-a35e-b489029bc661.sock
# From alternative terminal
export VSCODE_IPC_HOOK_CLI=/tmp/vscode-ipc-1e7b4a5b-9efe-4481-a35e-b489029bc661.sock
# Edit commands from the external terminal will open files in VS Code!
# Start an interactive Forgather session
forgather -i
# List top level "interactive" commands
forgather:samantha> help
# List Forgather commands
forgather:samantha> commands
# List available configurations.
forgather:samantha> ls
# Change the configuration to "1gpu_llama_7b/long_context.yaml"
# Note that tab-completion is supported
forgather:samantha> config llama2_7b/2gpu_pp.yaml
# Checkout the template hierarchy for this configuration
# If running in VS Code...
forgather:samantha [llama2_7b/2gpu_pp.yaml]> trefs --format svg -e
# Otherwise...
# Open the resulting file, "long_context.svg," with a compatible viewer.
forgather:samantha [llama2_7b/2gpu_pp.yaml]> trefs --format svg -o long_context.svg
# Take a look at one of the configurations we will be demonstrating
forgather:samantha [llama2_7b/2gpu_pp.yaml]> edit llama2_7b/2gpu_pp.yaml
# Take a look at the base Samantha project configuration.
forgather:samantha [llama2_7b/2gpu_pp.yaml]> edit templates/samantha.yaml
# Take a look at the base finetune and LM training templates.
# First, bring up the menu to interactively select the files to edit.
forgather:samantha [llama2_7b/2gpu_pp.yaml]> edit
...
# Then enter the numbers corresponding to "finetune_v2.yaml" and "lm_training_project.yaml"
# Show the preprocessed configuration in the editor
forgather:samantha [llama2_7b/2gpu_pp.yaml]> pp -e
# See what this configuration looks like, when translated to native Python code
forgather:samantha [llama2_7b/2gpu_pp.yaml]> graph --format python -e
# Take a look at the configuration-specific arguments
# Most of these arguments are derived from the configuration's "dynamic_args" section.
forgather:samantha [llama2_7b/2gpu_pp.yaml]> train --help
# Quit, when done
forgather:samantha [llama2_7b/2gpu_pp.yaml]> quit
Control Interface¶
Forgather has an interface for monitoring and controlling running training jobs. Using this interface is the preferred means of prematurely ending a training job, as it avoids the possibility of causing one or more workers to hang, when using control-c (pipeline parallel frequently hangs on termination).
usage: forgather control [-h] {list,status,stop,abort,save,cleanup} ...
list List discoverable training jobs
status Get status of a training job
stop Send graceful stop command to a training job (saves final checkpoint)
abort Abort training job WITHOUT saving checkpoint
save Trigger checkpoint save in a training job
cleanup Remove endpoint files for dead training jobs
The commands, other than "list," take a job-id as an additional argument, where you can find the job-id via "list."
Monitor with Tensorboard¶
You can monitor your training jobs with Tensorboard
forgather tb --output-dir OUTPUT_DIR [-- --bind_all]
# --bind_all : Bind to all IP interfaces, otherwise just localhost
Single GPU Training¶
We will be training the full model, not using a low-rank approximation or quantization. With bfloat16, we need approximately 14 GBs just for the model parameters. With a conventional training setup, you would also need an additional 14 GBs for the gradients, 28 GBs for the optimizer-states, and a fair amount more for activation states (depends on sequence length),
PyTorch uses float32 by default, which takes twice as much memory as bfloat16.
We address the optimizer state issue by using Adafactor, without momentum. This optimizer uses negligible memory for optimizer states and performs nearly identically to AdamW, as long as the batch size is relatively small.
When training in bf16 format, this optimizer uses stochastic-rounding, which yields results close to mixed-precision training accuracy.
To address the storage required for gradients, we combine the gradient computation step with the optimizer step. The result is that we only need to materialize one gradient at a time, and free it immediately after updating the parameter. This saves about 14 GBs.
[trainer_args]
...
fuse_optim_with_backward: True # Combine gradient computation with optimizer step
This just leaves the activation memory to contend with. To address this, we use activation checkpointing, which saves the activation at each layer, discarding the intermediate activations, which can be recomputed on the backward pass. This trades compute for memory.
[trainer_args]
...
gradient_checkpointing: True # Only save activations at each layer and recompute on backwards step
Note that 'fuse_optim_with_backward=True' is synergistic with 'gradient_checkpointing=True'
We can go one step further, by moving the activation checkpoints to CPU memory and back again, when needed to compute the gradient. This allows us to use a context length of 4096 on a single GPU.
Selecting Specific GPUs¶
By default, training uses the first N available GPUs. To use specific GPUs:
# Use only GPU 0
forgather -t config.yaml train -M "${MODEL}" -d 0
# Use GPUs 0, 1, and 3 (skip GPU 2)
forgather -t config.yaml train -M "${MODEL}" -d 0,1,3
# Alternative: use CUDA_VISIBLE_DEVICES
CUDA_VISIBLE_DEVICES=0,1,3 forgather -t config.yaml train -M "${MODEL}"
This is useful if some GPUs are busy or may have issues.
Testing Your Configuration¶
First, let's run a sanity check to verify if everything is working and that we don't run out of GPU memory.
forgather -t "llama2_7b/1gpu_default.yaml" train --save-strategy no --max-steps 10 -M "${FG_MODEL}"
# -t 1gpu_llama_7b/default.yaml : Train on a single GPU with conservative settings.
# --save-strategy no : Don't save checkpoints (for testing)
# -M "${FG_MODEL}" : Path to the model to train.
# --max-steps 10 : Run a quick test, with only 10 training steps
The 7B default config uses a 2048 token context; the 1B default config uses 4096. Both leave some VRAM headroom on a 24 GB card.
Once you have verified that a given config will run, you can train on the full dataset...
Single GPU, default settings¶
# Train a 7B Llama/Mistral model (seq_len = 2048)
forgather -t "llama2_7b/1gpu_default.yaml" train -M "${FG_MODEL}"
# Train the Llama-3.2-1B model (seq_len = 4096)
forgather -t "llama3_1b/1gpu_default.yaml" train -M "${FG_MODEL_1B}"
Single GPU, 16 GB¶
We can try to train on a 16 GB GPU. With the model weights using 14 GB, it's going to be pretty tight!
As above, this does not work with the HF model. Convert it to Forgather's format first.
Multi-GPU Setup¶
The finetune_v2 base template supports four trainer backends, selected via
ns.trainer_type (or --trainer-type on the command line): basic, ddp,
fsdp2, and pipeline. The Samantha configs ship one example per backend for
the 1B model and the pipeline + FSDP2 variants for the 7B model.
Which one should you reach for?
- Pipeline Parallel (
pipeline) — best fit for consumer-grade hardware. PP transfers activations between stages, not parameters, so it tolerates the slow PCIe interconnects typical of multi-GPU desktops. This is the recommended default for the 7B model on 24 GB cards. - FSDP2 (
fsdp2) — shards parameters, gradients, and optimizer state across the data-parallel mesh. Great when a single GPU can't hold the model, but the all-gather / reduce-scatter traffic needs a fast interconnect (NVLINK or similar) for reasonable throughput; expect it to be painful on PCIe-only machines. - DDP (
ddp) — replicates the full model on every GPU and averages gradients. Only works when the model + optimizer state already fits on a single device. For the 1B model this is the easiest win; for 7B you'd typically prefer PP or FSDP2.
For the details of each backend see LM Training Project Template.
Measured Throughput¶
These are 10-step smoke-test numbers on the reference hardware: a 6x RTX 4090
box with PCIe 4.0 and no NVLINK, each card power-limited to 250 W. GPU 2 was
excluded for thermal reasons, so the distributed runs used 5 or fewer cards.
The single-GPU and {2,4}gpu_pp configs are pinned to their advertised rank
counts, so the 5-card limit only directly affects the DDP/FSDP2 rows below
(which would run identically at 1, 2, 4, or 8 GPUs — the template adjusts).
At 250 W the cards are mildly throttled, but every distributed run here is
bandwidth-bound on the PCIe fabric rather than compute-bound, so the numbers
should be close to what full-power cards would produce.
Measurements taken with --save-strategy no --max-steps 10 against the
default settings in each config. Treat the throughput as order-of-magnitude —
a 10-step average is noisy, and MFU is reported per-rank (pipeline parallel
values include bubble time).
Llama2 7B (fg_llama_7b)¶
| Config | GPUs | seq_len | bs (per dev) | tok/s | Peak mem | MFU |
|---|---|---|---|---|---|---|
llama2_7b/1gpu_default.yaml |
1 | 2048 | 1 | 2,120 | 21.95 GiB | 36% |
llama2_7b/1gpu_minimum.yaml |
1 | 1280 | 1 | ~95 | 14.80 GiB | ~3% |
llama2_7b/2gpu_pp.yaml |
2 | 1280 | 2 | 3,478 | 18.7 / 20.4 GiB | 69% |
llama2_7b/4gpu_pp.yaml |
4 | 2048 | 2 | 8,893 | 16.3 – 19.2 GiB | 63% |
llama2_7b/fsdp2.yaml |
5 | 2048 | 1 | 1,360 | 12.6 GiB / rank | 10% |
Observations:
4gpu_ppis the throughput winner at 8.9K tok/s. The ZBV schedule keeps the pipeline densely packed; MFU lands in the low-60s even at seq_len 2048.2gpu_ppgets surprisingly high per-rank MFU (69%) because the two-stage ZBV schedule has very little bubble. Absolute throughput is lower than4gpu_pp, but it's the most compute-efficient 7B config we measured.1gpu_minimumis about 22x slower than1gpu_default. The bottleneck is CPU activation offloading — host-device bandwidth, not compute. The config also has to fall back to SDPA instead of flex-attention, because flex-attention currently doesn't compose with activation offloading in PyTorch, and without offloading the run OOMs on a 16 GB card. (At seq_len 1280 flex-attention is otherwise a little faster and a little more memory-efficient than SDPA; SDPA here is a workaround, not a preference.) This config exists to prove 7B fine-tuning on a 16 GB card is possible, not fast — reach for it only when you genuinely can't spare another card.fsdp2on PCIe is a cautionary tale. At 1.36K tok/s on 5 GPUs it is slower than1gpu_defaulton a single card: the all-gather / reduce-scatter traffic dominates step time on consumer interconnects. FSDP2 is the right backend when the model + optimizer state legitimately does not fit on one GPU, and when you have NVLINK or equivalent. For the 7B Samantha case you almost always want pipeline parallel instead.
Llama3 1B (fg_llama_1b)¶
| Config | GPUs | seq_len | bs (per dev) | Optimizer | tok/s | Peak mem |
|---|---|---|---|---|---|---|
llama3_1b/1gpu_default.yaml |
1 | 4096 | 2 | Adafactor | 12,844 | 13.19 GiB |
llama3_1b/ddp.yaml |
5 | 4096 | 1 | Adafactor | 20,853 | 10.79 GiB / rank |
llama3_1b/ddp_adam4bit.yaml |
5 | 4096 | 1 | torchao AdamW4bit | 19,851 | 12.01 GiB / rank |
llama3_1b/fsdp2.yaml |
5 | 4096 | 1 | Adafactor | 10,778 | 11.03 GiB / rank |
llama3_1b/pp.yaml |
2 | 4096 | 2 | Adafactor | 20,452 | 16.9 / 9.4 GiB |
Observations:
- DDP is the simplest, fastest 1B backend on PCIe. The model + optimizer state fit comfortably on one card, so you pay for gradient all-reduce but not for parameter sharding. At 20.9K tok/s on 5 ranks, it slightly beats the 2-GPU pipeline.
- Pipeline parallel is competitive on just 2 GPUs. The config is pinned
to
nproc_per_node = 2on purpose — scaling the ZBV schedule to 5 ranks balloons activation memory on the first stage and OOMs immediately. The takeaway generalises: ZBV pipelines scale micro-batches with the rank count, and the first stage typically pays the biggest memory price. - FSDP2 is again the slowest distributed variant. Same story as the 7B case — PCIe all-gathers dominate.
- torchao AdamW4bit is a ~5% throughput trade for first-moment momentum
back. The quantized-Adam variant of the DDP config runs at 19.9K tok/s
vs 20.9K for the Adafactor baseline, and adds ~1.2 GiB of peak per-rank
memory (the 4-bit first and second moments, plus block-quantization
metadata). Stochastic rounding (
bf16_stochastic_round: True) is enabled by the config and is required for stable pure-bf16 training with quantized moments.
Tuning attempts that did not help¶
The baseline configs above already sit right at the 24 GB memory edge. For each, I tried the next-most-obvious knob; every attempt below OOMed:
| Config | Attempted change | Result |
|---|---|---|
llama2_7b/1gpu_default.yaml |
--compile true (max-autotune) |
OOM — cudagraph scratch |
llama2_7b/1gpu_default.yaml |
--compile true --torch-compile-mode max-autotune-no-cudagraphs |
OOM — inductor workspace |
llama2_7b/2gpu_pp.yaml |
--seq-len 2048 (up from 1280) |
OOM |
llama2_7b/2gpu_pp.yaml |
--batch-size 4 (at seq_len 1280) |
OOM |
llama2_7b/4gpu_pp.yaml |
--batch-size 4 (at seq_len 2048) |
OOM |
llama2_7b/4gpu_pp.yaml |
--batch-size 4 --seq-len 1280 |
OOM |
llama2_7b/4gpu_pp.yaml |
--batch-size 4 --pipeline-schedule Schedule1F1B |
OOM |
The pipeline configs come in 1/2/4 GPU flavours because those are the shapes
of almost every real multi-GPU box. An 8-GPU ZBV pipeline would also work
with the current 4gpu_pp.yaml as a starting point — you'd mainly want to
re-tune batch_size and seq_len against the larger per-rank memory slice.
I don't have an 8-GPU rig to measure on, so there's no official config for
it in this project.
If you want to push further within a single card's memory budget, the
realistic path is to trade optimizer state for momentum back: Adafactor
without momentum is nearly state-free (~2 bytes/param in bf16 for its
row/column stats), so there's no memory to reclaim there. Where there is
room is swapping Adafactor for a quantized Adam — torchao's 4-bit Adam
keeps both moments at 4 bits per parameter (~1 GB extra on a 7B model) with
stochastic rounding for bf16 stability. That's still far cheaper than full
fp32 AdamW and gives you first-moment momentum back, which sometimes helps
convergence on small-batch fine-tuning. See
llama3_1b/ddp_adam4bit.yaml for a worked example.
Exercise: mixed-precision optimizer groups¶
The ddp_adam4bit.yaml config applies AdamW4bit uniformly to every
parameter. In principle, tensors whose natural scale is small (layer-norm
gains, biases, the embedding, the lm_head) are the ones you'd most expect
to misbehave under aggressive quantization, so splitting them into a
full-precision group is the obvious knob to try.
Two things to know before you start:
- torchao already auto-skips small params.
AdamW4bit._new_buffer(following the bitsandbytes convention) keeps state in native precision for any tensor with fewer than 4096 elements or whose numel is not divisible byblock_size(default 128). On Llama3 1B (hidden_size = 2048) and Llama2 7B (hidden_size = 4096) that means layer-norm gains and most biases never get quantized in the first place. The remaining candidates for an explicit full-precision group are therefore the large tensors:embed_tokens,lm_head, and anything else whose numel comfortably exceeds the threshold. [optimizer_groups]overrides per-group kwargs, not the optimizer class. The Forgather mechanism builds one optimizer factory and feeds itparam_groupswith per-group hyperparameter overrides (same shape thattorch.optimaccepts). You can changelr,weight_decay,betas, etc. per group, but you can't say "this group uses fulltorch.optim.AdamWand this group usesAdamW4bit" without writing a composite-optimizer wrapper. For an experiment that swaps the optimizer class per group you'd need to wrap two factories yourself — out of scope for the template, but a reasonable project.
With those caveats, there are still useful variants to try:
- Carve
embed_tokens/lm_headinto their own groups and give them a lowerlrorweight_decay. This is the standard "no-decay on embeddings" convention; the default[optimizer_groups]block inlm_training_project.yamlalready zeroes the decay, but you could go further and give them their own LR. - Verify what is actually being quantized. Pass
--debug-optimizer-groupsto have the trainer log every parameter → group assignment when the optimizer is built, and compare against your expectations. Combine with a short Python probe (the same one used to confirm the auto-skip behaviour) to see which tensors end up asOptimState4bitvs plainTensor.
Starting points:
examples/tiny_experiments/sinkgd/templates/exp.yaml— a multi-group[optimizer_groups]block that routes norms, biases, embeddings, and lm_head into three named groups with per-group kwargs.templatelib/examples/projects/lm_training_project.yaml— the default[optimizer_groups]block and thedebug_optimizer_groupstrainer arg.docs/project-templates/lm-training-projects.md#optimizer-parameter-groups— the reference for the override mechanism, including how to remove an inherited group by setting its value tonull.
Override the [optimizer_groups] block in a child config that extends
llama3_1b/ddp_adam4bit.yaml, enable --debug-optimizer-groups, run a
short smoke test, and compare training-loss trajectories against the
baseline over a few hundred steps. If the mixed-precision split makes a
measurable difference, you've found something worth committing.
Single Node Training¶
First, check if everything is working, like this:
forgather -t "llama2_7b/2gpu_pp.yaml" train --save-strategy no --max-steps 10 -M "${FG_MODEL}"
# Note that we don't need to specify the chat-template, as the conversion tool bakes it into the tokenizer.
Llama2 7B on multiple GPUs¶
# 2 GPU Pipeline Parallel (ZBV schedule, seq_len 1280)
forgather -t "llama2_7b/2gpu_pp.yaml" train -M "${FG_MODEL}"
# 4 GPU Pipeline Parallel (ZBV schedule, seq_len 2048)
forgather -t "llama2_7b/4gpu_pp.yaml" train -M "${FG_MODEL}"
# FSDP2 (requires a fast GPU interconnect to be worthwhile)
forgather -t "llama2_7b/fsdp2.yaml" train -M "${FG_MODEL}"
Llama3 1B on multiple GPUs¶
The 1B model is small enough that all three multi-GPU backends work out of the box. Use these to get a feel for the trade-offs on your hardware:
FG_MODEL_1B="${MODELS_DIR}/fg_Llama-3.2-1B-Instruct"
# Distributed Data Parallel — simplest multi-GPU scaling
forgather -t "llama3_1b/ddp.yaml" train -M "${FG_MODEL_1B}"
# FSDP2 — useful if you want to see how sharding behaves on the 1B model
forgather -t "llama3_1b/fsdp2.yaml" train -M "${FG_MODEL_1B}"
# Pipeline Parallel — ZBV schedule across all visible GPUs
forgather -t "llama3_1b/pp.yaml" train -M "${FG_MODEL_1B}"
Use -d 0,1 (or any comma-separated GPU list) to restrict the set of GPUs.
Testing the Finetuned Model¶
You can test the resulting model using the provided Open-AI compatible inference server and client or with 3rd party tools, like vLLM.
# Start inference server (from 'forgather' directory)
# Change the model path to match your output directory.
forgather inf server -c -m /path/to/fg_model
# Note: -c : This will search for the latest checkpoint, rather than loading the model from the root directory.
Test if inference is working:
forgather inf client --message "Hello, what is your name?"
Hi! I'm Samantha, and it's great to meet you.
Start an interactive session:
forgather inf client
Interactive Chat Mode (type 'quit', 'exit', or 'q' to quit)
Commands:
/clear - Clear conversation history
/system <message> - Set system prompt
/help - Show this help
> Hello Samantha. How are you feeling today?
I'm feeling quite engaged and excited to continue our exploration of new ideas and perspectives. What would you like to discuss today?
>
Test the model with text completion:
forgather inf client --completion "Once upon a time" --max-tokens 50
Once upon a time, before the age of social media, people used to write letters to each other. This was a way for them to express their thoughts, feelings, and emotions, and to stay connected with one another. Although letter-writing is not as common today
The server is Open-AI compatible, so you should be able to use any client compatible with this API.
Multi-node Training¶
This scenario is considerably more complex than the single-node scenario, with many factors requiring consideration. This will require gathering and configuring network settings, a shared file system, software compatibility, a strategy for loading the initial seed weights, a strategy for a shared dataset, and a strategy for saving checkpoints.
CLI Options¶
"forgather train" automatically sets the "torchrun" arguments for single-node training. For multi-node, you will need to explicitly pass them on the commandline.
To see what "torchrun" command will be used, without actually invoking it, pass the "--dry-run" argument. This can be used for diagnostics or as a starting point for manually invoking "torchrun."
To manually pass arguments to "torchrun," append "-- ARGS..." to the end of the command:
torchrun args¶
--nnodes NNODES : Number of nodes
--nproc-per-node NPROC_PER_NODE : Number of workers per node; supported values: [auto, cpu, gpu, int]
--rdzv-backend RDZV_BACKEND : Rendezvous backend
--rdzv-endpoint RDZV_ENDPOINT : Rendezvous backend endpoint; usually in form <host>:<port>
--rdzv-id RDZV_ID : User-defined group id
--rdzv-conf RDZV_CONF : Additional rendezvous configuration (<key1>=<value1>,<key2>=<value2>,...)
Examples
# Two GPUs
... --nnodes 1 --nproc-per-node 2
# Four GPUs
... --nnodes 2 --nproc-per-node 2
# or
... --nnodes 4 --nproc-per-node 1
If the nodes don't have the same number of GPU's, say one has 1 GPU and another has 3, then set nproc-per-node to match the number of GPUs on that node.
RDZV_BACKEND should be "c10d"
There are alternatives, but they are outside the scope of these instructions.
RDZV_ENDPOINT : One of the nodes but needs to be chosen to host the rendezvous, which will be used to coordinate the job. This can be a host-name or an IP address; the port is optional, but defaults to 29400.
RDZV_ID : A user defined group-id. This is just a number. Pick one and make sure to use the same values on all nodes.
RDZV_CONF : Additional args to pass to the rendezvous. As torchrun may have difficulty figuring out which machine is the host, pass "is_host=true" only on the host.
Environment Variables¶
There are a few environment variables you should be aware of. Environment variables can be set be prefixing the command with their values.
NCCL_SOCKET_IFNAME=IF_NAME
This explicitly sets the IP interface name to use for communication. NCCL communication is independent of the rendezvous config and has a tendency to pick the wrong Ethernet interface, if not explicitly told which one to use. Check the results of "ip addr" and find the name of the interface connected to the network you will be using.
TORCH_CPP_LOG_LEVEL=INFO or TORCH_DISTRIBUTED_DEBUG
These options enable additional synchronization checks and logging, which can be useful for debugging.
See reference
CUDA_LAUNCH_BLOCKING=1
This forces all communication to be synchronous. This is terrible for performance, but very useful for debugging hangs.
See reference
NCCL_DEBUG=TRACE or NCCL_DEBUG=INFO
These options cause NCCL to dump additional debug information which can be helpful for debugging communication issue.
See reference
All NCCL Environment Variables
Example¶
We will assume that we have two nodes:
hal9000
GPU(s): RTX 4090 x 6
IP Interface: enp37s0f1
Path to model (NFS share): /home/dinalt/ai_assets/models/fg_mistral
CWD (NFS share): /home/dinalt/ai_assets/forgather
muthur
GPU(s): RTX 3090 x 1
IP Interface: eno1
Path to model (NFS share): /mnt/ai_assets/models/fg_mistral
CWD (NFS share): /mnt/ai_assets/ai_assets/forgather
We have configured a NFS volume, where "/home/dinalt/ai_assets/," on "hal9000" is mounted at "/mnt/ai_assets" on "muthur." Our current working directories on each node correspond to "Path to Forgather," which ensures that the configuration files are identical on both nodes, even if we make changes. The model directory, "fg_mistral," is also shared between the two hosts.
We will have hal9000 host the rendezvous and we will be using the "llama2_7b/2gpu_pp.yaml" config, which is for 2 GPUs.
Start job on "hal9000"
NCCL_SOCKET_IFNAME=enp37s0f1 forgather -t llama2_7b/2gpu_pp.yaml -p examples/finetune/samantha/ train \
-M /home/dinalt/ai_assets/models/fg_mistral -- --nnodes 2 --nproc-per-node 1 --rdzv-backend c10d \
--rdzv-endpoint hal9000:29400 --rdzv-id 1 --rdzv-conf "is_host=true"
Start job on "muthur"
NCCL_SOCKET_IFNAME=eno1 forgather -t llama2_7b/2gpu_pp.yaml -p examples/finetune/samantha/ train \
-M /home/dinalt/ai_assets/models/fg_mistral -- --nnodes 2 --nproc-per-node 1 --rdzv-backend c10d \
--rdzv-endpoint hal9000:29400 --rdzv-id 1
The command are nearly identical, excepting these point: - The IP interface name matches that of the host it is running on (NCCL_SOCKET_IFNAME). Without specifying this, NCCL may pick the wrong interface to bind to. - The path to the shared model directory. It's the same set of files, just via a different path. - Only hal9000 has --rdzv-conf "is_host=true," which is needed because "torchrun" is not very good at correctly inferring that this is the rendezvous host.
The order in which they are started is not critical, although there is a 60 second timeout window in which to start all of the hosts.
Note that these machines have different GPU types. This works, but the slower of the two, the RTX 3090, is going to be a bottleneck. In theory, it should be possible to make this work with an asymmetric numbers of GPUs, say 3 GPUs on one machine, and 1 on the other, but "torchrun" does not support it. Supporting such a configuration is on my "TODO" list.
Network Setup¶
Pipeline parallel requires relatively low bandwidth; a plain Gigabit Ethernet link should suffice. WiFi is probably workable too, as long as there is a strong signal, plenty of bandwidth, and reasonably low latency, but a wired network is preferable.
Ideally, all of the nodes should be in the same subnet. Although there is no reason that it should not work through a router, the router could potentially be a bottleneck and adds latency.
If you have a firewall enabled, you will need to add exceptions for the participating hosts/ports. If you encounter communications issues, I would recommend disabling your firewall(s) temporarily, as this makes debugging the issue much easier. You will need to have port 29400 open for the rendezvous. Additional ports will be needed for the communication backend. By default, NCCL will use any available ephemeral port, which complicates firewall setup. You can find instructions for narrowing the range of ports used here.
Similarly, if you are using Docker, you will need to ensure that the required ports can be reached from your network. The easiest solution is to specify "--network host," which provides direct access to all of the host's network interfaces.
Shared File System Setup¶
While not strictly required, having a shared filed system greatly simplifies things. I would suggest setting up a shared NFS volume, which will be assumed for the remainder of the tutorial. Consult your favorite search engine or LLM for details on how to do this.
Software Setup¶
Ideally, all nodes should have an identical software environment. Using a common Docker container is the safest approach, although a fresh Python virtual environment may be sufficient.
If things are not working as expected, double-check that all of your package versions match!
Verify Software Versions Match¶
Before starting multi-node training, verify all nodes have matching PyTorch and NCCL versions:
# Run on each node
python -c 'import torch; print(f"PyTorch: {torch.__version__}\nNCCL: {torch.cuda.nccl.version()}")'
Example output:
All nodes must show identical versions. Even minor version differences will cause "Mismatched NCCL version" errors.
Initial Checkpoint¶
When training starts, we need to load the initial checkpoint. One was to solve this issue is to use store the model in a shared NFS directory, as we do in the above example. This can be a bit slow to load, although, it will be cached for subsequent runs. This is my recommend approach.
Note on Network Storage Performance: When loading models over NFS or network storage, initial model loading can take significant time (e.g., ~90 seconds for a 14GB model over Gigabit Ethernet). This is a one-time cost at the start of training - subsequent steps use cached data and run at normal speed. This is expected behavior, not a problem.
An alternative is to place an identical (local) copy of the initial weights on each node and specify the checkpoint to load on the commandline.
The primary disadvantage to this approach is that if you need to resume from a new checkpoint, you will need to remove this argument. This can be an issue if torchrun is configured for fault-tolerance. When a failure occurs, it will roll-back to the latest checkpoint by restarting the training script. With this parameter set, this will cause it to rollback to the start, rather than to the latest checkpoint.
Output Directory¶
We will saved checkpoints (and logs) in the output directory, which defaults to the model directory. When this is an NFS share, saving (and loading) checkpoints can be pretty slow. An alternative is to specify a unique local output directory on each node. In this case, each node will save (and load) only its shards in that directory. The only disadvantage to this approach is that the checkpoints will be scattered across all nodes and these will need to be collected at the end of training into a common directory before the model can be used for inference.
The "--save-on-each-node" flag will result in each node saving a copy of the files which are common to all nodes. In this case, the "pytorch_model.bin.index.json" and "eval_metrics.json" files. Don't use this option when using a shared directory, as it may corrupt the shared files.
tip
You can also use the "--output-dir" option when using a shared output directory (don't pass "--save-on-each-node"). If you copy everything (config.yaml, source-code, tokenizer), excepting the model weights from the original directory, the checkpoint saving logic will automatically symlink the saved weights from the latest checkpoint into the root of the output directory. This can be useful for testing the model with external tools, while the model is still training. For example, with text-generation-webui.
Troubleshooting¶
"Mismatched NCCL version detected"¶
Symptom: Multi-node training fails with error like:
Cause: Different PyTorch/NCCL versions on different nodes. This can happen when PyTorch releases a new version while you're testing.
Solution: 1. Verify versions on all nodes:
2. Use the same Python environment (venv/conda) on all nodes 3. If using containers, ensure all nodes use the exact same container imageTraining Hangs on Multi-node Setup¶
Symptoms: Training starts but hangs at initialization or after a few steps.
Debugging steps: 1. Check network connectivity: Ensure all nodes can reach each other on the required ports
-
Check NCCL interface: NCCL may be trying to use the wrong network interface
-
Enable debug logging:
-
Test with synchronous execution (slow but helps identify hangs):
Model Loading Takes Extremely Long (Multi-node)¶
Symptom: First training step takes 60-90 seconds, then subsequent steps are normal speed.
This is expected behavior, not a bug! When loading a 14GB model over Gigabit Ethernet (~125 MB/s theoretical max), it takes time: - 14GB model / 125 MB/s ≈ 112 seconds (theoretical) - Real-world with overhead: 60-90 seconds
Solutions:
- Accept it: It's a one-time cost at training start
- Use local copies: Copy model to local disk on each node, use --resume-from-checkpoint
- Upgrade network: Use 10GbE if available
FileNotFoundError: model.safetensors¶
Symptom: Training fails looking for model-00001-of-00002.safetensors.
Cause: Downloaded both PyTorch and SafeTensors formats, but SafeTensors index file exists while weights don't.
Solution: Exclude SafeTensors files during download:
huggingface-cli download mistralai/Mistral-7B-v0.1 --local-dir "${SRC_MODEL}" \
--exclude "*.safetensors" "model.safetensors.index.json"
Alternatively, delete the "index" file, for which weights don't exist.
Common Warnings and Expected Behaviors¶
Control Callback Shutdown Warning¶
When training ends, you may see:
WARNING:forgather.ml.trainer.callbacks.trainer_control:Control callback shutdown timed out after 2.0 seconds
This is normal and can be safely ignored. The control interface cleanup times out but doesn't affect training results.
HuggingFace CLI Deprecation Warning¶
You may see warnings about deprecated huggingface-cli download syntax. These can be safely ignored - the commands in this tutorial work correctly despite the warnings. The newer CLI command is "hf," although I have yet to write instructions for using it.
Finalizing the Model¶
When training completes, the output directory contains the latest weights plus
an accumulation of training-only state: multiple checkpoints, an optimizer
state file per checkpoint, scheduler / dataset / RNG / trainer state files,
training logs, and eval results. Most external tools and chat clients expect
a flat HuggingFace-shaped directory with weights at the root. Use
forgather finalize to consolidate to a clean handoff directory while
leaving the original training output untouched (so reproducing the run is
still possible):
# Default: trim to the latest checkpoint, drop scheduler / dataset / RNG /
# trainer state, and create root-level symlinks pointing into the kept
# checkpoint dir so HuggingFace AutoModel.from_pretrained(dest) works.
forgather finalize \
"${FG_MODEL}" \
"${MODELS_DIR}/fg_samantha_final"
# Pick a specific (non-latest) checkpoint:
forgather finalize \
"${FG_MODEL}" \
"${MODELS_DIR}/fg_samantha_step5000" \
-c "${FG_MODEL}/checkpoints/checkpoint-5000"
# Carry optimizer state too (warm-start a follow-on fine-tune):
forgather finalize \
"${FG_MODEL}" \
"${MODELS_DIR}/fg_samantha_warm" \
--keep-optimizer
# Single-copy layout: weights at the root, no checkpoints/ subdirectory.
forgather finalize \
"${FG_MODEL}" \
"${MODELS_DIR}/fg_samantha_flat" \
--root-copy
The destination is HuggingFace-loadable directly (the Forgather modelling
code is shipped alongside the weights, hence trust_remote_code=True):
python -c "from transformers import AutoModelForCausalLM; \
m = AutoModelForCausalLM.from_pretrained('${MODELS_DIR}/fg_samantha_final', \
trust_remote_code=True)"
See docs/guides/finalize-model.md
for the full reference, including how --add-tokens and --chat-template-path
can be combined with finalize to update the chat template or graft on
additional tokens at the same time.
Exporting back to native HuggingFace format¶
forgather finalize produces a directory that's HF-loadable but still
ships Forgather's custom modelling code (the *.py files alongside the
weights, accessed via trust_remote_code=True). For tools that won't
load remote code -- or for sharing with a wider audience that expects a
stock LlamaForCausalLM -- run forgather convert on the finalized
output to round-trip back into native HuggingFace Llama format:
# Auto-detects direction from the source's hf_model_type metadata
# (set during the original HF -> Forgather conversion).
forgather convert --dtype bfloat16 \
"${MODELS_DIR}/fg_samantha_final" \
"${MODELS_DIR}/Llama-2-7b-samantha"
The export preserves:
- The full
eos_token_idlist ingeneration_config.json-- including any ChatML stop tokens grafted on during the original conversion or finalize step. Verify with:
$ cat "${MODELS_DIR}/Llama-2-7b-samantha/generation_config.json"
{
...
"eos_token_id": [2, 32000], # original </s> + ChatML <|im_end|>
...
}
-
All other generation parameters from the source's
generation_config.json(do_sample,temperature,top_p,repetition_penalty, etc.). The exporter assigns the source'sGenerationConfigto the rebuilt HF model before saving, sosave_pretrainedwrites a faithful copy rather than synthesizing one frommodel.configalone. -
The chat template, tokenizer (including any added tokens / pad), and the model's full vocabulary.
The exported directory is loadable with vanilla HuggingFace -- no
trust_remote_code argument needed:
python -c "from transformers import AutoModelForCausalLM; \
m = AutoModelForCausalLM.from_pretrained('${MODELS_DIR}/Llama-2-7b-samantha')"
For background on why the multi-token EOS list matters and how
generate() actually uses it, see
docs/guides/eos-and-generate-stopping.md.