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:

  1. DistributedRandomSampler – distributes sample indices across ranks with per-epoch reshuffling

  2. pipe(tokenize, concurrency=N) – concurrent Alpaca-format prompt formatting and tokenization

  3. aggregate(batch_size) – groups into batches

  4. pipe(collate) – stacks tensors

  5. add_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_epoch on each iteration, so no manual set_epoch call is needed. Similarly, yielded batches are automatically transferred to the given device, if any, so no manual to(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 ... in call 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_tensor with 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.

main(args: Namespace) None[source]
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.