The vllm-radiance replica pair could not use both cards for one model: TP=2
needs a cross-card collective and the second R9700 cannot dispatch one. The
same wall blocks vLLM's pipeline parallelism, whose stage handoff is an RCCL
send/recv of the hidden state (distributed/parallel_state.py) -- a fact worth
writing down, because the obvious reading of the ask ('use PP to dodge the
atomics problem') is exactly backwards.
llama.cpp's layer split needs no collective at all: layers are distributed and
the boundary activation is a per-backend copy, once per forward pass. One
process, both cards, one model. The runtime is mainline llama.cpp at fb27a525
(the ref is pinned by DFlash2 AND qwen35 MTP; the ROCmFPX fork this host had
before has neither), built for gfx1201 against the private ROCm 7.14 toolchain
the earlier build already used, and bundled next to the binary.
Server: ajgazin Swift-Qwen3.8-27B-Uncensored UD-Q8_K_XL with the model's MTP
head in the file, plus the z-lab DFlash2 Q8_0 block-diffusion drafter at depth
7 (its block is 8, so 7 is the ceiling llama.cpp allows). Text only: vision
stays on frank-small.
Retired into the trash rather than deleted: the three vLLM quadlets, the
radiance entrypoint/healthcheck, the routing proxy and its tests, and the two
bench scripts that drove 'vllm bench serve' inside the retired image. The
deploy handler now stops them, waits for 7770 to be released, installs the
ROOT unit and refuses to start anything when the runtime or payload is
missing. The planner maps the scripts/ trio to frank-medium only, and the
scoping seam asserts the retired service dir is gone rather than merely
unmapped.
130 lines
5.6 KiB
Python
Executable File
130 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Routing policy for the frank-medium proxy: which replica serves a request.
|
|
|
|
Kept apart from the HTTP plumbing in affinity-proxy.py so the decisions can be
|
|
tested without aiohttp, a network or a GPU — the first version of these rules
|
|
shipped with a bug that only appeared on a continuation request, and a test
|
|
that loads this module catches that class of mistake directly.
|
|
|
|
Two cards, two behaviours, and the workload decides which is right:
|
|
|
|
* A **single-turn** request is one member of a batch (a benchmark run, a burst
|
|
of parallel subagents). Spreading those over both replicas lets both cards
|
|
prefill at once; measured on this host, a 16-request burst of 15k-token
|
|
prompts finishes in 18.6 s spread versus 30.1 s pinned.
|
|
* A **continuation** re-sends a prefix the replica has already prefilled. Pinned
|
|
to that replica the turn costs a cache lookup; spread, half the turns pay a
|
|
cold prefill again (measured: 1096 ms versus 2676 ms mean warm-turn TTFT on
|
|
90k-token contexts).
|
|
|
|
ROUTING picks the strategy; the default `adaptive` applies both rules above.
|
|
`affinity` and `roundrobin` exist so the choice can be re-measured, and are not
|
|
production settings.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import collections
|
|
import hashlib
|
|
import itertools
|
|
import json
|
|
import os
|
|
|
|
UPSTREAMS = [u.strip() for u in os.environ.get(
|
|
"UPSTREAMS", "http://127.0.0.1:7771,http://127.0.0.1:7772").split(",") if u.strip()]
|
|
ROUTING = os.environ.get("ROUTING", "adaptive").strip().lower()
|
|
# Leading bytes of prompt text that identify a conversation's shared prefix.
|
|
# 4 KiB covers a system prompt plus the start of the first user turn, which is
|
|
# where agent loops keep the text they resend every turn.
|
|
AFFINITY_BYTES = int(os.environ.get("AFFINITY_BYTES", "4096"))
|
|
# Prefixes remembered for pinning. Bounded so a long-lived proxy cannot grow
|
|
# without limit; 4096 conversations is far beyond what this fleet runs at once.
|
|
KNOWN_MAX = int(os.environ.get("KNOWN_MAX", "4096"))
|
|
|
|
_counter = itertools.count()
|
|
# digest -> replica index, insertion-ordered for O(1) LRU eviction. A prefix is
|
|
# remembered as soon as any request carries it; only a continuation is pinned
|
|
# there, because only a continuation can profit from the cache entry.
|
|
_known: "collections.OrderedDict[bytes, int]" = collections.OrderedDict()
|
|
|
|
|
|
def affinity_key(body: bytes, remote: str | None) -> tuple[bytes, bool]:
|
|
"""Key a request by its shared prefix, and report whether it continues one.
|
|
|
|
Chat and completions bodies both carry their prompt text in JSON, so the
|
|
leading bytes of that text are the prefix; a body we cannot parse (or an
|
|
endpoint with no prompt, e.g. /v1/models) falls back to the client address,
|
|
which still beats nothing for a single-client loop.
|
|
"""
|
|
fallback = (remote or "anonymous").encode()
|
|
try:
|
|
payload = json.loads(body)
|
|
except Exception:
|
|
return fallback, False
|
|
|
|
text = ""
|
|
continuation = False
|
|
if isinstance(payload, dict):
|
|
if isinstance(payload.get("prompt"), str):
|
|
text = payload["prompt"]
|
|
elif isinstance(payload.get("messages"), list):
|
|
continuation = len(payload["messages"]) > 1
|
|
parts = []
|
|
for msg in payload["messages"]:
|
|
if isinstance(msg, dict):
|
|
content = msg.get("content")
|
|
if isinstance(content, str):
|
|
parts.append(content)
|
|
elif isinstance(content, list): # multimodal content blocks
|
|
parts.extend(
|
|
blk.get("text", "") for blk in content
|
|
if isinstance(blk, dict) and isinstance(blk.get("text"), str)
|
|
)
|
|
text = "\n".join(parts)
|
|
if not text:
|
|
return fallback, continuation
|
|
return text[:AFFINITY_BYTES].encode("utf-8", "replace"), continuation
|
|
|
|
|
|
def pick(key: bytes, continuation: bool, up: list[bool]) -> int:
|
|
"""Replica for a request, or -1 when none is available.
|
|
|
|
Down replicas are never selected — a rollout must not send traffic to an
|
|
engine that is still compiling its graphs — and a pin whose replica is down
|
|
is re-established on a live one rather than kept pointing at a corpse.
|
|
"""
|
|
digest = hashlib.sha1(key).digest()
|
|
|
|
if ROUTING == "roundrobin":
|
|
start = next(_counter) % len(UPSTREAMS)
|
|
elif ROUTING == "adaptive":
|
|
entry = _known.get(digest)
|
|
if continuation and entry is not None and up[entry]:
|
|
_known.move_to_end(digest)
|
|
return entry
|
|
# Anything that is not a continuation spreads by counter — including a
|
|
# batch of requests that happen to share one prefix. Hashing the prefix
|
|
# would send every member of such a batch to whichever card the hash
|
|
# names (measured: 29.8 s for a 16-request burst, against 18.6 s when
|
|
# spread). The counter is only advanced by these requests, so batch
|
|
# members alternate across the cards.
|
|
start = next(_counter) % len(UPSTREAMS)
|
|
else: # affinity
|
|
start = int.from_bytes(digest[:8], "big") % len(UPSTREAMS)
|
|
|
|
for offset in range(len(UPSTREAMS)):
|
|
idx = (start + offset) % len(UPSTREAMS)
|
|
if up[idx]:
|
|
if ROUTING == "adaptive":
|
|
_known[digest] = idx
|
|
_known.move_to_end(digest)
|
|
if len(_known) > KNOWN_MAX:
|
|
_known.popitem(last=False)
|
|
return idx
|
|
return -1
|
|
|
|
|
|
def route(body: bytes, remote: str | None, up: list[bool]) -> int:
|
|
"""Convenience wrapper: decode the request body and choose a replica."""
|
|
return pick(*affinity_key(body, remote), up)
|