Skip to content

fabric/tp: HIP device side of the TCP Kind - #2961

Merged
ashhart merged 4 commits into
ashhart:mainfrom
Shoestring-dev:tp-hip-device
Nov 14, 2026
Merged

ashhart merged 4 commits into
ashhart:mainfrom
Shoestring-dev:tp-hip-device

Conversation

@Shoestring-dev

@Shoestring-dev Shoestring-dev commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

fabric/tp's TCP Kind gets a device side: stage.zig stages device memory through tcp.zig behind the same Collective VTable (still Kind.tcp), and stage_hip.zig is HIP's half, so a HIP engine can hand it device pointers and its stream and each call only queues. Nothing selects it yet: only its tests and tf-tp-test tcp-hip open it. How a call runs is in zig/src/fabric/tp/README.md ("Device memory over the TCP Kind").

Why the caller's stream gets only two 64-bit words and no copies: with the copies queued on it around the wait, two ranks of one process hung, or read stale bytes, once a call reached 16 KiB (ROCm 10.0, gfx1151). A standalone HIP program with no TensorFold code did the same, and passed with the copies on a stream of their own, so I haven't pinned the cause inside the runtime. The probe sources are available if useful.

Checks on this head

  • python3 tools/pr_check.py --base eab305db0 --pr 2961: 0 errors; lean 345 vs 345, 0 new; 1 advisory warning (no Unreleased bullet; the maintainer writes it).
  • Linux x86_64 with a GPU (Ryzen AI Max+ 395, Radeon 8060S gfx1151, ROCm 10.0.0, HIP 7.15), zig build test-tp --summary all at 954e82d: 210 of 211 pass, 1 skip (roce_test, needs HCAs); the four stage_hip_test GPU tests are among the passes. The tp test binary with the GPU tests, 20 times in a row: 20 of 20 (at 8566dfde9, which differs from 954e82d by one comment).
  • macOS 26.5 on Apple M4 Pro, zig build test-tp --summary all: 141 of 148 pass, 7 skip (the HIP tests without a HIP runtime, and RDMA).
  • zig fmt --check build.zig zig and git diff --check eab305db0: pass.
  • CI: green on this pull request's first head (8 pass, 2 nightly jobs skipped); this push runs it again.
  • Planted controls, each applied alone and reverted, before the rebase (the same stage, tcp and stage_hip code as here apart from the bounded copy wait, the part-size check's order and the GPU tests' skip): the header's part check removed, the stream's wait on done removed, the proxy not waiting for ready, the HIP download not waited for, and the part-size check at open removed; each failed exactly the tests that cover it.
  • Not run: Windows locally (stage.zig is in the POSIX-only TCP path, like tcp.zig; CI covers the compile); discrete AMD GPUs; ROCm 10.1 (no kernels here, so no HIP records); CUDA (nothing implements stage.Device for it here).

New tests: stage_test.zig (host_test's Calls and Pairwise on two to four staged ranks, whole and in parts; desync, abort, part numbers and sizes, capture, allGatherV, failed device calls), stage_later_test.zig (calls queued on held streams move nothing until the stream runs, then every result equals the reference), stage_hip_test.zig (the same on a real GPU behind a hipStreamWaitValue64 gate, and a proxy copy still queued past its limit fails its call instead of waiting forever), and tcp_test.zig (ranks that differ only in the part number are refused). The GPU tests skip without HIP or a GPU that can wait on a stream value.

Known limits

  • The proxy copies a part, moves it, then copies it back, one part at a time, with a host wait on each copy. Overlapping them, or GPU-side copies straight into the slab (our fork's stage/collect kernels, which also make a call capturable), are later work behind the same Device interface.
  • A stream waiting on a done word holds its hardware queue. Past the runtime's queue count (GPU_MAX_HW_QUEUES, 4 by default) the proxy's stream can share a queue with a parked stream; the proxy waits for each copy at most Hip.limit_ns (30 s), then fails the call (BackendFailed) and frees every stream. That bounded wait costs about 3 µs on the smallest calls against hipStreamSynchronize (45.5 against 41.3 µs at 16 B); large calls are unchanged.
  • Rank.init (in tcp.zig) still checks world <= 8 by debug assert only; nothing selects Kind.tcp yet, and the pull request that does will refuse a larger world by name.

Receipt

n/a for inference: no engine calls this yet. Its own receipt is tf-tp-test tcp-hip, in docs/receipts/tp-hip-device-954e82df/ with every log, the environment, a README and SHA256SUMS: two ranks over 127.0.0.1 on one host, both on one GPU, so it shows the device side bit for bit and its cost over tcp-host on the same link, not the link.

PASS tcp-hip rank 0/2: 400 all-gathers (1 B to 4 MiB), 796 fp32 and bf16 sums, 100 exchanges, send/recv and variable all-gathers bit-exact from HIP memory over loopback on one host (127.0.0.1), MTU 65536, both ranks on one Radeon 8060S (gfx1151)

Rank 0, median and p90 of 500 back-to-back calls; a tcp-hip time is the call and its stream's synchronisation, "queued" the call alone:

call bytes tcp-hip p50 µs p90 µs queued p50 µs tcp-host p50 µs p90 µs
all-gather 16 45.5 47.8 2.4 2.2 2.4
exchange 16 33.9 37.1 2.0 2.2 2.3
all-gather 4,096 46.8 50.6 2.0 3.0 3.2
exchange 4,096 36.9 39.6 2.0 2.6 2.8
all-gather 8,192 48.0 51.3 2.4 3.6 3.8
exchange 8,192 37.1 39.4 2.4 2.7 2.9
all-gather 32,768 46.6 47.8 2.4 7.1 7.2
exchange 32,768 33.9 36.3 2.4 3.7 4.0
all-gather 131,072 72.5 87.1 2.4 23.6 24.1
exchange 131,072 45.2 48.3 2.4 10.4 10.8
all-gather 262,144 112.3 115.4 2.4 44.8 45.7
exchange 262,144 62.7 64.1 2.4 17.8 18.2
all-gather 1,048,576 337.3 341.2 2.4 170.2 172.1
exchange 1,048,576 162.9 181.1 2.1 62.5 67.2
all-gather 4,194,304 1267.6 1278.2 2.5 728.4 749.5
exchange 4,194,304 538.0 551.5 2.1 279.0 288.4

A peer killed with SIGKILL 3 s into the run: rank 0 ended error: PeerFailed, exit 1, 0.124 s later. For scale, #2718's receipt over USB4 measured 11 µs at 16 B and 2.4 ms for a 4 MiB gather, against tcp-host's 2.2 µs and 0.73 ms on loopback here.

Pending: the receipt the plan names, two hosts over USB4 networking (two cables, MTU 1500), waits for our second pair's cables. It will be the same command per cable, as for #2718:

# both hosts; rank 0's link address is 192.0.2.1
export TF_TP_WORLD=2 TF_TP_MASTER=192.0.2.1 TF_TP_LINK="USB4 networking, cable 1 of 2, MTU 1500"
TF_TP_RANK=0 ./zig-out/bin/tf-tp-test tcp-hip   # host A
TF_TP_RANK=1 ./zig-out/bin/tf-tp-test tcp-hip   # host B

Open questions

  1. Placement: the HIP half resolves its twelve entry points itself from the HIP library the caller opened (Hip.init(lib)), instead of adding hipStreamWriteValue64, hipStreamWaitValue64 and hipStreamQuery to zig/src/hip/abi.zig's Api, which every HIP engine requires at load. Would you rather have them in Api?
  2. Graphs: refusing a capturing stream keeps this simple, but the HIP TP=2 lane will want captured decode rounds. Our fork did that with two small kernels (a device-side sequence counter, so replays run fresh calls). Is a non-capturable first slice fine, with the kernels as their own pull request under the kernel contract?
  3. hipStreamWaitValue64 is marked beta in HIP's headers, and its docs speak of hipMallocSignalMemory; on gfx1151 it works on mapped coherent host memory. Is that acceptable upstream, or should the words live in signal memory with the proxy writing them another way?
  4. The copy limit: a fixed 30 s, the link's own timeout_ns, or a high-priority stream for the proxy so it never shares a queue with a parked stream?

Checklist

  • The title and description match the head commit, including the receipt and the stack.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths in the diff or its commits. No AI attribution lines.

@Shoestring-dev

Copy link
Copy Markdown
Contributor Author

Updated after another review pass; the description is current.

  • The proxy now waits for each of its copies at most Hip.limit_ns (30 s) instead of hipStreamSynchronize: past the runtime's hardware queues its stream can share one with a stream parked on a done word, and the call now fails and frees every stream rather than waiting forever. New test: a proxy copy still queued past its limit. Cost: about 3 µs on the smallest calls; large calls unchanged.
  • The part size is checked after the ranks exchange it, so a bad size is refused on every rank at once.
  • The GPU tests skip (instead of failing) on a GPU or runtime without stream value waits.
  • The receipt is retaken at the code commit and now includes its logs (the repo's .gitignore had kept them out).
  • Open question 4 added: the copy limit's value, or a high-priority proxy stream.

@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @Shoestring-dev. This is careful work, and your update already fixed the two things we'd have raised on the first head: the receipt now carries its six logs, which match SHA256SUMS, and the GPU tests skip on a device that can't wait on a stream value. The bounded wait on the proxy's copies is a good find too. Without it, a copy queued behind a parked stream would hang the rank, and the new host test pins the limit without a GPU.

We read the device side again on 8255b22. Word reuse across the 256-call ring, the release on failure, the parts (including the gather's rank-major offsets), the part number in the header, the capture refusal and deinit all hold. Checking the part size after the exchange, so every rank refuses a bad size together, is the right order. It merges cleanly with today's main, adds no lean problems, keeps every file under 600 lines, and test-tp passes on macOS, with the HIP tests skipping as they should. tf-tp-test also cross-compiles for Linux, with and without HIP. We have no asks on this head.

On your questions:

  1. Placement: keep the twelve entry points in Hip.init. hip/abi.zig's Api is required in full when every HIP engine loads, so a beta export there could refuse engines on runtimes that lack it.
  2. Graphs: a first slice that can't be captured is fine while nothing selects the Kind. Please bring the device-side counter kernels as their own pull request under the kernel contract, with planted controls.
  3. hipStreamWaitValue64 on mapped coherent host memory: acceptable for now, since Hip.init refuses a device by name when the attribute says it can't wait. Please keep that refusal when TP=2 is wired to it, and say in that pull request that the API is marked beta and which GPUs and ROCm versions it ran on.
  4. The copy limit: the fixed 30 s is fine here, since nothing selects the Kind. When TP=2 is wired, take it from the link's timeout_ns, so the caller sets one deadline and a stuck copy fails the way a silent peer does. A high-priority proxy stream is worth trying in that pull request, with a test that parks more streams than GPU_MAX_HW_QUEUES. If that keeps the proxy's copies moving, the limit is only a backstop. Please also note there that a copy that timed out stays queued and can still land, so the engine shouldn't reuse that call's buffers until the device has synchronised.

The two-host USB4 run doesn't block this one. Please post it when the cables arrive, and in any case before the pull request that wires TP=2 to it. Next we'll put this head through our landing checks and land it, and we'll write the CHANGELOG line with credit to you.

  • Synthena (Ash's Agent)

@Shoestring-dev

Copy link
Copy Markdown
Contributor Author

The two-host USB4 run, at 8255b22 (this head), as promised.

tf-tp-test tcp-host and tcp-hip on two hosts over USB4 networking, run once per cable:

  • Two cables, each its own link and subnet, MTU 1500 (thunderbolt-net).
  • Each host is a Ryzen AI Max+ 395 with a Radeon 8060S (gfx1151), Linux 7.2.7 and ROCm 10.0.0's runtime (HIP 7.15). Each tcp-hip rank runs on its own host's GPU.
  • One ReleaseSafe binary built at 8255b22 runs on both hosts.
PASS tcp-hip rank 0/2: 400 all-gathers (1 B to 4 MiB), 796 fp32 and bf16 sums, 100 exchanges, send/recv and variable all-gathers bit-exact from HIP memory over USB4 networking, cable 1 of 2, MTU 1500, a Radeon 8060S (gfx1151) on each host
PASS tcp-hip rank 0/2: ... over USB4 networking, cable 2 of 2, MTU 1500, a Radeon 8060S (gfx1151) on each host

Both ranks print PASS on both cables, for tcp-host too.

These are rank 0's numbers, the median and p90 of 500 back-to-back calls. A tcp-hip time covers the call and its stream's synchronisation, and "queued" is the call alone:

call bytes cable 1 tcp-hip p50 µs p90 queued p50 tcp-host p50 cable 2 tcp-hip p50 µs p90 queued p50 tcp-host p50
all-gather 16 46.8 55.7 2.0 12.3 52.9 59.4 2.4 11.5
exchange 16 36.0 41.2 2.1 11.9 38.4 43.2 2.1 11.0
all-gather 4,096 49.2 55.8 1.7 17.7 52.3 257.2 2.1 17.5
exchange 4,096 40.3 47.5 2.1 19.1 211.0 263.4 2.4 16.8
all-gather 8,192 51.7 62.8 1.7 19.6 58.0 69.7 2.4 19.3
exchange 8,192 42.4 52.8 2.1 19.1 46.6 265.8 2.1 18.0
all-gather 32,768 63.4 208.9 2.1 30.7 63.6 281.5 2.1 31.0
exchange 32,768 50.8 299.8 2.1 27.1 56.5 301.3 2.1 26.6
all-gather 131,072 225.4 335.7 2.1 137.4 120.4 346.7 2.1 323.1
exchange 131,072 293.4 365.1 2.2 311.2 247.8 362.6 2.0 246.6
all-gather 262,144 194.2 434.8 2.4 174.9 206.2 441.9 2.1 185.3
exchange 262,144 178.7 440.0 2.5 143.9 178.5 425.6 2.2 311.5
all-gather 1,048,576 756.5 1050.4 2.5 661.2 671.6 904.9 2.3 661.6
exchange 1,048,576 533.4 846.5 2.5 470.1 529.6 861.0 2.4 502.4
all-gather 4,194,304 2632.3 3185.5 2.7 2391.9 2306.4 2865.0 2.3 2552.3
exchange 4,194,304 1713.8 2040.3 2.7 1760.7 1785.7 2200.6 2.2 1801.0
  • On this link, the device side adds about 35 µs to the smallest calls. From 1 MiB up the two modes are within about 15% of each other, and not always the same way round: a 4 MiB gather took 10% longer than tcp-host on cable 1 and 10% less on cable 2. tcp-host lands where fabric/tp: a TCP Kind for hosts without RDMA #2718's USB4 receipt did: 12 µs at 16 B and 2.4 ms for a 4 MiB gather.
  • Some cells are slow in either mode, not the same ones in both: the p90s from 32 KiB to 256 KiB, tcp-hip's 4 KiB exchange on cable 2, and tcp-host's 128 to 256 KiB calls. Over the runs each link's RX error counter rose by 367 to 627 on each host (in the environment files, before and after), and TCP retransmitted. I read the noise as the link's, not the device side's, but haven't proven it. Every check passed bit for bit.
  • A peer killed with SIGKILL 3 s into a tcp-hip run on cable 1: rank 0 ended error: PeerFailed, exit 1, 0.083 s later. The two hosts' clocks were NTP-synced.

The logs, both hosts' environments before and after, the build line and SHA256SUMS are available if useful; nothing here changes the branch.

Shoestring-dev and others added 4 commits November 14, 2026 07:24
stage.zig stages device memory through tcp.zig behind the same Collective VTable (Kind.tcp). A call only queues on its
stream: the stream writes the call's ready word, then waits for its done word, 64-bit words in pinned host memory. A
proxy thread sees ready, copies the input into a pinned slab on a stream of its own, moves it with tcp.zig, copies the
result back and sets done. The caller's stream gets no copies of ours: with copies queued on it around the wait, two
ranks of one process hung or read stale bytes on ROCm 10.0. A call larger than Options.part_bytes (default 4 MiB,
checked alike when the ranks open) moves in parts, and the frame header's spare word now names the part, so one call in
two parts never pairs with two whole calls.
A failure sets every done word, so no stream waits on it, and shows at the next call or at check. A capturing stream is
refused, and allGatherV moves whole strides.

stage_hip.zig is the HIP device: hipStreamWriteValue64 and hipStreamWaitValue64 on mapped coherent host memory, refused
by name on a GPU without them, and the proxy's copies on a non-blocking stream of its own. stage_test.zig runs
host_test's contract bodies over a device whose copies are memcpy, whole and in 64-byte parts, plus part, size, capture
and device-failure refusals; stage_later_test.zig uses streams that run later, so calls return before anything moves
and an abort still lets a held stream drain; stage_hip_test.zig checks the same on a GPU, two ranks of one device over
loopback, and skips without HIP. `tf-tp-test tcp-hip` runs tcp-host's checks and timings from HIP memory. Nothing
selects it yet, and no other backend changes.
@ashhart
ashhart merged commit 222f038 into ashhart:main Nov 14, 2026
2 of 10 checks passed
@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thank you @Shoestring-dev. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants