Skip to main content

scirs2_core/array_protocol/
gpu_ndarray.rs

1// Copyright (c) 2025, `SciRS2` Team
2//
3// Licensed under the Apache License, Version 2.0
4// (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
5//
6
7//! Real wgpu-backed `GpuNdarray<T>` that implements `ArrayProtocol`.
8//!
9//! Enabled only with the `array_protocol_wgpu` feature, which implies `wgpu`.
10//!
11//! ## Supported operations (GPU dispatch)
12//! - `add`, `subtract`, `multiply` — elementwise binary, workgroup (256,1,1), uses `arrayLength`
13//! - `multiply_by_scalar_f32` — elementwise scalar multiply, workgroup (256,1,1)
14//! - `matmul` — naive (one thread per output element), workgroup (16,16,1)
15//! - `sum(axis=None)` — two-pass reduce, workgroup (256,1,1)
16//! - `transpose` (2-D) — 16×16 bank-conflict-padded tile, workgroup (16,16,1)
17//!   (32×32 exceeds Metal's 256-invocation-per-workgroup limit)
18//! - `concatenate(axis=0)` — via `copy_buffer_to_buffer`, no shader
19//! - `concatenate(axis>0)` — WGSL gather kernel (`CONCAT_AXISN_WGSL`), storage-buf strides
20//! - `sum(axis=Some(ax))` — WGSL per-output-element axis reduction (`REDUCE_SUM_AXIS_WGSL`)
21//! - `reshape` — zero-copy (clone `Arc<Buffer>`, new shape/strides)
22//!
23//! ## CPU-fallback operations
24//! - `svd` — falls back to CPU `NdarrayWrapper`
25//! - `inverse` — falls back to CPU `NdarrayWrapper`
26//! - `multiply_by_scalar_f64`, `divide_by_scalar_f64` — convert to f32, then GPU
27//! - GPU kernel errors on axis ops — fallback to CPU (graceful degradation)
28//!
29//! ## GPU threshold
30//! Arrays with fewer than 4096 elements skip GPU dispatch entirely and fall back to CPU.
31
32use std::any::{Any, TypeId};
33use std::collections::HashMap;
34use std::marker::PhantomData;
35use std::sync::{Arc, OnceLock};
36
37use crate::array_protocol::{
38    ArrayFunction, ArrayProtocol, GPUArray, NdarrayWrapper, NotImplemented,
39};
40use crate::error::{CoreError, CoreResult, ErrorContext};
41use crate::gpu::backends::WebGPUContext;
42use crate::gpu::GpuError;
43
44// ──────────────────────────────────────────────────────────────────────
45// 1. GpuScalar — sealed trait, blanket impl for f32 only
46// ──────────────────────────────────────────────────────────────────────
47
48mod sealed {
49    pub trait Sealed {}
50}
51
52/// Marker trait for element types that wgpu-29 supports natively (f32 only;
53/// f64 is not supported in WGSL without extensions).
54pub trait GpuScalar: sealed::Sealed + Clone + Send + Sync + 'static {}
55
56impl sealed::Sealed for f32 {}
57impl GpuScalar for f32 {}
58
59// ──────────────────────────────────────────────────────────────────────
60// 2. GPU dispatch threshold, availability cache, and singleton context
61// ──────────────────────────────────────────────────────────────────────
62
63/// Arrays smaller than this skip GPU dispatch entirely.
64const GPU_THRESHOLD: usize = 4096;
65
66/// Cached GPU availability flag (computed once per process).
67static GPU_AVAILABLE: OnceLock<bool> = OnceLock::new();
68
69/// Cached singleton `WebGPUContext` — all `GpuNdarray` share one device.
70static GPU_CONTEXT: OnceLock<Option<Arc<WebGPUContext>>> = OnceLock::new();
71
72/// Returns the shared `WebGPUContext`, or `None` if no adapter is available.
73///
74/// Initializes the singleton device once; all subsequent calls return the cached value.
75pub fn global_context() -> Option<Arc<WebGPUContext>> {
76    GPU_CONTEXT
77        .get_or_init(|| match WebGPUContext::new() {
78            Ok(ctx) => Some(Arc::new(ctx)),
79            Err(_) => None,
80        })
81        .clone()
82}
83
84/// Returns `true` if a wgpu adapter was found when first called; cached afterwards.
85pub fn is_gpu_available() -> bool {
86    *GPU_AVAILABLE.get_or_init(|| global_context().is_some())
87}
88
89// ──────────────────────────────────────────────────────────────────────
90// 3. GpuNdarray<T>
91// ──────────────────────────────────────────────────────────────────────
92
93/// A GPU-backed n-dimensional array backed by a real wgpu `Buffer`.
94///
95/// Created via [`GpuNdarray::from_ndarray_data`] or [`GpuNdarray::from_data`].
96/// Converted back with [`GpuNdarray::to_ndarray`].
97pub struct GpuNdarray<T: GpuScalar> {
98    /// The live wgpu `Buffer` on the device.  Shared under `Arc` to make
99    /// `reshape` zero-copy and `Clone` cheap.
100    buffer: Arc<wgpu::Buffer>,
101
102    /// Logical shape (row-major).
103    shape: Vec<usize>,
104
105    /// Row-major strides in *elements* (not bytes).
106    strides: Vec<usize>,
107
108    /// Shared device/queue context.
109    context: Arc<WebGPUContext>,
110
111    _phantom: PhantomData<T>,
112}
113
114impl<T: GpuScalar> std::fmt::Debug for GpuNdarray<T> {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("GpuNdarray")
117            .field("shape", &self.shape)
118            .field("strides", &self.strides)
119            .finish_non_exhaustive()
120    }
121}
122
123impl<T: GpuScalar> Clone for GpuNdarray<T> {
124    fn clone(&self) -> Self {
125        Self {
126            buffer: Arc::clone(&self.buffer),
127            shape: self.shape.clone(),
128            strides: self.strides.clone(),
129            context: Arc::clone(&self.context),
130            _phantom: PhantomData,
131        }
132    }
133}
134
135impl<T: GpuScalar> GpuNdarray<T> {
136    /// Expose the underlying `Arc<wgpu::Buffer>` for zero-copy checks in tests.
137    #[must_use]
138    pub fn buffer_arc(&self) -> &Arc<wgpu::Buffer> {
139        &self.buffer
140    }
141
142    /// Total number of elements.
143    #[must_use]
144    fn numel(&self) -> usize {
145        self.shape.iter().product()
146    }
147
148    /// Row-major strides for the given shape.
149    fn compute_strides(shape: &[usize]) -> Vec<usize> {
150        let mut strides = vec![1usize; shape.len()];
151        for i in (0..shape.len().saturating_sub(1)).rev() {
152            strides[i] = strides[i + 1] * shape[i + 1];
153        }
154        strides
155    }
156}
157
158// ──────────────────────────────────────────────────────────────────────
159// 4. Low-level pipeline builder helper (not part of GpuNdarray impl)
160// ──────────────────────────────────────────────────────────────────────
161
162/// Build a compute pipeline with an explicit bind group layout.
163///
164/// `bgl_entries` must match the WGSL `@group(0)` bindings exactly.
165fn build_pipeline(
166    ctx: &WebGPUContext,
167    wgsl: &str,
168    bgl_entries: &[wgpu::BindGroupLayoutEntry],
169    label: &str,
170) -> Result<(wgpu::ComputePipeline, wgpu::BindGroupLayout), GpuError> {
171    let device = ctx.device();
172    let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
173        label: Some(label),
174        source: wgpu::ShaderSource::Wgsl(wgsl.into()),
175    });
176    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
177        label: Some(&format!("{label}_bgl")),
178        entries: bgl_entries,
179    });
180    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
181        label: Some(&format!("{label}_layout")),
182        bind_group_layouts: &[Some(&bgl)],
183        ..Default::default()
184    });
185    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
186        label: Some(&format!("{label}_pipeline")),
187        layout: Some(&pipeline_layout),
188        module: &shader_module,
189        entry_point: Some("main"),
190        compilation_options: Default::default(),
191        cache: None,
192    });
193    Ok((pipeline, bgl))
194}
195
196/// Shorthand BGL entry for a read-only storage buffer.
197fn storage_ro(binding: u32) -> wgpu::BindGroupLayoutEntry {
198    wgpu::BindGroupLayoutEntry {
199        binding,
200        visibility: wgpu::ShaderStages::COMPUTE,
201        ty: wgpu::BindingType::Buffer {
202            ty: wgpu::BufferBindingType::Storage { read_only: true },
203            has_dynamic_offset: false,
204            min_binding_size: None,
205        },
206        count: None,
207    }
208}
209
210/// Shorthand BGL entry for a read-write storage buffer.
211fn storage_rw(binding: u32) -> wgpu::BindGroupLayoutEntry {
212    wgpu::BindGroupLayoutEntry {
213        binding,
214        visibility: wgpu::ShaderStages::COMPUTE,
215        ty: wgpu::BindingType::Buffer {
216            ty: wgpu::BufferBindingType::Storage { read_only: false },
217            has_dynamic_offset: false,
218            min_binding_size: None,
219        },
220        count: None,
221    }
222}
223
224/// Shorthand BGL entry for a uniform buffer.
225fn uniform_buf(binding: u32) -> wgpu::BindGroupLayoutEntry {
226    wgpu::BindGroupLayoutEntry {
227        binding,
228        visibility: wgpu::ShaderStages::COMPUTE,
229        ty: wgpu::BindingType::Buffer {
230            ty: wgpu::BufferBindingType::Uniform,
231            has_dynamic_offset: false,
232            min_binding_size: None,
233        },
234        count: None,
235    }
236}
237
238// ──────────────────────────────────────────────────────────────────────
239// 5. from_ndarray / to_ndarray for f32
240// ──────────────────────────────────────────────────────────────────────
241
242impl GpuNdarray<f32> {
243    /// Upload a CPU `NdarrayWrapper<f32, _>` to the GPU.
244    ///
245    /// Returns `Err(GpuError)` if no adapter is available or upload fails.
246    pub fn from_ndarray_data(
247        data: &[f32],
248        shape: Vec<usize>,
249        context: Arc<WebGPUContext>,
250    ) -> Result<Self, GpuError> {
251        use wgpu::util::DeviceExt as _;
252        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
253        let buffer = context
254            .device()
255            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
256                label: Some("GpuNdarray<f32>"),
257                contents: &bytes,
258                usage: wgpu::BufferUsages::STORAGE
259                    | wgpu::BufferUsages::COPY_SRC
260                    | wgpu::BufferUsages::COPY_DST,
261            });
262        let strides = Self::compute_strides(&shape);
263        Ok(Self {
264            buffer: Arc::new(buffer),
265            shape,
266            strides,
267            context,
268            _phantom: PhantomData,
269        })
270    }
271
272    /// Download the GPU buffer to a flat `Vec<f32>`.
273    pub fn to_vec(&self) -> Result<Vec<f32>, GpuError> {
274        let byte_size = (self.numel() * std::mem::size_of::<f32>()) as u64;
275        let staging = self
276            .context
277            .device()
278            .create_buffer(&wgpu::BufferDescriptor {
279                label: Some("GpuNdarray-readback"),
280                size: byte_size,
281                usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
282                mapped_at_creation: false,
283            });
284
285        let mut encoder =
286            self.context
287                .device()
288                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
289                    label: Some("GpuNdarray-readback-encoder"),
290                });
291        encoder.copy_buffer_to_buffer(&self.buffer, 0, &staging, 0, byte_size);
292        self.context.queue().submit(Some(encoder.finish()));
293
294        self.context
295            .device()
296            .poll(wgpu::PollType::wait_indefinitely())
297            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
298
299        let slice = staging.slice(0..byte_size);
300        let (tx, rx) = std::sync::mpsc::channel();
301        slice.map_async(wgpu::MapMode::Read, move |r| {
302            let _ = tx.send(r);
303        });
304
305        self.context
306            .device()
307            .poll(wgpu::PollType::wait_indefinitely())
308            .map_err(|e| GpuError::Other(format!("poll-map error: {e:?}")))?;
309
310        rx.recv()
311            .map_err(|_| GpuError::Other("channel closed".into()))?
312            .map_err(|e| GpuError::Other(format!("map_async failed: {e:?}")))?;
313
314        let mapped = slice.get_mapped_range();
315        let result: Vec<f32> = mapped
316            .chunks_exact(4)
317            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
318            .collect();
319        drop(mapped);
320        staging.unmap();
321        Ok(result)
322    }
323
324    /// Download to a dynamic ndarray.
325    pub fn to_ndarray(&self) -> Result<ndarray::ArrayD<f32>, GpuError> {
326        let flat = self.to_vec()?;
327        ndarray::ArrayD::<f32>::from_shape_vec(self.shape.clone(), flat)
328            .map_err(|e| GpuError::Other(format!("shape_vec error: {e}")))
329    }
330
331    /// Construct from a `WebGPUContext`, uploading `data`.
332    ///
333    /// Uses the process-wide singleton `WebGPUContext` so that all arrays
334    /// created via this function share the same `wgpu::Device` and can be
335    /// combined freely in kernel dispatches.
336    pub fn from_data(data: &[f32], shape: Vec<usize>) -> Result<Self, GpuError> {
337        let ctx =
338            global_context().ok_or_else(|| GpuError::Other("No wgpu adapter available".into()))?;
339        Self::from_ndarray_data(data, shape, ctx)
340    }
341
342    // ─── public high-level ops ─────────────────────────────────────────
343
344    /// Elementwise add: `self + other`.  Both must have the same shape.
345    pub fn add(&self, other: &GpuNdarray<f32>) -> Result<GpuNdarray<f32>, GpuError> {
346        self.dispatch_elementwise_binary(other, 0)
347    }
348
349    /// Elementwise subtract: `self - other`.  Both must have the same shape.
350    pub fn subtract(&self, other: &GpuNdarray<f32>) -> Result<GpuNdarray<f32>, GpuError> {
351        self.dispatch_elementwise_binary(other, 1)
352    }
353
354    /// Elementwise multiply: `self * other`.  Both must have the same shape.
355    pub fn multiply(&self, other: &GpuNdarray<f32>) -> Result<GpuNdarray<f32>, GpuError> {
356        self.dispatch_elementwise_binary(other, 2)
357    }
358
359    /// Multiply every element by a scalar: `self * scalar`.
360    pub fn multiply_by_scalar_f32(&self, scalar: f32) -> Result<GpuNdarray<f32>, GpuError> {
361        self.dispatch_scalar_multiply(scalar)
362    }
363
364    /// Compute the sum of all elements (dot product via `a.multiply(b)?.sum_all()`).
365    pub fn sum_all(&self) -> Result<f32, GpuError> {
366        self.dispatch_sum_all()
367    }
368
369    /// Compute the dot product `self · other = sum(self * other)`.
370    pub fn dot_gpu(&self, other: &GpuNdarray<f32>) -> Result<f32, GpuError> {
371        let prod = self.dispatch_elementwise_binary(other, 2)?;
372        prod.dispatch_sum_all()
373    }
374
375    /// Tiled 16x16 matrix multiplication: self x other.
376    ///
377    /// Both arrays must be 2-D.  For a matrix-vector product, upload the
378    /// vector as a [n, 1] column; the result will have shape [m, 1].
379    pub fn matmul(&self, other: &GpuNdarray<f32>) -> Result<GpuNdarray<f32>, GpuError> {
380        self.dispatch_matmul(other)
381    }
382
383    // ─── internal GPU kernel dispatchers ───────────────────────────────
384
385    /// Dispatch an elementwise binary kernel (`add`, `subtract`, `multiply`).
386    ///
387    /// `op_id`: 0=add, 1=subtract, 2=multiply.
388    fn dispatch_elementwise_binary(
389        &self,
390        other: &GpuNdarray<f32>,
391        op_id: u32,
392    ) -> Result<GpuNdarray<f32>, GpuError> {
393        let n = self.numel();
394        if n != other.numel() {
395            return Err(GpuError::InvalidParameter(
396                "shape mismatch in elementwise binary".into(),
397            ));
398        }
399        let wgsl = match op_id {
400            0 => ELEMENTWISE_ADD_WGSL,
401            1 => ELEMENTWISE_SUB_WGSL,
402            _ => ELEMENTWISE_MUL_WGSL,
403        };
404        let byte_size = (n * 4) as u64;
405        let result_buf = self
406            .context
407            .device()
408            .create_buffer(&wgpu::BufferDescriptor {
409                label: Some("elementwise-result"),
410                size: byte_size,
411                usage: wgpu::BufferUsages::STORAGE
412                    | wgpu::BufferUsages::COPY_SRC
413                    | wgpu::BufferUsages::COPY_DST,
414                mapped_at_creation: false,
415            });
416
417        let bgl_entries = [storage_ro(0), storage_ro(1), storage_rw(2)];
418        let (pipeline, bgl) = build_pipeline(&self.context, wgsl, &bgl_entries, "elementwise")?;
419
420        let bind_group = self
421            .context
422            .device()
423            .create_bind_group(&wgpu::BindGroupDescriptor {
424                label: Some("elementwise-bg"),
425                layout: &bgl,
426                entries: &[
427                    wgpu::BindGroupEntry {
428                        binding: 0,
429                        resource: self.buffer.as_entire_binding(),
430                    },
431                    wgpu::BindGroupEntry {
432                        binding: 1,
433                        resource: other.buffer.as_entire_binding(),
434                    },
435                    wgpu::BindGroupEntry {
436                        binding: 2,
437                        resource: result_buf.as_entire_binding(),
438                    },
439                ],
440            });
441
442        let mut encoder =
443            self.context
444                .device()
445                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
446                    label: Some("elementwise-encoder"),
447                });
448        {
449            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
450                label: Some("elementwise-pass"),
451                timestamp_writes: None,
452            });
453            cpass.set_pipeline(&pipeline);
454            cpass.set_bind_group(0, &bind_group, &[]);
455            let workgroups = (n as u32 + 255) / 256;
456            cpass.dispatch_workgroups(workgroups, 1, 1);
457        }
458        self.context.queue().submit(Some(encoder.finish()));
459        self.context
460            .device()
461            .poll(wgpu::PollType::wait_indefinitely())
462            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
463
464        Ok(GpuNdarray {
465            buffer: Arc::new(result_buf),
466            shape: self.shape.clone(),
467            strides: self.strides.clone(),
468            context: Arc::clone(&self.context),
469            _phantom: PhantomData,
470        })
471    }
472
473    /// Dispatch scalar multiply: each element * scalar.
474    fn dispatch_scalar_multiply(&self, scalar: f32) -> Result<GpuNdarray<f32>, GpuError> {
475        let n = self.numel();
476        let byte_size = (n * 4) as u64;
477        let result_buf = self
478            .context
479            .device()
480            .create_buffer(&wgpu::BufferDescriptor {
481                label: Some("scalar-mul-result"),
482                size: byte_size,
483                usage: wgpu::BufferUsages::STORAGE
484                    | wgpu::BufferUsages::COPY_SRC
485                    | wgpu::BufferUsages::COPY_DST,
486                mapped_at_creation: false,
487            });
488
489        // Uniform: scalar (f32), n (u32) — 8 bytes padded to 16
490        let mut unif: Vec<u8> = Vec::with_capacity(16);
491        unif.extend_from_slice(&scalar.to_le_bytes());
492        unif.extend_from_slice(&(n as u32).to_le_bytes());
493        while unif.len() % 16 != 0 {
494            unif.push(0);
495        }
496        use wgpu::util::DeviceExt as _;
497        let uniform_buffer =
498            self.context
499                .device()
500                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
501                    label: Some("scalar-mul-uniform"),
502                    contents: &unif,
503                    usage: wgpu::BufferUsages::UNIFORM,
504                });
505
506        let bgl_entries = [storage_ro(0), storage_rw(1), uniform_buf(2)];
507        let (pipeline, bgl) =
508            build_pipeline(&self.context, SCALAR_MUL_WGSL, &bgl_entries, "scalar-mul")?;
509
510        let bind_group = self
511            .context
512            .device()
513            .create_bind_group(&wgpu::BindGroupDescriptor {
514                label: Some("scalar-mul-bg"),
515                layout: &bgl,
516                entries: &[
517                    wgpu::BindGroupEntry {
518                        binding: 0,
519                        resource: self.buffer.as_entire_binding(),
520                    },
521                    wgpu::BindGroupEntry {
522                        binding: 1,
523                        resource: result_buf.as_entire_binding(),
524                    },
525                    wgpu::BindGroupEntry {
526                        binding: 2,
527                        resource: uniform_buffer.as_entire_binding(),
528                    },
529                ],
530            });
531
532        let mut encoder =
533            self.context
534                .device()
535                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
536                    label: Some("scalar-mul-encoder"),
537                });
538        {
539            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
540                label: Some("scalar-mul-pass"),
541                timestamp_writes: None,
542            });
543            cpass.set_pipeline(&pipeline);
544            cpass.set_bind_group(0, &bind_group, &[]);
545            let workgroups = (n as u32 + 255) / 256;
546            cpass.dispatch_workgroups(workgroups, 1, 1);
547        }
548        self.context.queue().submit(Some(encoder.finish()));
549        self.context
550            .device()
551            .poll(wgpu::PollType::wait_indefinitely())
552            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
553
554        Ok(GpuNdarray {
555            buffer: Arc::new(result_buf),
556            shape: self.shape.clone(),
557            strides: self.strides.clone(),
558            context: Arc::clone(&self.context),
559            _phantom: PhantomData,
560        })
561    }
562
563    /// Dispatch tiled 16×16 matmul for 2-D arrays.
564    fn dispatch_matmul(&self, other: &GpuNdarray<f32>) -> Result<GpuNdarray<f32>, GpuError> {
565        if self.shape.len() != 2 || other.shape.len() != 2 {
566            return Err(GpuError::InvalidParameter(
567                "matmul requires 2-D arrays".into(),
568            ));
569        }
570        let (m, k) = (self.shape[0], self.shape[1]);
571        let (k2, n) = (other.shape[0], other.shape[1]);
572        if k != k2 {
573            return Err(GpuError::InvalidParameter(format!(
574                "matmul shape mismatch: [{m},{k}] x [{k2},{n}]"
575            )));
576        }
577
578        let byte_size = (m * n * 4) as u64;
579        let result_buf = self
580            .context
581            .device()
582            .create_buffer(&wgpu::BufferDescriptor {
583                label: Some("matmul-result"),
584                size: byte_size,
585                usage: wgpu::BufferUsages::STORAGE
586                    | wgpu::BufferUsages::COPY_SRC
587                    | wgpu::BufferUsages::COPY_DST,
588                mapped_at_creation: false,
589            });
590
591        let uniform_data: [u32; 3] = [m as u32, n as u32, k as u32];
592        let uniform_bytes: Vec<u8> = uniform_data.iter().flat_map(|v| v.to_le_bytes()).collect();
593        // Pad to 16-byte alignment (wgpu uniform requirement)
594        let mut uniform_padded = uniform_bytes;
595        while uniform_padded.len() % 16 != 0 {
596            uniform_padded.push(0);
597        }
598        use wgpu::util::DeviceExt as _;
599        let uniform_buffer =
600            self.context
601                .device()
602                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
603                    label: Some("matmul-uniform"),
604                    contents: &uniform_padded,
605                    usage: wgpu::BufferUsages::UNIFORM,
606                });
607
608        let bgl_entries = [storage_ro(0), storage_ro(1), storage_rw(2), uniform_buf(3)];
609        let (pipeline, bgl) = build_pipeline(&self.context, MATMUL_WGSL, &bgl_entries, "matmul")?;
610        let bind_group = self
611            .context
612            .device()
613            .create_bind_group(&wgpu::BindGroupDescriptor {
614                label: Some("matmul-bg"),
615                layout: &bgl,
616                entries: &[
617                    wgpu::BindGroupEntry {
618                        binding: 0,
619                        resource: self.buffer.as_entire_binding(),
620                    },
621                    wgpu::BindGroupEntry {
622                        binding: 1,
623                        resource: other.buffer.as_entire_binding(),
624                    },
625                    wgpu::BindGroupEntry {
626                        binding: 2,
627                        resource: result_buf.as_entire_binding(),
628                    },
629                    wgpu::BindGroupEntry {
630                        binding: 3,
631                        resource: uniform_buffer.as_entire_binding(),
632                    },
633                ],
634            });
635
636        let wg_x = (n as u32 + 15) / 16;
637        let wg_y = (m as u32 + 15) / 16;
638        let mut encoder =
639            self.context
640                .device()
641                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
642                    label: Some("matmul-encoder"),
643                });
644        {
645            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
646                label: Some("matmul-pass"),
647                timestamp_writes: None,
648            });
649            cpass.set_pipeline(&pipeline);
650            cpass.set_bind_group(0, &bind_group, &[]);
651            cpass.dispatch_workgroups(wg_x, wg_y, 1);
652        }
653        self.context.queue().submit(Some(encoder.finish()));
654        self.context
655            .device()
656            .poll(wgpu::PollType::wait_indefinitely())
657            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
658
659        Ok(GpuNdarray {
660            buffer: Arc::new(result_buf),
661            shape: vec![m, n],
662            strides: Self::compute_strides(&[m, n]),
663            context: Arc::clone(&self.context),
664            _phantom: PhantomData,
665        })
666    }
667
668    /// Dispatch two-pass sum reduction (axis=None).
669    fn dispatch_sum_all(&self) -> Result<f32, GpuError> {
670        let n = self.numel();
671        // First pass: workgroup partial sums
672        let workgroup_count = (n as u32 + 255) / 256;
673        let partial_byte_size = (workgroup_count as usize * 4) as u64;
674        let partial_buf = self
675            .context
676            .device()
677            .create_buffer(&wgpu::BufferDescriptor {
678                label: Some("sum-partial"),
679                size: partial_byte_size,
680                usage: wgpu::BufferUsages::STORAGE
681                    | wgpu::BufferUsages::COPY_SRC
682                    | wgpu::BufferUsages::COPY_DST,
683                mapped_at_creation: false,
684            });
685
686        let n_bytes = (n as u32).to_le_bytes();
687        let mut uniform_bytes = n_bytes.to_vec();
688        while uniform_bytes.len() % 16 != 0 {
689            uniform_bytes.push(0);
690        }
691        use wgpu::util::DeviceExt as _;
692        let uniform_buffer =
693            self.context
694                .device()
695                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
696                    label: Some("sum-uniform"),
697                    contents: &uniform_bytes,
698                    usage: wgpu::BufferUsages::UNIFORM,
699                });
700
701        let bgl_entries = [storage_ro(0), storage_rw(1), uniform_buf(2)];
702        let (pipeline, bgl) =
703            build_pipeline(&self.context, SUM_REDUCE_WGSL, &bgl_entries, "sum-reduce")?;
704        let bind_group = self
705            .context
706            .device()
707            .create_bind_group(&wgpu::BindGroupDescriptor {
708                label: Some("sum-bg"),
709                layout: &bgl,
710                entries: &[
711                    wgpu::BindGroupEntry {
712                        binding: 0,
713                        resource: self.buffer.as_entire_binding(),
714                    },
715                    wgpu::BindGroupEntry {
716                        binding: 1,
717                        resource: partial_buf.as_entire_binding(),
718                    },
719                    wgpu::BindGroupEntry {
720                        binding: 2,
721                        resource: uniform_buffer.as_entire_binding(),
722                    },
723                ],
724            });
725
726        let mut encoder =
727            self.context
728                .device()
729                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
730                    label: Some("sum-encoder"),
731                });
732        {
733            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
734                label: Some("sum-pass"),
735                timestamp_writes: None,
736            });
737            cpass.set_pipeline(&pipeline);
738            cpass.set_bind_group(0, &bind_group, &[]);
739            cpass.dispatch_workgroups(workgroup_count, 1, 1);
740        }
741        self.context.queue().submit(Some(encoder.finish()));
742        self.context
743            .device()
744            .poll(wgpu::PollType::wait_indefinitely())
745            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
746
747        // Read back partial sums and sum on CPU (second pass)
748        let staging = self
749            .context
750            .device()
751            .create_buffer(&wgpu::BufferDescriptor {
752                label: Some("sum-staging"),
753                size: partial_byte_size,
754                usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
755                mapped_at_creation: false,
756            });
757        let mut encoder2 =
758            self.context
759                .device()
760                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
761                    label: Some("sum-copy-encoder"),
762                });
763        encoder2.copy_buffer_to_buffer(&partial_buf, 0, &staging, 0, partial_byte_size);
764        self.context.queue().submit(Some(encoder2.finish()));
765
766        self.context
767            .device()
768            .poll(wgpu::PollType::wait_indefinitely())
769            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
770
771        let slice = staging.slice(0..partial_byte_size);
772        let (tx, rx) = std::sync::mpsc::channel();
773        slice.map_async(wgpu::MapMode::Read, move |r| {
774            let _ = tx.send(r);
775        });
776        self.context
777            .device()
778            .poll(wgpu::PollType::wait_indefinitely())
779            .map_err(|e| GpuError::Other(format!("map poll error: {e:?}")))?;
780        rx.recv()
781            .map_err(|_| GpuError::Other("channel closed".into()))?
782            .map_err(|e| GpuError::Other(format!("map_async: {e:?}")))?;
783
784        let mapped = slice.get_mapped_range();
785        let total: f32 = mapped
786            .chunks_exact(4)
787            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
788            .sum();
789        drop(mapped);
790        staging.unmap();
791        Ok(total)
792    }
793
794    /// Dispatch tiled 32×32 2-D transpose.
795    fn dispatch_transpose_2d(&self) -> Result<GpuNdarray<f32>, GpuError> {
796        if self.shape.len() != 2 {
797            return Err(GpuError::InvalidParameter(
798                "transpose_2d requires a 2-D array".into(),
799            ));
800        }
801        let (rows, cols) = (self.shape[0], self.shape[1]);
802        let byte_size = (rows * cols * 4) as u64;
803
804        let result_buf = self
805            .context
806            .device()
807            .create_buffer(&wgpu::BufferDescriptor {
808                label: Some("transpose-result"),
809                size: byte_size,
810                usage: wgpu::BufferUsages::STORAGE
811                    | wgpu::BufferUsages::COPY_SRC
812                    | wgpu::BufferUsages::COPY_DST,
813                mapped_at_creation: false,
814            });
815
816        // Uniform: rows (u32), cols (u32)
817        let uniform_data: [u32; 2] = [rows as u32, cols as u32];
818        let uniform_bytes: Vec<u8> = uniform_data.iter().flat_map(|v| v.to_le_bytes()).collect();
819        let mut uniform_padded = uniform_bytes;
820        while uniform_padded.len() % 16 != 0 {
821            uniform_padded.push(0);
822        }
823        use wgpu::util::DeviceExt as _;
824        let uniform_buffer =
825            self.context
826                .device()
827                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
828                    label: Some("transpose-uniform"),
829                    contents: &uniform_padded,
830                    usage: wgpu::BufferUsages::UNIFORM,
831                });
832
833        // Use 16×16 workgroup — Metal limit is 256 total invocations
834        let bgl_entries = [storage_ro(0), storage_rw(1), uniform_buf(2)];
835        let (pipeline, bgl) =
836            build_pipeline(&self.context, TRANSPOSE_WGSL, &bgl_entries, "transpose")?;
837        let bind_group = self
838            .context
839            .device()
840            .create_bind_group(&wgpu::BindGroupDescriptor {
841                label: Some("transpose-bg"),
842                layout: &bgl,
843                entries: &[
844                    wgpu::BindGroupEntry {
845                        binding: 0,
846                        resource: self.buffer.as_entire_binding(),
847                    },
848                    wgpu::BindGroupEntry {
849                        binding: 1,
850                        resource: result_buf.as_entire_binding(),
851                    },
852                    wgpu::BindGroupEntry {
853                        binding: 2,
854                        resource: uniform_buffer.as_entire_binding(),
855                    },
856                ],
857            });
858
859        // Workgroup size is 16×16 tiles
860        let wg_x = (cols as u32 + 15) / 16;
861        let wg_y = (rows as u32 + 15) / 16;
862        let mut encoder =
863            self.context
864                .device()
865                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
866                    label: Some("transpose-encoder"),
867                });
868        {
869            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
870                label: Some("transpose-pass"),
871                timestamp_writes: None,
872            });
873            cpass.set_pipeline(&pipeline);
874            cpass.set_bind_group(0, &bind_group, &[]);
875            cpass.dispatch_workgroups(wg_x, wg_y, 1);
876        }
877        self.context.queue().submit(Some(encoder.finish()));
878        self.context
879            .device()
880            .poll(wgpu::PollType::wait_indefinitely())
881            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
882
883        Ok(GpuNdarray {
884            buffer: Arc::new(result_buf),
885            shape: vec![cols, rows],
886            strides: Self::compute_strides(&[cols, rows]),
887            context: Arc::clone(&self.context),
888            _phantom: PhantomData,
889        })
890    }
891
892    /// Concatenate along axis=0 via `copy_buffer_to_buffer` (no shader).
893    fn dispatch_concatenate_axis0(
894        arrays: &[&GpuNdarray<f32>],
895    ) -> Result<GpuNdarray<f32>, GpuError> {
896        if arrays.is_empty() {
897            return Err(GpuError::InvalidParameter("empty array list".into()));
898        }
899        // Validate trailing dimensions match
900        let trailing = &arrays[0].shape[1..];
901        for arr in arrays.iter().skip(1) {
902            if arr.shape[1..] != *trailing {
903                return Err(GpuError::InvalidParameter(
904                    "concatenate axis=0: trailing dimensions must match".into(),
905                ));
906            }
907        }
908        let ctx = Arc::clone(&arrays[0].context);
909        let trailing_elems: usize = trailing.iter().product::<usize>().max(1);
910
911        let total_rows: usize = arrays.iter().map(|a| a.shape[0]).sum();
912        let total_elems = total_rows * trailing_elems;
913        let total_bytes = (total_elems * 4) as u64;
914
915        let result_buf = ctx.device().create_buffer(&wgpu::BufferDescriptor {
916            label: Some("concat-result"),
917            size: total_bytes,
918            usage: wgpu::BufferUsages::STORAGE
919                | wgpu::BufferUsages::COPY_SRC
920                | wgpu::BufferUsages::COPY_DST,
921            mapped_at_creation: false,
922        });
923
924        let mut encoder = ctx
925            .device()
926            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
927                label: Some("concat-encoder"),
928            });
929        let mut offset: u64 = 0;
930        for arr in arrays {
931            let arr_bytes = (arr.numel() * 4) as u64;
932            encoder.copy_buffer_to_buffer(&arr.buffer, 0, &result_buf, offset, arr_bytes);
933            offset += arr_bytes;
934        }
935        ctx.queue().submit(Some(encoder.finish()));
936        ctx.device()
937            .poll(wgpu::PollType::wait_indefinitely())
938            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
939
940        let new_shape = {
941            let mut s = vec![total_rows];
942            s.extend_from_slice(trailing);
943            s
944        };
945        let new_strides = Self::compute_strides(&new_shape);
946        Ok(GpuNdarray {
947            buffer: Arc::new(result_buf),
948            shape: new_shape,
949            strides: new_strides,
950            context: ctx,
951            _phantom: PhantomData,
952        })
953    }
954
955    /// Concatenate two arrays along an arbitrary axis (axis > 0) using a GPU gather kernel.
956    ///
957    /// Each output element is handled by one invocation; the kernel decomposes the flat output
958    /// index into multi-dim coordinates, branches on the axis coordinate, and gathers from A or B.
959    fn dispatch_concatenate_axisn(
960        a: &GpuNdarray<f32>,
961        b: &GpuNdarray<f32>,
962        axis: usize,
963    ) -> Result<GpuNdarray<f32>, GpuError> {
964        let ndim = a.shape.len();
965        if ndim > 8 {
966            return Err(GpuError::InvalidParameter(
967                "concat_axisn: ndim must be <= 8".into(),
968            ));
969        }
970
971        // Compute output shape
972        let mut out_shape = a.shape.clone();
973        out_shape[axis] += b.shape[axis];
974
975        let out_strides = GpuNdarray::<f32>::compute_strides(&out_shape);
976        let a_strides = GpuNdarray::<f32>::compute_strides(&a.shape);
977        let b_strides = GpuNdarray::<f32>::compute_strides(&b.shape);
978
979        let total_out = out_shape.iter().product::<usize>();
980        let byte_out = (total_out * 4) as u64;
981
982        let ctx = Arc::clone(&a.context);
983        let result_buf = ctx.device().create_buffer(&wgpu::BufferDescriptor {
984            label: Some("concat-axisn-result"),
985            size: byte_out,
986            usage: wgpu::BufferUsages::STORAGE
987                | wgpu::BufferUsages::COPY_SRC
988                | wgpu::BufferUsages::COPY_DST,
989            mapped_at_creation: false,
990        });
991
992        // Pack uniform: axis (u32), dim_a (u32), ndim (u32), _pad (u32) — 16 bytes
993        let dim_a = a.shape[axis] as u32;
994        let mut unif_bytes: Vec<u8> = Vec::with_capacity(16);
995        unif_bytes.extend_from_slice(&(axis as u32).to_le_bytes());
996        unif_bytes.extend_from_slice(&dim_a.to_le_bytes());
997        unif_bytes.extend_from_slice(&(ndim as u32).to_le_bytes());
998        unif_bytes.extend_from_slice(&0u32.to_le_bytes()); // _pad
999        debug_assert_eq!(unif_bytes.len(), 16);
1000
1001        // Pack shape/strides as storage buffers (avoid WGSL uniform 16-byte-per-element alignment)
1002        // Each: array of u32, length = ndim (padded to multiple of 4 u32s for buffer sizing)
1003        let pack_u32_slice = |vals: &[usize]| -> Vec<u8> {
1004            let mut out: Vec<u8> = Vec::with_capacity(vals.len() * 4);
1005            for &v in vals {
1006                out.extend_from_slice(&(v as u32).to_le_bytes());
1007            }
1008            // Pad to 16-byte boundary
1009            while out.len() % 16 != 0 {
1010                out.extend_from_slice(&0u32.to_le_bytes());
1011            }
1012            out
1013        };
1014
1015        let out_shape_bytes = pack_u32_slice(&out_shape);
1016        let out_strides_bytes = pack_u32_slice(&out_strides);
1017        let a_strides_bytes = pack_u32_slice(&a_strides);
1018        let b_strides_bytes = pack_u32_slice(&b_strides);
1019
1020        use wgpu::util::DeviceExt as _;
1021        let make_storage_buf = |bytes: &[u8], label: &str| -> wgpu::Buffer {
1022            ctx.device()
1023                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1024                    label: Some(label),
1025                    contents: bytes,
1026                    usage: wgpu::BufferUsages::STORAGE,
1027                })
1028        };
1029        let unif_buf = ctx
1030            .device()
1031            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1032                label: Some("concat-axisn-uniform"),
1033                contents: &unif_bytes,
1034                usage: wgpu::BufferUsages::UNIFORM,
1035            });
1036        let out_shape_buf = make_storage_buf(&out_shape_bytes, "concat-axisn-out-shape");
1037        let out_strides_buf = make_storage_buf(&out_strides_bytes, "concat-axisn-out-strides");
1038        let a_strides_buf = make_storage_buf(&a_strides_bytes, "concat-axisn-a-strides");
1039        let b_strides_buf = make_storage_buf(&b_strides_bytes, "concat-axisn-b-strides");
1040
1041        // Bindings:
1042        // 0=a (storage_ro), 1=b (storage_ro), 2=result (storage_rw),
1043        // 3=uniform, 4=out_shape (storage_ro), 5=out_strides (storage_ro),
1044        // 6=a_strides (storage_ro), 7=b_strides (storage_ro)
1045        let bgl_entries = [
1046            storage_ro(0),
1047            storage_ro(1),
1048            storage_rw(2),
1049            uniform_buf(3),
1050            storage_ro(4),
1051            storage_ro(5),
1052            storage_ro(6),
1053            storage_ro(7),
1054        ];
1055        let (pipeline, bgl) =
1056            build_pipeline(&ctx, CONCAT_AXISN_WGSL, &bgl_entries, "concat-axisn")?;
1057
1058        let bind_group = ctx.device().create_bind_group(&wgpu::BindGroupDescriptor {
1059            label: Some("concat-axisn-bg"),
1060            layout: &bgl,
1061            entries: &[
1062                wgpu::BindGroupEntry {
1063                    binding: 0,
1064                    resource: a.buffer.as_entire_binding(),
1065                },
1066                wgpu::BindGroupEntry {
1067                    binding: 1,
1068                    resource: b.buffer.as_entire_binding(),
1069                },
1070                wgpu::BindGroupEntry {
1071                    binding: 2,
1072                    resource: result_buf.as_entire_binding(),
1073                },
1074                wgpu::BindGroupEntry {
1075                    binding: 3,
1076                    resource: unif_buf.as_entire_binding(),
1077                },
1078                wgpu::BindGroupEntry {
1079                    binding: 4,
1080                    resource: out_shape_buf.as_entire_binding(),
1081                },
1082                wgpu::BindGroupEntry {
1083                    binding: 5,
1084                    resource: out_strides_buf.as_entire_binding(),
1085                },
1086                wgpu::BindGroupEntry {
1087                    binding: 6,
1088                    resource: a_strides_buf.as_entire_binding(),
1089                },
1090                wgpu::BindGroupEntry {
1091                    binding: 7,
1092                    resource: b_strides_buf.as_entire_binding(),
1093                },
1094            ],
1095        });
1096
1097        let workgroups = (total_out as u32 + 255) / 256;
1098        let mut encoder = ctx
1099            .device()
1100            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1101                label: Some("concat-axisn-encoder"),
1102            });
1103        {
1104            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1105                label: Some("concat-axisn-pass"),
1106                timestamp_writes: None,
1107            });
1108            cpass.set_pipeline(&pipeline);
1109            cpass.set_bind_group(0, &bind_group, &[]);
1110            cpass.dispatch_workgroups(workgroups, 1, 1);
1111        }
1112        ctx.queue().submit(Some(encoder.finish()));
1113        ctx.device()
1114            .poll(wgpu::PollType::wait_indefinitely())
1115            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
1116
1117        let new_strides = GpuNdarray::<f32>::compute_strides(&out_shape);
1118        Ok(GpuNdarray {
1119            buffer: Arc::new(result_buf),
1120            shape: out_shape,
1121            strides: new_strides,
1122            context: ctx,
1123            _phantom: PhantomData,
1124        })
1125    }
1126
1127    /// Reduce one axis via GPU: output[out_idx] = sum over j in 0..shape[axis] of input[...j...].
1128    ///
1129    /// Each invocation handles one output element.  Shape/strides are passed as
1130    /// `storage_ro` buffers to avoid WGSL uniform 16-byte-per-element alignment.
1131    fn dispatch_sum_axis(&self, axis: usize) -> Result<GpuNdarray<f32>, GpuError> {
1132        let ndim = self.shape.len();
1133        if ndim > 8 {
1134            return Err(GpuError::InvalidParameter(
1135                "sum_axis: ndim must be <= 8".into(),
1136            ));
1137        }
1138
1139        let axis_size = self.shape[axis];
1140
1141        // Output shape: remove `axis` dimension
1142        let out_shape: Vec<usize> = self
1143            .shape
1144            .iter()
1145            .enumerate()
1146            .filter(|&(i, _)| i != axis)
1147            .map(|(_, &d)| d)
1148            .collect();
1149        let out_strides = GpuNdarray::<f32>::compute_strides(&out_shape);
1150        let in_strides = &self.strides;
1151
1152        let total_out = out_shape.iter().product::<usize>().max(1);
1153        let byte_out = (total_out * 4) as u64;
1154
1155        let result_buf = self
1156            .context
1157            .device()
1158            .create_buffer(&wgpu::BufferDescriptor {
1159                label: Some("sum-axis-result"),
1160                size: byte_out,
1161                usage: wgpu::BufferUsages::STORAGE
1162                    | wgpu::BufferUsages::COPY_SRC
1163                    | wgpu::BufferUsages::COPY_DST,
1164                mapped_at_creation: false,
1165            });
1166
1167        // Uniform: axis (u32), axis_size (u32), ndim (u32), in_axis_stride (u32) — 16 bytes
1168        let in_axis_stride = self.strides[axis] as u32;
1169        let mut unif_bytes: Vec<u8> = Vec::with_capacity(16);
1170        unif_bytes.extend_from_slice(&(axis as u32).to_le_bytes());
1171        unif_bytes.extend_from_slice(&(axis_size as u32).to_le_bytes());
1172        unif_bytes.extend_from_slice(&(ndim as u32).to_le_bytes());
1173        unif_bytes.extend_from_slice(&in_axis_stride.to_le_bytes());
1174        debug_assert_eq!(unif_bytes.len(), 16);
1175
1176        let pack_u32_slice = |vals: &[usize]| -> Vec<u8> {
1177            let mut out: Vec<u8> = Vec::with_capacity(vals.len() * 4);
1178            for &v in vals {
1179                out.extend_from_slice(&(v as u32).to_le_bytes());
1180            }
1181            while out.len() % 16 != 0 {
1182                out.extend_from_slice(&0u32.to_le_bytes());
1183            }
1184            out
1185        };
1186
1187        let in_shape_bytes = pack_u32_slice(&self.shape);
1188        let in_strides_bytes = pack_u32_slice(in_strides);
1189        let out_shape_bytes = pack_u32_slice(&out_shape);
1190        let out_strides_bytes = pack_u32_slice(&out_strides);
1191
1192        use wgpu::util::DeviceExt as _;
1193        let unif_buf =
1194            self.context
1195                .device()
1196                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1197                    label: Some("sum-axis-uniform"),
1198                    contents: &unif_bytes,
1199                    usage: wgpu::BufferUsages::UNIFORM,
1200                });
1201        let make_storage_buf = |bytes: &[u8], label: &str| -> wgpu::Buffer {
1202            self.context
1203                .device()
1204                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1205                    label: Some(label),
1206                    contents: bytes,
1207                    usage: wgpu::BufferUsages::STORAGE,
1208                })
1209        };
1210        let in_shape_buf = make_storage_buf(&in_shape_bytes, "sum-axis-in-shape");
1211        let in_strides_buf = make_storage_buf(&in_strides_bytes, "sum-axis-in-strides");
1212        let out_shape_buf = make_storage_buf(&out_shape_bytes, "sum-axis-out-shape");
1213        let out_strides_buf = make_storage_buf(&out_strides_bytes, "sum-axis-out-strides");
1214
1215        // Bindings:
1216        // 0=input (storage_ro), 1=result (storage_rw), 2=uniform,
1217        // 3=in_shape (storage_ro), 4=in_strides (storage_ro),
1218        // 5=out_shape (storage_ro), 6=out_strides (storage_ro)
1219        let bgl_entries = [
1220            storage_ro(0),
1221            storage_rw(1),
1222            uniform_buf(2),
1223            storage_ro(3),
1224            storage_ro(4),
1225            storage_ro(5),
1226            storage_ro(6),
1227        ];
1228        let (pipeline, bgl) = build_pipeline(
1229            &self.context,
1230            REDUCE_SUM_AXIS_WGSL,
1231            &bgl_entries,
1232            "sum-axis",
1233        )?;
1234
1235        let bind_group = self
1236            .context
1237            .device()
1238            .create_bind_group(&wgpu::BindGroupDescriptor {
1239                label: Some("sum-axis-bg"),
1240                layout: &bgl,
1241                entries: &[
1242                    wgpu::BindGroupEntry {
1243                        binding: 0,
1244                        resource: self.buffer.as_entire_binding(),
1245                    },
1246                    wgpu::BindGroupEntry {
1247                        binding: 1,
1248                        resource: result_buf.as_entire_binding(),
1249                    },
1250                    wgpu::BindGroupEntry {
1251                        binding: 2,
1252                        resource: unif_buf.as_entire_binding(),
1253                    },
1254                    wgpu::BindGroupEntry {
1255                        binding: 3,
1256                        resource: in_shape_buf.as_entire_binding(),
1257                    },
1258                    wgpu::BindGroupEntry {
1259                        binding: 4,
1260                        resource: in_strides_buf.as_entire_binding(),
1261                    },
1262                    wgpu::BindGroupEntry {
1263                        binding: 5,
1264                        resource: out_shape_buf.as_entire_binding(),
1265                    },
1266                    wgpu::BindGroupEntry {
1267                        binding: 6,
1268                        resource: out_strides_buf.as_entire_binding(),
1269                    },
1270                ],
1271            });
1272
1273        let workgroups = (total_out as u32 + 255) / 256;
1274        let mut encoder =
1275            self.context
1276                .device()
1277                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1278                    label: Some("sum-axis-encoder"),
1279                });
1280        {
1281            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1282                label: Some("sum-axis-pass"),
1283                timestamp_writes: None,
1284            });
1285            cpass.set_pipeline(&pipeline);
1286            cpass.set_bind_group(0, &bind_group, &[]);
1287            cpass.dispatch_workgroups(workgroups, 1, 1);
1288        }
1289        self.context.queue().submit(Some(encoder.finish()));
1290        self.context
1291            .device()
1292            .poll(wgpu::PollType::wait_indefinitely())
1293            .map_err(|e| GpuError::Other(format!("poll error: {e:?}")))?;
1294
1295        let new_strides = GpuNdarray::<f32>::compute_strides(&out_shape);
1296        Ok(GpuNdarray {
1297            buffer: Arc::new(result_buf),
1298            shape: out_shape,
1299            strides: new_strides,
1300            context: Arc::clone(&self.context),
1301            _phantom: PhantomData,
1302        })
1303    }
1304
1305    /// Zero-copy reshape: clone `Arc<Buffer>`, new shape/strides.
1306    fn dispatch_reshape(&self, new_shape: Vec<usize>) -> Result<GpuNdarray<f32>, GpuError> {
1307        let new_numel: usize = new_shape.iter().product();
1308        if new_numel != self.numel() {
1309            return Err(GpuError::InvalidParameter(format!(
1310                "reshape: element count mismatch: {} vs {}",
1311                self.numel(),
1312                new_numel
1313            )));
1314        }
1315        let new_strides = Self::compute_strides(&new_shape);
1316        Ok(GpuNdarray {
1317            buffer: Arc::clone(&self.buffer),
1318            shape: new_shape,
1319            strides: new_strides,
1320            context: Arc::clone(&self.context),
1321            _phantom: PhantomData,
1322        })
1323    }
1324
1325    /// CPU fallback: download, run ndarray op, re-upload.
1326    fn cpu_fallback_unary<F>(&self, f: F) -> Result<GpuNdarray<f32>, GpuError>
1327    where
1328        F: FnOnce(ndarray::ArrayD<f32>) -> Result<ndarray::ArrayD<f32>, GpuError>,
1329    {
1330        let arr = self.to_ndarray()?;
1331        let result = f(arr)?;
1332        let shape = result.shape().to_vec();
1333        let flat: Vec<f32> = result.into_iter().collect();
1334        Self::from_ndarray_data(&flat, shape, Arc::clone(&self.context))
1335    }
1336}
1337
1338// ──────────────────────────────────────────────────────────────────────
1339// 5. ArrayProtocol impl
1340// ──────────────────────────────────────────────────────────────────────
1341
1342impl ArrayProtocol for GpuNdarray<f32> {
1343    fn array_function(
1344        &self,
1345        func: &ArrayFunction,
1346        _types: &[TypeId],
1347        args: &[Box<dyn Any>],
1348        kwargs: &HashMap<String, Box<dyn Any>>,
1349    ) -> Result<Box<dyn Any>, NotImplemented> {
1350        // Helper: extract GpuNdarray<f32> from args[idx]
1351        macro_rules! gpu_arg {
1352            ($idx:expr) => {{
1353                let boxed_ap = args[$idx]
1354                    .downcast_ref::<Box<dyn ArrayProtocol>>()
1355                    .ok_or(NotImplemented)?;
1356                boxed_ap
1357                    .as_any()
1358                    .downcast_ref::<GpuNdarray<f32>>()
1359                    .ok_or(NotImplemented)?
1360            }};
1361        }
1362
1363        // Helper: fall back to CPU if below threshold or GPU unavailable
1364        let n = self.numel();
1365        let use_gpu = n >= GPU_THRESHOLD && is_gpu_available();
1366
1367        match func.name {
1368            // ── elementwise add ─────────────────────────────────────────
1369            "scirs2::array_protocol::operations::add" => {
1370                let a = gpu_arg!(0);
1371                let b = gpu_arg!(1);
1372                if use_gpu {
1373                    // OP_ID 0 = add
1374                    let result = a
1375                        .dispatch_elementwise_binary(b, 0)
1376                        .map_err(|_| NotImplemented)?;
1377                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1378                } else {
1379                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1380                    let rb = b.to_ndarray().map_err(|_| NotImplemented)?;
1381                    let rc = ra + rb;
1382                    let flat: Vec<f32> = rc.into_iter().collect();
1383                    let result = GpuNdarray::<f32>::from_ndarray_data(
1384                        &flat,
1385                        a.shape.clone(),
1386                        Arc::clone(&a.context),
1387                    )
1388                    .map_err(|_| NotImplemented)?;
1389                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1390                }
1391            }
1392
1393            // ── elementwise subtract ─────────────────────────────────────
1394            "scirs2::array_protocol::operations::subtract" => {
1395                let a = gpu_arg!(0);
1396                let b = gpu_arg!(1);
1397                if use_gpu {
1398                    // OP_ID 1 = subtract
1399                    let result = a
1400                        .dispatch_elementwise_binary(b, 1)
1401                        .map_err(|_| NotImplemented)?;
1402                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1403                } else {
1404                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1405                    let rb = b.to_ndarray().map_err(|_| NotImplemented)?;
1406                    let rc = ra - rb;
1407                    let flat: Vec<f32> = rc.into_iter().collect();
1408                    let result = GpuNdarray::<f32>::from_ndarray_data(
1409                        &flat,
1410                        a.shape.clone(),
1411                        Arc::clone(&a.context),
1412                    )
1413                    .map_err(|_| NotImplemented)?;
1414                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1415                }
1416            }
1417
1418            // ── elementwise multiply ─────────────────────────────────────
1419            "scirs2::array_protocol::operations::multiply" => {
1420                let a = gpu_arg!(0);
1421                let b = gpu_arg!(1);
1422                if use_gpu {
1423                    // OP_ID 2 = multiply
1424                    let result = a
1425                        .dispatch_elementwise_binary(b, 2)
1426                        .map_err(|_| NotImplemented)?;
1427                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1428                } else {
1429                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1430                    let rb = b.to_ndarray().map_err(|_| NotImplemented)?;
1431                    let rc = ra * rb;
1432                    let flat: Vec<f32> = rc.into_iter().collect();
1433                    let result = GpuNdarray::<f32>::from_ndarray_data(
1434                        &flat,
1435                        a.shape.clone(),
1436                        Arc::clone(&a.context),
1437                    )
1438                    .map_err(|_| NotImplemented)?;
1439                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1440                }
1441            }
1442
1443            // ── multiply_by_scalar_f32 ────────────────────────────────────
1444            "scirs2::array_protocol::operations::multiply_by_scalar_f32" => {
1445                let a = gpu_arg!(0);
1446                // scalar value is stored as kwarg with key = scalar.to_string()
1447                let scalar = kwargs
1448                    .values()
1449                    .find_map(|v| v.downcast_ref::<f32>().copied())
1450                    .ok_or(NotImplemented)?;
1451                if use_gpu {
1452                    let result = a
1453                        .dispatch_scalar_multiply(scalar)
1454                        .map_err(|_| NotImplemented)?;
1455                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1456                } else {
1457                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1458                    let rc = ra * scalar;
1459                    let flat: Vec<f32> = rc.into_iter().collect();
1460                    let result = GpuNdarray::<f32>::from_ndarray_data(
1461                        &flat,
1462                        a.shape.clone(),
1463                        Arc::clone(&a.context),
1464                    )
1465                    .map_err(|_| NotImplemented)?;
1466                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1467                }
1468            }
1469
1470            // ── multiply_by_scalar_f64 — convert scalar to f32 ────────────
1471            "scirs2::array_protocol::operations::multiply_by_scalar_f64" => {
1472                let a = gpu_arg!(0);
1473                let scalar = kwargs
1474                    .values()
1475                    .find_map(|v| v.downcast_ref::<f64>().copied())
1476                    .ok_or(NotImplemented)? as f32;
1477                if use_gpu {
1478                    let result = a
1479                        .dispatch_scalar_multiply(scalar)
1480                        .map_err(|_| NotImplemented)?;
1481                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1482                } else {
1483                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1484                    let rc = ra * scalar;
1485                    let flat: Vec<f32> = rc.into_iter().collect();
1486                    let result = GpuNdarray::<f32>::from_ndarray_data(
1487                        &flat,
1488                        a.shape.clone(),
1489                        Arc::clone(&a.context),
1490                    )
1491                    .map_err(|_| NotImplemented)?;
1492                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1493                }
1494            }
1495
1496            // ── divide_by_scalar_f64 — convert to f32 scalar multiply ─────
1497            "scirs2::array_protocol::operations::divide_by_scalar_f64" => {
1498                let a = gpu_arg!(0);
1499                let scalar = kwargs
1500                    .values()
1501                    .find_map(|v| v.downcast_ref::<f64>().copied())
1502                    .ok_or(NotImplemented)?;
1503                if scalar == 0.0 {
1504                    return Err(NotImplemented);
1505                }
1506                let inv = (1.0 / scalar) as f32;
1507                if use_gpu {
1508                    let result = a
1509                        .dispatch_scalar_multiply(inv)
1510                        .map_err(|_| NotImplemented)?;
1511                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1512                } else {
1513                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1514                    let rc = ra * inv;
1515                    let flat: Vec<f32> = rc.into_iter().collect();
1516                    let result = GpuNdarray::<f32>::from_ndarray_data(
1517                        &flat,
1518                        a.shape.clone(),
1519                        Arc::clone(&a.context),
1520                    )
1521                    .map_err(|_| NotImplemented)?;
1522                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1523                }
1524            }
1525
1526            // ── matmul ─────────────────────────────────────────────────────
1527            "scirs2::array_protocol::operations::matmul" => {
1528                let a = gpu_arg!(0);
1529                let b = gpu_arg!(1);
1530                if use_gpu {
1531                    let result = a.dispatch_matmul(b).map_err(|_| NotImplemented)?;
1532                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1533                } else {
1534                    // CPU fallback via ndarray matmul
1535                    let ra = a.to_ndarray().map_err(|_| NotImplemented)?;
1536                    let rb = b.to_ndarray().map_err(|_| NotImplemented)?;
1537                    if ra.ndim() != 2 || rb.ndim() != 2 {
1538                        return Err(NotImplemented);
1539                    }
1540                    let ra2 = ra
1541                        .into_dimensionality::<ndarray::Ix2>()
1542                        .map_err(|_| NotImplemented)?;
1543                    let rb2 = rb
1544                        .into_dimensionality::<ndarray::Ix2>()
1545                        .map_err(|_| NotImplemented)?;
1546                    let rc = ra2.dot(&rb2);
1547                    let new_shape = vec![rc.nrows(), rc.ncols()];
1548                    let flat: Vec<f32> = rc.into_iter().collect();
1549                    let result = GpuNdarray::<f32>::from_ndarray_data(
1550                        &flat,
1551                        new_shape,
1552                        Arc::clone(&a.context),
1553                    )
1554                    .map_err(|_| NotImplemented)?;
1555                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1556                }
1557            }
1558
1559            // ── sum ─────────────────────────────────────────────────────────
1560            "scirs2::array_protocol::operations::sum" => {
1561                let a = gpu_arg!(0);
1562                let axis = kwargs
1563                    .get("axis")
1564                    .and_then(|v| v.downcast_ref::<usize>().copied());
1565
1566                match axis {
1567                    None => {
1568                        // Full reduction — return scalar
1569                        if use_gpu {
1570                            let total = a.dispatch_sum_all().map_err(|_| NotImplemented)?;
1571                            Ok(Box::new(total) as Box<dyn Any>)
1572                        } else {
1573                            let arr = a.to_ndarray().map_err(|_| NotImplemented)?;
1574                            let total: f32 = arr.sum();
1575                            Ok(Box::new(total) as Box<dyn Any>)
1576                        }
1577                    }
1578                    Some(ax) => {
1579                        // GPU axis reduction when above threshold; otherwise CPU fallback
1580                        let try_gpu = use_gpu && ax < a.shape.len();
1581                        if try_gpu {
1582                            match a.dispatch_sum_axis(ax) {
1583                                Ok(result) => {
1584                                    return Ok(
1585                                        Box::new(Box::new(result) as Box<dyn ArrayProtocol>)
1586                                            as Box<dyn Any>,
1587                                    );
1588                                }
1589                                Err(_) => {
1590                                    // fall through to CPU
1591                                }
1592                            }
1593                        }
1594                        let arr = a.to_ndarray().map_err(|_| NotImplemented)?;
1595                        let reduced = arr.sum_axis(ndarray::Axis(ax));
1596                        let new_shape = reduced.shape().to_vec();
1597                        let flat: Vec<f32> = reduced.into_iter().collect();
1598                        let result = GpuNdarray::<f32>::from_ndarray_data(
1599                            &flat,
1600                            new_shape,
1601                            Arc::clone(&a.context),
1602                        )
1603                        .map_err(|_| NotImplemented)?;
1604                        Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1605                    }
1606                }
1607            }
1608
1609            // ── transpose ──────────────────────────────────────────────────
1610            "scirs2::array_protocol::operations::transpose" => {
1611                let a = gpu_arg!(0);
1612                if use_gpu && a.shape.len() == 2 {
1613                    let result = a.dispatch_transpose_2d().map_err(|_| NotImplemented)?;
1614                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1615                } else {
1616                    // CPU fallback for non-2D or below threshold
1617                    let arr = a.to_ndarray().map_err(|_| NotImplemented)?;
1618                    let transposed = arr.t().to_owned();
1619                    let new_shape = transposed.shape().to_vec();
1620                    let flat: Vec<f32> = transposed.into_iter().collect();
1621                    let result = GpuNdarray::<f32>::from_ndarray_data(
1622                        &flat,
1623                        new_shape,
1624                        Arc::clone(&a.context),
1625                    )
1626                    .map_err(|_| NotImplemented)?;
1627                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1628                }
1629            }
1630
1631            // ── concatenate ─────────────────────────────────────────────────
1632            "scirs2::array_protocol::operations::concatenate" => {
1633                // axis is kwarg key = axis_value.to_string()
1634                let axis = kwargs
1635                    .values()
1636                    .find_map(|v| v.downcast_ref::<usize>().copied())
1637                    .unwrap_or(0);
1638
1639                let gpu_arrays: Vec<&GpuNdarray<f32>> = args
1640                    .iter()
1641                    .filter_map(|arg| {
1642                        arg.downcast_ref::<Box<dyn ArrayProtocol>>()
1643                            .and_then(|ap| ap.as_any().downcast_ref::<GpuNdarray<f32>>())
1644                    })
1645                    .collect();
1646
1647                if gpu_arrays.is_empty() {
1648                    return Err(NotImplemented);
1649                }
1650
1651                // Helper: CPU fallback for concatenate
1652                let cpu_concat_fallback = |gpu_arrays: &[&GpuNdarray<f32>],
1653                                           axis: usize|
1654                 -> Result<Box<dyn Any>, NotImplemented> {
1655                    let arrs: Vec<ndarray::ArrayD<f32>> = gpu_arrays
1656                        .iter()
1657                        .map(|g| g.to_ndarray())
1658                        .collect::<Result<Vec<_>, _>>()
1659                        .map_err(|_| NotImplemented)?;
1660                    let views: Vec<ndarray::ArrayViewD<f32>> =
1661                        arrs.iter().map(|a| a.view()).collect();
1662                    let concatenated = ndarray::concatenate(ndarray::Axis(axis), &views)
1663                        .map_err(|_| NotImplemented)?;
1664                    let ctx = Arc::clone(&gpu_arrays[0].context);
1665                    let new_shape = concatenated.shape().to_vec();
1666                    let flat: Vec<f32> = concatenated.into_iter().collect();
1667                    let result = GpuNdarray::<f32>::from_ndarray_data(&flat, new_shape, ctx)
1668                        .map_err(|_| NotImplemented)?;
1669                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1670                };
1671
1672                if axis == 0 && use_gpu {
1673                    let result = GpuNdarray::<f32>::dispatch_concatenate_axis0(&gpu_arrays)
1674                        .map_err(|_| NotImplemented)?;
1675                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1676                } else if axis > 0
1677                    && use_gpu
1678                    && gpu_arrays.len() >= 2
1679                    && gpu_arrays[0].shape.len() <= 8
1680                    && gpu_arrays[0].shape.iter().product::<usize>() >= GPU_THRESHOLD
1681                {
1682                    // GPU gather kernel for axis > 0; fold pair-wise for >2 arrays
1683                    let mut acc = gpu_arrays[0].clone();
1684                    let mut gpu_failed = false;
1685                    for next in gpu_arrays.iter().skip(1) {
1686                        match GpuNdarray::<f32>::dispatch_concatenate_axisn(&acc, next, axis) {
1687                            Ok(r) => acc = r,
1688                            Err(_) => {
1689                                gpu_failed = true;
1690                                break;
1691                            }
1692                        }
1693                    }
1694                    if gpu_failed {
1695                        cpu_concat_fallback(&gpu_arrays, axis)
1696                    } else {
1697                        Ok(Box::new(Box::new(acc) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1698                    }
1699                } else {
1700                    // CPU fallback
1701                    cpu_concat_fallback(&gpu_arrays, axis)
1702                }
1703            }
1704
1705            // ── reshape — zero-copy ─────────────────────────────────────────
1706            "scirs2::array_protocol::operations::reshape" => {
1707                let a = gpu_arg!(0);
1708                let new_shape = kwargs
1709                    .get("shape")
1710                    .and_then(|v| v.downcast_ref::<Vec<usize>>().cloned())
1711                    .ok_or(NotImplemented)?;
1712                let result = a.dispatch_reshape(new_shape).map_err(|_| NotImplemented)?;
1713                Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1714            }
1715
1716            // ── svd — CPU fallback (download, run a real decomposition,
1717            // re-upload) ─────────────────────────────────────────────────
1718            "scirs2::array_protocol::operations::svd" => {
1719                let a = gpu_arg!(0);
1720                let arr = a.to_ndarray().map_err(|_| NotImplemented)?;
1721                if arr.ndim() != 2 {
1722                    return Err(NotImplemented);
1723                }
1724                let arr2 = arr
1725                    .into_dimensionality::<ndarray::Ix2>()
1726                    .map_err(|_| NotImplemented)?;
1727                let ctx = Arc::clone(&a.context);
1728
1729                #[cfg(feature = "linalg")]
1730                {
1731                    let svd_result =
1732                        crate::linalg::svd_ndarray(&arr2).map_err(|_| NotImplemented)?;
1733                    let (u_rows, u_cols) = svd_result.u.dim();
1734                    let (vt_rows, vt_cols) = svd_result.vt.dim();
1735                    let s_len = svd_result.s.len();
1736                    let u_data: Vec<f32> = svd_result.u.into_iter().collect();
1737                    let s_data: Vec<f32> = svd_result.s.into_iter().collect();
1738                    let vt_data: Vec<f32> = svd_result.vt.into_iter().collect();
1739
1740                    let u_gpu = GpuNdarray::<f32>::from_ndarray_data(
1741                        &u_data,
1742                        vec![u_rows, u_cols],
1743                        Arc::clone(&ctx),
1744                    )
1745                    .map_err(|_| NotImplemented)?;
1746                    let s_gpu = GpuNdarray::<f32>::from_ndarray_data(
1747                        &s_data,
1748                        vec![s_len],
1749                        Arc::clone(&ctx),
1750                    )
1751                    .map_err(|_| NotImplemented)?;
1752                    let vt_gpu =
1753                        GpuNdarray::<f32>::from_ndarray_data(&vt_data, vec![vt_rows, vt_cols], ctx)
1754                            .map_err(|_| NotImplemented)?;
1755
1756                    Ok(Box::new((
1757                        Box::new(u_gpu) as Box<dyn ArrayProtocol>,
1758                        Box::new(s_gpu) as Box<dyn ArrayProtocol>,
1759                        Box::new(vt_gpu) as Box<dyn ArrayProtocol>,
1760                    )) as Box<dyn Any>)
1761                }
1762                #[cfg(not(feature = "linalg"))]
1763                {
1764                    // Without the `linalg` feature there is no in-crate real
1765                    // decomposition available — an honest `NotImplemented`
1766                    // rather than a fabricated identity/ones placeholder.
1767                    let _ = (arr2, ctx);
1768                    Err(NotImplemented)
1769                }
1770            }
1771
1772            // ── inverse — CPU fallback (download, invert for real,
1773            // re-upload) ─────────────────────────────────────────────────
1774            "scirs2::array_protocol::operations::inverse" => {
1775                let a = gpu_arg!(0);
1776                let arr = a.to_ndarray().map_err(|_| NotImplemented)?;
1777                if arr.ndim() != 2 || arr.shape()[0] != arr.shape()[1] {
1778                    return Err(NotImplemented);
1779                }
1780                let m = arr.shape()[0];
1781                let arr2 = arr
1782                    .into_dimensionality::<ndarray::Ix2>()
1783                    .map_err(|_| NotImplemented)?;
1784                let ctx = Arc::clone(&a.context);
1785
1786                #[cfg(feature = "linalg")]
1787                {
1788                    let inv = crate::linalg::inv_ndarray(&arr2).map_err(|_| NotImplemented)?;
1789                    let inv_data: Vec<f32> = inv.into_iter().collect();
1790                    let result = GpuNdarray::<f32>::from_ndarray_data(&inv_data, vec![m, m], ctx)
1791                        .map_err(|_| NotImplemented)?;
1792                    Ok(Box::new(Box::new(result) as Box<dyn ArrayProtocol>) as Box<dyn Any>)
1793                }
1794                #[cfg(not(feature = "linalg"))]
1795                {
1796                    // Without the `linalg` feature there is no in-crate real
1797                    // matrix inversion available — an honest `NotImplemented`
1798                    // rather than a fabricated identity placeholder.
1799                    let _ = (arr2, ctx, m);
1800                    Err(NotImplemented)
1801                }
1802            }
1803
1804            _ => Err(NotImplemented),
1805        }
1806    }
1807
1808    fn as_any(&self) -> &dyn Any {
1809        self
1810    }
1811
1812    fn shape(&self) -> &[usize] {
1813        &self.shape
1814    }
1815
1816    fn dtype(&self) -> TypeId {
1817        TypeId::of::<f32>()
1818    }
1819
1820    fn box_clone(&self) -> Box<dyn ArrayProtocol> {
1821        Box::new(self.clone())
1822    }
1823}
1824
1825// ──────────────────────────────────────────────────────────────────────
1826// 6. GPUArray trait impl
1827// ──────────────────────────────────────────────────────────────────────
1828
1829impl GPUArray for GpuNdarray<f32> {
1830    fn to_gpu(&self) -> CoreResult<Box<dyn GPUArray>> {
1831        // Already on GPU; cheap clone
1832        Ok(Box::new(self.clone()))
1833    }
1834
1835    fn to_cpu(&self) -> CoreResult<Box<dyn ArrayProtocol>> {
1836        let arr = self.to_ndarray().map_err(|e| {
1837            CoreError::ComputationError(ErrorContext::new(format!("GPU→CPU readback: {e}")))
1838        })?;
1839        Ok(Box::new(NdarrayWrapper::new(arr)))
1840    }
1841
1842    fn is_on_gpu(&self) -> bool {
1843        true
1844    }
1845
1846    fn device_info(&self) -> HashMap<String, String> {
1847        let mut info = HashMap::new();
1848        info.insert("backend".to_string(), "wgpu".to_string());
1849        info.insert("dtype".to_string(), "f32".to_string());
1850        info.insert("shape".to_string(), format!("{:?}", self.shape));
1851        info
1852    }
1853}
1854
1855// ──────────────────────────────────────────────────────────────────────
1856// 7. WGSL kernels — see gpu_ndarray_shaders.rs
1857// ──────────────────────────────────────────────────────────────────────
1858use super::gpu_ndarray_shaders::{
1859    CONCAT_AXISN_WGSL, ELEMENTWISE_ADD_WGSL, ELEMENTWISE_MUL_WGSL, ELEMENTWISE_SUB_WGSL,
1860    MATMUL_WGSL, REDUCE_SUM_AXIS_WGSL, SCALAR_MUL_WGSL, SUM_REDUCE_WGSL, TRANSPOSE_WGSL,
1861};
1862
1863// ──────────────────────────────────────────────────────────────────────
1864// 8. Tests — value-correctness for the svd/inverse CPU-fallback dispatch
1865// (split out to keep gpu_ndarray.rs under the workspace's 2000-line-per-file
1866// limit — see neural.rs/neural_backward_tests.rs for the same pattern).
1867// ──────────────────────────────────────────────────────────────────────
1868#[cfg(all(test, feature = "linalg"))]
1869#[path = "gpu_ndarray_dispatch_tests.rs"]
1870mod dispatch_correctness_tests;