[wip]fix(kvaware+kvd): two bugs blocking KV-aware routing + kvd together on SGLang, with image, docs and repro - #59
Conversation
free_tcp_port_block() scanned downward from a fixed base (ip_local_port_range
low - count) and released the probe sockets before returning. Two engines
started on one host therefore picked the *same* base deterministically.
This is reached only from the kv-aware path (worker.py, when enable_kv_events
is set), where SGLang binds one KV-event publisher per DP rank at base+rank.
The PD prefill and decode legs of a pair on one host both scanned from the
same place, both saw the same block free, and the second leg died with:
zmq.error.ZMQError: Address already in use (addr='tcp://*:32765')
Two alternatives were tried and rejected against live MVPs:
* Hold the reservation until the child binds. The probe binds 127.0.0.1:P
while the real listener binds 0.0.0.0:P (zmq `tcp://*`); holding it makes
our own subprocess fail with errno 98.
* 0.0.0.0 + SO_REUSEADDR reservation. A second probe can still take the same
port, so the reservation is not exclusive and buys nothing.
The reservation fundamentally cannot be exclusive, so remove the determinism
instead: try 64 random bases in the same sub-ephemeral window, then fall back
to the original exhaustive downward scan so we never fail while a block is
still free.
tests/unit/common/test_net_port_block.py covers: the block is free and
contiguous, it sits below the ephemeral range, repeated calls do not all
collide, and count<=1 delegates to free_tcp_port(). The collision test fails
on the pre-fix code (10/10 identical bases).
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yihou <yihou@amd.com>
For a bind mount findmnt reports the source with the bind subpath appended in
brackets:
/dev/md0[/mnt/nvme-raid/kvd-long]
_findmnt() returned that whole string and the caller handed it to lsblk, which
answers "not a block device" and makes the rotational/queue probe fail. The
failure is silent: classification falls back to buffered I/O even on hardware
that qualifies for O_DIRECT.
This hits exactly the normal kvd deployment — an L3 long-tier directory
bind-mounted into the engine container — so a container-hosted kvd would never
use O_DIRECT on its NVMe tier, with no error to say why.
Keep only the part before '[', which is the device.
Two cases added to tests/unit/kvd/test_storage_classify.py. The fake `_run`
had to be taught the real lsblk contract (non-zero exit on a bracketed target);
without that the pre-fix code passes the new tests vacuously.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yihou <yihou@amd.com>
The manual said KV-Cache Offload is "vLLM only (for now)" and the feature
matrix marked SGLang 🚧. Both are stale: infera has shipped
`InferaKvdBackend` (a SGLang `HiCacheStorage` backend) in
infera/engine/sglang/kvd_adapter.py, wired by --infera-kvd-socket.
Verified on hardware (GLM-5.2-MXFP4, two-node PD over Mooncake RDMA,
DP-attention on both legs): the backend connects, serves, and survives an
engine restart — a replay after restart added 170 hits with zero new sets,
i.e. the reuse came from L3 and not from the in-GPU radix cache.
Adds a SGLang section covering the parts that are not obvious from the vLLM
instructions, each verified against source:
* --hicache-size (absolute GB) instead of --hicache-ratio. The default
ratio 2.0 sizes the host pool off max_total_num_tokens and asked for
355 GB *per DP rank* on a 1.7B model.
* ratio < 1.5 silently disables L3 prefetch
(hicache_validate.HICACHE_RATIO_DANGER_THRESHOLD).
* On a PD decode leg kvd requires KV events ON: the decode leg forces
disable_radix_cache, SGLang rejects that with --enable-hierarchical-cache,
and infera only appends --disaggregation-decode-enable-radix-cache when
kv-events are enabled and the backend is Mooncake (args.py). So
--no-enable-kv-events on a decode leg silently disables kvd too.
* --infera-kvd-socket fails fast (5 s probe, kvd_wiring._probe_kvd) rather
than serving with a dead cache backend.
* A latency win alone does not prove kvd served anything — SGLang's in-GPU
radix cache serves a repeated prefix without touching L3. Read the daemon
counters; restart to attribute a hit to L3.
AIC GPU-Direct remains vLLM-only; SGLang reads via the daemon POSIX path.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yihou <yihou@amd.com>
Dockerfile.sglang already builds everything this deployment needs — engine,
infera wrapper, `infera.kvd` daemon, router, statctl — so this layers on it
rather than duplicating a ~78 GB build.
What it adds is the contract:
* A build-time self-check that imports InferaKvdBackend,
wire_infera_kvd_backend, attach_to_radix_cache and statctl, and asserts
free_tcp_port_block no longer returns a fixed base. A build that loses the
kvd adapter (bad merge, or a base bump that moves HiCacheStorage) fails
here instead of starting fine and serving with no L3 at all. Verified the
assertion discriminates: 10 distinct bases post-fix, 1 pre-fix.
* PYTHONHASHSEED=0 baked in. For kvd this is a correctness requirement, not
a tuning knob: unset, it is random per process, the two PD legs hash the
same prompt differently, and every restart orphans the whole L3 cache.
* The ionic Mooncake defaults from the verified GLM-5.2 two-node PD run
(MC_GID_INDEX=1, MC_DISABLE_HIP_TRANSPORT=1,
MOONCAKE_DISABLE_HIP_DMABUF=1), overridable at docker run.
* A digest-pinnable base arg, because the unified Mooncake rebuild is
exactly the layer you do not want silently swapped.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yihou <yihou@amd.com>
The feature pages give the flags. This page covers what an operator actually
needs and could not get from them:
* Experiment-only settings and what to use instead. The reproduction scripts
carry MC_FORCE_TCP=1, an 8 GB hicache, an L3 tier on /tmp, and a manual
libionic docker cp — every one of which is deliberately wrong for
production, for a reason worth writing down (e.g. /tmp is usually tmpfs,
so the "disk" tier is RAM competing with the RAM tier).
* A recommended all-switches-on configuration for the three roles.
* How to verify each switch took effect, with the specific log strings, and
a negative case for each. All three features fail silently by degrading to
a slower-but-correct path, so "it works" is not evidence. In our own run
the reuse phase was 2.7x faster with kvd's counters at ZERO — the win was
the in-GPU radix cache. The check that attributes reuse to kvd is a
restart-and-replay: hits climb, sets do not.
* Which numbers are tunable, their defaults, recommended values, and which
direction to move them. Notably --kv-overlap-weight defaults to 1.0 and
the per-role weights fall back to it, so a PD deployment that does not set
them explicitly behaves nearly round-robin and looks like kv-aware "does
nothing".
* A "what we did not measure" section, so the recommended weights are not
mistaken for benchmarked optima: no concurrency, one prefix, no observed
latency win from kvd.
Every log string and flag default cited here was checked against source.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yihou <yihou@amd.com>
Self-contained reproduction kit for the final clean run: the five commits as patches, every script verbatim, the captured results, and the logs. The run: GLM-5.2-MXFP4, two-node PD over Mooncake RDMA (real RDMA, not TCP), DP-attention 8/8 on both legs, KV-aware routing and kvd both on, on an image built from this branch's Dockerfile.sglang.kvaware-kvd. T1 correctness 4/4, coherent T2 conc=32 128/128 needle correct, 0 errors, 128/128 finish=stop, 3.1 req/s T3 conc=128 502/512 needle correct, 0 errors, 6.8 req/s T3's 10 non-clean responses are all finish=length at exactly the 1024-token cap — the run-on mode of a harness that sends no chat template and suppresses EOS for throughput. Not a KV defect; the conc=128 gate is throughput without errors. kvd is shown to actually serve, not merely to be wired: during the stress tests it wrote 32 GB and read nothing (gets=0), because SGLang's in-GPU radix cache serves repeats without touching L3. Restarting the engine empties that cache while the daemon survives; replaying the same prefixes then gives 102 gets / 102 hits / 0 misses with sets_total unchanged. Reads with no new writes on an empty GPU cache is the only clean attribution. The kit is verified end to end: a fresh clone at 8692fb4 plus the five patches produces a tree hashing identical to the branch HEAD, 51 tests pass, and reverting just the two source files fails 3 of them. notes.md records what went wrong, including two traps worth keeping: a docker save | gzip that was silently truncated but passed `gzip -t` (verify the tar, not the gzip), and a classifier that reports CORRUPT_REASONING for a correct answer because GLM-5.2 reasoned in Chinese. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: yihou <yihou@amd.com>
Walked the packup skill's completeness checklist against the folder and found
three items genuinely missing:
* §2 (purpose + spec) — the success criteria lived scattered in README with
no criterion-by-criterion verdict. Adds spec.md: the ask as given, each of
the 7 deliverables with its bar and the measured result, and the operator's
2026-07-31 refinement of the conc=128 bar (throughput without errors; the
finish=length tail is the no-chat-template / EOS-suppressed run-on mode,
not a KV defect). Also records what was ruled out of scope (854ebf7,
GPU-direct).
* §6 (debug/fix outcomes) — the patches had no what/why/how/context notes.
Adds patches/README.md, one section per patch, including the two rejected
alternatives behind 0001, the test that initially passed on the broken code
in 0002, the blast radius of each fix (0001 has exactly one caller; vLLM and
ATOM use free_tcp_port, untouched), and an explicit flag that 0003 reverses
29a69ca and should be confirmed with its author in review.
* §4 (software env) — the Dockerfiles were referenced by repo path only, so
the packup was not self-contained. Copies Dockerfile.sglang.kvaware-kvd,
Dockerfile.sglang and the two build/entrypoint scripts into dockerfiles/.
Also checked and recorded as clean: no file > 4 MB, no secret values (the three
grep hits in the engine logs are SGLang's server_args dump — ssl_keyfile_password=None,
..._token=None), and every original left in place (work.kvaware_kvd,
work.kvaware_kvd.packup_20260730, kvaware.kvd.packup.better, and the remote
workspace under /mnt/vast all intact — this packup only ever copied).
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: yihou <yihou@amd.com>
The `lint` job was failing on four hooks: trailing-whitespace, end-of-file-fixer,
ruff-format and ruff-check.
Almost all of it is whitespace. Two changes are worth a second look before this
is dismissed as noise, since both touch files that are evidence rather than
source:
* the .patch files lose the single space on blank context lines. Checked that
this does not break them — `git apply` reconstructs blank context lines, and
a stripped patch still applies with the correct result.
* results/raw/*.json gain a trailing newline. Content is untouched; the diff is
"\ No newline at end of file" only.
The one non-mechanical fix is E402 in scripts/stress_capture.py: `import os` sat
mid-file next to the IDX-replay block. Hoisted into the top import block, which
is where ruff's isort ordering wants it anyway.
Rebased onto main first — the branch was 39 commits behind.
Signed-off-by: Zhang, Jiejing <jiejing.zhang@amd.com>
52d7119 to
5a4dfba
Compare
|
@yihou heads up — I force-pushed this branch ( What changed, and nothing else: Rebased onto Fixed the
The one real fix is E402 in Added the missing DCO sign-off — please read this part. The I did this at @kzjeef's instruction, on the statement that you had authorised it. Flagging it explicitly because a DCO sign-off is a certification made in your name, and it should not appear in the history without you knowing it was added by someone else's rebase. If you did not authorise it, say so and I will strip the trailers so you can sign them yourself — for the record that is just: git fetch && git checkout yihou.dev.glm5.2.mxfp4.kvaware.kvd.pr
git rebase --signoff origin/main && git push --force-with-leaseThe trees are byte-identical to before the sign-off; only trailers were added. Still outstanding: |
jiejingzhangamd
left a comment
There was a problem hiding this comment.
Reviewed the ~40 lines of behaviour change (infera/common/net.py, infera/kvd/storage_classify.py); the remaining ~10k lines are the packup archive. Both diagnoses are convincing and the tests are good — 4 new for the port block, 50 for storage classification including two bind-mount cases. One blocking issue.
Blocking — free_tcp_port_block crashes on a tuned ip_local_port_range
highest = low - count
randomised = [random.randint(1024, highest) for _ in range(_PORT_BLOCK_TRIES)]random.randint raises when highest < 1024, which happens whenever the host's ephemeral range starts at or near 1024. net.ipv4.ip_local_port_range = 1024 65535 is a real high-concurrency tuning, not a hypothetical.
Verified against both versions with the range faked to 1024 65535:
main (original) -> RuntimeError: could not find 8 contiguous free TCP ports
PR (randomised)-> ValueError: empty range in randrange(1024, 1017)
So it is a regression in two ways. The old code's range(low - count, 1024, -1) was simply empty and fell through to the deliberate RuntimeError, which names the problem. The new code raises ValueError out of random before probing a single port — on a host where the old code would also have failed, but legibly.
Suggest guarding the window and keeping the existing failure path:
highest = low - count
if highest < 1024:
raise RuntimeError(
f"no room for a {count}-port block below the ephemeral range "
f"(ip_local_port_range starts at {low})"
)Worth a test — the existing four all run against the real /proc value, so this path cannot be hit in CI.
Non-blocking — find("[") misses one bind-mount shape
bracket = source.find("[")
if bracket > 0:> 0 rather than >= 0 is right, and I assume deliberate: it stops an IPv6 source like [2001:db8::1]:/export from being truncated to nothing. Worth a comment saying so, since it reads like an off-by-one.
It does leave one case: an IPv6 NFS source that is bind-mounted, [2001:db8::1]:/export[/sub], keeps its subpath and hits the same "not a block device" path this PR fixes. rfind handles that and is identical everywhere else:
/dev/md0[/mnt/nvme-raid/kvd-long] find -> /dev/md0 rfind -> /dev/md0
[2001:db8::1]:/export find -> unchanged (correct) rfind -> unchanged
[2001:db8::1]:/export[/sub] find -> unchanged (subpath kept) rfind -> [2001:db8::1]:/export
Marginal, since NFS classifies as buffered anyway — but it is a one-character change.
Non-blocking — the 64 random probes can repeat
[random.randint(...) for _ in range(64)] samples with replacement, so some of the 64 tries are wasted on bases already probed. random.sample(range(1024, highest + 1), k=min(64, highest - 1023)) would not, and would also make the empty window above impossible to express. Only matters under contention, which is exactly when the tries are worth the most.
Checked and fine
- Fork safety. The failure this fixes is two engines starting concurrently on one host, so it matters whether forked children draw the same sequence — CPython reseeds
randomon fork, and two children produced different picks in a direct test. Not an issue. - The exhaustive fallback still starts deterministically at
highest. If all 64 random tries fail, two callers re-converge — but at that point the host is nearly out of blocks and theRuntimeErroris imminent regardless. - Reservation is loopback-only and released before returning. The docstring explains why this cannot be exclusive; the reasoning holds.
Note on the branch
I rebased this onto main (was 39 commits behind), fixed the lint job, and added the DCO sign-off — see the comment above for exactly what changed and how to undo the sign-off if it was applied in error.
The randomised scan sampled `random.randint(1024, low - count)` without checking
that the window is non-empty. On a host whose ephemeral range starts at or near
1024 — `net.ipv4.ip_local_port_range = 1024 65535` is a real high-concurrency
tuning, not a hypothetical — `low - count` falls below 1024 and the call raises
out of `random` before probing a single port.
That regressed the failure twice over. Such a host has no room for a contiguous
block either way, but the original code's `range(low - count, 1024, -1)` was
simply empty and fell through to the deliberate RuntimeError, which names the
problem. Compared with the range faked to `1024 65535`:
before this branch RuntimeError: could not find 8 contiguous free TCP ports
on this branch ValueError: empty range in randrange(1024, 1017)
after this commit RuntimeError: no room for a 8-port block below the
ephemeral range (ip_local_port_range starts at 1024)
Also switches the 64 probes to `random.sample`, i.e. sampling without
replacement. Independent randints repeat bases, wasting tries under contention —
which is exactly when each try is worth the most. It also makes the empty window
unrepresentable rather than merely guarded.
Adds the regression test. The existing four all read the real /proc value, so
this path cannot be reached in CI; verified the new one fails with the original
ValueError when the fix is reverted.
Signed-off-by: Zhang, Jiejing <jiejing.zhang@amd.com>
|
Pushed
Switched the 64 probes to Added the regression test. The existing four read the real Behaviour on a normal host is unchanged: still a random base below the ephemeral range. The two non-blocking points from the review are untouched and yours to judge — |
The GLM-5.2 header recorded 'No Address already in use' from the DP attention run, worded so it reads as 'main has this bug and we did not trip it'. Two things are wrong with that now. The run predates #79, which landed the randomised scan start in free_tcp_port_block on main -- the same fix #59 carried. So the risk is retired, not merely untriggered. And the observation proved less than the wording implied even at the time: that collision needs two engines on ONE host, and a cross-node PD pair cannot produce it. Recording a clean result from a topology that cannot fail the test reads as evidence when it is not. Both the manifest header and the README table now say this. Signed-off-by: Zhang, Jiejing <jiejing.zhang@amd.com>

Two bug fixes found while bringing up KV-aware routing + kvd (tiered KV offload) together on the SGLang engine, plus the deployment image and the operator documentation for that configuration, plus a reproduction kit.
Verified end-to-end on GLM-5.2-MXFP4, two-node PD over Mooncake RDMA, DP-attention 8/8 on both legs, both features on.
The two fixes
fix(net)—free_tcp_port_blockreturned the same base to every callerIt scanned downward from a fixed start (
ip_local_port_range.low - count) and released the probe sockets before returning, so two engines launched on one host picked the same block deterministically. The second leg died with:Reached only from the kv-aware path (
sglang/worker.py:77, whenenable_kv_eventsis set and SGLang binds one KV-event publisher per DP rank atbase + rank).The reservation cannot be made exclusive — the probe binds
127.0.0.1:Pwhile the real listener binds0.0.0.0:P(zmqtcp://*). Two alternatives were tried and rejected against live MVPs:errno 98; we would be locking out our own subprocess.0.0.0.0+SO_REUSEADDR→ a second probe can still take the same port, so it buys nothing.So the fix removes the determinism instead: 64 random bases in the same sub-ephemeral window, then the original exhaustive scan as fallback.
Blast radius: exactly one caller in the repo. vLLM and ATOM use the other function,
free_tcp_port(), which is untouched.fix(kvd)— bind-mounted L3 silently lost O_DIRECTFor a bind mount
findmntreports the source with the bind subpath in brackets:That whole string went to
lsblk, which answers "not a block device". The failure is silent: classification falls back to buffered I/O even on hardware that qualifies for O_DIRECT — i.e. exactly the normal deployment, an L3 directory bind-mounted into the engine container.Blast radius:
storage_classifyis private to the kvd daemon, but the daemon is shared by all three engines, so vLLM's kvd L3 path benefits identically. Second-order effect checked deliberately:mount_sourceis also compared against/proc/mounts(src == source) for the NFSnconnect/rsize/wsizeprobes./proc/mountsnever carries the bracket form, so stripping moves that comparison from always fails to can succeed — a fix there too, not a regression.Both fixes have tests that fail on the pre-fix code
A trap worth flagging for reviewers: the first version of the
storage_classifytest passed on the broken code, because the fake_runreturned success forlsblkregardless of its argument. It had to be taught the real contract (reallsblkexits non-zero on a bracketed target). If you extend these tests, revert the fix and confirm they go red.Full unit suite, differential, same image:
main= 83 failed / 965 passed / 58 errors; this branch = 83 failed / 971 passed / 58 errors. Identical failures, +6 passes (the new tests). The 83/58 are pre-existing container-environment limitations present onmain, not introduced here.build(sglang)— the deployment imagedeploy/docker/Dockerfile.sglang.kvaware-kvdlayers onDockerfile.sglangrather than duplicating a 78.6 GB build — the existing image already contains engine + wrapper + kvd daemon + router + statctl, and one image runs all four roles.What it adds is the contract:
InferaKvdBackend,wire_infera_kvd_backend,attach_to_radix_cache,statctl, and assertsfree_tcp_port_blockno longer returns a fixed base. It discriminates — 10 distinct bases post-fix, 1 pre-fix. A build that loses the kvd adapter (bad merge, or a base bump that movesHiCacheStorage) now fails at build time instead of starting fine and serving with no L3 at all.PYTHONHASHSEED=0baked in — for kvd this is a correctness requirement, not tuning: unset it is random per process, the two PD legs hash the same prompt differently, and every restart orphans the whole L3 cache.Docs
docs(serving)— newmanual/serving/kvaware_kvd_operations.md: which settings are experiment-only and what production should use instead, the recommended all-switches-on config, how to verify each switch actually took effect (with a negative case for each — all three features fail silently by degrading to a slower-but-correct path), and which numbers are tunable with recommended values and which direction to move them.One trap documented there:
--kv-overlap-weightdefaults to1.0and the per-role weights fall back to it, so a PD deployment that doesn't set them explicitly behaves nearly round-robin and looks like kv-aware "does nothing".docs(kvd)—29a69ca("kvd offload is vLLM-only"), so please sanity-check me here @jiejingzhangamd. The reasoning:InferaKvdBackend(a SGLangHiCacheStoragebackend) has been in the tree sinceInfera v0.1.0— it predates the doc saying it doesn't exist.tests/e2e/pd_mixed/vllm/test_mixed_kvd.py; there is no SGLang equivalent), so the path was untested rather than absent.AIC GPU-Direct remains vLLM-only; SGLang reads via the daemon's POSIX path. Happy to drop this commit if you'd rather gate the doc change on a SGLang kvd e2e case landing first.
Hardware verification
Image built from this branch,
sha256:c9292019…, same digest on both nodes. 8× MI355X per node, ionic RoCE, all 8 rails active.finish=stop, 3.1 req/sconc=128's 10 non-clean responses are all
finish=lengthat exactly the 1024-token cap — the run-on mode of a harness that sends no chat template and suppresses EOS for throughput. The split is clean: 502 good atfinish=stopmedian 149 tokens, 10 bad at exactly 1024. No KV-corruption signature at either concurrency.kvd is shown to actually serve, not merely to be wired
This is the part that is easy to fake, so it is worth spelling out. During the stress tests kvd wrote 32 GB and read nothing (
gets_total=0) — SGLang's in-GPU radix cache serves repeated prefixes without ever touching L3. A passing test proves nothing here, and neither does a latency win (in earlier work a reuse phase ran 2.7× faster with kvd's counters flat at zero).The discriminating check is restart-and-replay — restarting the engine empties the GPU cache while the daemon and its L3 survive:
Reads with no new writes on an empty GPU cache. That reuse could only have come from L3.
Reproduction kit
kvaware_kvd_pr.packup_20260731/— spec with criteria-vs-result, ordered reproduce steps, environment (hardware / drivers / fabric / image digests), per-patch what-why-how-context, the Dockerfiles, every script verbatim, raw JSON results, and logs.Verified self-contained: a fresh clone at
8692fb4plus these patches produces a tree hashing identical to this branch's HEAD.notes.mdrecords the wrong turns, including two general traps: adocker save | gzipthat was silently truncated yet passedgzip -t(verify the tar, not the gzip), and a stress classifier that reportedCORRUPT_REASONINGfor a correct answer because GLM-5.2 legitimately reasoned in Chinese.Known gaps (deliberate, stated so nothing reads as more than it is)
wait_eventfix) is deliberately not included — it lives on another branch and is tracked separately.🤖 Generated with Claude Code