Skip to content

Fix UB signed shifts in SignedDigitDecompose and accelerate gadget decomposition - #1238

Merged
yspolyakov merged 4 commits into
devfrom
issue1226
Aug 5, 2026
Merged

Fix UB signed shifts in SignedDigitDecompose and accelerate gadget decomposition#1238
yspolyakov merged 4 commits into
devfrom
issue1226

Conversation

@pascoec

@pascoec pascoec commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #1226 reported deterministic UBSan failures (left shift of negative value,
left shift ... cannot be represented) in both overloads of
RingGSWAccumulator::SignedDigitDecompose, reached by any ordinary EvalBinGate
call. The offending expressions were the classic (d << k) >> k sign-extension
idiom, deliberately written to compile to the hardware sign-extender (shl/sar)
— this is a bottleneck path, so the reconstruction-style rewrite suggested in the
issue would have traded correctness-on-paper for real overhead.

This PR fixes the UB with zero performance cost, and then — prompted by what we
found while validating the fix — restructures the decomposition so that the final
code contains no sign extension and no signed shifts at all, making the original
concern moot by construction, while running substantially faster.

The fix (f327504)

Perform the left shift in the unsigned type and shift back arithmetically:

static_cast<SignedNativeInt>(static_cast<Integer>(d) << bits) >> bits

Unsigned left shift is well-defined; the value-preserving conversion back and the
arithmetic right shift are implementation-defined (not UB) and already relied on
elsewhere in the function. Generated code is unchanged — byte-identical bodies with
clang 18 and an identical instruction mix (same shl/sar pairs) with gcc 13 at
-O3 — and the reporter's TOY/LMKCDEY repro runs UBSan-clean.

The optimization (1c4674a, 4dc0ed0, 4667738)

Verifying the fix exposed that the surrounding loop, not the shift idiom, was the
real cost. A hardware-adder analogy explains the three steps:

  1. The original loop is a ripple-carry structure. Each digit is extracted and
    then fed back into the carry: r = signext(d); d = (d - r) >> gBits. Like a
    ripple adder, stage k+1 cannot start until stage k completes — a 4-op
    dependency chain per digit.

  2. Decouple the carry from the digit (1c4674a). Since r ≡ d (mod baseG) with
    r ∈ [-baseG/2, baseG/2), the carry has a closed form that never touches the
    digit: d = (d + baseG/2) >> gBits. The chain halves and digit extraction moves
    off the critical path — analogous to separating an adder's carry path from its
    sum logic.

  3. Vectorize across coefficients (4dc0ed0). The N polynomial coefficients are
    independent chains, so interchange the loops to digit-major order and let SIMD
    process 4/8/16 coefficients per instruction — an array of cheap adders instead of
    one fast one.

  4. Full carry-lookahead (4667738). Just as a lookahead adder computes every
    carry as a closed-form function of the primary inputs, biasing each coefficient
    once by H (= baseG/2 in every digit position) makes every balanced digit an
    independent unsigned window of the biased value:

    w = t + H - (t < Q/2 ? 0 : Q)               // one add per coefficient
    digit_k = ((w >> k*gBits) & (baseG-1)) - baseG/2   // no chain, no signed ops
    

    The carry chain disappears entirely, along with the SignExtend helper and every
    signed shift in the function. Requires digitsG*gBits < MaxBits, which all
    supported parameter sets satisfy with ample margin (≤53 of 64 bits; ≤31 of 32 for
    NATIVE_SIZE=32 builds).

Correctness

Outputs are bit-identical to the original code at every step (exhaustive
differential harness over TOY/STD128-shaped parameters plus edge values
0, ±1, Q−1, Q/2±1). The full binfhe unit suite passes in all 8 validated build
configurations — {clang 18, gcc 14} × {WITH_NATIVEOPT ON/OFF} ×
{NATIVE_SIZE 64/32} — and the issue's reproducer is UBSan-clean from the first
commit onward.

Performance

Measured on a dual Xeon Platinum 8360Y (Ice Lake, AVX-512), min of 4 repetitions,
single-thread pinned; 36-thread runs show the same or better ratios. Speedups are
dev → this PR:

NATIVE_SIZE=64 NATIVE_SIZE=32
SignedDigitDecompose (native-opt ON) 2.9–5.9x 7.2–11.0x
SignedDigitDecompose (native-opt OFF) 1.2–1.9x 4.1–5.4x
EvalBinGate end-to-end 1.02–1.16x 1.07–1.30x

No regressions in any of the 32 measured configuration/thread-count cells.

Fixes #1226.

pascoec added 4 commits August 3, 2026 18:17
The carry (d0 - r0) >> gBits equals (d0 + gHalf) >> gBits since the digit
r0 lies in [-gHalf, gHalf). The new form removes the extracted digit from
the loop-carried dependency chain (4 -> 2 cycles per digit) and drops the
now-unneeded digit extraction ahead of the loop. Output is bit-identical.
Process one gadget digit at a time across the whole polynomial, carrying
the running quotient per coefficient in a scratch array. The inner loops
become unit-stride and auto-vectorize (AVX2/AVX-512) when built with
native optimizations; output is bit-identical to the previous order.
…ecompose

Bias each coefficient once by H (gHalf in every digit position); every
balanced digit then reads out as an independent unsigned window
((w >> k*gBits) & gMask) - gHalf. This removes the per-digit carry chain
and all signed-shift arithmetic, drops the SignExtend helper, and speeds
up the decomposition a further 1.2-1.8x over the digit-major form
(up to 10.6x total vs the original). Output is bit-identical. Requires
digitsG*gBits < MaxBits, satisfied by all supported parameter sets.
@pascoec pascoec added this to the Release 1.6.0 milestone Aug 4, 2026
@pascoec pascoec self-assigned this Aug 4, 2026
@pascoec
pascoec requested a review from yspolyakov August 4, 2026 22:50
@pascoec pascoec added the optimization Improves performance label Aug 4, 2026
@pascoec pascoec changed the title fix for issue 1226 Fix UB signed shifts in SignedDigitDecompose and accelerate gadget decomposition Aug 4, 2026
@pascoec pascoec added the bug Something isn't working label Aug 4, 2026
@yspolyakov
yspolyakov merged commit 0473a7b into dev Aug 5, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working optimization Improves performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BinFHE LMKCDEY SignedDigitDecompose may perform signed left shifts with undefined behavior

2 participants