feat(storage): add chunked serialization and parallel deserialization - #2748
Conversation
|
/label status/waiting-for-review |
Merge Protections🟢 All 3 merge protections satisfied — ready to merge. Show 3 satisfied protections🟢 Require kind label
🟢 Require version label
🟢 Require linked issue for feature/bug PRs
|
d0cbe05 to
0190371
Compare
|
/kind feature |
0190371 to
f1c4b08
Compare
LHT129
left a comment
There was a problem hiding this comment.
关于 ParallelDeserialize(DeserializeReader&, ThreadPool&):建议不要在 Index 公共 API 中暴露由调用方传入 ThreadPool 的重载,只保留 ParallelDeserialize(DeserializeReader&)。
索引由 Engine 创建时已经具备绑定线程池的能力;这里复用 Engine/Index 已受管的线程池即可。额外让调用方注入线程池会使资源归属和并发控制出现两套入口,也会让索引层 API 与 Engine 的职责重复。若确有内部复用需求,可以将带线程池的实现保留在非公开内部接口。
f1c4b08 to
ed69838
Compare
LHT129
left a comment
There was a problem hiding this comment.
This is a substantial and well-engineered PR. The architecture is carefully thought out with clear separation of concerns. The code has already gone through multiple review rounds and the quality is high.
A few remaining observations:
-
[note] The ResizePhysicalForOverwrite in MMapRegion ignores previous_logical_size (cast to void), while BlockMemoryBackend uses it to clear the unwritten tail. The asymmetry is correct but could surprise someone adding a third backend.
-
[note] The measure_head_size functions use explicit base-class qualification to bypass derived overrides. This is intentional and well-documented, but creates a hidden coupling. The ByteIO serialize framing test pins this, which is good.
-
[note] Test coverage is excellent: compressed/plain round trips, reorder/dedup/MCI/conjugate indexes, file-backed IOs, empty indexes, corrupted inputs, tampered layouts, configuration mismatches, and dribbling decompressed streams.
LHT129
left a comment
There was a problem hiding this comment.
Thank you for this well-engineered PR. The chunked serialization and parallel deserialization implementation is thorough, with strong validation at every layer (ChunkedManifest::Validate, pre-validation in parallel_deserialize_manifest, per-component consumption checks), clear documentation of invariants, and comprehensive test coverage including negative/fuzzing tests.
Summary of changes:
- New public interfaces:
DeserializeReader(positioned reads + optional decompression) andSerializeWriter(byte sink + optional per-frame compression) ChunkedStreamWriter: splits components into head/io-data-frames/tail at precomputed boundariesChunkedManifest: JSON-serializable layout withValidate()enforcing physical invariants (no overlaps, no gaps, exact tiling)- Three-phase parallel load: Prepare (parse footer, pre-allocate extents), Fill (thread pool, one task per frame), Finalize (tails + post-load setup)
ResizeForOverwriteoptimization acrossBlockMemoryBackend,MMapRegion(fallocate),ContiguousBackend,ByteIO, andFixedLayout- Probe path for legacy uncompressed files without recorded layout
TaskBatch/FirstTaskErrorfor safe concurrent task dispatch with exception propagation- 939 lines of new tests covering round-trip, compressed/uncompressed, mmap-backed, tampered-footer rejection, dribbling streams, and compatibility
Review notes (non-blocking):
-
io_size_field_bytes()runtime measurement (src/algorithm/hgraph/hgraph_serialize.cpp:2833): The function measures the byte size ofStreamWriter::WriteObj(uint64_t)by constructing aCountingStreamWriterand performing a dry-run write. The result is a compile-time constant (8 bytes), and theByteIO Serialize Framing Pins Head Measurementtest already guards against format changes. Consider usingconstexpr uint64_t kIOSizeFieldBytes = sizeof(uint64_t)directly and keeping the test as a regression guard — this would make the intent clearer that it is a format constant, not something that varies per call. The runtime cost is negligible (called at most 4 times per serialization), so this is purely about code clarity. -
ChunkedManifest::FindComponentlinear search (src/storage/chunked_manifest.cpp:4094): The function does an O(n) linear scan through the components vector. It is called fromparallel_deserialize_manifestfor each Byte-granularity component during pass 2 (at most 4 calls) and for each component during the pre-validation loop. With the current component count (~10), this is perfectly fine. If the component count grows significantly, consider using anunordered_mapor sorting + binary search.
The implementation is solid — the validation layering (ChunkedManifest::Validate for physical invariants, then pre-validation for semantic constraints) is well-designed, the thread-safety invariants are clearly documented, and the test coverage is thorough. Nice work.
LHT129
left a comment
There was a problem hiding this comment.
Review Summary
This is a well-engineered PR that adds chunked serialization and parallel deserialization to the HGraph index. The architecture is carefully layered: public SerializeWriter/DeserializeReader interfaces at the API boundary, ChunkedStreamWriter/ChunkedManifest in the storage layer, and the parallel dispatch logic in hgraph_parallel_deserialize.cpp. The probe fallback path for legacy files, the if constexpr compile-time gating, and the Fallocate raw syscall choice all show solid engineering judgment.
Key strengths:
- Backward compatibility: The probe path handles layout-less uncompressed files, and
Deserialize(StreamReader&)still works for compressed chunked bodies (rejects with a clear error). - Concurrency safety: Pre-allocated io extents for lock-free concurrent fill,
TaskBatchwith first-error propagation, and the conjugate graph mutex invariant are all well thought out. - Test coverage: Round-trip tests cover compressed/uncompressed, 1/4/16 threads, corruption rejection, empty index, MCI, conjugate graph, file-backed IO, and dribbling streams.
- Documentation: Both Chinese and English docs with usage examples are included.
Suggestions (see inline comments for details):
- Consider debug assertions in
ChunkedStreamWriter::Write()for frame boundary overflow TaskBatchdestructor could log when it swallows exceptions during stack unwinding- The conjugate graph mutex invariant comment should be referenced from the header
Validate()overlap detection is O(n^2) — acceptable for now but worth notingFallocateerror message could distinguish ENOSPC from other failuresmeasure_head_sizebase-class dispatch is fragile — consider a regression test
No blocking issues found. The PR is ready for merge once the author reviews the suggestions.
LHT129
left a comment
There was a problem hiding this comment.
This is a new review round on the current state (958418b). The PR has matured significantly through prior rounds — most structural concerns have been addressed. The remaining issues are style consistency (alternative tokens) and one exception-type mismatch.
Alternative tokens (not, and, or)
The VSAG codebase consistently uses !, &&, ||. The following new files still contain alternative tokens:
src/algorithm/hgraph/hgraph_parallel_deserialize.cpp:noton lines 92, 126, 176, 197, 442, 561, 674;andon lines 197, 642, 645src/storage/chunked_stream_writer.cpp:noton lines 48, 115;andon line 182src/storage/chunked_manifest.cpp:noton lines 68, 154;oron line 68;andon lines 163, 180, 188, 239src/storage/parallel_deserialize_utils.h:noton line 202
The or token on chunked_manifest.cpp:68 was flagged in a prior review round but is still present.
DeserializeReader::ReadDecompressed default throws std::runtime_error
include/vsag/deserialize_reader.h:61 — The default ReadDecompressed throws std::runtime_error, while every other error path in the deserialization pipeline throws VsagException. If a caller accidentally invokes ParallelDeserialize on a compressed file with a plain reader, the std::runtime_error propagates through FirstTaskError::Capture() (which catches ...) and surfaces as a bare std::runtime_error without a proper ErrorType. The existing comment acknowledges this is intentional to avoid depending on internal headers, but the inconsistency means callers must catch both VsagException and std::runtime_error to handle all deserialization failures uniformly.
958418b to
be5551a
Compare
|
Review round addressed in be5551a (rebased onto current main, fd36393 — clean, no conflicts). Both items from the CHANGES_REQUESTED are fixed, plus the actionable suggestions from the inline round. Itemized: Blocking item 1 — alternative tokens. All Blocking item 2 — exception family. Fixed at the pipeline boundary rather than the public header: all three Implemented suggestions: TaskBatch destructor logs a warning when it suppresses a recorded failure during unwinding (with Fact-checked, no change needed (details in the respective threads): the Verification on be5551a: clean rebuild; targeted suites green (61 unit cases / 69,853 assertions including the two new tests; chunked FT cases 10,202 assertions); full unit-test run and CircleCI in flight. |
Loading a large index is I/O bound: the sequential Deserialize path reads and decompresses the file with a single thread, leaving multi-core machines and high-bandwidth storage underutilized. Split the index body into independently readable frames, record their physical placement in a chunked_layout footer key, and restore them concurrently through a caller-supplied positioned reader and thread pool. Serialize(SerializeWriter&, chunk_size) writes each component either as a whole frame or as a head / chunked io-data / tail triple, while SerializeWholeBody writes the same components as one continuous body. ParallelDeserialize pre-allocates the io extents on the main thread, fills the frames from the pool without locking because tasks touch disjoint ranges, and finalizes on the main thread. Layout coverage and per-frame byte counts are validated, so a corrupted frame fails with an error instead of corrupting memory. Uncompressed chunked files stay readable by the sequential path; compressed ones are rejected explicitly rather than misparsed. Add ReserveIO / GetIOSize / WriteRaw / DeserializeTail hooks across the datacell, layout and io abstractions, skip zero-filling in ResizeForOverwrite so the reserve step does not fault in every page from the main thread, and extend the parallel fill to file-backed io types. reader_io stays excluded because its write path is a no-op; its components fall back to whole frames. 1M x 1024 vectors with sq8 base and fp32 reorder load 2.7x-5.2x faster at 16 threads across block_memory, memory, mmap and buffer io, returning results identical to the in-memory index query by query. Signed-off-by: Gengquan Guo <1614287482@qq.com> Assisted-by: ClaudeCode:qwen3.8-max
be5551a to
6c87b09
Compare
|
Tick the box to add this pull request to the merge queue (same as
|
Loading a large index is I/O bound: the sequential Deserialize path reads and decompresses the file with a single thread, leaving multi-core machines and high-bandwidth storage underutilized. Split the index body into independently readable frames, record their physical placement in a chunked_layout footer key, and restore them concurrently through a caller-supplied positioned reader and thread pool.
Serialize(SerializeWriter&, chunk_size) writes each component either as a whole frame or as a head / chunked io-data / tail triple, while SerializeWholeBody writes the same components as one continuous body. ParallelDeserialize pre-allocates the io extents on the main thread, fills the frames from the pool without locking because tasks touch disjoint ranges, and finalizes on the main thread. Layout coverage and per-frame byte counts are validated, so a corrupted frame fails with an error instead of corrupting memory. Uncompressed chunked files stay readable by the sequential path; compressed ones are rejected explicitly rather than misparsed.
Add ReserveIO / GetIOSize / WriteRaw / DeserializeTail hooks across the datacell, layout and io abstractions, skip zero-filling in ResizeForOverwrite so the reserve step does not fault in every page from the main thread, and extend the parallel fill to file-backed io types. reader_io stays excluded because its write path is a no-op; its components fall back to whole frames.
1M x 1024 vectors with sq8 base and fp32 reorder load 2.7x-5.2x faster at 16 threads across block_memory, memory, mmap and buffer io, returning results identical to the in-memory index query by query.
Change Type
Linked Issue
What Changed
Serialize(SerializeWriter&, chunk_size)writes eachcomponent as a whole frame or a head / chunked io-data / tail triple, and records the
physical placement in a
chunked_layoutfooter key;SerializeWholeBodywrites thesame components as one continuous body.
ParallelDeserialize: restores an index from a chunked file with one task perframe via a caller-supplied
DeserializeReaderandThreadPool. Extents arepre-allocated on the main thread and filled lock-free (disjoint ranges); a probe path
also loads existing layout-less uncompressed files concurrently.
SerializeWriter/DeserializeReaderpublic interfaces andGetIOSize/ReserveIO/WriteRaw/DeserializeTailhooks across the datacell,layout and io abstractions; skip zero-filling in
ResizeForOverwrite.Test Evidence
make fmtmake lintmake testmake cov, run tests, and collect coverageTest details: