Why
offset_t is a template parameter with exactly two values, picked per call by
canUse32BitIndexMath: int32_t when every operand's largest element offset
fits in 32 bits, int64_t otherwise. That single axis costs ×2
instantiations of every kernel below the dispatch layer — on CUDA, ×2 device
code, ×2 SASS and ×2 ptxas memory. Measured on reg_flow (the module that
peaks at 12.98 GB of a 16 GB runner): dropping it is −50.3% instantiations and
−44.6% peak compiler RSS (#94; re-measured on the CPU side in #143).
The narrow path is an ATen inheritance. Its justification is register pressure,
which is a GPU argument. As far as anyone can tell it has never been
benchmarked in this repository — there is no GPU in CI — so the most
expensive build axis in the project is currently held on faith.
#143 makes the axis a per-backend build option (INDEXFLAGS / FF_INDEX32,
defaulting to on for both backends, both positions built by CI). That is
deliberately not a decision: it makes the decision reversible once somebody
with hardware answers this issue. This issue is the spec for that afternoon's
work.
Build the two artifacts
Identical in every respect except the axis:
make cuda INDEXFLAGS="-DFF_INDEX32=1" # A: shipping default
make cuda INDEXFLAGS="-DFF_INDEX32=0" # B: axis collapsed onto int64_t
Same BOUNDFLAGS/SPLINEFLAGS (the Makefile's shipping defaults — register
pressure depends on whether the boundary condition is static or Dynamic, so
this is not a free variable), same -gencode for the actual target arch, same
nvcc. Record all of it.
What to measure, per op
- Kernel time. CUDA events around the launcher (or
nsys). Median of 50
iterations after 10 warmup, GPU quiesced, clocks locked
(nvidia-smi -lgc <clk>). Report median and IQR, not mean. - Registers per thread, and local-memory spills.
ncu --metrics launch__registers_per_thread,l1tex__t_bytes_pipe_lsu_mem_local_op_ld.sum,l1tex__t_bytes_pipe_lsu_mem_local_op_st.sum
— or statically, cuobjdump -res-usage. This is the load-bearing
measurement. Register pressure is the entire claimed mechanism: if A and B
are within a couple of registers per thread and neither spills, there is
nothing left to argue about and the axis can go regardless of what the
timings say. - Achieved occupancy —
sm__warps_active.avg.pct_of_peak_sustained_active. - Host-side per-call cost, end to end through
ff::cuda::<op>, including
the copy_if_needed staging (see below).
Which ops
Chosen to span the register-pressure range:
| op | why |
|---|
reg_flow matvec, 3D, bending on | heaviest register user; the 12.98 GB module. Highest prior of a real effect |
reg_field matvec, 3D, membrane+bending | the other heavy regulariser |
pushpull pull and push, 3D, cubic, DCT2 | many offsets live at once; push also brings atomics |
resample 3D cubic | the plain gather, no accumulation |
posdef matvec | deliberate control: register-light, expect no difference. If this one moves, the harness is wrong |
distance_euclidean | sequential-scan access pattern, unlike everything above — and one of the three modules that already uses copyToDeviceAsync, so its host-side profile differs from the rest |
Which shapes
The axis only engages when the tensor fits in 32 bits, so the shape set has to
straddle that boundary:
[1, 1, 64, 64, 64] — small, launch-overhead dominated.[1, 3, 192, 192, 192] — a realistic flow field. The headline number.- largest shape still under 2³¹−1 max element offset (~2.1e9 elements), e.g.
[1, 1, 1280, 1280, 1280] float32 — the narrow path at its limit. - one shape past the boundary — both builds take the same 64-bit arm here,
so any difference is measurement noise. A null-result control; run it first. - a non-contiguous (strided / permuted) view of the realistic shape:
canUse32BitIndexMath keys on max offset, not numel, so a modest tensor
can still be forced onto the wide arm. nbatch = 1 and nbatch = 8 — occupancy differs.
dtypes: float32 and float64. float64 has the least register headroom, so if
the effect exists anywhere it should be largest there.
The decision rule, stated in advance
- Median kernel time within ~2% across all ops at the realistic shape and
≤2 registers/thread difference with no spills → the axis buys nothing
measurable. Flip the CUDA default to FF_INDEX32=0 and reclaim roughly half
the heaviest compile in the project. - Any op regressing >5% → keep it on CUDA. Consider flipping only the CPU
default separately: the CPU has 64-bit integer units and no register file to
protect, and its build is not the constrained one. - Report per op, not aggregated. If exactly one module shows the effect,
the right follow-up is a per-module policy, not a global one — FF_INDEX32
is currently all-or-nothing per backend and would need extending.
While the harness exists (cheap, related, do not conflate)
On the narrow path CUDA pays a cudaMallocHost + cudaFreeHostper array
per call (core/autocast.h, hostNew/hostDelete under
FF_AUTOCAST_PINNED_HOST). The header justifies pinning as "so the following
H2D copy can be async" — but 363 of the 391 upload sites under
include/fastfields/impl/cuda/ call the synchronouscopyToDevice; only
distance_euclidean.h, distance_l1.h and distance_mesh.h use
copyToDeviceAsync. So for almost every op there is no async copy for the
pinning to enable. Pinned allocation is expensive and serialising. That cost is
only paid on the 32-bit path, so it comes straight off whatever the register
win is worth, and it is worst exactly where the kernel is cheapest (small
tensors).
Time the end-to-end call at the small shape over many iterations and report it
separately from the kernel-time numbers. It is a different question with a
different fix (drop the pinning, or finish converting the uploads to async); it
just happens to be measurable with the same harness.
Record with the results
GPU model and arch, driver version, nvcc version, the -gencode flags actually
used, clock-lock state, and the BOUNDFLAGS/SPLINEFLAGS in force. Without
those the numbers are not reproducible and the next person has to redo it.
Why
offset_tis a template parameter with exactly two values, picked per call bycanUse32BitIndexMath:int32_twhen every operand's largest element offsetfits in 32 bits,
int64_totherwise. That single axis costs ×2instantiations of every kernel below the dispatch layer — on CUDA, ×2 device
code, ×2 SASS and ×2 ptxas memory. Measured on
reg_flow(the module thatpeaks at 12.98 GB of a 16 GB runner): dropping it is −50.3% instantiations and
−44.6% peak compiler RSS (#94; re-measured on the CPU side in #143).
The narrow path is an ATen inheritance. Its justification is register pressure,
which is a GPU argument. As far as anyone can tell it has never been
benchmarked in this repository — there is no GPU in CI — so the most
expensive build axis in the project is currently held on faith.
#143 makes the axis a per-backend build option (
INDEXFLAGS/FF_INDEX32,defaulting to on for both backends, both positions built by CI). That is
deliberately not a decision: it makes the decision reversible once somebody
with hardware answers this issue. This issue is the spec for that afternoon's
work.
Build the two artifacts
Identical in every respect except the axis:
Same
BOUNDFLAGS/SPLINEFLAGS(the Makefile's shipping defaults — registerpressure depends on whether the boundary condition is static or
Dynamic, sothis is not a free variable), same
-gencodefor the actual target arch, samenvcc. Record all of it.
What to measure, per op
nsys). Median of 50iterations after 10 warmup, GPU quiesced, clocks locked
(
nvidia-smi -lgc <clk>). Report median and IQR, not mean.ncu --metrics launch__registers_per_thread,l1tex__t_bytes_pipe_lsu_mem_local_op_ld.sum,l1tex__t_bytes_pipe_lsu_mem_local_op_st.sum— or statically,
cuobjdump -res-usage. This is the load-bearingmeasurement. Register pressure is the entire claimed mechanism: if A and B
are within a couple of registers per thread and neither spills, there is
nothing left to argue about and the axis can go regardless of what the
timings say.
sm__warps_active.avg.pct_of_peak_sustained_active.ff::cuda::<op>, includingthe
copy_if_neededstaging (see below).Which ops
Chosen to span the register-pressure range:
reg_flowmatvec, 3D, bending onreg_fieldmatvec, 3D, membrane+bendingpushpullpull and push, 3D, cubic, DCT2pushalso brings atomicsresample3D cubicposdefmatvecdistance_euclideancopyToDeviceAsync, so its host-side profile differs from the restWhich shapes
The axis only engages when the tensor fits in 32 bits, so the shape set has to
straddle that boundary:
[1, 1, 64, 64, 64]— small, launch-overhead dominated.[1, 3, 192, 192, 192]— a realistic flow field. The headline number.[1, 1, 1280, 1280, 1280]float32 — the narrow path at its limit.so any difference is measurement noise. A null-result control; run it first.
canUse32BitIndexMathkeys on max offset, not numel, so a modest tensorcan still be forced onto the wide arm.
nbatch = 1andnbatch = 8— occupancy differs.dtypes: float32 and float64. float64 has the least register headroom, so if
the effect exists anywhere it should be largest there.
The decision rule, stated in advance
≤2 registers/thread difference with no spills → the axis buys nothing
measurable. Flip the CUDA default to
FF_INDEX32=0and reclaim roughly halfthe heaviest compile in the project.
default separately: the CPU has 64-bit integer units and no register file to
protect, and its build is not the constrained one.
the right follow-up is a per-module policy, not a global one —
FF_INDEX32is currently all-or-nothing per backend and would need extending.
While the harness exists (cheap, related, do not conflate)
On the narrow path CUDA pays a
cudaMallocHost+cudaFreeHostper arrayper call (
core/autocast.h,hostNew/hostDeleteunderFF_AUTOCAST_PINNED_HOST). The header justifies pinning as "so the followingH2D copy can be async" — but 363 of the 391 upload sites under
include/fastfields/impl/cuda/call the synchronouscopyToDevice; onlydistance_euclidean.h,distance_l1.handdistance_mesh.husecopyToDeviceAsync. So for almost every op there is no async copy for thepinning to enable. Pinned allocation is expensive and serialising. That cost is
only paid on the 32-bit path, so it comes straight off whatever the register
win is worth, and it is worst exactly where the kernel is cheapest (small
tensors).
Time the end-to-end call at the small shape over many iterations and report it
separately from the kernel-time numbers. It is a different question with a
different fix (drop the pinning, or finish converting the uploads to async); it
just happens to be measurable with the same harness.
Record with the results
GPU model and arch, driver version, nvcc version, the
-gencodeflags actuallyused, clock-lock state, and the
BOUNDFLAGS/SPLINEFLAGSin force. Withoutthose the numbers are not reproducible and the next person has to redo it.