Post

Distributed Training and Serving Architecture for LLMs: What Engineers Need to Know

A serious understanding of LLM systems eventually runs into infrastructure reality. Large models are not only machine learning artifacts. They are distributed systems. Training and serving them at useful scale requires careful handling of memory, parallelism, checkpoints, scheduling, and runtime efficiency.

This article provides an engineering view of distributed training and serving architecture for LLMs.

1. Why Distribution Is Required

Modern LLMs exceed the memory and throughput limits of a single device. Distribution becomes necessary because of:

  • model size
  • optimizer state size
  • activation memory
  • batch throughput requirements
  • serving concurrency requirements

This is true in both training and inference, although the bottlenecks differ.

2. Training Has Three Big Infrastructure Constraints

Training large models is shaped by:

  • memory capacity
  • interconnect bandwidth
  • storage and checkpoint throughput

A model may fit in total memory across devices and still train poorly if communication overhead is too high.

3. Common Parallelism Strategies

Data parallelism

Each worker processes different mini-batches and gradients are synchronized. This is simple conceptually, but becomes memory-heavy with very large models.

Tensor parallelism

Individual layers are split across devices. This helps when a single layer is too large for one accelerator.

Pipeline parallelism

Different groups of layers are placed on different devices and execution is staged through the model.

In practice, large training jobs often combine these strategies.

4. Serving Has Different Constraints Than Training

Training optimizes throughput over long runs. Serving must optimize responsiveness, concurrency, and cost.

A serving stack usually cares more about:

  • time to first token
  • tokens per second
  • batch scheduling
  • KV cache handling
  • autoscaling behavior

This is why a training architecture is not automatically a good serving architecture.

5. Checkpointing and Reliability

Long-running jobs need robust checkpoint strategy. Otherwise, hardware failure or preemption can waste major compute.

Useful checkpoint concerns include:

  • checkpoint size
  • save frequency
  • recovery speed
  • storage bandwidth
  • compatibility across training phases

These are operational details, but they determine whether large-scale work is practical.

6. Runtime Efficiency Matters at Every Layer

A production serving system is influenced by:

  • tokenizer efficiency
  • prompt assembly overhead
  • retrieval latency
  • model runtime implementation
  • networking and placement
  • output validation and logging

In other words, serving cost is not just a model problem. It is an end-to-end systems problem.

7. Capacity Planning Is a Product Decision Too

A system that serves millions of low-complexity requests per day should not be architected the same way as a high-value reasoning system with low traffic and long contexts.

Capacity planning should be driven by:

  • request mix
  • latency targets
  • concurrency profile
  • context size distribution
  • acceptable cost per request

8. Minimal Parallelism Mental Model

A useful practical way to think about the three major training strategies is:

  • data parallelism splits examples
  • tensor parallelism splits computation within layers
  • pipeline parallelism splits layer groups across stages

That simple mental model is often enough for engineers who need to reason about architecture without implementing the runtime themselves.

9. Common Failure Modes

  • model fits in memory but communication overhead dominates
  • checkpointing is too slow for practical recovery
  • serving throughput is optimized at the expense of user latency
  • autoscaling reacts too late to traffic bursts
  • retrieval and prompt assembly erase model runtime gains

10. What to Measure

Useful infrastructure metrics include:

  • device utilization
  • inter-device communication overhead
  • checkpoint time
  • queue wait time
  • time to first token
  • tokens per second
  • cost per request

These make it possible to distinguish algorithmic limits from infrastructure limits.

11. Data Parallelism with PyTorch DDP and FSDP

DistributedDataParallel (DDP)

DDP replicates the full model on every GPU. Gradients are synchronized via all-reduce after each backward pass.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler

def setup(rank, world_size):
    dist.init_process_group("nccl", rank=rank, world_size=world_size)
    torch.cuda.set_device(rank)

def cleanup():
    dist.destroy_process_group()

def train_ddp(rank, world_size, model_fn, dataset):
    setup(rank, world_size)
    model = model_fn().to(rank)
    ddp_model = DDP(model, device_ids=[rank])

    sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
    loader  = torch.utils.data.DataLoader(dataset, sampler=sampler, batch_size=32)

    optimizer = torch.optim.AdamW(ddp_model.parameters(), lr=1e-4)

    for epoch in range(10):
        sampler.set_epoch(epoch)   # important: shuffles differently per epoch
        for batch in loader:
            inputs, labels = batch
            inputs, labels = inputs.to(rank), labels.to(rank)
            outputs = ddp_model(inputs)
            loss = criterion(outputs, labels)
            optimizer.zero_grad()
            loss.backward()        # gradients all-reduced automatically
            optimizer.step()

    cleanup()

# Launch with torchrun:
# torchrun --nproc_per_node=4 train.py

Fully Sharded Data Parallel (FSDP)

FSDP shards model parameters, gradients, and optimizer states across GPUs — enabling training of models that do not fit in a single device’s memory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from torch.distributed.fsdp import (
    FullyShardedDataParallel as FSDP,
    MixedPrecision,
    BackwardPrefetch,
    ShardingStrategy,
)
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
from transformers import LlamaDecoderLayer
import functools

# Mixed precision config
mp_policy = MixedPrecision(
    param_dtype=torch.bfloat16,
    reduce_dtype=torch.float32,
    buffer_dtype=torch.bfloat16,
)

# Auto-wrap policy: shard at the transformer block level
auto_wrap = functools.partial(
    transformer_auto_wrap_policy,
    transformer_layer_cls={LlamaDecoderLayer},
)

model = FSDP(
    model,
    auto_wrap_policy=auto_wrap,
    mixed_precision=mp_policy,
    sharding_strategy=ShardingStrategy.FULL_SHARD,   # FULL_SHARD / SHARD_GRAD_OP / NO_SHARD
    backward_prefetch=BackwardPrefetch.BACKWARD_PRE,
    device_id=rank,
)

When to use FSDP over DDP:

  • Model exceeds single-GPU memory even at fp16
  • Optimizer states are large (AdamW stores 2× params in fp32)
  • You need 70B+ models on commodity hardware

12. Tensor Parallelism with Megatron-LM

Tensor parallelism (TP) splits individual weight matrices column-wise or row-wise across GPUs. Each GPU holds a shard of each weight, and all-reduce is applied on the activations.

1
2
3
4
5
6
7
8
9
10
11
# Conceptual view: column-parallel linear
#   W [H, 4H] → W1 [H, 2H] on GPU0, W2 [H, 2H] on GPU1

# In Megatron-LM, layers are auto-split by tensor_parallel_size:
# python pretrain_gpt.py \
#   --tensor-model-parallel-size 4 \
#   --pipeline-model-parallel-size 2 \
#   --num-layers 32 \
#   --hidden-size 4096 \
#   --num-attention-heads 32 \
#   ...

Practical rules:

  • TP works best within a single node (NVLink or NVSwitch) — all-reduce is expensive over InfiniBand.
  • Use TP=4 or TP=8 within a node, PP across nodes.

13. Pipeline Parallelism and 3D Parallelism

Pipeline parallelism (PP) assigns different transformer layers to different devices (or nodes). Execution is pipelined using micro-batches.

1
2
3
4
5
6
7
8
9
Device 0: Layers 0-7
Device 1: Layers 8-15
Device 2: Layers 16-23
Device 3: Layers 24-31

Micro-batch schedule (1F1B — interleaved):
  Step 1: Device 0 forward  (micro-batch 1)
  Step 2: Device 0 forward  (micro-batch 2) + Device 1 forward (micro-batch 1)
  ...

3D Parallelism combines all three:

DimensionScopeTool
Data Parallelism (DP)Different examples per replicaDDP / FSDP
Tensor Parallelism (TP)Within a layer across GPUsMegatron-LM
Pipeline Parallelism (PP)Across layer groupsMegatron-LM / DeepSpeed

A typical large-scale training setup for a 70B model:

1
2
Total GPUs = DP × TP × PP
= 8 (DP) × 4 (TP) × 8 (PP) = 256 GPUs

14. DeepSpeed ZeRO Optimization

DeepSpeed’s ZeRO (Zero Redundancy Optimizer) eliminates state redundancy across data-parallel ranks.

StageWhat Is ShardedMemory Saving
ZeRO-1Optimizer states~4×
ZeRO-2Optimizer states + gradients~8×
ZeRO-3Optimizer states + gradients + parameters~64×
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# ds_config.json
{
  "train_batch_size": 512,
  "gradient_accumulation_steps": 4,
  "bf16": {"enabled": true},
  "zero_optimization": {
    "stage": 3,
    "offload_optimizer": {"device": "cpu", "pin_memory": true},
    "offload_param":     {"device": "cpu", "pin_memory": true},
    "overlap_comm": true,
    "contiguous_gradients": true,
    "reduce_scatter": true,
    "allgather_partitions": true,
    "allgather_bucket_size": 5e8,
    "reduce_bucket_size": 5e8
  },
  "gradient_clipping": 1.0,
  "steps_per_print": 100
}
1
2
# Launch with DeepSpeed
deepspeed --num_gpus=8 train.py --deepspeed ds_config.json

ZeRO-Infinity extends ZeRO-3 to NVMe SSD offloading — enabling trillion-parameter models on clusters where GPU memory alone is insufficient.


15. Flash Attention and Memory-Efficient Training

Flash Attention rewrites the attention kernel to avoid materializing the full N×N attention matrix in HBM (high-bandwidth memory), dramatically reducing memory usage and increasing throughput.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Flash Attention via PyTorch SDPA (PyTorch 2.0+)
import torch
import torch.nn.functional as F

# Standard (materializes N×N matrix in HBM — O(N²) memory)
# attn = Q @ K.T / sqrt(d_k)
# attn = softmax(attn) @ V

# Flash Attention (fused kernel, O(N) HBM memory)
with torch.backends.cuda.sdp_kernel(
    enable_flash=True,
    enable_math=False,
    enable_mem_efficient=True
):
    output = F.scaled_dot_product_attention(Q, K, V, attn_mask=None, dropout_p=0.0)
1
2
3
4
5
6
7
8
9
10
# Or install flash-attn directly
pip install flash-attn --no-build-isolation

# In Hugging Face transformers:
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Meta-Llama-3-8B",
    attn_implementation="flash_attention_2",
    torch_dtype=torch.bfloat16,
)

Flash Attention benefits:

  • 2–4× faster than standard attention on long sequences
  • 5–20× less memory usage for attention computation
  • Enables training on sequences > 32K tokens

16. Gradient Checkpointing

Gradient checkpointing trades compute for memory by recomputing activations during the backward pass instead of storing them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from torch.utils.checkpoint import checkpoint

class TransformerBlock(torch.nn.Module):
    def forward(self, x):
        # Use gradient checkpointing to save activation memory
        return checkpoint(self._forward, x, use_reentrant=False)

    def _forward(self, x):
        x = self.attention(x)
        x = self.ffn(x)
        return x

# In HuggingFace Transformers:
model.gradient_checkpointing_enable()

# Or via FSDP config:
from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import (
    checkpoint_wrapper,
    CheckpointImpl,
    apply_activation_checkpointing,
)

check_fn = lambda submodule: isinstance(submodule, LlamaDecoderLayer)
apply_activation_checkpointing(
    model,
    checkpoint_wrapper_fn=checkpoint_wrapper,
    check_fn=check_fn,
)

Trade-off: ~30–35% compute overhead in exchange for dramatically reduced activation memory — often enabling 2× larger batch sizes.


17. Checkpointing Strategy

Efficient Checkpointing with FSDP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from torch.distributed.fsdp import FullStateDictConfig, StateDictType

