From 3e26b0eb43242997cd336f7263e15aff34a46982 Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Wed, 29 Jul 2026 09:42:51 -0400 Subject: [PATCH 1/3] Refactor Compress to take its working memory as a parameter Extract InternalCompress, which uses a caller-provided WorkingMemory instead of allocating its own. No behavior change: the public Compress overload allocates the working memory exactly as before. This prepares for reusing working memory across compressions. --- snappy.cc | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/snappy.cc b/snappy.cc index 37d3c0b..7f6fa44 100644 --- a/snappy.cc +++ b/snappy.cc @@ -1865,7 +1865,9 @@ size_t Compress(Source* reader, Sink* writer) { return Compress(reader, writer, CompressionOptions{}); } -size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { +static size_t InternalCompress(Source* reader, Sink* writer, + CompressionOptions options, + internal::WorkingMemory* wmem) { assert(options.level == 1 || options.level == 2); size_t written = 0; size_t N = reader->Available(); @@ -1875,8 +1877,6 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { writer->Append(ulength, p - ulength); written += (p - ulength); - internal::WorkingMemory wmem(N); - while (N > 0) { // Get next block to compress (without copying if possible) size_t fragment_size; @@ -1891,7 +1891,7 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { pending_advance = num_to_read; fragment_size = num_to_read; } else { - char* scratch = wmem.GetScratchInput(); + char* scratch = wmem->GetScratchInput(); std::memcpy(scratch, fragment, bytes_read); reader->Skip(bytes_read); @@ -1910,7 +1910,7 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { // Get encoding table for compression int table_size; - uint16_t* table = wmem.GetHashTable(num_to_read, &table_size); + uint16_t* table = wmem->GetHashTable(num_to_read, &table_size); // Compress input_fragment and append to dest int max_output = MaxCompressedLength(num_to_read); @@ -1920,7 +1920,7 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { // scratch_output[] region is big enough for this iteration. // Need a scratch buffer for the output, in case the byte sink doesn't // have room for us directly. - char* dest = writer->GetAppendBuffer(max_output, wmem.GetScratchOutput()); + char* dest = writer->GetAppendBuffer(max_output, wmem->GetScratchOutput()); char* end = nullptr; if (options.level == 1) { end = internal::CompressFragment(fragment, fragment_size, dest, table, @@ -1940,6 +1940,11 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { return written; } +size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { + internal::WorkingMemory wmem(reader->Available()); + return InternalCompress(reader, writer, options, &wmem); +} + // ----------------------------------------------------------------------- // IOVec interfaces // ----------------------------------------------------------------------- From 402cfb7c93bf4ad421f23fe7800d26d717ebf0fd Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Wed, 29 Jul 2026 09:43:01 -0400 Subject: [PATCH 2/3] Add CompressionContext for reusing compression working memory Today, every Compress()/RawCompress() call allocates and frees an internal WorkingMemory. Callers that compress frequently, or that run with custom or per-thread allocators sensitive to large contiguous allocations, currently have no way to avoid this per-call allocation, and other mainstream codecs offer reusable contexts for exactly this purpose. Add an opaque CompressionContext holding a reusable WorkingMemory sized for kBlockSize (usable with inputs of any size and both compression levels), plus Compress()/RawCompress() overloads that use it instead of allocating internally. --- snappy.cc | 38 +++++++++++++++++++++++++++++++++ snappy.h | 42 +++++++++++++++++++++++++++++++++++++ snappy_unittest.cc | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/snappy.cc b/snappy.cc index 7f6fa44..efca3af 100644 --- a/snappy.cc +++ b/snappy.cc @@ -1945,6 +1945,33 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options) { return InternalCompress(reader, writer, options, &wmem); } +size_t Compress(Source* reader, Sink* writer, CompressionOptions options, + CompressionContext* ctx) { + assert(ctx != nullptr); + assert(ctx->working_memory_ != nullptr); + return InternalCompress(reader, writer, options, ctx->working_memory_); +} + +CompressionContext::CompressionContext() + : working_memory_(new internal::WorkingMemory(kBlockSize)) {} + +CompressionContext::~CompressionContext() { delete working_memory_; } + +CompressionContext::CompressionContext(CompressionContext&& other) noexcept + : working_memory_(other.working_memory_) { + other.working_memory_ = nullptr; +} + +CompressionContext& CompressionContext::operator=( + CompressionContext&& other) noexcept { + if (this != &other) { + delete working_memory_; + working_memory_ = other.working_memory_; + other.working_memory_ = nullptr; + } + return *this; +} + // ----------------------------------------------------------------------- // IOVec interfaces // ----------------------------------------------------------------------- @@ -2390,6 +2417,17 @@ void RawCompress(const char* input, size_t input_length, char* compressed, *compressed_length = (writer.CurrentDestination() - compressed); } +void RawCompress(const char* input, size_t input_length, char* compressed, + size_t* compressed_length, CompressionOptions options, + CompressionContext* ctx) { + ByteArraySource reader(input, input_length); + UncheckedByteArraySink writer(compressed); + Compress(&reader, &writer, options, ctx); + + // Compute how many bytes were added + *compressed_length = (writer.CurrentDestination() - compressed); +} + void RawCompressFromIOVec(const struct iovec* iov, size_t uncompressed_length, char* compressed, size_t* compressed_length) { RawCompressFromIOVec(iov, uncompressed_length, compressed, compressed_length, diff --git a/snappy.h b/snappy.h index ecd83d7..423142b 100644 --- a/snappy.h +++ b/snappy.h @@ -50,6 +50,10 @@ namespace snappy { class Source; class Sink; + namespace internal { + class WorkingMemory; + } // end namespace internal + struct CompressionOptions { // Compression level. // Level 1 is the fastest @@ -73,6 +77,33 @@ namespace snappy { static constexpr int DefaultCompressionLevel() { return 1; } }; + // Scratch memory for compression, reusable across compressions. Callers that + // compress frequently, or that need to avoid large heap allocations can + // allocate a CompressionContext once and pass it to Compress()/RawCompress() + // to reuse the working memory across calls. + // + // The context is sized for the largest block and works for inputs of any + // size. A context may be used by any number of sequential compressions, but + // must not be used from multiple threads concurrently. A moved-from context + // may only be destroyed or assigned to. + class CompressionContext { + public: + CompressionContext(); + ~CompressionContext(); + + CompressionContext(CompressionContext&& other) noexcept; + CompressionContext& operator=(CompressionContext&& other) noexcept; + + CompressionContext(const CompressionContext&) = delete; + CompressionContext& operator=(const CompressionContext&) = delete; + + private: + friend size_t Compress(Source* reader, Sink* writer, + CompressionOptions options, CompressionContext* ctx); + + internal::WorkingMemory* working_memory_; + }; + // ------------------------------------------------------------------------ // Generic compression/decompression routines. // ------------------------------------------------------------------------ @@ -84,6 +115,11 @@ namespace snappy { size_t Compress(Source* reader, Sink* writer, CompressionOptions options); + // Same as the above, but uses the working memory of "*ctx" instead of + // allocating it internally. See CompressionContext. + size_t Compress(Source* reader, Sink* writer, CompressionOptions options, + CompressionContext* ctx); + // Find the uncompressed length of the given stream, as given by the header. // Note that the true length could deviate from this; the stream could e.g. // be truncated. @@ -165,6 +201,12 @@ namespace snappy { void RawCompress(const char* input, size_t input_length, char* compressed, size_t* compressed_length, CompressionOptions options); + // Same as the above, but uses the working memory of "*ctx" instead of + // allocating it internally. See CompressionContext. + void RawCompress(const char* input, size_t input_length, char* compressed, + size_t* compressed_length, CompressionOptions options, + CompressionContext* ctx); + // Same as `RawCompress` above but taking an `iovec` array as input. Note that // `uncompressed_length` is the total number of bytes to be read from the // elements of `iov` (_not_ the number of elements in `iov`). diff --git a/snappy_unittest.cc b/snappy_unittest.cc index 2d04200..2ee6e22 100644 --- a/snappy_unittest.cc +++ b/snappy_unittest.cc @@ -543,6 +543,58 @@ TEST(Snappy, RandomData) { } } +TEST(Snappy, CompressionContext) { + std::minstd_rand0 rng(snappy::GetFlag(FLAGS_test_random_seed)); + std::uniform_int_distribution uniform_byte(0, 255); + + // A single context, reused across every compression below. + CompressionContext ctx; + + const size_t sizes[] = {0, + 1, + 100, + kBlockSize - 1, + kBlockSize, + kBlockSize + 1, + 2 * kBlockSize, + (1 << 20) + 17}; + for (int level = CompressionOptions::MinCompressionLevel(); + level <= CompressionOptions::MaxCompressionLevel(); ++level) { + CompressionOptions options(level); + for (size_t len : sizes) { + for (bool compressible : {true, false}) { + std::string input; + input.reserve(len); + while (input.size() < len) { + input.push_back(compressible + ? static_cast('a' + input.size() % 4) + : static_cast(uniform_byte(rng))); + } + + std::string plain(MaxCompressedLength(len), '\0'); + size_t plain_len = 0; + RawCompress(input.data(), input.size(), &plain[0], &plain_len, options); + plain.resize(plain_len); + + std::string with_context(MaxCompressedLength(len), '\0'); + size_t with_context_len = 0; + RawCompress(input.data(), input.size(), &with_context[0], + &with_context_len, options, &ctx); + with_context.resize(with_context_len); + + // Compressing with a reused context must produce output identical to + // the context-free API. + EXPECT_EQ(plain, with_context) << "level=" << level << " len=" << len + << " compressible=" << compressible; + + std::string uncompressed; + EXPECT_TRUE(Uncompress(with_context, &uncompressed)); + EXPECT_EQ(input, uncompressed); + } + } + } +} + TEST(Snappy, FourByteOffset) { // The new compressor cannot generate four-byte offsets since // it chops up the input into 32KB pieces. So we hand-emit the From 5b0152edfad4c20670e75179d60ceb2f7fb9b1ea Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Wed, 29 Jul 2026 08:37:56 -0400 Subject: [PATCH 3/3] Support caller-provided memory for CompressionContext Callers that must avoid heap allocation entirely can now construct a CompressionContext over a caller-provided workspace of WorkspaceSize() bytes. Internally, WorkingMemory gains a non-allocating constructor laying out its scratch space in a caller-provided buffer, and RequiredSize() factoring out the size computation its allocating constructor already performed. --- snappy-internal.h | 9 +++++++ snappy.cc | 67 +++++++++++++++++++++++++++++++++++++++------- snappy.h | 20 ++++++++++++++ snappy_unittest.cc | 52 +++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 9 deletions(-) diff --git a/snappy-internal.h b/snappy-internal.h index f68ab77..582e886 100644 --- a/snappy-internal.h +++ b/snappy-internal.h @@ -137,8 +137,16 @@ inline V128 V128_DupChar(char c) { return vdupq_n_u8(c); } class WorkingMemory { public: explicit WorkingMemory(size_t input_size); + + // Non-allocating: lays out the scratch space in the caller-provided + // buffer, which must be at least RequiredSize(input_size) bytes, aligned + // at least as strictly as uint16_t, and must outlive "*this". + WorkingMemory(size_t input_size, char* buffer); ~WorkingMemory(); + // The buffer size required by the non-allocating constructor above. + static size_t RequiredSize(size_t input_size); + // Allocates and clears a hash table using memory in "*this", // stores the number of buckets in "*table_size" and returns a pointer to // the base of the hash table. @@ -149,6 +157,7 @@ class WorkingMemory { private: char* mem_; // the allocated memory, never nullptr size_t size_; // the size of the allocated memory, never 0 + bool owns_mem_; // whether the destructor should free mem_ uint16_t* table_; // the pointer to the hashtable char* input_; // the pointer to the input scratch buffer char* output_; // the pointer to the output scratch buffer diff --git a/snappy.cc b/snappy.cc index efca3af..69cc209 100644 --- a/snappy.cc +++ b/snappy.cc @@ -76,6 +76,7 @@ #include #include #include +#include #include #include #include @@ -757,19 +758,36 @@ uint32_t CalculateTableSize(uint32_t input_size) { } // namespace namespace internal { -WorkingMemory::WorkingMemory(size_t input_size) { +size_t WorkingMemory::RequiredSize(size_t input_size) { const size_t max_fragment_size = std::min(input_size, kBlockSize); const size_t table_size = CalculateTableSize(max_fragment_size); - size_ = table_size * sizeof(*table_) + max_fragment_size + - MaxCompressedLength(max_fragment_size); - mem_ = std::allocator().allocate(size_); + return table_size * sizeof(uint16_t) + max_fragment_size + + MaxCompressedLength(max_fragment_size); +} + +WorkingMemory::WorkingMemory(size_t input_size) + : WorkingMemory(input_size, + std::allocator().allocate(RequiredSize(input_size))) { + owns_mem_ = true; +} + +WorkingMemory::WorkingMemory(size_t input_size, char* buffer) { + assert(buffer != nullptr); + assert(reinterpret_cast(buffer) % alignof(uint16_t) == 0); + const size_t max_fragment_size = std::min(input_size, kBlockSize); + const size_t table_size = CalculateTableSize(max_fragment_size); + mem_ = buffer; + size_ = RequiredSize(input_size); + owns_mem_ = false; table_ = reinterpret_cast(mem_); input_ = mem_ + table_size * sizeof(*table_); output_ = input_ + max_fragment_size; } WorkingMemory::~WorkingMemory() { - std::allocator().deallocate(mem_, size_); + if (owns_mem_) { + std::allocator().deallocate(mem_, size_); + } } uint16_t* WorkingMemory::GetHashTable(size_t fragment_size, @@ -1953,20 +1971,51 @@ size_t Compress(Source* reader, Sink* writer, CompressionOptions options, } CompressionContext::CompressionContext() - : working_memory_(new internal::WorkingMemory(kBlockSize)) {} + : working_memory_(new internal::WorkingMemory(kBlockSize)), + owns_working_memory_(true) {} + +size_t CompressionContext::WorkspaceSize() { + return sizeof(internal::WorkingMemory) + + internal::WorkingMemory::RequiredSize(kBlockSize); +} + +CompressionContext::CompressionContext(void* workspace, size_t workspace_size) + : owns_working_memory_(false) { + assert(workspace != nullptr); + assert(workspace_size >= WorkspaceSize()); + assert(reinterpret_cast(workspace) % + alignof(internal::WorkingMemory) == + 0); + (void)workspace_size; + char* base = static_cast(workspace); + working_memory_ = new (base) internal::WorkingMemory( + kBlockSize, base + sizeof(internal::WorkingMemory)); +} + +void CompressionContext::Reset() { + if (working_memory_ == nullptr) return; + if (owns_working_memory_) { + delete working_memory_; + } else { + working_memory_->~WorkingMemory(); + } + working_memory_ = nullptr; +} -CompressionContext::~CompressionContext() { delete working_memory_; } +CompressionContext::~CompressionContext() { Reset(); } CompressionContext::CompressionContext(CompressionContext&& other) noexcept - : working_memory_(other.working_memory_) { + : working_memory_(other.working_memory_), + owns_working_memory_(other.owns_working_memory_) { other.working_memory_ = nullptr; } CompressionContext& CompressionContext::operator=( CompressionContext&& other) noexcept { if (this != &other) { - delete working_memory_; + Reset(); working_memory_ = other.working_memory_; + owns_working_memory_ = other.owns_working_memory_; other.working_memory_ = nullptr; } return *this; diff --git a/snappy.h b/snappy.h index 423142b..5413153 100644 --- a/snappy.h +++ b/snappy.h @@ -88,7 +88,18 @@ namespace snappy { // may only be destroyed or assigned to. class CompressionContext { public: + // Allocates the working memory on the heap. CompressionContext(); + + // Constructs a context whose working memory is placed in the + // caller-provided "workspace" instead of being heap-allocated; the + // library performs no allocation at all. + // + // REQUIRES: "workspace" points to at least "workspace_size" bytes with + // "workspace_size >= WorkspaceSize()", is suitably aligned for any + // object type (as if returned by malloc), and outlives "*this". + CompressionContext(void* workspace, size_t workspace_size); + ~CompressionContext(); CompressionContext(CompressionContext&& other) noexcept; @@ -97,11 +108,20 @@ namespace snappy { CompressionContext(const CompressionContext&) = delete; CompressionContext& operator=(const CompressionContext&) = delete; + // The workspace size required by the non-allocating constructor above. + static size_t WorkspaceSize(); + private: friend size_t Compress(Source* reader, Sink* writer, CompressionOptions options, CompressionContext* ctx); + // Destroys the working memory as appropriate for how it was created + // (delete if heap-allocated, in-place destruction if placement-constructed + // in a caller-provided workspace). + void Reset(); + internal::WorkingMemory* working_memory_; + bool owns_working_memory_; }; // ------------------------------------------------------------------------ diff --git a/snappy_unittest.cc b/snappy_unittest.cc index 2ee6e22..4f44cbf 100644 --- a/snappy_unittest.cc +++ b/snappy_unittest.cc @@ -595,6 +595,58 @@ TEST(Snappy, CompressionContext) { } } +TEST(Snappy, CompressionContextStaticWorkspace) { + // The library performs no allocation for a context constructed over a + // caller-provided workspace. + std::vector workspace(CompressionContext::WorkspaceSize()); + CompressionContext static_ctx(workspace.data(), workspace.size()); + CompressionContext heap_ctx; + + const size_t sizes[] = {0, 1, kBlockSize - 1, kBlockSize + 1, + 2 * kBlockSize + 17}; + for (size_t len : sizes) { + std::string input; + input.reserve(len); + while (input.size() < len) { + input.push_back(static_cast('a' + input.size() % 7)); + } + + std::string with_static(MaxCompressedLength(len), '\0'); + size_t with_static_len = 0; + RawCompress(input.data(), input.size(), &with_static[0], &with_static_len, + CompressionOptions{}, &static_ctx); + with_static.resize(with_static_len); + + std::string with_heap(MaxCompressedLength(len), '\0'); + size_t with_heap_len = 0; + RawCompress(input.data(), input.size(), &with_heap[0], &with_heap_len, + CompressionOptions{}, &heap_ctx); + with_heap.resize(with_heap_len); + + EXPECT_EQ(with_static, with_heap) << "len=" << len; + + std::string uncompressed; + EXPECT_TRUE(Uncompress(with_static, &uncompressed)); + EXPECT_EQ(input, uncompressed); + } + + // Both context flavors keep working after being moved. + CompressionContext moved_static(std::move(static_ctx)); + CompressionContext moved_heap = std::move(heap_ctx); + const std::string input = "the quick brown fox jumps over the lazy dog"; + std::string a(MaxCompressedLength(input.size()), '\0'); + std::string b(MaxCompressedLength(input.size()), '\0'); + size_t a_len = 0; + size_t b_len = 0; + RawCompress(input.data(), input.size(), &a[0], &a_len, CompressionOptions{}, + &moved_static); + RawCompress(input.data(), input.size(), &b[0], &b_len, CompressionOptions{}, + &moved_heap); + a.resize(a_len); + b.resize(b_len); + EXPECT_EQ(a, b); +} + TEST(Snappy, FourByteOffset) { // The new compressor cannot generate four-byte offsets since // it chops up the input into 32KB pieces. So we hand-emit the