Skip to main content

strided_basic/
outer_product.rs

1//! Semantic outer-product API on dynamic-rank strided views.
2
3use core::mem::MaybeUninit;
4use std::ops::Mul;
5
6use crate::view::{StridedView, StridedViewMut};
7use crate::MaybeSendSync;
8use crate::{broadcast_mul_into, broadcast_mul_into_uninit};
9use crate::{ElementOp, Result, StridedError};
10
11type AxisVec<T> = crate::inline::InlineVec<T>;
12
13/// Compute `dest[lhs_free..., rhs_free..., batch...] =
14/// lhs[lhs_free..., batch...] * rhs[rhs_free..., batch...]`.
15///
16/// This is a semantic convenience wrapper over [`broadcast_mul_into`]. The
17/// broadcast/mul planner owns kernel selection, so explicit outer-product calls
18/// and equivalent broadcasted multiplication use the same implementation path.
19pub fn batched_outer_product_into<D, A, B, OpA, OpB>(
20    dest: &mut StridedViewMut<D>,
21    lhs: &StridedView<A, OpA>,
22    rhs: &StridedView<B, OpB>,
23    lhs_free_ndim: usize,
24    rhs_free_ndim: usize,
25) -> Result<()>
26where
27    D: Copy + MaybeSendSync + 'static,
28    A: Copy + MaybeSendSync + Mul<B, Output = D> + 'static,
29    B: Copy + MaybeSendSync + 'static,
30    OpA: ElementOp<A>,
31    OpB: ElementOp<B>,
32{
33    validate_batched_outer_shape(dest, lhs, rhs, lhs_free_ndim, rhs_free_ndim)?;
34
35    let batch_ndim = lhs.ndim() - lhs_free_ndim;
36    let mut lhs_axes = AxisVec::<usize>::with_capacity(lhs.ndim());
37    let mut rhs_axes = AxisVec::<usize>::with_capacity(rhs.ndim());
38
39    lhs_axes.extend(0..lhs_free_ndim);
40    rhs_axes.extend(lhs_free_ndim..lhs_free_ndim + rhs_free_ndim);
41
42    let batch_axis_start = lhs_free_ndim + rhs_free_ndim;
43    lhs_axes.extend(batch_axis_start..batch_axis_start + batch_ndim);
44    rhs_axes.extend(batch_axis_start..batch_axis_start + batch_ndim);
45
46    broadcast_mul_into(dest, lhs, &lhs_axes, rhs, &rhs_axes)
47}
48
49/// Compute a batched outer product into a fully overwritten uninitialized output.
50///
51/// Rank, shape, destination-injectivity, and reachable-byte overlap validation
52/// completes before the first write. Safe Rust borrows already prevent
53/// input/output aliasing; the explicit overlap check in the shared broadcast
54/// kernel preserves the contract for views produced through unsafe constructors.
55///
56/// `Ok(())` means every logical destination element is initialized. An error
57/// occurs before writes. A panic during replay may leave a partially initialized
58/// destination, which remains safe to drop as `MaybeUninit<D>`.
59///
60/// # Errors
61///
62/// Returns a typed rank or shape error for incompatible free/batch dimensions,
63/// [`StridedError::NonInjectiveOutputLayout`] for an overlapping output layout,
64/// [`StridedError::OverlappingInputOutput`] for aliased storage, or
65/// [`StridedError::OffsetOverflow`] when a reachable byte range is not
66/// representable.
67pub fn batched_outer_product_into_uninit<D, A, B, OpA, OpB>(
68    dest: &mut StridedViewMut<MaybeUninit<D>>,
69    lhs: &StridedView<A, OpA>,
70    rhs: &StridedView<B, OpB>,
71    lhs_free_ndim: usize,
72    rhs_free_ndim: usize,
73) -> Result<()>
74where
75    D: Copy + MaybeSendSync + 'static,
76    A: Copy + MaybeSendSync + Mul<B, Output = D> + 'static,
77    B: Copy + MaybeSendSync + 'static,
78    OpA: ElementOp<A>,
79    OpB: ElementOp<B>,
80{
81    validate_batched_outer_shape(dest, lhs, rhs, lhs_free_ndim, rhs_free_ndim)?;
82    let batch_ndim = lhs.ndim() - lhs_free_ndim;
83    let mut lhs_axes = AxisVec::<usize>::with_capacity(lhs.ndim());
84    let mut rhs_axes = AxisVec::<usize>::with_capacity(rhs.ndim());
85    lhs_axes.extend(0..lhs_free_ndim);
86    rhs_axes.extend(lhs_free_ndim..lhs_free_ndim + rhs_free_ndim);
87    let batch_axis_start = lhs_free_ndim + rhs_free_ndim;
88    lhs_axes.extend(batch_axis_start..batch_axis_start + batch_ndim);
89    rhs_axes.extend(batch_axis_start..batch_axis_start + batch_ndim);
90    broadcast_mul_into_uninit(dest, lhs, &lhs_axes, rhs, &rhs_axes)
91}
92
93fn validate_batched_outer_shape<D, A, OpA, B, OpB>(
94    dest: &StridedViewMut<D>,
95    lhs: &StridedView<A, OpA>,
96    rhs: &StridedView<B, OpB>,
97    lhs_free_ndim: usize,
98    rhs_free_ndim: usize,
99) -> Result<()> {
100    if lhs_free_ndim > lhs.ndim() {
101        return Err(StridedError::RankMismatch(lhs_free_ndim, lhs.ndim()));
102    }
103    if rhs_free_ndim > rhs.ndim() {
104        return Err(StridedError::RankMismatch(rhs_free_ndim, rhs.ndim()));
105    }
106
107    let lhs_batch_ndim = lhs.ndim() - lhs_free_ndim;
108    let rhs_batch_ndim = rhs.ndim() - rhs_free_ndim;
109    if lhs_batch_ndim != rhs_batch_ndim {
110        return Err(StridedError::RankMismatch(lhs_batch_ndim, rhs_batch_ndim));
111    }
112
113    let expected_dest_rank = lhs_free_ndim + rhs_free_ndim + lhs_batch_ndim;
114    if dest.ndim() != expected_dest_rank {
115        return Err(StridedError::RankMismatch(dest.ndim(), expected_dest_rank));
116    }
117
118    ensure_dims(&dest.dims()[..lhs_free_ndim], &lhs.dims()[..lhs_free_ndim])?;
119    ensure_dims(
120        &dest.dims()[lhs_free_ndim..lhs_free_ndim + rhs_free_ndim],
121        &rhs.dims()[..rhs_free_ndim],
122    )?;
123    ensure_dims(
124        &dest.dims()[lhs_free_ndim + rhs_free_ndim..],
125        &lhs.dims()[lhs_free_ndim..],
126    )?;
127    ensure_dims(
128        &dest.dims()[lhs_free_ndim + rhs_free_ndim..],
129        &rhs.dims()[rhs_free_ndim..],
130    )?;
131
132    Ok(())
133}
134
135fn ensure_dims(actual: &[usize], expected: &[usize]) -> Result<()> {
136    if actual == expected {
137        Ok(())
138    } else {
139        Err(StridedError::ShapeMismatch(
140            actual.to_vec(),
141            expected.to_vec(),
142        ))
143    }
144}
145
146/// Storage layout for a lazily ordered outer product.
147///
148/// Returned by [`plan_lazy_outer_product`]. The caller allocates a dense
149/// column-major base of [`base_dims`](Self::base_dims), fills it through a
150/// destination descriptor over that base with the logical output dims and
151/// [`output_strides`](Self::output_strides) at offset zero (for example with
152/// [`broadcast_mul_into_uninit`] and the original axis maps), and then exposes
153/// the result as a strided view with those same dims and strides.
154#[derive(Clone, Debug, PartialEq, Eq)]
155pub struct LazyOuterProductLayout {
156    /// Column-major extents of the dense base allocation.
157    pub base_dims: Vec<usize>,
158    /// Strides, in base elements, of the logical output axes over the base.
159    pub output_strides: Vec<isize>,
160}
161
162/// Plan an outer-product output whose memory order follows the inputs'
163/// physical stride order instead of the logical output order.
164///
165/// `lhs_axes[k]` (respectively `rhs_axes[k]`) names the output axis that
166/// operand axis `k` maps to, with the operand extent equal to the output
167/// extent. Output axes mapped only by `lhs` are its free axes, axes mapped
168/// only by `rhs` are its free axes, and axes mapped by both are batch axes.
169///
170/// A layout is returned only when the product splits into free and batch
171/// groups, both free groups have more than one element, the logical output
172/// order is `[lhs_free, rhs_free, batch]` or `[rhs_free, lhs_free, batch]`,
173/// all strides are non-negative, and sorting either operand's free axes by
174/// `(stride, axis)` changes their order. The base then holds the leading
175/// group's free axes in the leading operand's physical order, then the
176/// trailing group's free axes in the trailing operand's physical order, then
177/// the batch axes in output order. Traversing inputs in their physical order
178/// while writing the base contiguously is what makes the layout useful.
179///
180/// # Examples
181///
182/// ```
183/// use strided_basic::plan_lazy_outer_product;
184///
185/// // lhs is a transposed 2 x 3 matrix (row-major strides), rhs is a vector.
186/// let layout = plan_lazy_outer_product(&[2, 3, 4], &[2, 3], &[3, 1], &[0, 1], &[4], &[1], &[2])
187///     .unwrap()
188///     .unwrap();
189/// assert_eq!(layout.base_dims, [3, 2, 4]);
190/// assert_eq!(layout.output_strides, [3, 1, 6]);
191/// ```
192///
193/// # Errors
194///
195/// Returns [`StridedError::RankMismatch`] when an operand's dims, strides,
196/// and axis map lengths disagree, and [`StridedError::OffsetOverflow`] when a
197/// base extent product or stride is not representable. Inputs that are valid
198/// but ineligible return `Ok(None)`.
199pub fn plan_lazy_outer_product(
200    output_dims: &[usize],
201    lhs_dims: &[usize],
202    lhs_strides: &[isize],
203    lhs_axes: &[usize],
204    rhs_dims: &[usize],
205    rhs_strides: &[isize],
206    rhs_axes: &[usize],
207) -> Result<Option<LazyOuterProductLayout>> {
208    for (dims, strides, axes) in [
209        (lhs_dims, lhs_strides, lhs_axes),
210        (rhs_dims, rhs_strides, rhs_axes),
211    ] {
212        if dims.len() != strides.len() {
213            return Err(StridedError::RankMismatch(dims.len(), strides.len()));
214        }
215        if dims.len() != axes.len() {
216            return Err(StridedError::RankMismatch(dims.len(), axes.len()));
217        }
218    }
219    if !extents_match_output(output_dims, lhs_dims, lhs_axes)
220        || !extents_match_output(output_dims, rhs_dims, rhs_axes)
221        || lhs_strides
222            .iter()
223            .chain(rhs_strides)
224            .any(|&stride| stride < 0)
225    {
226        return Ok(None);
227    }
228    let Some(partition) = classify_outer_axes(output_dims.len(), lhs_axes, rhs_axes) else {
229        return Ok(None);
230    };
231    if checked_axes_product(lhs_dims, &partition.lhs_free)? <= 1
232        || checked_axes_product(rhs_dims, &partition.rhs_free)? <= 1
233    {
234        return Ok(None);
235    }
236
237    let output_rank = output_dims.len();
238    let lhs_prefix = partition
239        .lhs_free_out
240        .iter()
241        .chain(&partition.rhs_free_out)
242        .chain(&partition.batch_out)
243        .copied()
244        .eq(0..output_rank);
245    let rhs_prefix = !lhs_prefix
246        && partition
247            .rhs_free_out
248            .iter()
249            .chain(&partition.lhs_free_out)
250            .chain(&partition.batch_out)
251            .copied()
252            .eq(0..output_rank);
253    if !lhs_prefix && !rhs_prefix {
254        return Ok(None);
255    }
256
257    let lhs_physical = axes_by_physical_stride(lhs_strides, &partition.lhs_free);
258    let rhs_physical = axes_by_physical_stride(rhs_strides, &partition.rhs_free);
259    if lhs_physical == partition.lhs_free && rhs_physical == partition.rhs_free {
260        return Ok(None);
261    }
262
263    // Base axis order: leading free, trailing free, then batch in output order.
264    let mut base_out_axes = Vec::with_capacity(output_rank);
265    let (leading, leading_axes, trailing, trailing_axes) = if lhs_prefix {
266        (&lhs_physical, lhs_axes, &rhs_physical, rhs_axes)
267    } else {
268        (&rhs_physical, rhs_axes, &lhs_physical, lhs_axes)
269    };
270    base_out_axes.extend(leading.iter().map(|&axis| leading_axes[axis]));
271    base_out_axes.extend(trailing.iter().map(|&axis| trailing_axes[axis]));
272    base_out_axes.extend(partition.batch_out.iter().copied());
273
274    let base_dims: Vec<usize> = base_out_axes
275        .iter()
276        .map(|&axis| output_dims[axis])
277        .collect();
278    let mut output_strides = vec![0isize; output_rank];
279    let mut stride = 1isize;
280    for (&out_axis, &extent) in base_out_axes.iter().zip(&base_dims) {
281        // INVARIANT: `classify_outer_axes` proved every output axis appears
282        // exactly once in the free/batch groups, so each slot is written once.
283        output_strides[out_axis] = stride;
284        let extent = isize::try_from(extent).map_err(|_| StridedError::OffsetOverflow)?;
285        stride = stride
286            .checked_mul(extent)
287            .ok_or(StridedError::OffsetOverflow)?;
288    }
289    Ok(Some(LazyOuterProductLayout {
290        base_dims,
291        output_strides,
292    }))
293}
294
295struct OuterAxisPartition {
296    lhs_free_out: Vec<usize>,
297    rhs_free_out: Vec<usize>,
298    batch_out: Vec<usize>,
299    lhs_free: Vec<usize>,
300    rhs_free: Vec<usize>,
301}
302
303fn extents_match_output(output_dims: &[usize], dims: &[usize], axes: &[usize]) -> bool {
304    dims.iter()
305        .zip(axes)
306        .all(|(&dim, &axis)| output_dims.get(axis) == Some(&dim))
307}
308
309fn operand_axes_by_output(axes: &[usize], output_rank: usize) -> Option<Vec<Option<usize>>> {
310    let mut by_output = vec![None; output_rank];
311    for (operand_axis, &output_axis) in axes.iter().enumerate() {
312        if by_output
313            .get_mut(output_axis)?
314            .replace(operand_axis)
315            .is_some()
316        {
317            return None;
318        }
319    }
320    Some(by_output)
321}
322
323fn classify_outer_axes(
324    output_rank: usize,
325    lhs_axes: &[usize],
326    rhs_axes: &[usize],
327) -> Option<OuterAxisPartition> {
328    let lhs_by_output = operand_axes_by_output(lhs_axes, output_rank)?;
329    let rhs_by_output = operand_axes_by_output(rhs_axes, output_rank)?;
330    let mut partition = OuterAxisPartition {
331        lhs_free_out: Vec::new(),
332        rhs_free_out: Vec::new(),
333        batch_out: Vec::new(),
334        lhs_free: Vec::new(),
335        rhs_free: Vec::new(),
336    };
337    for output_axis in 0..output_rank {
338        match (lhs_by_output[output_axis], rhs_by_output[output_axis]) {
339            (Some(_), Some(_)) => partition.batch_out.push(output_axis),
340            (Some(lhs_axis), None) => {
341                partition.lhs_free_out.push(output_axis);
342                partition.lhs_free.push(lhs_axis);
343            }
344            (None, Some(rhs_axis)) => {
345                partition.rhs_free_out.push(output_axis);
346                partition.rhs_free.push(rhs_axis);
347            }
348            (None, None) => return None,
349        }
350    }
351    Some(partition)
352}
353
354fn checked_axes_product(dims: &[usize], axes: &[usize]) -> Result<usize> {
355    // A zero extent makes the group empty even when the other extents
356    // overflow.
357    if axes.iter().any(|&axis| dims[axis] == 0) {
358        return Ok(0);
359    }
360    axes.iter().try_fold(1usize, |acc, &axis| {
361        acc.checked_mul(dims[axis])
362            .ok_or(StridedError::OffsetOverflow)
363    })
364}
365
366fn axes_by_physical_stride(strides: &[isize], axes: &[usize]) -> Vec<usize> {
367    let mut sorted = axes.to_vec();
368    sorted.sort_by(|&lhs, &rhs| strides[lhs].cmp(&strides[rhs]).then(lhs.cmp(&rhs)));
369    sorted
370}