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
{{ message }}
Repository navigation
Speed up ChaCha20Poly1305 decryption by avoiding byte-by-byte copy #2483
This change is included in #2481 - only merge it if you decide against #2481.
A very low hanging fruit that improves decryption speed of ChaCha20Poly1305 by around 20% (even more on older JDKs!) with changing only +7/-3 lines in ChaCha20Poly1305.processBytes().
Claude went a bit crazy benchmarking and verifying this, see below because I let it try 2 different solutions and also in combination with #2481. But the tl;dr is that it's working great, even greater for older JVMs, and it's very simple. I will only provide variant A as a PR (B on request). Again, only merge if you decide against #2481.
Note also the remarks on OutputLengthException in main below.
The problem
When ChaCha20Poly1305 decrypts, the last 16 bytes it has seen may be the tag, so a block is only decrypted once 16 more bytes follow it. processBytes did this with an 80-byte buffer (64 + 16) and copied every byte of the ciphertext into it on its own: buf[bufPos] = in[inOff + i] and a ++bufPos == buf.length test per byte. When the buffer was full, its first 64 bytes went to Poly1305 and ChaCha, and the last 16 were moved to the front. Encryption has no such loop, as it already hands whole blocks from the input to ChaCha.
This affects XChaCha20Poly1305, the provider's ChaCha20-Poly1305 and XChaCha20-Poly1305 ciphers, HPKE, MLS, and CMS/S/MIME AuthEnvelopedData with ChaCha20-Poly1305.
Variant A: arraycopy into the buffer (+7 −3 lines)
- for (int i = 0; i < len; ++i)+ while (len > 0)
{
- buf[bufPos] = in[inOff + i];- if (++bufPos == buf.length)+ int n = Math.min(len, buf.length - bufPos);+ System.arraycopy(in, inOff, buf, bufPos, n);+ inOff += n;+ len -= n;+ bufPos += n;+ if (bufPos == buf.length)
{
poly1305.update(buf, 0, BUF_SIZE);
processData(buf, 0, BUF_SIZE, out, outOff + resultLen);
The loop is main's, with the per-byte copy replaced by an arraycopy of as much as the buffer takes. Poly1305 and ChaCha are called with the same arguments and in the same order as on main. Every block still goes through the buffer, and 16 bytes are still moved to its front after each block.
Variant B: whole blocks straight from the input (+39 −7 lines)
A block that starts in the buffer is completed from the input with one arraycopy and decrypted from the buffer once 16 more bytes are available. The loop also takes a buffer that already holds 64–79 bytes, as processByte or an earlier call can leave it.
After that the input is block-aligned, and every block followed by at least 16 more bytes goes to Poly1305 and ChaCha straight from in, as encryption already does.
What remains, fewer than 80 bytes, goes into the buffer with one arraycopy.
Poly1305 and ChaCha still get one 64-byte block per call, as on main (as opposed to #2481).
The test both variants add
core's ChaCha20Poly1305Test only ever decrypted in a single processBytes call, so the state the buffer is in when the next call arrives was never tested. Both branches add the same testPiecewiseDecryption:
20 message lengths around the block and tag boundaries (0–1,000 bytes);
each decrypted in pieces of every size from 1 to 161 bytes, and in 50 random splits that also send single bytes through processByte;
each call's return value checked against getUpdateOutputSize, and the plaintext and tag checked at the end.
It takes about 0.1 s.
Verification
Both branches went through the same checks.
Test suites: run on JDK 25. All pass:
core crypto.testAllTests (21) and ChaCha20Poly1305Test / XChaCha20Poly1305Test;
the provider's ChaCha20Poly1305Test and XChaCha20Poly1305Test;
CMS AuthEnvelopedDataTest and NewAuthEnvelopedDataStreamTest (14 + 14);
MLS MessageProtectionTest;
HPKETestVectors (15).
Checkstyle (core, prov) passes.
Mutations: each mutant was compiled over the branch's class and run against the new test and against main's version of the test.
B: four mutants are caught only by the new test, while main's test passes all four:
completing a buffered block with one byte too few following it (need + MAC_SIZE - 1);
waiting for one byte too many after a full buffer (<= MAC_SIZE);
not moving the rest of the buffer down after a block;
completing the block one byte short.
Lowering the direct loop's threshold to 79 bytes is caught by both tests. Moving 16 bytes instead of bufPos is an equivalent mutant, as bytes past bufPos are never read.
A: limiting the copy to BUF_SIZE - bufPos or to one byte less than the buffer takes makes the loop spin forever with n == 0, in both tests. Reading from inOff + 1 fails both. The variant's own lines are straight-line code that main's single-call test already runs in full.
Differential fuzzing: the AeadDiff harness from the batching work, run against main's class compiled under another name, with the same engine and MAC.
Each variant ran 23 seeds (11–13 and 101–120). A seed is 4,000 random ChaCha20/XChaCha20 Poly1305 instance pairs, about 55,000 operations and 22 MB.
The operation mix is unchanged from the batching work: 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 just below the data limit, and nonce reuse.
Return values, output, tags, size queries and exceptions were identical up to and including the first exception (4,237–4,327 matching exceptions per seed for seeds 11–13).
The harness also catches all four B mutants above.
After an exception both variants behave like the batching branch. A decryption call failing with OutputLengthException leaves main's buffer full, so its next call throws ArrayIndexOutOfBoundsException (index 80, length 80). A and B carry on with that block counted twice, and doFinal fails the tag check. None of them accepts anything, and the provider checks output sizes first, so JCA users never get here.
Benchmark classes: all eight classes of the second run decrypt 400 random ciphertexts of up to 40 KB identically, in one call and in random pieces, and reject the same tampered ones, on JDK 17, 21 and 25. The eight are main, A, B, both versions of the batching branch, Faster Salsa20Engine #2476 + Faster Poly1305 block processing #2477, and all three with either version of the batching.
Not run: the jdk1.4 Ant build (no /opt/jdk1.4.2 on this machine). The new test uses nothing after Java 1.4: no enhanced for, no autoboxing; Math.min, SecureRandom.nextInt(int) and nextBoolean() only.
Benchmark
JMH average time for decrypting a fixed ciphertext, 16-byte tag included, in one call or in calls of 1,000 or 100 bytes. Each class was compiled from its branch's source under another name, against main's engine and MAC.
The figures cover main, A, B and both versions of the batching branch (#2481) in one run.
Runs: on JDK 25, 5 forks × 10 × 1 s after 5 × 1 s of warm-up (the first comparison used 2 forks × 5). On JDK 17 and 21, 3 forks × 10 × 1 s.
Conditions: the run took 21 minutes.The 1-minute load was 0.8–1.8 (median 1.2), mostly the IDE, and no other benchmark ran.
Agreement with the first tightened run (19:51–20:42, main, A, B and the old batching): within about 2% everywhere, except JDK 17's main at 16 KB, where one fork of that run settled at 133 µs instead of 122 µs.
JDK 25, ns per decryption (speed-up against main):
The error bars were within ±1.9% everywhere except JDK 21's main at 64 B (±5.6%). There one fork started with two slow iterations (1,782 and 1,485 ns), and the rest ran 1,238–1,252 ns, so the true speed-ups at 64 B on JDK 21 are about 1.09×.
What the numbers say:
A against B in one call: within about 2% of each other on every JDK, and neither is consistently ahead. The first tightened run's 4% lead for A at 1 KB on JDK 25 came out at 1.3% here, and the very first comparison's 6% lead for B at 16 KB was noise.
A against B in pieces: B is ahead by 3–4% on JDK 21 and 25, and by 5% on JDK 17 in 100-byte calls. This is the one place where not copying at all is measurably worth something.
JDK 17:main's byte loop is much slower there (123 µs for 16 KB, against 100–102 µs on 21 and 25), and both variants bring JDK 17 to within 10% of the newer JDKs.
The batching branch: decrypting on its own, its gain is entirely the copy, and with the arraycopy remainder it is within about 2% of A in one call (it trails B by 4% at 16 KB on JDK 21).
In 100-byte calls it now lies between A and B on JDK 25 and 17, and level with A on JDK 21. With the byte-by-byte remainder it was the slowest of the three on JDK 25 and 21 (1.11× against 1.19× now on JDK 25), and level with A on JDK 17.
JDK 17 at 16 KB in one call is the exception, at 91.3 against 89.0 µs for the old version. The remainder there is only the 16-byte tag, and both versions shifted by about 3 µs within forks, so this is JIT variation, not the copy.
A is the smallest change that removes the cost: +7 −3 lines, with the same calls into Poly1305 and ChaCha as main, in the same order, through the same buffer. It is easy to review, and in a single call, which is what the provider and the test vectors do, it is as fast as B.
Parts of this contribution were produced with the assistance of a generative AI tool (Claude Code),
under the direction and review of the submitter, in line with the contributing guidelines. The submitter
has reviewed and understands the code. To the best of his knowledge, the contribution does not reproduce third-party material.
This change is included in #2481 - only merge it if you decide against #2481.
A very low hanging fruit that improves decryption speed of ChaCha20Poly1305 by around 20% (even more on older JDKs!) with changing only +7/-3 lines in
ChaCha20Poly1305.processBytes().Claude went a bit crazy benchmarking and verifying this, see below because I let it try 2 different solutions and also in combination with #2481. But the tl;dr is that it's working great, even greater for older JVMs, and it's very simple. I will only provide variant A as a PR (B on request). Again, only merge if you decide against #2481.
Note also the remarks on
OutputLengthExceptioninmainbelow.The problem
When
ChaCha20Poly1305decrypts, the last 16 bytes it has seen may be the tag, so a block is only decrypted once 16 more bytes follow it.processBytesdid this with an 80-byte buffer (64 + 16) and copied every byte of the ciphertext into it on its own:buf[bufPos] = in[inOff + i]and a++bufPos == buf.lengthtest per byte. When the buffer was full, its first 64 bytes went to Poly1305 and ChaCha, and the last 16 were moved to the front. Encryption has no such loop, as it already hands whole blocks from the input to ChaCha.This affects
XChaCha20Poly1305, the provider'sChaCha20-Poly1305andXChaCha20-Poly1305ciphers, HPKE, MLS, and CMS/S/MIME AuthEnvelopedData with ChaCha20-Poly1305.Variant A:
arraycopyinto the buffer (+7 −3 lines)The loop is main's, with the per-byte copy replaced by an
arraycopyof as much as the buffer takes. Poly1305 and ChaCha are called with the same arguments and in the same order as onmain. Every block still goes through the buffer, and 16 bytes are still moved to its front after each block.Variant B: whole blocks straight from the input (+39 −7 lines)
arraycopyand decrypted from the buffer once 16 more bytes are available. The loop also takes a buffer that already holds 64–79 bytes, asprocessByteor an earlier call can leave it.in, as encryption already does.arraycopy.Poly1305 and ChaCha still get one 64-byte block per call, as on
main(as opposed to #2481).The test both variants add
core'sChaCha20Poly1305Testonly ever decrypted in a singleprocessBytescall, so the state the buffer is in when the next call arrives was never tested. Both branches add the sametestPiecewiseDecryption:processByte;getUpdateOutputSize, and the plaintext and tag checked at the end.It takes about 0.1 s.
Verification
Both branches went through the same checks.
Test suites: run on JDK 25. All pass:
crypto.testAllTests(21) andChaCha20Poly1305Test/XChaCha20Poly1305Test;ChaCha20Poly1305TestandXChaCha20Poly1305Test;AuthEnvelopedDataTestandNewAuthEnvelopedDataStreamTest(14 + 14);MessageProtectionTest;HPKETestVectors(15).Checkstyle (
core,prov) passes.Mutations: each mutant was compiled over the branch's class and run against the new test and against
main's version of the test.B: four mutants are caught only by the new test, while
main's test passes all four:need + MAC_SIZE - 1);<= MAC_SIZE);Lowering the direct loop's threshold to 79 bytes is caught by both tests. Moving 16 bytes instead of
bufPosis an equivalent mutant, as bytes pastbufPosare never read.A: limiting the copy to
BUF_SIZE - bufPosor to one byte less than the buffer takes makes the loop spin forever withn == 0, in both tests. Reading frominOff + 1fails both. The variant's own lines are straight-line code thatmain's single-call test already runs in full.Differential fuzzing: the
AeadDiffharness from the batching work, run againstmain's class compiled under another name, with the same engine and MAC.processBytesof 0–6,000 bytes andprocessByte, output buffers exact, generous and one byte short, in place and overlapping, size queries, tampered and truncated ciphertexts, instances just below the data limit, and nonce reuse.After an exception both variants behave like the batching branch. A decryption call failing with
OutputLengthExceptionleavesmain's buffer full, so its next call throwsArrayIndexOutOfBoundsException(index 80, length 80). A and B carry on with that block counted twice, anddoFinalfails the tag check. None of them accepts anything, and the provider checks output sizes first, so JCA users never get here.Benchmark classes: all eight classes of the second run decrypt 400 random ciphertexts of up to 40 KB identically, in one call and in random pieces, and reject the same tampered ones, on JDK 17, 21 and 25. The eight are
main, A, B, both versions of the batching branch, Faster Salsa20Engine #2476 + Faster Poly1305 block processing #2477, and all three with either version of the batching.Not run: the jdk1.4 Ant build (no
/opt/jdk1.4.2on this machine). The new test uses nothing after Java 1.4: no enhancedfor, no autoboxing;Math.min,SecureRandom.nextInt(int)andnextBoolean()only.Benchmark
JMH average time for decrypting a fixed ciphertext, 16-byte tag included, in one call or in calls of 1,000 or 100 bytes. Each class was compiled from its branch's source under another name, against
main's engine and MAC.The figures cover
main, A, B and both versions of the batching branch (#2481) in one run.main, A, B and the old batching): within about 2% everywhere, except JDK 17'smainat 16 KB, where one fork of that run settled at 133 µs instead of 122 µs.JDK 25, ns per decryption (speed-up against
main):mainJDK 21 and 17, ns per decryption (speed-up against
main):mainThe error bars were within ±1.9% everywhere except JDK 21's
mainat 64 B (±5.6%). There one fork started with two slow iterations (1,782 and 1,485 ns), and the rest ran 1,238–1,252 ns, so the true speed-ups at 64 B on JDK 21 are about 1.09×.What the numbers say:
main's byte loop is much slower there (123 µs for 16 KB, against 100–102 µs on 21 and 25), and both variants bring JDK 17 to within 10% of the newer JDKs.arraycopyremainder it is within about 2% of A in one call (it trails B by 4% at 16 KB on JDK 21).Choosing between them
main, in the same order, through the same buffer. It is easy to review, and in a single call, which is what the provider and the test vectors do, it is as fast as B.arraycopy, which took it from the slowest of the three in 100-byte calls to between A and B.AI Disclosure
Parts of this contribution were produced with the assistance of a generative AI tool (Claude Code),
under the direction and review of the submitter, in line with the contributing guidelines. The submitter
has reviewed and understands the code. To the best of his knowledge, the contribution does not reproduce third-party material.