Skip to content

Batching ChaCha20Poly1305 to benefit from Poly1305 performance improvements #2481

Description

@winne42

With #2477, I proposed a change to improve Poly1305 performance by ~30..50%. While this change is already beneficial for some use cases, (X)ChaCha20Poly1305 performance remains the same (see sections "who benefits"/"who doesn't benefit" in that ticket).

This ticket shows how (X)ChaCha20Poly1305 could also benefit from the #2477 changes by processing all whole blocks in one call ("batching") instead of "one block at a time" for a performance improvement of ~11%. Together with with #2476 and #2477, this change improves ChaCha20Poly1305 encryption performance by ~50% and decryption performance by ~100%.

Note that a large part of the decryption performance improvements is the result of a very simple change from byte-by-byte to bulk-copy. In case you don't like this ticket, that simple change should definitely be done --> so I'll provide it as a separate small ticket as well with own benchmarks.

In the following, Claude's summary, just slightly changed. To understand the benchmark results better: my first implementation kept the byte-by-byte copy in the "remainder loop"; the PR now only includes the faster version where the remainder loop uses System.arraycopy() instead.

The change on its own

ChaCha20Poly1305 (and so XChaCha20Poly1305, the provider's ChaCha20-Poly1305 and XChaCha20-Poly1305 ciphers, HPKE, MLS, and CMS/S/MIME AuthEnvelopedData with ChaCha20-Poly1305) used to process its data one 64-byte block at a time:

  • Encryption: for every 64-byte block, one call to the ChaCha engine and one to Poly1305.
  • Decryption: every byte was copied into an 80-byte buffer one by one (the extra 16 bytes because the last 16 bytes of the input may be the tag), and each full block was then passed to Poly1305 and the engine.

Now:

  • Encryption: from two whole blocks up, all whole blocks of the call go to the engine in one call and their ciphertext to Poly1305 in one call. A single block still takes the old loop: through the run path, a 64-byte encryption was about 10% slower (879 against 803 ns in a separate run), and from two blocks there is no such cost.
  • Decryption: blocks that start in the buffer are completed from the input one at a time. After that the input is block-aligned, and every block followed by at least 16 more bytes (the possible tag) goes to Poly1305 and the engine as one run. What remains goes into the buffer with System.arraycopy, as much at a time as the buffer takes: the tag lookahead, a partial block, and any blocks wholeBlockRun held back. In the first version of the commit it still went in one byte at a time, which made small pieces slow (see the benchmark).
  • A helper, wholeBlockRun, keeps each run a multiple of 64 bytes, leaves the last block before the 2^38-byte data limit to the one-block path, and is capped by the caller at the room left in the output. So what happens at the data limit and at a short output buffer is unchanged.

The code adds a release note under 1.87, and testPiecewiseDecryption in core's ChaCha20Poly1305Test, a test case that was missing on main (see Verification).

On its own, with main's engine and main's Poly1305, encryption is unchanged (within ±2%): both callees still do the same per-block work, only in fewer calls. Decryption gets 1.2× faster from 1 KB, because the byte-by-byte copy through the buffer is gone. That holds when the ciphertext arrives in 100-byte calls too: 1.19× (against 1.11× in the first approach, where the remainder loop still copied byte by byte).

In relation to #2476 (faster chacha) and #2477 (faster poly1305)

The three PRs are independent and merge without code conflicts in any combination; only their release-note entries collide (adjacent lines).

Users with their own Poly1305 calls are not affected by this change: BC's TLS (BcChaCha20Poly1305, JceChaCha20Poly1305) already MACs whole records, so #2477 helps it directly.

Benchmark

Encryption is untouched by the remainder-loop change, so its numbers come from the original run. That was JMH average time with all eight combinations in one run: 2 forks × 5 × 1 s after 5 × 1 s warm-up, a whole encryption with a fresh nonce. AMD Ryzen 9 5900HX (AVX2), OpenJDK 25.0.4, Linux. The 1-minute load was 1.1–3.4 (median 1.5; Firefox used about half a core at the start), and no other benchmark ran.

Encryption, ns per operation (speed-up against main):

Combination 64 B 1 KB 16 KB
main 767 5,607 80,515
#2476 607 (1.26×) 4,044 (1.39×) 58,426 (1.38×)
#2477 773 (0.99×) 5,472 (1.02×) 80,180 (1.00×)
#2476 + #2477 630 (1.22×) 3,999 (1.40×) 58,087 (1.39×)
batching (this ticket) 758 (1.01×) 5,471 (1.02×) 82,559 (0.98×)
batching + #2476 620 (1.24×) 4,126 (1.36×) 59,213 (1.36×)
batching + #2477 780 (0.98×) 4,996 (1.12×) 72,563 (1.11×)
all three 624 (1.23×) 3,626 (1.55×) 52,233 (1.54×)

Error bars in that run were within ±3.5% everywhere, and no fork drifted (main at 16 KB: 79.5–81.5 µs per iteration).

Decryption was re-measured after the remainder loop got its arraycopy, in a separate and tighter run with both versions of the batching side by side.

  • Run: 5 forks × 10 × 1 s after 5 × 1 s of warm-up, decrypting a fixed ciphertext in one call or in calls of 1,000 or 100 bytes.
  • Conditions: the same machine and JDK 25.0.4. The 1-minute load was 0.8–1.8 (median 1.2), mostly the IDE.

Decryption, ns per operation (speed-up against main):

Combination 64 B 1 KB 16 KB 16 KB in 1,000-byte calls 16 KB in 100-byte calls
main 1,184 7,105 100,286 101,438 102,884
#2476 + #2477 1,055 (1.12×) 6,408 (1.11×) 92,504 (1.08×) 89,450 (1.13×) 92,468 (1.11×)
batching (this ticket) 1,096 (1.08×) 5,891 (1.21×) 83,111 (1.21×) 83,404 (1.22×) 86,208 (1.19×)
batching, byte-by-byte remainder 1,113 (1.06×) 5,855 (1.21×) 83,262 (1.20×) 84,008 (1.21×) 92,416 (1.11×)
all three 896 (1.32×) 3,767 (1.89×) 50,089 (2.00×) 51,613 (1.97×) 61,926 (1.66×)
all three, byte-by-byte remainder 904 (1.31×) 3,770 (1.88×) 49,993 (2.01×) 52,215 (1.94×) 67,923 (1.51×)

What the numbers say:

  • Small calls: the remainder loop only matters there. In one call the remainder is the 16-byte tag plus at most a partial block, and the two versions are equal within the error bars. In 100-byte calls, up to 79 bytes of each call go through the loop, and the arraycopy makes batching alone 1.07× and all three 1.10× faster.
  • Earlier run: the original run's decryption figures agree with these within about 2% for main and batching, and within 4% for all three (faster now). Faster Salsa20Engine #2476 + Faster Poly1305 block processing #2477 now measures 1.08–1.13× where it measured 1.05×.
  • Error bars: within ±1.1% everywhere except two cells. Batching at 64 B is ±1.9%. Faster Salsa20Engine #2476 + Faster Poly1305 block processing #2477 at 1 KB is ±2.5%, because one fork's first four iterations ran at 7.2–7.6 µs before it settled at 6.4 µs.
  • JDK 21 and 17 (3 forks, same run), batching alone:
    • in one call from 1 KB, 1.20–1.21× as fast as main on JDK 21 and 1.34× on JDK 17;
    • in 100-byte calls, 1.19× on JDK 21 and 1.53× on JDK 17, against 1.15× and 1.49× with the byte-by-byte remainder.
  • JDK 17 at 16 KB in one call: the new version measured 2.6% slower than the old (91.3 against 89.0 µs). Here the remainder is only the 16-byte tag, and both versions shifted by about 3 µs within forks, so this is JIT variation and not the copy.

Verification

  • The core crypto.test suite, the provider's ChaCha20Poly1305Test and XChaCha20Poly1305Test, CMS AuthEnvelopedDataTest and NewAuthEnvelopedDataStreamTest (14 + 14), and MLS MessageProtectionTest pass against the built jars. Checkstyle passes.

  • New against main's class, compiled under another name, with the same engine and MAC. Three seeds of 4,000 random instance pairs (ChaCha20 and XChaCha20 Poly1305) ran about 55,000 operations and 22–23 MB each:

    • encryption and decryption;
    • AAD in pieces;
    • processBytes of 0–6,000 bytes and processByte;
    • output buffers exact, generous and one byte short;
    • in place and overlapping;
    • size queries;
    • tampered and truncated ciphertexts;
    • instances placed just below the data limit by reflection;
    • nonce reuse.

    Return values, output, tags, size queries and exceptions were identical up to and including the first exception (4,150–4,320 matching exceptions per seed).

  • After an exception the state differs, as it is already corrupt in main. When a decryption call fails with an OutputLengthException, main has already fed the block to Poly1305 and leaves its buffer full, so its next call throws ArrayIndexOutOfBoundsException (index 80, length 80). The batched class carries on instead, with that block counted twice, and doFinal then fails the tag check (InvalidCipherTextException). Neither accepts anything. The provider checks output sizes before calling the cipher (ShortBufferException), so JCA users never reach this state.

  • All eight combinations in the benchmark produce identical ciphertexts and decrypt correctly (300 random messages up to 40 KB).

  • After the remainder-loop change, the following were run again:

    • the suites above all pass, as does HPKETestVectors (15);
    • the differential harness ran 23 seeds (11–13 and 101–120) against main, all identical up to the first exception;
    • testPiecewiseDecryption from the decrypt-copy branches passes (every piece size from 1 to 161 bytes, plus random splits through processByte); it is now part of this branch, see the next bullet;
    • the behaviour after an exception is unchanged;
    • all eight decryption variants of the new benchmark decrypt 400 random ciphertexts identically, in one call and in pieces, on JDK 17, 21 and 25.
  • testPiecewiseDecryption`:

    • core's ChaCha20Poly1305Test used to decrypt only in a single processBytes call, so this PR's buffer-completion loop was never tested from the repository.
    • The new test decrypts 20 message lengths around the block and tag boundaries in every piece size from 1 to 161 bytes, and in 50 random splits that also go through processByte. It checks each call's return value against getUpdateOutputSize, and the plaintext and tag at the end.
    • Mutants of this branch's code, each run against the new test and against main's version of the test:
      • completing a buffered block with one byte too few following it, and not moving the rest of the buffer down after a block: caught only by the new test;
      • a run that may take one tag byte (end - pos - MAC_SIZE + 1): caught by the new test in every run, and by main's random test in two runs of three.
    • Waiting for one byte more in the buffer-full branch (<= MAC_SIZE) survives both tests and 23 fuzzer seeds. It is equivalent here: the remainder loop then processes the full buffer itself, with the same result and return value.
    • The core crypto.test suite (21) passes with the test in place.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions