diff --git a/crates/onnx-genai-engine/src/native_decode/cuda.rs b/crates/onnx-genai-engine/src/native_decode/cuda.rs index b1a380738b..97bbf9d76e 100644 --- a/crates/onnx-genai-engine/src/native_decode/cuda.rs +++ b/crates/onnx-genai-engine/src/native_decode/cuda.rs @@ -1628,11 +1628,17 @@ impl DecodeCudaState { /// KV-only commit requests for the seq-major fixed path: commit the dense /// live-prefix ranges [`kv_commit::live_prefix_ranges`] computes for - /// [`KvCommitLayout::SeqMajor`] — a single contiguous run - /// `0..(committed_len × kv_heads × head_dim × elem)` per KV binding, because - /// seq-major bytes are token-contiguous. This is the *same* geometry unit - /// the driver-level residency measurement (`vmm_kv_layout_residency_gpu`) and - /// the `kv_commit.rs` unit tests exercise, so the live commit path and the + /// [`KvCommitLayout::SeqMajor`] — one contiguous run + /// `0..(committed_len × kv_heads × head_dim × elem)` **per sequence** (batch + /// axis 0), each `capacity × kv_heads × head_dim × elem` apart, because + /// seq-major bytes are token-contiguous within a sequence and the per- + /// sequence stride is the fixed full-context `capacity`. At `batch == 1` + /// (the only value any current caller constructs, stage 2b-impl-2, #750) this + /// is exactly one run and byte-identical to the previous single-sequence + /// form; the batch axis is outermost, so committing `batch` sequences never + /// relocates any sequence's bytes. This is the *same* geometry unit the + /// driver-level residency measurement (`vmm_kv_layout_residency_gpu`) and the + /// `kv_commit.rs` unit tests exercise, so the live commit path and the /// measured floor cannot drift apart. The mask is fully committed at /// construction and never grows here, so — unlike [`Self::vmm_growth_requests`] /// — no mask range is appended. Re-committing the already-mapped prefix is @@ -1648,14 +1654,16 @@ impl DecodeCudaState { let ranges = kv_commit::live_prefix_ranges( KvCommitLayout::SeqMajor, geometry, + self.batch, capacity, committed_len, ) .with_context(|| { format!( "seq-major VMM-backed CUDA KV '{}' dense-prefix commit ranges overflow \ - (capacity {capacity}, committed_len {committed_len})", - binding.input_name() + (capacity {capacity}, batch {}, committed_len {committed_len})", + binding.input_name(), + self.batch, ) })?; for range in ranges { @@ -1699,26 +1707,47 @@ impl DecodeCudaState { } /// Zero the newly committed dense token stripe `[old_committed, new_committed)` - /// on every KV binding. Not strictly required for correctness (the kernel - /// reads only `[0, total_lengths)`), but it keeps the padding suffix zeroed - /// exactly as the growing-bucket path does, guarding against any future - /// over-read touching uninitialized physical pages. + /// for **every sequence** on every KV binding. Not strictly required for + /// correctness (the kernel reads only `[0, total_lengths)`), but it keeps the + /// padding suffix zeroed exactly as the growing-bucket path does, guarding + /// against any future over-read touching uninitialized physical pages. + /// + /// Seq-major stores a sequence's tokens token-contiguously, so a sequence's + /// committed tail is one contiguous run; the `batch` sequences are a fixed + /// full-context `capacity × bytes_per_token` apart (batch axis outermost), + /// so this zeroes one run per sequence with no relocation (stage 2b-impl-2, + /// #750). At `batch == 1` this is a single memset byte-identical to the + /// previous contiguous-tail form. fn zero_seq_major_committed_tail( &self, old_committed: usize, new_committed: usize, ) -> anyhow::Result<()> { + if new_committed <= old_committed { + return Ok(()); + } for index in self.kv_binding_range.clone() { let binding = &self.bindings[index]; - let mut shape = binding.physical_shape().to_vec(); - shape[2] = old_committed; - let old_bytes = checked_shape_bytes(&shape, binding.dtype) - .context("seq-major committed-tail old size overflow")?; - shape[2] = new_committed; - let new_bytes = checked_shape_bytes(&shape, binding.dtype) - .context("seq-major committed-tail new size overflow")?; + let (geometry, capacity) = self.kv_binding_geometry(binding)?; + let bytes_per_token = geometry + .bytes_per_token() + .context("seq-major committed-tail bytes-per-token overflow")?; + let per_sequence_stride = capacity + .checked_mul(bytes_per_token) + .context("seq-major committed-tail per-sequence stride overflow")?; + let tail_offset = old_committed + .checked_mul(bytes_per_token) + .context("seq-major committed-tail offset overflow")?; + let tail_bytes = (new_committed - old_committed) + .checked_mul(bytes_per_token) + .context("seq-major committed-tail byte count overflow")?; let ptr = binding.device_ptr() as usize; - native_cuda_memset_zero(ptr + old_bytes, new_bytes - old_bytes)?; + for sequence in 0..self.batch { + let base = sequence + .checked_mul(per_sequence_stride) + .context("seq-major committed-tail sequence base overflow")?; + native_cuda_memset_zero(ptr + base + tail_offset, tail_bytes)?; + } } Ok(()) } @@ -1984,6 +2013,14 @@ impl DecodeCudaState { /// `docs/MEMORY_ARCHITECTURE.md`, "KV layout and residency"). Seq-major /// instead reports a fixed full-context stride and commits its dense prefix /// through [`Self::seq_major_kv_commit_requests`]. + /// + /// **Batch generality (stage 2b-impl-2, #750).** The per-binding KV range is + /// `checked_shape_bytes` of the physical shape whose axis 0 is `batch`, so + /// the flat range already spans `batch × kv_heads × new_capacity × head_dim` + /// bytes: a head-major packed bucket is dense from offset 0 across all `batch` + /// sequences, so one flat `0..bucket_bytes` run is correct at any batch (this + /// path only ever runs head-major — seq-major takes the fixed-stride commit). + /// The mask island spans `batch` rows. At `batch == 1` this is byte-identical. fn vmm_growth_requests( &self, new_capacity: usize, @@ -2011,6 +2048,7 @@ impl DecodeCudaState { } let mask_bytes = new_capacity .checked_mul(std::mem::size_of::()) + .and_then(|row_bytes| row_bytes.checked_mul(self.batch)) .with_context(|| { format!("VMM-backed CUDA mask growth overflows for capacity {new_capacity}") })?; @@ -2095,8 +2133,12 @@ impl DecodeCudaState { new_capacity: usize, valid_len: usize, ) -> anyhow::Result<()> { + // The mask binding is `[batch, new_capacity]`; the committed byte extent + // and the zeroing span all `batch` rows (stage 2b-impl-2, #750). At + // `batch == 1` this is byte-identical to the previous single-row memset. let bytes = new_capacity .checked_mul(std::mem::size_of::()) + .and_then(|row_bytes| row_bytes.checked_mul(self.batch)) .with_context(|| { format!("VMM-backed CUDA mask growth overflows for capacity {new_capacity}") })?; @@ -2225,12 +2267,17 @@ impl DecodeCudaState { initial_bucket_len }; let max_len = reported_len; - let mask_bytes = max_len - .checked_mul(std::mem::size_of::()) + // The mask binding is `[batch, len]`, so its committed / reserved byte + // extents span `batch` rows (stage 2b-impl-2, #750). Every current + // caller constructs at `batch == 1`, so the emitted byte counts are + // byte-identical to the previous single-row `len × i64` computation. + let mask_bytes = batch + .checked_mul(max_len) + .and_then(|elements| elements.checked_mul(std::mem::size_of::())) .context("initial CUDA mask size overflow")?; - let full_mask_bytes = capacity - .max_len - .checked_mul(std::mem::size_of::()) + let full_mask_bytes = batch + .checked_mul(capacity.max_len) + .and_then(|elements| elements.checked_mul(std::mem::size_of::())) .context("full CUDA mask reservation size overflow")?; // Seq-major fixed stride commits the whole (tiny) mask at construction so // the mask island is shape-static at the hard max and never grows; every @@ -3483,7 +3530,15 @@ impl onnx_genai_kv::KvCapacityGrowthBackend for NativeCudaCapacityBackend<'_> { )?; native_cuda_memset_zero( new_mask.device_ptr() as usize, - new_capacity * std::mem::size_of::(), + self.state + .batch + .checked_mul(new_capacity) + .and_then(|elements| elements.checked_mul(std::mem::size_of::())) + .with_context(|| { + format!( + "legacy realloc CUDA mask growth overflows for capacity {new_capacity}" + ) + })?, )?; new_mask.set_logical_shape(vec![self.state.batch, valid_len])?; Ok(Some(new_mask)) @@ -3585,6 +3640,22 @@ fn native_cuda_memset_zero(_dst: usize, _bytes: usize) -> anyhow::Result<()> { /// the live prefix keeps its byte offsets across growth and no KV data moves. /// This is the fixed-stride property #797 measured at the driver level, here /// realized on the engine growth path. +/// +/// **Batch-N refusal (stage 2b-impl-2, #750).** This helper feeds the two +/// *growing-bucket* copy paths ([`DecodeCudaState::apply_vmm_growth`] and the +/// legacy realloc `build_grown_buffers`), whose per-block stride *is* the +/// mutable bucket capacity. Head-major keeps the batch axis outermost, so each +/// `(batch, head)` block re-strides independently and batch-N growth is exact. +/// Seq-major is different: with `batch > 1` the per-sequence stride is the +/// growing bucket capacity, so growing the bucket relocates every sequence +/// `b > 0`'s KV bytes. That relocation would defeat the whole point of the +/// seq-major fixed-stride design (the "no KV moves / keep the captured graph" +/// contract) and the `moved_bytes_per_token == 0` growth accounting. Rather than +/// silently compute a wrong (capacity-dependent) stride, this refuses seq-major +/// growing-bucket growth at `batch > 1` with a named error. The relocation-free +/// batch-N seq-major path is the *fixed full-context stride* commit +/// ([`DecodeCudaState::seq_major_kv_commit_requests`]), which never calls this +/// helper. Turning batch-N on end-to-end is 2b-impl-4. pub(super) fn kv_growth_byte_layout( bnsh_shape: &[usize], layout: KvCommitLayout, @@ -3597,6 +3668,19 @@ pub(super) fn kv_growth_byte_layout( KvCommitLayout::SeqMajor => { let (batch, kv_heads, capacity, head_dim) = (bnsh_shape[0], bnsh_shape[1], bnsh_shape[2], bnsh_shape[3]); + if batch > 1 { + bail!( + "native CUDA seq-major (BSNH) KV *growing-bucket* growth cannot be made \ + batch-general for batch {batch} > 1 without relocating KV: a sequence's \ + per-sequence stride is the mutable bucket capacity ({capacity}), so growing \ + the bucket moves every sequence b>0's already-written KV bytes. Head-major \ + (BNSH) does not have this problem because its batch axis is outermost and \ + re-strides per (batch, head) block without moving batch 0. The relocation-free \ + batch-N seq-major path is the fixed full-context stride commit-on-demand path, \ + not this growing bucket. Refusing rather than computing a capacity-dependent \ + stride (stage 2b-impl-2, #750; batch-N is turned on in 2b-impl-4)." + ); + } (vec![batch, capacity, kv_heads, head_dim], 1) } }) diff --git a/crates/onnx-genai-engine/src/native_decode/kv_commit.rs b/crates/onnx-genai-engine/src/native_decode/kv_commit.rs index 669f8429ea..3a7051d9e5 100644 --- a/crates/onnx-genai-engine/src/native_decode/kv_commit.rs +++ b/crates/onnx-genai-engine/src/native_decode/kv_commit.rs @@ -93,39 +93,68 @@ impl KvBindingGeometry { } /// The byte ranges (within one binding's allocation) that must be committed to -/// make the live prefix of `valid_len` tokens physically resident, given the -/// layout and a fixed full-context `capacity` (the grow-axis stride). +/// make the live prefix of `valid_len` tokens physically resident for each of +/// `batch` sequences, given the layout and a fixed full-context `capacity` (the +/// grow-axis stride). /// -/// * Seq-major returns a single dense range `0..(valid_len × bytes_per_token)`. -/// * Head-major returns one range per head stripe, each covering that head's -/// live fragment `[h × capacity × head_dim × elem .. + valid_len × head_dim × -/// elem]`. This is the per-stripe floor of a fixed full-context stride. +/// * Seq-major returns one dense range per sequence, sequence `b` covering +/// `[b × capacity × bytes_per_token .. + valid_len × bytes_per_token]`. The +/// per-sequence stride is the fixed full-context `capacity`, so a sequence's +/// bytes never move as the *committed* prefix grows. +/// * Head-major returns one range per `(sequence, head)` stripe, stripe +/// `(b, h)` covering `[(b × kv_heads + h) × capacity × head_dim × elem .. + +/// valid_len × head_dim × elem]`. This is the per-stripe floor of a fixed +/// full-context stride. +/// +/// **Batch generality (stage 2b-impl-2, #750):** the batch axis is the +/// *outermost* axis of both layouts, so a sequence's fragments are a fixed +/// `per_sequence_stride = capacity × bytes_per_token` apart and computing them +/// needs no relocation — this is why the *fixed full-context stride* commit path +/// is batch-general. It is only a *growing bucket* (the realloc / VMM +/// growing-bucket path, not this fixed-stride commit) whose seq-major +/// per-sequence stride depends on the mutable bucket capacity and would relocate +/// every sequence `b > 0` on growth; that case is refused explicitly in +/// [`super::cuda::kv_growth_byte_layout`]. At `batch == 1` this function is +/// byte-identical to the historical single-sequence form. /// /// Returns `None` on arithmetic overflow. An empty vec means nothing to commit /// (`valid_len == 0`). pub(crate) fn live_prefix_ranges( layout: KvCommitLayout, geometry: KvBindingGeometry, + batch: usize, capacity: usize, valid_len: usize, ) -> Option>> { - if valid_len == 0 { + if valid_len == 0 || batch == 0 { return Some(Vec::new()); } let valid_len = valid_len.min(capacity); match layout { KvCommitLayout::SeqMajor => { - let bytes = valid_len.checked_mul(geometry.bytes_per_token()?)?; - Some(std::iter::once(0..bytes).collect()) + let bytes_per_token = geometry.bytes_per_token()?; + let live_width = valid_len.checked_mul(bytes_per_token)?; + let per_sequence_stride = capacity.checked_mul(bytes_per_token)?; + let mut ranges = Vec::with_capacity(batch); + for sequence in 0..batch { + let start = sequence.checked_mul(per_sequence_stride)?; + let end = start.checked_add(live_width)?; + ranges.push(start..end); + } + Some(ranges) } KvCommitLayout::HeadMajor => { let head_stride = capacity.checked_mul(geometry.bytes_per_token_per_head()?)?; + let per_sequence_stride = geometry.kv_heads.checked_mul(head_stride)?; let live_width = valid_len.checked_mul(geometry.bytes_per_token_per_head()?)?; - let mut ranges = Vec::with_capacity(geometry.kv_heads); - for head in 0..geometry.kv_heads { - let start = head.checked_mul(head_stride)?; - let end = start.checked_add(live_width)?; - ranges.push(start..end); + let mut ranges = Vec::with_capacity(batch.checked_mul(geometry.kv_heads)?); + for sequence in 0..batch { + let sequence_base = sequence.checked_mul(per_sequence_stride)?; + for head in 0..geometry.kv_heads { + let start = sequence_base.checked_add(head.checked_mul(head_stride)?)?; + let end = start.checked_add(live_width)?; + ranges.push(start..end); + } } Some(ranges) } @@ -174,11 +203,12 @@ pub(crate) fn committed_granules(ranges: &[Range], granule: usize) -> usi pub(crate) fn live_prefix_committed_bytes( layout: KvCommitLayout, geometry: KvBindingGeometry, + batch: usize, capacity: usize, valid_len: usize, granule: usize, ) -> Option { - let ranges = live_prefix_ranges(layout, geometry, capacity, valid_len)?; + let ranges = live_prefix_ranges(layout, geometry, batch, capacity, valid_len)?; committed_granules(&ranges, granule).checked_mul(granule) } @@ -202,7 +232,7 @@ mod tests { #[test] fn seq_major_is_one_dense_run_from_zero() { - let ranges = live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 32_768, 100).unwrap(); + let ranges = live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 1, 32_768, 100).unwrap(); assert_eq!(ranges, vec![0..(100 * 8 * 128 * 2)]); } @@ -211,7 +241,7 @@ mod tests { let capacity = 32_768; let valid = 100; let ranges = - live_prefix_ranges(KvCommitLayout::HeadMajor, QWEN14B, capacity, valid).unwrap(); + live_prefix_ranges(KvCommitLayout::HeadMajor, QWEN14B, 1, capacity, valid).unwrap(); assert_eq!(ranges.len(), QWEN14B.kv_heads); let head_stride = capacity * QWEN14B.head_dim * QWEN14B.elem_bytes; let live_width = valid * QWEN14B.head_dim * QWEN14B.elem_bytes; @@ -221,15 +251,66 @@ mod tests { } } + // Batch>1 control (stage 2b-impl-2, #750): seq-major returns one dense run + // per sequence, each a fixed `capacity × bytes_per_token` apart, so a + // transposed axis or a capacity-dependent stride would show up here (it + // could not at batch=1). No relocation: the per-sequence stride is the fixed + // full-context `capacity`, independent of `valid_len`. + #[test] + fn seq_major_batch_n_is_one_dense_run_per_sequence() { + let batch = 3; + let capacity = 32_768; + let valid = 100; + let ranges = + live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, batch, capacity, valid).unwrap(); + assert_eq!(ranges.len(), batch); + let bpt = QWEN14B.kv_heads * QWEN14B.head_dim * QWEN14B.elem_bytes; + let seq_stride = capacity * bpt; + let live = valid * bpt; + for (sequence, range) in ranges.iter().enumerate() { + assert_eq!(range.start, sequence * seq_stride); + assert_eq!(range.end, sequence * seq_stride + live); + } + // Sequence 0's fragment is byte-identical to the batch-1 result. + let batch1 = + live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 1, capacity, valid).unwrap(); + assert_eq!(ranges[0], batch1[0]); + } + + // Batch>1 control: head-major scatters `batch × kv_heads` fragments, batch + // outermost, `(b, h)` at `(b × kv_heads + h) × head_stride`. + #[test] + fn head_major_batch_n_is_one_fragment_per_sequence_head() { + let batch = 3; + let capacity = 32_768; + let valid = 100; + let ranges = + live_prefix_ranges(KvCommitLayout::HeadMajor, QWEN14B, batch, capacity, valid).unwrap(); + assert_eq!(ranges.len(), batch * QWEN14B.kv_heads); + let head_stride = capacity * QWEN14B.head_dim * QWEN14B.elem_bytes; + let live_width = valid * QWEN14B.head_dim * QWEN14B.elem_bytes; + for (index, range) in ranges.iter().enumerate() { + let start = index * head_stride; // dense (b*kv_heads + h) enumeration + assert_eq!(range.start, start); + assert_eq!(range.end, start + live_width); + } + } + #[test] fn empty_prefix_commits_nothing() { assert!( - live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 32_768, 0) + live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 1, 32_768, 0) + .unwrap() + .is_empty() + ); + // Batch has no effect when there is nothing live to commit. + assert!( + live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 4, 32_768, 0) .unwrap() .is_empty() ); assert_eq!( - live_prefix_committed_bytes(KvCommitLayout::HeadMajor, QWEN14B, 32_768, 0, GRANULE) + live_prefix_committed_bytes(KvCommitLayout::HeadMajor, QWEN14B, 1, 32_768, 0, GRANULE) .unwrap(), 0 ); @@ -254,6 +335,7 @@ mod tests { let head_major = live_prefix_committed_bytes( KvCommitLayout::HeadMajor, QWEN14B, + 1, capacity, valid, GRANULE, @@ -262,6 +344,7 @@ mod tests { let seq_major = live_prefix_committed_bytes( KvCommitLayout::SeqMajor, QWEN14B, + 1, capacity, valid, GRANULE, @@ -277,11 +360,17 @@ mod tests { #[test] fn near_empty_floor_is_kv_heads_times_smaller_seq_major_qwen05b() { let capacity = 32_768; - let head_major = - live_prefix_committed_bytes(KvCommitLayout::HeadMajor, QWEN05B, capacity, 1, GRANULE) - .unwrap(); + let head_major = live_prefix_committed_bytes( + KvCommitLayout::HeadMajor, + QWEN05B, + 1, + capacity, + 1, + GRANULE, + ) + .unwrap(); let seq_major = - live_prefix_committed_bytes(KvCommitLayout::SeqMajor, QWEN05B, capacity, 1, GRANULE) + live_prefix_committed_bytes(KvCommitLayout::SeqMajor, QWEN05B, 1, capacity, 1, GRANULE) .unwrap(); assert_eq!(head_major / seq_major, QWEN05B.kv_heads); // 2× } @@ -296,6 +385,7 @@ mod tests { let head_major = live_prefix_committed_bytes( KvCommitLayout::HeadMajor, QWEN14B, + 1, capacity, valid, GRANULE, @@ -304,6 +394,7 @@ mod tests { let seq_major = live_prefix_committed_bytes( KvCommitLayout::SeqMajor, QWEN14B, + 1, capacity, valid, GRANULE, @@ -316,7 +407,8 @@ mod tests { #[test] fn valid_len_is_clamped_to_capacity() { - let ranges = live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 256, 100_000).unwrap(); + let ranges = + live_prefix_ranges(KvCommitLayout::SeqMajor, QWEN14B, 1, 256, 100_000).unwrap(); assert_eq!(ranges, vec![0..(256 * 8 * 128 * 2)]); } diff --git a/crates/onnx-genai-engine/src/native_decode/tests.rs b/crates/onnx-genai-engine/src/native_decode/tests.rs index df52c84db2..4fddf46c08 100644 --- a/crates/onnx-genai-engine/src/native_decode/tests.rs +++ b/crates/onnx-genai-engine/src/native_decode/tests.rs @@ -2165,6 +2165,46 @@ fn seq_major_growth_is_fixed_stride_and_moves_no_bytes() { } } +// Batch-N control (stage 2b-impl-2, #750): head-major growing-bucket growth is +// batch-general, seq-major growing-bucket growth at batch>1 is an explicit named +// refusal (it would relocate every sequence b>0). This asserts the geometry math +// at batch>1 even though runtime batch stays fixed to 1 — a transposed axis or a +// silently wrong capacity-dependent stride could not surface at batch=1. +#[test] +fn kv_growth_byte_layout_refuses_seq_major_batch_n_and_allows_head_major_batch_n() { + // Head-major: batch axis outermost, byte layout == declared shape, grow axis + // 2, block count = batch * kv_heads — each (batch, head) stripe re-strides + // independently and correctly, so batch-N growth is exact. + let head_batch_n = [3usize, 8, 512, 128]; + let (bytes, axis) = kv_growth_byte_layout(&head_batch_n, KvCommitLayout::HeadMajor) + .expect("head-major batch-N growing bucket is batch-general"); + assert_eq!(bytes, head_batch_n.to_vec()); + assert_eq!(axis, 2); + let blocks: usize = bytes[..axis].iter().product(); + assert_eq!(blocks, 3 * 8, "head-major blocks = batch * kv_heads"); + + // Seq-major growing bucket at batch>1 is refused with a named error rather + // than a silently wrong capacity-dependent stride. + let seq_batch_n = [3usize, 8, 512, 128]; + let error = kv_growth_byte_layout(&seq_batch_n, KvCommitLayout::SeqMajor) + .expect_err("seq-major batch-N growing bucket must be refused"); + let message = error.to_string(); + assert!(message.contains("seq-major"), "names the layout: {message}"); + assert!(message.contains("batch 3"), "names the batch: {message}"); + assert!( + message.contains("relocat"), + "names the constraint: {message}" + ); + + // Batch-1 seq-major is unaffected: byte-identical BSNH permutation, grow + // axis 1. + let seq_batch_1 = [1usize, 8, 512, 128]; + let (seq_bytes, seq_axis) = kv_growth_byte_layout(&seq_batch_1, KvCommitLayout::SeqMajor) + .expect("seq-major batch-1 unaffected"); + assert_eq!(seq_bytes, vec![1, 512, 8, 128]); + assert_eq!(seq_axis, 1); +} + #[test] fn cuda_fixed_state_shapes_reject_unbounded_non_batch_dimensions() { let error = DecodeCudaState::persistent_state_shapes(