Which float64 sin(pi*x) is more accurate: direct, or hoisted out of the tap loop?¶
A windowed-sinc resample tap loop evaluates sin(pi*x) once per tap, at
x = k + frac — k the integer tap offset, frac the fractional sample phase.
Since
sin(pi*(k + frac)) = (-1)**k * sin(pi*frac)
is an identity in real arithmetic, the transcendental can be hoisted: evaluate the sine once per loop and turn it into each tap's value with a sign flip, which is exact in IEEE 754. That replaces 127 sine calls with one. It is not bit-identical to the direct form, which raised the question this study answers: which form is closer to the true value?
Everything below is measured against an mpmath reference at 50 decimal digits,
over 45 837 inputs. The script is scripts/sin_hoist_accuracy.py; the command
to rerun it is at the end.
Bottom line¶
The hoisted form is more accurate, decisively and systematically — not indistinguishable. Over the pooled sweep, taking only the 31 578 inputs where an ulp count is meaningful (see Metrics):
| pooled metric | direct = sin(pi*x) |
hoist[0,1) = (-1)**floor(x) * sin(pi*frac) |
|---|---|---|
| median error | 71.2 ulps | 0.400 ulps |
| mean error | 3.32e6 ulps | 11.3 ulps |
| max error | 1.02e10 ulps | 1.31e3 ulps |
| max absolute error | 2.9e-9 | 3.43e-16 |
| strictly closer to the reference | 634 inputs (1.38%) | 43 815 inputs (95.59%) |
In the regime the kernel actually occupies — a 127-tap loop, so |k| <= 63 —
the hoisted form's median error is 0.399 ulps against 28.8 ulps, it is
closer at 94.54% of inputs against 1.81%, and where it wins it wins by a median
factor of 62x (median margin 32.8 ulps, worst 9.29e4 ulps). Where the direct
form wins it wins by a median factor of 2.4x (median margin 1 ulp).
The reason is structural, not statistical: float64(pi) falls short of pi by
1.2246e-16, so sin(fl(pi)*x) evaluates the sine at an argument displaced by
about |x| * 1.2246e-16. That displacement grows with |x|; the hoisted form's
argument never leaves [0, 1), so its displacement does not grow at all. The
hoist's advantage is therefore a factor of roughly |x|, confirmed directly
below.
A third form is better still. Reducing to [-1/2, 1/2] instead of [0, 1)
— i.e. n = round(x), r = x - n, value (-1)**n * sin(pi*r) — costs exactly
the same one transcendental per loop and the same per-tap sign flip, and it is
never worse than 1.53 ulps at any of the 45 837 inputs — including the ones
adjacent to a zero, where the other two forms reach 1e15 ulps — with a max
absolute error of 1.24e-16. It removes the one weakness the [0, 1) hoist has:
at frac -> 1 the reduced argument sits next to pi, where rounding
fl(pi)*frac costs the result its leading digits. If the hoist is adopted, this
is the variant to adopt.
What was compared¶
| form | expression | sines per 127-tap loop |
|---|---|---|
direct |
sin(pi*x) |
127 |
hoist[0,1) |
(-1)**floor(x) * sin(pi*frac), frac = x - floor(x) |
1 |
hoist[-1/2,1/2] |
(-1)**n * sin(pi*r), n = round(x), r = x - n |
1 |
r is as loop-invariant as frac is (round splits the phase once, before the
loop), and the per-tap sign is still a parity flip of the tap index, so the
third form saves the same 126 of 127 transcendentals as the second. Its extra
cost over the second is one round instead of one floor per loop.
How it was measured¶
- Reference: mpmath at 50 decimal digits (~166 bits),
picarried at the same precision, evaluated from the exact reduced argument so that the reference is exactly zero at integerx. - Reference validated, not assumed: the reduced reference agrees with an
unreduced high-precision
sin(pi*x)computed at 120 digits to a maximum relative discrepancy of 2.6e-51 across every group. That is 35 orders of magnitude below the ~1e-16 effects under study. - The reductions were verified exact, not argued:
x - floor(x),x - round(x)andfrac - round(frac)were each checked in high precision at every input — 137 511 of 137 511 exact, zero violations. This matters for fairness: if a reduction were inexact, the hoisted form would be approximating a different real number and the comparison would be measuring the reduction rather than the sine. -
Two studies, because the answer depends on what counts as the input:
- Study A (same-x) — the input is one float64
x; all three forms approximatesin(pi*x)for that exactx. A like-for-like comparison of three routes to the same real number. - Study B (kernel-faithful) — the input is the
(frac, k)pair the loop actually holds and the target issin(pi*(frac + k))for the exact sum. The direct form must round that sum into a float64 first, which discards low bits offraconce|k|is large; the hoisted forms never form it. This is the error the kernel incurs.
Study B is the harsher test of the direct form and it agrees with Study A: pooled medians 83 / 0.369 / 0.257 ulps for direct /
[0,1)/[-1/2,1/2], with the hoisted form closer at 96.06% of inputs against 1.16%. The conclusion does not depend on which framing is used. - Study A (same-x) — the input is one float64
Sweep¶
45 837 inputs in four labelled groups:
| group | inputs | what it covers |
|---|---|---|
tap_loop |
29 696 | \|k\| <= 63 (the 127-tap loop) x 232 phases spanning [0, 1) |
large_offset |
6 192 | \|k\| from 1e1 to 7e6, where fl(pi)*x loses bits to argument reduction |
random_decades |
9 800 | uniform random x over decades 1e0 to 1e7, both signs |
integer_x |
149 | frac == 0 exactly, where the true value is exactly 0 |
The phase axis is not purely random: it enumerates 2**-j and 1 - 2**-j for
j = 1..53 and the dyadic rationals, so both branch boundaries of the reduction
are hit exactly rather than approached by luck.
Metrics¶
Error is |form - reference|, differenced in high precision, reported in
absolute terms and in ulps of the reference. Ulp counts are given twice: over
all points with a non-zero reference, and over the subset with
|reference| > 1e-3. The distinction matters — within ~3e-4 of a zero of
sin(pi*x) the result's own ulp collapses while the argument's representation
error does not, so an ulp count there measures the conditioning of the zero
rather than any form's quality, and mean ulps over the unrestricted set are
dominated by those points (which is why the headline table uses the restricted
set and the median). Absolute error is the meaningful metric near the zeros, and
it tells the same story: max 2.9e-9 direct against 3.43e-16 hoisted.
The advantage scales with the integer part¶
Median and max ulps at fixed |k|, over 300 phases in [0.05, 0.95] (Study A):
\|k\| |
direct |
hoist[0,1) |
hoist[-1/2,1/2] |
|---|---|---|---|
| 0 | 0.377 / 10.3 | 0.377 / 10.3 | 0.310 / 1.17 |
| 1 | 1.05 / 21.6 | 0.391 / 7.91 | 0.312 / 1.09 |
| 4 | 2.59 / 48.3 | 0.401 / 7.08 | 0.298 / 1.11 |
| 16 | 10.4 / 188 | 0.335 / 11.5 | 0.259 / 1.14 |
| 63 | 36.4 / 750 | 0.367 / 9.79 | 0.304 / 1.14 |
| 1e2 | 74.8 / 1.41e3 | 0.389 / 9.01 | 0.307 / 1.14 |
| 1e3 | 615 / 1.22e4 | 0.366 / 10.5 | 0.279 / 1.06 |
| 1e4 | 5.38e3 / 1.01e5 | 0.380 / 10.3 | 0.285 / 1.48 |
| 1e5 | 7.02e4 / 1.36e6 | 0.399 / 11.0 | 0.294 / 1.20 |
| 1e6 | 5.80e5 / 1.12e7 | 0.368 / 9.23 | 0.276 / 1.05 |
The direct form's median error grows linearly with |k| across six decades — a
factor of 1.5e6 from |k| = 0 to |k| = 1e6, matching |x| — while both
hoisted forms stay flat at a third of an ulp. At |k| = 0 the direct and
[0,1) forms are identical by construction, which is the control: the rows
agree exactly, as they must.
This is the decomposition behind the headline number. The direct form's error is not "sine error"; it is argument error, in a term the hoist deletes.
Where the direct form wins, and why it does not rescue it¶
The direct form is strictly closer at 1.38% of pooled inputs. Those wins are not
scattered: in the tap regime they concentrate at small |x| (median |x|
4.8, 90th percentile 36) and skew hard toward frac -> 1 (46.7% of them have
frac > 0.99, against 19.4% of the group as a whole). That is the [0, 1)
reduction's one weakness: at frac -> 1 its argument fl(pi)*frac sits next to
pi, and the rounding of that product eats the leading digits of a result that is
itself near zero. When |x| is small there is no compensating
argument-reduction penalty on the direct form, so the direct form wins there —
by a median factor of 2.4x, occasionally by 438x.
Isolating that boundary (median ulps over k in [-63, 1000], Study A;
selected rows from the script's full table):
1 - frac |
direct |
hoist[0,1) |
hoist[-1/2,1/2] |
|---|---|---|---|
| 0.5 | 0 | 0 | 0 |
| 3.12e-2 | 125 | 16.1 | 0.118 |
| 1.95e-3 | 1.45e3 | 373 | 0.104 |
| 1.22e-4 | 2.01e4 | 1.09e3 | 0.477 |
| 7.63e-6 | 4.18e5 | 2.46e4 | 0.174 |
| 4.77e-7 | 6.77e6 | 3.05e5 | 0.407 |
| 2.98e-8 | 1.09e8 | 4.79e6 | 0.060 |
| 1.86e-9 | 1.47e9 | 3.45e8 | 0.235 |
| 1.16e-10 | 8.39e9 | 5.25e9 | 0.276 |
| 1.11e-16 | 2.21e15 | 4.42e15 | 0.276 |
Both the direct and [0, 1) forms degrade without bound as x approaches an
integer; the [0, 1) hoist is 4–23x better than direct through most of that
approach and only catches up (then falls marginally behind) in the last decade
or two, where both are meaningless in relative terms. The symmetric reduction
stays under an ulp throughout — it is the only form that does not have this
failure mode, because r -> 0 there and a small argument is exactly the
well-conditioned case. Note also that at exactly integer x both hoists
return exactly 0, which is exactly right, while the direct form returns up to
2.23e-10.
At more than half of the points where direct beats hoist[0,1) in the tap
regime (328 of 537), hoist[-1/2,1/2] beats direct anyway. Choosing the
symmetric reduction removes the exception rather than trading against it.
Anchors¶
Per the discipline of not quoting a number that has never been checked against something external:
- Analytic prediction of the direct form's error.
pi - fl(pi) = 1.2246467991473532e-16— a constant independent of this study; it is also what libm returns forsin(float64(pi)). Predicting the direct form's error as|cos(pi*x)| * |x| * (pi - fl(pi))gives measured/predicted with median 1.10 (random decades) and 1.18 (large offsets), 10th–90th percentile 0.18–2.7. The spread is the|cos|factor and the product rounding the prediction omits; the median near 1 says the mechanism is identified, not merely correlated. - First-order error bound, all three forms. Bounding the argument
displacement by
|a| * (pi - fl(pi)) + 0.5 ulp(pi*|a|), converting through|cos(pi*a)|, and adding 1 ulp for libm gives a bound satisfied at 45 688 of 45 688 Study A points in the three ulp-bearing groups, with max measured/bound = 1.00. In Study B the direct form exceeds this bound at ~2 400 points (max ratio 2.01) — correctly, because Study B adds the rounding offrac + k, which the bound deliberately omits. The bound holds for both hoisted forms in both studies. - Independent sine implementation. numpy dispatches
np.sinto a SIMD kernel that is not the platform libm's scalarsin. On all 29 696 tap-regime arguments, for all three forms, the two implementations returned bit-identical results (max gap 0 ulps). The ranking is therefore not an artefact of numpy's vectorised sine. - Reference validated at two precisions and by two algebraically distinct routes (see above): agreement to 2.6e-51 relative.
What is not anchored here¶
Stated explicitly, because these are the gaps a reader would otherwise assume were covered:
- The speedup is not measured in this study. This is an accuracy measurement only. It says nothing about how much time the hoist saves.
-
End-to-end effect on resampled output is not measured here, and the two quantities are not interchangeable. This study measures the accuracy of the tap value
sin(pi*x). A resampled sample is a normalised weighted sum of 127 taps, and per-tap errors can cancel or accumulate in that sum. A difference measured on resampled output is therefore a different quantity from the per-tap differences reported here, and the two need not agree in magnitude. Relatedly, the disagreement between the two forms in the tap regime has median 111 ulps here — but inter-form distance is not an error bar for either form, and on its own says nothing about which is closer to the truth.It has since been measured at the kernel that does this work, which lives in
gwmock-signalrather than here, and the outcome is worth carrying back: end to end the two forms are near-indistinguishable, with the hoist closer to a high-precision reference at 49% of positions against 32% and an RMS error of 6.01e-16 against 6.33e-16. The per-tap factors below do not survive into the output, because the Kaiser taper suppresses exactly the large-|x|taps where the direct form is worst and the normalisation cancels part of the rest. Both forms sit ~4500x below that kernel's own truncation error, so nothing in this document should be read as an accuracy argument for changing it — the argument that survives is speed (~1.2x on the NumPy path, ~3.5x on the device path's CPU backend). -
The full tap weight is not measured here. The kernel's weight is
sin(pi*x)/(pi*x) * window(x), notsin(pi*x). Relative error passes through the division essentially unchanged, so the ranking carries — but the composite, including the window, is measured in that other repository, not in this one. - One platform, one libm. Measured with numpy 2.5.2 / Python 3.14.7 on
x86-64 glibc. The dominant term is argument-side, i.e. a property of
fl(pi)and|x|rather than of any sine implementation, so the ranking should carry to other libms and to GPU backends — but sub-ulp behaviour will differ and was not measured there. A GPU kernel in float32 would shift every number in this report; nothing here speaks to float32.
Reproducing it¶
uv run --with mpmath python scripts/sin_hoist_accuracy.py
mpmath is supplied on the fly by --with and is deliberately not added to
the project's dependencies — this is a standalone study, not part of the
package.
The script depends only on numpy and mpmath, writes its full report to stdout as
markdown, and takes ~4 s wall-clock. The sweep's random axes are seeded
(--seed, default 20260823), so the run is reproducible; --skip-checks drops
the two verification passes. Every number quoted in this document comes from
that output.