Skip to content

fix(nd): value-matched grid promotion#182

Merged
mgyoo86 merged 30 commits into
masterfrom
fix/nd_store_promote_eltype
Jul 6, 2026
Merged

fix(nd): value-matched grid promotion#182
mgyoo86 merged 30 commits into
masterfrom
fix/nd_store_promote_eltype

Conversation

@mgyoo86

@mgyoo86 mgyoo86 commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

ND builders promoted grids data-blind (float(eltype(grid)) → always Float64), which dragged narrow data (Float32/Float16/ComplexF32) up with them: a full Tv.(data) copy even under store=StorePolicy(copy=false), a double copy under copy=true, and Float64 outputs where Float32 was expected. Reported downstream by ImageTransformations' fast_imresize.

This PR makes the grid float type value-matched to the data (Int grid + Float32 data → Float32 grid), applied uniformly across all ND methods on both persistent and one-shot paths. copy=false now aliases the user's array (zero copies), and fast_imresize Float32 e2e goes 2354 µs / 8.4 MB → 139 µs / 256 KB.

The rules (now pinned as tests)

  • Output = natural promote_type(grid, data, query), with the grid entering value-matched. F32 grid + F32 data + F32 query → F32; F64 query → F64.
  • Arithmetic kernels (linear/cubic/quadratic/hermite) float-force Int grids — they divide. Selection kernel (constant) keeps pure natural promotion: all-Int in → Int out.
  • One-shot ≡ persistent output types. Scalar one-shots never eagerly convert vector grids (zero-alloc preserved); only cheap Range axes convert.
  • Complex/Dual data never contaminate the grid type; Dual queries keep their partials.

Tests & notes

Output-type matrix (methods × grid kinds × data × query types, ~400 assertions, all @inferred), zero-copy store pins, and axis-resolver dispatch-table pins. One @test_broken documents the known remaining gap (cubic explicit-PreCompute with Int grid + Float32 data), owned by the follow-up 1D one-shot value-match phase — the 1D methods still default to Float64 there and will get the same treatment in a separate PR. 13 commits (tests-first TDD history), 15 files, +480/−97.

mgyoo86 added 13 commits July 3, 2026 23:48
- test_nd_store_copyfalse_narrow_float.jl: StorePolicy(copy=false) must alias
  narrow-float ND data (Float32/Float16/ComplexF32) — itp.data === A, no
  O(n^2) widening copy at build; Float64/ComplexF64 aliasing pinned as guards.
- test_nd_output_type_promotion.jl: persistent ND eval output follows
  promote_type(grid, data, query) with @inferred stability across
  linear/cubic/quadratic/constant x grid containers x Float32/Float64.

Red at this commit: the ND builders derive the grid float from the grid
eltype alone (Int/OneTo -> Float64) and widen the data to match before the
store policy is consulted.
…rids

Tg = float(_promote_grid_eltype(grids)) floated an Int/OneTo grid to
Float64 regardless of the data eltype, dragging Tv — and the stored array,
via Tv.(data) — up with it before StorePolicy was consulted: copy=false
was silently violated with a full O(n^N) widening copy for Float32/
Float16/ComplexF32 data, and copy=true paid a double copy (widen + own).

Use the 1D rule _promote_grid_float(_promote_grid_eltype(grids), Tv_raw):
Float32 data keeps a Float32 grid so the data aliases under copy=false;
Int data still floats to Float64; duck/colorant types unaffected.

fast_imresize (ImageTransformations) bare-Float32 1024^2 -> 256^2
downsample: 2354us / 8.4MB -> 139us / 256KB, values within Float32
round-off.
- test_nd_output_type_promotion.jl: one-shot testitem — scalar one-shot
  output follows promote_type(grid, data, query) with @inferred stability
  (linear/cubic/quadratic/constant x grid containers x Float32/Float64).
- test_nd_raw_grid_oneshot.jl: quadratic scalar strategy contract — both
  AutoCoeffs (-> OnTheFly) and explicit PreCompute zero-alloc on Int Vector
  grids; bit-exact persistent parity (===) is opt-in via coeffs=PreCompute(),
  the default pins ~ rtol=1e-12.
- test_nd_oneshot_onthefly.jl: quadratic seed identity — default == explicit
  OnTheFly bit-exactly, ~ PreCompute within FP-reordering noise.

Red at this commit: scalar one-shots float Int grids to Float64 regardless
of the data eltype, and quadratic hardcodes AutoCoeffs -> PreCompute.
…Coeffs per policy

Scalar one-shots derived the grid float from the grid eltype alone, so an
Int/OneTo grid beside Float32 data evaluated in Float64 — throwing the ::Tr
witness assert (linear/cubic/quadratic) or silently returning Float64
(constant). Value-match via _promote_grid_float(grid eltype, Tv):

- linear/cubic/quadratic/constant scalar witnesses + 3-arg Tg-aware
  _resolve_axis (Range converts to _CachedRange{Tg}; raw Vector stays raw —
  the search promote-compares it).
