perf: hand ByteBuffers to Inflater and CRC32 in the coding stages - #1228
Open
pjfanning wants to merge 1 commit into
Open
perf: hand ByteBuffers to Inflater and CRC32 in the coding stages#1228pjfanning wants to merge 1 commit into
pjfanning wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Three places in the gzip/deflate stages copy a
ByteStringinto abyte[]purely to hand it tojava.util.zip:DeflateDecompressorBase.Inflate.parsecallsinflater.setInput(reader.remainingData.toArray)on every parse round with the whole remaining buffer, so every round copies it.GzipCompressor.updateCrcandGzipDecompressor.crc16usetoArrayUnsafe().toArrayUnsafe()only avoids the copy for a compactByteString; for a slice or a multi-fragment one it falls back totoArray. On pekko-actor 2.0.0-M4,compact.toArrayUnsafe() eq compact.toArrayUnsafe()istrue, butfalseforcompact.drop(1)and for a rope. AndByteReader.remainingDataisinput.drop(off), so it is never compact once any byte has been consumed — which is always the case by the timeInflateruns, since the wrap-probe / gzip-header steps have already consumed bytes.Inflaterhas acceptedByteBufferinput since Java 11 andCRC32since Java 8; the build targets JDK 17 (javacTargetinproject/Common.scala).Modification
inflater.setInput(reader.remainingData.asByteBuffer).getRemainingreports the buffer's remaining bytes when input was supplied as aByteBuffer, so the followingreader.skip(reader.remainingSize - inflater.getRemaining)needs no change. For a multi-fragment input,asByteBuffercompacts internally — one copy, i.e. no worse than today.asByteBuffers. Plural, not singular:CRC32is incremental, so per-fragment updates stay copy-free even for a rope, whereasByteBufferwould compact it first.ByteString.asByteBufferreturns a read-only heap buffer (ByteBuffer.wrap(...).asReadOnlyBuffer(), so neitherhasArraynorisDirect). I verified on JDK 21 thatInflater.setInputaccepts it — a 1 MB round trip inflated correctly withgetRemaining == 0— and thatCRC32.updateagrees with the array path.All three classes are
@InternalApi/private[coding].Result
Inflate. Simulating the
Inflate.parseloop faithfully (remainingData = buffer.drop(off), oneinflateinto a 64 KB chunk,skipthe consumed bytes), over 4 MB of incompressible data so that the compressed volume being carried is real:toArrayByteBufferWith 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 aByteBuffer; 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 sendsCRC32.updatedown 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.crc16only 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.Deflateris 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 viatoArrayUnsafe()against 194 ms viaasByteBufferfor 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.CoderSpecalready covers the changed paths thoroughly: "works for any split in prefix + suffix" decodes at every possible split of the compressed stream, soremainingDatais 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.CoderSpeccase: encode a deliberately multi-fragment input (assertedisCompact == 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