Skip to content

feat(runtime): pinned-host-buffer fast path for create_from_slice uploads#1334

Draft
lilith wants to merge 1 commit into
tracel-ai:mainfrom
lilith:pr/pinned-upload-rebased-2026-05-17
Draft

feat(runtime): pinned-host-buffer fast path for create_from_slice uploads#1334
lilith wants to merge 1 commit into
tracel-ai:mainfrom
lilith:pr/pinned-upload-rebased-2026-05-17

Conversation

@lilith

@lilith lilith commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Extend the staging-buffer machinery added in #1030 (Feat/pinned mem) so the
slice-input create_* paths also get the pinned-DMA fast path on CUDA, and
add two convenience helpers that let callers skip the intermediate pageable
host buffer entirely.

Motivation

On CUDA, cuMemcpyHtoDAsync from a pageable host buffer caps at ~5-6 GB/s
because the driver internally stages through a hidden pinned bounce buffer.
Allocating the host buffer with cuMemAllocHost_v2 (which is what
ComputeServer::staging does) lets the driver DMA directly, getting
~12-25 GB/s on PCIe 4.0 — i.e. a 2-4x bandwidth jump for free, just by
swapping the host-side allocation.

#1030 introduced the ComputeServer::staging API and the AllocationProperty
tagging on Bytes, and routed create / create_tensor through it. That
left the slice-input variants (create_from_slice / create_tensor_from_slice
/ create_tensors_from_slices) still building pageable Vec<u8> buffers and
never calling staging, so their HtoD copies kept hitting the slow path.
Downstream image/codec pipelines that upload sRGB/u8 buffers typically reach
for the slice variants because the input is already a &[u8] — losing the
pinned fast path silently.

A microbench upload of a 48 MB buffer to a desktop CUDA device went from
~9 ms (pageable) to ~3 ms (pinned) on my workstation. The full
upload_bench example is included.

What this adds

  • ComputeClient::reserve_staging(&[usize]) -> Vec<Bytes> reserves pinned
    host buffers of the requested sizes. Falls back to plain heap allocations
    if the backend's staging call returns an error (e.g. pinned memory
    exhausted), so callers always get buffers of the requested size.
  • ComputeClient::create_from_slice_pinned(&[u8]) -> Handle copies the
    input directly into a pinned staging buffer before uploading. Saves the
    caller &[u8] → pageable Vec<u8> → pinned Bytes host memcpy that
    create_from_slice performs today, halving host-side memory traffic for
    large uploads.
  • ComputeClient::create_tensors_from_slices_pinned is the batch variant
    of the above.
  • An examples/upload_bench crate measuring pageable vs pinned upload
    bandwidth across a few buffer sizes (4 MB / 48 MB / 192 MB).

What this changes implicitly (no public API change)

  • do_create_from_slices (the implementation behind create_from_slice /
    create_tensor_from_slice / create_tensors_from_slices) now wraps
    slices in Bytes and calls self.staging(..., true) before submitting,
    so the default upload path benefits from pinned DMA on CUDA without
    callers having to opt in. The slice-input variants still do one host
    memcpy (caller → pinned); the new *_pinned helpers do zero intermediate
    copies.
  • do_create no longer rewraps the already-staged Bytes via
    Bytes::from_bytes_vec(data.to_vec()) before handing them to the server.
    That rewrap allocated a fresh pageable Vec<u8>, demoting the buffer
    back to AllocationProperty::Native and re-triggering the slow CUDA
    pageable bounce on the subsequent HtoD copy. Forwarding the Bytes
    as-is preserves the pinned property end-to-end.

Backward compatibility

  • No public signatures change. All new symbols are additions.
  • Backends without a pinned-memory concept (or that return an error from
    staging) behave exactly as today.
  • The new *_pinned helpers fall back to plain vec![0u8; n] allocations
    on staging error, so callers do not need to branch on backend support.

Test plan

  • Reviewer eyes on the do_create_from_slices change — wrapping each
    input slice in a Bytes adds one allocation per upload on backends
    that decline to stage, but that allocation is the same one
    do_create_from_slices was already doing on its existing slow path
    (via Bytes::from_bytes_vec(data.to_vec())). Net per-upload alloc
    count is unchanged.
  • cargo test -p cubecl-runtime --lib passes locally (67/67).
  • cargo clippy -p cubecl-runtime -p upload_bench --features cuda --no-deps clean.
  • cargo fmt --check clean.
  • Reviewer eyes / CI on whether the examples/upload_bench/ layout
    matches existing example conventions (followed examples/gelu/).

… uploads

CUDA's cuMemcpyHtoDAsync from pageable host memory caps at ~5-6 GB/s
because the driver internally stages through a hidden pinned bounce
buffer. Allocating the host buffer with cuMemAllocHost_v2 lets the
driver DMA directly, getting ~12-25 GB/s on PCIe 4.0. PR tracel-ai#1030
introduced the underlying `ComputeServer::staging` + `Bytes` swap
machinery for the explicit `create` / `create_tensor` paths; this
change extends the same fast path to the slice-input variants and adds
explicit reserve-then-fill helpers for callers that want to skip the
intermediate pageable `Vec<u8>`.

What this adds:

- `ComputeClient::reserve_staging(&[usize]) -> Vec<Bytes>` reserves
  pinned host buffers of the requested sizes. Falls back to plain heap
  allocations if the backend returns an error (e.g. pinned memory
  exhausted), so callers always receive buffers of the requested size.
- `ComputeClient::create_from_slice_pinned(&[u8]) -> Handle` copies the
  input directly into a pinned staging buffer before uploading. Saves
  the caller `&[u8]` -> pageable `Vec<u8>` -> pinned `Bytes` host
  memcpy that `create_from_slice` performs today, halving host-side
  memory traffic for large uploads.
- `ComputeClient::create_tensors_from_slices_pinned` is the batch
  variant of the above.
- An `upload_bench` example under `examples/` measuring pageable vs
  pinned upload bandwidth across a few buffer sizes.

What this changes implicitly (no API change):

- `do_create_from_slices` (the implementation behind
  `create_from_slice` / `create_tensor_from_slice` /
  `create_tensors_from_slices`) now wraps slices in `Bytes` and calls
  `self.staging(..., true)` before submitting, so the default upload
  path also benefits from pinned DMA on CUDA without callers having to
  opt in. The slice-input variants still do one host memcpy
  (caller -> pinned), where the new `*_pinned` helpers do zero
  intermediate copies.
- `do_create` no longer rewraps the already-staged `Bytes` via
  `Bytes::from_bytes_vec(data.to_vec())` before handing them to the
  server. That rewrap allocated a fresh pageable `Vec<u8>`, demoting
  the buffer back to `AllocationProperty::Native` and re-triggering the
  slow CUDA pageable bounce on the subsequent HtoD copy. Forwarding the
  `Bytes` as-is preserves the pinned property end-to-end.

Backward compatibility:

- No public signatures change. All new symbols (`reserve_staging`,
  `create_from_slice_pinned`, `create_tensors_from_slices_pinned`) are
  additions.
- Backends without a pinned-memory concept ignore `staging` and behave
  exactly as today.
- The new helpers fall back to plain `vec![0u8; n]` allocations when the
  backend returns an error from `staging`, so callers do not need to
  branch on backend support.

Relationship to merged PR tracel-ai#1030 ("Feat/pinned mem"):

PR tracel-ai#1030 added `ComputeServer::staging` and the `Bytes` allocation-
property tracking that makes this possible. This change is purely a
client-side extension that pulls the slice-input `create_*` paths
through that same machinery and exposes two convenience helpers that
let callers skip the intermediate pageable host buffer entirely.
lilith added a commit to imazen/zenmetrics that referenced this pull request May 17, 2026
…ad-code cleanup

## Cubecl patch — v0.10.0 stable

Repoint the [patch.crates-io] block from lilith/cubecl commit
`08d34ac0` (rebased on 0.10.0-pre.4) to `de2f9857` (rebased on
the upstream **v0.10.0 release tag**, branch `pr/pinned-v0.10.0`).
Functionally identical to the prior patch — same convenience wrappers
(`create_from_slice_pinned`, `reserve_staging`) and same default-
`create_from_slice` routing through pinned staging — just on the
official released base instead of the pre-release.

The actual upstream PR for the convenience wrappers is
tracel-ai/cubecl#1334 (draft, against
post-v0.10.0 main which is now 0.11.0-pre.x post-mega-refactor).
PR #1030 had landed the base `staging()` API for v0.10.0 already.
Drop these patches once the wrappers land upstream and we migrate
to the cubecl release after v0.10.0.

## ssim2 dead-code cleanup

Remove the unused `blur_pass_t_kernel` (~95 lines) in
`crates/ssim2-gpu/src/kernels/blur.rs`. This was a fused
column-walk + transpose experiment shipped under commit `990e120`
(T_x.B v2) that turned out to regress: uncoalesced strided writes
costed more bandwidth than the saved transpose. The actual win
shipped under that commit was the LDS-tiled 32×32 transpose in
`transpose.rs`; the fused kernel was left in source as dead code
that LLVM dropped. Removing it now to keep the surface clean.

## Verification

- All 320 GPU-crate tests pass with the v0.10.0 patch (lib+tests,
  doctest cubecl::wgpu failures excepted as pre-existing).
- cvvdp-gpu 12 MP warm-ref bench: ~16.7 ms steady-state median
  (well within the ~22 ms historical band — GPU thermal state matters
  at this resolution). JOD 5.5640 bit-identical to prior runs.

@nathanielsimard nathanielsimard left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw create from slices is mostly used for testing, the new reserve staging doesn't seem to be implemented at the right level. submit_blocking is expensive and shouldn't be used for data transfer, only for data fetching.

@nulltea

nulltea commented Jun 4, 2026

Copy link
Copy Markdown

Independent +1 on the core change here. I hit the same thing from the other direction (a host-bound upload path) and arrived at the identical fix: in do_create/do_create_from_slices, Bytes::from_bytes_vec(data.to_vec()) deep-copies the whole upload buffer into a fresh allocation every call even though data is already owned.

Worth emphasizing that the data.to_vec() removal stands on its own, separate from the pinned-staging fast path: the copy itself is pure overhead (per-upload full-buffer memcpy + a fresh allocation that faults in and is freed right after, re-faulting next call). For large/frequent uploads that allocation churn tends to dominate even over the memcpy, and dropping it turns the upload back into a non-blocking async issue. The pinned fast path then improves the raw transfer on top of that.

It's still on main today, so it'd be great to land it. The PR looks like it's been sitting as a draft for a few weeks — is there anything blocking (the burn-validation step, or a rebase onto current main)? Happy to help with either if useful.

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.

3 participants