Repository navigation
cuda-core: define boolean storage and element padding/packing policy #1503
Description
Define a consistent storage and conversion policy for boolean, packed, and sub-byte element types. Logical dtype width, host storage layout, and Rust value validity impose different constraints on copies, casts, and readback.
Requirements
- Document boolean storage as one byte per element holding
0x00or0x01, the Rustboollayout. There is no separate byte-backed boolean storage type:boolimplementsDeviceCopy, and the safe reinterpreting casts exclude it through theAnyBitPatternbound (Safe element casts can produce invalidboolvalues (Tensor::reinterpret,DeviceBuffer::cast_elem/cast_chunks) #1506). - Specify conversions and validation between raw bytes, device boolean storage, and Rust
bool. Safe APIs must not materialize invalid Rust boolean values. - Distinguish logical dtype width from host storage size/alignment. Specify element counts, byte extents, and explicit pack/unpack boundaries for packed and sub-byte types.
- Define how padding, alignment, and initialized bytes affect copies, casts, and readback, including which guarantees belong to dtype metadata and which to the raw-buffer contract.
- Apply the policy consistently to owned, borrowed, and foreign storage and document foreign-memory validity requirements.
- Mark
DTypeId#[non_exhaustive]so storage types can be added without breaking downstream matches.
Acceptance
Document the representation/cast policy and add coverage for invalid boolean encodings and supported packed layouts. Coordinate cast coverage with #1401.
The shared-runtime migration in #1460/#1461 implements DeviceCopy for bool and keeps bool: DType; the cast rule is #1506, and no new boolean representation is planned. The sub-byte, packed, and padded storage questions (shape semantics for packed types, nibble order, odd extents, DeviceCopy aggregate layout, DTypeId::size_in_bytes versus logical width) were filed as #1513 and are consolidated here; the survey and proposed direction are in the first comment.
Host-interop merge scope (2026-10-30)
#1516 addresses the unsafe bool reinterpretation hole tracked in #1506 and now also requires NoUninit on safe buffer-cast sources, preventing uninitialized elements or padding from becoming initialized destination values. Negative compile tests cover bool targets and uninitialized/padded sources; bool-to-byte casts remain supported. #1603/#1604 carry these contracts into the shared host runtime.
Keep this issue open after those PRs merge: the complete packed/sub-byte layout policy and its coverage, logical width versus storage-size metadata, and DTypeId extensibility are not finished by the host migration. The existing checklist and #1401 remain the follow-up scope; merging the safety fix does not claim those broader requirements are complete.
Activity
elibol commentedon Oct 13, 2026
Consolidating #1513 into this issue. Its body follows, with the proposed direction at the end.
The host crates model every device buffer as
[T]: a count of elements, eachsize_of::<T>()bytes, one element per address step. That holds for byte-and-wider scalars and forbool, which is one byte. It does not hold for sub-byte element types, and it leaves the layout rules for aggregate element types implicit. Both gaps surface as soon as we exchange tensors with other frameworks, so this issue is about writing the storage model down and fixing the few places where the code is ambiguous.What the tree does today
i4andf4E2M1FNexist in kernels (cutile::ElementType) but are notDTypes, because one element is not one byte. Host storage for FP4 isf4e2m1fnx2incuda-core/src/dtype.rs, a byte holding two values with the first value in the low nibble, and kernels move between the two withpackandunpack.DTypeId::F4E2M1FNX2.size_in_bytes()is1, which is the size of the storage element, not of a logical FP4 value. ATensor<f4e2m1fnx2>therefore has its shape in byte pairs, and an odd logical extent cannot be expressed.0x00or0x01. Tile IR specifies this for pointer-tile loads and stores (operations,cuda_tile.load_ptr_tko9.6.3 andcuda_tile.store_ptr_tko9.6.6), and it matches the Rustboollayout.#[derive(DeviceCopy)]incuda-core-derivechecks that every field isDeviceCopyand nothing else. It acceptsrepr(Rust)structs and structs with padding. This is sound for the SIMT path because the same rustc lays out both sides, and host-to-device copies hand a raw pointer and a byte count to the driver without ever forming a&[u8]over the padding. It is not a layout other compilers or frameworks can rely on.What other systems do
(code, bits, lanes)and keeps shape and strides in elements. Sub-byte types are "packed in memory" by default, little bit-endian:(D >> (i * bits)) & maskis the i-th element. A producer can setDLPACK_FLAG_BITMASK_IS_SUBBYTE_TYPE_PADDEDto say each value instead occupies a whole byte. Booleans arebits = 8"as per common array library convention". There are codes forfloat4_e2m1fn, the FP6 formats, and the FP8 formats includinge8m0fnu.torch.float4_e2m1fn_x2, which "represents two 4-bit values packed into a byte"; shape and stride operations "operate at the byte boundary and will not unpack/repack sub-byte values". That is the same choice as ourf4e2m1fnx2: the storage pair is the element, and the shape is in pairs.int4,uint4, andfloat4_e2m1fnare stored unpacked, "padded up to a byte in memory", with the value in the low bits. That is DLPack's padded flag.So there are three conventions for the same FP4 data (packed with the shape in logical elements, packed with the shape in byte pairs, one per byte) and two for booleans (one per byte, bit-packed), and nothing in our API says which ones we speak.
What needs deciding
f4e2m1fnx2::from_nibbles(low, high)puts the first value in the low nibble, which is DLPack's little bit-endian order. That should be stated as a guarantee rather than left as an implementation detail.DeviceCopyaggregates. Decide whetherDeviceCopykeeps tolerating padding, and whether the derive should requirerepr(C)orrepr(transparent). bytemuck'sPodforbids padding; itsAnyBitPattern, which Safe element casts can produce invalidboolvalues (Tensor::reinterpret,DeviceBuffer::cast_elem/cast_chunks) #1506 adopts for the safe casts, tolerates it, so casting into a padded struct stays allowed under either answer. The question is what layout we promise to anything that is not rustc.DTypeId::size_in_bytes. It currently means the storage element size. It should say so, and interop needs the DLPackbitsvalue next to it.Proposed direction
DTypes, as PyTorch does.f4e2m1fnx2stays the FP4 host type, its shape stays in pairs, and logical sub-byte types stay kernel-only. Document this indtype.rsand the book, including the low-nibble-first order.bits = 4and the default packing becomesf4e2m1fnx2with the last dimension halved, odd extents and the padded flag are rejected with a clear error until there is a type for them,boolisbits = 8, the FP8 ande8m0fnutypes map one to one, and Arrow booleans must be unpacked by the caller.#[derive(DeviceCopy)]requirerepr(C)orrepr(transparent), keep tolerating padding, and document the copy rule: padding bytes are moved as raw bytes and never read as values. Point at Safe element casts can produce invalidboolvalues (Tensor::reinterpret,DeviceBuffer::cast_elem/cast_chunks) #1506 for how this interacts with safe casts.DTypeId::bits()alongsidesize_in_bytes()so the DLPack dtype can be produced without a side table, and a note on eachDTypeIdvariant stating whether it is a logical type or a packed storage type.cuda-core: safe element casts require AnyBitPattern; bool is DeviceCo…