You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Interpolators (Interp1D etc) currently map grid coordinates to a single function
output values. Some real cases share one grid across several values instead: neopdf (quark flavors), RGB images (3 color channels). Sharing the grid skips repeated
binary searches (or LinearUniform-style location evaluation) for the same point across
channels, and stores one copy of the grid axes instead of one per channel.
Alongside the existing 1-to-1 interpolators, add 1-to-many versions for Interp1D, Interp2D, Interp3D, and InterpND. The pattern is identical across Interp1D/2D/3D; 2D is shown below as the representative case. InterpND differs
only where runtime rank forces it to, spelled out under InterpND below.
API changes
InterpData2DMulti (+ InterpData1DMulti/InterpData3DMulti): hand-rolled, not a
generic extension of InterpData<D, N> (see Design notes for why).
#[derive(Debug,Clone)]pubstructInterpData2DMulti<D>whereD:Data + RawDataClone + Clone,D::Elem:PartialEq + Debug,{pubgrid:[ArrayBase<D,Ix1>;2],/// Shape `[nx, ny, n_channels]`.pubvalues:ArrayBase<D,Ix3>,}/// [`InterpData2DMulti`] that views data.pubtypeInterpData2DMultiViewed<T> = InterpData2DMulti<ViewRepr<T>>;/// [`InterpData2DMulti`] that owns data.pubtypeInterpData2DMultiOwned<T> = InterpData2DMulti<OwnedRepr<T>>;/// Hand-written, not derived, mirroring `InterpData<D, N>`'s own `PartialEq` impl/// exactly (`src/interpolator/data.rs`): required by `partialeq_impl!` below, which/// bounds on `InterpData2DMulti<D>: PartialEq`. Without this, that bound is never/// satisfied and the `PartialEq for Interp2DMulti<D, S>` impl it generates would be/// present but permanently unusable.impl<D>PartialEqforInterpData2DMulti<D>whereD:Data + RawDataClone + Clone,D::Elem:PartialEq + Debug,ArrayBase<D,Ix1>:PartialEq,{fneq(&self,other:&Self) -> bool{self.grid == other.grid && self.values == other.values}}impl<D>InterpData2DMulti<D>whereD:Data + RawDataClone + Clone,D::Elem:PartialOrd + Debug,{/// Same checks as `InterpData<D, N>::validate` (grid length, monotonicity,/// grid/values shape agreement), plus a non-empty channel axis: a grid with no/// channels has nothing for `Strategy2DMulti::interpolate_into` to write.pubfnvalidate(&self) -> Result<(),ValidateError>{ifself.n_channels() == 0{returnErr(ValidateError::Other("InterpData2DMulti requires at least 1 channel".to_string(),));}for i in0..2{let i_grid_len = self.grid[i].len();if i_grid_len < 2{returnErr(ValidateError::InsufficientGridPoints(i));}if !self.grid[i].windows(2).into_iter().all(|w| w[0] <= w[1]){returnErr(ValidateError::NonMonotonic(i));}if i_grid_len != self.values.shape()[i]{returnErr(ValidateError::IncompatibleShapes(i));}}Ok(())}pubfnn_channels(&self) -> usize{self.values.shape()[2]}/// Borrow channel `k` as a standalone, viewed `InterpData2D`.////// # Note/// The returned `values` view is **strided, not contiguous**: with channels last,/// `index_axis(Axis(2), k)` steps by `n_channels`. A strategy that reaches for/// `data.values.as_slice().unwrap()` will panic on it. Index via `ArrayView`/// instead, the same guidance `Strategy2D::interpolate` already carries for/// `Interp*Viewed`.pubfnchannel_view(&self,k:usize) -> InterpData2DViewed<&D::Elem>{InterpData2D{grid: std::array::from_fn(|i| self.grid[i].view()),values:self.values.index_axis(Axis(2), k),}}}
InterpData1DMulti/InterpData3DMulti: grid: [ArrayBase<D, Ix1>; 1 or 3], values: ArrayBase<D, Ix2 or Ix4>, same three methods, Owned/Viewed aliases, and hand-written PartialEq impl.
Channel axis is last ([nx, ny, n_channels]) so that evaluating every channel at one
point walks contiguous memory in C order. This is the same insight behind neopdf's
hand-rolled [cell][flavor][4] interleaved layout. The cost is that the single-channel
view is the strided one, which is the right trade given the type exists for the
all-channel case.
Strategy2DMulti: independent trait, no supertrait relationship to Strategy2D (see
Design notes for why not).
pubtraitStrategy2DMulti<D>:Debug + DynClonewhereD:Data + RawDataClone + Clone,D::Elem:PartialEq + Debug,{fnvalidate(&self,_data:&InterpData2DMulti<D>) -> Result<(),ValidateError>{Ok(())}fninit(&mutself,_data:&InterpData2DMulti<D>) -> Result<(),ValidateError>{Ok(())}/// Interpolate every channel at `point`, writing channel `k` into `out[k]`./// `out.len()` must equal `data.n_channels()`.////// The only required method. Per #45, the out-slice form is what strategies/// implement and the allocating forms below are defaulted wrappers over it: a/// multi-channel result is a `Vec` per *point*, so a `Vec`-returning required/// method would put an allocation in every single-point call, not just per batch.fninterpolate_into(&self,data:&InterpData2DMulti<D>,point:&[D::Elem;2],out:&mut[D::Elem],) -> Result<(),InterpolateError>;/// Unchecked [`Strategy2DMulti::interpolate_into`]. Default just unwraps it.fninterpolate_fast_into(&self,data:&InterpData2DMulti<D>,point:&[D::Elem;2],out:&mut[D::Elem],){/* default: unwrap interpolate_into */}/// Interpolate every channel at each of several points, sharing one grid across/// all of them. `out.len()` must equal `points.len() * data.n_channels()`; point/// `p`'s channels occupy `out[p * n_channels ..][.. n_channels]`.////// Flat, not `Vec<Vec<_>>`: nesting costs one allocation per point and forces a/// transpose on any caller that wants channel-major output. Default loops/// [`Strategy2DMulti::interpolate_into`] over the chunks. Override only if/// locating a point can be amortized across both channels *and* the batch at/// once, e.g. a Chebyshev-style strategy computing one coefficient matrix/// covering every point and every channel in a single pass.fnbatch_interpolate_into(&self,data:&InterpData2DMulti<D>,points:&[[D::Elem;2]],out:&mut[D::Elem],) -> Result<(),InterpolateError>{/* default: chunk out, loop interpolate_into */}fnbatch_interpolate_fast_into(&self,data:&InterpData2DMulti<D>,points:&[[D::Elem;2]],out:&mut[D::Elem],){/* default: chunk out, loop interpolate_fast_into */}/// Allocating [`Strategy2DMulti::interpolate_into`]. Defaulted, do not override.fninterpolate(&self,data:&InterpData2DMulti<D>,point:&[D::Elem;2],) -> Result<Vec<D::Elem>,InterpolateError>{/* default: alloc, call _into */}// ... `interpolate_fast`, `batch_interpolate` (-> Vec<D::Elem>, flat, same// chunking as above), `batch_interpolate_fast`, all defaulted the same way.}
validate/init default exactly like Strategy2D's own. Every built-in strategy needs
a real, hand-written interpolate_into, not a mechanical loop over its scalar interpolate: each one has a per-axis locate step (nearest index, step-direction index,
fractional blend position) that a naive per-channel loop would re-run once per channel for
the same point, exactly the repeated-search cost this issue exists to avoid.
Nearest locates each axis once (already how its scalar interpolate works, via locate_lower_index plus a distance comparison to the nearest grid index [i, j]), then
loops channels for a direct data.values[[i, j, k]] lookup, no blending.
Linear locates each axis once via locate_axis (#29, src/strategy/utils.rs, already
on main), then reuses that result to blend all n_channels values:
blend_from_locations is the same bilinear blend Strategy2D for Linear (already on main, src/interpolator/two/strategies.rs) does today, factored out of that impl into
a pub(crate) free function in src/strategy/utils.rs so it can run once per channel
against a pre-computed locations instead of each channel re-deriving its own via locate_axis. Signature:
pub(crate)fnblend_from_locations<T:Float>(locations:&[AxisLocation<T>;2],values:&ArrayView2<T>,) -> T
Body is the existing 4-way Exact/Interp match, verbatim, taking locations as an
argument rather than deriving them from point.
Strategy2D for Linear's own interpolate is left as-is, not required to route through
this helper too: nothing needs it to for correctness, since the two impls share only the
shape of the math, not state. Worth doing later as a dedupe pass, not part of this issue.
Step/StepLower/StepUpper/LinearUniform follow the same overall shape: locate once
via whichever helper they already use (locate_step_index/locate_lower_index_uniform),
then loop channels for the lookup or blend. None of them need a shared helper the way Linear does, since their existing single-point logic is already a plain index lookup or
a single two-point blend, not a 4-way match worth factoring out.
A stateful strategy (illustrative only, not proposed here) would cache per-channel state
in init by looping data.channel_view(k) for k in 0..data.n_channels(), then still
locate once per point in interpolate_into and blend against each channel's cached
state. The load-bearing detail: init sees all channels at once (&InterpData2DMulti<D>),
which is what makes a joint layout across channels expressible, not just a Vec of
independent per-channel states. See Design notes.
Interp2DMulti wrapper (mirrors Interp2D, reuses its macros):
new mirrors Interp2D::new exactly: data.validate()?, then check_extrapolate, then strategy.validate, then strategy.init. (D::Elem: Float, this impl block's own bound,
already implies the PartialOrd that validate needs.)
Inherent methods, matching the trait's method set: interpolate_into, interpolate_fast_into, batch_interpolate_into, batch_interpolate_fast_into, plus the
four allocating wrappers. No _multi suffix on any of them, the struct name already
carries it.
The extrapolate handling is the same per-axis logic Interp2D::interpolate and Interp2D::batch_interpolate (#21) already have, adapted in exactly one way: Extrapolate::Fill(value) writes value into all n_channels slots for that point
rather than a single slot. Enable/Clamp (unconditional point transform), Wrap
(conditional on out_of_bounds, since it is not identity at the boundary), and Error
(aggregating every offending point and dimension into one ExtrapolateError) are
unchanged. The batch version's per-mode partitioning is batch_interpolate_impl! in src/interpolator/mod.rs; parameterizing that macro over channel count is preferable to
duplicating it per Interp*Multi type.
InterpND. Same design, with three differences forced by runtime rank:
pubstructInterpDataNDMulti<D>{pubgrid:Vec<ArrayBase<D,Ix1>>,/// Rank `grid.len() + 1`; the trailing axis is channels.pubvalues:ArrayBase<D,IxDyn>,}
Rank check replaces the type-level guarantee.InterpDataND::validate can assume grid.len() == values.ndim(). Here validate must first check values.ndim() == grid.len() + 1 and return ValidateError::IncompatibleShapes otherwise, since nothing
in the type distinguishes a rank-n multi grid from a rank-n single grid. n_channels() is values.shape()[self.grid.len()], and channel_view(k) is values.index_axis(Axis(self.grid.len()), k).
Hand-written batch methods. There is no fixed N to route an inherent
array-typed method through, so InterpNDMulti::batch_interpolate_into gets the same
hand-written treatment InterpND::batch_interpolate already got in Batch interpolation #21, rather than
going through the shared macro.
InterpNDMulti is not optional scope. A downstream consumer with more than three grid
axes has no other route, and neopdf specifically routes 7 of its 13 grid configurations
through InterpND (see Downstream motivation).
InterpolatorMulti<T> (src/interpolator/mod.rs), the multi-channel analog of Interpolator<T>, filling out the same structural parallel Interp*Multi/Strategy*Multi
already commit to:
pubtraitInterpolatorMulti<T>:DynClone{fnndim(&self) -> usize;fnn_channels(&self) -> usize;fnvalidate(&self) -> Result<(),ValidateError>;fnset_extrapolate(&mutself,extrapolate:Extrapolate<T>) -> Result<(),ValidateError>;/// Interpolate every channel at `point`, writing into `out`. `out.len()` must equal/// `self.n_channels()`. The required method, per #45.fninterpolate_into(&self,point:&[T],out:&mut[T]) -> Result<(),InterpolateError>;fninterpolate_fast_into(&self,point:&[T],out:&mut[T]){self.interpolate_into(point, out).expect("interpolate_fast_into: invalid point or data")}/// Default chunks `out` by `n_channels` and loops [`InterpolatorMulti::interpolate_into`].fnbatch_interpolate_into(&self,points:&[&[T]],out:&mut[T],) -> Result<(),InterpolateError>{/* default: chunk out, loop interpolate_into */}fnbatch_interpolate_fast_into(&self,points:&[&[T]],out:&mut[T]){/* same, _fast_into */}/// Allocating [`InterpolatorMulti::interpolate_into`]. Defaulted, do not override.fninterpolate(&self,point:&[T]) -> Result<Vec<T>,InterpolateError>{/* default: alloc, call interpolate_into */}// ... `interpolate_fast`, `batch_interpolate`, `batch_interpolate_fast`, all// defaulted the same way: allocate, call the `_into` form.}clone_trait_object!(<T> InterpolatorMulti<T>);impl<T>InterpolatorMulti<T>forBox<dynInterpolatorMulti<T>>{// forwards every method to `(**self)`, same shape as `Interpolator<T>`'s own// `Box<dyn Interpolator<T>>` impl}
Implemented for Interp1DMulti/2DMulti/3DMulti/NDMulti<D, S>, both Owned and Viewed (unlike DynInterpolatorMulti below, nothing here needs 'static): each interpolate_into/batch_interpolate_into converts the incoming slice(s) to the fixed &[T; N]/&[[T; N]] the inherent methods take first, the same shadowing #39/#21 force on
the scalar Interpolator<T> impls, then forwards. InterpNDMulti needs no conversion,
same as InterpND. Bounds match each Interp*Multi's own Interpolator-equivalent impl
(e.g. D::Elem: Num + PartialOrd + Euclid + Copy + Debug for 2D, mirroring Interp2D's).
Every method needs an explicit override in each impl, not just interpolate_into,
for the reason #21/#46 give for Interpolator<T>: a defaulted body called through Box<dyn InterpolatorMulti<T>> dispatches through this trait's vtable once per point, instead of
reaching the concrete type's real implementation in one.
DynInterpolatorMulti, extending InterpolatorMulti<T> the same way #46's DynInterpolator extends Interpolator<T>:
Blanket impls for Interp1DMultiOwned/Interp2DMultiOwned/Interp3DMultiOwned/ InterpNDMultiOwned only: as_any requires Self: 'static, which Interp*MultiViewed
can't satisfy. Each impl is just fn as_any(&self) -> &dyn Any { self }; n_channels/ interpolate_into/etc. are all inherited from InterpolatorMulti<T> for free.
Where this lives
Mirrors the existing per-dimensionality split (mod.rs/strategies.rs/tests.rs under one/two/three/n); 2D shown, others identical.
File
Contents
src/interpolator/two/multi.rs (new)
InterpData2DMulti/Owned/Viewed, Interp2DMulti/Owned/Viewed, extrapolate_impl!/partialeq_impl! invocations, impl InterpolatorMulti<T> for Interp2DMulti<D, S>, impl DynInterpolatorMulti<T> for Interp2DMultiOwned<T, S>
src/interpolator/two/mod.rs
+ mod multi; and re-exports of its public types
src/interpolator/two/strategies.rs
+ impl Strategy2DMulti<D> for Nearest/Linear/Step/StepLower/StepUpper/LinearUniform
+ pub use two::{InterpData2DMulti, InterpData2DMultiOwned, InterpData2DMultiViewed}; and equivalents, mirroring the existing non-multi line
src/interpolator/mod.rs
+ InterpolatorMulti<T> definition, Box<dyn InterpolatorMulti<T>> forwarding impl, and clone_trait_object! call, next to Interpolator<T>; + DynInterpolatorMulti<T> definition, next to DynInterpolator (#46); + re-exports of Interp{1,2,3}DMulti{,Owned,Viewed}, InterpNDMulti{,Owned,Viewed}, InterpolatorMulti, DynInterpolatorMulti; parameterize batch_interpolate_impl! over channel count
src/lib.rsprelude
+ the Interp*Multi types and InterpolatorMulti, joining Interp*D/Interpolator today. InterpData*Multi stays out of prelude, matching InterpData2D today: fully public via interpolator::data, just not in the curated re-export. DynInterpolatorMulti stays out too, same reasoning as #46's DynInterpolator (see Design notes). Strategy*Multi needs no line either, pub use crate::strategy; already covers it.
Strategy2DEnum (src/strategy/enums/two.rs) is untouched; no Strategy2DMultiEnum or InterpolatorMultiEnum proposed here, see Non-goals.
Design notes
Hand-rolled, not InterpData<D, N> generalized: computing Dim<[Ix; N + 1]> from const N: usize needs unstable generic_const_exprs. Not available on stable. Does
not apply to InterpDataNDMulti, where IxDyn already carries rank at runtime, which
is why that one is a validation check rather than a type-level guarantee.
interpolate is a separate method, not a unified signature: forcing the scalar path
to return Vec<D::Elem> would tax every single-channel call with a heap allocation.
interpolate_into is the required method, not interpolate: see Allocation-free *_into interpolation variants #45. The
argument is sharper here than for the scalar batch case, because a multi-channel result
is per-point rather than per-batch.
Strategy2DMulti does not extend Strategy2D: an earlier draft did
(Strategy2DMulti<D>: Strategy2D<D>), with interpolate's default delegating to the
inherited scalar interpolate per channel. That breaks two ways. The scalar method has
no channel index, so a strategy caching per-channel or joint coefficients cannot tell
which channel it is being asked about. And the inherited validate/init take &InterpData2D<D>, single-channel-shaped, so there is no correct way to call them
against InterpData2DMulti<D>: one arbitrary channel ignores the rest, and looping the
scalar method against the same &mut self makes the last channel silently win.
Dropping the bound fixes both, at the cost of no blanket/macro opt-in for stateless
strategies. That cost is zero in practice: no strategy in this crate is stateless
enough to want the mechanical per-channel loop, every one has a locate step worth
sharing. This is not a theoretical concern either, see Downstream motivation.
InterpolatorMulti<T> mirrors Interpolator<T>, not Strategy*Multi: its required
method is the allocation-free interpolate_into, not an allocating interpolate, same
reasoning as Strategy*Multi's own required method above: a multi-channel result is
per-point, so an allocating required method taxes every call, not just batches. Every
other method (interpolate, _fast, batch_*) is a defaulted wrapper over it, mirroring
the shape Strategy*Multi already commits to. n_channels lives on the trait itself, not
just DynInterpolatorMulti: even a non-erased Box<dyn InterpolatorMulti<T>> caller has
to size out before calling, without downcasting.
DynInterpolatorMulti extends InterpolatorMulti<T>: same reasoning DynInterpolator: object-safe, downcastable interpolator trait #46 gives for DynInterpolator extending Interpolator<T>: the borrowed Interp*MultiViewed types
simply don't implement DynInterpolatorMulti (still blocked by as_any needing Self: 'static), InterpolatorMulti<T> itself is untouched. Collapses DynInterpolatorMulti to
one method, as_any; Send/Sync stay scoped per-impl for the same reason a custom Strategy2D (examples/custom_strategy.rs) may hold non-thread-safe state.
InterpolatorMulti<T> joins the prelude, DynInterpolatorMulti stays out: mirrors DynInterpolator: object-safe, downcastable interpolator trait #46's identical split for Interpolator<T>/DynInterpolator. prelude is curated for
the common path; heterogeneous storage + downcasting (the neopdf case) is a narrower,
advanced use case, one explicit use away for consumers who need it.
Flat batch output, not Vec<Vec<T>>: nesting allocates per point and fixes an
orientation on the caller. A flat out with documented n_channels chunking lets the
caller own the layout.
Naming: type and trait names carry a trailing Multi throughout; none of their own
methods repeat it, Multi in the name already says so, InterpolatorMulti/ DynInterpolatorMulti included, matching how DynInterpolator: object-safe, downcastable interpolator trait #46 settled the same question for DynInterpolator (the trait, not a method-name suffix, is what disambiguates a type-erased
call). batch_ is a prefix, not a suffix like _fast/_into, for the reason Batch interpolation #21 gives: it
changes what is being operated on (many points instead of one), rather than being a
variant of the same operation.
Downstream motivation
QCDLab/neopdf is the driving consumer, and it has moved well past hand-rolling one
trait. As of aeb45a0 it maintains two complete shared-grid multi-channel evaluators
that bypass ninterp entirely, both structured exactly as this issue proposes:
locate() once per point, then eval_allpids(&loc, pid_slots, force_positive_fn, out: &mut [f64]) over flavors. Hermite x-coefficients precomputed at build time into a [cell][flavor][4] interleaved layout.
ChebyshevAllPids (neopdf/src/strategy.rs)
Same split. locate() returns barycentric coefficients per dimension, eval_allpids contracts them per flavor.
Both are reached before the ninterp-backed path in GridPDF::xfxq2_allpids, with Vec<Vec<Box<dyn DynInterpolator>>> as the generic fallback. The ninterp path is already
the slow path for every grid type neopdf ships.
Three things follow.
The trait shape is validated by working code.InterleavedHermite's interleaved
coefficient layout is per-channel state built jointly across all channels, which is
expressible as Strategy2DMulti::init precisely because init sees &InterpData2DMulti<D>. Under the rejected supertrait design, with init taking &InterpData2D<D> per channel, it would not have been. The Design note above is not
hypothetical.
Memory duplication is the unglamorous win.InterpolatorFactory::create is called
once per (subgrid, flavor) and does subgrid.grid_slice(pid_index).to_owned() plus its
own subgrid.xs.mapv(f64::ln) and q2s.mapv(f64::ln). So the log-transformed axis arrays
are recomputed and stored once per flavor. On top of that, GridPDF retains the original knot_arrayand the interleaved coefficients (themselves 4 floats per cell per flavor).
One InterpData*Multi per subgrid collapses the axis duplication outright and makes the
values a single array.
Dimensional coverage matters.InterpolationConfig has 13 variants routed to Interp2D (1), Interp3D (5), and InterpND (7). Both fast paths have build arms for
2D through 5D. Shipping this issue without InterpNDMulti would leave both hand-rolled
evaluators alive for the 4D and 5D configurations, which is why ND is in scope here rather
than deferred.
Not absorbed by this issue, correctly: neopdf's cross-subgrid point routing (grouping a
batch by which of several interpolators each point falls into before calling the batch
method on each) is PDF-domain logic, and its force_positive clipping is a post-map.
Non-goals
No unification of scalar and multi-channel trait method signatures.
No const-generic channel-count parameter: encoding it into the array rank on stable
needs the same unstable generic_const_exprs as the hand-rolled-struct decision above.
Channel count is a runtime axis size instead.
No per-channel strategy selection: sharing one locate step per point requires one
strategy for all channels. Separate Interp2D/Interp2DViewed instances already cover
channels that genuinely need different strategies. Same reasoning rules out a Vec<S>/[S; N] of per-channel strategy instances even of the same type: it
reintroduces the naive per-channel cost.
No channel subsetting, tracked separately in Channel subsetting for Strategy*Multi #47. interpolate_into here
always evaluates every channel. Subsetting is additive on top of this trait (defaultable
in terms of interpolate_into), but it should not lag far behind: without it, a
consumer with both single-channel and all-channel access patterns has to keep
per-channel Interp2D instances alongside Interp2DMulti, which gives back the memory
win this issue is partly here for.
No Box<dyn Strategy1DMulti/2DMulti/3DMulti/NDMulti<D>> support in this pass, and
therefore no forwarding concern for one. Interp2D-style boxed-strategy runtime
swapping was never proposed for the *Multi wrappers. Revisit if it is added later,
following Batch interpolation #21's Box<dyn Strategy1D/2D/3D/ND<D>> precedent exactly.
No Strategy*MultiEnum/InterpolatorMultiEnum, the *Multi counterpart to Strategy*Enum/InterpolatorEnum (src/interpolator/enums.rs, src/strategy/enums/). InterpolatorEnum's variants are Interp1D<D, Strategy1DEnum> etc., so this needs four
new Strategy*MultiEnum types before an InterpolatorMultiEnum wrapping them is even
possible, doubling the existing enum-dispatch surface. That module already hand-rolls
what enum_dispatch would give for free if it supported a generic trait on a
non-generic enum (see the NOTE at the top of enums.rs); doubling the current
boilerplate before addressing that seems like the wrong order. Box<dyn InterpolatorMulti<T>>/Box<dyn DynInterpolatorMulti<T>> cover the runtime-polymorphism
need in the meantime, same as Box<dyn Interpolator<T>> did before InterpolatorEnum
existed.
DynInterpolator: object-safe, downcastable interpolator trait #46: defines the scalar Interpolator<T>/DynInterpolator<T> pair in src/interpolator/mod.rs, the precedent InterpolatorMulti<T>/DynInterpolatorMulti<T>
follow here (subtrait extension, not a redeclared method set). Independent in both
directions.
Motivation
Interpolators (
Interp1Detc) currently mapgridcoordinates to a single functionoutput
values. Some real cases share onegridacross severalvaluesinstead:neopdf(quark flavors), RGB images (3 color channels). Sharing the grid skips repeatedbinary searches (or
LinearUniform-style location evaluation) for the same point acrosschannels, and stores one copy of the grid axes instead of one per channel.
Alongside the existing 1-to-1 interpolators, add 1-to-many versions for
Interp1D,Interp2D,Interp3D, andInterpND. The pattern is identical acrossInterp1D/2D/3D; 2D is shown below as the representative case.InterpNDdiffersonly where runtime rank forces it to, spelled out under
InterpNDbelow.API changes
InterpData2DMulti(+InterpData1DMulti/InterpData3DMulti): hand-rolled, not ageneric extension of
InterpData<D, N>(see Design notes for why).InterpData1DMulti/InterpData3DMulti:grid: [ArrayBase<D, Ix1>; 1 or 3],values: ArrayBase<D, Ix2 or Ix4>, same three methods,Owned/Viewedaliases, and hand-writtenPartialEqimpl.Channel axis is last (
[nx, ny, n_channels]) so that evaluating every channel at onepoint walks contiguous memory in C order. This is the same insight behind
neopdf'shand-rolled
[cell][flavor][4]interleaved layout. The cost is that the single-channelview is the strided one, which is the right trade given the type exists for the
all-channel case.
Strategy2DMulti: independent trait, no supertrait relationship toStrategy2D(seeDesign notes for why not).
validate/initdefault exactly likeStrategy2D's own. Every built-in strategy needsa real, hand-written
interpolate_into, not a mechanical loop over its scalarinterpolate: each one has a per-axis locate step (nearest index, step-direction index,fractional blend position) that a naive per-channel loop would re-run once per channel for
the same point, exactly the repeated-search cost this issue exists to avoid.
Nearestlocates each axis once (already how its scalarinterpolateworks, vialocate_lower_indexplus a distance comparison to the nearest grid index[i, j]), thenloops channels for a direct
data.values[[i, j, k]]lookup, no blending.Linearlocates each axis once vialocate_axis(#29,src/strategy/utils.rs, alreadyon
main), then reuses that result to blend alln_channelsvalues:blend_from_locationsis the same bilinear blendStrategy2D for Linear(already onmain,src/interpolator/two/strategies.rs) does today, factored out of thatimplintoa
pub(crate)free function insrc/strategy/utils.rsso it can run once per channelagainst a pre-computed
locationsinstead of each channel re-deriving its own vialocate_axis. Signature:Body is the existing 4-way
Exact/Interpmatch, verbatim, takinglocationsas anargument rather than deriving them from
point.Strategy2D for Linear's owninterpolateis left as-is, not required to route throughthis helper too: nothing needs it to for correctness, since the two impls share only the
shape of the math, not state. Worth doing later as a dedupe pass, not part of this issue.
Step/StepLower/StepUpper/LinearUniformfollow the same overall shape: locate oncevia whichever helper they already use (
locate_step_index/locate_lower_index_uniform),then loop channels for the lookup or blend. None of them need a shared helper the way
Lineardoes, since their existing single-point logic is already a plain index lookup ora single two-point blend, not a 4-way match worth factoring out.
A stateful strategy (illustrative only, not proposed here) would cache per-channel state
in
initby loopingdata.channel_view(k)fork in 0..data.n_channels(), then stilllocate once per point in
interpolate_intoand blend against each channel's cachedstate. The load-bearing detail:
initsees all channels at once (&InterpData2DMulti<D>),which is what makes a joint layout across channels expressible, not just a
Vecofindependent per-channel states. See Design notes.
Interp2DMultiwrapper (mirrorsInterp2D, reuses its macros):newmirrorsInterp2D::newexactly:data.validate()?, thencheck_extrapolate, thenstrategy.validate, thenstrategy.init. (D::Elem: Float, this impl block's own bound,already implies the
PartialOrdthatvalidateneeds.)Inherent methods, matching the trait's method set:
interpolate_into,interpolate_fast_into,batch_interpolate_into,batch_interpolate_fast_into, plus thefour allocating wrappers. No
_multisuffix on any of them, the struct name alreadycarries it.
The extrapolate handling is the same per-axis logic
Interp2D::interpolateandInterp2D::batch_interpolate(#21) already have, adapted in exactly one way:Extrapolate::Fill(value)writesvalueinto alln_channelsslots for that pointrather than a single slot.
Enable/Clamp(unconditional point transform),Wrap(conditional on
out_of_bounds, since it is not identity at the boundary), andError(aggregating every offending point and dimension into one
ExtrapolateError) areunchanged. The batch version's per-mode partitioning is
batch_interpolate_impl!insrc/interpolator/mod.rs; parameterizing that macro over channel count is preferable toduplicating it per
Interp*Multitype.InterpND. Same design, with three differences forced by runtime rank:InterpDataND::validatecan assumegrid.len() == values.ndim(). Herevalidatemust first checkvalues.ndim() == grid.len() + 1and returnValidateError::IncompatibleShapesotherwise, since nothingin the type distinguishes a rank-
nmulti grid from a rank-nsingle grid.n_channels()isvalues.shape()[self.grid.len()], andchannel_view(k)isvalues.index_axis(Axis(self.grid.len()), k).StrategyNDMulti::interpolate_into(&self, data, point: &[D::Elem], out: &mut [D::Elem]), matchingStrategyND.InterpNDMulti's batchmethods take
&[&[D::Elem]].Nto route an inherentarray-typed method through, so
InterpNDMulti::batch_interpolate_intogets the samehand-written treatment
InterpND::batch_interpolatealready got in Batch interpolation #21, rather thangoing through the shared macro.
InterpNDMultiis not optional scope. A downstream consumer with more than three gridaxes has no other route, and
neopdfspecifically routes 7 of its 13 grid configurationsthrough
InterpND(see Downstream motivation).InterpolatorMulti<T>(src/interpolator/mod.rs), the multi-channel analog ofInterpolator<T>, filling out the same structural parallelInterp*Multi/Strategy*Multialready commit to:
Implemented for
Interp1DMulti/2DMulti/3DMulti/NDMulti<D, S>, bothOwnedandViewed(unlikeDynInterpolatorMultibelow, nothing here needs'static): eachinterpolate_into/batch_interpolate_intoconverts the incoming slice(s) to the fixed&[T; N]/&[[T; N]]the inherent methods take first, the same shadowing #39/#21 force onthe scalar
Interpolator<T>impls, then forwards.InterpNDMultineeds no conversion,same as
InterpND. Bounds match eachInterp*Multi's ownInterpolator-equivalent impl(e.g.
D::Elem: Num + PartialOrd + Euclid + Copy + Debugfor 2D, mirroringInterp2D's).Every method needs an explicit override in each impl, not just
interpolate_into,for the reason #21/#46 give for
Interpolator<T>: a defaulted body called throughBox<dyn InterpolatorMulti<T>>dispatches through this trait's vtable once per point, instead ofreaching the concrete type's real implementation in one.
DynInterpolatorMulti, extendingInterpolatorMulti<T>the same way #46'sDynInterpolatorextendsInterpolator<T>:Blanket impls for
Interp1DMultiOwned/Interp2DMultiOwned/Interp3DMultiOwned/InterpNDMultiOwnedonly:as_anyrequiresSelf: 'static, whichInterp*MultiViewedcan't satisfy. Each impl is just
fn as_any(&self) -> &dyn Any { self };n_channels/interpolate_into/etc. are all inherited fromInterpolatorMulti<T>for free.Where this lives
Mirrors the existing per-dimensionality split (
mod.rs/strategies.rs/tests.rsunderone/two/three/n); 2D shown, others identical.src/interpolator/two/multi.rs(new)InterpData2DMulti/Owned/Viewed,Interp2DMulti/Owned/Viewed,extrapolate_impl!/partialeq_impl!invocations,impl InterpolatorMulti<T> for Interp2DMulti<D, S>,impl DynInterpolatorMulti<T> for Interp2DMultiOwned<T, S>src/interpolator/two/mod.rsmod multi;and re-exports of its public typessrc/interpolator/two/strategies.rsimpl Strategy2DMulti<D> for Nearest/Linear/Step/StepLower/StepUpper/LinearUniformsrc/strategy/traits.rsStrategy1DMulti/2DMulti/3DMulti/NDMultidefinitionssrc/strategy/utils.rspub(crate) blend_from_locationssrc/interpolator/data.rspub use two::{InterpData2DMulti, InterpData2DMultiOwned, InterpData2DMultiViewed};and equivalents, mirroring the existing non-multi linesrc/interpolator/mod.rsInterpolatorMulti<T>definition,Box<dyn InterpolatorMulti<T>>forwarding impl, andclone_trait_object!call, next toInterpolator<T>; +DynInterpolatorMulti<T>definition, next toDynInterpolator(#46); + re-exports ofInterp{1,2,3}DMulti{,Owned,Viewed},InterpNDMulti{,Owned,Viewed},InterpolatorMulti,DynInterpolatorMulti; parameterizebatch_interpolate_impl!over channel countsrc/lib.rspreludeInterp*Multitypes andInterpolatorMulti, joiningInterp*D/Interpolatortoday.InterpData*Multistays out ofprelude, matchingInterpData2Dtoday: fully public viainterpolator::data, just not in the curated re-export.DynInterpolatorMultistays out too, same reasoning as #46'sDynInterpolator(see Design notes).Strategy*Multineeds no line either,pub use crate::strategy;already covers it.Strategy2DEnum(src/strategy/enums/two.rs) is untouched; noStrategy2DMultiEnumorInterpolatorMultiEnumproposed here, see Non-goals.Design notes
InterpData<D, N>generalized: computingDim<[Ix; N + 1]>fromconst N: usizeneeds unstablegeneric_const_exprs. Not available on stable. Doesnot apply to
InterpDataNDMulti, whereIxDynalready carries rank at runtime, whichis why that one is a validation check rather than a type-level guarantee.
interpolateis a separate method, not a unified signature: forcing the scalar pathto return
Vec<D::Elem>would tax every single-channel call with a heap allocation.interpolate_intois the required method, notinterpolate: see Allocation-free*_intointerpolation variants #45. Theargument is sharper here than for the scalar batch case, because a multi-channel result
is per-point rather than per-batch.
Strategy2DMultidoes not extendStrategy2D: an earlier draft did(
Strategy2DMulti<D>: Strategy2D<D>), withinterpolate's default delegating to theinherited scalar
interpolateper channel. That breaks two ways. The scalar method hasno channel index, so a strategy caching per-channel or joint coefficients cannot tell
which channel it is being asked about. And the inherited
validate/inittake&InterpData2D<D>, single-channel-shaped, so there is no correct way to call themagainst
InterpData2DMulti<D>: one arbitrary channel ignores the rest, and looping thescalar method against the same
&mut selfmakes the last channel silently win.Dropping the bound fixes both, at the cost of no blanket/macro opt-in for stateless
strategies. That cost is zero in practice: no strategy in this crate is stateless
enough to want the mechanical per-channel loop, every one has a locate step worth
sharing. This is not a theoretical concern either, see Downstream motivation.
InterpolatorMulti<T>mirrorsInterpolator<T>, notStrategy*Multi: its requiredmethod is the allocation-free
interpolate_into, not an allocatinginterpolate, samereasoning as
Strategy*Multi's own required method above: a multi-channel result isper-point, so an allocating required method taxes every call, not just batches. Every
other method (
interpolate,_fast,batch_*) is a defaulted wrapper over it, mirroringthe shape
Strategy*Multialready commits to.n_channelslives on the trait itself, notjust
DynInterpolatorMulti: even a non-erasedBox<dyn InterpolatorMulti<T>>caller hasto size
outbefore calling, without downcasting.DynInterpolatorMultiextendsInterpolatorMulti<T>: same reasoningDynInterpolator: object-safe, downcastable interpolator trait #46 gives forDynInterpolatorextendingInterpolator<T>: the borrowedInterp*MultiViewedtypessimply don't implement
DynInterpolatorMulti(still blocked byas_anyneedingSelf: 'static),InterpolatorMulti<T>itself is untouched. CollapsesDynInterpolatorMultitoone method,
as_any;Send/Syncstay scoped per-impl for the same reason a customStrategy2D(examples/custom_strategy.rs) may hold non-thread-safe state.InterpolatorMulti<T>joins the prelude,DynInterpolatorMultistays out: mirrorsDynInterpolator: object-safe, downcastable interpolator trait #46's identical split forInterpolator<T>/DynInterpolator.preludeis curated forthe common path; heterogeneous storage + downcasting (the
neopdfcase) is a narrower,advanced use case, one explicit
useaway for consumers who need it.Vec<Vec<T>>: nesting allocates per point and fixes anorientation on the caller. A flat
outwith documentedn_channelschunking lets thecaller own the layout.
Multithroughout; none of their ownmethods repeat it,
Multiin the name already says so,InterpolatorMulti/DynInterpolatorMultiincluded, matching howDynInterpolator: object-safe, downcastable interpolator trait #46 settled the same question forDynInterpolator(the trait, not a method-name suffix, is what disambiguates a type-erasedcall).
batch_is a prefix, not a suffix like_fast/_into, for the reason Batch interpolation #21 gives: itchanges what is being operated on (many points instead of one), rather than being a
variant of the same operation.
Downstream motivation
QCDLab/neopdfis the driving consumer, and it has moved well past hand-rolling onetrait. As of
aeb45a0it maintains two complete shared-grid multi-channel evaluatorsthat bypass ninterp entirely, both structured exactly as this issue proposes:
InterleavedHermite(neopdf/src/interleaved.rs, 508 lines)locate()once per point, theneval_allpids(&loc, pid_slots, force_positive_fn, out: &mut [f64])over flavors. Hermite x-coefficients precomputed at build time into a[cell][flavor][4]interleaved layout.ChebyshevAllPids(neopdf/src/strategy.rs)locate()returns barycentric coefficients per dimension,eval_allpidscontracts them per flavor.Both are reached before the ninterp-backed path in
GridPDF::xfxq2_allpids, withVec<Vec<Box<dyn DynInterpolator>>>as the generic fallback. The ninterp path is alreadythe slow path for every grid type neopdf ships.
Three things follow.
The trait shape is validated by working code.
InterleavedHermite's interleavedcoefficient layout is per-channel state built jointly across all channels, which is
expressible as
Strategy2DMulti::initprecisely becauseinitsees&InterpData2DMulti<D>. Under the rejected supertrait design, withinittaking&InterpData2D<D>per channel, it would not have been. The Design note above is nothypothetical.
Memory duplication is the unglamorous win.
InterpolatorFactory::createis calledonce per (subgrid, flavor) and does
subgrid.grid_slice(pid_index).to_owned()plus itsown
subgrid.xs.mapv(f64::ln)andq2s.mapv(f64::ln). So the log-transformed axis arraysare recomputed and stored once per flavor. On top of that,
GridPDFretains the originalknot_arrayand the interleaved coefficients (themselves 4 floats per cell per flavor).One
InterpData*Multiper subgrid collapses the axis duplication outright and makes thevalues a single array.
Dimensional coverage matters.
InterpolationConfighas 13 variants routed toInterp2D(1),Interp3D(5), andInterpND(7). Both fast paths have build arms for2D through 5D. Shipping this issue without
InterpNDMultiwould leave both hand-rolledevaluators alive for the 4D and 5D configurations, which is why ND is in scope here rather
than deferred.
Not absorbed by this issue, correctly: neopdf's cross-subgrid point routing (grouping a
batch by which of several interpolators each point falls into before calling the batch
method on each) is PDF-domain logic, and its
force_positiveclipping is a post-map.Non-goals
needs the same unstable
generic_const_exprsas the hand-rolled-struct decision above.Channel count is a runtime axis size instead.
strategy for all channels. Separate
Interp2D/Interp2DViewedinstances already coverchannels that genuinely need different strategies. Same reasoning rules out a
Vec<S>/[S; N]of per-channel strategy instances even of the same type: itreintroduces the naive per-channel cost.
Strategy*Multi#47.interpolate_intoherealways evaluates every channel. Subsetting is additive on top of this trait (defaultable
in terms of
interpolate_into), but it should not lag far behind: without it, aconsumer with both single-channel and all-channel access patterns has to keep
per-channel
Interp2Dinstances alongsideInterp2DMulti, which gives back the memorywin this issue is partly here for.
Box<dyn Strategy1DMulti/2DMulti/3DMulti/NDMulti<D>>support in this pass, andtherefore no forwarding concern for one.
Interp2D-style boxed-strategy runtimeswapping was never proposed for the
*Multiwrappers. Revisit if it is added later,following Batch interpolation #21's
Box<dyn Strategy1D/2D/3D/ND<D>>precedent exactly.Strategy*MultiEnum/InterpolatorMultiEnum, the*Multicounterpart toStrategy*Enum/InterpolatorEnum(src/interpolator/enums.rs,src/strategy/enums/).InterpolatorEnum's variants areInterp1D<D, Strategy1DEnum>etc., so this needs fournew
Strategy*MultiEnumtypes before anInterpolatorMultiEnumwrapping them is evenpossible, doubling the existing enum-dispatch surface. That module already hand-rolls
what
enum_dispatchwould give for free if it supported a generic trait on anon-generic enum (see the
NOTEat the top ofenums.rs); doubling the currentboilerplate before addressing that seems like the wrong order.
Box<dyn InterpolatorMulti<T>>/Box<dyn DynInterpolatorMulti<T>>cover the runtime-polymorphismneed in the meantime, same as
Box<dyn Interpolator<T>>did beforeInterpolatorEnumexisted.
Dependencies
find_nearest_indexand other index helpers are ambiguous #29 (closed, merged):locate_axis/AxisLocationare onmain.batch_interpolate/batch_interpolate_fastand thebatch_interpolate_impl!macro this issue parameterizes.interpolatewhose name shadowingforces the
try_intoconversions in the blanket impls.*_intointerpolation variants #45: establishes the_intoconvention. Should land first; this issue'ssignatures assume it.
DynInterpolator: object-safe, downcastable interpolator trait #46: defines the scalarInterpolator<T>/DynInterpolator<T>pair insrc/interpolator/mod.rs, the precedentInterpolatorMulti<T>/DynInterpolatorMulti<T>follow here (subtrait extension, not a redeclared method set). Independent in both
directions.
Strategy*Multi#47.