Skip to content

feat: optional CUDA host-memory pinning of the ring buffers - #5

Merged
sash-a merged 15 commits into
mainfrom
feat/pin-host-memory
Aug 3, 2026
Merged

feat: optional CUDA host-memory pinning of the ring buffers#5
sash-a merged 15 commits into
mainfrom
feat/pin-host-memory

Conversation

@sash-a

@sash-a sash-a commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Adds Server(..., pin_host_memory=True), which CUDA-page-locks echo's ring
buffers so a host-to-device copy of a sampled batch is a real DMA transfer
instead of a chunked staging copy the driver walks through on the calling
thread. Off by default; 0.1.10.2.0.

Why

Copying out of pageable memory isn't a DMA transfer. The driver stages it
through a small internal pinned buffer in CPU-executed chunks, holding the
calling thread and the driver lock throughout — so cudaMemcpyAsync stops
being asynchronous and blocks the same thread's kernel launches. On a learner
issuing tens of thousands of small launches per step, that contention, not
bandwidth, is what shows up as host-side dispatch dominating the step.

Measured locally (RTX 5060 Ti, driver 595.71.05, CUDA 13.3), cudaMemcpyAsync
out of pageable memory holds the calling thread for 97% of the copy
(3.645 ms of 3.751 ms); out of page-locked memory it returns in 0.002 ms.
Bandwidth barely moves on this machine (~14 GB/s either way) because the host
link is already saturated — the occupancy figure is the reason to turn this on.
Full numbers and caveats are in the guide.

The guarantee

If the constructor returns, every ring buffer is page-locked. Anything that
would prevent it — no CUDA runtime found, no usable device, a registration
rejected — raises RuntimeError, naming every path probed and the CUDA error
symbolically. There is no status object and no silent fallback, because a
silent no-op is indistinguishable from pinning not helping.

That failure mode is not hypothetical: the first cut of this branch resolved the
runtime with a single dlopen("libcudart.so"). The pip CUDA runtime wheels ship
only the versioned soname, with no unversioned symlink and no ldconfig entry,
so every pin was quietly a no-op. Resolution is now a three-rung ladder —
already-mapped libraries, then the installed CUDA wheels, then sonames
(versioned first) — built by a pure candidates() function so its ordering is
unit-tested, since reordering the rungs is exactly how it broke.

Shape

src/host_pinning/ splits in two, meeting only at CudaApi:

File Role
mod.rs CudaApi, Region, PinError
resolve.rs find the CUDA runtime
register.rs page-lock with it, and roll back cleanly
  • No build-time CUDA dependency. The four entry points are dlsym'd at pin
    time, RTLD_LOCAL. One wheel installs on GPU and CPU-only hosts alike, and
    with pinning off nothing is loaded.
  • Registration is not in the constructor. A constructor returning Err
    never runs Drop, so registering there forces a hand-written unregister loop
    on the error path. pin_host_memory runs on a fully-built buffer and Drop
    owns rollback for both the failure path and teardown.
  • CudaApi is injected, not global, so rollback — whose only visible
    consequence is the absence of leaked registrations — is testable. A stub
    that fails on the third buffer must produce exactly two unregister calls.

Testing

cargo test 73 passed, pytest 73 passed / 3 skipped, cargo clippy --all-targets clean, mkdocs build --strict clean. Verified on a GPU host.

Most of the coverage needs no GPU: the resolution ladder is pure, and
registration and rollback run against injected stubs. The GPU tests carry a
gpu marker and skip automatically, so CI needs no per-runner configuration.

The Python tests deliberately do not ask echo what it did — they ask the
CUDA runtime through ctypes for the registration flags on the address behind a
sampled batch. A test that trusted echo's own account would have passed against
the silent no-op described above.

Docs

New guide (mechanism, measured
numbers, footprint arithmetic, and how to confirm pinning engaged — including
why VmLck is not a valid check, since it stays at zero even when pinning
works) and a design page covering the module
split.

🤖 Generated with Claude Code

sash-a and others added 14 commits June 25, 2026 19:33
Page-lock each ring buffer via cudaHostRegister so a downstream jax.device_put
of the numpy views is a fast, truly-async H2D DMA instead of a synchronous
host->device staging copy. libcudart is resolved at runtime via dlopen (libc
only) -- no CUDA toolchain / link-time dependency; the code is always compiled
and is a graceful no-op unless ECHO_PIN_HOST_MEMORY=1 and libcudart is present.
Pinning happens only at ring-buffer construction (PytreeRingBuf::new) and
teardown (Drop), off the tokio runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps CLAUDE.md, docs/agents/ and .scratch/ out of the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites CUDA host-memory pinning so that it works, and so that it cannot
silently not work.

The previous implementation opened the unversioned `libcudart.so` soname,
which the pip CUDA runtime wheels do not ship. The dlopen returned NULL,
every pin call became a no-op, and nothing reported it — so a workload
could be measured against a code path that never executed and be
concluded not to benefit from pinning.

Pinning is now one keyword argument, off by default:

    Server(example, batch_size, ..., pin_host_memory=False)

The contract is that if `Server(...)` returns, every ring buffer is
page-locked. Any inability to deliver that raises `RuntimeError` naming
every path probed and the CUDA error symbolically. That guarantee is what
replaces an observability API: construction succeeding *is* the
assertion, and unlike an explicit check it cannot be forgotten.

- Resolution is a three-rung ladder — already-mapped libraries, then the
  installed CUDA wheels (searching the `nvidia` vendor package rather
  than a named component, since CUDA 13's consolidated layout differs
  from CUDA 12's per-component one), then sonames, versioned first. No
  soname symlink or LD_LIBRARY_PATH entry is needed.
- Page-locking moved out of `PytreeRingBuf::new` into an explicit
  fallible step on a constructed buffer, so the existing `Drop` owns
  rollback for both the failure path and normal teardown rather than a
  hand-written unregister loop. `new` stays infallible and the eight
  existing ring-buffer tests are untouched.
- CUDA entry points go through an injectable struct of function
  pointers, so registration, rollback and drop are all exercised with
  stubs on a machine with no GPU.
- Registration is portable-flag only. Page-aligned allocation and
  read-only registration were both measured and rejected: alignment
  gained 1.6% of copy time (bar was 5%), and read-only is unsupported on
  the test GPU and is documented as permission rather than optimisation.
- `ECHO_PIN_HOST_MEMORY` is deleted; the argument is the only control.

GPU tests carry a `gpu` marker and skip when no device is present. The
new guide documents the mechanism, the measured numbers, the unswappable
footprint arithmetic, and how to verify pinning engaged — including that
`VmLck` stays at zero even when it is working, which is what made the
original false negative believable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other module keeps its tests under tests/; host_pinning was the only
one with an in-file #[cfg(test)] module, and at 1012 lines was three times
the next-largest source file. The tests move to src/host_pinning/tests.rs
— in-crate rather than under tests/ because the module is private and the
stub-injection tests need the internal CudaApi — leaving 488 lines of
source.

Also cuts the comments back towards the density of the surrounding code.
The load-bearing "why" stays (the pageable staging mechanism, why the
unversioned soname is not enough, why the vendor package is searched
rather than a named component, why pinning is a post-construction step,
what forcing runtime init actually costs); the restatements of the guide
and the narrative asides go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module did two jobs: find the CUDA runtime, and page-lock memory with
it. Resolution was ~75% of the source and the part with no CUDA calls in
it at all, so the two barely interact — resolve hands back a CudaApi and
register uses it.

    host_pinning/
    ├── mod.rs          shared types: CudaApi, Region, PinError
    ├── resolve.rs      the three-rung ladder
    ├── resolve/tests.rs
    ├── register.rs     pin_all / unpin_all / rollback
    └── register/tests.rs

Tests nest under the module they cover rather than sitting in one file, so
each keeps its own privacy: the ladder's internals stay private to
resolve, and the stub-injection tests stay next to the unsafe code they
exist to cover. Only scan_mapped_runtimes widens, to pub(super), because
register's GPU test needs the mapped-runtime path to look up
cudaHostGetFlags independently of the pointers under test.

No behaviour change; the same 20 tests pass, 10 per module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tests were inside src/ because host_pinning was the crate's only
private module, and a private module's internals are invisible to tests/,
which are separate crates. That inverted the repo's convention: every
other module is pub with pub internals for precisely this reason —
PytreeRingBuf::slot_mut is a pub unsafe fn so tests/ring_buf.rs can drive
it.

So host_pinning becomes a pub mod and tests/host_pinning.rs joins the
other six, one per module.

This drops ticket 01's "module stays private" checklist item, deliberately
and noted there. What that constraint protected is unaffected: the crate is
publish = false and ships only as a Python extension, whose entire surface
for this feature is pin_host_memory=True. Two things keep the widening
honest — CudaApi's fields stay private behind `unsafe fn CudaApi::new`, so
no caller can fabricate one with arbitrary function pointers, and
pin_all/unpin_all stay unsafe fn with their Safety contracts.

Same 20 tests, still passing, GPU ones included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Rust-internals section had no entry for host_pinning at all — the
module map in overview.md predated the feature and was never updated — and
the resolution ladder's design was only described in the user-facing guide.

Adds docs/src/design/host-pinning.md covering the internals: the
resolve/register split and why it's clean, why resolution is a ladder and
what each rung is for, why registration is a post-construction step, the
function-pointer injection seam and what it exists to test, why the module
is pub, and the absence of any build-time CUDA dependency.

Trims ring-buffer.md's pinning section to what is actually about the ring
buffer — that its buffers are never reallocated, so a registration stays
valid for their life, and that Drop reverses it — with the module-level
rationale moved to the new page. Adds the module map row, the nav entry,
and a pointer from the guide.

development.md now says where the Rust tests live, and distinguishes the
Rust skip-and-pass convention from the Python `gpu` marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`just docs-serve` binds to loopback, so it is invisible when the checkout
lives on a remote box. Adds a recipe that takes a bind address, and
documents both routes in development.md — SSH port forwarding (preferred,
no server-side change) and binding to a reachable interface.

Also records the /echo/ path prefix that site_url imposes on the dev
server, since http://127.0.0.1:8000/ on its own 404s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two new pages carried a lot of scaffolding that wasn't doing work:
roadmap sentences ("this page covers..."), self-referential asides
("this section is the reason this page exists"), and a bolded
mini-headline on every paragraph of the Measured section. Technical
content is unchanged; the prose is trimmed to the voice of the
surrounding docs.

Substantive fixes found while editing:

- The sample RuntimeError in the guide omitted the two "(none)" lines
  that resolve() emits for a rung that ran and found nothing, which are
  the whole point of reporting empty rungs. Replaced with real output.
- "Two jobs, one file each" introduced a three-row table.
- Dropped the CHANGELOG's Changed and Removed sections. Relative to
  0.1.1 the resolution ladder is new, not changed, and
  ECHO_PIN_HOST_MEMORY was added and removed inside this branch, so it
  never shipped and users can't have depended on it.
- The Added entry showed pin_host_memory=False, the default, rather than
  the value that turns the feature on.
- Reported the primary-context cost as ~128 MB in register.rs to match
  the measured figure in the guide, not ~100 MB.
- Replaced "~1800x" occupancy with "three orders of magnitude"; the
  ratio was derived from a 0.002 ms figure at timer resolution.
- ring-buffer.md now links to the constructor rationale rather than
  restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e pinning

The failure message is the entire interface to a resolution failure, and
nothing tested it against a real failure. The Rust test hand-builds a
PinError and checks Display; the Python test that would have exercised
the actual accumulation was skipif(HAS_GPU), so it skipped on every
machine with a GPU — i.e. everywhere this gets developed. That gap is
why the guide's example output was wrong: it omitted the two "(none)"
lines that resolve() emits for a rung that ran and found nothing, which
are the whole reason empty rungs are reported at all.

Replaced it with a subprocess test that forces every rung to fail
(nothing mapped, nvidia unimportable, no LD_LIBRARY_PATH) and asserts
each rung appears, in order. It runs on GPU hosts too, since it needs
resolution to fail rather than a device to be absent. Verified by
mutation: dropping the empty-rung note fails it, and reordering the
reporting loop fails it on ordering.

The soname probe runs inside the subprocess deliberately. Probing in the
parent reports every soname as resolvable, because the module loads the
runtime by absolute path at import and dlopen then matches the
already-loaded object by soname — which would have skipped this test on
any host with the wheel installed.

Also release the GIL around pinning. Measured, a cold cudaFree(NULL)
creating the primary context takes ~210 ms, all of it with the GIL held;
detach lets other Python threads run at 82% of their unblocked rate
during construction instead of stalling. detach is synchronous, so the
constructor still returns only once every buffer is registered and the
page-locked guarantee is unchanged. It also removes a deadlock class:
holding the GIL across a CUDA call lets echo block against a thread that
holds a CUDA lock and wants the GIL.

Minor: note why empty_rung_note's Soname arm is unreachable, and why the
wheel walk does not follow directory symlinks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trimming the Measured and Verifying sections left four things pointing
at content that no longer exists:

- The footprint section linked to #verifying-that-pinning-engaged. Folded
  the part that mattered into one sentence instead: VmLck stays at zero
  because the driver's page-locking bypasses mlock accounting, so it is
  neither a binding limit nor a way to check pinning engaged.
- design/host-pinning.md and the CHANGELOG both advertised the guide as
  covering measured numbers and how to verify pinning; they now describe
  what it actually covers.
- server.py lost the blank line before `Raises:`, so griffe stopped
  parsing it as a section and rendered it as loose prose after the
  parameters table. Confirmed against the built HTML: the page had only a
  Parameters: section, and now has Parameters: and Raises:.

Also: the README said pinning means "even H2D doesn't require copies".
Pinning doesn't remove the copy, it makes it a DMA transfer instead of a
staged one — and next to echo's zero-copy claims that reads as though H2D
were free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sash-a
sash-a force-pushed the feat/pin-host-memory branch 2 times, most recently from c63c481 to efbf94b Compare August 3, 2026 13:47
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sash-a
sash-a merged commit 6ee9698 into main Aug 3, 2026
10 checks passed
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.

1 participant