- OnTheFly hetero core: value-matched Tg for pool buffers. The global-solve
  path keeps RAW-form grids (inner 1D one-shots wrap :exclusive axes
  themselves; the wrapped virtual n+1 vs data n asserts) and converts only
  float-mismatched axes — Int+Float64 stays raw, preserving the 1D cubic
  cache memoization.
- quadratic scalar AutoCoeffs -> _resolve_coeffs_nd_oneshot (same policy as
  cubic): scalar -> OnTheFly (1.5-3.5x faster, 0-5 ULP vs PreCompute),
  batch -> PreCompute. Bit-exact persistent parity (=== itp(q)) is opt-in
  via coeffs=PreCompute().
- _cache_axis_pooled 3-arg Vector overloads: converting copyto! into a
  POOLED buffer (zero heap; acquire! returns a plain Vector so the 2-arg
  wrap aliases it). The Range 3-arg overloads are load-bearing dispatch
  shields: AbstractRange <: AbstractVector would otherwise send Ranges into
  the pooled-Vector overload and lose the stack _CachedRange form.
Hermite testitem: output follows promote_type(grid, data-and-partials,
query) with @inferred stability across grid containers x Float32/Float64.

Red at this commit: the one-shot prepare floats an Int/OneTo grid to
Float64 regardless of the data/partials eltype (inv(Int) in _coeff_op),
silently returning Float64 and copying data + K partials every call.
…cision

_hermite_oneshot_prepare derived Tg from the grid eltype alone, so an Int/
OneTo grid beside Float32 data + partials promoted Tv to Float64 via
_coeff_op's inv(Int) — coercing (copying) the data and all K partials on
every one-shot call and silently returning Float64. Two independent Float64
sources fixed:

- Tg value-matched to promote_type(eltype(data), Tv_part): Tv stays narrow,
  the _coerce_*_eltype calls pass through (no per-call copies).
- Float-mismatched axes convert to Tg (Int grid + Float32 -> _CachedRange{F32})
  so the cell widths (inv(h)) don't reintroduce Float64; matching axes stay
  raw (Int + Float64 unchanged; Dual axes never convert).
- Batch witness Tr uses the same value-matched Tg, agreeing with the eval.

Persistent hermite was already covered by the central _nd_promote_grids fix.
coeffs=PreCompute() routes through the 1D _get_cubic_cache machinery, which
is still data-unaware (floats an Int grid to Float64); the value-matched
witness then rejects the Float64 eval with a TypeError. Default (OnTheFly)
is green. The test_broken flips when the 1D one-shot value-match phase
lands, forcing the pin update.
…sion bridge

Type the per-query cell widths instead of converting whole axes: a new
data-aware _compute_all_local_params(..., Tg) overload converts hs/inv_hs
to the caller-supplied value-matched Tg (Int grid + Float32 data → Float32),
so raw Int axes no longer widen the eval to Float64 via inv(Int). dLs keep
their natural q - L promotion — converting them would strip Dual-query
partials. The 4-arg form delegates with its previous grid-eltype derivation
(bit-identical for existing callers).

Hermite cores compute Tg once and pass it to the params call and to
_pack_and_extend_nodal_derivs_pooled (new ::Type{Tg} parameter with the old
data-blind default), so an :exclusive extension can no longer reintroduce
Float64 into dL. The prepare-level grids_eff conversion bridge is removed —
grids flow raw end-to-end; widths cost O(N) scalars per query instead of an
O(n) axis conversion (and no per-call allocation for Int Vector grids).

The hetero global-solve bridge stays: inner 1D one-shots re-derive their
types from raw grid eltypes internally, which width-typing at the ND level
cannot reach (resolved by the 1D value-match phase).
The matrix covered Int ranges (OneTo) but not Int Vectors — which is exactly
where the last hole hid: 322 rows, one red (linear one-shot IntVec F32xF32).
The raw-Vector alpha leaf ((q - L) / float(R - L)) and the raw-Vector
_get_inv_h fallback (inv(xR - xL)) are data-blind: an Int Vector axis beside
Float32 data leaked Float64 into the eval, throwing the value-matched ::Tr
witness. Convert inv_h to the value-matched Tg at the read and compute
alpha = (q - L) * inv_h from it (the persistent path's 3-arg form):

- _CachedRange axes: bit-identical (the 4-arg alpha already multiplied by
  the cached inv_h; convert folds).
- Raw-Vector / seam cells: <= 1 ULP (division -> reciprocal multiply);
  exact for power-of-two widths, so the unit-spacing === pins hold.
- alpha keeps query-blood promotion (Dual query => Dual alpha); the seam
  cell now shares the deriv path's denominator by construction.
…ion stays natural

Arithmetic kernels (linear/cubic/quadratic) divide, so Int data floats:
Tg = float(promote(grid, Int)) — Int grid + Int data -> Float64 for every
query type, while a Float32 grid keeps Float32 (Int cannot widen it).
The selection kernel (constant) has no x*y arithmetic: output follows the
pure promote_type(grid, data, query) with NO float forcing — all-Int in,
Int out — and one-shot must match persistent.

Red at this commit: the constant scalar one-shot applied the arithmetic
value-matched (float-forcing) Tg, so an IntOneTo axis converted to Float64
and diverged from the persistent/batch selection contract.
…ernel rule)

