The vault was 155 pages / 31,210 lines, most of it measurement ledgers for payloads that no longer run. A throughput figure for a runtime nobody operates is not a record: AI moves faster than the vault, so the number is wrong before anyone reads it, and a wrong number in a runbook gets believed. Per the new purpose test in `AGENTS.md` → *Documentation Policy*, it is destroyed. Gone (`.AGENTTRASH/20261019-destruction/`, KILL-LIST.txt is the manifest): - the `frank-medium` endpoint and its five era pages, `frank-npu`, FastFlowLM, the ikllama/kingjones/dealign/frank-redo eras, the ling/swift15/freetoken/ubatch campaign pages, qwen3.8 spec-matrix and strix-optimizations, the two "Running Qwen3.6 …" pages; - the whole `Pipeline Parallelism/` folder (no live script passes a pipeline flag) and the multi-device/x4-pair/hybrid/offload experiment pages; - every vLLM page — no vLLM endpoint exists on this fleet; - `LLM/deployment.md` (`Architecture/Deployment Flow.md` is the test-referenced owner) and the external surveys (SGLang, Lemonade, llama.cpp forks/diff, self-hosted app census, the TTS survey) that were obsolete by construction; - `log.md`, 2,380 lines. Git holds the chronology; a causal law belongs to the page that owns the thing it governs, never to a diary. Survivors: 101 pages, 17,542 lines, 0 OKF violations, 0 unresolved links. Eras tables keep the window, payload, runtime, reason and rollback pointer; the rate columns are blanked. The live shapes stay verbatim — those numbers are what the deployer and `models.yml` consume.
13 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| reference | PCIe Link and Atomics | Link probing, the streaming-versus-compute ceiling arithmetic, and the atomics wall that decides multi-GPU collectives. |
|
2026-10-05T00:00:00-07:00 |
PCIe Link and Atomics
1. Degraded or just idle?
The trap: an idle GPU reports LnkSta: Speed 2.5GT/s (downgraded) — the
driver downshifts to Gen1 for power. That is not a fault and not the slot's
fault. Never conclude from an idle reading.
- Path ceiling from the kernel (cheapest, most decisive):
sudo journalctl -k -b --no-pager | grep "available PCIe bandwidth"→pci 0000:c4:00.0: 63.012 Gb/s available PCIe bandwidth, limited by 16.0 GT/s PCIe x4 link at 0000:00:03.1 (capable of 252.048 Gb/s with ... x16). The "limited by" device names the bottleneck (usually a root port) and its width — that is the number a swap could move; "capable of x16" is the endpoint's OWN capability, and the gap between them is the story.dmesgrotates;journalctl -k -bkeeps this boot. - Both sides separately:
LnkCap/LnkSta/LnkCtl2on endpoint AND root port.LnkCtl2: Target Link Speed: 16GT/son both ends = the platform intends Gen4, so a Gen1 current speed is power management, not misconfiguration. A root port whoseLnkCapwidth exceeds the trained width means the socket/adapter wires fewer lanes — M.2 connectors are 4 lanes by pinout; an M.2 riser can never deliver more. - Prove the link under load (the step people skip): on NVIDIA sample NVML
while the GPU works
(
nvidia-smi --query-gpu=pcie.link.gen.current,pcie.link.gen.hostmax,pcie.link.width.current,pcie.link.width.max):gen.currentclimbing 1 →hostmax= idle downshift, healthy; stuck belowhostmaxunder load = real degradation;width.current < width.maxunder load = physical lane limit — no software touches it. On a busy production GPU this is a usage smoke test, not a benchmark — never stop a live service to read its link. - Do not plan hardware from
dmidecode -t slot— consumer firmware labels Ethernet/SD controllers as "M.2 slots" and advertises slots in PCI domains that do not exist. Trustlspci -nn | grep "PCI bridge"andlspci -tv(empty root ports still enumerate; an absent port is absent). - "Would moving the card help?" Compare the bottleneck device from step 1
with the target port's
LnkCapwidth; two M.2 sockets = both x4 = a no-op. CleanDevSta/CEStawith a low trained speed means "wired narrow", not "failing". Report ceiling, card capability, what the idle reading meant, and whether any physical change moves the number.
Gen4 usable payload after 128b/130b: x4 7.877 GB/s, x8 15.75, x16 31.5.
2. The engine shape decides whether the link matters at all
| engine shape | what crosses PCIe per token | link matters? |
|---|---|---|
| streams weights (expert LRU, paging) | the missed weights (~GB/token) | yes — decisive |
computes offloaded weights in RAM (llama.cpp -ot exps=CPU) |
activations only (~KB) | no; host RAM bandwidth decides |
| everything resident | nothing | no |
Ceiling arithmetic: link time/token = active expert bytes / measured H2D rate; ceiling = 1 / link_time × resident-fraction saved (LRU hits).
Compare against the compute-in-RAM path at host RAM bandwidth (256 GB/s
LPDDR5X on the Strix Halo — 128 GB LPDDR5x-8000 on a 256-bit bus,
amd.com spec page).
If the link is >10x slower than the memory behind it (x4 is 31x here)
streaming loses by construction and no tuning rescues it; doubling the link
only doubles a losing number.
Worked example: the Dankclaw Gen4 x4 run of 2026-09-16 — measured H2D/D2H rates, the 3 MiB slab cost, and the streaming-vs-compute comparison it decided — is recorded on §The link, measured. Read it once and apply the rule above: on that link the streaming design lost by 3–5x, and the x8 ceiling would only have halved the loss.
3. Measure the link: two probes, same mechanics
Stop all production services first (fleet bench law) — the 3090's VRAM may be nearly full, and the probe contends for the very link it measures.
CUDA probe (pcie-link-test.cu): sweep {3, 16, 64, 256 MiB, 1, 2 GiB}
with cudaMalloc + cudaHostAlloc, event-timed; per size measure sync H2D,
async H2D, D2H, one single transfer, and pageable H2D. 3 MiB is the row
that matters for expert streaming; big sizes give the ceiling.
HIP probe for RDNA4 (pcie_bw.hip) — the ROCm landmines each cost real
time, so encode them:
hipGetDevicePropertiesSEGFAULTS on some builds; usehipGetDeviceCounthipSetDeviceonly. First line:setvbuf(stdout, NULL, _IONBF, 0);— a segfaulting process loses block-buffered stdout and hides the crash point.
- Build with an explicit arch:
HIP_TARGETS=gfx1201 hipcc -O2 --offload-arch=gfx1201 -o p p.hip; compile with| grep -E "error:"never| tail -N(truncation hid a missing#include <cstring>). hipMemset/hipDeviceSynchronizeare[nodiscard]— use or cast.- Mount the whole
/dev/driinto ROCm containers; individualrenderD*nodes enumerate but fail context creation. rocm-smi --showmetrics:pcie_link_widthis trustworthy, butpcie_link_speedreads the ASPM idle value andpcie_bandwidth_acc/instare usuallyN/Aon consumer cards — live traffic generally cannot be sampled.
// pcie_bw.hip — sweep sizes, pinned vs pageable
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <hip/hip_runtime.h>
static double one(size_t bytes, bool pinned, int iters) {
void *dev = NULL, *host = NULL;
if (hipMalloc(&dev, bytes) != hipSuccess) return -1;
if (pinned) { if (hipHostMalloc(&host, bytes, hipHostMallocDefault) != hipSuccess) return -1; }
else { host = malloc(bytes); }
memset(host, 0x5a, bytes)
hipEvent_t a, b; hipEventCreate(&a); hipEventCreate(&b)
for (int i = 0; i < 3; i++) hipMemcpy(dev, host, bytes, hipMemcpyHostToDevice)
hipDeviceSynchronize()
hipEventRecord(a, 0)
for (int i = 0; i < iters; i++) hipMemcpy(dev, host, bytes, hipMemcpyHostToDevice)
hipEventRecord(b, 0); hipEventSynchronize(b)
float ms = 0; hipEventElapsedTime(&ms, a, b)
double gb = (double)bytes * iters / (ms / 1000.0) / 1e9;
if (!pinned) free(host); else hipHostFree(host)
hipFree(dev); hipEventDestroy(a); hipEventDestroy(b)
return gb;
}
int main(int argc, char **argv) {
setvbuf(stdout, NULL, _IONBF, 0)
if (hipSetDevice((argc > 1) ? atoi(argv[1]) : 0) != hipSuccess) return 1;
size_t sz[] = {2459136UL, 3UL<<20, 16UL<<20, 64UL<<20, 256UL<<20, 1UL<<30};
for (int i = 0; i < 6; i++) {
size_t b = sz[i];
int iters = (b >= (1UL<<30)) ? 10 : (b >= (256UL<<20)) ? 20 : 100;
printf(" %10.3f MiB | pinned %6.2f | pageable %6.2f\n",
b/1048576.0, one(b,true,iters), one(b,false,iters))
}
return 0;
}
Reading it:
- Engine-vs-link is the diagnosis, not the raw number: compare
engine_effective_GB_stolink_GB_sat the SAME transfer size. Engine at ~40 % of the link at the same granularity = the wire is not the problem and no hardware change will help. - Achieved GB/s is itself the proof of the trained link state (6.7 GB/s only
exists at Gen4 x4) — no separate under-load
LnkStasample needed. - pinned ≈ pageable ⇒ pinning is not a lever. async ≈ sync ⇒ link-limited (the usual "pageable costs 2x" rule does not apply at this link).
- Small-transfer penalty (2.345 MiB: ~106 µs vs ~78 µs ideal, ~21 %) is per-transfer overhead — recoverable only by BATCHING transfers, not by a faster link. The engine-vs-wire version of that reading on Dankserver (bulk slab rate against the engine's effective gather rate, same granularity) is frank-archon's tcclaviger-era record.
- Both GPUs symmetric ⇒ no cross-device rebalance is available; measure both.
- Don't quote a derived line from inside a probe without hand-checking it — a printf format/argument mismatch in one of these printed a nonsense ceiling. GiB→GB before dividing.
4. The PCIe-atomics wall (AMD multi-GPU collectives)
Symptom: multi-GPU AMD host fails TP/PP; the failure surfaces as
hipLaunchKernel: hipErrorIllegalState for ncclDevKernel_* — nothing in
vLLM/RCCL/PyTorch names atomics. Get the real message:
AMD_LOG_LEVEL=3 <the failing process>
# → "Pcie atomics not enabled, hostcall not supported" → "AQL dispatch failed!"
Cause chain: RCCL kernels carry a hidden hostcall-buffer parameter; ROCr
refuses to fill it without PCIe atomics, which it reads once at driver
probe (rocclr/device/rocm/rocdevice.cpp checkAtomicSupport() — no else,
no override). No env var, module param, or sysfs toggle exists —
setpci -s <gpu> CAP_EXP+28.w=0440 (DCTL2 bit 6) changes nothing at runtime.
Check the CHAIN, not the card:
sudo lspci -vv -s <gpu> | grep -E "AtomicOpsCtl|LnkSta|LnkCap" # want ReqEn+
lspci -tv # every hop
sudo lspci -vv -s <bridge> | grep AtomicOpsCap # want Routing+
Routing- is a read-only hardware capability. Typical AM5 layout: one x16
slot on CPU root ports (atomics fine), the rest behind the chipset (usually
not). Bandwidth for TP is small anyway: ~10 MB per token-step for a 27B at
batch 4 is a millisecond-scale figure at x4 — compute-bound, not
interconnect-bound (full derivation: §Bandwidth).
Probe matrix (bare 2-rank torch.distributed, one process per GPU, 25 s PG
timeout) — one probe answers several design questions:
| Op | CPU-lane card | chipset card | Decides |
|---|---|---|---|
broadcast/all_reduce/barrier |
OK | FAIL | TP is dead |
send/recv (P2P), in ISOLATION |
hang | FAIL | PP is dead too — vLLM's PP handoff IS this primitive |
Test send/recv alone (a prior failed op leaves the group broken). Run the
probe from a REAL file (torch.multiprocessing.spawn re-executes it; piping
into python3 - fails with FileNotFoundError: '/<stdin>'). world_size=1
always succeeds — one-card success proves nothing.
SAFETY — non-negotiable: never run a GPU experiment beside a serving
unit: sudo systemctl stop <svc> && sudo systemctl mask <svc> first (mask,
never disable — disable deletes a relinked unit file), or use a host that
serves nobody. A hung AQL dispatch can wedge the GPU driver; a probe beside a
live service on 2026-09-16 preceded a hard host down (no ARP, no ICMP from
two subnets, power cycle required). Telling off from wedged afterwards: ip neigh show <ip> from a peer — FAILED/INCOMPLETE = off/NIC dead; a wedged
kernel usually still answers ARP. Validate your own probe's naming before
reporting an outage (gitea vs gitea-server).
Fixes, ranked:
- CPU-attached M.2 → PCIe x4 riser (~$30) — puts the card on the CPU root complex; x4 suffices for TP.
- Board whose CPU x16 bifurcates x8/x8 (done 2026-09-20 on dankserver — the
per-device chain census, including both endpoints reading
ReqEn+, is 's). Re-checked 2026-10-18 without root: both root ports reportcurrent_link_width=8at32.0 GT/s(/sys/bus/pci/devices/0000:00:01.{1,3}/current_link_width), and the live engine states direct P2P between the cards. - llama.cpp
--split-mode layer— no collectives at all (); costs the GGUF conversion plus paged attention / continuous batching / spec decode. - Independent TP=1 replicas + routing proxy — cards never share one model.
Closed software routes (do not re-litigate): vLLM's IPC custom allreduce
is gated by a CUDA-sm-keyed table ({9.0, 10.0, 10.3, 10.7}; ROCm reports
12.0 → off on RDNA4 by code limit) — the table is
CUSTOM_ALL_REDUCE_MAX_SIZES in
vllm/distributed/device_communicators/all_reduce_utils.py, whose keys on
today's main are exactly "9.0", "10.0", "10.3", "10.7"
(source
read 2026-10-18); nothing keys on a ROCm-reported 12.0. — and patching it is
insufficient because TP init's broadcast/barrier/logits all_gather have
no non-RCCL path. A
fork wrapping .all_reduce (e.g. radiance RADIANCE_USE_R4D_AR one-shot
P2P, gloo for IPC-handle exchange) does not intercept
PyNcclCommunicator.__init__'s warmup allreduce. NCCL symmetric memory needs
ncclDevComm* (many builds ship the version string without the device API).
P/D disaggregation needs a KV connector AND coordination collectives. gloo is
CPU/TCP — unreachable for per-layer TP.
Open, unverified: rebuilding RCCL so kernels stop declaring a hostcall
buffer (-mprintf-kind=buffered is the usual claim) — counter-evidence: no
live printf/assert in RCCL device source. Test path: rebuild, dump a
kernel's declared args (roc-obj-extract + llvm-readelf --notes), look for
hidden_hostcall_buffer; if gone, retry the 2-rank probe (service stopped).
Related
- the layer-split path around the wall
- / — measured history
- frank-archon — the gather economics of the retired tcclaviger vLLM era (2026-09-29 → 10-05)