Llm finetuning¶
LLM fine-tuning with SPDL data loading pipeline.
Fine-tunes LLaMA 3.2 1B on Alpaca-style instruction data using LoRA, with SPDL PipelineBuilder for high-performance concurrent tokenization.
SPDL Data Pipeline¶
The core of this example is the SPDL data loading pipeline:
DistributedRandomSampler– distributes sample indices across ranks with per-epoch reshufflingpipe(tokenize, concurrency=N)– concurrent Alpaca-format prompt formatting and tokenizationaggregate(batch_size)– groups into batchespipe(collate)– stacks tensorsadd_sink(buffer_size=3)– prefetch buffer for the training loop
Data¶
Download instruction-following datasets:
# https://github.com/tatsu-lab/stanford_alpaca
python download_alpaca.py --output /tmp/alpaca.jsonl
# https://huggingface.co/datasets/databricks/databricks-dolly-15k
python download_dolly.py --output /tmp/dolly.jsonl
Data format (JSONL with Alpaca-style fields):
{"instruction": "Explain what a linked list is.", "input": "", "output": "A linked list is..."}
Usage¶
torchrun \
--nproc_per_node 8 \
-m spdl.examples.llm_finetune.llm_finetuning \
--model-path /path/to/Llama-3.2-1B-Instruct \
--data-path \
/tmp/alpaca.jsonl \
/tmp/dolly.jsonl
With the default settings (global batch size 8x32), the training throughput reaches roughly ~570 samples on H100 GPUs.
Source¶
Source
Click here to see the source.
1# Copyright (c) Meta Platforms, Inc. and affiliates.
2# All rights reserved.
3#
4# This source code is licensed under the BSD-style license found in the
5# LICENSE file in the root directory of this source tree.
6
7"""LLM fine-tuning with SPDL data loading pipeline.
8
9Fine-tunes LLaMA 3.2 1B on Alpaca-style instruction data using LoRA,
10with SPDL PipelineBuilder for high-performance concurrent tokenization.
11
12SPDL Data Pipeline
13^^^^^^^^^^^^^^^^^^
14
15The core of this example is the SPDL data loading pipeline:
16
171. ``DistributedRandomSampler`` -- distributes sample indices across ranks
18 with per-epoch reshuffling
192. ``pipe(tokenize, concurrency=N)`` -- concurrent Alpaca-format prompt
20 formatting and tokenization
213. ``aggregate(batch_size)`` -- groups into batches
224. ``pipe(collate)`` -- stacks tensors
235. ``add_sink(buffer_size=3)`` -- prefetch buffer for the training loop
24
25Data
26^^^^
27
28Download instruction-following datasets::
29
30 # https://github.com/tatsu-lab/stanford_alpaca
31 python download_alpaca.py --output /tmp/alpaca.jsonl
32 # https://huggingface.co/datasets/databricks/databricks-dolly-15k
33 python download_dolly.py --output /tmp/dolly.jsonl
34
35Data format (JSONL with Alpaca-style fields)::
36
37 {"instruction": "Explain what a linked list is.", "input": "", "output": "A linked list is..."}
38
39Usage
40^^^^^
41
42::
43
44 torchrun \\
45 --nproc_per_node 8 \\
46 -m spdl.examples.llm_finetune.llm_finetuning \\
47 --model-path /path/to/Llama-3.2-1B-Instruct \\
48 --data-path \\
49 /tmp/alpaca.jsonl \\
50 /tmp/dolly.jsonl
51
52With the default settings (global batch size 8x32), the training throughput reaches roughly ~570
53samples on H100 GPUs.
54"""
55
56from __future__ import annotations
57
58__all__ = [
59 "build_model",
60 "build_pytorch_dataloader",
61 "build_spdl_dataloader",
62 "load_data",
63 "main",
64 "train",
65]
66
67
68import argparse
69import logging
70import os
71import time
72from collections.abc import Callable
73from datetime import timedelta
74from pathlib import Path
75
76import torch
77import torch.distributed as dist
78from torch.nn.parallel import DistributedDataParallel as DDP
79
80try:
81 from examples.llm_finetune.utils.dataloader import ( # pyre-ignore[21]
82 build_pytorch_dataloader,
83 )
84 from examples.llm_finetune.utils.pipeline import ( # pyre-ignore[21]
85 build_spdl_dataloader,
86 )
87 from examples.llm_finetune.utils.utils import ( # pyre-ignore[21]
88 load_data,
89 report_progress,
90 resolve_model_path,
91 )
92except ImportError:
93 from spdl.examples.llm_finetune.utils.dataloader import build_pytorch_dataloader
94 from spdl.examples.llm_finetune.utils.pipeline import (
95 build_spdl_dataloader,
96 )
97 from spdl.examples.llm_finetune.utils.utils import (
98 load_data,
99 report_progress,
100 resolve_model_path,
101 )
102
103_LG: logging.Logger = logging.getLogger(__name__)
104
105
106# ---------------------------------------------------------------------------
107# Model setup
108# ---------------------------------------------------------------------------
109
110
111def build_model(
112 model_path: str,
113 device: torch.device,
114 lora_r: int,
115 lora_alpha: int,
116 lora_dropout: float,
117) -> torch.nn.Module:
118 """Load LLaMA model and apply LoRA."""
119 from peft import get_peft_model, LoraConfig, TaskType
120 from transformers import AutoModelForCausalLM
121
122 _LG.info("Loading model from %s", model_path)
123 model = AutoModelForCausalLM.from_pretrained(
124 model_path,
125 torch_dtype=torch.bfloat16,
126 attn_implementation="sdpa",
127 )
128
129 lora_config = LoraConfig(
130 task_type=TaskType.CAUSAL_LM,
131 r=lora_r,
132 lora_alpha=lora_alpha,
133 lora_dropout=lora_dropout,
134 target_modules=["q_proj", "v_proj"],
135 )
136 model = get_peft_model(model, lora_config)
137 model.print_trainable_parameters()
138
139 model = model.to(device)
140 return model
141
142
143# ---------------------------------------------------------------------------
144# Training
145# ---------------------------------------------------------------------------
146
147
148def train(
149 *,
150 model_path: str,
151 data_path: list[str],
152 output_dir: str,
153 max_seq_len: int,
154 batch_size: int,
155 num_epochs: int,
156 lr: float,
157 weight_decay: float,
158 max_grad_norm: float,
159 log_interval: int,
160 lora_r: int,
161 lora_alpha: int,
162 lora_dropout: float,
163 num_workers: int,
164 dataloader: str = "spdl",
165 mp_context: str = "forkserver",
166 progress_fn: Callable[[int, int], None] | None = None,
167) -> None:
168 """Main training function, called per-rank."""
169 if dist.is_initialized():
170 rank = dist.get_rank()
171 world_size = dist.get_world_size()
172 else:
173 rank = 0
174 world_size = 1
175 local_rank: int = int(os.environ.get("LOCAL_RANK", 0))
176 if torch.cuda.is_available():
177 device = torch.device(f"cuda:{local_rank}")
178 torch.cuda.set_device(device)
179 else:
180 device = torch.device("cpu")
181
182 _LG.info(
183 "Rank %d/%d on device %s (dataloader=%s)",
184 rank,
185 world_size,
186 device,
187 dataloader,
188 )
189
190 # --- Data ---
191 samples = load_data(data_path)
192
193 from transformers import AutoTokenizer
194
195 tokenizer = AutoTokenizer.from_pretrained(model_path)
196 if tokenizer.pad_token is None:
197 tokenizer.pad_token = tokenizer.eos_token
198
199 # --- Model ---
200 model = build_model(
201 model_path,
202 device,
203 lora_r=lora_r,
204 lora_alpha=lora_alpha,
205 lora_dropout=lora_dropout,
206 )
207 if dist.is_initialized():
208 ddp_model = DDP(
209 model,
210 device_ids=[local_rank] if torch.cuda.is_available() else None,
211 )
212 else:
213 ddp_model = model
214
215 # --- Optimizer ---
216 optimizer = torch.optim.AdamW(
217 ddp_model.parameters(),
218 lr=lr,
219 weight_decay=weight_decay,
220 foreach=True,
221 )
222
223 num_steps_per_epoch = len(samples) // (batch_size * world_size)
224 total_steps = num_steps_per_epoch * num_epochs
225 if rank == 0:
226 _LG.info(
227 "Training: %d samples, %d epochs, %d steps/epoch, %d total steps",
228 len(samples),
229 num_epochs,
230 num_steps_per_epoch,
231 total_steps,
232 )
233 if progress_fn is not None:
234 progress_fn(0, total_steps)
235
236 scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
237 optimizer,
238 T_max=total_steps,
239 eta_min=lr * 0.1,
240 )
241
242 # --- Build data source ---
243 if dataloader == "pytorch":
244 dl = build_pytorch_dataloader(
245 samples=samples,
246 tokenizer=tokenizer,
247 max_seq_len=max_seq_len,
248 batch_size=batch_size,
249 rank=rank,
250 world_size=world_size,
251 num_workers=num_workers,
252 mp_context=mp_context,
253 device=device,
254 )
255 else:
256 dl = build_spdl_dataloader(
257 samples=samples,
258 tokenizer=tokenizer,
259 max_seq_len=max_seq_len,
260 batch_size=batch_size,
261 rank=rank,
262 world_size=world_size,
263 num_threads=num_workers,
264 mp_context=mp_context,
265 )
266
267 # --- Training loop ---
268 global_step = 0
269 ddp_model.train()
270
271 for epoch in range(num_epochs):
272 _LG.info("Epoch %d/%d", epoch + 1, num_epochs)
273
274 t0 = time.monotonic()
275 epoch_loss = 0.0
276 num_batches = 0
277
278 for batch in dl:
279 outputs = ddp_model(
280 input_ids=batch["input_ids"],
281 attention_mask=batch["attention_mask"],
282 labels=batch["labels"],
283 )
284 loss = outputs.loss
285
286 loss.backward()
287 torch.nn.utils.clip_grad_norm_(ddp_model.parameters(), max_grad_norm)
288 optimizer.step()
289 scheduler.step()
290 optimizer.zero_grad()
291
292 epoch_loss += loss.item()
293 num_batches += 1
294 global_step += 1
295
296 if rank == 0:
297 if progress_fn is not None:
298 progress_fn(global_step, total_steps)
299 if global_step % log_interval == 0:
300 avg_loss = epoch_loss / num_batches
301 elapsed = time.monotonic() - t0
302 _LG.info(
303 "Step %d | loss=%.4f | lr=%.2e | %.1f samples/s",
304 global_step,
305 avg_loss,
306 scheduler.get_last_lr()[0],
307 num_batches * batch_size * world_size / elapsed,
308 )
309
310 elapsed = time.monotonic() - t0
311 if rank == 0:
312 avg_loss = epoch_loss / max(num_batches, 1)
313 _LG.info(
314 "Epoch %d complete | avg_loss=%.4f | %.1fs | %.1f samples/s",
315 epoch + 1,
316 avg_loss,
317 elapsed,
318 num_batches * batch_size * world_size / elapsed,
319 )
320
321 # --- Save ---
322 if rank == 0 and output_dir:
323 output_path = Path(output_dir)
324 output_path.mkdir(parents=True, exist_ok=True)
325 model.save_pretrained(output_path) # pyre-ignore[29]
326 tokenizer.save_pretrained(output_path)
327 _LG.info("Model saved to %s", output_path)
328
329
330def parse_args() -> argparse.Namespace:
331 parser = argparse.ArgumentParser(description=__doc__)
332 # Model
333 parser.add_argument(
334 "--model-path",
335 type=str,
336 required=True,
337 help="Path to pretrained LLaMA model directory",
338 )
339 parser.add_argument(
340 "--output-dir",
341 type=str,
342 default="",
343 help="Directory to save fine-tuned LoRA weights",
344 )
345 # Data
346 parser.add_argument(
347 "--data-path",
348 type=str,
349 nargs="+",
350 required=True,
351 help="One or more paths to Alpaca-format JSONL files (local or manifold://).",
352 )
353 parser.add_argument("--max-seq-len", type=int, default=512)
354 # Training
355 parser.add_argument("--batch-size", type=int, default=4)
356 parser.add_argument("--num-epochs", type=int, default=10)
357 parser.add_argument("--lr", type=float, default=5e-4)
358 parser.add_argument("--weight-decay", type=float, default=0.01)
359 parser.add_argument("--max-grad-norm", type=float, default=1.0)
360 parser.add_argument("--log-interval", type=int, default=10)
361 # LoRA
362 parser.add_argument("--lora-r", type=int, default=8)
363 parser.add_argument("--lora-alpha", type=int, default=16)
364 parser.add_argument("--lora-dropout", type=float, default=0.05)
365 # Pipeline
366 parser.add_argument(
367 "--num-workers",
368 type=int,
369 default=8,
370 help="Concurrent tokenization workers in the data pipeline",
371 )
372 parser.add_argument(
373 "--dataloader",
374 type=str,
375 choices=["spdl", "pytorch"],
376 default="spdl",
377 help="Data loading backend: 'spdl' (default) or 'pytorch' (torch DataLoader)",
378 )
379 parser.add_argument(
380 "--mp-context",
381 type=str,
382 choices=["fork", "spawn", "forkserver"],
383 default="forkserver",
384 help="Multiprocessing context for workers (default: forkserver)",
385 )
386 return parser.parse_args()
387
388
389def init_logging() -> None:
390 """Initialize logging."""
391 rank = os.environ.get("RANK", "?")
392 logging.basicConfig(
393 level=logging.INFO,
394 format=f"%(asctime)s [%(levelname).1s] [Rank{rank}] %(name)s: %(message)s",
395 )
396
397
398def main(args: argparse.Namespace) -> None:
399 use_distributed = "RANK" in os.environ
400 if use_distributed:
401 backend = "nccl" if torch.cuda.is_available() else "gloo"
402 dist.init_process_group(backend=backend, timeout=timedelta(minutes=3))
403 try:
404 train(
405 model_path=resolve_model_path(args.model_path),
406 data_path=args.data_path,
407 output_dir=args.output_dir,
408 max_seq_len=args.max_seq_len,
409 batch_size=args.batch_size,
410 num_epochs=args.num_epochs,
411 lr=args.lr,
412 weight_decay=args.weight_decay,
413 max_grad_norm=args.max_grad_norm,
414 log_interval=args.log_interval,
415 lora_r=args.lora_r,
416 lora_alpha=args.lora_alpha,
417 lora_dropout=args.lora_dropout,
418 num_workers=args.num_workers,
419 dataloader=args.dataloader,
420 mp_context=args.mp_context,
421 progress_fn=report_progress,
422 )
423 finally:
424 if use_distributed:
425 dist.destroy_process_group()
426
427
428if __name__ == "__main__":
429 init_logging()
430 main(parse_args())
API Reference¶
Functions
- build_model(model_path: str, device: device, lora_r: int, lora_alpha: int, lora_dropout: float) Module[source]¶
Load LLaMA model and apply LoRA.
- build_pytorch_dataloader(samples: list[dict[str, str]], tokenizer: PreTrainedTokenizerBase, max_seq_len: int, batch_size: int, rank: int, world_size: int, num_workers: int, mp_context: str = 'forkserver', device: torch.device | None = None) _TDataLoader[source]¶
Build a reusable PyTorch DataLoader for distributed LLM fine-tuning.
Build once before the training loop and reuse across epochs. The returned wrapper automatically calls
DistributedSampler.set_epochon each iteration, so no manualset_epochcall is needed. Similarly, yielded batches are automatically transferred to the given device, if any, so no manualto(device)call is needed.
- build_spdl_dataloader(samples: list[dict[str, str]], tokenizer: PreTrainedTokenizerBase, max_seq_len: int, batch_size: int, rank: int, world_size: int, num_threads: int, mp_context: str = 'forkserver') _TDataLoader[source]¶
Build a reusable SPDL data loader with nested pipeline architecture.
Creates two nested pipelines to separate CPU-bound data loading from GPU transfer, reducing the noisy-neighbour effect where data loading threads in the main process compete with the training loop for CPU time, delaying GPU kernel launches.
- Inner pipeline (runs in a subprocess):
Sampling → lookup → tokenize (concurrent) → aggregate → collate. All CPU work runs in a dedicated subprocess with its own thread pool, completely isolating it from the training process. The subprocess is created once and reused across epochs — each
for ... incall rebuilds the pipeline inside the same subprocess.- Outer pipeline (runs in the main process):
Receives CPU batches from the subprocess via IPC queue and transfers them to GPU using
transfer_tensorwith a dedicated single-thread executor. This ensures GPU transfer uses a consistent CUDA stream and overlaps with training computation.
Build once before the training loop and iterate each epoch:
dataloader = build_spdl_dataloader(samples, tokenizer, ...) for epoch in range(num_epochs): for batch in dataloader: train(batch)
- load_data(paths: Sequence[str]) list[dict[str, str]][source]¶
Load and concatenate data from one or more JSONL files.
- train(*, model_path: str, data_path: list[str], output_dir: str, max_seq_len: int, batch_size: int, num_epochs: int, lr: float, weight_decay: float, max_grad_norm: float, log_interval: int, lora_r: int, lora_alpha: int, lora_dropout: float, num_workers: int, dataloader: str = 'spdl', mp_context: str = 'forkserver', progress_fn: Callable[[int, int], None] | None = None) None[source]¶
Main training function, called per-rank.