Execution Model¶
This page describes how FedProC runs per-client work — the single dispatch contract shared by every strategy. Understanding it is enough to add a new strategy that runs correctly in both serial and Ray-parallel mode without writing any parallel boilerplate.
Two orthogonal knobs¶
| Knob | Flag | What it controls |
|---|---|---|
| Parallelism | derived | Whether selected clients run concurrently via Ray. |
| Efficiency | --efficiency |
Device residency policy: when models move GPU↔CPU. |
These are independent concerns and can be combined freely.
- Parallelism is enabled automatically when GPUs and workers are available:
configs.parallel = (num_gpus > 0 and num_workers > 0). It is read in exactly one place —tFL._dispatchon the server. Clients never branch on it. - Efficiency (
low/med/high) only decides when a model is offloaded to CPU during training/eval to manage VRAM:low— offload after every epoch / eval step,med— offload once after each local train/eval call,high— keep resident when possible (default).
Efficiency does not affect what train() returns. The CPU-ification needed
to cross the Ray process boundary is handled separately, only on the parallel
path (see _offload_package).
The dispatch contract¶
All per-client method calls go through one server method:
self._dispatch(method_name, clients)
_dispatch chooses the execution mode:
- Serial — for each client, call the method and apply its return value:
self._apply_client_result(client, getattr(client, method)()) - Parallel — a bounded Ray worker pool (one in-flight job per worker). Each
worker runs the method and CPU-ifies the returned package before it crosses
the process boundary; the driver then applies it:
# in the worker package = client._offload_package(getattr(client, method)()) # on the driver, as each future completes self._apply_client_result(client, ray.get(future))
Because both paths route through _apply_client_result, the same code applies
results in serial and parallel. In serial the package holds live references to
the client's own objects, so the apply is a true no-op (identity checks); in
parallel the package holds CPU copies deserialized from the worker, so the values
are copied back onto the original client.
train() returns a package¶
Client train() (and any other dispatched method) always returns a package
dict — it never inspects self.parallel or self.efficiency:
def train(self) -> Dict[str, Any]:
... # train in place; efficiency governs residency here
return {
"model": self.model, # live reference
"optimizer_state": self.optimizer,
"train_time": time.time() - start_time,
"train_samples": self.train_samples,
}
_apply_client_result¶
The base implementation (tFL._apply_client_result) applies the four standard
keys, with identity checks that make serial a no-op, and a guard that raises
NotImplementedError if the package contains any key outside
tFL._KNOWN_PACKAGE_KEYS = {"model", "optimizer_state", "train_time", "train_samples", "id"}.
The guard exists to catch a strategy that returns extra state but forgets to
handle it — failing loudly instead of silently dropping the state.
_offload_package¶
Called only on the Ray worker path. The base implementation moves
model to CPU and converts optimizer_state to a CPU state dict, leaving every
other key untouched. Strategies that return additional GPU tensors override it
(calling super() first) to CPU-ify those too.
_gather_losses¶
Evaluation is the map-reduce counterpart of _dispatch: instead of applying
state back, it gathers one scalar loss per client. tFL._gather_losses(mode,
dataset_type) uses the same bounded worker pool and is shared by
evaluate_generalization_loss and evaluate_personalization_loss.
Adding a strategy¶
1. Standard gradient strategy — nothing to do¶
Inherit and you are done. The inherited train() / _dispatch /
_apply_client_result / _offload_package already handle everything:
class FedNew(tFL):
def aggregate_models(self): ...
class FedNew_Client(tFL_Client):
pass # or override train_one_epoch for a custom local objective
2. Strategy that returns custom state (e.g. closed-form statistics)¶
Return the extra keys from train(), and give the server a full override of
_apply_client_result that writes them back. Because the override does not call
super(), the base key guard never runs — so you do not set
_KNOWN_PACKAGE_KEYS. (Reference: FedRidge.)
class FedStat(tFL):
def _apply_client_result(self, client, package):
if package is None:
return
client._sigma_xx = package["sigma_xx"]
client._sigma_xy = package["sigma_xy"]
client.train_samples = package["train_samples"]
class FedStat_Client(tFL_Client):
def train(self) -> Dict[str, Any]:
... # compute on CPU; set self._sigma_xx / self._sigma_xy in place
return {
"sigma_xx": self._sigma_xx,
"sigma_xy": self._sigma_xy,
"train_samples": self.train_samples,
}
If a custom tensor could live on the GPU, also override _offload_package on the
client to move it to CPU for the Ray return:
class FedStat_Client(tFL_Client):
def _offload_package(self, package):
package = super()._offload_package(package)
if package is not None and "extra" in package:
package["extra"] = package["extra"].cpu()
return package
3. Gradient strategy with extra keys on top of the standard package¶
Extend the key set and call super() so the base still applies model/optimizer.
(Reference: FedALA, MOTAR.)
class FedExtra(pFL):
_KNOWN_PACKAGE_KEYS = tFL._KNOWN_PACKAGE_KEYS | frozenset({"aux"})
def _apply_client_result(self, client, package):
if package is None:
return
super()._apply_client_result(client, package)
client.aux = package["aux"]
Multi-phase strategies¶
A strategy that dispatches different methods in different phases (e.g. a
one-shot warm-start followed by gradient rounds) returns different package
schemas. Route on the schema in _apply_client_result:
def _apply_client_result(self, client, package):
if package is None:
return
if "model" in package: # gradient phase
tFL._apply_client_result(self, client, package)
else: # statistics phase
super()._apply_client_result(client, package)
(References: FedTELOSInit, FedTELOSLocalFinev7.)
Every dispatched method must return its state
A dispatched method that returns None is treated as a no-op by
_apply_client_result. That is correct for a method whose only effect is
already captured elsewhere, but a method that trains a model and returns
None will silently lose its work in parallel mode (the Ray worker
trains a throwaway copy). Always return a package from any method that
mutates client state you need back on the driver.