The scalar one-shot applied the arithmetic float-forcing value-match to a
selection kernel, converting an IntOneTo axis to Float64 — diverging from
the persistent/batch paths (_nd_promote_grids_raw) and from the IntVec
container. Constant has no x*y arithmetic: use the raw promoted grid eltype
so all-Int inputs stay Int and the output follows the natural
promote_type(grid, data, query), identical across one-shot/persistent and
grid containers.
… the Tg axis

- `_Cached{Range,Vector}` x `PeriodicBC{:exclusive}` x Tg diagonal methods:
  the specific-container and specific-BC arms cross-ambiguated without them
  (Aqua RED + MethodError in the hetero one-shot exclusive path).
- Convert-first in the Range exclusive arms (`_resolve_axis` raw/diagonal,
  `_cache_axis` raw): `_resolve_bc_period` types the period from the axis
  eltype, so resolving against the pre-conversion axis let a Float64 period
  re-widen a value-matched Float32 axis through the EPA seam promote.
- Pin the full 3-arg dispatch table + the period-follows-Tg contract in
  test_axis_data_resolvers.jl.
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

FastInterpolations.jl Benchmarks

🚨 Regression baseline is a different CPU

CPU
This PR run `znver3
Master baseline (latest commit) `znver5

The Previous / Imm. Ratio / Grad. Ratio columns compare against master history, but master's most recent commit was benchmarked on a different CPU — so a naive comparison is cross-CPU and unreliable. The ratios below use only same-znver3|4c history and are blank if master has none on this CPU yet.

All benchmarks (50 total, click to expand)
Benchmark Current: 0f747b3 Previous Imm. Ratio Grad. Ratio
10_nd_construct/bicubic_2d 38642 ns 37170.0 ns 1.04 1.042
10_nd_construct/bilinear_2d 638.38 ns 577.1 ns 1.106 1.039
10_nd_construct/tricubic_3d 363195 ns 356999.0 ns 1.017 1.026
10_nd_construct/trilinear_3d 1721.22 ns 1616.8 ns 1.065 1.009
11_nd_eval/bicubic_2d_batch 1388.6 ns 1394.6 ns 0.996 0.998
11_nd_eval/bicubic_2d_scalar 17.93 ns 17.8 ns 1.006 1.004
11_nd_eval/bilinear_2d_scalar 8.61 ns 8.6 ns 1.0 0.996
11_nd_eval/tricubic_3d_batch 3279.1 ns 3265.1 ns 1.004 1.004
11_nd_eval/tricubic_3d_scalar 34.16 ns 33.9 ns 1.009 0.996
11_nd_eval/trilinear_3d_scalar 14.12 ns 14.0 ns 1.007 1.01
12_cubic_eval_gridquery/range_random 4225.48 ns 4225.7 ns 1.0 1.025
12_cubic_eval_gridquery/range_sorted 4215.46 ns 4215.9 ns 1.0 1.0
12_cubic_eval_gridquery/vec_random 9062.52 ns 9064.3 ns 1.0 0.973
12_cubic_eval_gridquery/vec_sorted 3207.78 ns 3216.0 ns 0.997 0.995
13_nd_oneshot_gridquery/bicubic_2d_rand_rand 64860.4 ns 65512.2 ns 0.99 1.01
13_nd_oneshot_gridquery/bicubic_2d_sort_rand 62032.9 ns 62635.8 ns 0.99 1.018
13_nd_oneshot_gridquery/bicubic_2d_sort_sort 59942.9 ns 60708.3 ns 0.987 1.008
13_nd_oneshot_gridquery/bilinear_2d_rand_rand 17218.96 ns 16406.7 ns 1.05 1.014
13_nd_oneshot_gridquery/bilinear_2d_sort_rand 9955.4 ns 9389.4 ns 1.06 1.093
13_nd_oneshot_gridquery/bilinear_2d_sort_sort 5197.5 ns 5241.4 ns 0.992 1.007
14_series_oneshot_batch/constant_inplace_vec_k8_q1000_rand 19234.9 ns 18039.7 ns 1.066 1.041
14_series_oneshot_batch/linear_inplace_vec_k8_q1000_rand 17738.1 ns 17642.9 ns 1.005 0.985
1_cubic_oneshot/q00001 547.64 ns 543.2 ns 1.008 1.007
1_cubic_oneshot/q10000 43551.3 ns 43535.2 ns 1.0 1.0
2_cubic_construct/g0100 1377.16 ns 1386.4 ns 0.993 0.984
2_cubic_construct/g1000 12651.6 ns 12655.7 ns 1.0 0.999
3_cubic_eval/q00001 20.73 ns 20.0 ns 1.035 1.033
3_cubic_eval/q00100 441.22 ns 441.8 ns 0.999 1.025
3_cubic_eval/q10000 42638.7 ns 42636.9 ns 1.0 1.027
4_linear_oneshot/q00001 25.94 ns 26.4 ns 0.981 0.966
4_linear_oneshot/q10000 18427.3 ns 18398.5 ns 1.002 1.001
5_linear_construct/g0100 36.06 ns 33.8 ns 1.068 1.048
5_linear_construct/g1000 285.03 ns 262.8 ns 1.085 1.054
6_linear_eval/q00001 10.61 ns 10.4 ns 1.019 1.019
6_linear_eval/q00100 203.78 ns 194.4 ns 1.048 1.054
6_linear_eval/q10000 18298.2 ns 18130.8 ns 1.009 1.03
7_cubic_range/scalar_query 8.11 ns 8.1 ns 1.0 1.0
7_cubic_vec/scalar_query 11.02 ns 10.7 ns 1.029 1.029
8_cubic_multi/construct_s001_q100 659.82 ns 653.2 ns 1.01 1.0
8_cubic_multi/construct_s010_q100 4513.82 ns 4446.5 ns 1.015 1.01
8_cubic_multi/construct_s100_q100 40122 ns 39835.7 ns 1.007 1.032
8_cubic_multi/eval_s001_q100 821.34 ns 821.9 ns 0.999 1.008
8_cubic_multi/eval_s010_q100 1807.98 ns 1796.8 ns 1.006 1.007
8_cubic_multi/eval_s010_q100_scalar_loop 2324.76 ns 2317.1 ns 1.003 1.001
8_cubic_multi/eval_s100_q100 11432.3 ns 11325.2 ns 1.009 1.004
8_cubic_multi/eval_s100_q100_scalar_loop 3361.3 ns 3361.2 ns 1.0 0.981
9_nd_oneshot/bicubic_2d 45827.6 ns 45245.8 ns 1.013 1.006
9_nd_oneshot/bilinear_2d 450.44 ns 451.4 ns 0.998 0.997
9_nd_oneshot/tricubic_3d 427714.9 ns 430887.5 ns 0.993 1.014
9_nd_oneshot/trilinear_3d 917.7 ns 920.7 ns 0.997 1.008

⚠️ Performance Regression Confirmed ⚠️

After re-running 5 flagged benchmark(s) 10 time(s), 1 regression(s) confirmed.

Benchmark Current Previous Imm. Ratio Grad. Ratio Tier
10_nd_construct/bilinear_2d 638.38 ns 577.1 ns 1.106 1.039 immediate

Thresholds: immediate > 1.1x (vs latest master), gradual > 1.1x (vs sliding window)

Runner: znver3|4c — AMD EPYC 7763 64-Core Processor, julia 1.12.6. Times are min-merged and compared only against this same machine's history.

This comment was automatically generated by Benchmark workflow.

@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.78164% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.39%. Comparing base (9c1f9a6) to head (0f747b3).

Files with missing lines Patch % Lines
src/cubic/cubic_cache_pool.jl 80.64% 6 Missing ⚠️
src/akima/akima_slopes.jl 86.66% 2 Missing ⚠️
src/cardinal/cardinal_slopes.jl 77.77% 2 Missing ⚠️
src/core/cached_vector.jl 91.30% 2 Missing ⚠️
src/pchip/pchip_slopes.jl 77.77% 2 Missing ⚠️
src/core/periodic_axis.jl 83.33% 1 Missing ⚠️
src/core/utils.jl 90.90% 1 Missing ⚠️
src/hermite/hermite_local_slopes.jl 99.11% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #182      +/-   ##
==========================================
- Coverage   96.85%   96.39%   -0.47%     
==========================================
  Files         151      151              
  Lines       12609    12848     +239     
==========================================
+ Hits        12213    12385     +172     
- Misses        396      463      +67     
Files with missing lines Coverage Δ
src/akima/akima_oneshot.jl 100.00% <100.00%> (ø)
src/cardinal/cardinal_oneshot.jl 100.00% <100.00%> (ø)
src/constant/constant_oneshot.jl 100.00% <100.00%> (ø)
src/constant/nd/constant_nd_adjoint_types.jl 91.66% <100.00%> (ø)
src/constant/nd/constant_nd_oneshot.jl 100.00% <100.00%> (ø)
src/constant/nd/constant_nd_types.jl 100.00% <100.00%> (ø)
src/core/cached_range.jl 97.91% <100.00%> (+0.16%) ⬆️
src/core/nd_utils.jl 90.48% <100.00%> (+0.20%) ⬆️
src/cubic/cubic_oneshot.jl 100.00% <100.00%> (ø)
src/cubic/nd/cubic_nd_oneshot.jl 100.00% <100.00%> (ø)
... and 28 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes ND interpolation type promotion so that integer grids are floated in a data-aware (value-matched) way instead of always becoming Float64, preventing unwanted widening/copies for narrow-float (e.g. Float32 / Float16 / ComplexF32) inputs and aligning persistent vs one-shot ND output eltypes with the natural promote_type(grid, data, query) contract.

