From e7aad0d5b664006d64a42437b913e40dad6c0f27 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 5 May 2026 16:28:05 -0700 Subject: [PATCH] Add auto-segmentation to the serial profile (#730) Summary: The `serial` profile can no longer ingest inputs larger than 4 GiB, likely due to the internal LZ engine being updated from Zstandard to the new implementation. This change applies the same approach used by numeric profiles: large inputs are automatically split into smaller, more manageable chunks. The default chunk size is 16 MiB, and users can override it with `--chunk-size`. A new standard segmenter, `SEGM_serial`, is added and modeled directly on `SEGM_numFromSerial`. It splits serial input by byte size, using a default of 16 MiB. The chunk size can also be configured through the new public local integer parameter `ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM`. Each chunk is forwarded to a successor graph. If no successor is provided, the segmenter falls back to `ZL_GRAPH_COMPRESS_GENERIC`. As with the other segmenters, when `formatVersion < ZL_CHUNK_VERSION_MIN`, the segmenter emits a single chunk so the resulting frame remains decodable by older clients. This also adds: - A new public macro, `ZL_SEGMENT_SERIAL` - Two builder helpers, `ZL_Compressor_buildSerialSegmenter[2]`, in `include/openzl/codecs/zl_segmenters.h` - A typed C++ wrapper, `graphs::SegmentSerial`, modeled on `graphs::SDDL2` The CLI `serial` profile now reads `--chunk-size` and wraps its existing `ACE+LZ` graph with the new segmenter. As a result, it now sets `supportsChunkSize_ = true`. Finally, the new standard graph ID, `ZL_StandardGraphID_segment_serial`, is appended to the public enum before `_public_end`, preserving all existing wire-format IDs. Reviewed By: kevinjzhang Differential Revision: D103759746 --- cli/tests/BUCK | 28 ++ cli/tests/cli_integration_tests.py | 57 +++ cli/utils/compress_profiles.cpp | 12 +- cpp/include/openzl/cpp/Codecs.hpp | 1 + .../openzl/cpp/codecs/SegmentSerial.hpp | 74 ++++ cpp/tests/TestCodecs.cpp | 33 ++ include/openzl/codecs/zl_segmenters.h | 55 ++- include/openzl/zl_graphs.h | 2 + src/openzl/compress/graph_registry.c | 2 + .../compress/segmenters/segmenter_serial.c | 124 ++++++ .../compress/segmenters/segmenter_serial.h | 37 ++ tests/registry/OpenZLComponents.h | 4 + tests/registry/components/SegmentSerial.cpp | 100 +++++ tests/round_trip/test_segmenter.cpp | 403 +++++++++++++++++- 14 files changed, 926 insertions(+), 6 deletions(-) create mode 100644 cpp/include/openzl/cpp/codecs/SegmentSerial.hpp create mode 100644 src/openzl/compress/segmenters/segmenter_serial.c create mode 100644 src/openzl/compress/segmenters/segmenter_serial.h create mode 100644 tests/registry/components/SegmentSerial.cpp diff --git a/cli/tests/BUCK b/cli/tests/BUCK index 27cb1743e..921d1e9ce 100644 --- a/cli/tests/BUCK +++ b/cli/tests/BUCK @@ -229,6 +229,34 @@ custom_unittest( ], ) +custom_unittest( + name = "serial_segmentation_default_test", + command = [ + "$(location :integration_test_bin)", + "$(location ..:zli)", + "SerialSegmentationTest.test_serial_default_chunk_size", + ], + type = "simple", + deps = [ + "..:zli", + ":integration_test_bin", + ], +) + +custom_unittest( + name = "serial_segmentation_chunk_size_test", + command = [ + "$(location :integration_test_bin)", + "$(location ..:zli)", + "SerialSegmentationTest.test_serial_with_chunk_size", + ], + type = "simple", + deps = [ + "..:zli", + ":integration_test_bin", + ], +) + custom_unittest( name = "strict_mode_permissive_test", command = [ diff --git a/cli/tests/cli_integration_tests.py b/cli/tests/cli_integration_tests.py index 728d3c143..4522119cc 100644 --- a/cli/tests/cli_integration_tests.py +++ b/cli/tests/cli_integration_tests.py @@ -930,6 +930,63 @@ def test_numeric_profiles_with_chunk_size(self) -> None: ) +class SerialSegmentationTest(unittest.TestCase): + """ + Test case for the serial profile's auto-segmentation via the CLI. + + Generates raw byte data, compresses with the `serial` profile, decompresses, + and verifies round-trip correctness with and without --chunk-size. + """ + + def setUp(self) -> None: + import shutil + import tempfile + + self.tmpdir = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(self.tmpdir, True)) + + # Generate ~2MB of data so --chunk-size 1M triggers multi-chunk + # segmentation (2 chunks). + target_bytes = 2 * 1000 * 1000 # 2 MB + data = bytes((i % 256) for i in range(target_bytes)) + self.input_path: str = os.path.join(self.tmpdir, "serial.bin") + with open(self.input_path, "wb") as f: + f.write(data) + + def _round_trip(self, extra_args: str | None = None) -> None: + compressed_path = self.input_path + ".zl" + decompressed_path = self.input_path + ".rt" + + compressor_info = CompressorInfo( + compressor_str="serial", + compressor_type=CompressorType.PROFILE, + ) + execute_compress( + file_to_compress_path=self.input_path, + compressor_info=compressor_info, + compressed_file_path=compressed_path, + extra_args=extra_args, + ) + execute_decompress( + compressed_file_path=compressed_path, + decompressed_file_path=decompressed_path, + ) + from file_utils import file_contents_match + + self.assertTrue( + file_contents_match(self.input_path, decompressed_path), + f"Round-trip failed for serial profile on {self.input_path}", + ) + + def test_serial_default_chunk_size(self) -> None: + """Default chunk size (16 MiB) on 2MB data → single-chunk segmentation.""" + self._round_trip() + + def test_serial_with_chunk_size(self) -> None: + """--chunk-size 1M on 2MB data forces multi-chunk segmentation.""" + self._round_trip(extra_args="--chunk-size 1M") + + class StrictModeTest(_CompressDecompressBaseTest): """ Test case for strict mode behavior. diff --git a/cli/utils/compress_profiles.cpp b/cli/utils/compress_profiles.cpp index 46c327fad..9fa5fb085 100644 --- a/cli/utils/compress_profiles.cpp +++ b/cli/utils/compress_profiles.cpp @@ -319,10 +319,16 @@ compressProfiles() mp[kSerialName] = std::make_shared( kSerialName, "Serial data (aka raw bytes)", - [](ZL_Compressor* compressor, void*, const ProfileArgs&) { - return ZL_Compressor_buildACEGraphWithDefault( + [](ZL_Compressor* compressor, void*, const ProfileArgs& args) { + ZL_GraphID inner = ZL_Compressor_buildACEGraphWithDefault( compressor, ZL_GRAPH_LZ); - }); + size_t chunkSize = args.chunkSize().value_or( + ZL_DEFAULT_SEGMENTER_CHUNK_BYTE_SIZE); + return ZL_Compressor_buildSerialSegmenter( + compressor, chunkSize, inner); + }, + nullptr, + true); std::string kPytorchName = "pytorch"; mp[kPytorchName] = std::make_shared( diff --git a/cpp/include/openzl/cpp/Codecs.hpp b/cpp/include/openzl/cpp/Codecs.hpp index 6d57adff9..c52959b04 100644 --- a/cpp/include/openzl/cpp/Codecs.hpp +++ b/cpp/include/openzl/cpp/Codecs.hpp @@ -31,6 +31,7 @@ #include "openzl/cpp/codecs/RangePack.hpp" // IWYU pragma: export #include "openzl/cpp/codecs/SDDL.hpp" // IWYU pragma: export #include "openzl/cpp/codecs/SDDL2.hpp" // IWYU pragma: export +#include "openzl/cpp/codecs/SegmentSerial.hpp" // IWYU pragma: export #include "openzl/cpp/codecs/Sentinel.hpp" // IWYU pragma: export #include "openzl/cpp/codecs/Split.hpp" // IWYU pragma: export #include "openzl/cpp/codecs/SplitByStruct.hpp" // IWYU pragma: export diff --git a/cpp/include/openzl/cpp/codecs/SegmentSerial.hpp b/cpp/include/openzl/cpp/codecs/SegmentSerial.hpp new file mode 100644 index 000000000..160752bab --- /dev/null +++ b/cpp/include/openzl/cpp/codecs/SegmentSerial.hpp @@ -0,0 +1,74 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +#pragma once + +#include "openzl/codecs/zl_segmenters.h" +#include "openzl/cpp/Compressor.hpp" +#include "openzl/cpp/codecs/Graph.hpp" +#include "openzl/cpp/codecs/Metadata.hpp" + +namespace openzl { +namespace graphs { + +class SegmentSerial : public Graph { + public: + static constexpr GraphID graph = ZL_SEGMENT_SERIAL; + + static constexpr GraphMetadata<1> metadata = { + .inputs = { InputMetadata{ .typeMask = TypeMask::Serial } }, + .description = + "Auto-segmenting graph for serial inputs. Chunks the input into " + "independently compressed segments and forwards each chunk to a " + "successor graph (defaults to ZL_GRAPH_COMPRESS_GENERIC). " + "Defaults to 16 MiB chunking when no chunk-size hint is provided; " + "treats a 0 hint as the default chunk size.", + }; + + explicit SegmentSerial(GraphID successor, size_t chunkByteSize = 0) + : successor_(successor), chunkByteSize_(chunkByteSize) + { + } + + ~SegmentSerial() override = default; + + SegmentSerial(const SegmentSerial&) = default; + SegmentSerial& operator=(const SegmentSerial&) = default; + SegmentSerial(SegmentSerial&&) = default; + SegmentSerial& operator=(SegmentSerial&&) = default; + + GraphID baseGraph() const override + { + return graph; + } + + /// Delegates to ZL_Compressor_buildSerialSegmenter2 so the C builder + /// is the single source of truth for chunkByteSize validation, default + /// substitution, and the resulting parameter_invalid error code. + GraphID parameterize(Compressor& compressor) const override + { + return compressor.unwrap(ZL_Compressor_buildSerialSegmenter2( + compressor.get(), chunkByteSize_, successor_)); + } + + /// Used by setMultiInputDestination in FunctionGraph contexts. Mirrors + /// the C builder's wire-level parameterization but without validation — + /// callers in that path must pass a chunkByteSize that fits in int. + poly::optional parameters() const override + { + LocalParams lp; + if (chunkByteSize_ != 0) { + lp.addIntParam( + ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM, + static_cast(chunkByteSize_)); + } + return GraphParameters{ .customGraphs = { { successor_ } }, + .localParams = std::move(lp) }; + } + + private: + GraphID successor_; + size_t chunkByteSize_; +}; + +} // namespace graphs +} // namespace openzl diff --git a/cpp/tests/TestCodecs.cpp b/cpp/tests/TestCodecs.cpp index d5ece5459..07206b93b 100644 --- a/cpp/tests/TestCodecs.cpp +++ b/cpp/tests/TestCodecs.cpp @@ -1,5 +1,7 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. +#include + #include #include "cpp/tests/TestUtils.hpp" @@ -45,4 +47,35 @@ TEST_F(TestCodecs, lz4_hc) compressor_.selectStartingGraph(graph); auto compressed = testRoundTrip(compressor_, Input::refSerial(data)); } + +TEST_F(TestCodecs, segmentSerial_defaultChunkSize) +{ + /* chunkByteSize = 0 sentinel: use the segmenter's built-in default. */ + std::string data(4096, 'a'); + auto graph = graphs::SegmentSerial(ZL_GRAPH_COMPRESS_GENERIC) + .parameterize(compressor_); + compressor_.selectStartingGraph(graph); + auto compressed = testRoundTrip(compressor_, Input::refSerial(data)); +} + +TEST_F(TestCodecs, segmentSerial_explicitChunkSize) +{ + /* Explicit chunk size at the minimum threshold must round-trip. */ + std::string data(ZL_MIN_CHUNK_SIZE * 2, 'a'); + auto graph = + graphs::SegmentSerial(ZL_GRAPH_COMPRESS_GENERIC, ZL_MIN_CHUNK_SIZE) + .parameterize(compressor_); + compressor_.selectStartingGraph(graph); + auto compressed = testRoundTrip(compressor_, Input::refSerial(data)); +} + +TEST_F(TestCodecs, segmentSerial_chunkSizeOverflowThrows) +{ + /* The C builder rejects chunk sizes that would not fit in int; the C++ + * wrapper surfaces that rejection as a typed Exception at parameterize + * time (construction itself does not validate). */ + graphs::SegmentSerial wrapper( + ZL_GRAPH_COMPRESS_GENERIC, static_cast(INT_MAX) + 1); + EXPECT_THROW(wrapper.parameterize(compressor_), Exception); +} } // namespace openzl diff --git a/include/openzl/codecs/zl_segmenters.h b/include/openzl/codecs/zl_segmenters.h index 42a617448..928b553ae 100644 --- a/include/openzl/codecs/zl_segmenters.h +++ b/include/openzl/codecs/zl_segmenters.h @@ -15,8 +15,12 @@ extern "C" { #endif -/** Pass as successorGraph to use the built-in default pipeline - * (interpret serial as numeric + compress). */ +/** Pass as successorGraph to let the segmenter substitute its built-in + * default successor. Each segmenter family resolves this to its own + * default — see the corresponding builder's documentation + * (e.g. ZL_Compressor_buildNumFromSerialSegmenter resolves to + * ZL_GRAPH_INTERPRET_NUMxx_COMPRESS; ZL_Compressor_buildSerialSegmenter + * resolves to ZL_GRAPH_COMPRESS_GENERIC). */ #define ZL_SEGMENTER_DEFAULT_SUCCESSOR ZL_GRAPH_ILLEGAL // Numeric segmenter (numeric input) @@ -41,6 +45,22 @@ extern "C" { #define ZL_SEGMENT_NUM64_FROM_SERIAL \ ZL_MAKE_GRAPH_ID(ZL_StandardGraphID_segment_num64_from_serial) +// Serial-input segmenter (no element-width interpretation) +// Input : 1 stream of serial data +// Result : chunks the serial input using default chunk size, +// and forwards each chunk to the default successor +// ZL_GRAPH_COMPRESS_GENERIC. +// Both chunk size and successor can be overridden via +// ZL_Compressor_buildSerialSegmenter(). +#define ZL_SEGMENT_SERIAL ZL_MAKE_GRAPH_ID(ZL_StandardGraphID_segment_serial) + +/** + * Local int parameter ID for the serial segmenter's chunk byte size. + * When omitted, ZL_SEGMENT_SERIAL falls back to + * ZL_DEFAULT_SEGMENTER_CHUNK_BYTE_SIZE. + */ +#define ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM 7700 + /** * @brief Register a serial-numeric segmenter. * @@ -78,6 +98,37 @@ ZL_Compressor_buildNumFromSerialSegmenter2( size_t chunkByteSize, ZL_GraphID successorGraph); +/** + * @brief Register a serial segmenter. + * + * Creates a parameterized segmenter that accepts serial input, chunks it + * by @p chunkByteSize, and forwards each chunk to @p successorGraph. + * + * @param compressor The compressor to register with + * @param chunkByteSize Maximum chunk size in bytes. Pass 0 to use the + * built-in default (ZL_DEFAULT_SEGMENTER_CHUNK_BYTE_SIZE). Otherwise + * must be in [ZL_MIN_CHUNK_SIZE, INT_MAX]; smaller positive values + * are rejected because ZL_compressBound() assumes chunks of at least + * ZL_MIN_CHUNK_SIZE bytes. + * @param successorGraph The graph to process each chunk, or + * ZL_SEGMENTER_DEFAULT_SUCCESSOR to use ZL_GRAPH_COMPRESS_GENERIC + * @return The registered segmenter graph ID, or ZL_GRAPH_ILLEGAL on error + */ +ZL_GraphID ZL_Compressor_buildSerialSegmenter( + ZL_Compressor* compressor, + size_t chunkByteSize, + ZL_GraphID successorGraph); + +/** + * Same as ZL_Compressor_buildSerialSegmenter(), but returns a + * ZL_RESULT_OF(ZL_GraphID) for richer error reporting. + */ +ZL_RESULT_OF(ZL_GraphID) +ZL_Compressor_buildSerialSegmenter2( + ZL_Compressor* compressor, + size_t chunkByteSize, + ZL_GraphID successorGraph); + #if defined(__cplusplus) } #endif diff --git a/include/openzl/zl_graphs.h b/include/openzl/zl_graphs.h index f63bae594..6051d30e1 100644 --- a/include/openzl/zl_graphs.h +++ b/include/openzl/zl_graphs.h @@ -43,6 +43,8 @@ typedef enum { ZL_StandardGraphID_lz, + ZL_StandardGraphID_segment_serial, + ZL_StandardGraphID_public_end // last id, used to detect end of public // range } ZL_StandardGraphID; diff --git a/src/openzl/compress/graph_registry.c b/src/openzl/compress/graph_registry.c index d9c5dd43c..68f1d5daf 100644 --- a/src/openzl/compress/graph_registry.c +++ b/src/openzl/compress/graph_registry.c @@ -21,6 +21,7 @@ #include "openzl/compress/implicit_conversion.h" // ICONV_isCompatible for type checking #include "openzl/compress/private_nodes.h" // ZL_PrivateStandardGraphID_end, private node ID definitions #include "openzl/compress/segmenters/segmenter_numeric.h" // SEGM_numeric_desc +#include "openzl/compress/segmenters/segmenter_serial.h" // SEGM_serial_desc #include "openzl/compress/selector.h" // SelectorCtx, ZL_SelectorFn, SelCtx_* functions #include "openzl/compress/selectors/ml/ml_selector_graph.h" // ZL_MLSel_dynGraph #include "openzl/compress/selectors/selector_compress.h" // SI_selector_compress, SI_selector_compress_* functions @@ -149,6 +150,7 @@ const InternalGraphDesc GR_standardGraphs[ZL_PrivateStandardGraphID_end] = { REGISTER_SEGMENTER(ZL_StandardGraphID_segment_num16_from_serial, SEGM_NUM_FROM_SERIAL_DESC(2, 16, ZL_PrivateStandardGraphID_interpret_num16_compress)), REGISTER_SEGMENTER(ZL_StandardGraphID_segment_num32_from_serial, SEGM_NUM_FROM_SERIAL_DESC(4, 32, ZL_PrivateStandardGraphID_interpret_num32_compress)), REGISTER_SEGMENTER(ZL_StandardGraphID_segment_num64_from_serial, SEGM_NUM_FROM_SERIAL_DESC(8, 64, ZL_PrivateStandardGraphID_interpret_num64_compress)), + REGISTER_SEGMENTER(ZL_StandardGraphID_segment_serial, SEGM_SERIAL_DESC), REGISTER_SELECTOR(ZL_StandardGraphID_select_numeric, "!zl.select_numeric", SI_selector_numeric, ZL_Type_numeric), REGISTER_MIGRAPH(ZL_StandardGraphID_clustering, MIGRAPH_CLUSTERING), REGISTER_DYNAMIC_GRAPH(ZL_StandardGraphID_simple_data_description_language, "!zl.sddl", ZL_Type_serial, ZL_SDDL_dynGraph), diff --git a/src/openzl/compress/segmenters/segmenter_serial.c b/src/openzl/compress/segmenters/segmenter_serial.c new file mode 100644 index 000000000..f45ea9ea3 --- /dev/null +++ b/src/openzl/compress/segmenters/segmenter_serial.c @@ -0,0 +1,124 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +#include "openzl/compress/segmenters/segmenter_serial.h" +#include "openzl/codecs/zl_generic.h" // ZL_GRAPH_COMPRESS_GENERIC +#include "openzl/codecs/zl_segmenters.h" // ZL_SEGMENT_SERIAL +#include "openzl/common/assertion.h" +#include "openzl/zl_compress.h" // ZL_MIN_CHUNK_SIZE +#include "openzl/zl_compressor.h" +#include "openzl/zl_input.h" +#include "openzl/zl_selector.h" // ZL_LP_INVALID_PARAMID +#include "openzl/zl_version.h" + +#include // INT_MAX + +ZL_Report SEGM_serial(ZL_Segmenter* sctx) +{ + ZL_RESULT_DECLARE_SCOPE_REPORT(sctx); + size_t const numInputs = ZL_Segmenter_numInputs(sctx); + ZL_ASSERT_EQ(numInputs, 1); + const ZL_Input* const input = ZL_Segmenter_getInput(sctx, 0); + ZL_ASSERT_NN(input); + + /* read chunk size from local params, or use default. Reject negative + * paramValue before the size_t cast — a corrupted compressor could + * otherwise wrap to a huge size and silently disable chunking. */ + ZL_IntParam const chunkParam = ZL_Segmenter_getLocalIntParam( + sctx, ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM); + if (chunkParam.paramId != ZL_LP_INVALID_PARAMID) { + ZL_ERR_IF_LT(chunkParam.paramValue, 0, parameter_invalid); + } + size_t const chunkByteSizeMax = + (chunkParam.paramId != ZL_LP_INVALID_PARAMID) + ? (size_t)chunkParam.paramValue + : ZL_DEFAULT_SEGMENTER_CHUNK_BYTE_SIZE; + ZL_ERR_IF_NOT(chunkByteSizeMax > 0, parameter_invalid); + + /* retrieve successor graph (release-mode-checked: a corrupted compressor + * could parameterize to nbCustomGraphs == 0 and cause an OOB read) */ + ZL_GraphIDList const customGraphs = ZL_Segmenter_getCustomGraphs(sctx); + ZL_ERR_IF_LT(customGraphs.nbGraphIDs, 1, parameter_invalid); + ZL_GraphID const headGraph = customGraphs.graphids[0]; + + size_t totalBytes = ZL_Input_contentSize(input); + /* Old wire formats cannot encode segmented output. */ + if (ZL_Segmenter_getCParam(sctx, ZL_CParam_formatVersion) + < ZL_CHUNK_VERSION_MIN) { + ZL_ERR_IF_ERR(ZL_Segmenter_processChunk( + sctx, &totalBytes, 1, headGraph, NULL)); + return ZL_returnSuccess(); + } + + while (totalBytes > chunkByteSizeMax) { + ZL_ERR_IF_ERR(ZL_Segmenter_processChunk( + sctx, &chunkByteSizeMax, 1, headGraph, NULL)); + totalBytes -= chunkByteSizeMax; + } + ZL_ASSERT_LE(totalBytes, chunkByteSizeMax); + ZL_ERR_IF_ERR( + ZL_Segmenter_processChunk(sctx, &totalBytes, 1, headGraph, NULL)); + + return ZL_returnSuccess(); +} + +/* ---- Registration helper ---- */ + +ZL_RESULT_OF(ZL_GraphID) +ZL_Compressor_buildSerialSegmenter2( + ZL_Compressor* compressor, + size_t chunkByteSize, + ZL_GraphID successorGraph) +{ + ZL_RESULT_DECLARE_SCOPE(ZL_GraphID, compressor); + + /* Use default successor if none specified */ + if (!ZL_GraphID_isValid(successorGraph)) { + successorGraph = ZL_GRAPH_COMPRESS_GENERIC; + } + + /* chunkByteSize == 0 means "use the segmenter's built-in default" + * (matches the SDDL2 and C++ wrapper conventions). */ + if (chunkByteSize == 0) { + chunkByteSize = ZL_DEFAULT_SEGMENTER_CHUNK_BYTE_SIZE; + } + /* Enforce ZL_MIN_CHUNK_SIZE: ZL_compressBound() assumes chunks of at + * least this size; smaller chunks may produce output exceeding the + * bound. */ + ZL_ERR_IF_LT(chunkByteSize, ZL_MIN_CHUNK_SIZE, parameter_invalid); + /* Must fit in int (ZL_IntParam::paramValue is int) */ + ZL_ERR_IF_NOT(chunkByteSize <= (size_t)INT_MAX, parameter_invalid); + + /* Parameterize with chunk size and successor graph */ + ZL_IntParam intParams[] = { + { .paramId = ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM, + .paramValue = (int)chunkByteSize }, + }; + ZL_LocalParams localParams = { + .intParams = { .intParams = intParams, .nbIntParams = 1 }, + }; + ZL_ParameterizedGraphDesc const desc = { + .graph = ZL_SEGMENT_SERIAL, + .customGraphs = &successorGraph, + .nbCustomGraphs = 1, + .localParams = &localParams, + }; + ZL_GraphID const result = + ZL_Compressor_registerParameterizedGraph(compressor, &desc); + ZL_ERR_IF_NOT(ZL_GraphID_isValid(result), graph_invalid); + return ZL_WRAP_VALUE(result); +} + +ZL_GraphID ZL_Compressor_buildSerialSegmenter( + ZL_Compressor* compressor, + size_t chunkByteSize, + ZL_GraphID successorGraph) +{ + ZL_RESULT_OF(ZL_GraphID) + res = ZL_Compressor_buildSerialSegmenter2( + compressor, chunkByteSize, successorGraph); + if (ZL_RES_isError(res)) { + return ZL_GRAPH_ILLEGAL; + } else { + return ZL_RES_value(res); + } +} diff --git a/src/openzl/compress/segmenters/segmenter_serial.h b/src/openzl/compress/segmenters/segmenter_serial.h new file mode 100644 index 000000000..4619acd44 --- /dev/null +++ b/src/openzl/compress/segmenters/segmenter_serial.h @@ -0,0 +1,37 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +#ifndef ZSTRONG_COMPRESS_SEGMENTERS_SERIAL_H +#define ZSTRONG_COMPRESS_SEGMENTERS_SERIAL_H + +#include "openzl/codecs/zl_segmenters.h" // ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM +#include "openzl/shared/portability.h" +#include "openzl/zl_graphs.h" // ZL_StandardGraphID_compress_generic +#include "openzl/zl_segmenter.h" + +ZL_BEGIN_C_DECLS + +/** + * Segmenter that accepts serial input and chunks it by byte size. + * Chunk size is read from local int param + * ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM, defaulting to + * ZL_DEFAULT_SEGMENTER_CHUNK_BYTE_SIZE. + * Each chunk is forwarded to the first custom graph (the successor). + */ +ZL_Report SEGM_serial(ZL_Segmenter* sctx); + +#define SEGM_SERIAL_DESC \ + { \ + .name = "!zl.segment_serial", \ + .segmenterFn = SEGM_serial, \ + .inputTypeMasks = &(const ZL_Type){ ZL_Type_serial }, \ + .numInputs = 1, \ + .lastInputIsVariable = false, \ + .customGraphs = \ + (const ZL_GraphID[]){ \ + { ZL_StandardGraphID_compress_generic } }, \ + .numCustomGraphs = 1, \ + } + +ZL_END_C_DECLS + +#endif // ZSTRONG_COMPRESS_SEGMENTERS_SERIAL_H diff --git a/tests/registry/OpenZLComponents.h b/tests/registry/OpenZLComponents.h index dd7e0eefc..59f056109 100644 --- a/tests/registry/OpenZLComponents.h +++ b/tests/registry/OpenZLComponents.h @@ -83,6 +83,7 @@ enum class OpenZLComponentID { PartitionBitpack, SegmentNumeric, SegmentNumFromSerial, + SegmentSerial, SentinelByte, SentinelNum, CompressSmallLengths, @@ -157,6 +158,7 @@ std::unique_ptr makeBitSplitBF16Component(); std::unique_ptr makePartitionBitpackComponent(); std::unique_ptr makeSegmentNumericComponent(); std::unique_ptr makeSegmentNumFromSerialComponent(); +std::unique_ptr makeSegmentSerialComponent(); std::unique_ptr makeSentinelByteComponent(); std::unique_ptr makeSentinelNumComponent(); std::unique_ptr makeCompressSmallLengthsComponent(); @@ -285,6 +287,8 @@ inline std::unique_ptr makeOpenZLComponent( return components::makeSegmentNumericComponent(); case OpenZLComponentID::SegmentNumFromSerial: return components::makeSegmentNumFromSerialComponent(); + case OpenZLComponentID::SegmentSerial: + return components::makeSegmentSerialComponent(); case OpenZLComponentID::SentinelByte: return components::makeSentinelByteComponent(); case OpenZLComponentID::SentinelNum: diff --git a/tests/registry/components/SegmentSerial.cpp b/tests/registry/components/SegmentSerial.cpp new file mode 100644 index 000000000..dc9f064ab --- /dev/null +++ b/tests/registry/components/SegmentSerial.cpp @@ -0,0 +1,100 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +#include "openzl/codecs/zl_generic.h" +#include "openzl/codecs/zl_segmenters.h" +#include "openzl/zl_compress.h" // ZL_MIN_CHUNK_SIZE +#include "tests/datagen/structures/CompressibleStringProducer.h" +#include "tests/registry/OpenZLComponents.h" +#include "tests/registry/OpenZLInput.h" + +namespace openzl::tests::components { +namespace { + +class SegmentSerialComponent : public OpenZLComponent { + public: + std::string name() const override + { + return "SegmentSerial"; + } + + int minFormatVersion() const override + { + return ZL_MIN_FORMAT_VERSION; + } + + std::vector predefinedGraphs(Compressor& compressor) const override + { + compressor.selectStartingGraph(ZL_SEGMENT_SERIAL); + return { ZL_SEGMENT_SERIAL }; + } + + std::vector generateGraphs( + Compressor& compressor, + datagen::DataGen& gen, + size_t num) const override + { + std::vector graphs; + graphs.reserve(num); + for (size_t i = 0; i < num; ++i) { + // Pick chunk sizes >= ZL_MIN_CHUNK_SIZE (the public builder + // rejects smaller positive values). Combined with the bare + // predefined graph (default chunking), this exercises both + // single-chunk and multi-chunk paths. + size_t const chunkSizeBytes = gen.usize_range( + "chunk_size_bytes", + ZL_MIN_CHUNK_SIZE, + ZL_MIN_CHUNK_SIZE * 4); + graphs.push_back( + compressor.unwrap(ZL_Compressor_buildSerialSegmenter2( + compressor.get(), + chunkSizeBytes, + ZL_GRAPH_COMPRESS_GENERIC))); + } + return graphs; + } + + std::vector> predefinedInputs() const override + { + std::vector> inputs; + inputs.push_back(SerialOpenZLInput::make("")); + inputs.push_back(SerialOpenZLInput::make(std::string(1, 'a'))); + inputs.push_back(SerialOpenZLInput::make(std::string(64, '\xab'))); + // Larger input with varied bytes to exercise multi-chunk paths. + { + std::string data(8192, '\0'); + for (size_t i = 0; i < data.size(); ++i) { + data[i] = static_cast(i & 0xFF); + } + inputs.push_back(SerialOpenZLInput::make(std::move(data))); + } + return inputs; + } + + std::vector> generateInputs( + datagen::DataGen& gen, + size_t num, + size_t maxInputSize, + const Compressor&, + GraphID) const override + { + std::vector> inputs; + inputs.reserve(num); + for (size_t i = 0; i < num; ++i) { + size_t const size = gen.usize_range("input_size", 0, maxInputSize); + datagen::CompressibleStringProducer producer( + gen.getRandWrapper(), + size, + gen.u32_range("match_prob", 0, 100) / 100.0); + inputs.push_back(SerialOpenZLInput::make(producer("input"))); + } + return inputs; + } +}; + +} // namespace + +std::unique_ptr makeSegmentSerialComponent() +{ + return std::make_unique(); +} +} // namespace openzl::tests::components diff --git a/tests/round_trip/test_segmenter.cpp b/tests/round_trip/test_segmenter.cpp index 0c79cec63..f3ca7ae8f 100644 --- a/tests/round_trip/test_segmenter.cpp +++ b/tests/round_trip/test_segmenter.cpp @@ -1094,7 +1094,7 @@ static ZL_GraphID registerNumFromSerial_u32_singleChunk( compressor, ZL_CParam_formatVersion, g_testVersion))) abort(); ZL_GraphID graph = makeNumericGraph(compressor, 32); - /* 16 MB chunk, input is much smaller */ + /* 16 MiB chunk, input is much smaller */ return ZL_Compressor_buildNumFromSerialSegmenter( compressor, 4, 16 << 20, graph); } @@ -1711,4 +1711,405 @@ TEST(Segmenter, numFromSerial_chunkCount_manyChunks) #endif // ZL_ALLOW_INTROSPECTION +/* ============================================================ */ +/* ======= Pure serial segmenter (ZL_SEGMENT_SERIAL) ====== */ +/* ============================================================ */ + +/* Helper: a simple serial-input compression graph */ +static ZL_GraphID makeSerialGraph(ZL_Compressor*) +{ + return ZL_GRAPH_COMPRESS_GENERIC; +} + +/* --- Round-trip: single chunk (input < chunk size) --- */ + +static ZL_GraphID registerSerial_singleChunk(ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + /* 16 MiB chunk, input is much smaller */ + return ZL_Compressor_buildSerialSegmenter( + compressor, 16 << 20, makeSerialGraph(compressor)); +} + +TEST(Segmenter, serial_singleChunk) +{ + (void)roundTripGen( + ZL_Type_serial, registerSerial_singleChunk, "serial, single chunk"); +} + +/* --- Round-trip with the smallest accepted chunk size --- + * roundTripGen feeds 1376-byte input; with chunkByteSize=ZL_MIN_CHUNK_SIZE + * this is a single-chunk smoke test. Multi-chunk coverage lives in the + * chunkCount_* introspection tests below. */ + +static ZL_GraphID registerSerial_minChunk(ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + return ZL_Compressor_buildSerialSegmenter( + compressor, ZL_MIN_CHUNK_SIZE, makeSerialGraph(compressor)); +} + +TEST(Segmenter, serial_minChunkSize) +{ + (void)roundTripGen( + ZL_Type_serial, + registerSerial_minChunk, + "serial, chunk == ZL_MIN_CHUNK_SIZE (smallest accepted)"); +} + +/* --- Old format: collapse to one chunk regardless of chunk size --- */ + +static ZL_GraphID registerSerial_oldFormat(ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, ZL_CHUNK_VERSION_MIN - 1))) + abort(); + return ZL_Compressor_buildSerialSegmenter( + compressor, ZL_MIN_CHUNK_SIZE, makeSerialGraph(compressor)); +} + +TEST(Segmenter, serial_oldFormat_singleChunkFallback) +{ + (void)roundTripGen( + ZL_Type_serial, + registerSerial_oldFormat, + "serial, old format single-chunk fallback"); +} + +/* --- Invalid chunk size --- */ + +TEST(Segmenter, serial_invalidChunkSize) +{ + ZL_Compressor* compressor = ZL_Compressor_create(); + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + ZL_GraphID graph = makeSerialGraph(compressor); + + /* Chunk size 0 means "use default" (matches SDDL2 + C++ wrapper) */ + ZL_GraphID result = + ZL_Compressor_buildSerialSegmenter(compressor, 0, graph); + EXPECT_TRUE(ZL_GraphID_isValid(result)) + << "Chunk size 0 should be accepted as 'use default'"; + + /* Sub-ZL_MIN_CHUNK_SIZE positive values are rejected (would invalidate + * ZL_compressBound). */ + result = ZL_Compressor_buildSerialSegmenter(compressor, 1, graph); + EXPECT_FALSE(ZL_GraphID_isValid(result)) + << "Chunk size below ZL_MIN_CHUNK_SIZE should be rejected"; + + result = ZL_Compressor_buildSerialSegmenter( + compressor, ZL_MIN_CHUNK_SIZE - 1, graph); + EXPECT_FALSE(ZL_GraphID_isValid(result)) + << "Chunk size ZL_MIN_CHUNK_SIZE - 1 should be rejected"; + + /* Chunk size > INT_MAX should be rejected (would truncate in ZL_IntParam) + */ + result = ZL_Compressor_buildSerialSegmenter( + compressor, (size_t)INT_MAX + 1, graph); + EXPECT_FALSE(ZL_GraphID_isValid(result)) + << "Chunk size > INT_MAX should be rejected"; + + /* Chunk size == ZL_MIN_CHUNK_SIZE is the smallest accepted positive + * value */ + result = ZL_Compressor_buildSerialSegmenter( + compressor, ZL_MIN_CHUNK_SIZE, graph); + EXPECT_TRUE(ZL_GraphID_isValid(result)) + << "Chunk size == ZL_MIN_CHUNK_SIZE should succeed"; + + ZL_Compressor_free(compressor); +} + +/* --- buildSerialSegmenter2: error reporting variant --- */ + +TEST(Segmenter, serial2_invalidChunkSize) +{ + ZL_Compressor* compressor = ZL_Compressor_create(); + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + ZL_GraphID graph = makeSerialGraph(compressor); + + /* Chunk size 0 means "use default", not an error. */ + ZL_RESULT_OF(ZL_GraphID) + res = ZL_Compressor_buildSerialSegmenter2(compressor, 0, graph); + EXPECT_FALSE(ZL_RES_isError(res)) + << "Chunk size 0 should be accepted as 'use default'"; + + res = ZL_Compressor_buildSerialSegmenter2( + compressor, ZL_MIN_CHUNK_SIZE - 1, graph); + EXPECT_TRUE(ZL_RES_isError(res)) + << "Chunk size below ZL_MIN_CHUNK_SIZE should be rejected"; + EXPECT_EQ(ZL_RES_code(res), ZL_ErrorCode_parameter_invalid); + + res = ZL_Compressor_buildSerialSegmenter2( + compressor, (size_t)INT_MAX + 1, graph); + EXPECT_TRUE(ZL_RES_isError(res)) + << "Chunk size > INT_MAX should be rejected"; + EXPECT_EQ(ZL_RES_code(res), ZL_ErrorCode_parameter_invalid); + + ZL_Compressor_free(compressor); +} + +/* --- chunkByteSize == 0 round-trips through builder default path --- */ + +static ZL_GraphID registerSerial_zeroChunkSize( + ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + return ZL_Compressor_buildSerialSegmenter( + compressor, 0, makeSerialGraph(compressor)); +} + +TEST(Segmenter, serial_zeroChunkSizeUsesDefault) +{ + (void)roundTripGen( + ZL_Type_serial, + registerSerial_zeroChunkSize, + "serial, chunkByteSize=0 substitutes default"); +} + +/* --- Corrupted serialized graph: negative chunk size param value --- */ + +static ZL_GraphID registerCorruptedSerial_negativeChunkSize( + ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + + ZL_GraphID const graph = makeSerialGraph(compressor); + ZL_IntParam const intParams[] = { + { .paramId = ZL_SEGMENT_SERIAL_CHUNK_BYTE_SIZE_PARAM, + .paramValue = -1 }, + }; + ZL_LocalParams const localParams = { + .intParams = { .intParams = intParams, .nbIntParams = 1 }, + }; + ZL_ParameterizedGraphDesc const desc = { + .graph = ZL_SEGMENT_SERIAL, + .customGraphs = &graph, + .nbCustomGraphs = 1, + .localParams = &localParams, + }; + return ZL_Compressor_registerParameterizedGraph(compressor, &desc); +} + +TEST(Segmenter, serial_corruptedSerializedGraph_negativeChunkSize) +{ + char const input[32] = { 0 }; + char compressed[ZL_COMPRESSBOUND(sizeof(input))]; + + ZL_Report const compressionReport = ZL_compress_usingGraphFn( + compressed, + sizeof(compressed), + input, + sizeof(input), + registerCorruptedSerial_negativeChunkSize); + EXPECT_TRUE(ZL_isError(compressionReport)); + EXPECT_EQ(ZL_errorCode(compressionReport), ZL_ErrorCode_parameter_invalid); +} + +static ZL_GraphID registerSerial2(ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + ZL_RESULT_OF(ZL_GraphID) + res = ZL_Compressor_buildSerialSegmenter2( + compressor, 16 << 20, makeSerialGraph(compressor)); + if (ZL_RES_isError(res)) + abort(); + return ZL_RES_value(res); +} + +TEST(Segmenter, serial2_roundTrip) +{ + (void)roundTripGen( + ZL_Type_serial, registerSerial2, "serial2, error-result variant"); +} + +/* --- Default successor (ZL_SEGMENTER_DEFAULT_SUCCESSOR) --- */ + +static ZL_GraphID registerSerial_defaultSuccessor( + ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + return ZL_Compressor_buildSerialSegmenter( + compressor, 16 << 20, ZL_SEGMENTER_DEFAULT_SUCCESSOR); +} + +TEST(Segmenter, serial_defaultSuccessor) +{ + (void)roundTripGen( + ZL_Type_serial, + registerSerial_defaultSuccessor, + "serial, default successor (ZL_GRAPH_COMPRESS_GENERIC)"); +} + +/* --- Bare ZL_SEGMENT_SERIAL: no parameterization, default chunk + successor */ + +static ZL_GraphID registerSerial_bare(ZL_Compressor* compressor) noexcept +{ + if (ZL_isError(ZL_Compressor_setParameter( + compressor, ZL_CParam_formatVersion, g_testVersion))) + abort(); + return ZL_SEGMENT_SERIAL; +} + +TEST(Segmenter, serial_bare) +{ + (void)roundTripGen( + ZL_Type_serial, registerSerial_bare, "bare ZL_SEGMENT_SERIAL"); +} + +/* --- Empty serial input round-trip --- + * Empty input is fed through processChunk(size=0) on both format-version + * paths in SEGM_serial. Both must produce valid compressed output that + * decompresses to 0 bytes. Pinning down both paths so a future change to + * either branch can't silently break empty-input handling. */ + +static void verifySerial_emptyInputRoundTrip(int formatVersion) +{ + openzl::Compressor compressor; + compressor.setParameter(openzl::CParam::FormatVersion, formatVersion); + ZL_GraphID const graph = makeSerialGraph(compressor.get()); + ZL_GraphID const segmenter = ZL_Compressor_buildSerialSegmenter( + compressor.get(), ZL_MIN_CHUNK_SIZE, graph); + ASSERT_TRUE(ZL_GraphID_isValid(segmenter)); + compressor.selectStartingGraph(segmenter); + + openzl::CCtx cctx; + cctx.refCompressor(compressor); + auto compressed = cctx.compressSerial({}); + + ZL_DCtx* const rawDctx = ZL_DCtx_create(); + ZL_TypedBuffer* const tbuf = ZL_TypedBuffer_create(); + ZL_Report const dr = ZL_DCtx_decompressTBuffer( + rawDctx, tbuf, compressed.data(), compressed.size()); + EXPECT_FALSE(ZL_isError(dr)); + EXPECT_EQ(ZL_validResult(dr), 0u); + + ZL_TypedBuffer_free(tbuf); + ZL_DCtx_free(rawDctx); +} + +TEST(Segmenter, serial_emptyInput_newFormat) +{ + if (g_testVersion < ZL_CHUNK_VERSION_MIN) + return; + verifySerial_emptyInputRoundTrip(g_testVersion); +} + +TEST(Segmenter, serial_emptyInput_oldFormat) +{ + /* Exercises the formatVersion < ZL_CHUNK_VERSION_MIN branch in + * SEGM_serial, which calls processChunk(size=0) directly without + * entering the chunk loop. */ + verifySerial_emptyInputRoundTrip(ZL_CHUNK_VERSION_MIN - 1); +} + +#if ZL_ALLOW_INTROSPECTION + +/* --- Verify chunk count using introspection hooks --- */ + +static void verifySerialChunkCount( + size_t chunkByteSize, + size_t inputSize, + size_t expectedChunks) +{ + /* Generate input data */ + unsigned char* input = (unsigned char*)malloc(inputSize); + assert(input); + for (size_t i = 0; i < inputSize; i++) + input[i] = (unsigned char)(i & 0xFF); + + /* Build compressor */ + openzl::Compressor compressor; + compressor.setParameter(openzl::CParam::FormatVersion, g_testVersion); + ZL_GraphID graph = makeSerialGraph(compressor.get()); + ZL_GraphID segmenter = ZL_Compressor_buildSerialSegmenter( + compressor.get(), chunkByteSize, graph); + ASSERT_TRUE(ZL_GraphID_isValid(segmenter)); + compressor.selectStartingGraph(segmenter); + + /* Compress with chunk counter */ + ChunkCounterHook compressHook; + openzl::CCtx cctx; + cctx.refCompressor(compressor); + ZL_Report attachr = ZL_CCtx_attachIntrospectionHooks( + cctx.get(), compressHook.getRawHooks()); + ASSERT_FALSE(ZL_isError(attachr)); + auto compressed = cctx.compressSerial({ (const char*)input, inputSize }); + + EXPECT_EQ(compressHook.chunkCount, expectedChunks) + << "Compression: expected " << expectedChunks << " chunks for " + << inputSize << " bytes with " << chunkByteSize << " byte chunks"; + + /* Decompress with chunk counter */ + DecompressChunkCounterHook decompressHook; + ZL_DCtx* rawDctx = ZL_DCtx_create(); + ZL_Report dattachr = ZL_DCtx_attachDecompressIntrospectionHooks( + rawDctx, decompressHook.getRawHooks()); + ASSERT_FALSE(ZL_isError(dattachr)); + ZL_TypedBuffer* tbuf = ZL_TypedBuffer_create(); + ZL_Report dr = ZL_DCtx_decompressTBuffer( + rawDctx, tbuf, compressed.data(), compressed.size()); + ASSERT_FALSE(ZL_isError(dr)); + + EXPECT_EQ(decompressHook.chunkCount, expectedChunks) + << "Decompression: expected " << expectedChunks << " chunks"; + + /* Verify round-trip */ + size_t decompressedSize = ZL_validResult(dr); + EXPECT_EQ(decompressedSize, inputSize); + EXPECT_EQ(memcmp(input, ZL_TypedBuffer_rPtr(tbuf), inputSize), 0); + + ZL_TypedBuffer_free(tbuf); + ZL_DCtx_free(rawDctx); + free(input); +} + +TEST(Segmenter, serial_chunkCount_singleChunk) +{ + if (g_testVersion < ZL_CHUNK_VERSION_MIN) + return; + /* Half-chunk-size of input → 1 chunk */ + verifySerialChunkCount(ZL_MIN_CHUNK_SIZE, ZL_MIN_CHUNK_SIZE / 2, 1); +} + +TEST(Segmenter, serial_chunkCount_exactSplit) +{ + if (g_testVersion < ZL_CHUNK_VERSION_MIN) + return; + /* 2x ZL_MIN_CHUNK_SIZE bytes → exactly 2 chunks */ + verifySerialChunkCount(ZL_MIN_CHUNK_SIZE, 2 * ZL_MIN_CHUNK_SIZE, 2); +} + +TEST(Segmenter, serial_chunkCount_unevenSplit) +{ + if (g_testVersion < ZL_CHUNK_VERSION_MIN) + return; + /* 2x chunk + 100 bytes → 3 chunks (chunk + chunk + 100-byte remainder) */ + verifySerialChunkCount(ZL_MIN_CHUNK_SIZE, 2 * ZL_MIN_CHUNK_SIZE + 100, 3); +} + +TEST(Segmenter, serial_chunkCount_manyChunks) +{ + if (g_testVersion < ZL_CHUNK_VERSION_MIN) + return; + /* 16x ZL_MIN_CHUNK_SIZE bytes → 16 chunks */ + verifySerialChunkCount(ZL_MIN_CHUNK_SIZE, 16 * ZL_MIN_CHUNK_SIZE, 16); +} + +#endif // ZL_ALLOW_INTROSPECTION + } // namespace