Skip to content

fix(kernel): clear the latched CUDA error before raising in pinned_tensor - #144

Open
rakhimovv wants to merge 2 commits into
FlashML-org:mainfrom
rakhimovv:fix/pinned-tensor-clear-cuda-error
Open

rakhimovv wants to merge 2 commits into
FlashML-org:mainfrom
rakhimovv:fix/pinned-tensor-clear-cuda-error

Conversation

@rakhimovv

@rakhimovv rakhimovv commented Aug 24, 2026 •

Copy link
Copy Markdown

Thanks for FreeToken — it set up and ran cleanly on a fresh H100 box, and the offload design was a pleasure to read through.

While running the suite I hit a failure that turned out to be worth a fix. Happy to adjust anything here, including dropping it if you would rather solve it differently.

What breaks

Most CUDA calls in python/freetoken/kernel/csrc/pinned_tensor.cpp are guarded with
TORCH_CHECK(err == cudaSuccess, ...); two attribute queries and the deleter were not
checked at all. TORCH_CHECK reports the error to the caller but never reads it out of the
runtime, so the failure stays latched in the calling thread. The failures these calls can
originate are non-sticky — the context is still perfectly usable — but the next unrelated
CUDA call picks up the stale status and fails as if it were its own.

The result is that a handled exception from this extension silently poisons the rest
of the process.

Revised after review: cudaFreeHost was not covered, and the attribute probe was
raising where it used to fall back safely. Both corrected; see "The fix".

Related: #125 fixes the test-side symptom of the same CUDA 13 behaviour; see
"Relationship to #125" below.

Observed failure chain

On this box the full suite fails twice, and the second failure is collateral from the
first:

2 failed, 1371 passed, 9 skipped
  1. tests/kernels/test_pinned_tensor.py::test_host_device_ptr_is_identity_under_uva
    calls host_device_ptr() on unregistered pageable memory. On CUDA 13 that returns
    cudaErrorInvalidValue, so the extension raises:

    RuntimeError: cudaHostGetDevicePointer failed (host memory must be pinned+mapped): invalid argument
    
  2. The error is never drained. Later in the same pytest process,
    tests/kernels/test_triton_attention.py::test_paged_triton_attention_matches_reference[None-256]
    dies on its very first line — q = torch.randn(4, num_q_heads, head_dim, device=device) —
    with:

    torch.AcceleratorError: CUDA error: invalid argument
    

That the second failure is collateral and not a Triton bug is easy to confirm: run that
file on its own and it is green.

$ .venv/bin/python -m pytest tests/kernels/test_triton_attention.py
33 passed in 10.76s

A direct probe shows the error is merely uncleared rather than fatal: calling
host_device_ptr on pageable memory, then cudaGetLastError() through ctypes, returns
1 (cudaErrorInvalidValue) on the first read and 0 on the second, after which
torch.randn(4, device="cuda") succeeds normally.

Probe output before the change:

host_ptr_identity: True
pageable -> RuntimeError: cudaHostGetDevicePointer failed (host memory must be pinned+mapped): invalid argument
post-error cuda alloc: POISONED -> AcceleratorError CUDA error: invalid argument

and after:

host_ptr_identity: True
pageable -> RuntimeError: cudaHostGetDevicePointer failed (host memory must be pinned+mapped): invalid argument
post-error cuda alloc: OK
pinned host_device_ptr identity: True
production device_ptr == data_ptr: True

Why this is a real bug, not a test artifact

The clearest live example is kernel/backend.py:driver_cuda_version(), which wraps the
extension call in except Exception: return None and carries on using CUDA afterwards —
it runs at config time on the main thread (via moe/nvfp4_backends.py), so a latched
status there lands on whatever CUDA call the engine makes next.

cudaFreeHost is the other one that matters, and for a different reason: it is a
from_blob deleter, so it runs during GC with no exception to attribute a failure to. A
status left latched there has no visible origin at all.

pinned.device_ptr() short-circuits on _host_ptr_identity(), so the failing pointer
translation is not itself on the hot path on Linux.

The test suite is where it showed up first because pytest keeps one process alive across
files; a long-running server has the same property.

The fix

An FT_CUDA_CHECK macro that calls cudaGetLastError() to drain the latched status
before raising, on the seven calls that already raised: cudaMallocHost, cudaHostAlloc,
cudaGetDevice, cudaHostGetDevicePointer, cudaHostRegister and cudaDriverGetVersion.
The message text of each check is unchanged — old and new both take TORCH_CHECK's
variadic path, so the strings are byte-identical and it is still a c10::Error.

