Skip to content

Speed up ChaCha20Poly1305 decryption by avoiding byte-by-byte copy #2483

Description

@winne42

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.test AllTests (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.
  • Machine: AMD Ryzen 9 5900HX (AVX2), Linux, OpenJDK 25.0.4, 21.0.12 and 17.0.20.
  • 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):

Decryption of main A: arraycopy B: direct batching (#2481) batching, byte-by-byte remainder
64 B in one call 1,184 1,084 (1.09×) 1,082 (1.09×) 1,096 (1.08×) 1,113 (1.06×)
1 KB in one call 7,105 5,799 (1.23×) 5,877 (1.21×) 5,891 (1.21×) 5,855 (1.21×)
16 KB in one call 100,286 82,411 (1.22×) 83,129 (1.21×) 83,111 (1.21×) 83,262 (1.20×)
16 KB in 1,000-byte calls 101,438 83,540 (1.21×) 80,768 (1.26×) 83,404 (1.22×) 84,008 (1.21×)
16 KB in 100-byte calls 102,884 87,259 (1.18×) 84,178 (1.22×) 86,208 (1.19×) 92,416 (1.11×)

JDK 21 and 17, ns per decryption (speed-up against main):

Decryption of main A: arraycopy B: direct batching (#2481) batching, byte-by-byte remainder
JDK 21, 64 B 1,270 1,139 (1.12×) 1,135 (1.12×) 1,142 (1.11×) 1,164 (1.09×)
JDK 21, 1 KB 7,339 6,048 (1.21×) 5,922 (1.24×) 6,077 (1.21×) 6,126 (1.20×)
JDK 21, 16 KB 102,010 83,885 (1.22×) 82,140 (1.24×) 85,182 (1.20×) 85,293 (1.20×)
JDK 21, 16 KB in 100-byte calls 104,698 88,093 (1.19×) 85,577 (1.22×) 87,959 (1.19×) 90,934 (1.15×)
JDK 17, 64 B 1,316 1,225 (1.07×) 1,234 (1.07×) 1,234 (1.07×) 1,251 (1.05×)
JDK 17, 1 KB 8,644 6,381 (1.35×) 6,331 (1.37×) 6,471 (1.34×) 6,474 (1.34×)
JDK 17, 16 KB 122,804 88,996 (1.38×) 89,836 (1.37×) 91,344 (1.34×) 89,010 (1.38×)
JDK 17, 16 KB in 100-byte calls 143,145 96,337 (1.49×) 91,620 (1.56×) 93,353 (1.53×) 96,119 (1.49×)

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.
    • Its runs pay off only together with Faster Poly1305 block processing #2477, which neither variant can use, as both hand Poly1305 64 bytes per call.

Choosing between them

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.

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