# Save consolidated checkpoint (all ranks combine to rank-0)
with FSDP.state_dict_type(
    model,
    StateDictType.FULL_STATE_DICT,
    FullStateDictConfig(offload_to_cpu=True, rank0_only=True),
):
    state_dict = model.state_dict()
    if rank == 0:
        torch.save(state_dict, "checkpoint.pt")

# Save sharded checkpoint (each rank saves its shard — faster)
with FSDP.state_dict_type(model, StateDictType.SHARDED_STATE_DICT):
    state_dict = model.state_dict()
    dist.barrier()
    torch.save(state_dict, f"checkpoint_rank{rank}.pt")

Checkpoint frequency heuristics

Training stageRecommended frequency
Early training (first 5%)Every 500 steps
Mid trainingEvery 1000 steps
Final fine-tuningEvery 250 steps
Long jobs (>100 hrs)Every 2 hours wall-clock

Use async checkpointing where possible — write to NVMe or object storage while training continues.


18. Serving Architecture: vLLM

vLLM is the dominant open-source serving engine for LLMs, implementing PagedAttention for efficient KV cache management.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from vllm import LLM, SamplingParams

# Load model with tensor parallelism across 4 GPUs
llm = LLM(
    model="meta-llama/Meta-Llama-3-8B-Instruct",
    tensor_parallel_size=4,        # spread across 4 GPUs
    gpu_memory_utilization=0.90,   # use 90% of available VRAM for KV cache
    max_model_len=8192,
    dtype="bfloat16",
    quantization="awq",            # optional: load pre-quantized weights
)

sampling = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
    stop=["\n\n###"],
)

outputs = llm.generate(
    ["Explain transformer architecture in detail.", "What is quantization?"],
    sampling,
)
for output in outputs:
    print(output.outputs[0].text)

Deploy as REST API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Start vLLM OpenAI-compatible server
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Meta-Llama-3-8B-Instruct \
    --tensor-parallel-size 4 \
    --gpu-memory-utilization 0.90 \
    --max-model-len 8192 \
    --port 8000

# Query the API
curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Meta-Llama-3-8B-Instruct",
    "messages": [{"role": "user", "content": "Hello!"}],
    "max_tokens": 200
  }'

PagedAttention

PagedAttention manages KV cache memory in fixed-size pages (blocks), similar to virtual memory in operating systems. This eliminates fragmentation and enables:

  • Continuous batching (process new requests without waiting for a batch to finish)
  • Efficient KV cache sharing between requests (prefix caching)
  • Near-zero memory waste from padding
1
2
3
4
5
6
7
8
Traditional KV Cache:
  Request 1 [1024 tokens]: ████████████████████ (pre-allocated maximum)
  Request 2 [256 tokens]:  █████░░░░░░░░░░░░░░░ (75% wasted)

PagedAttention:
  Request 1: [Page1][Page2][Page3][Page4] → exactly 4 pages used
  Request 2: [Page5]                      → exactly 1 page used
  Free pool: [Page6][Page7]...[PageN]     → available for new requests

19. Continuous Batching and Autoscaling

Continuous batching

Unlike static batching (wait for full batch, process, return), continuous batching processes requests on a token-by-token basis — adding new requests as soon as slots free up.

1
2
3
4
5
6
7
8
9
Static batching:
  T=0: [Req1, Req2, Req3] → process → return all at T=10
  T=10: [Req4, Req5]      → process → return all at T=15

Continuous batching:
  T=0:  [Req1, Req2, Req3] started
  T=3:  Req2 finishes → Req4 immediately joins
  T=5:  Req1 finishes → Req5 joins
  ...   throughput increases, p50 latency drops

Autoscaling (Kubernetes)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# HPA config for vLLM deployment
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-server
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: External
    external:
      metric:
        name: vllm_request_queue_depth
      target:
        type: AverageValue
        averageValue: "5"    # scale up when avg queue depth > 5

20. Triton Inference Server

For multi-framework production serving with strict SLA management:

1
2
3
4
5
6
7
8
9
# Pull and run TensorRT-LLM backend
docker pull nvcr.io/nvidia/tritonserver:24.01-trtllm-python-py3

# Model repository structure
model_repo/
  llama3-8b/
    config.pbtxt
    1/
      model.engine    # TensorRT-LLM compiled engine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# config.pbtxt
name: "llama3-8b"
backend: "tensorrtllm"
max_batch_size: 256

parameters {
  key: "max_beam_width"
  value: { string_value: "1" }
}
parameters {
  key: "executor_worker_path"
  value: { string_value: "/opt/tritonserver/backends/tensorrtllm/trtllmExecutorWorker" }
}

dynamic_batching {
  preferred_batch_size: [64, 128, 256]
  max_queue_delay_microseconds: 5000    # 5ms max wait for batch fill
}

21. GPU Cluster Configurations

Common training cluster layouts

Use CaseConfigGPU TypeTopology
7B model SFT8× GPU, 1 nodeA100 80 GBDDP
13B model SFT8× GPU, 1 nodeA100 80 GBDDP + FSDP
70B pre-training64× GPU, 8 nodesH100 80 GBDP=8, TP=4, PP=2
405B pre-training512× GPU, 64 nodesH100 80 GBDP=16, TP=8, PP=4

Network topology matters

1
2
3
4
5
6
7
Within a Node (NVLink/NVSwitch):
  GPU0 ↔ GPU1 ↔ GPU2 ↔ GPU3 (600 GB/s bidirectional)
  → Tensor parallelism ideal here

Across Nodes (InfiniBand):
  Node0 ↔ Node1 ↔ ... ↔ NodeN (100–400 Gb/s)
  → Pipeline parallelism and data parallelism

Use TP within nodes (fast NVLink) and PP/DP across nodes (InfiniBand).


22. Memory Profiling and Optimization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import torch

# Profile GPU memory usage during training
def profile_memory_usage(model, input_batch, device="cuda"):
    torch.cuda.reset_peak_memory_stats(device)
    torch.cuda.synchronize(device)

    before = torch.cuda.memory_allocated(device) / 1e9
    with torch.cuda.amp.autocast(dtype=torch.bfloat16):
        output = model(**input_batch)
        loss   = output.loss
        loss.backward()

    torch.cuda.synchronize(device)
    after   = torch.cuda.memory_allocated(device) / 1e9
    peak    = torch.cuda.max_memory_allocated(device) / 1e9

    print(f"Memory before: {before:.2f} GB")
    print(f"Memory after:  {after:.2f} GB")
    print(f"Peak usage:    {peak:.2f} GB")

# Memory breakdown estimator
def estimate_model_memory(
    num_params: int,
    precision_bytes: int = 2,   # 2 for bf16, 4 for fp32
    optimizer_states: int = 8,  # AdamW: 2 fp32 states × 4 bytes each
) -> dict:
    param_memory      = num_params * precision_bytes / 1e9
    gradient_memory   = num_params * precision_bytes / 1e9
    optimizer_memory  = num_params * optimizer_states / 1e9
    return {
        "parameters_GB":     round(param_memory, 2),
        "gradients_GB":      round(gradient_memory, 2),
        "optimizer_GB":      round(optimizer_memory, 2),
        "total_GB":          round(param_memory + gradient_memory + optimizer_memory, 2),
    }

# 7B model in bf16 with AdamW:
print(estimate_model_memory(7_000_000_000))
# parameters: 14 GB, gradients: 14 GB, optimizer: 56 GB → total: 84 GB
# → needs at least 2× A100 80 GB with FSDP

23. Benchmarking Serving Performance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import asyncio
import aiohttp
import time
from statistics import mean, quantiles

async def send_request(session, url, payload):
    start = time.perf_counter()
    async with session.post(url, json=payload) as resp:
        data = await resp.json()
        latency = time.perf_counter() - start
        tokens = data["usage"]["completion_tokens"]
        return latency, tokens

async def benchmark(url, payloads, concurrency=10):
    results = []
    async with aiohttp.ClientSession() as session:
        for i in range(0, len(payloads), concurrency):
            batch = payloads[i:i+concurrency]
            tasks = [send_request(session, url, p) for p in batch]
            batch_results = await asyncio.gather(*tasks)
            results.extend(batch_results)

    latencies = [r[0] for r in results]
    tokens    = [r[1] for r in results]
    total_tokens_per_sec = sum(tokens) / sum(latencies) * concurrency

    q = quantiles(latencies, n=100)
    print(f"Requests:       {len(results)}")
    print(f"p50 latency:    {q[49]:.3f}s")
    print(f"p95 latency:    {q[94]:.3f}s")
    print(f"p99 latency:    {q[98]:.3f}s")
    print(f"Throughput:     {total_tokens_per_sec:.1f} tokens/s")

24. Mixed Precision Training

Mixed precision uses bf16 or fp16 for forward/backward passes and fp32 for optimizer updates, halving memory usage and doubling throughput on modern GPUs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from torch.cuda.amp import GradScaler

scaler = GradScaler()   # for fp16 (handles gradient underflow)

for batch in dataloader:
    optimizer.zero_grad()
    with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
        output = model(**batch)
        loss   = output.loss

    # bf16 doesn't need GradScaler (no underflow risk)
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    scheduler.step()

# For fp16, use scaler:
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()

25. Infrastructure Cost Estimation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def estimate_training_cost(
    model_params: int,
    tokens_to_train: int,
    gpu_flops_per_sec: float = 312e12,  # A100 bf16: 312 TFLOPS
    mfu: float = 0.40,                  # typical model FLOP utilization
    gpu_cost_per_hour: float = 3.00,    # USD/GPU/hour
    num_gpus: int = 64,
) -> dict:
    # ~6 FLOPs per parameter per token (forward + backward)
    total_flops    = 6 * model_params * tokens_to_train
    effective_flops = gpu_flops_per_sec * mfu * num_gpus
    training_seconds = total_flops / effective_flops
    training_hours   = training_seconds / 3600
    total_cost       = training_hours * gpu_cost_per_hour * num_gpus

    return {
        "training_hours":  round(training_hours, 1),
        "gpu_hours":       round(training_hours * num_gpus, 0),
        "estimated_cost":  f"${total_cost:,.0f}",
    }

# 7B model, 1T tokens, 64× A100
print(estimate_training_cost(7e9, 1e12, num_gpus=64))
# → ~3000 GPU hours, ~$576k

Production Cluster Setup: Quick Reference

A100 / H100 Training Cluster

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# torchrun multi-node launch (2 nodes, 8 GPUs each = 16 total)
# Run on EACH node:
torchrun \
    --nnodes=2 \
    --nproc_per_node=8 \
    --node_rank=$NODE_RANK \
    --master_addr=$MASTER_ADDR \
    --master_port=29500 \
    train.py \
    --model_name meta-llama/Meta-Llama-3-8B \
    --per_device_train_batch_size 4 \
    --gradient_accumulation_steps 8 \
    --fsdp "full_shard auto_wrap" \
    --fsdp_transformer_layer_cls_to_wrap LlamaDecoderLayer \
    --bf16 True \
    --max_seq_length 4096

# With DeepSpeed ZeRO-3:
torchrun --nnodes=2 --nproc_per_node=8 train.py \
    --deepspeed ds_config_zero3.json

Kubernetes GPU Job (A100 cluster)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: batch/v1
kind: Job
metadata:
  name: llm-training-job
spec:
  template:
    spec:
      containers:
      - name: trainer
        image: nvcr.io/nvidia/pytorch:24.01-py3
        resources:
          limits:
            nvidia.com/gpu: 8
        env:
        - name: MASTER_ADDR
          value: "llm-training-job-0.llm-training"
        command:
        - torchrun
        - --nnodes=4
        - --nproc_per_node=8
        - train.py
      nodeSelector:
        cloud.google.com/gke-accelerator: nvidia-a100-80gb

Model Serving Infrastructure Reference

StackWhen to UseStrengthsLimitations
vLLMOpen models, high throughputPagedAttention, continuous batchingGPU only
TGIHuggingFace models, multi-GPUFlash Attention, safetyLess flexible
Triton + TRT-LLMEnterprise, SLA-criticalNVIDIA-optimized, FP8Complex setup
llama.cppCPU / consumer hardwareGGUF, memory efficientLimited throughput
OllamaLocal dev + APIEasiest setupNot for production scale
OpenAI APIAnyNo infra managementCost, data privacy

Monitoring Production Serving

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from prometheus_client import Counter, Histogram, Gauge, start_http_server

# Metrics for LLM serving
REQUEST_LATENCY = Histogram(
    "llm_request_duration_seconds",
    "Time to complete LLM request",
    buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0],
)
TOKEN_THROUGHPUT = Counter("llm_tokens_generated_total", "Total tokens generated")
QUEUE_DEPTH      = Gauge("llm_request_queue_depth", "Current request queue depth")
GPU_UTIL         = Gauge("gpu_utilization_percent", "GPU utilization", ["gpu_id"])
KV_CACHE_HIT     = Counter("llm_kv_cache_hits_total", "KV cache prefix hits")

start_http_server(9090)   # Prometheus scrapes from this port

@REQUEST_LATENCY.time()
def serve_request(prompt: str) -> str:
    QUEUE_DEPTH.inc()
    try:
        result = llm.generate(prompt)
        TOKEN_THROUGHPUT.inc(len(result.split()))
        return result
    finally:
        QUEUE_DEPTH.dec()

Conclusion

Distributed training and serving architecture are essential parts of serious LLM engineering because they determine whether model quality can be delivered at useful scale. Understanding the parallelism strategies (DP, TP, PP, ZeRO), memory optimization techniques (Flash Attention, gradient checkpointing, mixed precision), and serving systems (vLLM, PagedAttention, continuous batching) is what separates LLM practitioners who can prototype from those who can ship and operate production systems at scale. The model may attract the attention, but infrastructure is what decides whether that model can actually be trained, deployed, and operated efficiently within real-world cost and latency constraints. Investment in proper monitoring, capacity planning, and fault tolerance is what makes large-scale LLM systems reliable over time.


Distributed Training Quick Reference

StrategyWhen to UseMemory SavingsComplexity
DDPModel fits on 1 GPUNone (copies full model)Low
FSDP Stage 1Optimizer memory is the bottleneck~4×Medium
FSDP Stage 2Gradient memory is the bottleneck~8×Medium
FSDP Stage 3Model doesn’t fit on 1 GPU~64×High
ZeRO-InfinityModel doesn’t fit on all GPUsScales to diskVery High
Tensor ParallelVery large FFN layersDepends on TP degreeHigh
Pipeline Parallel50B+ params across nodesMinimalVery High

Common Serving Stack Configurations

Model SizeHardwareStackConfig
7B1× RTX 4090 (24GB)Ollama / llama.cppGGUF Q4_K_M
7B1× A100 (80GB)vLLMbf16, 1 GPU
13B2× A100 (80GB)vLLMbf16, TP=2
70B4× A100 (80GB)vLLMbf16, TP=4
Mixtral 8x7B2× A100 (80GB)vLLMAWQ, TP=2
405B8× H100 (80GB)vLLMFP8, TP=8

Training Cost Estimator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def training_cost_estimate(
    model_params:    int,
    training_tokens: int,
    gpu_type:        str  = "A100_80GB",
    num_gpus:        int  = 64,
    cloud_cost_hr:   float = 3.0,
) -> dict:
    GPU_TFLOPS = {
        "A100_80GB": 312e12,
        "H100_80GB": 989e12,
        "RTX_4090":  165e12,
    }
    mfu        = 0.40   # typical model FLOP utilization

    total_flops = 6 * model_params * training_tokens
    eff_flops   = GPU_TFLOPS.get(gpu_type, 312e12) * mfu * num_gpus
    hours       = total_flops / eff_flops / 3600
    cost_usd    = hours * cloud_cost_hr * num_gpus

    return {
        "training_hours":    round(hours, 1),
        "gpu_hours":         round(hours * num_gpus),
        "estimated_cost":    f"${cost_usd:,.0f}",
        "cost_per_1B_token": f"${cost_usd / max(training_tokens / 1e9, 1):.2f}",
    }

# 7B model, 1T tokens, 64× A100
print(training_cost_estimate(7e9, 1e12, "A100_80GB", 64, 3.0))
# {'training_hours': 282.5, 'gpu_hours': 18080, 'estimated_cost': '$54,240', 'cost_per_1B_token': '$54.24'}

# 70B model, 2T tokens, 256× H100
print(training_cost_estimate(70e9, 2e12, "H100_80GB", 256, 4.5))
# {'training_hours': 83.4, 'gpu_hours': 21350, 'estimated_cost': '$384,300', 'cost_per_1B_token': '$192.15'}

Communication Bandwidth Reference

SetupBandwidthSuitable for
NVLink (within node, A100)600 GB/s bidirectionalTensor parallelism
NVSwitch (within node, H100)900 GB/s bidirectionalTensor parallelism
InfiniBand HDR (across nodes)200 Gb/sPipeline + data parallelism
InfiniBand NDR (across nodes)400 Gb/sLarge-scale training
10 GbE Ethernet10 Gb/sSmall DDP only

Key insight: Tensor parallelism requires NVLink bandwidth. Use TP within nodes, PP/DP across nodes via InfiniBand.


Fault Tolerance and Recovery

Large training jobs fail. Hardware errors, preemptions, and network issues are common at scale. Design for recovery from the start.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# torchrun auto-restart on failure
# Save checkpoint every 500 steps; restart from last good checkpoint

#!/bin/bash
# train_with_restart.sh
MAX_RESTARTS=5
RESTART_COUNT=0

while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do
    torchrun --nnodes=8 --nproc_per_node=8 train.py \
        --resume_from_checkpoint ./checkpoints/latest
    EXIT_CODE=$?
    if [ $EXIT_CODE -eq 0 ]; then
        echo "Training completed successfully"
        exit 0
    fi
    RESTART_COUNT=$((RESTART_COUNT + 1))
    echo "Training failed (attempt $RESTART_COUNT/$MAX_RESTARTS). Restarting..."
    sleep 30
done
echo "Max restarts exceeded. Training failed."
exit 1

Training Infrastructure Cost Reference

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Estimate training cost for a given config
def training_cost_estimate(
    model_params:    int,
    training_tokens: int,
    gpu_type:        str  = "A100_80GB",
    num_gpus:        int  = 64,
    cloud_cost_hr:   float = 3.0,
) -> dict:
    GPU_TFLOPS = {"A100_80GB": 312e12, "H100_80GB": 989e12, "RTX_4090": 165e12}
    mfu        = 0.40   # typical model FLOP utilization

    total_flops = 6 * model_params * training_tokens
    eff_flops   = GPU_TFLOPS[gpu_type] * mfu * num_gpus
    hours       = total_flops / eff_flops / 3600
    cost_usd    = hours * cloud_cost_hr * num_gpus

    return {
        "training_hours":    round(hours, 1),
        "gpu_hours":         round(hours * num_gpus),
        "estimated_cost":    f"${cost_usd:,.0f}",
        "cost_per_1B_token": f"${cost_usd / (training_tokens / 1e9):.2f}",
    }

# Examples:
print(training_cost_estimate(7e9, 1e12, "A100_80GB", 64))   # 7B model, 1T tokens
# {'training_hours': 282, 'gpu_hours': 18048, 'estimated_cost': '$54,145'}

print(training_cost_estimate(70e9, 2e12, "H100_80GB", 512))  # 70B model, 2T tokens
# {'training_hours': 263, 'gpu_hours': 134656, 'estimated_cost': '$403,968'}
This post is licensed under CC BY 4.0 by the author.