Use SWAR + VarHandle for 7-bit binary Smile encode/decode - #774
Merged
Conversation
Smile packs binary values 7 payload bytes into 8 encoded bytes of 7
significant bits each (`ENCODE_BINARY_AS_7BIT`, enabled by default). Both
directions were done a byte at a time: the decoder did 8 separate loads,
~10 ALU ops and 7 separate stores per chunk; the encoder 7 loads, ~14 ops
and 8 stores.
Both collapse to a single 8-byte load, ~9 ALU ops and a single 8-byte
store using SWAR bit manipulation, with the load and store going through
the existing `SmileVarHandleUtil` (extended here with `getLongBE` /
`setLongBE`, mirrored in `SmileByteShiftUtil`).
New `Smile7BitBinaryCodec` holds both directions and probes VarHandle
availability with the same pattern as `SmileParserBase._decodeQuad()`.
Where VarHandle is unusable there is nothing to gain -- the byte-shifting
fallback would be doing exactly the per-byte work being avoided -- so it
reports failure and each caller runs its existing loop unchanged. The
same fallback covers chunks that would over-read or over-write the buffer
by the one byte of overhang the 8-vs-7 asymmetry needs.
Applied at all five sites: three decode (`_readBinaryEncoded`,
`_finishBinary7Bit`, `_finishBinary7BitLong`) and two encode (the
`byte[]` and `InputStream` variants of `_write7BitBinaryWithLength`).
Measured on JDK 17.0.19 (Temurin), inner loops in isolation:
encode 922.0 -> 309.7 us/pass 2.98x
decode 794.6 -> 271.3 us/pass 2.93x
End-to-end through the streaming API, median of 3 alternating passes:
payload write GB/s read GB/s
1KB 0.66 -> 0.95 0.85 -> 0.99
64KB 0.68 -> 0.92 0.88 -> 1.00
1MB 0.51 -> 0.98 0.71 -> 0.86
Adds round-trip coverage over every length across the first chunk
boundaries plus sizes past the internal buffers and the "long binary"
threshold, for all four write/read path combinations, and a check that
the encoded bytes themselves are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VarHandle for 7-bit binary encode/decodeVarHandle for 7-bit binary Smile encode/decode
Conflict: `SmileByteShiftUtil` -- this branch added `getLongBE`/`setLongBE` to it, while 3.x (FasterXML#779) removed the class in favour of `ByteArrayUtil` from `jackson-core`. Resolved by taking the deletion: nothing references the class (`Smile7BitBinaryCodec` reports failure and lets the caller run its own loop where `VarHandle` is unusable, rather than calling a shift fallback), and both added methods are equivalent to their `ByteArrayUtil` counterparts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RK4ezx55QXz8QWki9SXEq5
`SmileByteShiftUtil` is gone (FasterXML#779): the byte-shifting side is now `ByteArrayUtil` in `jackson-core`. Name it in the `SmileVarHandleUtil` fallback rule, and record in `Smile7BitBinaryCodec` that not calling it is deliberate: composing the 8-byte load and store out of shifts costs more per chunk than the per-byte loop it would replace, so the existing caller loop stays the faster path where `VarHandle` is unusable. Measured on JDK 17.0.19 (Temurin), inner loops in isolation, 150k chunks per pass, median of 31 passes x 3 alternating rounds (stable to ~1% across runs): encode perByte 661 us SWAR+ByteArrayUtil 838 us 0.79x decode perByte 601 us SWAR+ByteArrayUtil 837 us 0.72x (SWAR+VarHandle, for reference: 1.94x encode / 1.61x decode over perByte.) Also fixes the `SmileVarHandleUtil` class doc, which still said "reading" only after this branch added `setLongBE`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RK4ezx55QXz8QWki9SXEq5
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.
Smile packs binary values 7 payload bytes into 8 encoded bytes of 7 significant bits each (
ENCODE_BINARY_AS_7BIT, enabled by default, so this is the path for every Smile binary value). Both directions were done a byte at a time and were not part of #757:Both collapse to a single 8-byte load, ~9 ALU ops and a single 8-byte store:
with the exact inverse for encode.
Structure
New package-private
Smile7BitBinaryCodecholds both directions and probes VarHandle availability using the same pattern asSmileParserBase._decodeQuad()(the byte-shifting fallback lives in a class that never referencesVarHandle, so the fallback path cannot fail to link).SmileVarHandleUtilgainsgetLongBE/setLongBE.Where VarHandle is unusable the codec reports failure and each caller runs its existing loop, unchanged. This is deliberate, and stronger than "nothing to gain": routing the fallback through
ByteArrayUtilwould be an outright slowdown, because composing the 8-byte load and store out of shifts costs more per chunk than the per-byte loop it would be replacing.Measured on JDK 17.0.19 (Temurin), inner loops in isolation, 150k chunks/pass, median of 31 passes x 3 alternating rounds (reproducible to ~1% across runs):
ByteArrayUtilVarHandleSo the per-byte loop stays the right fallback. That same fallback covers chunks that would over-read or over-write by the one byte of overhang the 8-vs-7 asymmetry needs (the decode store writes 8 bytes for 7 wanted; the encode load reads 8 for 7 wanted).
Applied at all five sites: three decode (
_readBinaryEncoded,_finishBinary7Bit,_finishBinary7BitLong) and two encode (thebyte[]andInputStreamvariants of_write7BitBinaryWithLength).Benchmarks
JDK 17.0.19 (Temurin). Inner loops in isolation, both forms lifted verbatim from the call sites:
End-to-end through the streaming API (
writeBinary/getBinaryValue), median of 3 alternating passes against a3.xbuild:Read gains are smaller because
getBinaryValue()on large values also pays forByteArrayBuildersegment copies, which the codec does not touch.Tests
Binary7BitRoundtripTestcovers every length 0–63 plus sizes past the internal buffers and the 250k "long binary" threshold, across all four write/read path combinations (byte[]vsInputStreamwrite, in-memory vs streaming read), so both the SWAR path and the overhang fallback are exercised at every chunk boundary. Also asserts the encoded bytes themselves are unchanged against an independent reference.Verified the tests actually catch regressions in the new code: perturbing a shift in either direction fails 4 of the 5 tests.
Full
smilemodule test suite passes.Note on malformed input
The current decoder reads
buffer[i]without& 0xFF, so an encoded byte with the high bit set (illegal in Smile) sign-extends and corrupts the result. The SWAR form masks it to 0 instead. Both are garbage for garbage input, but the bytes differ — flagging in case that matters for any fuzz corpus.🤖 Generated with Claude Code