Skip to main content

hanzo_nn/
ops.rs

1//! Tensor ops.
2//!
3
4use hanzo_ml::{CpuStorage, DType, Layout, Module, Result, Shape, Tensor, D};
5use rayon::prelude::*;
6
7/// Applies the softmax function to the input tensor, rescaling the element so that elements on
8/// a slice of fixed index on dimension `dim` are between 0 and 1 and sum to 1.
9///
10/// ```rust
11/// use hanzo_ml::{Tensor, Device, test_utils::to_vec2_round};
12/// let a = Tensor::new(&[[0f32, 1., 0., 1.], [-2., 2., 3., -3.]], &Device::Cpu)?;
13/// let a = hanzo_nn::ops::softmax(&a, 1)?;
14/// assert_eq!(
15///     to_vec2_round(&a, 4)?,
16///     &[
17///         [0.1345, 0.3655, 0.1345, 0.3655],
18///         [0.0049, 0.2671, 0.7262, 0.0018]
19///     ]);
20/// # Ok::<(), hanzo_ml::Error>(())
21/// ```
22pub fn softmax<D: hanzo_ml::shape::Dim>(xs: &Tensor, dim: D) -> Result<Tensor> {
23    let dim = dim.to_index(xs.shape(), "softmax")?;
24    let max = xs.max_keepdim(dim)?;
25    let diff = xs.broadcast_sub(&max)?;
26    let num = diff.exp()?;
27    let den = num.sum_keepdim(dim)?;
28    num.broadcast_div(&den)
29}
30
31pub fn log_softmax<D: hanzo_ml::shape::Dim>(xs: &Tensor, d: D) -> Result<Tensor> {
32    let d = d.to_index(xs.shape(), "log-softmax")?;
33    let max = xs.max_keepdim(d)?;
34    let diff = xs.broadcast_sub(&max)?;
35    let sum_exp = diff.exp()?.sum_keepdim(d)?;
36    let log_sm = diff.broadcast_sub(&sum_exp.log()?)?;
37    Ok(log_sm)
38}
39
40pub fn silu(xs: &Tensor) -> Result<Tensor> {
41    xs.silu()
42}
43
44pub fn swiglu(xs: &Tensor) -> Result<Tensor> {
45    let xs = xs.chunk(2, D::Minus1)?;
46    &xs[0].silu()? * &xs[1]
47}
48
49struct Sigmoid;
50
51impl hanzo_ml::CustomOp1 for Sigmoid {
52    fn name(&self) -> &'static str {
53        "sigmoid"
54    }
55
56    fn cpu_fwd(&self, storage: &CpuStorage, layout: &Layout) -> Result<(CpuStorage, Shape)> {
57        use hanzo_ml::backend::BackendStorage;
58
59        fn fwd<T: num_traits::Float>(v: T) -> T {
60            (v.neg().exp() + T::one()).recip()
61        }
62
63        // FIXME: using `hanzo_ml::map_dtype` causes compilation errors.
64        let storage = match storage {
65            CpuStorage::BF16(slice) => {
66                CpuStorage::BF16(hanzo_ml::cpu_backend::unary_map(slice, layout, fwd))
67            }
68            CpuStorage::F16(slice) => {
69                CpuStorage::F16(hanzo_ml::cpu_backend::unary_map(slice, layout, fwd))
70            }
71            CpuStorage::F32(slice) => {
72                CpuStorage::F32(hanzo_ml::cpu_backend::unary_map(slice, layout, fwd))
73            }
74            CpuStorage::F64(slice) => {
75                CpuStorage::F64(hanzo_ml::cpu_backend::unary_map(slice, layout, fwd))
76            }
77            _ => Err(hanzo_ml::Error::UnsupportedDTypeForOp(
78                storage.dtype(),
79                self.name(),
80            ))?,
81        };
82        Ok((storage, layout.shape().clone()))
83    }
84
85    #[cfg(feature = "cuda")]
86    fn cuda_fwd(
87        &self,
88        storage: &hanzo_ml::CudaStorage,
89        layout: &Layout,
90    ) -> Result<(hanzo_ml::CudaStorage, Shape)> {
91        use hanzo_ml::backend::BackendStorage;
92        use hanzo_ml::cuda_backend::cudarc::driver::{
93            CudaSlice, DeviceRepr, LaunchConfig, PushKernelArg, ValidAsZeroBits,
94        };
95        use hanzo_ml::cuda_backend::SlicePtrOrNull;
96        use hanzo_ml::cuda_backend::{kernel_name, kernels, Map1, WrapErr};
97        use hanzo_ml::{CudaDevice, WithDType};
98
99        struct S;
100        impl Map1 for S {
101            fn f<T: DeviceRepr + WithDType + ValidAsZeroBits>(
102                &self,
103                src: &CudaSlice<T>,
104                dev: &CudaDevice,
105                layout: &Layout,
106            ) -> Result<CudaSlice<T>> {
107                let shape = layout.shape();
108                let dims = shape.dims();
109                let el_count = shape.elem_count();
110                let cfg = LaunchConfig::for_num_elems(el_count as u32);
111                let ds = SlicePtrOrNull::params_from_layout(dev, layout)?;
112                let src = &src.slice(layout.start_offset()..);
113                let func = dev.get_or_load_func(&kernel_name::<T>("usigmoid"), &kernels::UNARY)?;
114                // SAFETY: Set later by running the kernel.
115                let out = unsafe { dev.alloc::<T>(el_count)? };
116
117                let mut builder = func.builder();
118                hanzo_ml::builder_arg!(builder, el_count, dims.len());
119                ds.builder_arg(&mut builder);
120                builder.arg(src);
121                builder.arg(&out);
122                // SAFETY: ffi.
123                unsafe { builder.launch(cfg) }.w()?;
124                Ok(out)
125            }
126        }
127
128        let dev = storage.device();
129        let slice = S.map(&storage.slice, dev, layout)?;
130        let dst = hanzo_ml::CudaStorage {
131            slice,
132            device: dev.clone(),
133        };
134        Ok((dst, layout.shape().clone()))
135    }
136
137    #[cfg(feature = "metal")]
138    fn metal_fwd(
139        &self,
140        storage: &hanzo_ml::MetalStorage,
141        layout: &Layout,
142    ) -> Result<(hanzo_ml::MetalStorage, Shape)> {
143        use hanzo_ml::backend::BackendStorage;
144        use hanzo_ml::MetalError;
145        let device = storage.device();
146        let dtype = storage.dtype();
147        let shape = layout.shape();
148        let el_count = shape.elem_count();
149        let buffer = device
150            .new_buffer_builder()
151            .with_size_for(el_count, dtype)
152            .with_label("sigmoid")
153            .build()?;
154        let encoder = device.command_encoder()?;
155        encoder.set_label("sigmoid");
156        let src = hanzo_metal_kernels::BufferOffset {
157            buffer: storage.buffer(),
158            offset_in_bytes: layout.start_offset() * storage.dtype().size_in_bytes(),
159        };
160
161        if layout.is_contiguous() {
162            use hanzo_metal_kernels::unary::contiguous;
163            let kernel_name = match dtype {
164                DType::F16 => contiguous::sigmoid::HALF,
165                DType::F32 => contiguous::sigmoid::FLOAT,
166                DType::BF16 => contiguous::sigmoid::BFLOAT,
167                dtype => {
168                    hanzo_ml::bail!("Metal contiguous unary sigmoid {dtype:?} not implemented")
169                }
170            };
171            hanzo_metal_kernels::call_unary_contiguous(
172                device.metal_device(),
173                &encoder,
174                device.kernels(),
175                kernel_name,
176                dtype.size_in_bytes(),
177                el_count,
178                src,
179                &buffer,
180            )
181            .map_err(MetalError::from)?;
182        } else {
183            use hanzo_metal_kernels::unary::strided;
184            let kernel_name = match dtype {
185                DType::F16 => strided::sigmoid::HALF,
186                DType::F32 => strided::sigmoid::FLOAT,
187                DType::BF16 => strided::sigmoid::BFLOAT,
188                dtype => {
189                    hanzo_ml::bail!("Metal strided unary sigmoid {dtype:?} not implemented")
190                }
191            };
192            let dst = hanzo_metal_kernels::BufferOffset::zero_offset(&buffer);
193            hanzo_metal_kernels::call_unary_strided(
194                device.metal_device(),
195                &encoder,
196                device.kernels(),
197                kernel_name,
198                layout.dims(),
199                src,
200                layout.stride(),
201                dst,
202            )
203            .map_err(MetalError::from)?;
204        }
205
206        let new_storage = hanzo_ml::MetalStorage::new(buffer, device.clone(), el_count, dtype);
207        Ok((new_storage, layout.shape().clone()))
208    }
209
210    fn bwd(&self, _arg: &Tensor, res: &Tensor, grad_res: &Tensor) -> Result<Option<Tensor>> {
211        // d/dx sigmoid(x) = (1 - sigmoid(x)) * sigmoid(x)
212        let d_dx_sigmoid = res.ones_like()?.sub(res)?.mul(res)?;
213        Ok(Some(grad_res.mul(&d_dx_sigmoid)?))
214    }
215}
216
217pub fn sigmoid(xs: &Tensor) -> Result<Tensor> {
218    xs.apply_op1(Sigmoid)
219}
220
221pub fn hard_sigmoid(xs: &Tensor) -> Result<Tensor> {
222    // TODO: Should we have a specialized op for this?
223    ((xs + 3.0)? / 6.0)?.clamp(0f32, 1f32)
224}
225
226pub fn mish(xs: &Tensor) -> Result<Tensor> {
227    xs * (1.0 + xs.exp()?)?.log()?.tanh()
228}
229
230pub fn leaky_relu(xs: &Tensor, negative_slope: f64) -> Result<Tensor> {
231    let zeros = xs.zeros_like()?;
232    xs.maximum(&zeros)? + xs.minimum(&zeros)? * negative_slope
233}
234
235pub fn selu(xs: &Tensor, alpha: f32, gamma: f32) -> Result<Tensor> {
236    let is_pos = xs.gt(0f32)?;
237    let alpha_t = Tensor::full(alpha, xs.dims(), xs.device())?;
238    let neg = xs.exp()?.mul(&alpha_t)?.sub(&alpha_t)?;
239    let selu = is_pos.where_cond(xs, &neg)?;
240    let gamma_t = Tensor::full(gamma, xs.dims(), xs.device())?;
241    selu.broadcast_mul(&gamma_t)
242}
243
244pub fn dropout(xs: &Tensor, drop_p: f32) -> Result<Tensor> {
245    // This implementation is inefficient as it stores the full mask for the backward pass.
246    // Instead we could just store the seed and have a specialized kernel that would both
247    // generate the random mask and apply it.
248    // Another easier optimization would be to be able to generate boolean mask using just a bit of
249    // entropy per element rather than generating a full float per element.
250    if !(0. ..1.).contains(&drop_p) {
251        hanzo_ml::bail!("dropout probability has to be in [0, 1), got {drop_p}")
252    }
253    let rand = Tensor::rand(0f32, 1f32, xs.shape(), xs.device())?;
254    let scale = 1.0 / (1.0 - drop_p as f64);
255    let drop_p = Tensor::new(drop_p, xs.device())?.broadcast_as(xs.shape())?;
256    let mask = (rand.ge(&drop_p)?.to_dtype(xs.dtype())? * scale)?;
257    xs * mask
258}
259
260#[derive(Clone, Debug)]
261pub struct Dropout {
262    drop_p: f32,
263}
264
265impl Dropout {
266    pub fn new(drop_p: f32) -> Dropout {
267        Self { drop_p }
268    }
269
270    pub fn forward(&self, xs: &Tensor, train: bool) -> Result<Tensor> {
271        if train {
272            dropout(xs, self.drop_p)
273        } else {
274            Ok(xs.clone())
275        }
276    }
277}
278
279impl hanzo_ml::ModuleT for Dropout {
280    fn forward_t(&self, xs: &Tensor, train: bool) -> Result<Tensor> {
281        self.forward(xs, train)
282    }
283}
284
285struct SoftmaxLastDim;
286
287impl hanzo_ml::CustomOp1 for SoftmaxLastDim {
288    fn name(&self) -> &'static str {
289        "softmax-last-dim"
290    }
291
292    fn cpu_fwd(&self, storage: &CpuStorage, layout: &Layout) -> Result<(CpuStorage, Shape)> {
293        fn softmax<T: hanzo_ml::WithDType + num_traits::Float>(
294            src: &[T],
295            layout: &Layout,
296        ) -> Result<(CpuStorage, Shape)> {
297            let src = match layout.contiguous_offsets() {
298                None => hanzo_ml::bail!("input has to be contiguous"),
299                Some((o1, o2)) => &src[o1..o2],
300            };
301            let el_count = layout.shape().elem_count();
302            let dims = layout.shape().dims();
303            let dim_m1 = dims[dims.len() - 1];
304            let mut dst = vec![T::zero(); el_count];
305            src.par_chunks(dim_m1)
306                .zip(dst.par_chunks_mut(dim_m1))
307                .for_each(|(src, dst)| {
308                    let mut max = T::neg_infinity();
309                    unsafe { T::vec_reduce_max(src.as_ptr(), &mut max, dim_m1) };
310                    for (s, d) in src.iter().zip(dst.iter_mut()) {
311                        *d = (*s - max).exp();
312                    }
313                    let mut sum_exp = T::zero();
314                    unsafe { T::vec_reduce_sum(dst.as_ptr(), &mut sum_exp, dim_m1) };
315                    for d in dst.iter_mut() {
316                        *d /= sum_exp
317                    }
318                });
319            let storage = hanzo_ml::WithDType::to_cpu_storage_owned(dst);
320            Ok((storage, Shape::from_dims(dims)))
321        }
322
323        match storage {
324            CpuStorage::BF16(slice) => softmax::<half::bf16>(slice, layout),
325            CpuStorage::F16(slice) => softmax::<half::f16>(slice, layout),
326            CpuStorage::F32(slice) => softmax::<f32>(slice, layout),
327            CpuStorage::F64(slice) => softmax::<f64>(slice, layout),
328            _ => hanzo_ml::bail!("unsupported dtype for softmax {:?}", storage),
329        }
330    }
331
332    #[cfg(feature = "vulkan")]
333    fn vulkan_fwd(
334        &self,
335        storage: &hanzo_ml::VulkanStorage,
336        layout: &Layout,
337    ) -> Result<(hanzo_ml::VulkanStorage, Shape)> {
338        let out = storage.softmax_last_dim(layout)?;
339        Ok((out, layout.shape().clone()))
340    }
341
342    #[cfg(feature = "rocm")]
343    fn rocm_fwd(
344        &self,
345        storage: &hanzo_ml::RocmStorage,
346        layout: &Layout,
347    ) -> Result<(hanzo_ml::RocmStorage, Shape)> {
348        let out = storage.softmax_last_dim(layout)?;
349        Ok((out, layout.shape().clone()))
350    }
351
352    #[cfg(feature = "cuda")]
353    fn cuda_fwd(
354        &self,
355        storage: &hanzo_ml::CudaStorage,
356        layout: &Layout,
357    ) -> Result<(hanzo_ml::CudaStorage, Shape)> {
358        use hanzo_ml::cuda_backend::cudarc::driver::{
359            CudaSlice, DeviceRepr, LaunchConfig, PushKernelArg,
360        };
361        use hanzo_ml::cuda_backend::{kernel_name, kernels, Map1, WrapErr};
362        use hanzo_ml::{CudaDevice, WithDType};
363
364        struct S;
365        impl Map1 for S {
366            fn f<T: DeviceRepr + WithDType>(
367                &self,
368                src: &CudaSlice<T>,
369                dev: &CudaDevice,
370                layout: &Layout,
371            ) -> Result<CudaSlice<T>> {
372                let src = match layout.contiguous_offsets() {
373                    None => hanzo_ml::bail!("input has to be contiguous"),
374                    Some((o1, o2)) => src.slice(o1..o2),
375                };
376                let el = layout.shape().elem_count();
377                let dims = layout.shape().dims();
378                let dim_m1 = dims[dims.len() - 1];
379                let (n_rows, n_cols) = (el / dim_m1, dim_m1);
380
381                let cfg = LaunchConfig {
382                    grid_dim: (n_rows as u32, 1, 1),
383                    block_dim: (1, 32, 1),
384                    shared_mem_bytes: 0,
385                };
386                let func = dev.get_or_load_func(&kernel_name::<T>("softmax"), &kernels::REDUCE)?;
387                // SAFETY: Set later by running the kernel.
388                let dst = unsafe { dev.alloc::<T>(el)? };
389                let mut builder = func.builder();
390                builder.arg(&src);
391                builder.arg(&dst);
392                hanzo_ml::builder_arg!(builder, n_cols as i32);
393                // SAFETY: ffi.
394                unsafe { builder.launch(cfg) }.w()?;
395                Ok(dst)
396            }
397        }
398
399        use hanzo_ml::backend::BackendStorage;
400        let dev = storage.device();
401        let slice = S.map(&storage.slice, dev, layout)?;
402        let dst = hanzo_ml::cuda_backend::CudaStorage {
403            slice,
404            device: dev.clone(),
405        };
406        Ok((dst, layout.shape().clone()))
407    }
408
409    #[cfg(feature = "metal")]
410    fn metal_fwd(
411        &self,
412        storage: &hanzo_ml::MetalStorage,
413        layout: &Layout,
414    ) -> Result<(hanzo_ml::MetalStorage, Shape)> {
415        use hanzo_ml::backend::BackendStorage;
416        let device = storage.device();
417        let encoder = device.command_encoder()?;
418        encoder.set_label("softmax");
419        let kernels = device.kernels();
420        let name = match storage.dtype() {
421            DType::F32 => "softmax_f32",
422            DType::F16 => "softmax_f16",
423            DType::BF16 => "softmax_bf16",
424            dtype => hanzo_ml::bail!("softmax-last-dim is not implemented for {dtype:?}"),
425        };
426
427        let n = layout.stride().len();
428        if !(layout.is_contiguous() && layout.stride()[n - 1] == 1) {
429            hanzo_ml::bail!("Non contiguous softmax-last-dim is not implemented");
430        }
431
432        let last_dim = layout.dims()[layout.shape().rank() - 1];
433        let elem_count = layout.shape().elem_count();
434        let output = device
435            .new_buffer_builder()
436            .with_size_for(elem_count, storage.dtype())
437            .with_label("softmax")
438            .build()?;
439        hanzo_metal_kernels::call_last_softmax(
440            device.metal_device(),
441            &encoder,
442            kernels,
443            name,
444            elem_count,
445            last_dim,
446            storage.buffer(),
447            layout.start_offset() * storage.dtype().size_in_bytes(),
448            &output,
449        )
450        .map_err(hanzo_ml::Error::wrap)?;
451        let newstorage =
452            hanzo_ml::MetalStorage::new(output, device.clone(), elem_count, storage.dtype());
453        Ok((newstorage, layout.shape().clone()))
454    }
455}
456
457pub fn softmax_last_dim(xs: &Tensor) -> Result<Tensor> {
458    // The fused rocm softmax kernel handles contiguous F16/F32/BF16; fall back to the unfused
459    // composite for anything else so we never feed the kernel a shape/dtype it can't handle.
460    if xs.device().is_rocm() {
461        let supported =
462            matches!(xs.dtype(), DType::F16 | DType::F32 | DType::BF16) && xs.is_contiguous();
463        if !supported {
464            return softmax(xs, D::Minus1);
465        }
466    }
467    xs.apply_op1_no_bwd(&SoftmaxLastDim)
468}
469
470#[derive(Debug, Clone)]
471struct RmsNorm {
472    eps: f32,
473}
474
475impl hanzo_ml::CustomOp2 for RmsNorm {
476    fn name(&self) -> &'static str {
477        "rms-norm"
478    }
479
480    fn cpu_fwd(
481        &self,
482        s1: &CpuStorage,
483        l1: &Layout,
484        s2: &CpuStorage,
485        l2: &Layout,
486    ) -> Result<(CpuStorage, Shape)> {
487        use hanzo_ml::backend::BackendStorage;
488
489        let eps = self.eps;
490        fn inner<
491            T: hanzo_ml::WithDType
492                + num_traits::Float
493                + num_traits::AsPrimitive<f32>
494                + num_traits::FromPrimitive,
495        >(
496            src: &[T],
497            layout: &Layout,
498            alpha: &[T],
499            alpha_layout: &Layout,
500            eps: f32,
501        ) -> Result<(CpuStorage, Shape)> {
502            let src = match layout.contiguous_offsets() {
503                None => hanzo_ml::bail!("input has to be contiguous"),
504                Some((o1, o2)) => &src[o1..o2],
505            };
506            let alpha = match alpha_layout.contiguous_offsets() {
507                None => hanzo_ml::bail!("alpha has to be contiguous"),
508                Some((o1, o2)) => &alpha[o1..o2],
509            };
510            let el_count = layout.shape().elem_count();
511            let dims = layout.shape().dims();
512            let dim_m1 = dims[dims.len() - 1];
513            let n_rows = el_count / dim_m1;
514            let mut dst = vec![T::zero(); el_count];
515
516            fn rms_row<
517                T: hanzo_ml::WithDType
518                    + num_traits::Float
519                    + num_traits::AsPrimitive<f32>
520                    + num_traits::FromPrimitive,
521            >(
522                src: &[T],
523                alpha: &[T],
524                n: usize,
525                eps: f32,
526                dst: &mut [T],
527            ) {
528                let sum2 = src
529                    .iter()
530                    .map(|&v| {
531                        let v = v.as_();
532                        v * v
533                    })
534                    .sum::<f32>();
535                let m = (sum2 / n as f32 + eps).sqrt();
536                let m = T::from_f32(m).unwrap_or_else(T::nan);
537                for ((d, s), alpha) in dst.iter_mut().zip(src.iter()).zip(alpha) {
538                    *d = *s / m * *alpha
539                }
540            }
541
542            if n_rows <= 32 {
543                let n = dim_m1;
544                for row in 0..n_rows {
545                    let src = &src[row * n..(row + 1) * n];
546                    let dst = &mut dst[row * n..(row + 1) * n];
547                    rms_row(src, alpha, n, eps, dst);
548                }
549            } else {
550                src.par_chunks(dim_m1)
551                    .zip(dst.par_chunks_mut(dim_m1))
552                    .for_each(|(src, dst)| {
553                        let n = src.len();
554                        rms_row(src, alpha, n, eps, dst);
555                    });
556            }
557            let storage = hanzo_ml::WithDType::to_cpu_storage_owned(dst);
558            Ok((storage, Shape::from_dims(dims)))
559        }
560
561        use CpuStorage as C;
562        match (s1, s2) {
563            (C::BF16(s1), C::BF16(s2)) => inner::<half::bf16>(s1, l1, s2, l2, eps),
564            (C::F16(s1), C::F16(s2)) => inner::<half::f16>(s1, l1, s2, l2, eps),
565            (C::F32(s1), C::F32(s2)) => inner::<f32>(s1, l1, s2, l2, eps),
566            _ => hanzo_ml::bail!("unsupported dtype for rmsnorm {:?}", s1.dtype()),
567        }
568    }
569
570    #[cfg(feature = "vulkan")]
571    fn vulkan_fwd(
572        &self,
573        s1: &hanzo_ml::VulkanStorage,
574        l1: &Layout,
575        s2: &hanzo_ml::VulkanStorage,
576        l2: &Layout,
577    ) -> Result<(hanzo_ml::VulkanStorage, Shape)> {
578        let out = s1.rms_norm(l1, s2, l2, self.eps)?;
579        Ok((out, l1.shape().clone()))
580    }
581
582    #[cfg(feature = "cuda")]
583    fn cuda_fwd(
584        &self,
585        s1: &hanzo_ml::CudaStorage,
586        l1: &Layout,
587        s2: &hanzo_ml::CudaStorage,
588        l2: &Layout,
589    ) -> Result<(hanzo_ml::CudaStorage, Shape)> {
590        use hanzo_ml::cuda_backend::cudarc::driver::{
591            CudaSlice, DeviceRepr, LaunchConfig, PushKernelArg,
592        };
593        use hanzo_ml::cuda_backend::{kernel_name, kernels, Map2, WrapErr};
594        use hanzo_ml::{CudaDevice, WithDType};
595
596        struct S {
597            eps: f32,
598        }
599        impl Map2 for S {
600            fn f<T: DeviceRepr + WithDType>(
601                &self,
602                src: &CudaSlice<T>,
603                layout: &Layout,
604                alpha: &CudaSlice<T>,
605                alpha_layout: &Layout,
606                dev: &CudaDevice,
607            ) -> Result<CudaSlice<T>> {
608                let src = match layout.contiguous_offsets() {
609                    None => hanzo_ml::bail!("input has to be contiguous"),
610                    Some((o1, o2)) => src.slice(o1..o2),
611                };
612                let alpha = match alpha_layout.contiguous_offsets() {
613                    None => hanzo_ml::bail!("alpha has to be contiguous"),
614                    Some((o1, o2)) => alpha.slice(o1..o2),
615                };
616                let el = layout.shape().elem_count();
617                let dims = layout.shape().dims();
618                let dim_m1 = dims[dims.len() - 1];
619                let (n_rows, n_cols) = (el / dim_m1, dim_m1);
620
621                let block_size = if n_cols < 1024 { 32 } else { 1024 };
622                let cfg = LaunchConfig {
623                    grid_dim: (n_rows as u32, 1, 1),
624                    block_dim: (block_size, 1, 1),
625                    shared_mem_bytes: 0,
626                };
627                let func = dev.get_or_load_func(&kernel_name::<T>("rmsnorm"), &kernels::REDUCE)?;
628                // SAFETY: Set later by running the kernel.
629                let dst = unsafe { dev.alloc::<T>(el)? };
630                let mut builder = func.builder();
631                builder.arg(&src);
632                builder.arg(&dst);
633                builder.arg(&alpha);
634                hanzo_ml::builder_arg!(builder, n_cols as i32, block_size as i32, self.eps);
635                // SAFETY: ffi.
636                unsafe { builder.launch(cfg) }.w()?;
637                Ok(dst)
638            }
639        }
640
641        use hanzo_ml::backend::BackendStorage;
642        let dev = s1.device();
643        let slice = S { eps: self.eps }.map(&s1.slice, l1, &s2.slice, l2, dev)?;
644        let dst = hanzo_ml::cuda_backend::CudaStorage {
645            slice,
646            device: dev.clone(),
647        };
648        Ok((dst, l1.shape().clone()))
649    }
650
651    #[cfg(feature = "rocm")]
652    fn rocm_fwd(
653        &self,
654        s1: &hanzo_ml::RocmStorage,
655        l1: &Layout,
656        s2: &hanzo_ml::RocmStorage,
657        l2: &Layout,
658    ) -> Result<(hanzo_ml::RocmStorage, Shape)> {
659        let out = s1.rms_norm(l1, s2, l2, self.eps)?;
660        Ok((out, l1.shape().clone()))
661    }
662
663    #[cfg(feature = "metal")]
664    fn metal_fwd(
665        &self,
666        s1: &hanzo_ml::MetalStorage,
667        l1: &Layout,
668        s2: &hanzo_ml::MetalStorage,
669        l2: &Layout,
670    ) -> Result<(hanzo_ml::MetalStorage, Shape)> {
671        use hanzo_ml::backend::BackendStorage;
672        let device = s1.device();
673        let encoder = device.command_encoder()?;
674        encoder.set_label("rmsnorm");
675        let kernels = device.kernels();
676        let name = match (s1.dtype(), s2.dtype()) {
677            (DType::F32, DType::F32) => "rmsnorm_f32",
678            (DType::F16, DType::F16) => "rmsnorm_f16",
679            (DType::BF16, DType::BF16) => "rmsnorm_bf16",
680            (dt1, dt2) => hanzo_ml::bail!("rmsnorm is not implemented for {dt1:?} {dt2:?}"),
681        };
682
683        if !(l1.is_contiguous() && l2.is_contiguous()) {
684            hanzo_ml::bail!("Non contiguous rmsnorm is not implemented");
685        }
686
687        let last_dim = l1.dims()[l1.shape().rank() - 1];
688        let elem_count = l1.shape().elem_count();
689        let output = device
690            .new_buffer_builder()
691            .with_size_for(elem_count, s1.dtype())
692            .with_label("rmsnorm")
693            .build()?;
694        hanzo_metal_kernels::call_rms_norm(
695            device.metal_device(),
696            &encoder,
697            kernels,
698            name,
699            elem_count,
700            last_dim,
701            self.eps,
702            s1.buffer(),
703            l1.start_offset() * s1.dtype().size_in_bytes(),
704            s2.buffer(),
705            l2.start_offset() * s2.dtype().size_in_bytes(),
706            &output,
707        )
708        .map_err(hanzo_ml::Error::wrap)?;
709        let newstorage =
710            hanzo_ml::MetalStorage::new(output, device.clone(), elem_count, s1.dtype());
711        Ok((newstorage, l1.shape().clone()))
712    }
713}
714
715pub fn rms_norm_slow(x: &Tensor, alpha: &Tensor, eps: f32) -> Result<Tensor> {
716    let x_dtype = x.dtype();
717    let internal_dtype = match x_dtype {
718        DType::F16 | DType::BF16 => DType::F32,
719        d => d,
720    };
721    let hidden_size = x.dim(D::Minus1)?;
722    let x = x.to_dtype(internal_dtype)?;
723    let norm_x = (x.sqr()?.sum_keepdim(D::Minus1)? / hidden_size as f64)?;
724    let x_normed = x.broadcast_div(&(norm_x + eps as f64)?.sqrt()?)?;
725    x_normed.to_dtype(x_dtype)?.broadcast_mul(alpha)
726}
727
728pub fn rms_norm(xs: &Tensor, alpha: &Tensor, eps: f32) -> Result<Tensor> {
729    let hidden_size_xs = xs.dim(D::Minus1)?;
730    let hidden_size_alpha = alpha.dims1()?;
731    if hidden_size_xs != hidden_size_alpha {
732        hanzo_ml::bail!(
733            "shape mismatch in rms-norm {:?} {:?}",
734            xs.shape(),
735            alpha.shape()
736        )
737    }
738    // The fused CustomOp2 (cpu/cuda/metal/rocm) expects alpha to live on the same device
739    // (and, for the gpu kernels, the same dtype) as xs. RmsNorm weights are frequently held
740    // on CPU / in a different dtype, which used to surface as the intermittent
741    // "device mismatch in rms-norm Cpu vs Rocm" flake. Normalize alpha up front.
742    let alpha = if alpha.device().same_device(xs.device()) {
743        alpha.clone()
744    } else {
745        alpha.to_device(xs.device())?
746    };
747    let alpha = if alpha.dtype() == xs.dtype() {
748        alpha
749    } else {
750        alpha.to_dtype(xs.dtype())?
751    };
752    // The fused rocm kernel only handles contiguous F16/F32; fall back to the unfused
753    // composite (rms_norm_slow) for anything else so we never feed the kernel a shape/dtype
754    // it can't handle.
755    if xs.device().is_rocm() {
756        let supported = matches!(xs.dtype(), DType::F16 | DType::F32)
757            && xs.is_contiguous()
758            && alpha.is_contiguous();
759        if !supported {
760            return rms_norm_slow(xs, &alpha, eps);
761        }
762    }
763    xs.apply_op2_no_bwd(&alpha, &RmsNorm { eps })
764}
765
766// Fused SwiGLU: silu(a) * b, elementwise (both same shape). One op instead of silu + mul.
767struct SiluMul;
768
769impl hanzo_ml::CustomOp2 for SiluMul {
770    fn name(&self) -> &'static str {
771        "silu-mul"
772    }
773
774    fn cpu_fwd(
775        &self,
776        s1: &CpuStorage,
777        l1: &Layout,
778        s2: &CpuStorage,
779        l2: &Layout,
780    ) -> Result<(CpuStorage, Shape)> {
781        fn inner<
782            T: hanzo_ml::WithDType
783                + num_traits::Float
784                + num_traits::AsPrimitive<f32>
785                + num_traits::FromPrimitive,
786        >(
787            a: &[T],
788            la: &Layout,
789            b: &[T],
790            lb: &Layout,
791        ) -> Result<(CpuStorage, Shape)> {
792            let a = match la.contiguous_offsets() {
793                Some((o1, o2)) => &a[o1..o2],
794                None => hanzo_ml::bail!("silu-mul: a must be contiguous"),
795            };
796            let b = match lb.contiguous_offsets() {
797                Some((o1, o2)) => &b[o1..o2],
798                None => hanzo_ml::bail!("silu-mul: b must be contiguous"),
799            };
800            let dst: Vec<T> = a
801                .iter()
802                .zip(b.iter())
803                .map(|(&x, &y)| {
804                    let xf = x.as_();
805                    T::from_f32(xf / (1.0 + (-xf).exp()) * y.as_()).unwrap_or_else(T::nan)
806                })
807                .collect();
808            Ok((
809                hanzo_ml::WithDType::to_cpu_storage_owned(dst),
810                Shape::from_dims(la.shape().dims()),
811            ))
812        }
813        use hanzo_ml::backend::BackendStorage;
814        use CpuStorage as C;
815        match (s1, s2) {
816            (C::BF16(a), C::BF16(b)) => inner::<half::bf16>(a, l1, b, l2),
817            (C::F16(a), C::F16(b)) => inner::<half::f16>(a, l1, b, l2),
818            (C::F32(a), C::F32(b)) => inner::<f32>(a, l1, b, l2),
819            _ => hanzo_ml::bail!("silu-mul: unsupported dtype {:?}", s1.dtype()),
820        }
821    }
822
823    #[cfg(feature = "vulkan")]
824    fn vulkan_fwd(
825        &self,
826        s1: &hanzo_ml::VulkanStorage,
827        l1: &Layout,
828        s2: &hanzo_ml::VulkanStorage,
829        l2: &Layout,
830    ) -> Result<(hanzo_ml::VulkanStorage, Shape)> {
831        let out = s1.silu_mul(l1, s2, l2)?;
832        Ok((out, l1.shape().clone()))
833    }
834
835    #[cfg(feature = "rocm")]
836    fn rocm_fwd(
837        &self,
838        s1: &hanzo_ml::RocmStorage,
839        l1: &Layout,
840        s2: &hanzo_ml::RocmStorage,
841        l2: &Layout,
842    ) -> Result<(hanzo_ml::RocmStorage, Shape)> {
843        let out = s1.silu_mul(l1, s2, l2)?;
844        Ok((out, l1.shape().clone()))
845    }
846}
847
848/// Fused SwiGLU: `silu(gate) * up`. Falls back to the unfused tensor ops where there's no kernel.
849pub fn silu_mul(gate: &Tensor, up: &Tensor) -> Result<Tensor> {
850    if gate.device().is_cuda() || gate.device().is_metal() {
851        // No fused kernel on these yet; compose (silu then mul).
852        return silu(gate)?.mul(up);
853    }
854    // The fused rocm kernel only handles contiguous F16/F32/BF16 of identical shape; fall back to
855    // the unfused composite otherwise so we never feed it a shape/dtype it can't handle.
856    if gate.device().is_rocm() {
857        let supported = matches!(gate.dtype(), DType::F16 | DType::F32 | DType::BF16)
858            && gate.dtype() == up.dtype()
859            && gate.is_contiguous()
860            && up.is_contiguous()
861            && gate.shape() == up.shape();
862        if !supported {
863            return silu(gate)?.mul(up);
864        }
865    }
866    gate.apply_op2_no_bwd(up, &SiluMul)
867}
868
869#[derive(Debug, Clone)]
870struct LayerNorm {
871    eps: f32,
872}
873
874impl hanzo_ml::CustomOp3 for LayerNorm {
875    fn name(&self) -> &'static str {
876        "layer-norm"
877    }
878
879    fn cpu_fwd(
880        &self,
881        s1: &CpuStorage,
882        l1: &Layout,
883        s2: &CpuStorage,
884        l2: &Layout,
885        s3: &CpuStorage,
886        l3: &Layout,
887    ) -> Result<(CpuStorage, Shape)> {
888        use hanzo_ml::backend::BackendStorage;
889
890        let eps = self.eps;
891        fn inner<
892            T: hanzo_ml::WithDType
893                + num_traits::Float
894                + num_traits::AsPrimitive<f32>
895                + num_traits::FromPrimitive,
896        >(
897            src: &[T],
898            layout: &Layout,
899            alpha: &[T],
900            alpha_layout: &Layout,
901            beta: &[T],
902            beta_layout: &Layout,
903            eps: f32,
904        ) -> Result<(CpuStorage, Shape)> {
905            let src = match layout.contiguous_offsets() {
906                None => hanzo_ml::bail!("input has to be contiguous"),
907                Some((o1, o2)) => &src[o1..o2],
908            };
909            let alpha = match alpha_layout.contiguous_offsets() {
910                None => hanzo_ml::bail!("alpha has to be contiguous"),
911                Some((o1, o2)) => &alpha[o1..o2],
912            };
913            let beta = match beta_layout.contiguous_offsets() {
914                None => hanzo_ml::bail!("beta has to be contiguous"),
915                Some((o1, o2)) => &beta[o1..o2],
916            };
917            let el_count = layout.shape().elem_count();
918            let dims = layout.shape().dims();
919            let dim_m1 = dims[dims.len() - 1];
920            let mut dst = vec![T::zero(); el_count];
921            src.par_chunks(dim_m1)
922                .zip(dst.par_chunks_mut(dim_m1))
923                .for_each(|(src, dst)| {
924                    let mut sum = 0f32;
925                    let mut sum2 = 0f32;
926                    for v in src {
927                        let v = v.as_();
928                        sum += v;
929                        sum2 += v * v;
930                    }
931                    let mean = sum / dim_m1 as f32;
932                    let var = sum2 / dim_m1 as f32 - mean * mean;
933                    let inv_std = (var + eps).sqrt().recip();
934                    for ((d, s), (alpha, beta)) in
935                        dst.iter_mut().zip(src.iter()).zip(alpha.iter().zip(beta))
936                    {
937                        let alpha = alpha.as_();
938                        let beta = beta.as_();
939                        let d_ = (s.as_() - mean) * inv_std * alpha + beta;
940                        *d = T::from_f32(d_).unwrap_or_else(T::nan);
941                    }
942                });
943            let storage = hanzo_ml::WithDType::to_cpu_storage_owned(dst);
944            Ok((storage, Shape::from_dims(dims)))
945        }
946
947        use CpuStorage as C;
948        match (s1, s2, s3) {
949            (C::BF16(s1), C::BF16(s2), C::BF16(s3)) => {
950                inner::<half::bf16>(s1, l1, s2, l2, s3, l3, eps)
951            }
952            (C::F16(s1), C::F16(s2), C::F16(s3)) => inner::<half::f16>(s1, l1, s2, l2, s3, l3, eps),
953            (C::F32(s1), C::F32(s2), C::F32(s3)) => inner::<f32>(s1, l1, s2, l2, s3, l3, eps),
954            _ => hanzo_ml::bail!("unsupported dtype for rmsnorm {:?}", s1.dtype()),
955        }
956    }
957
958    #[cfg(feature = "cuda")]
959    fn cuda_fwd(
960        &self,
961        s1: &hanzo_ml::CudaStorage,
962        l1: &Layout,
963        s2: &hanzo_ml::CudaStorage,
964        l2: &Layout,
965        s3: &hanzo_ml::CudaStorage,
966        l3: &Layout,
967    ) -> Result<(hanzo_ml::CudaStorage, Shape)> {
968        use hanzo_ml::cuda_backend::cudarc::driver::{
969            CudaSlice, DeviceRepr, LaunchConfig, PushKernelArg,
970        };
971        use hanzo_ml::cuda_backend::{kernel_name, kernels, Map3, WrapErr};
972        use hanzo_ml::{CudaDevice, WithDType};
973
974        struct S {
975            eps: f32,
976        }
977        impl Map3 for S {
978            fn f<T: DeviceRepr + WithDType>(
979                &self,
980                src: &CudaSlice<T>,
981                layout: &Layout,
982                alpha: &CudaSlice<T>,
983                alpha_layout: &Layout,
984                beta: &CudaSlice<T>,
985                beta_layout: &Layout,
986                dev: &CudaDevice,
987            ) -> Result<CudaSlice<T>> {
988                let src = match layout.contiguous_offsets() {
989                    None => hanzo_ml::bail!("input has to be contiguous"),
990                    Some((o1, o2)) => src.slice(o1..o2),
991                };
992                let alpha = match alpha_layout.contiguous_offsets() {
993                    None => hanzo_ml::bail!("alpha has to be contiguous"),
994                    Some((o1, o2)) => alpha.slice(o1..o2),
995                };
996                let beta = match beta_layout.contiguous_offsets() {
997                    None => hanzo_ml::bail!("beta has to be contiguous"),
998                    Some((o1, o2)) => beta.slice(o1..o2),
999                };
1000                let el = layout.shape().elem_count();
1001                let dims = layout.shape().dims();
1002                let dim_m1 = dims[dims.len() - 1];
1003                let (n_rows, n_cols) = (el / dim_m1, dim_m1);
1004
1005                let block_size = if n_cols < 1024 { 32 } else { 1024 };
1006                let cfg = LaunchConfig {
1007                    grid_dim: (n_rows as u32, 1, 1),
1008                    block_dim: (block_size, 1, 1),
1009                    shared_mem_bytes: 0,
1010                };
1011                let func =
1012                    dev.get_or_load_func(&kernel_name::<T>("layernorm"), &kernels::REDUCE)?;
1013                // SAFETY: Set later by running the kernel.
1014                let dst = unsafe { dev.alloc::<T>(el)? };
1015                let mut builder = func.builder();
1016                builder.arg(&src);
1017                builder.arg(&dst);
1018                builder.arg(&alpha);
1019                builder.arg(&beta);
1020                hanzo_ml::builder_arg!(builder, n_cols as i32, block_size as i32, self.eps);
1021                // SAFETY: ffi.
1022                unsafe { builder.launch(cfg) }.w()?;
1023                Ok(dst)
1024            }
1025        }
1026
1027        use hanzo_ml::backend::BackendStorage;
1028        let dev = s1.device();
1029        let slice = S { eps: self.eps }.map(&s1.slice, l1, &s2.slice, l2, &s3.slice, l3, dev)?;
1030        let dst = hanzo_ml::cuda_backend::CudaStorage {
1031            slice,
1032            device: dev.clone(),
1033        };
1034        Ok((dst, l1.shape().clone()))
1035    }
1036
1037    #[cfg(feature = "metal")]
1038    fn metal_fwd(
1039        &self,
1040        s1: &hanzo_ml::MetalStorage,
1041        l1: &Layout,
1042        s2: &hanzo_ml::MetalStorage,
1043        l2: &Layout,
1044        s3: &hanzo_ml::MetalStorage,
1045        l3: &Layout,
1046    ) -> Result<(hanzo_ml::MetalStorage, Shape)> {
1047        use hanzo_ml::backend::BackendStorage;
1048        let device = s1.device();
1049        let encoder = device.command_encoder()?;
1050        encoder.set_label("layernorm");
1051        let kernels = device.kernels();
1052        let name = match (s1.dtype(), s2.dtype(), s3.dtype()) {
1053            (DType::F32, DType::F32, DType::F32) => "layernorm_f32",
1054            (DType::F16, DType::F16, DType::F16) => "layernorm_f16",
1055            (DType::BF16, DType::BF16, DType::BF16) => "layernorm_bf16",
1056            (dt1, dt2, dt3) => {
1057                hanzo_ml::bail!("layernorm is not implemented for {dt1:?} {dt2:?} {dt3:?}")
1058            }
1059        };
1060
1061        if !(l1.is_contiguous() && l2.is_contiguous() && l3.is_contiguous()) {
1062            hanzo_ml::bail!("Non contiguous layernorm is not implemented");
1063        }
1064
1065        let last_dim = l1.dims()[l1.shape().rank() - 1];
1066        let elem_count = l1.shape().elem_count();
1067        let output = device
1068            .new_buffer_builder()
1069            .with_size_for(elem_count, s1.dtype())
1070            .with_label("layernorm")
1071            .build()?;
1072        hanzo_metal_kernels::call_layer_norm(
1073            device.metal_device(),
1074            &encoder,
1075            kernels,
1076            name,
1077            elem_count,
1078            last_dim,
1079            self.eps,
1080            s1.buffer(),
1081            l1.start_offset() * s1.dtype().size_in_bytes(),
1082            s2.buffer(),
1083            l2.start_offset() * s2.dtype().size_in_bytes(),
1084            s3.buffer(),
1085            l3.start_offset() * s3.dtype().size_in_bytes(),
1086            &output,
1087        )
1088        .map_err(hanzo_ml::Error::wrap)?;
1089        let newstorage =
1090            hanzo_ml::MetalStorage::new(output, device.clone(), elem_count, s1.dtype());
1091        Ok((newstorage, l1.shape().clone()))
1092    }
1093}
1094
1095pub fn layer_norm_slow(x: &Tensor, alpha: &Tensor, beta: &Tensor, eps: f32) -> Result<Tensor> {
1096    let x_dtype = x.dtype();
1097    let internal_dtype = match x_dtype {
1098        DType::F16 | DType::BF16 => DType::F32,
1099        d => d,
1100    };
1101    let hidden_size = x.dim(D::Minus1)?;
1102    let x = x.to_dtype(internal_dtype)?;
1103    let x = {
1104        let mean_x = (x.sum_keepdim(D::Minus1)? / hidden_size as f64)?;
1105        x.broadcast_sub(&mean_x)?
1106    };
1107    let norm_x = (x.sqr()?.sum_keepdim(D::Minus1)? / hidden_size as f64)?;
1108    let x_normed = x.broadcast_div(&(norm_x + eps as f64)?.sqrt()?)?;
1109    x_normed
1110        .to_dtype(x_dtype)?
1111        .broadcast_mul(alpha)?
1112        .broadcast_add(beta)
1113}
1114
1115pub fn layer_norm(xs: &Tensor, alpha: &Tensor, beta: &Tensor, eps: f32) -> Result<Tensor> {
1116    let hidden_size_xs = xs.dim(D::Minus1)?;
1117    let hidden_size_alpha = alpha.dims1()?;
1118    let hidden_size_beta = beta.dims1()?;
1119    if hidden_size_xs != hidden_size_alpha || hidden_size_xs != hidden_size_beta {
1120        hanzo_ml::bail!(
1121            "shape mismatch in layer-norm src: {:?} alpha: {:?} beta: {:?}",
1122            xs.shape(),
1123            alpha.shape(),
1124            beta.shape()
1125        )
1126    }
1127    if xs.device().is_rocm() || xs.device().is_vulkan() {
1128        return layer_norm_slow(xs, alpha, beta, eps);
1129    }
1130    xs.apply_op3_no_bwd(alpha, beta, &LayerNorm { eps })
1131}
1132
1133// https://pytorch.org/docs/stable/generated/torch.nn.PixelShuffle.html
1134pub fn pixel_shuffle(xs: &Tensor, upscale_factor: usize) -> Result<Tensor> {
1135    let (b_size, c, h, w) = xs.dims4()?;
1136    let out_c = c / upscale_factor / upscale_factor;
1137    xs.reshape((b_size, out_c, upscale_factor, upscale_factor, h, w))?
1138        .permute((0, 1, 4, 2, 5, 3))?
1139        .reshape((b_size, out_c, h * upscale_factor, w * upscale_factor))
1140}
1141
1142pub fn pixel_unshuffle(xs: &Tensor, downscale_factor: usize) -> Result<Tensor> {
1143    let (b_size, c, h, w) = xs.dims4()?;
1144    let out_c = c * downscale_factor * downscale_factor;
1145    xs.reshape((
1146        b_size,
1147        c,
1148        h / downscale_factor,
1149        downscale_factor,
1150        w / downscale_factor,
1151        downscale_factor,
1152    ))?
1153    .permute((0, 1, 3, 5, 2, 4))?
1154    .reshape((b_size, out_c, h / downscale_factor, w / downscale_factor))
1155}
1156
1157// https://pytorch.org/docs/stable/generated/torch.nn.ReplicationPad2d.html
1158pub fn replication_pad2d(xs: &Tensor, pad: usize) -> Result<Tensor> {
1159    match pad {
1160        0 => Ok(xs.clone()),
1161        1 => {
1162            let (_b_size, _c, h, w) = xs.dims4()?;
1163            let (first, last) = (xs.narrow(3, 0, 1)?, xs.narrow(3, w - 1, 1)?);
1164            let xs = Tensor::cat(&[&first, xs, &last], 3)?;
1165            let (first, last) = (xs.narrow(2, 0, 1)?, xs.narrow(2, h - 1, 1)?);
1166            Tensor::cat(&[&first, &xs, &last], 2)
1167        }
1168        n => hanzo_ml::bail!("replication-pad with a size of {n} is not supported"),
1169    }
1170}
1171
1172#[derive(Clone, Debug)]
1173pub struct Identity;
1174
1175impl Identity {
1176    pub fn new() -> Identity {
1177        Self
1178    }
1179}
1180
1181impl Default for Identity {
1182    fn default() -> Self {
1183        Self
1184    }
1185}
1186
1187impl Module for Identity {
1188    fn forward(&self, xs: &Tensor) -> Result<Tensor> {
1189        Ok(xs.clone())
1190    }
1191}
1192
1193#[allow(dead_code)]
1194struct Sdpa {
1195    scale: f32,
1196    softcapping: f32,
1197    mask: Option<Tensor>,
1198    do_causal: bool,
1199}
1200
1201// Flash-decoding for the Vulkan decode attention (cached; the decode path re-enters vulkan_fwd once
1202// per layer per token). Default ON -- the split-K kernel fills the GPU vs the one-workgroup-per-head
1203// sdpa_blk (256 VGPR / min occupancy). VK_SDPA_SPLIT_OFF=1 reverts for A/B; matches vk_sdpa_graph.
1204#[cfg(feature = "vulkan")]
1205fn vk_sdpa_split() -> bool {
1206    static S: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1207    *S.get_or_init(|| {
1208        std::env::var("VK_SDPA_SPLIT_OFF")
1209            .map(|v| v == "0")
1210            .unwrap_or(true)
1211    })
1212}
1213#[cfg(feature = "vulkan")]
1214fn vk_sdpa_nsplit() -> usize {
1215    static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1216    *N.get_or_init(|| {
1217        std::env::var("VK_SDPA_NSPLIT")
1218            .ok()
1219            .and_then(|v| v.parse::<usize>().ok())
1220            .filter(|&n| n >= 1)
1221            .unwrap_or(8)
1222    })
1223}
1224
1225impl hanzo_ml::CustomOp3 for Sdpa {
1226    fn name(&self) -> &'static str {
1227        "metal-sdpa"
1228    }
1229
1230    fn cpu_fwd(
1231        &self,
1232        _s1: &CpuStorage,
1233        _l1: &Layout,
1234        _s2: &CpuStorage,
1235        _l2: &Layout,
1236        _s3: &CpuStorage,
1237        _l3: &Layout,
1238    ) -> Result<(CpuStorage, Shape)> {
1239        hanzo_ml::bail!("SDPA has no cpu impl")
1240    }
1241
1242    // Fused GQA flash SDPA on Vulkan: the DSL `sdpa_blk` .spv, one workgroup per (batch,head,query),
1243    // GQA-native (reads the shared KV head, no repeat_kv). Preconditions (single-query decode shape,
1244    // head_dim 128, no softcap/mask) are gated by the caller (engine attention::vulkan_decode_attn);
1245    // this validates defensively and bails so an unsupported call surfaces an error rather than a
1246    // silent miscompute. Inputs are contiguous f32 (the caller makes them contiguous).
1247    #[cfg(feature = "vulkan")]
1248    fn vulkan_fwd(
1249        &self,
1250        q: &hanzo_ml::VulkanStorage,
1251        q_l: &Layout,
1252        k: &hanzo_ml::VulkanStorage,
1253        k_l: &Layout,
1254        v: &hanzo_ml::VulkanStorage,
1255        v_l: &Layout,
1256    ) -> Result<(hanzo_ml::VulkanStorage, Shape)> {
1257        use hanzo_ml::backend::BackendStorage;
1258        if q.dtype() != hanzo_ml::DType::F32
1259            || k.dtype() != hanzo_ml::DType::F32
1260            || v.dtype() != hanzo_ml::DType::F32
1261        {
1262            hanzo_ml::bail!("sdpa_blk vulkan: f32 only (got q{:?})", q.dtype());
1263        }
1264        let (b, h, sq, d) = q_l.shape().dims4()?;
1265        let (_, hkv, l, kd) = k_l.shape().dims4()?;
1266        let (_, _, _, vd) = v_l.shape().dims4()?;
1267        if d != 128 || kd != 128 || vd != 128 {
1268            hanzo_ml::bail!("sdpa_blk vulkan: only head_dim 128 (got q{d}/k{kd}/v{vd})");
1269        }
1270        if self.softcapping != 1.0 || self.mask.is_some() {
1271            hanzo_ml::bail!("sdpa_blk vulkan: no softcap/mask support");
1272        }
1273        if hkv == 0 || h % hkv != 0 {
1274            hanzo_ml::bail!("sdpa_blk vulkan: n_heads {h} not a multiple of n_kv {hkv}");
1275        }
1276        // q must be contiguous at offset 0 (it's a single decode token -- tiny to materialize). k/v may
1277        // be a STRIDED view of a max_seq-sized KV cache: the kernel reads them in place at their real
1278        // strides (no per-layer .contiguous() of the whole active cache), needing only the innermost
1279        // (head_dim) stride == 1, offset 0, and k/v sharing one layout (one kbase feeds both reads).
1280        if !q_l.is_contiguous() || q_l.start_offset() != 0 {
1281            hanzo_ml::bail!("sdpa_blk vulkan: q must be contiguous at offset 0");
1282        }
1283        let ks = k_l.stride();
1284        let vs = v_l.stride();
1285        if k_l.start_offset() != 0 || v_l.start_offset() != 0 || ks != vs || ks[3] != 1 {
1286            hanzo_ml::bail!(
1287                "sdpa_blk vulkan: k/v need matching strides, head_dim contiguous, offset 0"
1288            );
1289        }
1290        let dev = q.device().clone();
1291        // Flash-decoding A/B (VK_SDPA_SPLIT=1): the split-K occupancy fix vs the one-workgroup-per-head
1292        // sdpa_blk. n_split via VK_SDPA_NSPLIT (default 4). Eager path only; the graph path is separate.
1293        let out = if vk_sdpa_split() {
1294            dev.sdpa_decode_split_vk(
1295                q,
1296                k,
1297                v,
1298                b,
1299                h,
1300                hkv,
1301                sq,
1302                l,
1303                d,
1304                self.scale,
1305                vk_sdpa_nsplit(),
1306                ks[0],
1307                ks[1],
1308                ks[2],
1309            )?
1310        } else {
1311            dev.sdpa_blk_vk(
1312                q,
1313                k,
1314                v,
1315                b,
1316                h,
1317                hkv,
1318                sq,
1319                l,
1320                d,
1321                self.scale,
1322                self.do_causal,
1323                ks[0],
1324                ks[1],
1325                ks[2],
1326            )?
1327        };
1328        Ok((out, Shape::from_dims(&[b, h, sq, d])))
1329    }
1330
1331    #[cfg(feature = "metal")]
1332    fn metal_fwd(
1333        &self,
1334        q: &hanzo_ml::MetalStorage,
1335        q_l: &Layout,
1336        k: &hanzo_ml::MetalStorage,
1337        k_l: &Layout,
1338        v: &hanzo_ml::MetalStorage,
1339        v_l: &Layout,
1340    ) -> Result<(hanzo_ml::MetalStorage, Shape)> {
1341        use hanzo_metal_kernels::SdpaDType;
1342        use hanzo_ml::backend::BackendStorage;
1343
1344        let device = q.device();
1345
1346        let out_dims = vec![q_l.dim(0)?, q_l.dim(1)?, q_l.dim(2)?, v_l.dim(3)?];
1347        let elem_count: usize = out_dims.iter().product();
1348        let out_shape = Shape::from_dims(&out_dims);
1349        let out_layout = Layout::contiguous(out_shape.clone());
1350
1351        let output = device
1352            .new_buffer_builder()
1353            .with_size_for(elem_count, q.dtype())
1354            .with_label("sdpa_o")
1355            .build()?;
1356
1357        // q,k must have matching emb dim
1358        if q_l.dim(D::Minus1)? != k_l.dim(D::Minus1)? {
1359            hanzo_ml::bail!("`q` and `k` last dims must match");
1360        }
1361
1362        // k,v must have matching n kv heads
1363        if v_l.dim(D::Minus(3))? != k_l.dim(D::Minus(3))? {
1364            hanzo_ml::bail!("`k` and `v` head dims must match");
1365        }
1366
1367        // n_heads % n_kv_heads == 0; n_heads >= 1, n_kv_heads >= 1.
1368        if q_l.dim(D::Minus(3))? % k_l.dim(D::Minus(3))? != 0 {
1369            hanzo_ml::bail!("query `n_heads` must be a multiple of `n_kv_heads`");
1370        }
1371
1372        let k_head = k_l.dim(D::Minus1)?;
1373        let q_head = q_l.dim(D::Minus1)?;
1374        let q_seq = q_l.dim(2)?;
1375        let k_seq = k_l.dim(2)?;
1376
1377        let mut implementation_supports_use_case = q_head == k_head;
1378        let supported_head_dim = q_head == 32
1379            || q_head == 64
1380            || q_head == 72
1381            || q_head == 80
1382            || q_head == 96
1383            || q_head == 128
1384            || q_head == 256
1385            || q_head == 512;
1386
1387        let supports_sdpa_full_mask = self.mask.is_none() || q_seq <= k_seq;
1388        // F32 full attention at head_dim=512 exceeds 32KB Metal threadgroup memory
1389        let supports_sdpa_full_dtype = !(q_head == 512 && q.dtype() == DType::F32);
1390        // The vector kernel reduces over a single query row, so it is only correct at q_seq == 1;
1391        // everything longer goes through the full kernel. The split is exclusive by construction.
1392        let supports_sdpa_full =
1393            q_seq > 1 && supported_head_dim && supports_sdpa_full_mask && supports_sdpa_full_dtype;
1394        let supports_sdpa_vector = q_seq == 1 && supported_head_dim && q_seq <= k_seq;
1395
1396        implementation_supports_use_case &= supports_sdpa_full || supports_sdpa_vector;
1397
1398        if !supported_head_dim {
1399            hanzo_ml::bail!(
1400                "Meta SDPA does not support q head dim {q_head}: q dims {:?}, k dims {:?}, v dims {:?}.",
1401                q_l.dims(),
1402                k_l.dims(),
1403                v_l.dims()
1404            );
1405        }
1406        if !implementation_supports_use_case {
1407            hanzo_ml::bail!(
1408                "Meta SDPA does not support q dims {:?}, k dims {:?}, v dims {:?}.",
1409                q_l.dims(),
1410                k_l.dims(),
1411                v_l.dims()
1412            );
1413        }
1414
1415        for t in [k.dtype(), v.dtype()] {
1416            if q.dtype() != t {
1417                hanzo_ml::bail!("all q, k, v dtypes must match.");
1418            }
1419        }
1420
1421        let itype = match q.dtype() {
1422            DType::BF16 => SdpaDType::BF16,
1423            DType::F16 => SdpaDType::F16,
1424            DType::F32 => SdpaDType::F32,
1425            other => hanzo_ml::bail!("unsupported sdpa type {other:?}"),
1426        };
1427
1428        let encoder = q.device().command_encoder()?;
1429        if supports_sdpa_vector {
1430            // Route to the 2 pass fused attention if the k seqlen is large.
1431            // https://github.com/ml-explore/mlx/pull/1597
1432            const TWO_PASS_K_THRESHOLD: usize = 1024;
1433            if k_seq >= TWO_PASS_K_THRESHOLD {
1434                let mut intermediate_shape = [
1435                    &out_dims[0..out_dims.len() - 2],
1436                    &[hanzo_metal_kernels::SDPA_2PASS_BLOCKS],
1437                    &[out_dims[out_dims.len() - 1]],
1438                ]
1439                .concat();
1440                let intermediate = device
1441                    .new_buffer_builder()
1442                    .with_size_for(intermediate_shape.iter().product::<usize>(), DType::F32)
1443                    .with_label("sdpa_2pass_intermediate")
1444                    .build()?;
1445                let _ = intermediate_shape.pop().unwrap();
1446                let sums = device
1447                    .new_buffer_builder()
1448                    .with_size_for(intermediate_shape.iter().product::<usize>(), DType::F32)
1449                    .with_label("sdpa_2pass_sums")
1450                    .build()?;
1451                let maxs = device
1452                    .new_buffer_builder()
1453                    .with_size_for(intermediate_shape.iter().product::<usize>(), DType::F32)
1454                    .with_label("sdpa_2pass_maxs")
1455                    .build()?;
1456
1457                encoder.set_label("vector_attention");
1458                hanzo_metal_kernels::call_sdpa_vector_2pass(
1459                    q.device().device(),
1460                    &encoder,
1461                    q.device().kernels(),
1462                    q_l.start_offset() * q.dtype().size_in_bytes(),
1463                    q_l.dims(),
1464                    q.buffer(),
1465                    k_l.start_offset() * k.dtype().size_in_bytes(),
1466                    k_l.dims(),
1467                    k_l.stride(),
1468                    k.buffer(),
1469                    v_l.start_offset() * v.dtype().size_in_bytes(),
1470                    v_l.stride(),
1471                    v.buffer(),
1472                    &output,
1473                    &intermediate,
1474                    &sums,
1475                    &maxs,
1476                    self.scale,
1477                    self.softcapping,
1478                    itype,
1479                )
1480                .map_err(hanzo_ml::Error::wrap)?;
1481            } else {
1482                encoder.set_label("vector_attention");
1483                hanzo_metal_kernels::call_sdpa_vector(
1484                    q.device().device(),
1485                    &encoder,
1486                    q.device().kernels(),
1487                    q_l.start_offset() * q.dtype().size_in_bytes(),
1488                    q_l.dims(),
1489                    q.buffer(),
1490                    k_l.start_offset() * k.dtype().size_in_bytes(),
1491                    k_l.dims(),
1492                    k_l.stride(),
1493                    k.buffer(),
1494                    v_l.start_offset() * v.dtype().size_in_bytes(),
1495                    v_l.stride(),
1496                    v.buffer(),
1497                    &output,
1498                    self.scale,
1499                    self.softcapping,
1500                    itype,
1501                )
1502                .map_err(hanzo_ml::Error::wrap)?;
1503            }
1504        } else if supports_sdpa_full {
1505            encoder.set_label("full_attention");
1506            if self.softcapping != 1. {
1507                hanzo_ml::bail!("SDPA full requires softcapping to be disabled (1.0)");
1508            }
1509
1510            let mask_s_l = self.mask.as_ref().map(|m| m.storage_and_layout());
1511
1512            let (mask_type, mask_buffer, mask_strides) = if let Some(mask) = &self.mask {
1513                let (mask_s, mask_l) = mask_s_l.as_ref().unwrap();
1514
1515                let mask_buffer = match &**mask_s {
1516                    hanzo_ml::Storage::Metal(m) => m.buffer(),
1517                    _ => hanzo_ml::bail!("Expected metal device for mask"),
1518                };
1519
1520                let mask_type = match mask.dtype() {
1521                    DType::BF16 => SdpaDType::BF16,
1522                    DType::F16 => SdpaDType::F16,
1523                    DType::F32 => SdpaDType::F32,
1524                    other => hanzo_ml::bail!("unsupported sdpa type {other:?}"),
1525                };
1526                if mask_type != itype {
1527                    hanzo_ml::bail!("Mask type {mask_type:?} must match q type {itype:?}");
1528                }
1529
1530                if mask_l.dims() != [q_l.dim(0)?, q_l.dim(1)?, q_l.dim(2)?, k_seq] {
1531                    hanzo_ml::bail!(
1532                        "Mask shape must be {:?} (bs, qheads, qseq, kseq), got {:?}",
1533                        [q_l.dim(0)?, q_head, q_l.dim(2)?, k_seq],
1534                        mask_l.dims()
1535                    );
1536                }
1537
1538                (
1539                    Some(mask_type),
1540                    Some(mask_buffer),
1541                    Some(mask_l.stride().to_vec()),
1542                )
1543            } else {
1544                (None, None, None)
1545            };
1546
1547            hanzo_metal_kernels::call_sdpa_full(
1548                q.device().device(),
1549                &encoder,
1550                q.device().kernels(),
1551                q_l.start_offset() * q.dtype().size_in_bytes(),
1552                q_l.dims(),
1553                q_l.stride(),
1554                q.buffer(),
1555                k_l.start_offset() * k.dtype().size_in_bytes(),
1556                k_l.dims(),
1557                k_l.stride(),
1558                k.buffer(),
1559                v_l.start_offset() * v.dtype().size_in_bytes(),
1560                v.buffer(),
1561                v_l.stride(),
1562                mask_type,
1563                mask_buffer,
1564                mask_strides.as_deref(),
1565                &output,
1566                out_layout.stride(),
1567                self.scale,
1568                self.do_causal,
1569                itype,
1570            )
1571            .map_err(hanzo_ml::Error::wrap)?;
1572        } else {
1573            hanzo_ml::bail!("must be vector or full sdpa kernel");
1574        }
1575
1576        let newstorage = hanzo_ml::MetalStorage::new(output, device.clone(), elem_count, q.dtype());
1577        Ok((newstorage, out_shape))
1578    }
1579}
1580
1581/// Scaled dot product attention with a fused kernel.
1582///
1583/// Computes softmax(qk^T*scale)v.
1584///
1585/// **Inputs shapes:**
1586/// - `q`: (bs, qhead, seq, hidden)
1587/// - `k`: (bs, kv_head, kv_seq, hidden)
1588/// - `k`: (bs, kv_head, kv_seq, v_hidden)
1589/// - `mask`: (bs, qhead, seq, kv_seq)
1590/// - `do_causal`: Apply causal masking. If this is true, the mask does not need to be provided.
1591/// - `scale` is applied before softmax.
1592/// - If `softcapping` != 1.0:
1593///      - Computation is: softmax(tanh(qk^T*scale/cap)*cap)v
1594///
1595/// **Output shape:** (bs, qhead, seq, v_hidden)
1596///
1597/// Note: For Grouped Query Attention and Multi-Query Attention, the k and v inputs should not be pre-tiled to match q.
1598///
1599/// ## On Metal:
1600/// - If `seq` == 1:
1601///     - Use a vectorized kernel
1602///     - Supports `seq` != `kv_seq` (cross attn. support)
1603///     - Supports GQA when `qhead` is a multiple of `kv_head`
1604/// - Otherwise:
1605///     - Masking is supported
1606///     - Supports `seq` != `kv_seq` (cross attn. support)
1607///     - Supports GQA when `qhead` is a multiple of `kv_head`
1608///     - Softcapping is not supported.
1609pub fn sdpa(
1610    q: &Tensor,
1611    k: &Tensor,
1612    v: &Tensor,
1613    mask: Option<&Tensor>,
1614    do_causal: bool,
1615    scale: f32,
1616    softcapping: f32,
1617) -> Result<Tensor> {
1618    q.apply_op3_no_bwd(
1619        k,
1620        v,
1621        &Sdpa {
1622            scale,
1623            softcapping,
1624            mask: mask.cloned(),
1625            do_causal,
1626        },
1627    )
1628}