Skip to content

Bit packing serialization - #758

Open
James Ryan Carr (jryancarr) wants to merge 11 commits into
microsoft:contribfrom
jryancarr:bit-packing-serialization
Open

Bit packing serialization#758
James Ryan Carr (jryancarr) wants to merge 11 commits into
microsoft:contribfrom
jryancarr:bit-packing-serialization

Conversation

@jryancarr

Copy link
Copy Markdown

Summary

This PR adds a new serialization mode, compr_mode_type::bitpack (native) / ComprModeType.BitPack (dotnet), that assumes the serialized bytes contain arrays of uint64_t where some number of high bits are always 0. It breaks the input stream into blocks of 1024 bytes, and then uses a simple heuristic for guessing where in the block the array starts, inferring what the always-0 high bits are, and packing away the always-0 bits in the output stream (a more detailed overview including diagrams and a worked example is provided in the overview comment at the top of native/src/seal/util/bitpack.h). For blocks of the input stream that contain arrays of uint64_t, this approach achieves near-optimal compression. For blocks of the input stream that don't contain such arrays (e.g. blocks containing metadata), this approach probably won't compress the data at all, but these blocks are rare enough that when serializing actual SEAL objects the approach beats both zstd and zlib on size, save speed, and load speed.

The advantages of this approach are:

  1. Performance: This approach produces significantly smaller payloads than zstd and zlib when serializing all SEAL cryptographic payloads (i.e. everything except Plaintexts), while also having faster save and load performance. This PR adds new benchmarks to the existing benchmark suite so reviewers can verify this themselves if desired. Here are a few lines as a representative sample. The compr_mode_type is at the end of the benchmark name, e.g. SaveCiphertextBFVNone uses compr_mode_type::none; bitpack is the last two lines in each section.
-------------------------------------------------------------------------------------------------------------------------------------------
Benchmark                                                                                 Time             CPU   Iterations UserCounters...
-------------------------------------------------------------------------------------------------------------------------------------------
n=1024 / log(q)=27 / SERIALIZE / SaveCiphertextBFVNone/iterations:10                   1.19 us         1.20 us           10 size=16.497k
n=1024 / log(q)=27 / SERIALIZE / LoadCiphertextBFVNone/iterations:10                   2.83 us         2.80 us           10 size=16.497k
n=1024 / log(q)=27 / SERIALIZE / SaveCiphertextBFVZlib/iterations:10                    603 us          602 us           10 size=9.154k
n=1024 / log(q)=27 / SERIALIZE / LoadCiphertextBFVZlib/iterations:10                   36.6 us         36.5 us           10 size=9.164k
n=1024 / log(q)=27 / SERIALIZE / SaveCiphertextBFVZstd/iterations:10                   54.7 us         54.7 us           10 size=8.638k
n=1024 / log(q)=27 / SERIALIZE / LoadCiphertextBFVZstd/iterations:10                   23.7 us         23.7 us           10 size=8.632k
n=1024 / log(q)=27 / SERIALIZE / SaveCiphertextBFVBitPack/iterations:10                6.97 us         7.00 us           10 size=7.665k
n=1024 / log(q)=27 / SERIALIZE / LoadCiphertextBFVBitPack/iterations:10                3.76 us         3.70 us           10 size=7.665k
...
n=8192 / log(q)=218 / SERIALIZE / SaveCiphertextBFVNone/iterations:10                  7.14 us         7.10 us           10 size=524.401k
n=8192 / log(q)=218 / SERIALIZE / LoadCiphertextBFVNone/iterations:10                  24.7 us         24.7 us           10 size=524.401k
n=8192 / log(q)=218 / SERIALIZE / SaveCiphertextBFVZlib/iterations:10                 19333 us        19328 us           10 size=427.229k
n=8192 / log(q)=218 / SERIALIZE / LoadCiphertextBFVZlib/iterations:10                  1495 us         1490 us           10 size=427.254k
n=8192 / log(q)=218 / SERIALIZE / SaveCiphertextBFVZstd/iterations:10                   405 us          404 us           10 size=432.492k
n=8192 / log(q)=218 / SERIALIZE / LoadCiphertextBFVZstd/iterations:10                   335 us          335 us           10 size=432.437k
n=8192 / log(q)=218 / SERIALIZE / SaveCiphertextBFVBitPack/iterations:10                209 us          209 us           10 size=359.355k
n=8192 / log(q)=218 / SERIALIZE / LoadCiphertextBFVBitPack/iterations:10               79.6 us         79.0 us           10 size=359.355k
...
n=32768 / log(q)=881 / SERIALIZE / SaveRelinKeysNone/iterations:10                     2982 us         2982 us           10 size=125.831M
n=32768 / log(q)=881 / SERIALIZE / LoadRelinKeysNone/iterations:10                     8783 us         8773 us           10 size=125.831M
n=32768 / log(q)=881 / SERIALIZE / SaveRelinKeysZlib/iterations:10                  2615502 us      2613740 us           10 size=118.89M
n=32768 / log(q)=881 / SERIALIZE / LoadRelinKeysZlib/iterations:10                   374059 us       373681 us           10 size=118.89M
n=32768 / log(q)=881 / SERIALIZE / SaveRelinKeysZstd/iterations:10                   109441 us       109333 us           10 size=118.527M
n=32768 / log(q)=881 / SERIALIZE / LoadRelinKeysZstd/iterations:10                    91171 us        91053 us           10 size=118.527M
n=32768 / log(q)=881 / SERIALIZE / SaveRelinKeysBitPack/iterations:10                 47544 us        47449 us           10 size=108.729M
n=32768 / log(q)=881 / SERIALIZE / LoadRelinKeysBitPack/iterations:10                 18187 us        18148 us           10 size=108.729M
  1. No external dependencies: This approach adds no new dependencies and does not use zlib or zstd, giving users a compression option that does not require third-party libraries.

Security

I've done my best to ensure this new serialization scheme follows the recent improvements to serialization security:

  • Hostile size claims: The claimed original size does not trigger a memory allocation of that size, it only caps how many bytes the decoder will produce. Every read is bounded by SEALHeader.size, which the existing 4.3.3 guard has already verified against the input actually available (or refused to trust, on non-seekable streams).
  • Decompression bombs: BitUnpackGetBuffer is a pull-based streambuf modeled directly on ztools::InflateGetBuffer: blocks are decoded on demand in constant memory, work is bounded by what the parser's own validated metadata requests, and remaining data in the stream after parsing is handled using the existing guards from zlib / zstd.
  • Every attacker-controlled field is validated before use, and failures are fail-closed.
  • bitpack runs inside the existing hostile-input test loops, and adds per-field tamper tests including hostile size claims in both directions.
  • During development we ran a fuzz test using 200k iterations under UBSan, which resulted in no crashes, hangs, sanitizer reports, or unbounded allocation. The code for this is not part of the PR but I can provide it on request.
  • bitpack does not provide integrity checking like zlib and zstd do, and this is noted in the headers. However, those integrity checks don't protect against malicious alteration of the payload, only accidental corruptions, so bitpack's security posture against malicious adversaries is the same as the other schemes in this regard.

Compatibility

  • The wire format of all existing modes and the default compression mode are unchanged; loading previously serialized data is unaffected.
  • Objects serialized with the new mode are rejected by earlier releases via the existing IsSupportedComprMode check (the same behavior as a zstd stream in a build without zstd).
  • The mode has no external dependency and is always available (no CMake option, no #ifdef in the enum).
  • The block size is recorded in the object header to allow for future versions to change it or select it dynamically without breaking compatibility with the current format.

Other notes

Development was AI-assisted (Claude Code with model Fable 5); I manually reviewed all code and documentation. The validation / benchmarks above were run on my Apple M-series laptop.

I hope the maintainers find this contribution useful, and would be happy to work with them on any requested changes!

Ciphertext and key data consist of 64-bit words storing integers modulo
primes much smaller than the word size. The significant bits are close to
uniformly random and hence essentially incompressible; only the always-zero
high bits are redundant, and a general-purpose compressor cannot remove
partial bytes of them.

The new mode splits the serialized stream into 4096-byte blocks and packs
each block's run of 64-bit words using only as many bits per word as the
largest word in the block requires, so each word begins immediately after
the last significant bit of the previous one. A per-block phase (0-7
verbatim leading bytes) aligns the packed run with the data's natural word
grid, since serialized metadata is not always a multiple of eight bytes.
On a BFV ciphertext at poly_modulus_degree 8192 this saves 17% over
Zstandard (31% over uncompressed).

Loading is streamed one block at a time through a pull-based streambuf,
mirroring the zlib/zstd paths, so hostile size claims cannot drive
unbounded allocation. The default compression mode and the wire format of
all existing modes are unchanged; the new mode requires no external
dependency and is always available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…24 bytes.

A block-size sweep across real objects shows the optimum scales with
sqrt(object size / number of unpackable runs): roughly 512 bytes for a
small ciphertext, 2-4 KB for multi-megabyte keys. No single constant is
optimal, and the previous 4096 was baked into the decoder, silently
making it part of the wire format. The block size (as its base-2
logarithm, validated to 64 B - 64 KB on load) is now recorded in the
encoded stream, so future encoders can tune it per object without a
format change. The encoder default moves to 1024 bytes, which is within
0.3% of the per-object optimum on large objects and about 15% smaller
than 4096-byte blocks on small ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The maximum useful phase is one byte less than a word: a phase of
bytes_per_word reproduces the alignment of phase zero while wasting a
verbatim word. Spelling the bound as bytes_per_word - 1 also
distinguishes it from the bit-within-a-byte masks in the packing loops,
whose 7s are bits-per-byte quantities and intentionally unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For a final block shorter than a word, every phase up to the block
length encodes zero packed words at identical cost, splitting the bytes
between the verbatim phase prefix and tail; the encoder's tie-break
settles on phase zero. The decoder must nevertheless accept all of the
equivalent encodings and reject a phase beyond the block length, which
would underflow the word count. A new test pins both behaviors with
hand-crafted streams.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sts.

The compr_mode_type::bitpack enumerator (C++ and .NET) now states that
bit-packing, unlike ZLIB and Zstandard, performs no integrity checking
of the data; users migrating from zlib would otherwise silently lose
Adler-32's accidental-corruption detection. Removed a stray blank line
left in the enum documentation by an earlier edit.

Added GaloisKeys and RelinKeys bit-packed round-trip tests covering both
expanded keys (data equality and a size win over the unpacked form) and
seeded keys, whose nested seeded frames must load to keys identical to
the same seeded object saved uncompressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jryancarr

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Enveil"

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