Skip to content

fix(cuda-macros): load kernels through a vector so cleanup stays linear - #1588

Open
sachinsharma3191 wants to merge 1 commit into
NVIDIA:mainfrom
sachinsharma3191:fix/1501-module-loader-quadratic
Open

sachinsharma3191 wants to merge 1 commit into
NVIDIA:mainfrom
sachinsharma3191:fix/1501-module-loader-quadratic

Conversation

@sachinsharma3191

Copy link
Copy Markdown
Contributor

Closes #1501.

Why it is superquadratic

A module's loaders build LoadedModule in one struct literal whose field
initializers each end in ?:

Ok(LoadedModule {
    __module: module.clone(),
    __generic_functions: Arc::new(Mutex::new(HashMap::new())),
    __first_function: module.load_function(<__first_CudaKernel as CudaKernel>::PTX_NAME)?,
    __second_function: module.load_function(<__second_CudaKernel as CudaKernel>::PTX_NAME)?,
    //  ... one per kernel
})

Every ? there sits inside a half-built struct. The fields evaluated before
it are live, and each owns an Arc — CudaFunction holds one to keep the
module loaded — so the i-th early return has to drop i values. Over a
module's kernels that is 1+2+...+n drop calls: O(n²) cleanup generated from
O(n) expansion, which matches the report's observation that expansion stayed
linear at about 106 lines per kernel while LLVM time did not.

The change

Load into a vector first, then take the functions back out in order:

let mut __cuda_oxide_functions: Vec<CudaFunction> = Vec::with_capacity(2usize);
__cuda_oxide_functions.push(module.load_function(<__first_CudaKernel as CudaKernel>::PTX_NAME)?);
__cuda_oxide_functions.push(module.load_function(<__second_CudaKernel as CudaKernel>::PTX_NAME)?);
let mut __cuda_oxide_functions = __cuda_oxide_functions.into_iter();
Ok(LoadedModule {
    __module: module.clone(),
    __generic_functions: Arc::new(Mutex::new(HashMap::new())),
    __first_function: __cuda_oxide_functions.next().expect(/* ... */),
    __second_function: __cuda_oxide_functions.next().expect(/* ... */),
})

Exactly one droppable value is live at each ?, so the cleanup is linear.

A push per kernel, rather than a loop over a name table, is what preserves
per-kernel cfg: statements take attributes, const array elements do not.
Taking the functions back out is infallible by construction — both halves are
emitted from one iterator, in one order, under the same cfgs — so the
expect is unreachable rather than a new failure mode.

All three loaders share the new helper: both from_module variants
(contracted and uncontracted) and the nested namespace's from_parent, which
had the identical shape. Fixing only from_module would have left nested
modules quadratic.

No API change: field names, types, load order and error type are unchanged.

Measurements

I could not build the reporter's crate (no CUDA toolkit on this machine), so I
reproduced the shape instead: a generated crate with a droppable
CudaFunction equivalent, N fields, and the two loader shapes, compiled with
rustc -O at the kernel counts from the report.

kernels field initializers vector
50 0.92s 0.33s
100 5.98s 0.54s
200 95.05s 1.27s

Between 100 and 200 kernels the old shape grows at an exponent near 4 and the
new one near 1.2; at 200 kernels it is 75x faster. The 95s for the old shape
lands within 3% of the 98s reported for a real 200-kernel crate, which is
what makes me reasonably confident the loader is the dominant term rather
than one contributor among several.

@navneet83, if you still have the generator, a re-measure of the real
200-kernel crate against this branch would confirm it end to end — in
particular whether the LLVM_thinlto share you measured drops with it. The N
launch methods are untouched here, so if a superlinear term remains it should
show up in that split.

Testing

  • New unit test kernels_load_into_a_vector_instead_of_the_struct_literal
    expands a module with two kernels and a nested namespace, and asserts every
    load is a push on one vector, that no kernel loads directly into a struct
    field, that both loaders use a vector, and that each kernel takes its
    function back out. The second assertion is the regression: it fails on the
    previous shape.
  • The rest of cuda-macros' unit tests pass.
  • cargo clippy -p cuda-macros --lib -- -D warnings and cargo oxide fmt --check are clean.
  • Not run locally: the trybuild suites under crates/cuda-macros/tests/, and
    anything else needing a CUDA toolkit. cuda-macros' dev-dependencies reach
    cuda-bindings, whose build script needs cuda.h, so those run in CI here.

@copy-pr-bot

copy-pr-bot Bot commented Oct 21, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@sachinsharma3191

Copy link
Copy Markdown
Contributor Author

Force-pushed a corrected version of this, and a correction to what the
description claimed.

The first claim was too strong

The original push said the cleanup becomes linear. It does not, in the shape I
first pushed. Loading into a Vec and then taking the functions back out with
next().expect(...) fixes the error paths, but each expect can panic, and a
panic unwinds through a partially built struct — so the unwind cleanup is still
quadratic, with a smaller constant. Measuring further out shows it plainly
(rustc -O, generated crate, seconds):

kernels vector + iterator
200 1.19
400 3.46
800 23.47

Exponent 1.2, then 1.5, then 2.8. Better than before, still the wrong shape.

What it does now

Load into a vector, convert that to a fixed-size array, then bind every
function with one irrefutable pattern:

let mut __cuda_oxide_functions: Vec<CudaFunction> = Vec::with_capacity(2usize);
__cuda_oxide_functions.push(module.load_function(/* ... */)?);
__cuda_oxide_functions.push(module.load_function(/* ... */)?);
let __cuda_oxide_loaded: [CudaFunction; 2usize] = __cuda_oxide_functions
    .try_into()
    .expect("every declared kernel was loaded above");
let [__cuda_oxide_loaded__first_function, __cuda_oxide_loaded__second_function] =
    __cuda_oxide_loaded;
Ok(LoadedModule { /* fields take the locals */ })

The conversion is the only fallible step and is unreachable by construction
(the pushes and the length come from one iterator). After it nothing can
unwind, so the moves that fill the struct emit no cleanup edges at all.

A cfg'd kernel leaves the loaded count unknown until the compiler evaluates
the cfg, and a pattern cannot carry #[cfg] per element, so a module with
any conditional kernel keeps the iterator form — one push and one take per
kernel, each under its own cfg, which statements can carry. That path still
has the quadratic panic tail; it is the fallback, not the common case.

Measurements

rustc -O on a generated crate that mimics the expansion, with a droppable
CudaFunction equivalent. Dashes are sizes the original shape is too slow to
measure here.

kernels field initializers iterator array
50 0.92s 0.35s 0.40s
100 7.71s 0.55s 0.51s
200 93.58s 2.51s 0.84s
400 — 3.46s 1.81s
800 — 23.47s 5.36s

Wall clock on a laptop is noisy — the iterator's 200-kernel cell measured
1.19s in a quiet run and 2.51s immediately after a 90s compile — so the
generated IR is the better evidence, being machine-independent:

kernels field initializers iterator array
50 26,292 4,528 3,350
100 99,417 8,195 6,067
200 388,167 14,895 10,867

Per doubling: 3.78x then 3.90x for the original (4x is quadratic), 1.81x then
1.79x for the array (linear). At the 200 kernels this issue reported, that is
388,167 IR lines down to 10,867.

Still not verified end to end

I have no CUDA toolkit on this machine, so I could not build a real
200-kernel crate, and cuda-macros' dev-dependencies reach cuda-bindings
and its cuda.h. What ran locally: the crate's unit tests including two new
ones (array path, and the cfg fallback), clippy, and cargo oxide fmt --check. The trybuild suites and everything toolkit-dependent run in CI.

@navneet83 — if you still have the generator, a measurement of the real
200-kernel crate against this branch would close the loop, particularly
whether the LLVM_thinlto share you saw drops with it. The N launch methods
are untouched here, so a remaining superlinear term should show up in that
split.

@sachinsharma3191
sachinsharma3191 force-pushed the fix/1501-module-loader-quadratic branch from e488b4b to 916d909 Compare October 21, 2026 06:24
A module's loaders built `LoadedModule` in one struct literal whose field
initializers each ended in `?`. Every one of those sits inside a half-built
struct: the fields evaluated before it are live and each owns an `Arc`
(`CudaFunction` holds one), so the i-th early return has to drop i values.
Summed over a module's kernels that is O(n^2) cleanup from O(n) expansion,
and host build time followed -- 200 kernels in one `#[cuda_module]` took 98s
against 8s for 100, with nearly all of it inside LLVM.

Load into a vector, convert that to a fixed-size array, then bind every
function with one irrefutable pattern. The conversion is the only fallible
step, and nothing after it can unwind, so the moves that fill the struct emit
no cleanup edges at all.

A `cfg`'d kernel leaves the count unknown until the compiler evaluates it,
and a pattern cannot carry `#[cfg]` per element, so such a module keeps an
iterator: one push and one take per kernel, each under its own `cfg`, which
statements can carry. That tail is still quadratic on the panic path, but
with a far smaller constant than the original.

All three loaders share the helper: both `from_module` variants and the
nested namespace's `from_parent`, which had the identical shape.

Measured with a generated crate that mimics the expansion, compiled with
rustc -O (the dash marks sizes the old shape is too slow to measure here):

  kernels   field initializers   iterator   array
       50                0.92s      0.35s   0.40s
      100                7.71s      0.55s   0.51s
      200               93.58s      2.51s   0.84s
      400                    -      3.46s   1.81s
      800                    -     23.47s   5.36s

Generated LLVM IR lines, which confirm the shape independently of the
machine:

  kernels   field initializers   iterator   array
       50              26,292       4,528   3,350
      100              99,417       8,195   6,067
      200             388,167      14,895  10,867

Per doubling the old form grows 3.78x then 3.90x, the array 1.81x then
1.79x. At the 200 kernels the report measured, that is 93.58s -> 0.84s.

Closes NVIDIA#1501.

Signed-off-by: sachin <[email protected]>
@sachinsharma3191
sachinsharma3191 force-pushed the fix/1501-module-loader-quadratic branch from 916d909 to 90e2ee2 Compare October 21, 2026 06:34

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.

Host build time grows superquadratically with kernels per #[cuda_module]

1 participant