You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)
Faster Salsa20Engine #2476 makes Salsa20Engine.processBytes XOR whole blocks in a plain loop. That already pays off for one-block calls, so it speeds up main's ChaCha20-Poly1305 without this change (1.26–1.39× for encryption), and batching adds nothing on top of it (batching + Faster Salsa20Engine #2476: 1.24–1.36×).
All three together: at 16 KB, encryption 1.54× (204 → 314 MB/s) and decryption 2.00× (163 → 327 MB/s). Decryption in 100-byte calls is 1.66× (against 1.51× with the byte-by-byte remainder loop).
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):
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×.
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.
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 soXChaCha20Poly1305, the provider'sChaCha20-Poly1305andXChaCha20-Poly1305ciphers, HPKE, MLS, and CMS/S/MIME AuthEnvelopedData with ChaCha20-Poly1305) used to process its data one 64-byte block at a time:Now:
System.arraycopy, as much at a time as the buffer takes: the tag lookahead, a partial block, and any blockswholeBlockRunheld 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).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
testPiecewiseDecryptionincore'sChaCha20Poly1305Test, a test case that was missing onmain(see Verification).On its own, with
main's engine andmain'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)
Salsa20Engine.processBytesXOR whole blocks in a plain loop. That already pays off for one-block calls, so it speeds upmain's ChaCha20-Poly1305 without this change (1.26–1.39× for encryption), and batching adds nothing on top of it (batching + Faster Salsa20Engine #2476: 1.24–1.36×).Poly1305.updateabsorb whole blocks in one loop. That only pays off for long runs, andmain's ChaCha20-Poly1305 hands the MAC 64 bytes per call, so on its own Faster Poly1305 block processing #2477 does nothing here (0.99–1.02×), and neither does it on top of Faster Salsa20Engine #2476 (Faster Salsa20Engine #2476 + Faster Poly1305 block processing #2477: 1.22–1.40×, the same as Faster Salsa20Engine #2476 alone). The change in this ticket is what lets Faster Poly1305 block processing #2477 reach the AEAD: batching + Faster Poly1305 block processing #2477 gives 1.11–1.12× for encryption from 1 KB.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):mainError bars in that run were within ±3.5% everywhere, and no fork drifted (
mainat 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.Decryption, ns per operation (speed-up against
main):mainWhat the numbers say:
arraycopymakes batching alone 1.07× and all three 1.10× faster.mainand 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×.mainon JDK 21 and 1.34× on JDK 17;Verification
The core
crypto.testsuite, the provider'sChaCha20Poly1305TestandXChaCha20Poly1305Test, CMSAuthEnvelopedDataTestandNewAuthEnvelopedDataStreamTest(14 + 14), and MLSMessageProtectionTestpass 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:processBytesof 0–6,000 bytes andprocessByte;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 anOutputLengthException,mainhas already fed the block to Poly1305 and leaves its buffer full, so its next call throwsArrayIndexOutOfBoundsException(index 80, length 80). The batched class carries on instead, with that block counted twice, anddoFinalthen 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:
HPKETestVectors(15);main, all identical up to the first exception;testPiecewiseDecryptionfrom the decrypt-copy branches passes (every piece size from 1 to 161 bytes, plus random splits throughprocessByte); it is now part of this branch, see the next bullet;testPiecewiseDecryption`:core'sChaCha20Poly1305Testused to decrypt only in a singleprocessBytescall, so this PR's buffer-completion loop was never tested from the repository.processByte. It checks each call's return value againstgetUpdateOutputSize, and the plaintext and tag at the end.main's version of the test:end - pos - MAC_SIZE + 1): caught by the new test in every run, and bymain's random test in two runs of three.<= 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.crypto.testsuite (21) passes with the test in place.