Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.

[teeny] Phase B1: resize/restrict/splinc loop() take teeny carriers - #66

Merged
balbasty merged 1 commit into
teenyfrom
claude/61-resize-restrict-splinc-carriers
Aug 4, 2026
Merged

[teeny] Phase B1: resize/restrict/splinc loop() take teeny carriers#66
balbasty merged 1 commit into
teenyfrom
claude/61-resize-restrict-splinc-carriers

Conversation

@claude

@claudeclaudeBot commented Aug 4, 2026

Copy link
Copy Markdown

Agent:claude-fastfields-to-teeny

Phase B1 of the tensor-native-boundaries umbrella (fastfields-lib#57) — the impl half.

This is one logical change split across two repos only because GitHub cannot span them: the cpu-lib half is fastfields/fastfields-cpu-lib#79, and neither PR compiles without the other (resize.cpp/restrict.cpp/splinc.cpp are these loop()s' only callers, and they convert in the same PR-set). Please review them together — same split convention as Phase A's cpu-impl#60 / cpu-lib#74.

Convention reference: fastfields-lib/TEENY-MIGRATION.md §9.

Closes#61


What changed

All three loop()s took a decayed (nbatch, T*, size[], stride[]) tuple and immediately reassembled the tensor their caller had just taken apart. They now take the carrier itself:

// splinc.htemplate <int npoles, bound::type B, classA, typenamereduce_t>
voidloop(A at, constreduce_t * _poles)
// resize.h / restrict.h
template <int D, int O, bound_t B, class AO, class AI, typename reduce_t>
void loop(AO ao, AI ai, reduce_t shift, constreduce_t * _scale,
bound_t bound = bound_t::Dynamic)

Rank, spatial extents, input strides and the batch offsets are derived from the carrier that owns each (R2/R3). resize/restrict previously took four shared size[]/stride[] arrays for two tensors whose spatial extents genuinely differ — that is exactly the shape R3 exists to retire. nbatch is now ao.ndim - D.

Read-only operands are carriers of const scalar_t (R4) — no const_cast; writing through ai is a compile error.

_scale / _poles stay raw pointers: they are vectors of values, not tensor operands (the §9.1 carve-out that also covers pushpull's loc[D]).

Not touched, and load-bearing for the gate:kernels/splinc.hfilter, kernels/gather.hgather_sep/row_k/row_n, pushpull's _axis/_make_axis/_low/_fastweight/_bound_at, the axis-table precomputation, restrict's flat-CSR transpose construction, the cur_b batch-cell caching, and every per-voxel/per-line loop body. The old signatures are deleted, not deprecated.


Judgment calls

1. Template shape follows Phase A exactly: one parameter per TENSOR.AO/AI deduced, D/O/B still explicit template parameters from the *-lib dispatch (R1 — those are not geometry and are not derivable from a carrier). offset_t and scalar_t are derived inside the body (decltype(ao.size(0)), remove_pointer_t<decltype(AO::data)>) because resize/restrict need them for gather_sep's and _make_axis's explicit template arguments — being explicit there guarantees the same instantiation as before, which is exactly what gate criterion 1 measures.

splinc needs no scalar_t parameter at all: unlike distance's dt(at, w) it has no scalar operand of the element type (the poles are reduce_t), so there is nothing to tie back and filter deduces from line.data() as it always did. That is why it carries no static_assert twin of Phase A's.

2. Added one entry-only _TNY_CHECK(ao.ndim == ai.ndim) to resize/restrict. The issue floated this; I took it, but only for the one precondition that is genuinely unchecked. teeny's own peel_front_at<-D> already asserts ndim >= D on each carrier, so that half would be redundant — but nothing ties the two carriers' ranks to each other, and the batch cell index is shared between them, so a rank mismatch would peel ai at an index its own batch does not have. It is entry-only, outside every loop, and compiles out under NDEBUG. Batch extent equality deliberately stays where it is, in the lib's CHECK_SAME_BATCH (behavioural ABI). This is the first runtime assert in this repo's headers, which is why it is scoped this tightly.

3. parallel_for callbacks now spell int64_t rather than long. Phase A's LLP64 tidy-up, applied here because I was rewriting those exact lines: identical on LP64 (hence the byte-identical object code below), a latent narrowing fix on Windows.

4. restrict's flat-CSR buffers. The issue asked to confirm no offset_t widening surprise. They are keyed off osize/isize/fstride, which now read from ao.size(...)/ai.size(...)/ai.stride(...) — carriers from from_dlpack carry int64_t meta, and with the CPU int32 arm deleted lib-side (D1/R5) offset_t is int64_t throughout. The CSR construction is otherwise untouched line for line.


The Phase B1 gate — all criteria, with evidence

Measured on one machine, clang++ 18.1.3 and g++ 13.3.0, -std=c++17 -DTNY_MAX_RANK=64.

Environment note. A second agent was working the sibling Phase B2 (posdef) PR-set in the same shared checkouts and switched them onto its branches mid-capture. All work here therefore moved to isolated git worktrees at the Phase A merge commits (cpu-impl 21833c4, cpu-lib 0b03229). The first object baseline had been taken from the shared tree before that switch; rather than assume it was unaffected (posdef is not included by any of these three TUs), it was rebuilt in the clean isolated tree and comparedresize.o, restrict.o, splinc.o all byte-identical whole-file. So the captured baseline provably represents origin/teeny.

Method / anti-fooling

Comparison is per COMDAT section (one per template instantiation):

  • bytes — raw section contents (objdump -s) and the section's relocation records (objdump -r). Identical bytes with different relocations would be different code, so both must match.
  • loops — loop bodies located by opcode (the range closed by a backward branch), keyed by (length, mnemonic sequence) and compared as a multiset, so a loop that merely moved or was re-inlined does not register. Spans containing ret are excluded: those are function epilogues plus inter-function alignment padding (nop/nopw/data16/cs/xchg), whose spelling the assembler picks freely and which execute in no loop.

Controls. Positive (a file against itself): 2987/2987 sections identical, 19633 loop bodies, 0 changed shapes. Negative (-O2 vs -O3 of the same source): 749 byte diffs, 385 reloc-only diffs, 973 changed loop shapes — so the harness does detect difference, and the relocation channel is live rather than vacuous.

1. Untouched kernel-layer instantiations byte-identical at -O2 — ✅

Base's int64 instantiations against new's. D1/R5 deletes the int32 arm wholesale; a base-only symbol counts as "the deleted int32 arm" only by proof — rewriting intlong in its demangled name must yield a name present among the preserved int64 instantiations — never by substring guess.

moduleconfigcommonidenticalbytes diffreloc diffint32 deletedunexpected goneonly-new
splincclang++ -O22626002600
splincclang++ -O32626002600
splincg++ -O2 / -O30 ¹00000
resizeclang++ -O2 / -O36600600
resizeg++ -O2 / -O36600600
restrictclang++ -O2 / -O36600600
restrictg++ -O2 / -O36600600

All 12 combinations pass: zero byte differences, zero relocation differences, zero unexpected deletions, zero new symbols.

¹ g++ fully inlines splinc::filter — no standalone symbol in either build — so criterion 1 is subsumed by criterion 2 there, exactly as Phase A recorded for distance_l1::kernel. resize/restrict's standalone kernel symbols are ff::cpu::bound::_index<...>; the rest (_make_axis, gather_sep, _pull_rec, _low, _fastweight, _bound_at) is inlined into the driver in both builds and is therefore covered by criterion 2.

A relocation difference that was investigated, not waved away

splinc's 26 int64 filter instantiations first reported identical bytes but differing relocations under clang. Cause: clang's TU-local constant-pool labels .LCPI<funcindex>_<k> renumber (.LCPI223_*.LCPI7_*) because the TU now emits half as many functions; offsets, relocation types and addends were already identical. A dedicated checker resolved every renamed label through the symbol table to its section+offset and compared the constant bytes:

checking 26 int64 filter instantiations
resolved+compared 95 renamed constant references; mismatches=0
VERDICT: label renumbering only, constants identical

Only after that proof were the labels canonicalised in the comparison.

2. Driver loops: no new instructions in the per-element loop — ✅

Where the per-element work lives was established empirically, not assumed: under clang it is the driver's lambda (...::{lambda(long,long)#1}::operator()); _Function_handler/parallel_for hold none.

resize, clang++ -O2, per-voxel lambda, shape level:

lambda sections base/new = 288/288
loop bodies base/new = 3936/3936
distinct shapes base/new = 156/156
shapes with CHANGED multiplicity = 0 -> IDENTICAL

Role breakdown for the same object:

rolebase bodiesnew bodies
lambda::operator() (per-voxel)39363936
resize::loop outer (once-per-call)30181866

The only change is −1152 bodies in the once-per-call prologue — the as_anyrank(..., copy_meta) metadata-copy loops this phase moves out to the caller.

The controlled experiment (the strongest form)

Base-vs-new mixes two changes: D1 deleting the int32 arm, and the carrier refactor. To isolate the refactor a third build was made — base64origin/teeny with only the D1 change applied (ternaries collapsed to int64), old raw-args signatures otherwise untouched. base64 and new contain the same instantiation set and differ only by the carrier refactor. Instantiations are paired one-to-one by a key parsed from the demangled name (leading ints, bound, element type, symbol role), so nothing depends on aggregate symbol counts matching.

moduleconfigpairedunmatchedper-element ADDEDverdict
resizeclang++ -O212000/00PASS
restrictclang++ -O212000/00PASS
splincclang++ -O2940/00PASS
restrictg++ -O27200/00PASS
resizeg++ -O27200/0128 ²explained
splincg++ -O26030/0 ³14 ²explained

Under clang, not one added instruction in any per-element loop, on any module. Additions on the clang side exist only in outer (once-per-call) and are shape shifts paired with larger removals — the axis-table build now reads its extents from the carrier instead of from caller arrays, so the same work is addressed differently; the net for outer is strongly negative (resize −960, restrict −972).

² The two g++ "additions" are shape churn with a net REDUCTION, not extra work. For resize the per-voxel lambda's total body count goes 771 → 707 (−64). Every one of the 106 affected keys shows the identical pattern +1 −2, bodies 3 → 2, and the shape ledger says exactly what happened:

top REMOVED: -68 len=7 cqto idivq imul sub add cmp jne <- the div/mod decode loop
top ADDED: +5 len=287 mov xor test jle mov mov cqto idiv ...
+5 len=437 ... (long fused spans, len 156-565)

68 instances of the 7-instruction integer div/mod loop disappear, and the remaining work is counted as a few longer spans because g++ fused it into one backward-branch region (the heuristic scores a fused loop as one long body). So the per-voxel loop under g++ contains less work, not more — g++ re-inlining the now-smaller driver differently, which is precisely the jitter Phase A's review documented as acceptable, with the stronger statement here that the body count goes down.

³ splinc under g++: +4 bodies in invoke, and it is relocated work, not new work — quantified. The 30 unmatched base keys are all role outer: once carrier construction leaves the driver, g++ stops emitting splinc::loop as a standalone symbol at all and inlines it into the lib shim. Per-role loop-body totals make the trade explicit:

base64 : outer 341 + invoke 214 = 555 total
new : outer 0 + invoke 218 = 218 total (-337)

The invoke handler gains 4 bodies (top added shape +4 len=7 cqto idivq imul sub add cmp jne — the batch-index decode) while 341 outer bodies disappear, and the bodies removed from invoke are exactly the call-containing ones (mov mov mov call mov movsd ... divsd ..., −2 each at len 125/140) — i.e. the call into the now-inlined driver is gone and its decode moved inline. Net for the module: −337 loop bodies. Nothing was added to the per-element work; a once-per-call decode moved across an inlining boundary that no longer exists.

3. Oracle unchanged — ✅

Baseline, clang++, with a verifiedmake clean (the script records the post-clean file count; every read was post-clean: objects/libs/deps=0 test-binaries=0 — the cpu-lib#56 trap), all 12 binaries PASSED:

binarybaselinebinarybaseline
test_resize4130test_distance3067
test_restrict191test_distance_mesh4622
test_splinc4577test_distance_spline704
test_posdef5092test_pushpull326
test_reg_dispatch6030test_reg_field525
test_reg_flow1123test_reg_op186

— exactly the figures issues #61/#75 state. The g++ baseline is byte-for-byte the same listing (diff of the two counts files is empty), each with its own verified post-clean: objects/libs/deps=0 test-binaries=0, 12/12 PASSED.

After the change, whole 12-binary suite, verified clean, all PASSED. diff of the full baseline listing against the post-change listing yields exactly three differing lines — the three touched modules, each by exactly its deliberate test additions:

binarybaselineafter
test_resize41304195+65 deliberate
test_restrict191271+80 deliberate
test_splinc45774813+236 deliberate
test_distance30673067unchanged
test_distance_mesh46224622unchanged
test_distance_spline704704unchanged
test_posdef50925092unchanged
test_pushpull326326unchanged
test_reg_dispatch60306030unchanged
test_reg_field525525unchanged
test_reg_flow11231123unchanged
test_reg_op186186unchanged

Every other module is untouched — resize/restrict/splinc did not move distance/posdef/pushpull/reg_*.

This table is identical on clang++ and g++: the post-change listings from the two compilers diff to nothing at all ("COMPILERS AGREE EXACTLY"), and each of the four suite runs (base×2, new×2) recorded its own post-clean: objects/libs/deps=0 test-binaries=0 before building, with 12/12 PASSED every time.

The three touched modules, old implementation vs new, same test files:

testnew tests × OLD implnew tests × NEW imploriginal-tests baselinedelta
test_splinc4813 / 04813 / 04577+236
test_restrict271 / 0271 / 0191+80
test_resize4195 / 04195 / 04130+65

Identical counts and zero failures on both sides — the added checks pin pre-existing behaviour rather than encoding the new implementation (verified in a separate throwaway worktree so the pristine baseline was never touched). Deltas are exactly the checks added.

4. ASan + UBSan — ✅

-fsanitize=address,undefined, detect_leaks=1, halt_on_error=1, print_stacktrace=1, on the touched paths:

test_splinc : checks 4813, failures 0, exit=0, no sanitizer reports
test_restrict: checks 271, failures 0, exit=0, no sanitizer reports
test_resize : checks 4195, failures 0, exit=0, no sanitizer reports
sanitize overall status=0

Built with g++ — as in Phase A, this box has no clang libclang_rt.asan.

5. Instantiation count (the D1/R5 win)

resize.o, clang++ -O2: resize::loop symbols 4032 → 2016 (base = 2016 int32 + 2016 int64; new = 2016 carrier-based). Halved, as D1 intends — an expected binary-size/compile-time win, not a gate anomaly.


Generated by Claude Code

Phase B1 of the tensor-native-boundaries umbrella (fastfields-lib#57), the
impl half. Applies the Phase A pattern (fastfields-cpu-impl#60) to the three
resampling entry points.
All three `loop()` signatures took a decayed (nbatch, T*, size[], stride[])
tuple and immediately reassembled the tensor the caller had just taken apart.
They now take the carrier itself, one template parameter per TENSOR:
splinc::loop <npoles, B>(A at, const reduce_t * poles)
resize::loop <D, O, B>(AO ao, AI ai, reduce_t shift,
const reduce_t * scale, bound_t bound)
restrict::loop<D, O, B>(AO ao, AI ai, reduce_t shift,
const reduce_t * scale, bound_t bound)
Rank, spatial extents, input strides and the batch offsets are derived from
the carrier that OWNS each (R2/R3) -- resize/restrict took FOUR shared
size[]/stride[] arrays for two tensors whose spatial extents genuinely
differ, which is exactly the shape R3 retires. The read-only operand is a
carrier of `const scalar_t` (R4), no const_cast.
`_scale` / `_poles` stay raw pointers: they are vectors of VALUES, not tensor
operands (the sec. 9.1 carve-out that also covers pushpull's loc[D]).
Not touched, and load-bearing for the gate: kernels/splinc.h `filter`,
kernels/gather.h `gather_sep`/`row_k`/`row_n`, pushpull's `_axis`/`_make_axis`/
`_low`/`_fastweight`/`_bound_at`, the axis-table precomputation, restrict's
flat-CSR transpose construction, the `cur_b` batch-cell caching and every
per-voxel/per-line loop body.
Adds one entry-only `_TNY_CHECK(ao.ndim == ai.ndim)` to resize/restrict: the
batch cell index is shared between the two carriers, and teeny's own
peel_front check only constrains each carrier against D, not against the
other. Compiled out under NDEBUG; batch EXTENT equality stays the *-lib's
CHECK_SAME_BATCH.
Also spells the parallel_for callbacks int64_t rather than long (Phase A's
LLP64 tidy-up; identical on LP64, a latent narrowing fix on Windows).
Paired with fastfields-cpu-lib#75 -- neither compiles without the other.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xcQBMEdA5eQtTsx2wB3AC
@balbasty
balbasty merged commit e844e97 into teenyAug 4, 2026
1 check failed
@balbasty
balbasty deleted the claude/61-resize-restrict-splinc-carriers branch August 4, 2026 11:09
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@balbasty@claude