Skip to content

fix(cuda): harden driver error handling, access tracking and host copies - #10871

Merged
0ax1 merged 17 commits into
developfrom
ad/cuda-driver-access-tracking-fixes
Nov 3, 2026
Merged

0ax1 merged 17 commits into
developfrom
ad/cuda-driver-access-tracking-fixes

Conversation

@0ax1

@0ax1 0ax1 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes in the CUDA driver wrapper for deferred errors, stream-ordered access
tracking, and host-copy bounds and synchronization. They ship together because
most build on CudaContext::make_current, added by the first commit, and share the
same cleanup and access-tracking paths. Each commit message explains its change.

Borrowed host-pointer classification now binds the context before querying CUDA,
treats only CUDA_ERROR_INVALID_VALUE as pageable memory, and records other errors
while conservatively waiting. D2H copies avoid a redundant synchronization after
a successful explicit wait. Cross-context device access remains rejected, with
the restriction documented on the device-pointer APIs.

Changes

  • fix(cuda): keep recorded errors out of cleanup and access tracking
  • fix(cuda): keep the first deferred error instead of the last
  • fix(cuda): complete earlier work when ordering a device access fails
  • fix(cuda): wait for enqueued accesses whose tracking fails
  • fix(cuda): reject device accesses from another context's stream
  • fix(cuda): bound copies by trusted lengths only
  • fix(cuda): reject overflowing lengths when reinterpreting memory
  • fix(cuda): allocate empty device buffers without the driver
  • refactor(cuda): stop recording errors into a context being dropped
  • refactor(cuda): record each pinned transfer's completion once
  • fix(cuda): wait for copies from borrowed page-locked slices
  • fix(cuda): handle borrowed host pointer query failures safely
  • fix(cuda): avoid redundant D2H synchronization
  • docs(cuda): document device pointer context restriction
Mainline: 7d003295d1 (origin/develop)
HEAD:     51ab33f32d

2 cold + 2 hot samples per dataset/revision, after warmup.
Times in ms. Negative change = faster on HEAD.

+-------+-------------------------+----------+----------+---------+
| Cache | Dataset                 | develop  | HEAD     | Change  |
+-------+-------------------------+----------+----------+---------+
| Cold  | Synthetic, 2M rows      |    2.608 |    2.540 |  -2.61% |
| Cold  | Numeric, 2M rows        |    1.726 |    1.736 |  +0.62% |
| Cold  | Lineitem SF10, decimal  |  107.479 |   98.751 |  -8.12% |
| Cold  | Lineitem SF10, float64  |  104.769 |  101.768 |  -2.86% |
+-------+-------------------------+----------+----------+---------+
| Hot   | Synthetic, 2M rows      |    1.549 |    1.559 |  +0.62% |
| Hot   | Numeric, 2M rows        |    0.772 |    0.726 |  -5.94% |
| Hot   | Lineitem SF10, decimal  |   72.988 |   72.212 |  -1.06% |
| Hot   | Lineitem SF10, float64  |   72.261 |   74.750 |  +3.44% |
+-------+-------------------------+----------+----------+---------+

GPU: GH200. Vortex block rows: 16,777,216.

@0ax1 0ax1 added the changelog/fix A bug fix label Oct 27, 2026
@0ax1

0ax1 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@claude review this pr in depth

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @0ax1's task in 3m 0s —— View job


Review of PR #10871: CUDA driver error handling, access tracking and host copies

  • Gather context
  • Read the driver diffs (context, stream, memory, launch, sys, module)
  • Read the downstream changes (pinned.rs, executor.rs, stream.rs)
  • Review the tests
  • Post review

I didn't find any blocking problems. The error-handling and tracking fixes look right, and each commit does what its message says. The main thing I'd change is that the new check for page-locked memory fails open. Details below, most important first.


1. borrowed() treats every driver error as pageable memory (medium)

vortex-cuda/driver/src/memory.rs, fn borrowed:

let known = unsafe { sys::cuPointerGetAttribute(...) }.result().is_ok();
if known && memory_type == sys::CU_MEMORYTYPE_HOST { wait_for } else { none }

