Skip to content

perf: hand ByteBuffers to Inflater and CRC32 in the coding stages - #1228

Open
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:perf/coding-bytebuffer
Open

perf: hand ByteBuffers to Inflater and CRC32 in the coding stages#1228
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:perf/coding-bytebuffer

Conversation

@pjfanning

@pjfanning pjfanning commented Aug 24, 2026

Copy link
Copy Markdown
Member

Motivation

Three places in the gzip/deflate stages copy a ByteString into a byte[] purely to hand it to java.util.zip:

  • DeflateDecompressorBase.Inflate.parse calls inflater.setInput(reader.remainingData.toArray) on every parse round with the whole remaining buffer, so every round copies it.
  • GzipCompressor.updateCrc and GzipDecompressor.crc16 use toArrayUnsafe().

toArrayUnsafe() only avoids the copy for a compact ByteString; for a slice or a multi-fragment one it falls back to toArray. On pekko-actor 2.0.0-M4, compact.toArrayUnsafe() eq compact.toArrayUnsafe() is true, but false for compact.drop(1) and for a rope. And ByteReader.remainingData is input.drop(off), so it is never compact once any byte has been consumed — which is always the case by the time Inflate runs, since the wrap-probe / gzip-header steps have already consumed bytes.

Inflater has accepted ByteBuffer input since Java 11 and CRC32 since Java 8; the build targets JDK 17 (javacTarget in project/Common.scala).

Modification

  • inflater.setInput(reader.remainingData.asByteBuffer). getRemaining reports the buffer's remaining bytes when input was supplied as a ByteBuffer, so the following reader.skip(reader.remainingSize - inflater.getRemaining) needs no change. For a multi-fragment input, asByteBuffer compacts internally — one copy, i.e. no worse than today.
  • Feed both checksums one buffer per fragment via asByteBuffers. Plural, not singular: CRC32 is incremental, so per-fragment updates stay copy-free even for a rope, where asByteBuffer would compact it first.

ByteString.asByteBuffer returns a read-only heap buffer (ByteBuffer.wrap(...).asReadOnlyBuffer(), so neither hasArray nor isDirect). I verified on JDK 21 that Inflater.setInput accepts it — a 1 MB round trip inflated correctly with getRemaining == 0 — and that CRC32.update agrees with the array path.

All three classes are @InternalApi / private[coding].

Result

Inflate. Simulating the Inflate.parse loop faithfully (remainingData = buffer.drop(off), one inflate into a 64 KB chunk, skip the consumed bytes), over 4 MB of incompressible data so that the compressed volume being carried is real:

compressed data held per push via toArray via ByteBuffer
64 KB 34 ms 12 ms
8 KB 40 ms 28 ms

With highly compressible input the copy is negligible and the two are equal (155 ms vs 154 ms over 8 MB), so this is a win where there is volume to carry and a tie otherwise.

Checksum. CRC32 over a 1 MB input, 200 updates: sliced input 221 ms via toArrayUnsafe() vs 154 ms via a ByteBuffer; compact input 102 ms vs 98 ms. Never slower, ~30% faster when the input is not compact, and it stops allocating a full-size copy of everything being gzipped. The read-only heap buffer sends CRC32.update down the JDK's internal 4 KB chunked-copy path rather than a straight array pass, which is why the win is ~30% and not total.

crc16 only ever sees the gzip header (tens of bytes); that one is for consistency, not speed.

What is deliberately not changed

DeflateCompressor.scala:54, deflater.setInput(input.toArrayUnsafe()), looks like the same pattern but measures badly, so it is left alone. Deflater is slower with a read-only heap buffer than with a plain array, and unlike the inflate side there is no large per-round copy to win back: deflating 1 MB twenty times took 162 ms via toArrayUnsafe() against 194 ms via asByteBuffer for a compact input (~20% worse), and 362 ms against 346 ms for a sliced one (a wash).

Tests

  • sbt "http-tests / Test / testOnly org.apache.pekko.http.scaladsl.coding.*" - 78 passed. CoderSpec already covers the changed paths thoroughly: "works for any split in prefix + suffix" decodes at every possible split of the compressed stream, so remainingData is a slice at every offset; plus "decompress in very small chunks", "be able to decode chunk-by-chunk", corrupt-input handling and the round-trips.
  • New CoderSpec case: encode a deliberately multi-fragment input (asserted isCompact == false) and check it produces byte-identical output to the compact form, pinning the per-fragment checksum as fragmentation-independent. It is a regression guard rather than a failing-before test, since the previous code was also correct. Writing it turned up a nice subtlety worth knowing: bs.grouped(n).reduce(_ ++ _) gives you back a compact ByteString, because adjacent slices of one array are merged by ++; the test copies each chunk into its own array to get a genuine rope.
  • sbt http/mimaReportBinaryIssues - clean.
  • scalafmt --mode diff-ref=upstream/main - clean.

References

None - found while auditing ByteString usage across the code base

Motivation:
Three places in the gzip/deflate stages copy a ByteString into a byte array only
to hand it to java.util.zip:

- DeflateDecompressorBase.Inflate.parse calls setInput with the whole remaining
  buffer on every parse round, via toArray, so every round copies it.
- GzipCompressor.updateCrc and GzipDecompressor.crc16 use toArrayUnsafe, which
  only avoids the copy for a compact ByteString and falls back to a full copy for
  a slice or a multi-fragment one.

ByteReader.remainingData is input.drop(off), so it is not compact once any byte
has been consumed, which is always the case by the time Inflate runs. Inflater
and Deflater have taken ByteBuffer input since Java 11 and CRC32 since Java 8,
and the build targets JDK 17.

Modification:
Pass reader.remainingData.asByteBuffer to Inflater.setInput. getRemaining reports
the buffer's remaining bytes, so the following reader.skip is unchanged. Feed the
checksums one buffer per fragment with asByteBuffers, which stays copy free even
for a multi-fragment ByteString, where the singular asByteBuffer would compact.

Result:
No array copy per inflate round, and no full size copy per gzipped entity.
Measured over a 1 MB input, 200 CRC32 updates: 221 ms via toArrayUnsafe versus
154 ms via a ByteBuffer for a sliced input, 102 ms versus 98 ms for a compact
one, so it is never slower and stops allocating a copy of everything gzipped.

Tests:
- sbt "http-tests / Test / testOnly org.apache.pekko.http.scaladsl.coding.*" - 78 passed. CoderSpec already covers these paths well: 'works for any split in prefix + suffix' decodes at every possible split point, so remainingData is a slice at every offset, plus chunked decoding and corrupt input
- New CoderSpec case asserts the encoder produces identical output for a multi-fragment input and a compact one, pinning the per-fragment checksum. It is a regression guard, not a failing-before test, since the previous code was also correct
- sbt http/mimaReportBinaryIssues - clean
- scalafmt --mode diff-ref=upstream/main - clean

References:
None - found while auditing ByteString usage across the code base
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant