Hoisting the sine out of the resampling tap loop¶
The Kaiser-windowed sinc kernel evaluated sinc(x) once per tap, at
x = frac - offset for each of its 127 integer offsets. Because
sin(pi*(frac - offset)) = (-1)**offset * sin(pi*frac)
is an identity in real arithmetic, all 127 transcendentals follow from one: each
tap keeps a sign flip, exact in IEEE 754, and the division sinc already
performed.
This document records what that actually bought, measured at the site rather
than inferred from the arithmetic. Every number below was produced by commit
eaf428d4d0e809a8ad39a048b937cb4e954b8169 via
scripts/sinc_tap_hoist_measurement.py (numpy 2.3.5, CPython 3.14.7, x86-64
glibc, two threads, 127 taps, beta = 32).
Bottom line¶
Speed is the justification; accuracy is not — and the speed is host-path only.
The device-path saving is an artefact of the CPU backend. On a real GPU the hoist is a regression: it takes ~13% longer at 2^18 and ~29% longer at 2^22 (speedups of 0.89x and 0.78x), and the penalty grows with size. Do not quote a device-path speedup from this document without reading On a real GPU.
| direct (one sine per tap) | hoisted (one per position) | |
|---|---|---|
| NumPy path, 2^18 samples | 1.40 s | 1.23 s (1.14x) |
| device path on a CPU backend, 2^18 | 0.138 s | 0.039 s (3.51x) |
| device path on a V100 GPU, 2^18 | 0.00351 s | 0.00395 s (0.89x) |
| device path on a V100 GPU, 2^22 | 0.04197 s | 0.05399 s (0.78x) |
| RMS output error vs a 50-digit reference | 6.33e-16 | 6.01e-16 |
| closer to that reference | 32.0% of positions | 49.0% of positions |
The two CPU figures differ because of the window, not the sine: the NumPy
path still evaluates i0(beta*sqrt(1 - v)) per tap and spends most of its time
there, so removing the sine moves a small share of the total; the device path's
window is a Chebyshev polynomial, which had left the sine as the loop's dominant
cost. On a GPU neither is the bottleneck — see below.
On accuracy the honest answer is "unchanged, with a slight tilt in the hoist's
favour" — not the decisive win the per-tap arithmetic suggests. A resampled
sample is a normalised sum of 127 taps, the Kaiser taper suppresses exactly
the large-|x| taps where the direct sine is least accurate, and the
normalisation cancels part of what survives. Both forms land about 4500x below
this kernel's own truncation error (4.027e-12 of peak), so neither is what
limits it.
What is left, then, is a host-path gain of 1.14x-1.33x on one CPU (1.02x-1.10x on another, below) against a device-path loss of 0.78x-0.91x on a V100, bought with a kernel that is no longer bit-identical. The accuracy argument should not be leaned on in either direction. The commit that introduced the change argued it on "the device path's 3.5x": that figure is the CPU backend's, and the GPU measurement below supersedes it.
Speed¶
Fastest of 5 runs per cell. Run-to-run spread across three repetitions of the whole measurement was about ±0.1x, so these are quoted to two figures, not three.
NumPy path¶
| output samples | direct (s) | hoisted (s) | speedup | shipped (s) |
|---|---|---|---|---|
| 16384 | 0.0648 | 0.0496 | 1.31x | 0.0479 |
| 65536 | 0.2920 | 0.2290 | 1.28x | 0.2280 |
| 262144 | 1.4039 | 1.2278 | 1.14x | 1.1746 |
Across three repetitions the range was 1.14x–1.33x, decreasing with size as the
per-tap i0 comes to dominate. The shipped column is the kernel as committed,
timed identically; it tracks the hoisted transcription, which is the
cross-check that the timing compares what it claims to.
Device path¶
| output samples | direct (s) | hoisted (s) | speedup | shipped matches |
|---|---|---|---|---|
| 16384 | 0.0237 | 0.0045 | 5.30x | hoisted |
| 65536 | 0.0365 | 0.0111 | 3.27x | hoisted |
| 262144 | 0.1378 | 0.0393 | 3.51x | hoisted |
JAX 0.11.0, CPU backend — this host has no NVIDIA GPU. Compilation is excluded. The 5.3x at the smallest size is partly fixed overhead; 3.3x–3.5x at the larger sizes is the figure to believe.
On a real GPU¶
Two SLURM jobs on a Tesla V100-SXM2-32GB (driver 580.173.02, JAX 0.11.1
reporting default backend: gpu, CPython 3.13, commit eaf428d via branch head
09a8cac). The first ran the same measurement script as above; the second
re-ran the device comparison at larger sizes, with 20 repeats and in both
evaluation orders, because a result that reverses the CPU conclusion deserves to
be checked before it is believed rather than after.
| output samples | direct (s) | hoisted (s) | speedup |
|---|---|---|---|
| 16384 | 0.0008 | 0.0008 | 1.02x |
| 65536 | 0.0009 | 0.0009 | 1.08x |
| 262144 | 0.0035 | 0.0039 | 0.90x |
Confirmation run, 20 repeats per cell, both orders:
| output samples | order | direct (s) | hoisted (s) | speedup |
|---|---|---|---|---|
| 262144 | direct first | 0.00351 | 0.00395 | 0.889x |
| 262144 | hoisted first | 0.00355 | 0.00392 | 0.907x |
| 1048576 | direct first | 0.01222 | 0.01539 | 0.794x |
| 1048576 | hoisted first | 0.01221 | 0.01544 | 0.791x |
| 4194304 | direct first | 0.04197 | 0.05399 | 0.777x |
| 4194304 | hoisted first | 0.04198 | 0.05388 | 0.779x |
The two orders agree to within 2%, the penalty is monotone in size, and it settles near 0.78x once the arrays are large enough that per-call dispatch cannot explain anything. The shipped kernel reproduced the hoisted transcription bit for bit at every size, so this compares the two kernels and not two transcriptions that had drifted.
Why it reverses. On the GPU this loop is bandwidth-bound, not
transcendental-bound. At 2^18 samples it already moves ~266 MB through 127
gathers, and 3.5 ms against the V100's ~900 GB/s leaves it an order of magnitude
off bandwidth peak. The hoist removes one sin per tap — which fp64 hardware
handles in a unit that was not the constraint — and in exchange the precomputed
sine becomes an extra full-length array that every one of the 127 iterations
must read. That is the trade in one line: hoisting a per-position array out of
a loop removes arithmetic and adds traffic, which wins where arithmetic is
scarce and loses where bandwidth is. The CPU backend is the first case; the
GPU is the second.
Both figures are real; neither generalises to the other. A document quoting only the 3.5x would be advertising a speedup this project's own GPU path does not get.
How the comparison is kept honest¶
Both variants come from one transcribed body per backend, selected by a
flag, so the loops are structurally identical apart from the sine — Chebyshev
window included. Substituting the exact i0 window into one side would have
hidden the sine's saving behind a far larger cost. The script then requires the
shipped kernel to reproduce one of its two transcriptions bit for bit before
reporting any timing, and names which; neither would mean a transcription had
drifted and the speedup no longer isolated the sine.
Accuracy of the resampled output¶
400 positions inside a 4096-sample band-limited series at 0.5 x Nyquist, unit amplitude, interior only (the ends clamp taps, which is boundary handling rather than arithmetic). The reference evaluates the same normalised tap sum at 50 decimal digits.
Against an exact reference — the end-to-end error of each form¶
| metric | direct | hoisted |
|---|---|---|
| max absolute error | 2.31e-15 | 2.08e-15 |
| RMS absolute error | 6.33e-16 | 6.01e-16 |
| median absolute error | 4.00e-16 | 3.33e-16 |
| max error, ulps of output | 57.4 | 38.8 |
| median error, ulps of output | 5.03 | 4.70 |
Closer to the reference: hoisted at 196 positions (49.0%), direct at 128 (32.0%), identical at 76 (19.0%).
Against a reference sharing the float64 window — isolates the sine¶
Each form's error above includes its own Kaiser-window arithmetic, which is the same code in both and large enough to sit on top of the sine. Taking the window from the float64 loop and treating it as exact removes that common term:
| metric | direct | hoisted |
|---|---|---|
| max absolute error | 1.50e-15 | 1.19e-15 |
| RMS absolute error | 3.94e-16 | 3.71e-16 |
| median absolute error | 2.10e-16 | 2.00e-16 |
| max error, ulps of output | 46.9 | 10.8 |
| median error, ulps of output | 2.96 | 2.55 |
Closer to the reference: hoisted at 182 positions (45.5%), direct at 142 (35.5%), identical at 76.
So the hoist is better by 6% in RMS and 5% in the median once the window is factored out, and better at roughly half the positions against a third — a tilt, not a separation. Read this as "accuracy is not harmed", which is all the change needs.
The two forms against each other¶
Max 8.88e-16 absolute (46 ulps of output), median 2 ulps, RMS 2.33e-16, bit-identical at 76 of 400 positions. This is the quantity a bit-comparability bar would look at, and it is the end-to-end counterpart of the much larger per-tap ulp differences: the tap loop's own structure absorbs most of them.
Against the kernel's own error floor¶
The largest arithmetic effect anywhere above is 8.88e-16 on a unit-amplitude signal. This kernel's truncation error at 127 taps and beta = 32 is 4.027e-12 of peak — larger by a factor of 4.5e3. Changing the arithmetic here cannot move the kernel's accuracy; only taps and beta can.
Why the sine is reduced to [-1/2, 1/2]¶
nearest = round(frac), r = frac - nearest, and
sin(pi*(frac - offset)) = (-1)**(nearest - offset) * sin(pi*r). The
alternative — leaving the argument in [0, 1) — costs exactly the same one
sine, but frac near 1 puts that argument next to pi, where rounding the
product fl(pi)*frac costs the result its leading digits. A separate sweep of
the sine alone, over 45837 inputs against a 50-digit reference, put the
symmetric form within 1.53 ulps everywhere while the [0, 1) form reached
1.3e3 ulps at that boundary. The difference does not survive into the output
at this kernel's settings — see above — but it costs nothing to avoid, and it
would matter to any caller that used the tap weights unnormalised.
One consequence worth stating because it is easy to misread in the source: the
per-position (-1)**nearest factor is not load-bearing. It negates the tap
sum and the weight sum alike, so it cancels exactly in their quotient — verified
bit-identical with and without it. It is kept so that each weight is the kernel
weight rather than its negation; the per-tap (-1)**offset parity is the part
that matters, and dropping that was confirmed to break the tests by O(1).
What is not measured here¶
- Other GPUs. The GPU figures are one V100. An A100/H100 or a consumer card has a different arithmetic-to-bandwidth ratio, so the size of the regression will differ — but the mechanism (extra per-tap traffic) is architectural, not V100-specific.
- float32. Everything here is float64. Nothing in this document speaks to a reduced-precision path.
- Production shapes on the host path. The NumPy timings stop at 2^18 output samples and its speedup was still falling with size there; full segments are larger. The GPU comparison does reach 2^22, and its regression had settled by then.
- One signal. A single tone at 0.5 x Nyquist, one amplitude. The accuracy figures are properties of that signal as well as of the kernel.
- One libm. The accuracy figures are x86-64 glibc via numpy 2.3.5 (the cluster timings ran numpy 2.5.2 / CPython 3.13, which is also why its host-path ratio differs). The mechanism behind the sine's error is argument-side and should carry to other libms, but the sub-ulp figures will not be identical.
Reproducing it¶
uv run --extra jax --with mpmath python scripts/sinc_tap_hoist_measurement.py
mpmath is supplied on the fly and is deliberately not a project dependency.
The run takes about 45 s. --seed fixes the signal and the positions (the speed
and accuracy sections draw from separate generators, so a section's numbers do
not depend on which other sections ran); --skip-speed and --skip-accuracy
select the halves.