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:
| Dimension | Scope | Tool |
|---|---|---|
| Data Parallelism (DP) | Different examples per replica | DDP / FSDP |
| Tensor Parallelism (TP) | Within a layer across GPUs | Megatron-LM |
| Pipeline Parallelism (PP) | Across layer groups | Megatron-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.
| Stage | What Is Sharded | Memory Saving |
|---|---|---|
| ZeRO-1 | Optimizer states | ~4× |
| ZeRO-2 | Optimizer states + gradients | ~8× |
| ZeRO-3 | Optimizer 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 stage | Recommended frequency |
|---|---|
| Early training (first 5%) | Every 500 steps |
| Mid training | Every 1000 steps |
| Final fine-tuning | Every 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 Case | Config | GPU Type | Topology |
|---|---|---|---|
| 7B model SFT | 8× GPU, 1 node | A100 80 GB | DDP |
| 13B model SFT | 8× GPU, 1 node | A100 80 GB | DDP + FSDP |
| 70B pre-training | 64× GPU, 8 nodes | H100 80 GB | DP=8, TP=4, PP=2 |
| 405B pre-training | 512× GPU, 64 nodes | H100 80 GB | DP=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
| Stack | When to Use | Strengths | Limitations |
|---|---|---|---|
| vLLM | Open models, high throughput | PagedAttention, continuous batching | GPU only |
| TGI | HuggingFace models, multi-GPU | Flash Attention, safety | Less flexible |
| Triton + TRT-LLM | Enterprise, SLA-critical | NVIDIA-optimized, FP8 | Complex setup |
| llama.cpp | CPU / consumer hardware | GGUF, memory efficient | Limited throughput |
| Ollama | Local dev + API | Easiest setup | Not for production scale |
| OpenAI API | Any | No infra management | Cost, 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
| Strategy | When to Use | Memory Savings | Complexity |
|---|---|---|---|
| DDP | Model fits on 1 GPU | None (copies full model) | Low |
| FSDP Stage 1 | Optimizer memory is the bottleneck | ~4× | Medium |
| FSDP Stage 2 | Gradient memory is the bottleneck | ~8× | Medium |
| FSDP Stage 3 | Model doesn’t fit on 1 GPU | ~64× | High |
| ZeRO-Infinity | Model doesn’t fit on all GPUs | Scales to disk | Very High |
| Tensor Parallel | Very large FFN layers | Depends on TP degree | High |
| Pipeline Parallel | 50B+ params across nodes | Minimal | Very High |
Common Serving Stack Configurations
| Model Size | Hardware | Stack | Config |
|---|---|---|---|
| 7B | 1× RTX 4090 (24GB) | Ollama / llama.cpp | GGUF Q4_K_M |
| 7B | 1× A100 (80GB) | vLLM | bf16, 1 GPU |
| 13B | 2× A100 (80GB) | vLLM | bf16, TP=2 |
| 70B | 4× A100 (80GB) | vLLM | bf16, TP=4 |
| Mixtral 8x7B | 2× A100 (80GB) | vLLM | AWQ, TP=2 |
| 405B | 8× H100 (80GB) | vLLM | FP8, 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
| Setup | Bandwidth | Suitable for |
|---|---|---|
| NVLink (within node, A100) | 600 GB/s bidirectional | Tensor parallelism |
| NVSwitch (within node, H100) | 900 GB/s bidirectional | Tensor parallelism |
| InfiniBand HDR (across nodes) | 200 Gb/s | Pipeline + data parallelism |
| InfiniBand NDR (across nodes) | 400 Gb/s | Large-scale training |
| 10 GbE Ethernet | 10 Gb/s | Small 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'}
