Skip to content

Benchmark the 32-bit index path on a real GPU, and decide FF_INDEX32's default from the result #144

Description

@balbasty

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

  1. 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.
  2. 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.
  3. Achieved occupancy
    sm__warps_active.avg.pct_of_peak_sustained_active.
  4. 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:

opwhy
reg_flow matvec, 3D, bending onheaviest register user; the 12.98 GB module. Highest prior of a real effect
reg_field matvec, 3D, membrane+bendingthe other heavy regulariser
pushpull pull and push, 3D, cubic, DCT2many offsets live at once; push also brings atomics
resample 3D cubicthe plain gather, no accumulation
posdef matvecdeliberate control: register-light, expect no difference. If this one moves, the harness is wrong
distance_euclideansequential-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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions