Sign in to keep reading
“ML Model Distribution” is free with an account. Your progress and bookmarks follow you across devices.
Sign in to continueNEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
NEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
Design an ML Model Distribution System - This system design isn't just about moving files — it's about understanding P2P propagation, bandwidth bottlenecks, and building fault-tolerant delivery to thousands of GPU workers.
Written by a Staff+ Engineer and an Engineering Manager
12+ years of experience in distributed systems and real-time messaging. Tech lead for large-scale chat applications serving billions of users. Over 12 years of industry experience with 6 years in engineering leadership, building and scaling tech infrastructure teams that deliver end-to-end large-scale distributed systems.
“ML Model Distribution” is free with an account. Your progress and bookmarks follow you across devices.
Sign in to continueWhat to Design?
Could you answer these today? Tick what you already know.
| System Constraints | |
|---|---|
| Parameter | Value |
| Model size | 500 GB |
| Workers | 1,000 GPU machines |
| External bandwidth | 10 Gbps (internet → data center) |
| Internal bandwidth | 10 Gbps full-duplex per worker |
| Goal | Minimize time to deploy to ALL workers |
| Reliability | Must survive worker crashes mid-transfer |
"Full-duplex" means a worker can upload at 10 Gbps AND download at 10 Gbps simultaneously. This is the key multiplier that makes P2P distribution viable.
FR1 — Download the model from an external source
The system must pull a 500GB model from the internet or a remote object store (e.g., S3) through the single 10 Gbps external pipe. This is the entry point and the fundamental bottleneck.
FR2 — Deliver the full model to every GPU worker
Every one of the 100–1,000 GPU workers must receive a complete, verified copy of the model before it can be used for inference. Partial delivery is not acceptable.
FR3 — Minimize total deployment time
The design must treat time-to-full-delivery as the primary optimization metric. Naive sequential approaches are disqualifying. The architecture must exploit internal bandwidth.
NFR1 — Fault tolerance: No single non-seed worker failure should abort the transfer. Chunks must have redundant sources.
NFR2 — Bandwidth efficiency: The system should saturate the 10 Gbps external pipe as the only bottleneck. Internal propagation should not become a ceiling.
NFR3 — Scalability: The architecture should work for 10,000 workers with minimal design changes — O(log N) propagation, not O(N).
NFR4 — Integrity: Each chunk must be verified (e.g., via checksum) before a worker marks it as complete or begins seeding it.
NFR5 — Observability: The system must provide a definitive signal when all workers reach complete status, enabling the orchestrator to proceed with inference job scheduling. Completion is derived by the Tracker — no separate signal needed from workers.
The question below marked [Core Thinking] is answered directly in the Core Thinking section. The remaining four are addressed in the Deep Dives.
The single 10 Gbps external pipe is the fundamental constraint. Everything else is about making that pipe the only bottleneck — never a node inside the cluster.
The key realization: once any chunk is inside the cluster, it can spread at internal speeds — effectively free. The problem reduces to: how fast can you fill the cluster from one slow external pipe?
There are three plausible approaches. Understanding why each one fails or succeeds — and doing the math — is what separates a passing answer from a staff-level one.
The seed node receives the full 500GB from the internet (~400 sec), then pushes it to Worker 1, then Worker 2, one by one. Each worker waits for the full file before the next starts.
Result: ~111 hours for 1,000 workers.
Time = N × file_size / bandwidth
= 1,000 × 500 GB / 10 Gbps
= ~111 hours
Every worker waits in line. The external pipe is used once per worker sequentially — catastrophically wasteful.
Each node that receives the full file forwards it to 2 children simultaneously. Logarithmic rounds instead of linear steps.
Result: ~67 minutes.
Rounds = ceil(log₂(N)) = 10 rounds
Time per round = file_size / bandwidth = 400 sec
Total = 10 × 400 sec = ~4,000 sec (~67 min)
Better — logarithmic rounds. But each round must wait for the full 500GB file to land before the next round starts. No pipelining. The file is still treated as an atomic blob.
Split the file into chunks. The seed node begins seeding chunk 1 the moment it lands — while chunk 2 is still downloading. Workers immediately become seeders for each verified chunk, spreading it to peers in parallel.
Result: ~7 minutes.
Download time = file_size / bandwidth = 400 sec
Propagation = ceil(log₂(N)) × chunk_transfer_time
= 10 × 0.8 sec = 8 sec
Total = ~408 sec (~7 min)
The key difference: chunking eliminates per-round idle time. Each chunk is an independent unit that enters the propagation graph the moment it lands — not after the full file arrives. The external pipe is always saturated, and internal propagation overlaps with ongoing downloads.
The staff-level answer: Pipeline beats tree because it eliminates the per-round wait. It beats sequential because workers become distributors, not consumers. The bottleneck stays the external pipe regardless of fleet size.
| Strategy Comparison | ||
|---|---|---|
| Strategy | Time (1,000 workers) | Why it falls short |
| Sequential | ~111 hours | O(N) — each worker waits in line |
| Binary Tree | ~67 min | O(log N) rounds but no pipelining — full file per round |
| Chunked P2P Pipeline | ~7 min ✅ | O(log N) rounds + pipelining — chunks overlap with download |
Each round, every seeder sends a chunk to one new worker. The count doubles every round:
Round 0: 1 seeder (seed node)
Round 1: 2 seeders
Round 2: 4 seeders
Round 3: 8 seeders
...
Round 10: 1,024 seeders → covers all 1,000 workers ✓
Rounds needed:
ceil(log₂(1000)) = 10 rounds
Time per round (1 GB chunk at 10 Gbps):
1 GB / 10 Gbps = 0.8 sec per round
Total propagation overhead:
10 rounds × 0.8 sec = ~8 seconds
Staff+ signal: Propagation is O(log N). At 10,000 workers: ceil(log₂(10000)) = 14 rounds × 0.8s = ~11 sec overhead. The bottleneck stays the external pipe regardless of fleet size.
A single 500GB blob must be fully received before it can be forwarded. Chunks solve three problems:
| How Chunking Solves Core Problems | |
|---|---|
| Problem | How chunking solves it |
| No pipelining | Workers seed chunk 1 while chunk 2 still downloads |
| Wasted bandwidth | Workers pull different chunks from multiple peers in parallel |
| Full-restart on failure | Only in-flight chunks need re-fetch, not the whole file |
Optimal chunk size tradeoff:
| Chunk Size Tradeoffs | |
|---|---|
| Chunk size | Tradeoff |
| Too small (1 MB) | Millions of chunks → metadata overhead collapses performance |
| Too large (50 GB) | Loses pipelining; effectively back to blob transfer |
| Sweet spot (1–4 GB) | Balances metadata cost with transfer pipelining benefit |
The BitTorrent Insight
This problem is BitTorrent disguised as a systems design question. The optimal solution turns workers from passive consumers into active distributors. The moment you recognize this, the design falls into place.
| Design Decisions | |
|---|---|
| Design choice | Reason |
| Chunked transfer | Enables pipelining and parallel seeding |
| P2P propagation | Turns O(N) problem into O(log N) |
The Tracker is the only centralized component. It does not move data — it only moves metadata. All actual data flows peer-to-peer between workers. Transfer is intentionally excluded — it is soft operational state (a metrics log or Datadog event), not a domain entity the system depends on to function.
The ML artifact being distributed. One record per deployment job.
| Model | ||
|---|---|---|
| Column | Type | Comment |
model_id | UUID | Primary key |
name | VARCHAR(255) | Human-readable name, e.g. llama-3-70b |
version | ||
Atomic unit of transfer. One model splits into many chunks; each is independently downloaded, verified, and seeded.
| Chunk | ||
|---|---|---|
| Column | Type | Comment |
chunk_id | UUID | Primary key |
model_id | UUID | FK → Model.model_id |
index | ||
Chunk
statusis seed-node-only state.downloadingmeans the seed node is actively pulling this chunk from the external object store — it does not exist inside the cluster yet.availablemeans the seed holds it and P2P propagation can begin. Workers never write toChunk.status— their per-chunk progress is tracked entirely throughChunkAvailabilityrows (one row inserted per worker per verified chunk).
A GPU machine registered in the distribution mesh. A worker simultaneously downloads chunks it doesn't have yet and seeds chunks it has already verified — these are not mutually exclusive phases.
| Worker | ||
|---|---|---|
| Column | Type | Comment |
worker_id | UUID | Primary key |
ip_address | VARCHAR(45) | Internal IP; used by peers to open direct HTTP connections |
hostname | VARCHAR(255) | |
Worker status lifecycle:
| Worker Status Lifecycle | |
|---|---|
| Status | Meaning |
idle | Registered but has received no chunks yet |
active | Normal operating state — downloading missing chunks AND seeding already-verified chunks simultaneously |
complete | Holds all chunks; derived by Tracker when COUNT(ChunkAvailability WHERE worker_id = X) == Model.chunk_count |
offline | No heartbeat >10s; removed from all peer maps |
Why not separate
downloadingandseedingstates? Because they happen at the same time. A worker that has verified chunks 1–50 is actively seeding those to peers while pulling chunks 51–500 in parallel. Splitting them into separate states implies mutual exclusivity that doesn't exist —activecorrectly captures this concurrent reality.
The only centralized component. Its sole job is knowing which workers hold which chunks and answering peer lookup requests. It never touches model data — all byte transfers flow directly between workers.
The Tracker holds four in-memory structures:
WorkerMap — HashMap<worker_id, WorkerState>. Every registered worker entry with IP, status, active upload count, and last heartbeat. Written on POST /tracker/register and POST /tracker/heartbeat. Read on every peer lookup to filter out offline workers and sort by upload load.
| WorkerMap | ||
|---|---|---|
| Column | Type | Comment |
worker_id | UUID | Key |
ip_address | VARCHAR(45) | Used by peers to open direct connections |
status | ENUM | |
ChunkAvailability — HashMap<chunk_id, Vec<worker_id>>. The hot index powering all peer lookups. One entry per (chunk, worker) pair, appended on every POST /tracker/chunk-complete. Read on every GET /tracker/peers.
| ChunkAvailability | ||
|---|---|---|
| Column | Type | Comment |
chunk_id | UUID | FK → Chunk.chunk_id |
worker_id | UUID | FK → Worker.worker_id |
verified_at | ||
HeartbeatTimer — background loop running every ~1s. Scans WorkerMap; any worker with last_heartbeat_at > 10s ago is marked offline and purged from ChunkAvailability peer lists immediately.
Model metadata — read-only constants set at job start: chunk_count = 500, chunk_size_bytes = 1GB. Used to derive Worker.status = complete when COUNT(ChunkAvailability WHERE worker_id = X) == chunk_count. No separate completion signal needed from the worker.
Why all in-memory? ChunkAvailability alone reaches 500 chunks × 1,000 workers = 500K rows at scale, written on every chunk completion and read on every peer lookup. A Redis hash or in-process dict handles this at microsecond latency. All four structures are soft state — fully reconstructible on Tracker restart since workers re-announce their held chunks on reconnect.
A static reference file generated once when the deployment job is created. It describes every chunk of the model so that workers know exactly what to fetch and can verify each piece independently — without querying the Tracker for this information.
| Chunk Manifest | ||
|---|---|---|
| Column | Type | Comment |
model_id | UUID | FK → Model.model_id |
chunk_count | INT | Total number of chunks (e.g. 500) |
chunk_size_bytes | ||
The Manifest is immutable after job creation. Workers fetch it once via
GET /manifestimmediately after registration and cache it locally for the duration of the job. It is the single source of truth for what "done" looks like for each chunk.
| Workers upload and download simultaneously |
| External pipe as sole bottleneck | Internal bandwidth is effectively unlimited |
| Chunk manifest + retry logic | Resilience against node failures without restart |
With the pattern established — chunked P2P pipeline coordinated by a lightweight Tracker — the next step is to nail down exactly what the system needs to store and how components talk to each other. Core Entities & API defines the data model and API that make this pattern concrete.
VARCHAR(64)Semantic version, e.g. v2.1.0 |
total_size_bytes | BIGINT | Total file size in bytes |
chunk_count | INT | Total number of chunks the model splits into |
chunk_size_bytes | INT | Nominal chunk size; last chunk may be smaller |
status | ENUM | pending → distributing → complete → failed |
source_url | TEXT | Object store URL, e.g. s3://bucket/models/llama-3-70b/ |
created_at | TIMESTAMP | When the deployment job was initiated |
INT| Zero-based position; determines byte range |
byte_offset | BIGINT | Start byte in the full model file |
size_bytes | INT | Actual chunk size |
checksum_sha256 | CHAR(64) | SHA-256 hash; verified by receiver before seeding |
status | ENUM | pending → downloading → available → failed |
Optional, e.g. gpu-node-042 |
status | ENUM | idle → active → complete → offline |
active_uploads | INT | Concurrent outbound transfers; capped (e.g. max 4) to prevent hot peers |
last_heartbeat_at | TIMESTAMP | Workers silent >10s are marked offline and removed from peer maps |
registered_at | TIMESTAMP | When this worker joined the current distribution session |
idle / active / complete / offline |
active_uploads | INT | Used to sort peer responses (ASC = prefer least loaded) |
last_heartbeat_at | TIMESTAMP | Staleness check — offline if >10s ago |
TIMESTAMP |
| When worker confirmed checksum |
INT| Nominal chunk size (e.g. 1 GB); last chunk may be smaller |
chunk_id | UUID | Per-chunk primary key |
index | INT | Zero-based chunk position |
byte_offset | BIGINT | Start byte of this chunk in the full model file |
size_bytes | INT | Actual size of this chunk |
checksum_sha256 | CHAR(64) | SHA-256 hash; verified by the receiver before seeding |