Uh oh!
There was an error while loading. Please reload this page.
PROTOTYPE (do not merge): the 32-bit index axis is half the instantiation surface - #94
PROTOTYPE (do not merge): the 32-bit index axis is half the instantiation surface#94balbasty wants to merge 1 commit into
Conversation
…X32 knob RFC prototype for discussion, not for merge as-is. One module converted (src/lib-cpu/reg_field.cpp) to show what the other five of its shape would look like. Primary motivation is instantiation count, not line count. Every templated kernel below the dispatch layer is templated on offset_t, offset_t has exactly two values, and which one is used is a runtime property of the tensor (canUse32BitIndexMath). So the 32-bit index path costs exactly x2 instantiations of everything -- on the CUDA side, x2 device code, hence x2 ptxas memory and x2 SASS in a library that is already 166 MB and whose reg_flow module already peaks at 13.6 GB of nvcc RSS on a 16 GB runner, before any -arch/-gencode multiplier. include/fastfields/api/dispatch.h (new) The ndim x bound x dtype x index-width pyramid, stated once. dispatch_nbd<Op>(key, args...) replaces NDIM_SWITCH / BOUND_SWITCH / BND1..3 / one <OP>_DT per entry point / one <OP>_ARGS per entry point. Arguments become an ordinary call argument list, so the GNU `args...` named-variadic-macro syntax (which MSVC's traditional preprocessor rejects) goes with them. FF_INDEX32 turns the index axis into one build flag instead of ~40 hard-coded ternaries across 21 translation units. include/fastfields/core/autocast.h IndexArray<offset_t>: RAII replacement for the copy_if_needed / free_if_needed pair, with a small stack buffer instead of an allocation. Fixes the leak the manual form has when anything between the two throws, and takes cudaMallocHost/cudaFreeHost off the CUDA per-call path. The old helpers are untouched; nothing else uses IndexArray yet. src/lib-cpu/reg_field.cpp Converted. 1369 -> 1036 lines. _field_matvec was byte-identical to _field_matvec_acc<'='> and is gone; the seven remaining wrappers became op structs, and the op character is a template parameter of the struct rather than a macro per variant. tests/dispatch/packcheck.cpp Hand-run, not globbed by any make target, so the recorded baseline is untouched. Checks the driver hands the leaf the same ndim, scalar_t, offset_t and boundary-pack length the macros did, over all 48 (ndim, bound, index-width) configurations, and that the dtype / ndim / bound rejection paths still throw. C++11. Clean under clang++ and g++ at -std=c++11 and -std=c++14, -pedantic, with and without FF_INDEX32.
balbasty
commented
Aug 20, 2026
|
#151 recorded what FF_INDEX32 costs on the unsplit modules. This is the same question asked of a slice, because the interesting case is whether splitting makes the axis cheap enough that the knob stops mattering. It does not. Same TU, index axis toggled: reg_flow_matvec_3d FF_INDEX32=1 1.94 GiB 277.1 s 17.0 MB FF_INDEX32=0 1.05 GiB 174.4 s 10.0 MB -45.8% peak, which is the same proportion #94 measured on the unsplit file with the host compiler (-44.6%). The split and the knob multiply rather than overlap: 12.98 -> 1.94 GiB from one, 1.94 -> 1.05 GiB from the other.
balbasty
commented
Aug 20, 2026
Taking the five points in order. Short version: you are right on (3), and the Two things changed underneath this PR and both matter for what is left of it:
I also have nvcc available locally this time, which the original run did 1. |
| surface | TUs | axes resolved, outer → inner | leaf template order |
|---|---|---|---|
reg_field / reg_flow (± _rls) | 6 | ndim(3) → bound(8, expanded to an ndim-long pack) → dtype(2) → index(2) | <ndim, [op,] scalar_t, offset_t, bound::type...> |
pushpull / pushpull_backward | 4 | ndim(3) → order(8) → bound(8) → dtype(2) → index(2) | <ndim, spline::type, bound::type, scalar_t, offset_t> |
resize / restrict | 4 | ndim(3) → order(8) → bound(8) → dtype(2) → index(2) | as pushpull, but literal bounds |
splinc | 2 | dtype(2) → index(2) → npoles(3) → bound(5) | <npoles, bound::type, scalar_t, offset_t> |
posdef | 2 | C(1,2,3,dyn) → dtype → index; and dtype → index | <C, scalar_t, offset_t> / <scalar_t, offset_t> |
distance | 2 | three different pyramids in one file | <scalar_t,offset_t> / <ndim,scalar_t,offset_t> / <ndim,scalar_t,index_t,offset_t> |
solve_field | 1 | dtype → index | <scalar_t, offset_t> |
Eight distinct leaf template-argument orders across seven surfaces. The
specific ways they refuse to line up:
- Only the regularisers expand the boundary condition into an ndim-long
pack. Everywhere else it is a single value. The pack length is load-bearing
(bound::getutils<B>/<B,B>/<B,B,B>detect isotropy from it). splincresolves dtype and index width outermost; every other surface
resolves them innermost. A dispatcher fixed in axis order cannot express it.distance's mesh pyramid has an extra dtype axis (the face-index type,
8 arms, signed and unsigned) and putsoffset_tlast, not third.- The boundary axis exists in four mutually incompatible forms: the reg
pack, pushpull's singleFF_BOUND_<NAME>, resize/restrict's single
literalbound_t::…, and splinc's 5-of-8 literal subset. The literal
ones do not route throughDynamic, i.e.resize,restrictandsplinc
do not honourBOUNDFLAGSat all. I don't think that's deliberate, but it
is load-bearing for what follows. - Even inside
reg_field.cppthe leaf order is not uniform: nine entry
points thread a compile-timecharop as the second template argument and
seven do not.
So api/dispatch.h was exactly as mis-named as you say. It is also a name
collision waiting to happen: core/dispatch.h already exists and holds the
genuinely surface-independent helpers (FF_VOIDPTR, FF_CANUSE32BITS,FF_CHECK_*, off32_t, FF_INDEX32). A second dispatch.h under api/
would read as its sibling while meaning something much narrower.
Now the number that decides (4) and (5). Counting the dtype × index-width
fan — the innermost 2×2 that is the one thing every surface does have — by
where the copies actually are:
| fans | off32_t mentions | |
|---|---|---|
| the 6 regulariser TUs | 52 | 104 |
| the other 15 TUs, combined | 19 | 36 |
| whole tree | 71 | 140 |
73% of the duplication is in one surface, because that surface copies the fan
once per entry point rather than once per module — thirteen times inreg_field.cpp alone. Every other surface has one to three copies total.
Within the regularisers the rest is near-exact: BND1/BND2/BND3 andBOUND_SWITCH are byte-identical in all six TUs, and NDIM_SWITCH differs
in exactly one word — the noun in its diagnostic, "field" versus "flow".
4. Can we write a more generic dispatcher?
No — and I'd argue against it even though it is possible. Four reasons, in
descending order of how much I believe them:
- The deduplication yield is close to zero. A dispatcher generic over all
seven surfaces needs a per-surface trait naming the axis list, the mapping
from axis values to leaf template-argument positions, and the accept/reject
sets (splinc's 5-of-8 bounds, mesh's 2-of-3 ranks). That trait is per surface
and about as long as the pyramid it replaces. Meanwhile the measured
redundancy is 52 of 71 fans inside a single surface, which a per-surface
header captures completely. The generic machinery would exist to capture the
19 remaining fans spread across six surfaces that have one to three each. - C++11 makes the general case expensive in exactly the wrong currency. No
if constexpr, no fold expressions, noenable_if_t, no generic lambdas,
and noindex_sequence(that is C++14, and the CPU/hub layers are C++11). A
dispatcher generic in the axis list is recursive over that list, and each
node is a class-template instantiation per axis prefix. Note that this PR's
own driver deliberately avoided that — it hand-wrote three_expand_boundspecialisations rather than recursing, with the comment that
the recursive form "instantiatesndim + 1class templates … per (op, ndim,
bound) triple, which is pure front-end cost for no benefit" — and it still
only handled one fixed axis list. Generalising re-introduces precisely what
it removed. - It cannot be sold on memory, and I want to be explicit about that rather
than imply otherwise. I re-measured on currentmain: a driver of this
shape is exactly instantiation-neutral.reg_field.cpp,clang++ -O3 -fPICwith the CUDA shipping bound policy, counting defined text/weak
symbols — 10,140 before, 10,140 after, object 19,533,368 → 19,533,360
bytes. Leaf codegen is what ptxas processes, and it does not move. The only
honest case for a generic dispatcher is readability and MSVC portability. - It would quietly change instantiation policy. Any unified boundary axis
has to pick one treatment, andresize/restrict/splinccurrently use
literal bounds that bypassBOUNDFLAGSwhile the regularisers and pushpull
route throughFF_BOUND_<NAME>. Unifying them changes what gets
instantiated — in one direction that is a memory regression, in the other a
behaviour change. Either way it is not a refactor, and given that any
increase in instantiation count is a non-starter, it is the wrong thing to
bundle into one.
On MSVC specifically, since the PR raised it: the GNU named-variadicargs... syntax is a genuine blocker and it is orthogonal to this question —
it is fixed by spelling the macros with ISO ... / __VA_ARGS__, which the
new header does. But I want to be plain that this is a down payment, not a fix:
MSVC's traditional preprocessor also mis-forwards __VA_ARGS__ into a nested
macro (it arrives as a single argument), so a nested pyramid needs/Zc:preprocessor regardless of how it is written. There is no MSVC in this
environment and I have not tested any of that — it is from the documented
behaviour, not measured. If MSVC becomes a real target, that is its own
investigation and the template-driver form does side-step it, which is the one
argument for the driver I still find persuasive.
5. Dispatch utils files per API surface?
Yes — and the tree has already converged on this twice, independently of this
PR.
api/cpu/pushpull_dispatch.handapi/cuda/pushpull_dispatch.hare
already merged and are exactly this: one header per surface, shared by that
surface's two TUs,#pragma once,FF_-prefixed macros. Worth noting that
the two backend copies are macro-identical — they differ only in comment
text and one#includeline (impl/cpu/pushpull.hvsimpl/cuda/pushpull.h). Whether that pair should collapse further is a
separate question I have not touched.- perf(cuda): split reg_flow into per-(family, ndim) translation units #147 independently arrived at the same answer while splitting
reg_flow:
itssrc/lib-cuda/reg_flow_slice.inldefinesFF_FLOW_BND1/2/3,FF_FLOW_MV_DT,FF_FLOW_DG_DT, … — the same pyramid, renamed per surface.
So the pattern is established; what is missing is the regulariser one, which is
where 73% of the duplication lives. Naming and placement, following pushpull:
include/fastfields/api/<backend>/reg_dispatch.h—<surface>_dispatch.h,
matchingpushpull_dispatch.h, so the name says which surface it belongs to
and cannot be mistaken forcore/dispatch.h.api/, notcore/. Per the definition PROPOSAL (do not merge): what core/ means, and a naming scheme #149 settled on and you agreed to,core/holds what more than one layer needs and that is not the computation
of a named fastfields operation. A header that names one operation family's
leaf templates is tied to a named operation, so it belongs beside the entry
points whose leaves it names.- One header for
reg_fieldandreg_flowrather than one each, because
their pyramids were byte-identical apart from that one diagnostic noun, which
becomes a parameter.
What I am opening instead of this PR
Three independently reviewable pieces. Each says what it is worth alone.
core/autocast.h:IndexArray— fixes the leak-on-throw (32 bytes / 2
allocations per throwing call, reproduced under LSan), stops the narrow arm
allocating for the ranks that actually occur, takescudaMallocHostoff the
CUDA per-call path, and adds the missing<stdexcept>. Converts the eight
non-regulariser surfaces on both backends (228 call sites, 15 TUs). Worth
landing even if the dispatch question is answered "leave it alone".api/cpu/reg_dispatch.h— the answer to (3) and (5): 52 fans → 2,BOUND_SWITCHandNDIM_SWITCH→ one each, −473 lines across the two CPU
regulariser TUs. Measured instantiation-neutral (10,140 → 10,140). Worth
landing as readability only; it makes no memory claim.- The CUDA mirror of that header is deliberately not in it, because
src/lib-cuda/reg_flow.cppis being rewritten by perf(cuda): split reg_flow into per-(family, ndim) translation units #147. That half should
follow perf(cuda): split reg_flow into per-(family, ndim) translation units #147 rather than collide with it, and perf(cuda): split reg_flow into per-(family, ndim) translation units #147'sFF_FLOW_*macros are
the natural thing to point at the shared header once both have landed.
This PR should be closed rather than reworked in place — its headline
finding landed as #143, its api/dispatch.h is superseded by the per-surface
header, and its autocast half is now its own change. I have left the branch
alone; say the word and I will close it, or retitle it as the record of the
measurement if that is more useful.
What I dropped from it, and why
- The template-driver form (a struct per op,
dispatch_nbd<Op>(key, …)).
It is instantiation-neutral, so it buys no memory, and its own "known costs"
section is right that wrapping wrapper bodies in structs re-indents them and
makes the whole file changed-lines for the format lint. The macro header gets
the same deduplication with a diff that is almost entirely deletions. tests/dispatch/packcheck.cpp— it existed to prove the driver handed
the leaf the samendim/scalar_t/offset_t/pack length the macros did.
The replacement does not need it: the pack macros are the same tokens in a
different file, so there is nothing new that could disagree.
Gate
tools/test-baseline.sh --legs default,lib --check tools/test-baseline.expected
→ I will post the exact rows on each PR rather than assert them here. CUDA is
compile-verified locally with nvcc 12.0 as well as in CI, but there is still no
GPU here, so anything about runtime behaviour on the CUDA side — including
whether dropping cudaMallocHost changes upload throughput — is unmeasured and
I am not claiming it either way.
Generated by Claude Code
balbasty
commented
Aug 20, 2026
The two replacements are open:
Neither carries this PR's The CUDA mirror of #158's header is the obvious third piece, but it wants Happy to close this one whenever you like. Generated by Claude Code |
balbasty
commented
Aug 20, 2026
Two corrections to my long reply above, both found by checking my own numbers 1. The op / no-op split in
The point it was supporting is unaffected — the leaf order is still not uniform 2. The leak in (2) is less reachable on the CPU than I implied. I said it is
Which also explains why CI is green on Status: #158 is green on every check CI runs for it, including One more thing came out of the measurement and is filed separately as #160: Generated by Claude Code |
balbasty
commented
Aug 20, 2026
One point of precision on my answer to (5), because "yes, dispatch utils per API A per-surface header earns its place only where a surface has more than one
For the bottom five, a header would move one switch out of one file and add an
So the rule I would actually propose is narrower than "one per surface":
That is precisely what It also happens to be the rule that predicts where the bugs would be: a macro Generated by Claude Code |
balbasty
commented
Aug 20, 2026
Closing the loop on the one claim in this PR that was an extrapolation rather This PR argued the index axis costs "×2 device code, hence ×2 ptxas memory and #143's two-leg matrix now builds the same tree twice with only
Roughly 2×, reproduced across independent runs. So the shape of the original Three caveats, because this is easy to over-read:
Which is the last thing I wanted to check before recommending this PR be closed: Generated by Claude Code |
What this measures
The brief was "can autocast be simplified, and can the dispatch boilerplate be
reduced". Both turn out to be the same question, and the answer is a number
rather than a style preference.
Every templated kernel below the dispatch layer is templated on
offset_t.offset_thas exactly two values, chosen per call bycanUse32BitIndexMath.So the 32-bit index path costs exactly ×2 instantiations of everything — on
CUDA that is ×2 device code, hence ×2 ptxas memory and ×2 SASS.
autocast.hexists solely to feed that second instantiation.
Against a
reg_flowthat peaks at 12.98 GB of a 16 GB runner today (PR #87'smeasured table), with no
-archflags and before half/bf16, that axis is themost expensive thing in the build.
Measured —
reg_flow, the binding constraintclang++ -O3 -fPIC,BOUNDFLAGSset to the CUDA shipping policy(
-DFF_STATIC_BOUNDS=0 -DFF_STATIC_BOUND_DCT2=1 -DFF_STATIC_BOUND_DST2=1)."instantiations" counts defined text/weak symbols in the templated impl layer —
the set ptxas would have to process on the CUDA side.
mainBOUNDFLAGSall-Dynamicndim(3D arm)Measured —
reg_field, and what the template driver itself costsmainmain+ no 32-bit (macro edit only)FF_INDEX32=0Two things follow, and they should be decided separately:
marginally smaller object, peak RSS flat, ~3% more front-end CPU. It does
not regress memory (the failure mode worth worrying about), and it does
not fix it either. It is a readability change, nothing more.
FF_INDEX32=0is the whole win, and it does not need the driver — the"macro edit only" row is
mainwith 26use_32bits ? … : …ternariescollapsed by hand, and it lands on exactly the same 4,770 instantiations.
What is in the diff
include/fastfields/api/dispatch.h(new) — the ndim × bound × dtype ×index-width pyramid stated once.
dispatch_nbd<Op>(key, args…)replacesNDIM_SWITCH,BOUND_SWITCH,BND1/BND2/BND3, one<OP>_DTper entrypoint and one
<OP>_ARGSper entry point. Arguments become an ordinary callargument list, so the GNU
args...named-variadic-macro syntax goes withthem (MSVC's traditional preprocessor rejects it).
FF_INDEX32makes the index axis one build flag instead of ~40 hard-codedternaries across 21 translation units.
include/fastfields/core/autocast.h—IndexArray<offset_t>, the RAIIform of the
copy_if_needed/free_if_neededpair, with a small stackbuffer instead of an allocation. Fixes the leak the manual form has when
anything between the two throws (every
reg_*wrapper doesnew reduce_t[...]after the copy; every CUDA launcher throws), and takescudaMallocHost/cudaFreeHostoff the CUDA per-call path. The header'sjustification for pinning — "so the following H2D copy can be async" — does
not hold for the launchers that exist: 365 of the
impl/cudaupload sitescall the synchronous
copyToDevice. The old helpers are untouched.src/lib-cpu/reg_field.cpp— converted. 1369 → 1036 lines._field_matvecwas byte-identical to_field_matvec_acc<'='>and is gone.tests/dispatch/packcheck.cpp— hand-run, deliberately not globbed by anymake target so the recorded baseline cannot move. Checks the driver hands the
leaf the same
ndim,scalar_t,offset_tand boundary-pack length themacros did across all 48 (ndim, bound, index-width) configurations, and that
the dtype/ndim/bound rejection paths still throw. Passes under clang++ and
g++, with and without
FF_INDEX32.Gate
tools/test-baseline.sh --tree . --legs default,lib --cxx clang++→13 suites, 59,886 checks, 0 failures, plus the 2 hub suites (14 checks).
Row-for-row identical to
tools/test-baseline.expected.C++11. Clean under
clang++andg++at-std=c++11and-std=c++14,-pedantic, with and withoutFF_INDEX32. nvcc is not available here — theCUDA half of any migration must be gated on a real
build-cudarun.Known costs
makes the whole file "changed lines" for the lint gate — and the repo's
existing sources are not clang-format-clean (
reg_flow.cppalone would take2,160 replacements). A migration should therefore keep the wrappers as free
functions and add a six-line forwarding struct per op, leaving bodies
untouched. This PR does not do that, on purpose, so the cost is visible.
splinc/posdef/distance/solve_fieldhave differently-shapedpyramids and are not worth forcing into this driver.
Generated by Claude Code