Files
Strata/serve/replicas.py
T

587 lines
25 KiB
Python

"""Data-parallel replicas: N independent engines, each on its own group of cards, behind one server.
A model that fits two cards (IQ3_S on two R9700s) runs better as two 2-card engines than as one 4-card layer split:
the split runs one pipeline whose stages wait on each other, two replicas run two pipelines. The server keeps its one
HTTP front, its one Service and its one set of endpoints; `ReplicaEngine` stands in for the engine and spreads the
requests over the replicas.
Opt-in, in the run config:
"replicas": 2 the "gpu" list cut into 2 equal groups ([0,1] and [2,3])
"replicas": [{"gpus": [0, 1]}, {"gpus": [2, 3]}] the groups named; an entry may add "env" and "args"
or `serve/server.py --replicas 2`. Nothing changes without it.
Routing. Each request goes to the replica with the lowest cost, cost = prompt tokens the replica would have to read
again (it holds the start of a conversation in its cache) + LOAD_TOKENS for every request already running there. A
conversation's next turn therefore goes back to its replica, until that replica is so much busier than another that
reading the prompt again is cheaper than waiting. What each replica holds is tracked as hashes of 64-token blocks of
the prompts and answers it served (the engine itself checks the real prefix, as it always does).
Host RAM. Every engine keeps its own copy of the experts in RAM (about 47 GiB for Qwen3.8-Flash-Next IQ3_S), so two
replicas need two copies and four need four. "replica_shared_arena": true (or STRATA_REPLICA_SHARED_ARENA=1; opt-in)
backs the experts' arena of all replicas with ONE shared memory object (the engine's --shared-expert-arena, MAP_SHARED):
the experts are in RAM once, whatever the number of replicas. On Linux the object is a memfd the server creates
(os.memfd_create): it is not a file on a mount, so the size of /dev/shm does not limit it; the engines open it as
/proc/<server pid>/fd/<n>. On Windows it is a named section (CreateFileMapping) the engines open by name. Without memfd (an old kernel, a sandbox that refuses it) a file in /dev/shm is used instead.
The pack hash in the header refuses another model; the first engine to start reads the experts into it and the others
wait for it and skip the read (a state word in the header: nobody / a pid reading / filled; an engine whose reader died
takes the read over); the server closes the memfd (or unlinks the file) when it ends - an engine that still maps it
keeps its pages, the kernel frees them with the last mapping - and a /dev/shm file whose server is gone is removed at
the next start. See shared_arena_plan.
CPU cores. On Linux the replicas get disjoint physical cores (serve/cpu_affinity.py, from PR #1436 by agorevski), so
the host threads of the engines (the expert workers) do not compete. "replica_cpus": false turns it off.
"""
from __future__ import annotations
import collections
import copy
import os
import sys
import threading
import time
BLOCK = 64 # tokens per hashed block of a prompt
LOAD_TOKENS = 3000 # the cost of one request already running on a replica, in prompt tokens
INDEX_MAX = 262144 # block hashes kept (about 17M tokens of prompts)
HEAL_COOLDOWN_S = 60.0 # a replica that died is started again at most this often
class ReplicaConfigError(ValueError):
pass
def gpu_groups(cfg: dict) -> list[list[int]]:
"""The cards of each replica, from the config's "replicas" (see the module doc); [] when there are none or one."""
spec = cfg.get("replicas")
if spec is None or spec is False:
return []
cards = _cards(cfg.get("gpu"))
if isinstance(spec, bool) or (not isinstance(spec, (int, list))):
raise ReplicaConfigError(f'"replicas" must be a number or a list of {{"gpus": [...]}}, not {spec!r}')
if isinstance(spec, int):
if spec <= 1:
return []
if not cards:
raise ReplicaConfigError(f'"replicas": {spec} needs the "gpu" list to cut into groups (e.g. "gpu": [0, 1, 2, 3])')
if len(cards) % spec:
raise ReplicaConfigError(f'"replicas": {spec} does not divide the {len(cards)} cards {cards} evenly: name the '
f'groups ("replicas": [{{"gpus": [0, 1]}}, ...]) or change the count')
n = len(cards) // spec
return [cards[i * n:(i + 1) * n] for i in range(spec)]
groups = []
for e in spec:
g = e.get("gpus") if isinstance(e, dict) else e
groups.append(_cards(g))
if any(not g for g in groups):
raise ReplicaConfigError('every replica needs its cards: {"gpus": [0, 1]}')
flat = [c for g in groups for c in g]
if len(set(flat)) != len(flat):
raise ReplicaConfigError(f"a card is in two replicas: {groups}")
if len(groups) < 2:
return []
return groups
def _cards(g) -> list[int]:
if g is None or g == "":
return []
items = g if isinstance(g, (list, tuple)) else str(g).split(",")
try:
return [int(str(x).strip()) for x in items if str(x).strip() != ""]
except ValueError:
raise ReplicaConfigError(f"cards must be numbers, not {g!r}") from None
ARENA_PREFIX = "strata-replicas-"
_MEMFDS: dict = {} # the memfds this server created: path (/proc/<pid>/fd/<n>) -> fd
ARENA_MIN_RAM = 8 << 30 # a memfd arena: at least this much free RAM to try (the engine reserves the real size)
ARENA_MIN_FREE = 8 << 30 # /dev/shm must have at least this much free to try (the engine reserves the real size)
ARENA_INCOMPATIBLE = ("--mmap-experts", "--resident-experts", "--shared-expert-arena", "--resident-budget-gib")
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def sweep_stale_arenas(shm: str = "/dev/shm", uid: int | None = None) -> list[str]:
"""Remove the arena files of servers that no longer run (a killed server cannot unlink its own). A file of a living
server, or of another user, is never touched; a mapping an engine still holds stays valid either way."""
if uid is None:
uid = os.getuid() if hasattr(os, "getuid") else 0
gone = []
try:
names = os.listdir(shm)
except OSError:
return gone
head = f"{ARENA_PREFIX}{uid}-"
for n in names:
if not (n.startswith(head) and n.endswith(".bin")):
continue
try:
pid = int(n[len(head):-4])
except ValueError:
continue
if _pid_alive(pid):
continue
try:
os.unlink(os.path.join(shm, n))
gone.append(n)
except OSError:
pass
return gone
def _free(path: str) -> int:
try:
st = os.statvfs(path)
return st.f_bavail * st.f_frsize
except OSError:
return 0
def _mem_available() -> int:
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemAvailable:"):
return int(line.split()[1]) * 1024
except (OSError, ValueError, IndexError):
pass
return 0
def shared_arena_plan(cfg: dict, n_replicas: int, *, shm: str = "/dev/shm", platform: str | None = None,
environ=None, free_bytes=None, use_memfd: bool | None = None) -> tuple[str | None, str]:
"""(the file for --shared-expert-arena or None, a line for the log). Opt-in: "replica_shared_arena": true (or
STRATA_REPLICA_SHARED_ARENA=1). Only on Linux (the engine's MAP_SHARED backing), for Qwen's arena (not DeepSeek,
not the mapped or budgeted RAM modes, which have no arena), and with room in /dev/shm. The engine checks the real
size and reserves it before it loads (a tmpfs that is too small is an error at start, not a bus error later)."""
environ = os.environ if environ is None else environ
platform = sys.platform if platform is None else platform
want = cfg.get("replica_shared_arena")
if want is None:
want = str(environ.get("STRATA_REPLICA_SHARED_ARENA", "")).strip().lower() in ("1", "true", "on", "yes")
if not want or n_replicas < 2:
return None, ""
if platform.startswith("win"):
# a named pagefile-backed section the engines open by name (src/core/pinned.cu); it lives while any engine holds it
name = f"Local{chr(92)}strata-replicas-{os.getpid()}"
return f"win:{name}", (f"one shared expert arena for {n_replicas} replicas (a Windows section, {name}): "
"the experts are in RAM once")
if not platform.startswith("linux"):
return None, "shared expert arena: Linux and Windows only; each replica keeps its own copy of the experts in RAM"
args = [str(a) for a in cfg.get("args") or []]
if isinstance(cfg.get("replicas"), list):
for e in cfg["replicas"]:
if isinstance(e, dict):
args += [str(a) for a in e.get("args") or []]
if cfg.get("arch") == "deepseek41" or any(a in args for a in ARENA_INCOMPATIBLE):
return None, "shared expert arena: this model/config has no private expert arena to share; each replica as before"
if use_memfd is None:
use_memfd = hasattr(os, "memfd_create") and str(cfg.get("replica_shared_arena")).lower() != "devshm"
if use_memfd:
avail = free_bytes if free_bytes is not None else _mem_available()
if avail < ARENA_MIN_RAM:
return None, (f"shared expert arena: only {avail / 2**30:.1f} GiB of RAM is free; each replica keeps its own "
"copy of the experts in RAM")
try:
fd = os.memfd_create("strata-replicas-arena", os.MFD_CLOEXEC)
except OSError:
fd = -1
if fd >= 0:
path = f"/proc/{os.getpid()}/fd/{fd}"
_MEMFDS[path] = fd
warn = ""
try: # the arena once plus about 7 GB of private RAM per engine (measured); a warning, never a refusal
from strata_control import replica_profile as _rp
q = _rp.quant_of(cfg)
if q:
need = _rp.ARENA_GB[q] + n_replicas * _rp.PER_REPLICA_GB
if avail / 2**30 < need + 4:
warn = (f" WARNING: about {need:.0f} GB are needed for {n_replicas} engines and {avail / 2**30:.0f} GB "
"are free: the last engine may not fit (fewer replicas, or the layer split, is safer)")
except Exception: # noqa: BLE001
pass
return path, (f"one shared expert arena for {n_replicas} replicas (memory object of the server, not limited by "
f"the size of /dev/shm): the experts are in RAM once.{warn}")
if not (os.path.isdir(shm) and os.access(shm, os.W_OK)):
return None, f"shared expert arena: {shm} is not usable; each replica keeps its own copy of the experts in RAM"
free = free_bytes if free_bytes is not None else _free(shm)
if free < ARENA_MIN_FREE:
return None, (f"shared expert arena: {shm} has only {free / 2**30:.1f} GiB free (a tmpfs is half the RAM by "
"default); each replica keeps its own copy of the experts in RAM")
sweep_stale_arenas(shm)
uid = os.getuid() if hasattr(os, "getuid") else 0
path = os.path.join(shm, f"{ARENA_PREFIX}{uid}-{os.getpid()}.bin")
return path, f"one shared expert arena for {n_replicas} replicas ({path}): the experts are in RAM once"
def remove_arena(path: str | None) -> None:
"""Close the memfd / unlink the arena file (the server's end, or a start that fell back): engines that map it keep
their pages."""
if not path or path.startswith("win:"): # (a Windows section goes with its last handle)
return
fd = _MEMFDS.pop(path, None)
if fd is not None:
try:
os.close(fd)
except OSError:
pass
return
try:
os.unlink(path)
except OSError:
pass
def _suffixed(path, i: int):
if not isinstance(path, str) or not path:
return path
root, ext = os.path.splitext(path)
return f"{root}.r{i}{ext}"
def replica_cfg(cfg: dict, i: int, gpus: list[int]) -> dict:
"""The config of replica i: its own cards, log and learned-profile file; the entry's "env" and "args" added."""
c = copy.deepcopy(cfg)
spec = cfg.get("replicas")
entry = spec[i] if isinstance(spec, list) and isinstance(spec[i], dict) else {}
c.pop("replicas", None)
c["gpu"] = gpus if len(gpus) > 1 else gpus[0]
c.pop("hip_ordinal", None) # a one-card config's Windows ordinal: not for a group
c["log"] = _suffixed(cfg.get("log"), i)
if cfg.get("expert_profile_save"):
c["expert_profile_save"] = _suffixed(cfg["expert_profile_save"], i)
if entry.get("env"):
c["env"] = {**(c.get("env") or {}), **{str(k): str(v) for k, v in entry["env"].items()}}
if entry.get("args"):
c["args"] = list(c["args"]) + [str(x) for x in entry["args"]]
return c
def cpu_sets(n: int, enabled: bool = True):
"""n disjoint sets of CPUs (whole physical cores, SMT siblings together), or None (off, not Linux, or the topology
cannot be read: the engines then share the cores as they would without replicas)."""
if not enabled or n < 2 or not hasattr(os, "sched_getaffinity"):
return None
try:
from serve.cpu_affinity import partition_cpu_sets
return partition_cpu_sets(n, set(os.sched_getaffinity(0)))
except (ValueError, OSError, ImportError) as e:
print(f"[strata] replicas: no separate CPU cores ({e})", flush=True)
return None
class Router:
"""Which replica holds the start of a prompt: chained hashes of its 64-token blocks, each with the set of replicas
that served it (a bit mask)."""
def __init__(self, n: int, cap: int = INDEX_MAX):
self.n, self.cap = n, cap
self.blocks: collections.OrderedDict[int, int] = collections.OrderedDict()
self.lock = threading.Lock()
@staticmethod
def chain(ids) -> list[int]:
out, h = [], 0
for k in range(len(ids) // BLOCK):
h = hash((h, tuple(ids[k * BLOCK:(k + 1) * BLOCK])))
out.append(h)
return out
def matched(self, ids) -> list[int]:
"""Per replica: how many leading tokens of `ids` it holds (a multiple of BLOCK)."""
got = [0] * self.n
alive = (1 << self.n) - 1
with self.lock:
for k, h in enumerate(self.chain(ids)):
m = self.blocks.get(h, 0) & alive
if not m:
break
alive = m
for r in range(self.n):
if m >> r & 1:
got[r] = (k + 1) * BLOCK
return got
def note(self, r: int, ids) -> None:
with self.lock:
for h in self.chain(ids):
self.blocks[h] = self.blocks.pop(h, 0) | (1 << r)
while len(self.blocks) > self.cap:
self.blocks.popitem(last=False)
def forget(self, r: int) -> None:
"""Replica r lost its cache (it restarted)."""
with self.lock:
bit = ~(1 << r)
for h in [h for h, m in self.blocks.items() if m >> r & 1]:
m = self.blocks[h] & bit
if m:
self.blocks[h] = m
else:
del self.blocks[h]
class ReplicaEngine:
"""Stands in for one engine (the interface Service uses), over several."""
NOT_HERE = {"session_file", "vram", "ctl", "slot_cv"} # not offered: one conversation file / VRAM resize / control line
PROPAGATE = {"silence_s", "gpu_busy"}
def __init__(self, engines: list, load_tokens: int = LOAD_TOKENS):
if len(engines) < 2:
raise ValueError("ReplicaEngine needs two or more engines")
d = self.__dict__
d["engines"] = list(engines)
d["router"] = Router(len(engines))
d["load_tokens"] = load_tokens
d["inflight"] = [0] * len(engines)
d["run_locks"] = [threading.Lock() for _ in engines] # an engine without batch slots: one request at a time
d["lock"] = threading.Lock()
d["tl"] = threading.local()
d["healing"] = {}
d["heal_at"] = [0.0] * len(engines)
d["routed"] = [0] * len(engines)
d["gen_seen"] = [getattr(e, "gen", 0) for e in engines]
# ---- attribute plumbing
@property
def current(self):
return getattr(self.tl, "cur", None) or self.engines[0]
def __getattr__(self, name):
if name in self.NOT_HERE or name.startswith("__"):
raise AttributeError(name)
return getattr(self.current, name)
def __setattr__(self, name, value):
if name in self.PROPAGATE:
for e in self.engines:
setattr(e, name, value)
else:
self.__dict__[name] = value
@property
def last(self):
return self.current.last
@last.setter
def last(self, value):
self.current.last = value
@property
def batch(self) -> int:
"""Parallel slots, in total: the Service lets this many requests run at once (it holds no FIFO). An engine
without slots counts one."""
return sum(max(1, int(getattr(e, "batch", 0) or 0)) for e in self.engines)
@property
def waiting(self) -> int:
return sum(int(getattr(e, "waiting", 0) or 0) for e in self.engines)
@property
def max_context(self) -> int:
v = [e.max_context for e in self.engines if getattr(e, "max_context", 0) and e.alive()]
return min(v) if v else 0
@property
def known_ctx(self) -> int:
v = [getattr(e, "known_ctx", 0) for e in self.engines]
return min(x for x in v if x) if any(v) else 0
@property
def unloaded(self) -> bool:
return all(getattr(e, "unloaded", False) for e in self.engines)
@property
def starting(self) -> bool:
return not self.alive() and any(getattr(e, "starting", False) for e in self.engines)
@property
def info(self) -> dict:
i = dict(self.engines[0].info)
i["replicas"] = len(self.engines)
i["replicas_alive"] = sum(1 for e in self.engines if e.alive())
return i
@info.setter
def info(self, value):
pass
@property
def spawn(self):
return self.engines[0].spawn
@property
def log_path(self):
return self.engines[0].log_path
@property
def progress(self):
c = getattr(self.tl, "cur", None)
if c is not None:
return c.progress
return next((e.progress for e in self.engines if getattr(e, "progress", None)), None)
@property
def prefill_tok_s_mean(self):
c = getattr(self.tl, "cur", None)
if c is not None:
return c.prefill_tok_s_mean
return next((e.prefill_tok_s_mean for e in self.engines if getattr(e, "prefill_tok_s_mean", None)), None)
def alive(self) -> bool:
return any(e.alive() for e in self.engines)
def exit_code(self):
return next((c for c in (e.exit_code() for e in self.engines if not e.alive()) if c is not None), None)
def death_note(self) -> str:
return next((e.death_note() for e in self.engines if not e.alive()), "")
def slots_view(self) -> list[dict]:
out, base = [], 0
for k, e in enumerate(self.engines):
n = int(getattr(e, "batch", 0) or 0)
view = e.slots_view() if n and hasattr(e, "slots_view") else \
[{"slot": 0, "state": "busy" if self.inflight[k] else "idle", "held_tokens": 0}]
for s in view:
out.append({**s, "slot": base + s["slot"], "replica": k})
base += max(1, n)
return out
def replica_view(self) -> list[dict]:
"""For /metrics: each replica's state, requests running, and requests routed to it so far."""
return [{"replica": k, "alive": e.alive(), "running": self.inflight[k], "routed": self.routed[k],
"batch": int(getattr(e, "batch", 0) or 0)} for k, e in enumerate(self.engines)]
# ---- life cycle
def restart(self, tries: int = 3):
dead = [k for k, e in enumerate(self.engines) if not e.alive()]
errors: dict[int, BaseException] = {}
def one(k):
try:
self.engines[k].restart(tries)
self.router.forget(k)
except BaseException as e: # noqa: BLE001
errors[k] = e
ts = [threading.Thread(target=one, args=(k,), daemon=True) for k in dead]
for t in ts:
t.start()
for t in ts:
t.join()
if errors and not self.alive():
raise next(iter(errors.values()))
for k, e in errors.items():
print(f"[strata] replica {k} did not start ({e}); the others serve", flush=True)
def unload(self):
for e in self.engines:
e.unload()
for k in range(len(self.engines)):
self.router.forget(k)
def close(self):
err = None
for e in self.engines:
try:
e.close()
except BaseException as x: # noqa: BLE001
err = err or x
remove_arena(self.__dict__.get("arena_file")) # (an engine that still maps it keeps its pages)
if err:
raise err
# ---- routing
def route(self, ids) -> int:
"""The replica for this prompt (and its running count raised): lowest cost among the living ones."""
got = self.router.matched(ids)
with self.lock:
alive = [k for k, e in enumerate(self.engines) if e.alive()]
if not alive:
return -1
cost = {k: (len(ids) - got[k]) + self.load_tokens * self.inflight[k] for k in alive}
# the least loaded wins a tie of cost (equal cards, nothing cached): spread, do not pile on replica 0
k = min(alive, key=lambda k: (cost[k], self.inflight[k], self.routed[k]))
self.inflight[k] += 1
self.routed[k] += 1
if len(alive) < len(self.engines):
self.heal()
return k
def heal(self) -> None:
"""Start a replica that died again, in the background (the others keep serving)."""
now = time.monotonic()
for k, e in enumerate(self.engines):
if e.alive() or getattr(e, "unloaded", False) or getattr(e, "starting", False) or self.healing.get(k):
continue
if now < self.heal_at[k]:
continue
self.heal_at[k] = now + HEAL_COOLDOWN_S
self.healing[k] = True
def run(k=k):
try:
print(f"[strata] replica {k} had stopped; starting it again", flush=True)
self.engines[k].restart()
self.router.forget(k)
print(f"[strata] replica {k} is running again", flush=True)
except BaseException as x: # noqa: BLE001
print(f"[strata] replica {k} did not start again: {x}", flush=True)
finally:
self.healing[k] = False
threading.Thread(target=run, daemon=True).start()
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
# imported here: serve.server imports this module
from serve.server import EngineDied, EngineNonFinite
k = self.route(ids)
if k < 0:
raise EngineDied("no replica is running; this request was not sent")
e = self.engines[k]
self.tl.cur = e
produced: list[int] = []
held = False
nonfinite = False
try:
selected = getattr(cancel, "_strata_replica_selected", None)
if selected is not None:
selected(k)
self.router.note(k, ids)
if not int(getattr(e, "batch", 0) or 0): # no slots: this replica takes one request at a time
lock = self.run_locks[k]
while not lock.acquire(timeout=0.25):
if cancel is not None and cancel.is_set():
return
held = True
if embeddings is not None:
gen = e.generate(ids, max_new, sampling, cancel, embeddings=embeddings)
else:
gen = e.generate(ids, max_new, sampling, cancel)
for t in gen:
if t is not None:
produced.append(t)
yield t
except EngineNonFinite:
nonfinite = True
raise
finally:
if held:
self.run_locks[k].release()
with self.lock:
self.inflight[k] -= 1
if nonfinite: # #879: the engine dropped every cached conversation
self.router.forget(k)
elif produced:
self.router.note(k, list(ids) + produced)