swarm-server / swarm /compression.py
abhimittal's picture
Deploy swarm parameter server
09d402e verified
Raw History Blame Contribute Delete
5.46 kB
"""Gradient compression β€” the bandwidth kernel.
On a real GPU cluster the interconnect is NVLink at ~600 GB/s. Here it is HTTP
over the public internet at maybe 10 MB/s, so **bytes on the wire are the
bottleneck**, not FLOPs. A worker that spends 200 ms computing a gradient and
8 s uploading it is 97% idle.
The fix is standard in distributed training but rarely built from scratch:
* **Top-K sparsification** β€” only the ``ratio`` fraction of gradient entries with
the largest magnitude are transmitted (as index/value pairs). At ratio=0.01
that is a ~50x reduction in payload.
* **Error feedback** β€” the entries we *dropped* are not discarded; they are kept
in a local residual buffer and added to the next step's gradient. A small
consistent gradient direction therefore accumulates until it becomes large
enough to be sent. Without this, sparsification biases training and
convergence stalls; with it, Top-K is near-lossless in practice.
(Seide et al. 2014; Lin et al., "Deep Gradient Compression", 2018.)
* **int8 quantization** β€” the surviving values are optionally quantized to 8-bit
with a per-tensor scale, for another ~4x on top.
The server reconstructs a dense gradient before aggregating, so compression is
entirely transparent to :class:`~server.aggregator.GradientAggregator`.
"""
from __future__ import annotations
import math
from typing import Dict, Mapping, Optional, Sequence, Tuple
import torch
# Wire-format suffixes. safetensors payloads are flat {name: tensor} maps, so a
# compressed tensor is split across a few synthetic keys.
IDX = "::idx"
VAL = "::val"
SCALE = "::scale"
# Values for the X-Compression header.
NONE = "none"
TOPK = "topk"
TOPK_INT8 = "topk-int8"
MODES = (NONE, TOPK, TOPK_INT8)
def _numel(shape: Sequence[int]) -> int:
return int(math.prod(shape)) if len(shape) else 1
def topk_count(numel: int, ratio: float) -> int:
"""How many entries survive Top-K for a tensor of ``numel`` elements."""
if ratio >= 1.0:
return numel
return max(1, min(numel, int(numel * ratio)))
def compress(
tensors: Mapping[str, torch.Tensor],
mode: str = TOPK,
ratio: float = 0.01,
residual: Optional[Dict[str, torch.Tensor]] = None,
) -> Dict[str, torch.Tensor]:
"""Compress ``{name: dense_gradient}`` into a sparse wire payload.
``residual`` (error feedback) is read *and updated in place* when provided:
dropped mass is carried into the next call so no gradient signal is lost,
only delayed.
"""
if mode == NONE:
return dict(tensors)
if mode not in MODES:
raise ValueError(f"unknown compression mode {mode!r}; expected one of {MODES}")
quantize = mode == TOPK_INT8
out: Dict[str, torch.Tensor] = {}
for name, grad in tensors.items():
g = grad.detach().to(torch.float32)
if residual is not None:
prev = residual.get(name)
if prev is not None:
g = g + prev
flat = g.reshape(-1)
k = topk_count(flat.numel(), ratio)
# Select by magnitude; sorted=False is cheaper and order is irrelevant.
_, idx = torch.topk(flat.abs(), k, sorted=False)
vals = flat[idx]
if residual is not None:
# Everything we did NOT send stays behind for the next step.
carry = flat.clone()
carry[idx] = 0.0
residual[name] = carry.view_as(grad)
if quantize:
peak = vals.abs().max()
scale = (peak / 127.0) if peak > 0 else torch.tensor(1.0)
out[name + VAL] = torch.round(vals / scale).clamp_(-127, 127).to(torch.int8)
out[name + SCALE] = scale.reshape(1).to(torch.float32)
else:
out[name + VAL] = vals
out[name + IDX] = idx.to(torch.int64)
return out
def decompress(
payload: Mapping[str, torch.Tensor],
shapes: Mapping[str, torch.Size],
mode: str = TOPK,
) -> Dict[str, torch.Tensor]:
"""Rebuild dense gradients from a sparse payload.
Shapes come from the server's own model, so they never travel on the wire.
"""
if mode == NONE:
return dict(payload)
if mode not in MODES:
raise ValueError(f"unknown compression mode {mode!r}; expected one of {MODES}")
out: Dict[str, torch.Tensor] = {}
for name, shape in shapes.items():
try:
idx = payload[name + IDX]
val = payload[name + VAL]
except KeyError as e: # pragma: no cover - surfaced as a 400 by the app
raise KeyError(f"compressed payload missing entry for {name!r}: {e}") from e
scale = payload.get(name + SCALE)
if scale is not None:
val = val.to(torch.float32) * float(scale.reshape(-1)[0])
flat = torch.zeros(_numel(shape), dtype=torch.float32)
flat[idx.to(torch.int64)] = val.to(torch.float32)
out[name] = flat.view(shape)
return out
def payload_bytes(payload: Mapping[str, torch.Tensor]) -> int:
"""Total tensor bytes in a payload (excludes safetensors' small header)."""
return sum(t.numel() * t.element_size() for t in payload.values())
def compression_report(
dense: Mapping[str, torch.Tensor], wire: Mapping[str, torch.Tensor]
) -> Tuple[int, int, float]:
"""(dense_bytes, wire_bytes, ratio) β€” ratio is how many times smaller the wire is."""
d = payload_bytes(dense)
w = payload_bytes(wire)
return d, w, (d / w if w else 1.0)