The only error that means "pageable memory" is CUDA_ERROR_INVALID_VALUE. Other errors make the copy skip the wait without any signal, for example CUDA_ERROR_INVALID_CONTEXT / NOT_INITIALIZED, or a DEINITIALIZED during shutdown. This is now a soundness question, for two reasons:

  • borrowed() runs before self.bind() in both memcpy_htod and memcpy_dtoh. So the query can run on a thread with no current context. Today's callers (clone_htod, copy_to_device*) happen to allocate first, which binds the context, but nothing guarantees that.
  • VortexCudaStream::copy_to_device (vortex-cuda/src/stream.rs) no longer keeps data alive in the future (_keep_alive was removed). If the query fails on page-locked data, the source can be freed while the DMA still reads it. Before this PR, the future kept it alive.

Suggestion: match on the error. Return SyncOnDrop::none() only for CUDA_ERROR_INVALID_VALUE, and return SyncOnDrop::wait_for(stream) for anything else. Optionally call stream.make_current() before the query. Keeping let _keep_alive = data; in copy_to_device also costs nothing and adds a second safeguard.

Fix this →

2. recorded_errors_do_not_leak_frees allocates 4 GiB (low to medium, flaky test)

vortex-cuda/driver/src/tests.rs: let bytes = 4 << 30; with alloc_zeros(bytes)?. On a GPU with 8 GiB or less, or when other tests in the same process allocate at the same time (cargo test threads share the default pool), this returns CUDA_ERROR_OUT_OF_MEMORY. Because of the ?, the test then fails instead of skipping.

The leak check only needs an allocation clearly larger than what other tests use at the same time. Something like 512 MiB would do, or skip the test on OOM.

Fix this →

3. memcpy_dtoh now synchronizes twice for page-locked targets (nit, performance)

memcpy_dtoh already ends with cuStreamSynchronize. Because the slice HostSlice impls now go through borrowed(), every memcpy_dtoh also pays a cuPointerGetAttribute. For page-locked targets it then runs a second cuStreamSynchronize when the Wait guard drops. Both are harmless but unnecessary.

If it matters, the dtoh path could skip the guard, for example with an internal non-guarded slice accessor. The PR description already mentions the extra query, so this is optional.

4. A cross-context access now panics (nit, behaviour change)

The assert_eq! in Access::before is sound. It runs before the lock, so the mutex isn't poisoned, and it matches the module's "caller bugs panic" policy. It is still a public behaviour change: CUDA itself allows a device pointer to be used from another context on the same device. PooledPinnedBuffer::transfer_to_device already rejects this, and I couldn't find any in-tree caller that relies on it. It might be worth a line in the changelog or the DevicePtr docs.


Things I checked that look correct

  • record_err keeps the first error (compare_exchange(0, …)). check_err still swaps the value to 0, so later errors are recorded again once the first one is checked. ✅
  • make_current vs bind_to_thread. Cleanup paths (free_raw, before_free, after, event and stream drops) no longer take an unrelated pending error, so a recorded error can no longer leak a free. User-facing synchronize() still calls check_err first, and the new _in_place variants are pub(crate). ✅
  • Access::after failure paths. Before, the Write branch did state.write.take() before record_raw, so a failed record dropped the previous write. Now the event comes back from the state on success, or goes back to the pool when it was freshly taken. The Read branch does the same thing. ✅
  • access() falls back to a context-wide sync. All copy paths call self.bind()? after device_ptr*, which returns the recorded error, and launch uses before(..)?, so in those paths the fallback is mostly redundant. It does matter for raw-pointer users such as pooled_read_at/file.rs::copy_to_device. That function binds before device_ptr_mut and then launches kernels without checking errors again, so keeping the fallback is the right call. ✅
  • launch now tracks every access even if an earlier one fails, and keeps the first error. ✅
  • CudaSlice::drop leaks the memory when before_free fails, instead of freeing memory that other streams may still read without the waits. release still returns the events to the pool, which is fine: waits that were already enqueued captured the earlier event state. ✅
  • Empty allocations. alloc_raw(0) returns 0 after bind(), and free_raw(0) does nothing. This matches cuMemAllocAsync's behaviour and fixes the cuMemAlloc path. ✅
  • The fits::<S> overflow check in all four transmute* methods, and the new alignment requirements in their docs. ✅
  • Copy bounds use the copied slice, not HostSlice::len. Both asserts now run on the slice the trait impl returns, and the ShortHostSlice test covers both directions. ✅
  • Sealing DeviceSlice. The only impls are CudaSlice, CudaView and CudaViewMut. DevicePtr / DevicePtrMut inherit the seal through their supertrait. ✅
  • Guard drop order. In memcpy_htod, the target-access guard (inside memcpy_htod_async) drops before the source Wait guard. In memcpy_dtoh, the source access guard drops before the target guard. Both orders are correct. ✅
  • pinned.rs. Removing PinnedByteBufferView and using memcpy_htod_async plus one checked event.record keeps the same safety argument, and removes the second cuEventRecord per transfer. Pinned memory doesn't seem to escape into host ByteBuffers (device_read_at, ensure_on_device), so the new synchronous behaviour shouldn't hit the hot read paths. ✅
  • New sys.rs constants match cuda.h: CU_MEMPOOL_ATTR_USED_MEM_CURRENT = 7, CU_POINTER_ATTRIBUTE_MEMORY_TYPE = 2, CU_MEMORYTYPE_HOST = 1. ✅

Checks

I only read the code. I didn't compile it or run any tests: this runner has no GPU, and the review workflow is read-only.

@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 13.48%

2 warnings

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 2 improved benchmarks
✅ 2305 untouched benchmarks
⏩ 38 skipped benchmarks1
🗄️ 47 archived benchmarks run2

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ CPU Simulation filter_powerlaw_by_correlated_runs[100000] 62.1 µs 53.7 µs +15.62%
⚡ Walltime lt_i64_nullable_avx512 8.2 µs 7.4 µs +11.37%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ad/cuda-driver-access-tracking-fixes (f22338a) with develop (246a397)

Open in CodSpeed

Footnotes

  1. 38 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. 47 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩

0ax1 added 14 commits November 3, 2026 10:08
`CudaContext::bind_to_thread` returns and clears any error recorded with
`record_err` before making the context current. Frees, access tracking
and drops all bound through it, so one error recorded anywhere in the
context made them bail out early and record the error again:

- `CudaSlice::drop` skipped `cuMemFreeAsync`, leaking every allocation
  dropped while the error was pending.
- `Access::after` skipped recording a finished access, so a later free
  could skip waiting for a read still running on another stream.
- `PinnedByteBuffer::drop` failed `CudaEvent::synchronize` and leaked
  its page-locked allocation.

Cleanup and tracking now bind with `make_current`, which leaves recorded
errors for the public entry points that can report them, and
`CudaEvent::synchronize` waits for its own event regardless of them.

A free also used to run even when ordering it after other streams'
accesses failed; it now leaks the allocation instead, since those
accesses may still be in flight.

Signed-off-by: Alexander Droste <[email protected]>
`CudaContext::check_err` documents that it returns the first error
recorded where none could be returned, but `record_err` overwrote any
pending error, so the original cause was replaced by its consequences,
such as failed frees after a failed launch.

Only store an error when none is pending.

Signed-off-by: Alexander Droste <[email protected]>
`DevicePtr::device_ptr` and `DevicePtrMut::device_ptr_mut` return a
pointer and an armed guard even when `Access::before` failed to make the
stream wait for earlier conflicting accesses. The caller then enqueues
work that may overlap another stream's write, and for writes the guard
also records the write as following every earlier read: the next free
waits only for this write and can release memory still being read
elsewhere.

The pointer methods cannot report the failure, so fail closed instead:
synchronize the context, after which every earlier access is complete
and the access needs no waits. If that fails too, the context is broken
and rejects the access's work as well.

Signed-off-by: Alexander Droste <[email protected]>
Once a kernel or copy is enqueued, recording its access is the only
thing that orders later frees and writes after it. Three paths lost that
ordering when recording failed:

- `LaunchArgs::launch` returned on the first failed `Access::after`,
  skipping the remaining arguments, so freeing any of them could
  overlap the kernel still reading it.
- `Access::after` took the previous write event out of the state before
  recording the new write, and pushed a new read event before recording
  it. A failed record dropped the write's ordering, or left an
  unrecorded read event that later waits treat as complete.
- `SyncOnDrop` only stored the error.

Recording now leaves the state unchanged on failure and returns new
events to the pool. Launches and guards record every access, and fall
back to synchronizing the stream for any access they could not record,
so the access is complete before anything needs to follow it.

Signed-off-by: Alexander Droste <[email protected]>
Access tracking takes events from the accessing stream's context and
returns them to the pool of the context that later releases them, such
as the allocation's. Accessing memory from a stream of another context,
for example one made with `CudaContext::new_non_primary`, therefore
moved that context's events into the allocation context's pool, where
recording them fails with `CUDA_ERROR_INVALID_HANDLE`, and where they
outlive the context that owns them.

Cross-context access is a caller bug, and the pointer methods cannot
return an error, so assert that both streams share the CUDA context
before changing any tracking state.

Signed-off-by: Alexander Droste <[email protected]>
The copy methods are safe, but bounded their transfers by lengths that
safe code outside the driver could misreport:

- `DeviceSlice`, `DevicePtr` and `DevicePtrMut` were implementable
  anywhere, so a wrapper could forward a real allocation's pointer
  while reporting a `len` and `num_bytes` that disagree, making
  `clone_dtoh` copy past its vector's capacity.
- `memcpy_htod` and `memcpy_dtoh` checked `HostSlice::len`, then copied
  the slice `stream_synced_slice` returned, which may be longer.

Seal the device memory traits, which nothing outside the driver
implements, and check host copies against the slices they copy.

Signed-off-by: Alexander Droste <[email protected]>
`transmute` and `transmute_mut` checked `len * size_of::<S>()` against
the memory's size without checking the multiplication, so in release
builds a large `len` wrapped around, passed the check and produced a
view far larger than its allocation.

Check the multiplication. Their safety contracts also required valid
values of `S` but not alignment, although a view sliced at an odd
offset yields a pointer misaligned for `S`, and a misaligned access
faults the kernel and breaks the context; require that as well.

Signed-off-by: Alexander Droste <[email protected]>
Empty uploads, such as `copy_to_device` of an empty slice, allocate
zero bytes. `cuMemAllocAsync` returns a null pointer for them, but
`cuMemAlloc`, which devices without memory pools use, rejects them with
`CUDA_ERROR_INVALID_VALUE`, so empty buffers failed only on those
devices.

Return a null pointer for empty allocations on both paths, as the pool
allocator already did, and skip freeing it.

Signed-off-by: Alexander Droste <[email protected]>
`CudaContext::drop` stored the errors of its final driver calls with
`record_err`, which writes them into the context being dropped, where
nothing can read them. Ignore them explicitly instead.

Signed-off-by: Alexander Droste <[email protected]>
`PooledPinnedBuffer::copy_to_device` copied from a view whose
`SyncOnDrop` guard recorded the buffer's completion event when the copy
was enqueued, then recorded the same event on the same stream again,
because only the explicit record's error is returned and decides
whether the buffer takes the recovery path.

Copy from a plain slice and keep only the checked record, which removes
`PinnedByteBufferView` and its `HostSlice` implementation and one
driver call per transfer.

Signed-off-by: Alexander Droste <[email protected]>
`CudaStream::memcpy_htod` is safe and borrows its source only for the
call, but for slices, vectors and arrays it enqueued the copy without
waiting. That is sound for pageable memory, which the driver stages
before the copy reads it, but the device reads page-locked memory
asynchronously. A pooled pinned buffer passed as a plain slice, for
example, could be modified or reused while the copy still read it.

The guards of borrowed slices now wait for the copy when the slice is
page-locked memory. Callers that keep their source alive until the copy
completes, such as the pinned buffer pool, use the new unsafe
`memcpy_htod_async` to keep their copies asynchronous.

Uploads no longer need their future to keep the source alive, so
dropping the future before the copy completes no longer frees memory
the copy reads.

Signed-off-by: Alexander Droste <[email protected]>
Disarm only same-stream wait guards after a successful D2H synchronization, preserving error-path waits and event recording. Add guard and pinned-target coverage, and remove the oversized shared-pool leak test.

Signed-off-by: Alexander Droste <[email protected]>
@0ax1
0ax1 force-pushed the ad/cuda-driver-access-tracking-fixes branch from f7eeeaf to 51ab33f Compare November 3, 2026 10:57
@0ax1
0ax1 requested a review from mprammer November 3, 2026 11:01
@0ax1
0ax1 marked this pull request as ready for review November 3, 2026 11:02
@0ax1
0ax1 requested review from onursatici and robert3005 and removed request for onursatici November 3, 2026 11:02
@0ax1
0ax1 enabled auto-merge (squash) November 3, 2026 11:04
Use cuPointerGetAttributes to classify borrowed host slices without the expected INVALID_VALUE error that Compute Sanitizer reports for pageable pointers. Preserve pinned-memory waits and record every actual query failure conservatively. Add focused pageable and pinned guard coverage.

Signed-off-by: Alexander Droste <[email protected]>
@0ax1
0ax1 disabled auto-merge November 3, 2026 11:37
Consolidate borrowed-pointer error handling, simplify ordered frees, and collect access-tracking errors without short-circuiting. Remove the unused pointer attribute enum alias, explain the D2H wait disarm at its call site, and shrink the pinned-copy test allocation to 1 MiB.

Signed-off-by: Alexander Droste <[email protected]>
@0ax1
0ax1 enabled auto-merge (squash) November 3, 2026 11:53
@0ax1
0ax1 merged commit 1baa4b8 into develop Nov 3, 2026
75 checks passed
@0ax1
0ax1 deleted the ad/cuda-driver-access-tracking-fixes branch November 3, 2026 13:49
connortsui20 added a commit that referenced this pull request Nov 3, 2026
## Summary

Follow-up to #11110. Keep the remaining noisy cases out of automatic
CodSpeed runs while preserving them for local benchmarks.

The Python scalar exclusions in #11110 never took effect because the
runner exports simulation as `instrumentation`, while the fixture
checked for `simulation`. Use the plugin's resolved mode so these cases
are actually skipped.

## Changes

- Allow a trailing comma in `#[divan::bench(...)]` when the CPU-feature
macro appends its options. The multiline bitpacked take definition
covers this case in CI builds.
- Share the resolved simulation check between the scalar and filter
fixtures. Also skip the 2 integer one-shot filtered scans that still
report system calls under simulation.
- Keep AVX2 bitpacked take with 80 selections local after batching still
produced a regression alert. Retain the 64-selection case and both
AVX-512 cases in CI.
- Keep AVX-512 nullable integer comparison local because its minimum
changes by more than 10% while its median stays stable.
- Keep the small random power-law boolean filter and the large
correlated power-law boolean filter local under simulation. The former
has repeated unexplained changes, and the latter repeatedly makes system
calls that simulation cannot measure.

<details>
<summary>Evidence from recent PR reports</summary>

Reviewed 40 CodSpeed report comments updated between November 2 at 19:28
UTC and November 3 at 19:28 UTC, including their comparison revisions
and the affected measurement modes.

| Case | Evidence |
| --- | --- |
| Python integer scalar scans |
[#11154](#11154 (comment))
still reports the repeated scan's 10 system calls. [A develop run after
#11110](https://github.com/vortex-data/vortex/actions/runs/41677647530/job/126062463160)
executes both integer scalar cases instead of skipping them. The [runner
exports the legacy `instrumentation`
value](https://github.com/CodSpeedHQ/codspeed/blob/main/crates/runner-shared/src/measurement_mode.rs),
which pytest-codspeed 5.0.3 normalizes to simulation. |
| Python integer filtered scans |
[#11130](#11130 (comment))
reports 30 system calls totaling 14.3 ms in `test_scan_filter[int]`.
[#11138](#11138 (comment))
reports 30 calls totaling 16.3 ms in
`test_scan_filter_to_arrow_array[int]`. |
| AVX2 `threshold[80]` |
[#11004](#11004 (comment))
compares `f7b5b1d864` with `e59b236d7b`: 2.183 to 2.531 µs, reported as
a 13.75% regression. Both use 1000 samples of 16 iterations. The head
median is 2.556 µs, so this is not just a single outlying minimum. The
PR does not change the fastlanes code or benchmark. |
| AVX-512 `lt_i64_nullable` |
[#11168](#11168 (comment))
reports 7.342 to 8.217 µs. Several unrelated CUDA PRs report the
opposite direction, including
[#10872](#10872 (comment)),
at 8.208 to 7.348 µs. Their head medians remain around 8.3 to 8.5 µs. |
| Boolean `filter_powerlaw_by_random[10000]` |
[#11167](#11167 (comment))
reports a 16.2% regression,
[#11168](#11168 (comment))
a 23.56% improvement, and
[#11169](#11169 (comment))
a 13.06% regression. The cause is unknown. |
| Boolean `filter_powerlaw_by_correlated_runs[100000]` |
[#10871](#10871 (comment))
and
[#11165](#11165 (comment))
each report 2 system calls totaling about 44 µs. |

The NEON chunked-take alerts on #11090 are on a PR that changes
execution substantially, so those cases remain enabled. Other dictionary
simulation alerts need more attribution before excluding them.

</details>

CI passes formatting, Python lint and script tests, Rust lint,
default-feature and all-feature Rust builds, all 3 CPU walltime
benchmark jobs, and Python benchmarks (36 passed, 4 intentionally
skipped). The previously failed simulation shard also passes. Some
remaining CI jobs and CodSpeed performance analysis are still pending.

No local checks were run. The pre-commit and pre-push hooks were
bypassed as requested.

---------

Signed-off-by: Connor Tsui <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants