WIP: Bundled multi-point multiplication #1994

pull peterdettman wants to merge 11 commits into bitcoin-core:master from peterdettman:ecmult_bundles changing 6 files +1514 −6
  1. peterdettman commented at 6:52 PM on November 12, 2026: contributor

    Bundled multi-point multiplication

    Summary

    Below the Pippenger threshold, secp256k1_ecmult_multi_var currently uses Strauss' algorithm: a wNAF table of odd multiples for every point. This PR takes the points six at a time instead and gives each such bundle one table of subset sums. The main loop does the same number of additions, but the tables are cheaper to build and smaller, and the scalar recoding is close to free.

    • 15% to 18% faster than strauss_wnaf once there are three bundles, and 12% to 15% with one. A call that leaves five points over loses part of that: 7% to 10% at eleven points, the worst case (see Benchmarks)
    • about 58% of the scratch space per point: 846 bytes against 1452 on 64-bit
    • the crossover with Pippenger's algorithm moves from 88 points to 181, so calls of 88 to 180 points gain up to 12% over what they get today
    • zero scalars and points at infinity need no special handling in a bundle

    The idea

    Split every scalar with lambda, as now, into two halves; call the plain halves and the lambda halves the two sides. Take ECMULT_BUNDLE_SIZE = 6 points as a bundle. Its first point is the anchor, the other five are followers. On each side, recode the bundle's six halves together into ECMULT_BUNDLE_BITS = 130 positions so that at every position

    • the anchor's digit is +1 or -1, never zero, and
    • each follower's digit is either zero or equal to the anchor's.

    At one position on one side the bundle then contributes

    sign * (anchor + some subset of the followers)

    which is an entry of a 32-entry table of subset sums, or its negation. A bundle index is one byte that names it: a sign bit over the 5-bit number of the table entry. The main loop runs 130 positions, and at each one spends two lookups and two additions per bundle, one per side. The lambda side uses the same table with x multiplied by beta.

    Points that do not fill a bundle (at most five per call, see batches below) are handled as in Strauss' algorithm, exactly as today, in the same loop.

    Terms

    term meaning
    bundle ECMULT_BUNDLE_SIZE points served by one table
    anchor, follower a bundle's first point; each of the others
    half, side an output of scalar_split_lambda; the plain or the lambda halves taken together
    position one of the ECMULT_BUNDLE_BITS doublings of the main loop
    bundle index a byte per bundle per side per position: anchor's sign, then one table-entry bit per follower
    digit a scalar's signed digit at a position (+1, -1 or 0), as in the two conditions above
    table the 2^(SIZE-1) subset sums of a bundle that contain its anchor
    chain the sequence of additions that builds the tables, whose z ratios give one common z
    points left over those that do not fill a bundle; they are handled as in Strauss' algorithm

    Why it is faster

    Per six points, against six points in Strauss' algorithm at WINDOW_A = 5:

    bundle Strauss
    main loop additions 260 about 258
    table build 31 additions, 32 normalizations 42 additions, 6 doublings, 48 normalizations
    recoding 6 splits, a few word operations each 6 splits, 12 wNAF recodings
    tables and indices (or wNAF digits) 4356 bytes 7752 bytes

    Benchmarks

    bench_ecmult-style runs of n points plus G, microseconds per point, lower is better. i7-12700H, one P-core, clock fixed at 2.3 GHz (turbo and hyper-threading off), process pinned at high priority. Each figure is the minimum over six alternating rounds of ten runs each; rounds agree to within 0.7%.

    Against strauss_wnaf:

    points gcc 16.1 -O2: Strauss bundle change clang 22.1 -O2: Strauss bundle change
    6 16.68 14.59 -12.5% 18.32 15.65 -14.6%
    11 16.13 14.96 -7.2% 17.81 16.12 -9.5%
    12 16.07 13.80 -14.1% 17.76 14.83 -16.5%
    17 15.81 14.24 -10.0% 17.52 15.37 -12.3%
    18 15.80 13.50 -14.6% 17.48 14.50 -17.0%
    24 15.67 13.31 -15.1% 17.31 14.36 -17.1%
    48 15.47 13.08 -15.4% 17.07 14.12 -17.3%
    96 15.36 12.96 -15.6% 16.89 13.97 -17.3%
    144 15.33 12.88 -16.0% 16.81 13.91 -17.2%
    180 15.34 12.88 -16.0% 16.80 13.88 -17.4%

    Below six points the code path is unchanged and the two columns agree to 0.1%. The rows for 11 and 17 are the worst cases, one bundle or two with five points left over at Strauss' cost.

    Against pippenger_wnaf, which is what secp256k1_ecmult_multi_var uses today from 88 points:

    points gcc: Pippenger bundle change clang: Pippenger bundle change
    96 14.62 12.96 -11.4% 15.91 13.97 -12.2%
    120 14.01 12.92 -7.8% 15.22 13.93 -8.5%
    144 13.62 12.88 -5.4% 14.84 13.91 -6.2%
    168 13.12 12.89 -1.8% 14.30 13.89 -2.9%
    180 12.89 12.88 -0.0% 14.06 13.88 -1.3%
    192 12.71 12.87 +1.3% 13.85 13.86 +0.1%
    204 12.56 12.85 +2.3% 13.69 13.84 +1.1%

    The two are equal at about 180 points with gcc and a little past 190 with clang; sweeps at every third and sixth point, made when the threshold was chosen, put the crossovers at 181 and 194. ECMULT_PIPPENGER_THRESHOLD is set to 181, the lower of the two, which also gives Pippenger's algorithm some credit for the smaller scratch space it needs. This is one machine and two compilers; the value will need tuning on more hardware. For comparison, on this machine Strauss and Pippenger are equal at about 77 points (gcc) and 74 (clang), against the 88 in the source.

    What is and is not new

    The representation is the sign-aligned column form (GLV-SAC) of Faz-Hernandez, Longa and Sanchez (https://eprint.iacr.org/2013/158, section 3), also used by FourQ (https://eprint.iacr.org/2015/565). There the columns run across the sub-scalars of one multiplication; here they run across the points of a bundle, separately on each side of the lambda split.

    Two things differ from those papers:

    • The recoding is closed form. With u the anchor's half and f a follower's, both as signed integers, the follower's presence mask is ~((f + ~u) ^ u): one addition and one XNOR per follower per side, where the papers run a loop over the positions. (Literature search for this form is still owed before claiming it.)
    • 130 positions rather than 129 are needed, because secp256k1's split halves reach 0.635 * 2^128 and an anchor's and a follower's together must stay below 2^129.

    Commits

    1. secp256k1_ge_z: an affine point together with the z of the isomorphic curve it is on, valid at infinity too. It is the accumulator for a chain of additions that must keep its z ratios across points at infinity, which a secp256k1_gej cannot do.
    2. The bundle table, built in Gray code order: each entry is the previous one plus or minus one input point, so the table is one chain with one live accumulator. All bundles extend the same chain and reach a common z in one backward pass, with no inversion.
    3. The lookups, which define the bundle index.
    4. secp256k1_hscalar: a 160-bit two's complement integer. Split halves are small signed integers stored as residues mod n; this is them as integers, so the recoding is plain word arithmetic.
    5. The recoding.
    6. The main loop over whole bundles and G. Its comment carries the outline and the cost counts.
    7. Points left over handled as in Strauss' algorithm, in the same loop.
    8. The batch function over a scratch space, a bundle mode for bench_ecmult, tests.
    9. secp256k1_ecmult_multi_var uses it where it used Strauss.
    10. Batches are sized for bundles and made of whole bundles, so only a call's last batch has points left over. A scratch space too small for one bundle still gets Strauss' algorithm.
    11. ECMULT_PIPPENGER_THRESHOLD goes from 88 to 181.

    Degenerate inputs

    A subset sum can be the point at infinity even when no input is (a follower equal to the anchor's negation, or chosen inputs deeper in), so this is the first table in the library whose entries may be infinite. The chain passes through such entries with a ratio of 1, the entry is stored as an ordinary infinite secp256k1_ge, and the lookups carry the flag. The exhaustive tests run the whole algorithm over the small test groups (orders 7, 13 and 199), where a subset sum vanishes one time in the group order and so the tables do hold infinities.

    Notes

    Extensive use of Claude Code throughout, in particular producing this final reorganization of the branch.

  2. group: add secp256k1_ge_z, an affine point with the z of its curve
    A table of combinations of several points can be built as one chain of
    additions whose z ratios bring every entry to a common z afterwards, as
    is done for the odd multiples of a single point. Unlike those multiples,
    combinations can be infinite, and secp256k1_gej_add_ge_var cannot carry
    a chain across an infinity: it refuses an infinite input when asked for
    a ratio, and reports a vanishing sum with a zero ratio.
    
    secp256k1_ge_z holds an affine point together with the z of the
    isomorphic curve it is on, which stays defined when the point is
    infinite. secp256k1_ge_z_add_ge_var adds an affine point to it and
    always reports a usable ratio. secp256k1_ge_z_set_infinity and
    secp256k1_ge_z_verify complete the type.
    
    The tests compare both coordinates of each result. test_gej_eq_strict
    is added for that, since secp256k1_gej_eq_var cannot tell a wrong y.
    1d97edfef3
  3. ecmult: build a bundle's table of subset sums as a Gray code chain
    A bundle is a group of ECMULT_BUNDLE_SIZE = 6 points served by one
    table. Its scalars will be recoded so that the only combinations asked
    of it are a sign times the sum of its first point, the anchor, and a
    subset of the others, so the table holds the subset sums that contain
    the anchor and a negative sign is applied at lookup.
    
    In Gray code order each entry is the previous one plus or minus an
    input point. The table is then one chain of mixed additions with a
    single accumulator, a secp256k1_ge_z since an entry can be infinite,
    and the chain's z ratios bring every entry to a common z. Successive
    bundles extend the same chain, and
    secp256k1_ecmult_bundle_table_set_globalz walks it back from the end.
    d25689bacb
  4. ecmult: add the bundle table lookups, and with them the bundle index
    A bundle index is the byte that selects what a bundle contributes: the
    number of a table entry, one bit per follower, under a sign bit for the
    anchor. secp256k1_ecmult_bundle_table_get_ge resolves one.
    secp256k1_ecmult_bundle_table_get_ge_lambda does the same for the
    lambda multiples, which share the table and differ only in x;
    secp256k1_ecmult_bundle_table_lambda_x prepares those x coordinates.
    
    An entry of a bundle table can be infinite, so the lambda lookup
    carries the infinity flag, which secp256k1_ecmult_table_get_ge_lambda
    has no need to do.
    6d318a3f10
  5. ecmult: add secp256k1_hscalar, a lambda split half as a signed integer
    The halves that secp256k1_scalar_split_lambda produces are small signed
    integers held as residues mod n. secp256k1_hscalar is such a half as
    the integer it is, 160 bits of two's complement, so that a recoding can
    add halves, complement and XOR them and read their bits with plain word
    arithmetic.
    
    The conversion is a conditional addition of 2^256 - n keyed on the
    scalar's top bit. Under VERIFY the words dropped are checked to be sign
    extension.
    ead0f67e24
  6. ecmult: recode a bundle's scalars into a stream of bundle indices
    Each scalar is split by lambda, and on each side of the split the
    bundle's halves are recoded together into ECMULT_BUNDLE_BITS positions:
    the anchor's coefficient is +1 or -1 at every position, and each
    follower's is zero or the anchor's. A position is then a sign and a
    subset of the followers, which is a bundle index as the table lookups
    take it.
    
    With the halves as half-scalars, the anchor's signs are the bits of its
    half u in two's complement, the top one inverted. They denote 2*u + 1,
    so the anchor's scalar q is first adjusted to (q + K)/2 with
    K = -(1 + lambda), which is the scalar offsetting of
    secp256k1_ecmult_const_gej. A follower's presence mask comes in closed
    form, ~((f + ~u) ^ u) for its half f: one addition and one XNOR, with
    no pass over the positions.
    
    130 positions are needed rather than 129, the halves of a split
    reaching 0.6354*2^128 and f + ~u having to fit in two's complement of
    that many bits.
    02b2c2c772
  7. ecmult: add bundled multi-point multiplication over whole bundles
    secp256k1_ecmult_bundle_multi puts the pieces together: recode every
    bundle's scalars, build every bundle's table on one chain and bring
    them to a common z, then run ECMULT_BUNDLE_BITS positions with one
    lookup and one addition per bundle on each side of the lambda split.
    G is handled as in Strauss' algorithm, in the same loop.
    
    The main loop does as many additions as Strauss' algorithm would for
    the same points. The gain is in the tables, which take fewer additions
    and normalizations to build and, with the indices, little more than
    half the space, and in the recoding. The function's comment has the
    outline of the method and the counts.
    
    For now the number of points has to be a multiple of
    ECMULT_BUNDLE_SIZE.
    f473b2e063
  8. ecmult: handle points that do not fill a bundle as in Strauss' algorithm
    secp256k1_ecmult_bundle_multi now accepts any number of points from
    one bundle up. The ones left over, secp256k1_ecmult_bundle_remainder
    of them, get tables of odd multiples and wNAF digits as in Strauss'
    algorithm, on the same chain as the bundles' tables
    (secp256k1_ecmult_bundle_chain_set_globalz) and in the same main loop,
    so they cost what they would have cost there.
    
    Bundles are never partly filled: a bundle costs the main loop the same
    whether full or not, so each missing point puts it further behind
    Strauss' algorithm.
    d134788f6a
  9. ecmult: add a batch function for bundled multiplication
    secp256k1_ecmult_bundle_batch is the counterpart of
    secp256k1_ecmult_strauss_batch: it takes points from a callback,
    allocates the bundle tables, indices and the leftover points' wNAF
    state on the scratch space, and runs secp256k1_ecmult_bundle_multi.
    With fewer points than fill one bundle, or if an allocation fails, the
    batch goes to Strauss' algorithm.
    
    Nothing selects it yet. It is added to the multi-point tests alongside
    the other batch functions and to bench_ecmult as the bundle mode, and
    the exhaustive tests run it over the small groups, where a bundle's
    subset sums vanish often enough for the tables to hold infinities.
    96633ef211
  10. ecmult: use the bundled algorithm from secp256k1_ecmult_multi_var
    Below the Pippenger threshold, secp256k1_ecmult_multi_var now calls
    secp256k1_ecmult_bundle_batch where it called
    secp256k1_ecmult_strauss_batch. Batches are still sized as for
    Strauss' algorithm, which is what a batch falls back to when it has
    fewer points than one bundle or does not fit.
    
    The exhaustive bundle test now goes through secp256k1_ecmult_multi_var
    as well, and the bench_ecmult help text names the new default.
    b7676e140e
  11. ecmult: size batches for the bundled algorithm, in whole bundles
    secp256k1_ecmult_multi_var sized the bundled algorithm's batches as
    for Strauss' algorithm, which needs about 70% more space per point,
    and split a call into equal batches, each of which could have points
    left over.
    
    secp256k1_bundle_scratch_size is what a batch takes, and
    secp256k1_bundle_max_points a batch size, in whole bundles, such that
    it and every smaller batch fit. Fewer points can need more space, a
    point left over costing more than a bundled one, so room is set aside
    for the worst smaller batch. Batch sizes are rounded up to whole
    bundles, so only the last batch of a call has points left over.
    
    With that, secp256k1_ecmult_bundle_batch no longer falls back to
    Strauss' algorithm when an allocation fails. A scratch space too small
    for batches to be sized this way still gets Strauss' algorithm, sized
    as before.
    07bb1c5188
  12. ecmult: raise ECMULT_PIPPENGER_THRESHOLD to 181
    The threshold was the number of points from which Pippenger's
    algorithm beats Strauss' algorithm. What it now has to beat is the
    bundled algorithm, which is 16% to 18% faster than Strauss' at these
    sizes, so the crossover moves up.
    
    Measured on an i7-12700H P-core at a fixed 2.3 GHz, with n points and
    G, the two are equal at about 181 points when built with gcc 16.1 -O2
    and at about 194 with clang 22.1 -O2. The lower of the two is taken,
    which also gives some credit to Pippenger's algorithm for the smaller
    scratch space it needs. On the same machine Strauss' algorithm and
    Pippenger's cross at about 77 points (gcc) and 74 (clang).
    
    This is one machine and two compilers. The value will need tuning on
    more hardware.
    
    test_ecmult_multi_batching runs twice the threshold in points, so it
    now does about four times the work.
    d22cc578ba
  13. fametrano referenced this in commit b52cb2d57f on Nov 13, 2026
  14. fametrano referenced this in commit 82830d9ece on Nov 13, 2026
  15. Yudis-bit commented at 5:59 PM on November 13, 2026: contributor

    Does the Pippenger crossover shift on cores with 32 KiB L1D?

    On an i7-8550U (Kaby Lake, 32 KiB L1D, 256 KiB L2, gcc 16.1 -O2), bench_ecmult shows a consistent 11–13% drop across whole bundles against strauss_wnaf:

    • ecmult_multi_6p_g: 23.5 us -> 20.9 us (-11.1%)
    • ecmult_multi_12p_g: 22.7 us -> 19.8 us (-12.8%)

    The 4612-byte table footprint per 6-point bundle stays entirely resident in L1D across the 130 doublings. All test suites (exhaustive_tests, noverify_tests, tests) pass.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin-core/secp256k1. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-11-14 00:15 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me