The two remaining sites drain but deliberately do not raise:

  • cudaFreeHost in the free_pinned deleter, because a deleter must not throw.
  • the two cudaDeviceGetAttribute calls in host_ptr_identity(), which previously
    discarded their status with uva/reg pre-initialised to 0 — the same idiom as
    driver_cuda_version()'s // stays 0 when no driver is installed. That fallback is
    deliberate and it is the safe answer: "no identity" routes device_ptr() through
    host_device_ptr(), the real translation, which is correct on every platform. Raising
    there would turn an unqueryable attribute into a fatal error on the offload path, and
    _host_ptr_identity is lru_cached, which does not cache exceptions — so it would
    re-raise on every call rather than once.

That covers all nine CUDA calls in the file.

Why a new macro rather than C10_CUDA_CHECK, which drains the same way: _pinned_tensor
is a CppExtension linking only cudart, not libc10_cuda, and C10_CUDA_CHECK would
also replace these specific messages with a generic "CUDA error: ...".

Relationship to #125

#125 fixes the first failure above from the test side: it stops
test_host_device_ptr_is_identity_under_uva from passing a pageable pointer at all, on
the correct grounds that unregistered memory was never inside host_device_ptr's
contract. That is a fair reading and I have no objection to it.

It does not address what this PR is about. #125 changes only
tests/kernels/test_pinned_tensor.py and states that runtime code is unchanged, so
after it lands the C++ still leaves its error latched — the suite just stops containing
a call that triggers it. Any real cudaHostRegister or cudaHostGetDevicePointer
failure, of the kind host_banks.pin() is written to catch, still poisons whatever CUDA
call comes next.

The two changes are complementary rather than competing, and both are needed for a green
suite on CUDA 13. This PR deliberately does not touch
test_host_device_ptr_is_identity_under_uva, to stay out of #125's way.

Test added

A new test, test_failed_pinned_call_leaves_the_context_usable, drives the failure path
directly and asserts the property production depends on:

with pytest.raises(RuntimeError, match="cudaHostRegister failed"):
    ext.host_register(0, 64)
torch.randn(4, device="cuda").sum().item()

It uses host_register, not host_device_ptr, so it does not overlap #125's subject
matter and does not depend on any pageable-pointer behaviour.

Before this change it fails on the line after the raises block:

>       torch.randn(4, device="cuda").sum().item()
E       torch.AcceleratorError: CUDA error: invalid argument
tests/kernels/test_pinned_tensor.py:145: AcceleratorError

After it, it passes.

Before / after

All runs on bd372b6, same box, same install.

Whole suite, unmodified tree:

$ .venv/bin/python -m pytest tests/ -v -rA
2 failed, 1371 passed, 9 skipped

failing on tests/kernels/test_pinned_tensor.py::test_host_device_ptr_is_identity_under_uva
and tests/kernels/test_triton_attention.py::test_paged_triton_attention_matches_reference[None-256].

Whole suite with this PR applied:

$ .venv/bin/python -m pytest tests/ -q
1 failed, 1373 passed, 9 skipped

(Wall-clock is omitted deliberately: the two runs differ mostly in JIT kernel-cache warmth,
not in anything this change does.)

The Triton collateral failure is gone and the new test passes. The one remaining failure
is test_host_device_ptr_is_identity_under_uva, which is #125's to fix and which this
branch leaves untouched.

Isolating the new test against the C++ change alone — same tree, extension rebuilt each
time:

$ .venv/bin/python -m pytest tests/kernels/test_pinned_tensor.py -v   # without the C++ fix
2 failed, 7 passed in 14.21s
$ .venv/bin/python -m pytest tests/kernels/test_pinned_tensor.py -v   # with it
1 failed, 8 passed in 14.98s

Tested on

  • GPU: NVIDIA H100 80GB HBM3 (sm_90), one GPU of four in the box
  • NVIDIA driver: 580.126.16
  • CPU: Intel Xeon Platinum 8462Y+, 56 threads; 2015 GiB system RAM
  • OS: Linux 5.15.0-157-generic
  • CUDA toolkit: nvcc release 13.1, V13.1.115
  • torch 2.11.0+cu130, Python 3.12.13
  • FreeToken 0.1.2, at commit bd372b6
  • Install: uv pip install -e ".[accel,dev]"

Commands:

uv pip install -e ".[accel,dev]"
.venv/bin/python -m pytest tests/ -v -rA
.venv/bin/python -m pytest tests/kernels/test_triton_attention.py

…nsor

TORCH_CHECK reports a failed CUDA call but does not read the status out of
the runtime, so the error stays latched in the calling thread. The errors
raised here are non-sticky and the context remains usable, but the next
unrelated CUDA call picks up the stale status and fails as if it were its
own.

That turns a handled exception into a process-wide fault. host_banks.pin()
catches the RuntimeError from host_register() and re-raises a friendlier
message; any torch call made while handling that failure would report the
stale cudaHostRegister error instead of its own.

Route every CUDA call in the file through an FT_CUDA_CHECK macro that drains
the status with cudaGetLastError() before raising. The two
cudaDeviceGetAttribute calls in host_ptr_identity() were previously
unchecked and are now checked as well. Check messages are unchanged.

Add a test that drives the failure path directly and asserts the context
survives it.
…on-fatal

Two corrections to the previous commit.

cudaFreeHost was the one call left uncovered, and it is the worst place to
leak a status: it runs as a from_blob deleter during GC, with no exception
to attribute the failure to. It drains unconditionally and never throws.

host_ptr_identity's two cudaDeviceGetAttribute calls previously discarded
their status with uva/reg pre-initialised to 0 -- the same idiom as
driver_cuda_version's "stays 0 when no driver is installed", i.e. a
deliberate fallback rather than an oversight. Raising there turned an
unqueryable attribute into a fatal error on the offload path, and
_host_ptr_identity is lru_cached, which does not cache exceptions, so it
would have re-raised on every call. Drain without raising and keep the 0
fallback: "no identity" routes device_ptr through host_device_ptr, the real
translation, which is correct everywhere.

Also soften the macro comment: these calls report whatever is latched on
the thread, so "every error below is non-sticky" was a stronger claim than
holds. Draining leaves the context usable for the failures these calls
originate; a sticky error latched elsewhere re-latches on the next call.
MT-z added a commit to MT-z/FreeToken that referenced this pull request Sep 16, 2026
…, measured on this box)

rakhimovv's FlashML-org#144, open since 2026-08-24, no comments. `TORCH_CHECK` reports a failed
runtime call but never reads the status out of the calling thread, so the next unrelated
CUDA call reports *this* failure as its own. A handled exception silently poisons the
process. The PR adds `FT_CUDA_CHECK`, which drains before raising, over
`cudaMallocHost`, `cudaHostAlloc`, `cudaHostRegister`, `cudaGetDevice` and
`cudaDriverGetVersion`, and makes the `from_blob` deleter drain without throwing.

His trigger does not fire here. On his CUDA 13 H100, `host_device_ptr()` on pageable
memory returns `cudaErrorInvalidValue`; on this box the same call succeeds (920465344,
no exception), and `tests/kernels/test_pinned_tensor.py` passes 8/8 either way. His
collateral-damage chain does not reproduce either: `test_triton_attention.py` fails the
same 4 tests alone as it does in the full suite, so those 4 are real, not poisoned.

The mechanism does reproduce, through a different call. Registering the same host range
twice returns `cudaErrorHostMemoryAlreadyRegistered`, and with both versions of
`pinned_tensor.cpp` built side by side as separate JIT modules -- the in-tree .so is
built at install time and does not pick up a source edit, so measuring the branch alone
would have measured the same stale binary twice:

    after the handled RuntimeError, torch.randn(4, device="cuda")
      try/all c82de34   POISONED -- AcceleratorError: CUDA error: ... already mapped
      with FlashML-org#144         OK

It is on this box's path, not a general remark: `cudaMallocHost`, `cudaHostAlloc` and
`cudaHostRegister` are what the MoE offload host banks use, and 61 GB of RAM under
`--moe-backend offload` is where a pinned allocation actually fails.

Prescription and result match. `test_failed_pinned_call_leaves_the_context_usable` fails
with `AcceleratorError` when only `pinned_tensor.cpp` is reverted and rebuilt, and passes
when it is restored.

Full suite after `setup.py build_ext --inplace`: 11 failed, 1831 passed, 60 skipped --
the same 11 as try/all, plus the one test the PR adds.

Note for anyone repeating this: taking this in requires rebuilding the compiled
extensions (38 s here). A branch switch alone leaves the old .so in place.

Assisted-by: Claude Opus 5
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 3, 2026
…A error before raising in pinned_tensor
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 3, 2026
On some WSL hosts the driver refuses pinning after a few GiB, far below
the 40%-of-RAM estimate, so the split residency never engaged and the
load died in cudaHostRegister. Pin 256 MiB blocks once until the driver
refuses or the estimate is reached, free them, and budget 80% of it.

Alternative to upstream PR FlashML-org#233: probes up to the old estimate instead
of a fixed 8 GiB (which would cap large hosts at 6.4 GiB), returns 0
rather than "uncapped" when nothing pins, allocates through the pinned
extension that now clears the driver's latched error (FlashML-org#144), and keeps
main's reserved-bytes signature.

Co-authored-by: kitty <2165990891@qq.com>
Assisted-by: Claude Opus 5.5
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 3, 2026
Logs the low-RAM loading batch pulled forward: FlashML-org#334, FlashML-org#233, FlashML-org#144 and FlashML-org#56.

Assisted-by: Claude Opus 5.5

This branch has not been deployed

No deployments
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