Skip to content

feat(cuda-device): add safe DisjointSlice::empty - #1722

Open
praveenperera wants to merge 1 commit into
NVIDIA:mainfrom
praveenperera:feat/disjoint-slice-empty-1693
Open

praveenperera wants to merge 1 commit into
NVIDIA:mainfrom
praveenperera:feat/disjoint-slice-empty-1693

Conversation

@praveenperera

@praveenperera praveenperera commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A device helper with an optional output takes a DisjointSlice<T> and skips writes when it is empty. Every DisjointSlice constructor is unsafe, so a caller that does not want the output wrote an unsafe block, with a SAFETY comment, for a value that cannot be misused.

Change

  • DisjointSlice::empty(), a const fn returning a slice with NonNull::dangling() and length 0, and Default for every index space. A zero-length slice covers no memory, and every safe accessor in every space checks the length before it resolves an element, so each returns None. The doc comment walks through each accessor.
  • The runtime-width spaces (Runtime2DIndex, RuntimeRowMajorTiles) are covered too: their empty slice carries a row width of 0, which get_mut, tile_2d32_rt and thread::index_2d_runtime already reject. SpaceLayout gains a sealed EMPTY constant for that layout value and documents the length-check rule a new space must keep.
  • mir-importer: rustc folds empty() into a constant allocation, which import could not decode. A DisjointSlice constant is now rebuilt field by field, at rustc's layout offsets, into the same construct_disjoint_slice op a slice literal produces. It goes through the existing allocation decoder first, which rejects pointer provenance and zeroes padding. This covers a top-level constant, a slice nested in a constant aggregate, and an array of slices.
  • A shared reference to a constant holding a slice (const E: &DisjointSlice<f32> = &DisjointSlice::empty()) is refused with a diagnostic. Import materializes a promoted reference's pointee in the current function, so a helper returning it as &'static would hand back a dead local. Every accessor takes &mut self, so such a reference has little use, and &mut DisjointSlice::empty() and by-value passing both work. Raw pointer constants without provenance, such as a null *mut DisjointSlice, still compile.

Tests

  • Host tests in cuda-device check that every accessor (get_mut, element_thread32, tile_thread32, thread_run32, grid_stride_runs32, tile_2d32, tile_2d32_rt) returns None on an empty slice where the same witness resolves on a non-empty one, and that the pointer is non-null and aligned.
  • disjoint_from_raw_parts gains skip_partial, which passes empty() and default() by value and by &mut. It also gains skip_partial_consts, which passes named constants: a flat slice, a runtime-width slice whose image ends in padding, a tuple holding a slice beside a scalar, and an array of two slices. That kernel also reads null and dangling raw pointers to a slice back from #[inline(never)] helpers. Without the importer change the example fails to compile (Unsupported constant type).
  • New expected-failure fixture error_disjoint_slice_const_ref pins the reference diagnostic, and it compiles (and so fails the smoketest) without the refusal.

Validation

  • cargo test -p cuda-device -p mir-importer, clippy with -D warnings for both crates and both examples, cargo oxide fmt --check
  • check-spdx-headers.sh, sync-example-locks.sh --check, check-book-catalog-stamp.sh, check-error-example-status.sh, check-example-smoketest-contract.sh, just book
  • smoketest.sh --compile-only for both examples
  • cargo oxide run disjoint_from_raw_parts on an RTX 5090: all four kernels match exactly

Closes #1693

@copy-pr-bot

copy-pr-bot Bot commented Nov 3, 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.

A device helper with an optional output takes a DisjointSlice and skips
writes when it is empty, but every constructor is unsafe, so callers
that do not want the output needed an unsafe block for a value that
cannot be misused.

Add a const DisjointSlice::empty() and a Default impl for every index
space. A zero-length slice covers no memory, and every safe accessor
in every space checks the slice length before resolving an element, so
each one returns None. The pointer is NonNull::dangling(), and spaces
with a runtime row width carry a width of zero, which their accessors
also reject. SpaceLayout gains a sealed EMPTY constant for that layout
value and documents the length-check rule new spaces must keep.

rustc folds empty() into a constant allocation, which mir-importer had
no decoder for. Decode a slice constant field by field at its layout
offsets into the same construct_disjoint_slice op a slice literal
produces. A top-level slice constant, one nested in a constant
aggregate, and an array of slices all go through the existing
allocation decoder first, which rejects pointer provenance and zeroes
padding.

A shared reference to such a constant would be materialized in the
current function and its address taken, so a helper returning it as
&'static would hand back a dead local. Import refuses a reference to a
constant holding a slice instead; error_disjoint_slice_const_ref pins
that diagnostic. A raw pointer constant without provenance, such as a
null one, names no storage and still compiles.

The disjoint_from_raw_parts example now passes empty() and default()
to helpers, by value, by reference and as named constants, and host
tests check that every accessor returns None on an empty slice where it
resolves the same witness on a non-empty one.

Closes NVIDIA#1693

Signed-off-by: Praveen Perera <me@praveenperera.com>
@praveenperera
praveenperera force-pushed the feat/disjoint-slice-empty-1693 branch from d5e521f to 8e4b2e8 Compare November 3, 2026 17:01
praveenperera added a commit to praveenperera/cuda-rust that referenced this pull request Nov 3, 2026
Bring DisjointSlice::empty to the reviewed version of upstream
NVIDIA#1722.

Every index space gets a Default impl, and SpaceLayout gains a sealed
EMPTY constant and documents the length-check rule new spaces keep.
mir-importer decodes a slice constant field by field into the same
construct op a slice literal produces, including slices nested in
constant aggregates and arrays. A reference to a constant that holds a
slice is now refused, since it would point at a dead local;
error_disjoint_slice_const_ref pins that diagnostic. The
disjoint_from_raw_parts example passes empty() and default() by value,
by reference and as named constants.

Signed-off-by: Praveen Perera <me@praveenperera.com>
praveenperera added a commit to praveenperera/cuda-rust that referenced this pull request Nov 3, 2026
A reference constant such as `const PAIR: &(u32, u32) = &(3, 4)`, or a
borrow rustc promotes to 'static such as `&42`, was materialized as an
alloca, a store, and the slot's address in whichever function named it.
The reference is 'static, so safe code can return it: an
stack frame, and LLVM deleted the store. Raw pointers formed from such
references (`&42 as *const u32 as *mut u32`) had the same problem.

Each anonymous rustc allocation a reference constant points at now
becomes one module-level read-only device global holding rustc's byte
image, keyed by its AllocId so every reference into it, including
interior pointers such as `&PAIR.1`, shares one global. Pointers stored
inside the allocation become relocations to the inner allocation's own
global or to the device static. The same globals back thin pointer
fields of aggregate and enum constants and pointer words in device
static initializers, which previously rejected anonymous memory. A
`*mut T` into that read-only storage is established as `*const T` and
then cast raw-to-raw, as the source's `as` cast does; the storage stays
read-only.

A pointee whose device layout the importer cannot prove byte-identical
to rustc's, such as `&Option<u32>`, declares the same global typed as
the pointee. Lowering now accepts several types for one immutable
initialized global, validates each against the shared bytes, and keeps
one symbol, so `&Option<u32>` and `&u32` into its payload still compare
equal. An interior view is rejected when its pointee's layout cannot
be proven.

A pointer into shared memory cannot be stored in a constant or static.
Every stored pointer targets an initialized global in the global or
constant address space, while a pointer to SharedArray or Barrier is
an addrspace(3) pointer. A constant, constant table, or static
initializer that stores a relocation and whose type holds such a
pointer, through fields, closure captures, or pointees, is refused, as
is a type-erased stored pointer to a SharedArray or Barrier static,
which has no initialized global.

The promoted_ref_return example returns each form from an
device, and verifies that no helper keeps a stack slot or a .local
depot. error_shared_pointer_constant and error_shared_static_relocation
pin the two shared-memory refusals.

Closes NVIDIA#1724

From upstream NVIDIA#1726, which is based on a main without
NVIDIA#1722. Merged here, a reference to a constant that holds a DisjointSlice
is still refused before the new global path, so
error_disjoint_slice_const_ref keeps its diagnostic.

Signed-off-by: Praveen Perera <me@praveenperera.com>
praveenperera added a commit to praveenperera/cuda-rust that referenced this pull request Nov 4, 2026
Static shared allocations were named `__shared_mem_N` from a counter in
MIR walk order, and ordinary device globals `__device_global_N` the same
way. Walk order follows rustc's mono-item order, so adding a kernel
renumbered the other kernels' globals and changed their PTX although
their code had not changed. Release builds also keyed shared statics by
the constant's Debug spelling, which carries walk-order dependent
interner ids, while full-debug builds already used the mangled symbol.

Key every shared static by its mangled Rust symbol in all build
profiles, and name keyed shared allocations and device globals
`__shared_mem_<crate>_<hash>` and `__device_global_<crate>_<hash>`,
where the hash is a 64-bit FNV-1a of that key. The crate component stays
the StableCrateId hash, since a static from a shared dependency hashes
the same in every crate (NVIDIA#1277). References to one static still share
one global, and a hash collision between two keys fails lowering rather
than aliasing them. Keyless shared allocations keep the module counter.

SharedArray is zero-sized, so a constant pointer to such a field of a
tuple, struct or array static can have the same byte offset as its
siblings, as union fields and enum variants do whatever their size.
Find the field from the static's layout and the pointee type and add
its path to the key, so each field gets its own stable global, and
`*const` and `*mut` pointers to one field share it, as a static's
single address requires. Two fields of one type at one offset cannot be
told apart and would alias, so reject them with a compile error asking
for one static per array.

Add the shared_static_names example and a compile-only check that
builds it with extra kernels spliced in, in release and full-debug
builds, and requires the original kernels' PTX, field pointers
included, to stay the same. CI runs it in examples-compile. Add the
error_shared_static_same_fields and error_shared_static_union_field
fixtures for the rejected cases.

Fixes NVIDIA#1477

Stacked on NVIDIA#1722: the smoketest ERROR_EXAMPLES list keeps NVIDIA#1722's
error_disjoint_slice_const_ref and adds this change's
error_shared_static_same_fields and error_shared_static_union_field.

Signed-off-by: Praveen Perera <me@praveenperera.com>
praveenperera added a commit to praveenperera/cuda-rust that referenced this pull request Nov 4, 2026
A reference constant such as `const PAIR: &(u32, u32) = &(3, 4)`, or a
borrow rustc promotes to 'static such as `&42`, was materialized as an
alloca, a store, and the slot's address in whichever function named it.
The reference is 'static, so safe code can return it: an
#[inline(never)] device helper handed its caller a pointer into a dead
stack frame, and LLVM deleted the store. Raw pointers formed from such
references (`&42 as *const u32 as *mut u32`) had the same problem.

Each anonymous rustc allocation a reference constant points at now
becomes one module-level read-only device global holding rustc's byte
image. Every reference into it, including interior pointers such as
`&PAIR.1`, shares that global. The global is keyed by a SHA-256 digest
of its contents: alignment, bytes, and relocations resolved to their
targets' own keys. rustc's AllocId numbering follows interning order, so
keying by it renamed a global whenever unrelated constants were added.
Identical read-only allocations share one global, which Rust permits.
Pointers stored inside the allocation become relocations to the inner
allocation's own global or to the device static. The same globals back
thin pointer fields of aggregate and enum constants and pointer words in
device static initializers, which previously rejected anonymous memory.
A `*mut T` into that read-only storage is established as `*const T` and
then cast raw-to-raw, as the source's `as` cast does; the storage stays
read-only.

A pointee whose device layout the importer cannot prove byte-identical
to rustc's, such as `&Option<u32>`, declares the same global typed as
the pointee. Lowering now accepts several types for one immutable
initialized global, validates each against the shared bytes, and keeps
one symbol, so `&Option<u32>` and `&u32` into its payload still compare
equal. An interior view is rejected when its pointee's layout cannot
be proven.

A pointer into shared memory cannot be stored in a constant or static.
Every stored pointer targets an initialized global in the global or
constant address space, while a pointer to SharedArray or Barrier is
an addrspace(3) pointer. A constant, constant table, or static
initializer that stores a relocation and whose type holds such a
pointer, through fields, closure captures, or pointees, is refused, as
is a type-erased stored pointer to a SharedArray or Barrier static,
which has no initialized global.

The promoted_ref_return example returns each form from an
#[inline(never)] helper, checks the values and pointer identity on the
device, and verifies that no helper keeps a stack slot or a .local
depot. error_shared_pointer_constant and error_shared_static_relocation
pin the two shared-memory refusals. The example's check also splices in
kernels with their own constants and requires every helper's global keys
to stay the same. The compile-only smoketest now builds and runs the
shape check of an example in the libNVVM lane before emit-ltoir, so CI
runs it too.

Closes NVIDIA#1724

Stacked on NVIDIA#1722 and NVIDIA#1728. A shared reference to a constant that holds
a DisjointSlice is still refused before the new global path, so
error_disjoint_slice_const_ref keeps its diagnostic, and the operand
and statics imports keep what NVIDIA#1722 and NVIDIA#1728 use. The smoketest
ERROR_EXAMPLES list and the STATUS.md fixture table carry all three
changes' fixtures, and NVVM_VERIFY_EXAMPLES gains promoted_ref_return.

Signed-off-by: Praveen Perera <me@praveenperera.com>
praveenperera added a commit to praveenperera/cuda-rust that referenced this pull request Nov 5, 2026
Static shared allocations were named `__shared_mem_N` from a counter in
MIR walk order, and ordinary device globals `__device_global_N` the same
way. Walk order follows rustc's mono-item order, so adding a kernel
renumbered the other kernels' globals and changed their PTX although
their code had not changed. Release builds also keyed shared statics by
the constant's Debug spelling, which carries walk-order dependent
interner ids, while full-debug builds already used the mangled symbol.

Key every shared static by its mangled Rust symbol in all build
profiles, and name keyed shared allocations and device globals
`__shared_mem_<crate>_<hash>` and `__device_global_<crate>_<hash>`,
where the hash is a 64-bit FNV-1a of that key. The crate component stays
the StableCrateId hash, since a static from a shared dependency hashes
the same in every crate (NVIDIA#1277). References to one static still share
one global, and a hash collision between two keys fails lowering rather
than aliasing them. Keyless shared allocations keep the module counter.

SharedArray is zero-sized, so a constant pointer to such a field of a
tuple, struct or array static can have the same byte offset as its
siblings, as union fields and enum variants do whatever their size.
Find the field from the static's layout and the pointee type and add
its path to the key, so each field gets its own stable global, and
`*const` and `*mut` pointers to one field share it, as a static's
single address requires. Two fields of one type at one offset cannot be
told apart and would alias, so reject them with a compile error asking
for one static per array.

Add the shared_static_names example and a compile-only check that
builds it with extra kernels spliced in, in release and full-debug
builds, and requires the original kernels' PTX, field pointers
included, to stay the same. CI runs it in examples-compile. Add the
error_shared_static_same_fields and error_shared_static_union_field
fixtures for the rejected cases.

Fixes NVIDIA#1477

Stacked on NVIDIA#1722: the smoketest ERROR_EXAMPLES list keeps NVIDIA#1722's
error_disjoint_slice_const_ref and adds this change's
error_shared_static_same_fields and error_shared_static_union_field.

Signed-off-by: Praveen Perera <me@praveenperera.com>
praveenperera added a commit to praveenperera/cuda-rust that referenced this pull request Nov 5, 2026
A reference constant such as `const PAIR: &(u32, u32) = &(3, 4)`, or a
borrow rustc promotes to 'static such as `&42`, was materialized as an
alloca, a store, and the slot's address in whichever function named it.
The reference is 'static, so safe code can return it: an
#[inline(never)] device helper handed its caller a pointer into a dead
stack frame, and LLVM deleted the store. Raw pointers formed from such
references (`&42 as *const u32 as *mut u32`) had the same problem.

Each anonymous rustc allocation a reference constant points at now
becomes one module-level read-only device global holding rustc's byte
image. Every reference into it, including interior pointers such as
`&PAIR.1`, shares that global. The global is keyed by a SHA-256 digest
of its contents: alignment, bytes, and relocations resolved to their
targets' own keys. rustc's AllocId numbering follows interning order, so
keying by it renamed a global whenever unrelated constants were added.
Identical read-only allocations share one global, which Rust permits.
Pointers stored inside the allocation become relocations to the inner
allocation's own global or to the device static. The same globals back
thin pointer fields of aggregate and enum constants and pointer words in
device static initializers, which previously rejected anonymous memory.
A `*mut T` into that read-only storage is established as `*const T` and
then cast raw-to-raw, as the source's `as` cast does; the storage stays
read-only.

A pointee whose device layout the importer cannot prove byte-identical
to rustc's, such as `&Option<u32>`, declares the same global typed as
the pointee. Lowering now accepts several types for one immutable
initialized global, validates each against the shared bytes, and keeps
one symbol, so `&Option<u32>` and `&u32` into its payload still compare
equal. An interior view is rejected when its pointee's layout cannot
be proven.

A pointer into shared memory cannot be stored in a constant or static.
Every stored pointer targets an initialized global in the global or
constant address space, while a pointer to SharedArray or Barrier is
an addrspace(3) pointer. A constant, constant table, or static
initializer that stores a relocation and whose type holds such a
pointer, through fields, closure captures, or pointees, is refused, as
is a type-erased stored pointer to a SharedArray or Barrier static,
which has no initialized global.

The promoted_ref_return example returns each form from an
#[inline(never)] helper, checks the values and pointer identity on the
device, and verifies that no helper keeps a stack slot or a .local
depot. error_shared_pointer_constant and error_shared_static_relocation
pin the two shared-memory refusals. The example's check also splices in
kernels with their own constants and requires every helper's global keys
to stay the same. The compile-only smoketest now builds and runs the
shape check of an example in the libNVVM lane before emit-ltoir, so CI
runs it too.

Closes NVIDIA#1724

Stacked on NVIDIA#1722 and NVIDIA#1728. A shared reference to a constant that holds
a DisjointSlice is still refused before the new global path, so
error_disjoint_slice_const_ref keeps its diagnostic, and the operand
and statics imports keep what NVIDIA#1722 and NVIDIA#1728 use. The smoketest
ERROR_EXAMPLES list and the STATUS.md fixture table carry all three
changes' fixtures, and NVVM_VERIFY_EXAMPLES gains promoted_ref_return.

Signed-off-by: Praveen Perera <me@praveenperera.com>

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.

cuda-device: a safe DisjointSlice::empty()

1 participant