Federated averaging
Federated averaging (FedAvg) is FlashRuntime's answer to a question the
PyTorch guide and the
JobSpec & isolation guide both run into: what do
you do when the machines that want to help train a model cannot talk to
each other? Volunteer nodes run their task containers with --network
none (see the repo's docs/guides/donate-a-machine.md) — no LAN, no
internet, no way for one container to find another. Coordinated
multi-process training (DDP, FSDP) needs the opposite: every rank must
rendezvous with every other rank over a process group before the first
all_reduce. On a volunteer pool those two requirements are irreconcilable,
so FlashRuntime does not attempt coordinated training there at all.
FedAvg sidesteps the rendezvous problem by never requiring it. Instead of ranks synchronizing gradients mid-step, rounds synchronize whole models between steps:
- The driver broadcasts the current weights as a plain
artifact://blob. - Each participating node downloads the weights, trains independently on chunks of the data it was offered until its deadline, and uploads a weight delta (not the new weights — see below) plus the list of chunk ids it actually finished.
- Once the deltas that committed cover enough of the data between them, the driver averages them, applies the result to the broadcast weights, and starts the next round.
No node ever needs to see another node's IP address, let alone open a
connection to it. Every cross-node interaction is a PUT/GET against the
coordinator's artifact store, which is exactly the same shape of traffic a
volunteer node already does to pull its task inputs and push its results.
That is why this is a round loop implemented as a driver chaining
ordinary lease jobs (flashml_workloads/fedavg_driver.py,
flashml_workloads/fedavg_worker.py) — the same "pipelines are jobs chained
by a driver, not a new execution mode" pattern as the sharded-k-means POC —
rather than a new backend.
Why a delta, not the new weights
Each worker's task uploads delta.json (the change it made to the weights
it started from) alongside metrics.json. The driver averages deltas,
not raw weight snapshots, because a delta is a direction that stays
meaningful even if the weights it was computed against are no longer the
newest ones — the exact situation a straggling volunteer produces when it
finally reports in after the round has moved on. Averaging final weights
directly would require every worker to have started from the same
snapshot; averaging deltas only requires knowing what each worker started
from, which the driver already does.
Chunks, slots, and how a round closes
kmeans_driver (the other job-chaining driver in this codebase) requires
every dispatched shard to report before it aggregates. FedAvg
deliberately does not — and it does not count participants either. A round
closes on data coverage.
A pass over the data is cut into total_chunks uniform chunks. Uniform
is load-bearing rather than convenient: the reduce clips any contribution
whose norm exceeds three times the round's median, and that cap is only
silent on an honest round while every contribution covers the same amount of
data — so heterogeneity is expressed as how many chunks a machine
completes, never how big one is.
A round posts slots task offers. Every offer carries the same chunk
budget and the same deadline; what differs is where in the sequence it
starts, so two machines working at the same speed cover disjoint data and a
machine that outruns its share simply wraps. A slot is not a partition and
slots is not a machine count — it is a ceiling on offers, because how many
machines are online is not knowable when the round is submitted. Unclaimed
slots are never waited for. A machine claims an offer, trains as many
chunks as it can before its deadline, and reports the ids it finished.
The round ends when the union of those ids meets its coverage target
(sync_every of one pass), or when round_timeout_s fires — in which case
it combines whatever did arrive rather than throwing the round away. The
only way a round fails is having nothing creditable to combine at all.
This is the correct policy for volunteer compute: machines that donate spare
cycles are unequal and unreliable by construction — laptops close, Wi-Fi
drops — and requiring a fixed number of them would let one closed laptop
stall every other participant's round.
Consecutive rounds advance forward through the data. Every slot's start is advanced by the round index times the round's coverage target, so round 1 begins where round 0's target ended. Without that, slot geometry would repeat every round and — since a round stops the moment it hits a target below a full pass — the same prefix of the dataset would be retrained forever while the tail was never trained at all.
The advance wraps rather than running off the end: offsets are
(round × target) mod total_chunks, so they cycle. One cycle is exactly one
pass over the data — that is the guarantee, and it is a per-pass one, not a
per-run one. Training four epochs means walking the same four offsets four
times, which is what "four epochs" should mean.
The corollary is what makes coverage-closing safe rather than merely
convenient: once the driver has read the round's deltas and applied them,
any delta that commits afterward for that round is discarded, never
folded into a later round. run_fedavg freezes the contributor set at the
moment the round closes and never re-reads that job's artifacts again
(fedavg_driver.py, run_fedavg). A late delta was computed against
weights that no longer exist by the time it arrives — the model has already
moved past them — and applying it on top of a newer round's weights would
not be "one more contribution," it would silently corrupt the average with
a step that was never actually taken from the current state. Discarding is
the honest behavior; a driver that tried to be more "inclusive" here would
be quietly wrong instead.
tests/test_fedavg_convergence.py::test_round_completes_on_quorum_when_a_node_never_reports
pins exactly this: three slots are offered but the test's agent pool is
capped to exactly two successful claims and then stops claiming, so the
third offer is never bound to any node and sits PENDING for the life of the
test. The round still aggregates on the two that committed — with an exact
participants == 2 assertion — rather than hanging until the deadline
waiting for an offer nobody was ever going to take. (The cap on claims, not
the node count, is what makes the third offer genuinely abandoned: either
registered node can claim any offer, so without the cap both nodes could
sequentially serve all three before the driver's poll notices coverage.)
Nothing sets a floor on the contributor count
There is no min_participants, and no other floor anywhere. A single fast
machine that covers the target alone closes a round by itself, and the
backstop can close a round on one contribution. This is deliberate — a floor
is exactly the "one closed laptop stalls everyone" failure the coverage
model exists to avoid — but it has a cost worth stating plainly: the median
clip in fedavg_weights is robust statistics, and robust statistics need a
majority of contributors to be honest. At two contributions the median is
the mean, which an attacker moves directly; at one there is nothing to
compare against. The influence cap is protection at a healthy round and is
not protection at a thin one. Result verification is a separate, unbuilt
concern.
What counts as a participant, and what the driver refuses
Everything a volunteer node produces — the delta, the reported chunk ids, the sample count, the metrics file, the filenames — is attacker-controlled input. Result verification (catching a node that lies about a delta it honestly computed) is a later milestone, but input validation and containment are not deferred:
- A participant is an accepted commit, not an uploaded file. The driver
counts only keys that exactly match the round's dispatched task set
(
jobs/{job_id}/shard-{i:03d}/metrics.jsonfor the slots this round actually offered), and cross-checks them against the tasks the coordinator reportsCOMPLETED(GET /v1alpha1/jobs/{id}/tasks). Both halves are load-bearing: the agent uploads a task's output tree recursively, so a nestedout/a/metrics.jsonwould otherwise mint a second participant from one lease; and uploads happen before the commit is offered, so an attempt the coordinator rejected (lost lease, sha256 mismatch) would otherwise still be averaged in. - A contribution's weight is what the coordinator can prove it handed
out.
verify_allotmentintersects the chunk ids a node reports with the sequence that slot was actually offered, dropping duplicates and anything past the budget, and the surviving count is the contribution's weight in the average. The node's ownsamplesfield is never that weight — a node that sets its own weight sets its own influence over the model. (samplessurvives in exactly one place: the reportedmean_loss, a number an operator reads and nothing else.) Weights must be positive: validating only the total is not enough —(delta=-999, n=-999)plus(delta=1.0, n=1000)totals a healthy 1 but yields999001.0where the honest step is1.0, because a weighted mean is only a convex combination when every weight is positive. - NaN and Inf are rejected, not averaged. Python's
jsonboth emits and parsesNaN/Infinity, and NaN is absorbing: one non-finite value turns every weight NaN, and every later round then trains from NaN while the run still reports success. This one needs no attacker — a learning rate that diverges on one machine does it.fedavg_weightsfails closed on any non-finite value entering the reduce or leavingapply_delta/subtract, naming the parameter and index. lease_secondsis bounded (modea.MAX_LEASE_SECONDS, one hour). A lease deadline is the only thing that returns an abandoned task to the queue, so1e9would pin an offer to a closed laptop for ~31 years andinfoverflowstimedeltainside the coordinator's claim path. The driver clamps to the same ceiling before sizing a worker's training deadline from it — a deadline derived from a lease the coordinator will never grant tells the worker to train past its own expiry, and work that cannot be committed is work nobody can use.
Artifact PUT is now authenticated and lease-scoped when the coordinator
sets FLASHML_NODE_TOKENS (the per-machine-token slice): a node token can
only write under jobs/{job}/{task}/ for a task it currently holds a live
lease on. The round-weights key
(jobs/{job_id}/round-{round:03d}/weights.json) belongs to no task and no
node's lease, so a plain node token cannot write it — the driver instead
authenticates with an operator token (FLASHML_OPERATOR_TOKENS), which
is attributable but deliberately not lease-scoped, exactly because drivers
are legitimate writers outside any lease (see
docs/guides/donate-a-machine.md). Result verification is still a separate,
unbuilt concern: this scoping stops an unrelated node from overwriting the
round weights, not from a participant lying about the delta it honestly
computed.
The flashml.yaml shape
A federated-averaging round is submitted as an ordinary lease-mode job:
apiVersion: flashml.dev/v1alpha1
kind: Job
metadata:
name: fedavg-r000
spec:
execution:
backend: leases
image:
repository: local/tier1
tag: dev
workload:
type: federated_averaging
parameters:
round: 0
# The round's chunk plan, stated once for every slot it expands into.
slots: 2 # offers, a ceiling — not a machine count
total_chunks: 4 # how finely one pass is cut
chunk_budget: 2 # chunks each offer may walk
chunk_offset: 0 # (round * coverage target) mod total_chunks
deadline_s: 96.0 # when a worker must stop training and upload
local_steps: 20
lr: 0.1
batch_size: 16
seed: 0
in_dim: 8
hidden: 16
out_dim: 2
dataset_size: 256
# weights: artifact://jobs/<prev-job>/round-000/weights.json
# (omitted on round 0 — each worker seeds its own model from `seed`)
The expansion (service/modea._expand_fedavg) turns that into one task per
slot, each carrying the same budget and deadline and its own start_chunk
— chunk_offset is consumed there and never reaches the worker. It fails
the submission outright on anything the worker would only discover inside a
container on a volunteer's machine: a missing worker parameter, a
non-positive chunk_budget, a missing or non-finite deadline_s, or a
total_chunks larger than dataset_size (which would leave chunks with no
rows in them at all). One bad body must not burn every attempt of every slot
across the fleet and read as a fleet-wide node fault.
Almost nobody writes that block by hand. run_fedavg derives all of it —
you give it epochs and sync_every (both in passes over the data),
total_chunks, slots, and expected_machines, and the round count, the
per-round coverage target, the chunk budget, the offset and the training
deadline all fall out:
run_fedavg(
HttpCoordinator(coordinator_url),
epochs=4, # how much training you want
sync_every=1.0, # how often to combine, in passes
total_chunks=4, # how finely one pass is cut
slots=2, # offers per round (a ceiling)
expected_machines=4, # who is expected to CLAIM — sizes the budget
worker_params=WORKER_PARAMS,
initial_weights=initial_weights,
)
expected_machines is separate from slots because the two pull in
opposite directions: the offer ceiling should be generous, and dividing the
round's work by a generous ceiling would starve every machine that actually
turned up. Nobody types a round count and nobody types a machine count.
isolation.tier is left at its default, "standard", deliberately: unlike
the argv runner tier for arbitrary bring-your-code jobs, a
federated_averaging task's payload is a fixed, trusted module execution
(flashml_workloads.fedavg_worker), so it does not need the sandboxed argv
path and its argv_capable gate. A node only needs module_capable
(fail-open — absent counts as capable) to be eligible. run_fedavg builds
this JobSpec once per round and submits it as a new job
(flashml_workloads/fedavg_driver.py:_round_body) — the round number, its
chunk_offset and the weights URI are the only things that change between
the driver's own resume points. The
image and isolation tier are run_fedavg parameters
(image=, isolation_tier=); the defaults above are this repo's e2e
fixture image, which only works because SubprocessRunner ignores image
entirely — a docker-tier volunteer needs a real, pullable reference.
What this proves — and what it does not
tests/test_fedavg_convergence.py runs this loop against a real
coordinator over real HTTP: real job expansion, real leases, real local
artifact storage (FLASHML_LOCAL_ARTIFACTS_DIR), and real commit-time
sha256 validation on every uploaded artifact. Two independent worker
"agents" (a few lines of urllib, standing in for flashnode work — see
the test file's docstring for why an in-repo test cannot import flashnode
directly) pull leases, train, and commit without ever talking to each
other. The measured per-round mean loss across four rounds with two
participating nodes:
round 0 participants 2/2 covered 100% mean_loss 0.3501
round 1 participants 2/2 covered 100% mean_loss 0.1615
round 2 participants 2/2 covered 100% mean_loss 0.1032
round 3 participants 2/2 covered 100% mean_loss 0.0797
converged: 0.3501 -> 0.0797 over 4 rounds
covered is the number the round actually closed on, printed rather than
assumed: 100% here because four chunks over two slots at
expected_machines=4 gives each offer half the pass, so the two nodes cover
it between them. A round that had to fall back on its backstop would print a
lower figure, and that is exactly when you want to see it.
(scripts/fedavg_local_demo.py reproduces this and exits non-zero if the
final round's loss is not below the first — a demo that prints numbers
nobody checks is not evidence. The run above is that script's own output.)
Read that number correctly: this proves collaborative training, not
faster training. Two nodes did not finish training in half the wall-clock
time of one — they trained sequentially through four rounds, each doing
its own local steps, and the loss came down because their independently
computed updates were combined. Nothing here claims a throughput or
speed-up result; DDP/FSDP make that claim, on a coordinated pool that can
rendezvous, and that claim is out of scope for volunteer nodes entirely (the
repo's docs/guides/donate-a-machine.md has the full list of things the
volunteer pool does not attempt, including "no coordinated multi-process
training"). What FedAvg proves is that machines which cannot see or trust
each other — and
in the volunteer case, cannot even reach each other over the network — can
still jointly move one model's loss in the right direction, coordinated
entirely through the coordinator's leases and artifact store.