Changes:

  • Introduces value-matched ND grid float promotion (Int grid + Float32 data ⇒ Float32 grid) across ND builders and one-shot paths.
  • Refactors ND one-shot axis resolution and local-parameter computation to preserve narrow types (and avoid inv(Int)-driven Float64 leakage).
  • Adds/updates targeted tests to pin zero-copy StorePolicy(copy=false), output-type matrices (@inferred), and key axis resolver dispatch behavior.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/test_nd_store_copyfalse_narrow_float.jl New tests pin zero-copy behavior and prevent narrow-float widening under StorePolicy(copy=false) for ND builds.
test/test_nd_raw_grid_oneshot.jl Updates quadratic ND one-shot tests for new coeffs policy (scalar AutoCoeffs ⇒ OnTheFly) and raw-grid allocation/type parity.
test/test_nd_output_type_promotion.jl New tests pin the ND output-type contract (promote_type(grid, data, query)) for persistent + one-shot across methods and grid kinds.
test/test_nd_oneshot_onthefly.jl Adjusts quadratic AutoCoeffs expectations to reflect scalar OnTheFly default and PreCompute ≈ parity.
test/test_axis_data_resolvers.jl Adds dispatch-table tests to pin Tg-aware _resolve_axis behavior (including ambiguity-preventing diagonals).
src/quadratic/nd/quadratic_nd_oneshot.jl Applies value-matched grid float type in pooled axis caching and adopts shared ND coeffs resolution policy for one-shot.
src/linear/nd/linear_nd_oneshot.jl Resolves axes with Tg-aware _resolve_axis, and types inv_h/α consistently to prevent Float64 leakage from Int grids.
src/hetero/hetero_oneshot.jl Uses value-matched Tg for axis resolution and carefully converts only float-mismatched raw grids for global-solve collapse.
src/hermite/nd/hermite_nd_oneshot.jl Value-matches Tg against data∪partials, threads Tg into pooled pack/extend and local-param computation.
src/hermite/nd/hermite_nd_build.jl Extends pooled pack/extend helper to accept caller-supplied Tg for exclusive periodic extension typing.
src/cubic/nd/cubic_nd_oneshot.jl Uses value-matched Tg for ND cubic scalar one-shot type behavior to align witness/eval.
src/core/nd_utils.jl Adds Tg-supplied _compute_all_local_params overload and switches ND grid promotion to value-matched _promote_grid_float.
src/core/cached_vector.jl Improves pooled axis caching for mismatched vector eltypes and adds Tg-aware _resolve_axis overloads for vectors.
src/core/cached_range.jl Adds Tg-aware _resolve_axis/_cache_axis_pooled for ranges and convert-first exclusive-period handling to preserve Tg.
src/constant/nd/constant_nd_oneshot.jl Ensures constant ND one-shot keeps natural promotion (no arithmetic float-forcing) via raw grid-eltype Tg.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core/cached_vector.jl Outdated
mgyoo86 added 6 commits July 4, 2026 19:42
…partials

Two review findings on the value-match arc:

- cubic scalar `coeffs=PreCompute()`: the value-matched wrapper witness (F32)
  vs the legacy-width backend (F64, identity-memoised data-unaware
  `_get_cubic_cache`) made a documented public call throw TypeError for
  Int grids + Float32 data. The PreCompute branch now asserts the backend's
  own width (returns Float64, master parity) until the 1D cubic-cache
  value-match phase lands; the @test_broken type pin flips then.
- hermite ND persistent build value-matched grids against data only, while
  the one-shot promotes against data ∪ partials: Float32 data + Float64
  partials stored Int coordinates as Float32 (quantized above 2^24,
  persistent drifted 9.5e-5 from one-shot). New 3-arg
  `_nd_promote_grids(grids, data, Tv_extra)` folds the partial eltype into
  the value-match; persistent is now bit-identical to one-shot.

Pins: hard no-throw + OnTheFly-parity regression guard (plain @test — the
@test_broken had swallowed the throw), mixed-width parity testitem, and an
all-Float32 no-over-widen guard.
…ver Tg divergence

RED: on a raw Vector grid under an :exclusive PeriodicBC with narrow-float data, the value-matched linear ND one-shot threw (Float64 axis vs Float32 witness) while persistent returned Float32, and _resolve_axis diverged from _cache_axis (Float64 vs Float32 axis). Also flips the _CachedVector x :exclusive diagonal test to the convert-to-Tg contract, matching the _CachedRange diagonal.
The one-shot _resolve_axis :exclusive Vector arms resolved the period against the RAW grid (float(Int)=Float64), so a value-matched Tg=Float32 axis re-widened to Float64 past the Float32 witness -> one-shot crash / type divergence from the persistent path. Add a shared _wrap_exclusive(inner, bc) that always resolves the period against the already-converted inner, and route every :exclusive arm (Range+Vector, one-shot+persistent, 2-/3-arg) through it. The Vector 3-arg arms now convert-first to Tg; the 12 other arms are behavior-preserving. Closes the 2-arg/3-arg period-timing split.
… Int->Float64 consistency

Worktree ablation at the pre-fix commit confirmed both linear AND cubic one-shot crashed (Float64 axis vs Float32 witness) on an Int Vector + :exclusive PeriodicBC + Float32 data; the pin was previously linear-only. Also locks the selection-kernel contract: constant's exclusive axis floats Int->Float64 for both one-shot and persistent (consistent, not the arithmetic value-match) so a future change can't silently split them. Hermite uses a separate _pack_and_extend path, out of scope here.
… _resolve_axis

Copilot review on PR #182: the 3-arg (and 2-arg) :exclusive Vector arms match _ExclusivePeriodicAxis (it is <: AbstractVector) and re-wrap it, nesting toward length (n+1)+1 which throws in the ctor. _cache_axis already defends this; _resolve_axis only had the 1-arg passthrough.
… (no double-wrap)

Add the 2-arg and 3-arg :exclusive _ExclusivePeriodicAxis passthroughs to _resolve_axis, mirroring the _cache_axis set and the documented dispatch-table contract (_ExclusivePeriodicAxis + any -> passthrough). Non-exclusive 2-/3-arg forms already fall to the raw = x passthrough, so only the :exclusive arms needed the guard.
@mgyoo86 mgyoo86 changed the title # fix(nd): value-matched grid promotion fix(nd): value-matched grid promotion Jul 5, 2026
mgyoo86 added 2 commits July 4, 2026 22:12
…ion)

Julia 1.10/1.11 elide type-stable OnTheFly ND intermediates unreliably: the same warm path stack-allocates in isolation but heap-allocates (~1-2 KB) in the full-suite compilation context, so @allocated on LTS measures Julia's elision rather than FI's zero-alloc contract. All three LTS jobs (ubuntu/windows/macos) fail while every 1.12 job passes. A 100x LTS budget absorbs the elision noise while still catching a catastrophic regression; 1.12+ stays strict (0). Not AAP auto-manage: compact! is a no-op on <1.12, and a 30s wall-clock timer would fail 1.12 too.
…ange axes

Re-lands the 1D value-match phase (dropped in a branch rework) and extends it.

1D one-shot axes were resolved data-blind (`float(eltype(x))` → Float64 for Int
grids) while the persistent path was already value-matched:

- New Tg-typed 2-arg `_resolve_axis(x, ::Type{Tg})` (Ranges → `_to_float(x, Tg)`,
  vectors pass through) for the no-BC normalize points; linear/constant thread
  the BC 3-arg form (constant keeps the raw natural selection rule); quadratic
  uses the pooled 3-arg (Int vectors convert into pool buffers); hermite
  promotes against y ∪ dy via the extracted `_hermite_grid_float`.
- Cubic pre-normalizes to `_CachedRange{Tg}` — isbits axes have value-
  deterministic objectid, so the spline-cache memoisation survives.

ND scalar `coeffs=PreCompute()` now value-matches Range axes the same way; the
witness follows the per-axis width (`_cubic_pc_grid_float`), so all-Range grids
return Float32 (the `@test_broken` flipped to hard tests) and raw-Vector axes
stay well-typed at the legacy cache width.

Int-VECTOR axes for linear/hermite close via canonical compile-time cell-
geometry types: a 1-arg witness op `_inv_op` with `_promote_eltype` computes the
reciprocal-spacing type at the function surface (`Tinv = _promote_eltype(
_inv_op, _promote_grid_float(Tg, Tv))`), and use sites just `convert` — a no-op
for float/duck/Unitful axes (inverse units convert to themselves), a real
Float64→value-width float only for Int axes. α is now uniformly (q−L)·inv_h,
matching the persistent and ND `_locate_cell` forms.

Pins: 1D output-type matrix (scalar `@inferred` × 5 gridspecs, persistent
parity, batch eltypes, constant natural, hermite y∪dy, Int-data, Series guard).
Remaining `@test_broken` ×5 (all pre-existing behavior): 1D Int-vector
cubic/pchip/akima/cardinal + ND cubic PreCompute Int-vector axis.
@mgyoo86 mgyoo86 force-pushed the fix/nd_store_promote_eltype branch from 7909b54 to 1d965a3 Compare July 5, 2026 05:27
mgyoo86 added 2 commits July 4, 2026 22:29
…r backend

Replace the per-axis witness helper (`_cubic_pc_grid_float`) with the same
pooled 3-arg wrap the quadratic scalar backend uses: Ranges → isbits
`_CachedRange{Tg}` (spline caches memoise via value-deterministic objectid),
mismatched Vectors convert into pool buffers (warm one-shots stay zero-alloc).
The whole solve pipeline then runs at the value-matched width, so the wrapper
asserts plain `Tr` and Int-VECTOR axes now return Float32 too — the last ND
cubic PreCompute `@test_broken` becomes a hard test.
The value-match arc resolved grid floats via `map(g -> f(g, Tg), grids)` closures
capturing the type witness Tg. A closure field of type `Type` de-optimizes on Julia
LTS (weak const-prop), heap-allocating downstream per-fiber/per-query eval (60-90 KB
windowed OnTheFly, 0.4-8 KB scalar PreCompute) while 1.12 elides it entirely.

Thread Tg as a plain map argument via `ntuple(_ -> Tg, Val(N))` across the 6 one-shot
sites (hetero OnTheFly + raw-form bridge, cubic/quadratic PreCompute grid wrap, linear
resolve+inv_h, constant resolve). Behavior-identical: Int-Range/OneTo + Float32 data
still returns Float32; verified LTS warm-path alloc returns to 0 (master parity).

Restore ND_ALLOC_THRESHOLD to the master 240-byte margin (the earlier 100x inflation
was masking these real regressions).
mgyoo86 added 7 commits July 5, 2026 17:51
Consistency follow-up to 71cff11: the same `map((g,bc) -> f(g,bc,Tg), ...)`
Tg-capturing-closure pattern lived in 10 persistent constructors (*_nd_types.jl,
*_adjoint_types.jl, quadratic_nd_interpolant.jl). These are build-once paths and the
Interpolant struct re-concretizes the grid type at its boundary, so they were NOT a
zero-alloc-contract violation (unlike the one-shot backends) — but uniform closure-free
threading via ntuple(_ -> Tg, Val(N)) removes the anti-pattern everywhere.

Behavior-identical (T === Tg): persistent build+eval matches one-shot across all 7
families + hetero + store paths.
…t to cubic

Unit-pin the width-first `_get_h`/`_get_inv_h`/`_get_inv_2cell`/secant forms
(span-then-convert-then-inv: a 2^24-coordinate Int axis must not cancel under
endpoint conversion) and drop pchip/akima/cardinal from the IntVec known-red
carve-out — only cubic (eltype-aware spline-cache key) remains deferred.
…rdinal one-shots

Raw Int-Vector axes minted `inv(Int)::Float64` inside secants and cell
geometry, silently widening Float32 one-shots (IntVec broken 4 -> 1; only
cubic's eltype-aware spline-cache key remains deferred).

- Leaf width-first forms `_get_h`/`_get_inv_h`/`_get_inv_2cell(Tw, x, i)`:
  raw axes difference in their OWN eltype, convert the SPAN, then divide —
  the reciprocal is born at `Tw` (single rounding; large-Int coordinates
  cannot cancel). Wrapped axes reuse the cached reciprocal via dispatch
  shields (`_CachedVector`/`_CachedRange`/`_ExclusivePeriodicAxis`).
- Width-first secants + periodic primitives; the `:exclusive` seam resolves
  its period AT `Tw`, so a Float64 period cannot re-widen the weights.
- hermite sm eval arms declare `Tw = _promote_grid_float(Tg, Tv)` and thread
  it through `_local_slope(Tw, ...)` and the kernel h/inv_h. Width-less
  `_local_slope` bodies are preserved verbatim (integrate/adjoint callers):
  eltype delegation would let an Int `Tw` reach the seam-period convert.
- PreCompute backends type `Tdy` from the value-matched width and pass `Tw`
  into the bulk slope fillers (old kwarg forms delegate with `eltype(x)` —
  bit-identical; secant-fastpath suite unchanged).
- cardinal scale typed `typeof(float(one(Tw)))` — dimensionless slot,
  Unitful-safe, and a Float64 default tension stops widening F32 axes.
…losures

`ntuple(_ -> Tg, Val(N))` is itself a Tg-capturing closure: in a degraded-
inference context (a CI worker after ~600 testitems) the tuple elements decay
to `DataType` and every per-axis call goes through dynamic dispatch — the
Julia 1.12 CI warm-alloc failures at exactly 32 B/axis (cubic/quad PreCompute
64/96 B), unreproducible in a fresh local process where Tg const-folds. The
quadratic OnTheFly control (same pooled map, no Tg argument) stayed at 0 B on
the same CI worker, isolating the Tg value flow as the cause.

Replace the one-shot hot sites with @generated tuple helpers whose `Tg` is a
STATIC signature parameter (`_convert_grids_typed` precedent): no closure and
no runtime `Type` tuple on any Julia version, and a degraded context is
bounded to one dispatch per call instead of per axis.

- `_cache_axes_pooled(pool, grids, Tg)` — cubic/quadratic PreCompute scalar
- `_resolve_axes(grids, bcs, Tg)`      — linear/constant/hetero OnTheFly
- `_bridge_axes_raw(grids, Tg)`        — hetero global-solve bridge (the
  raw-vs-convert branch now decided per axis in the generator, from types)
- `_convert_inv_hs(grids, idxs, Ls, Rs, Tg)` — linear ND scalar (keeps the
  convert-after form bit-for-bit)

Persistent-constructor sites keep the ntuple form (build-time, once — benign).
Verified: 1.12 raw-grid/promotion/hetero/OTF/allocation suites green; LTS
temp-env probe improves PreCompute 112 B -> 48 B (residual 48 B floor is
pre-existing and identical on untouched paths).
Harmonize the linear ND scalar one-shot onto the width-first geometry
family and close the last narrow-float follow-ups.

- Add a width-first search-result row `_get_inv_h(Tw, g, idx, xL, xR)`
  across the Vector / _CachedVector / _CachedRange / _ExclusivePeriodicAxis
  hierarchy: a raw axis spans `xR - xL` in its own eltype and divides at the
  value-matched `Tw` (span-first — an Int axis no longer mints Float64 via
  `inv(Int)`), while wrapped axes reuse their cached reciprocal. Rename the
  linear one-shot helper `_convert_inv_hs` -> `_typed_inv_hs`. A brute-force
  sweep over Int spans 2..2^24 shows convert-after and span-first agree
  bit-for-bit, so no value pin needs re-baselining.

- Make the `_bridge_axes_raw` @generated helper extension-safe: its generator
  body ran `float(eltype(...))`, which on a `ForwardDiff.Dual` axis reaches a
  constructor the extension defines later -> a "method too new" world-age
  MethodError that broke every AD extension test. Decide keep/convert from a
  pure `Ti === Tg` identity first and only call `float()` on Base numeric
  eltypes. Generators must assemble expressions from Base-only operations.

- Pin the Int-Vector-grid + Float32-data arms (Tg = Float32): the pooled
  PreCompute / linear wraps stay zero-alloc, and the OnTheFly collapse bridge's
  per-call heap convert is marked `@test_broken` (>= 1.12) until it acquires
  from the pool. Refresh the stale raw-grid comment (pooled wrap + autocache
  content-match, not objectid memoisation).
…Float32

A cubic one-shot on a raw `Vector{Int}` grid with `Float32` data returned
`Float64` — the cache is keyed by grid alone and an integer grid was hardcoded
to the `Float64` bank, so `cache.x` (and the solve) widened to Float64. The
persistent interpolant already value-matches (it converts the grid at build),
so scalar and batch one-shots were the inconsistent paths.

Route the cache by a value-matched float type instead. `_get_cubic_cache` gains
an optional `::Type{Tg}` argument (default reproduces the previous `float(T)`
behavior); when supplied, the Integer/Rational arms of the derivative and
periodic cache lookups land in the `Vector{Tg}` bank. The scalar, batch, and
periodic one-shot cores pass `Tg_eff = _promote_grid_float(grid, data)`, so an
Int grid beside Float32 data is cached and solved at Float32 — scalar, batch,
and persistent now agree bit-for-bit. Float / Range / Dual grids ignore `Tg`
(already value-typed or ephemeral). Series and adjoint keep the default float;
threading `Tg` there later is pure call-site wiring.

An Int grid used with both Float32 and Float64 data now occupies two banks
(type-separated, ring-bounded) — the inherent cost of a real Float32
factorization; the common single-precision case is unchanged.

Also delete the now-redundant `_bridge_axes_raw` helper: the hetero OnTheFly
caller already promotes every axis via `_nd_promote_grids`, so the per-axis
raw/convert split was a no-op. (The mixed-method hetero fallback's own eager
grid convert remains, pinned `@test_broken` as a follow-up.)
…ger convert)

The mixed-method hetero one-shot fallback promoted grids via `_nd_promote_grids`,
which eagerly converted every axis (`_convert_grids_typed` → per-axis `Tg.(x)`,
~176 B on an Int Vector axis) even though the result was already value-matched.

Split promotion into a type-only path. New `_nd_promote_types` returns
`(Tg, Tv, Tz)` from the grid/data types alone (no `_convert_grids_typed`). The
scalar and batch hetero dispatches use it and hand the OnTheFly branch the raw
grids — each inner 1D one-shot value-matches its own axis via the data-aware
cubic cache / width-first geometry. The PreCompute branch still gets converted
grids (`_interp_nd_hetero_oneshot` takes a homogeneous-eltype `Tg` tuple and
uses it directly). Grid validation runs on the raw grids (domain checks are
eltype-independent).

Every mixed combination (Cubic×{Linear,Cubic,Constant,Quadratic,Pchip,Cardinal,
Akima}, Linear×Pchip) now matches the all-Float32-grid result bit-for-bit with
the eager convert removed (~176 B → the shared ~48 B pool residual, zero on
1.12). PreCompute, batch, and Dual-grid paths are unchanged.
@mgyoo86 mgyoo86 merged commit 2e99fd0 into master Jul 6, 2026
16 checks passed
@mgyoo86 mgyoo86 deleted the fix/nd_store_promote_eltype branch July 6, 2026 05:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants