Skip to content

[core] Optimize ordinary manifest merging with raw Avro blocks - #9213

Open
leaves12138 wants to merge 9 commits into
apache:masterfrom
leaves12138:codex/manifest-block-merge
Open

[core] Optimize ordinary manifest merging with raw Avro blocks#9213
leaves12138 wants to merge 9 commits into
apache:masterfrom
leaves12138:codex/manifest-block-merge

Conversation

@leaves12138

@leaves12138 leaves12138 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Purpose

Optimize ordinary (non-sorted) manifest full and minor compaction without materializing every manifest entry.

The legacy merger decodes complete ManifestEntry objects, builds an identifier-to-entry map, and rewrites every surviving entry. On large data-evolution tables this creates high allocation pressure and makes manifest merging a major part of commit latency.

This PR introduces a block-aware ordinary merger which:

  • reads DELETE entries with a minimal projection;
  • uses primitive RowID and identifier indexes to reject unaffected ADD entries cheaply;
  • copies an unchanged Avro block, or an entire compatible add-only manifest, without decoding and re-encoding every record;
  • decodes and filters individual entries only for blocks that may contain a matching DELETE or whose encoded schema is incompatible;
  • keeps planning memory bounded to the manifests handled by the configured read batch;
  • retains the previous implementation as ManifestFileLegacyMerger, selectable with manifest.merge-optimize.enabled=false.

This PR only changes ordinary manifest full/minor compaction. The manifest sort-compaction branch and ManifestFileSorter are unchanged.

Correctness validation

Benchmarks used real metadata from a big table. Each mode was warmed up once and then measured without an -Xmx limit. Output manifests were scanned and compared with an order-independent content fingerprint containing entry kind, file identifier and first RowID. Every legacy/optimized pair produced the same entry count, ADD/DELETE counts, and content fingerprint.

Minor compaction

Snapshot Legacy Block-aware Speedup Peak RSS legacy Peak RSS block-aware RSS reduction
8795 6.524 s 0.053 s 124.3x 10.30 GiB 0.94 GiB 90.9%
8800 15.685 s 0.104 s 150.3x 11.91 GiB 0.87 GiB 92.7%
8808 4.340 s 0.040 s 108.6x 5.40 GiB 1.26 GiB 76.6%

Full compaction

Snapshot Legacy Block-aware Speedup Peak RSS legacy Peak RSS block-aware RSS reduction
8795 34.214 s 5.963 s 5.74x 16.26 GiB 1.54 GiB 90.6%
8808 38.332 s 6.653 s 5.76x 16.24 GiB 1.42 GiB 91.2%
8816 39.123 s 6.310 s 6.20x 16.26 GiB 1.29 GiB 92.1%

Peak RSS covers the whole benchmark JVM, including warm-up and output verification, so it is intentionally more conservative than measuring the merge allocation alone. Different output byte sizes are expected because raw-block reuse changes Avro block boundaries/compression layout; logical contents matched in all six comparisons.

Tests

  • Linux x86_64 targeted regression : 19 tests, 0 failures.
  • Added full/minor tests for add-only raw-block reuse, unaffected blocks around DELETE entries, non-RowID identifier filtering, fallback to the legacy merger, bounded manifest scans, encoded-record buffer flushing, aggregate-stat preservation, and binary-layout compatibility.

Manifest read parallelism

The documented scan.manifest.parallelism default is preserved: a null value now reaches ManifestReadThreadPool, so DELETE collection and the existing RowID planning path use the default number of processors. An explicit value of 1 remains serial.

I also evaluated extending bounded parallel planning to add-only manifests. Snapshot 4780 contains 22,614,974 ADD entries, no DELETE entries, and about 582 MiB of manifests. Five add-only minor-compaction runs produced these medians:

Path Median time Peak RSS
Streaming raw-block copy 448 ms 198 MiB
8-thread manifest planning 506 ms 341 MiB

The parallel version was about 13% slower and used about 72% more peak RSS. Full compaction showed the same direction: the streaming path took about 270-282 ms at about 200 MiB RSS, while all-path parallel planning took about 372-380 ms at about 323 MiB RSS. Logical output counts and bytes were identical.

The reason is that a compatible add-only manifest already follows the cheapest path: its compressed Avro blocks are copied directly without decompression or entry filtering. Parallel planning adds no useful CPU work; it only creates stable copies of raw blocks, retains multiple manifest plans until the single ordered writer consumes them, and adds executor scheduling overhead. Therefore add-only compaction intentionally keeps the streaming raw-block-copy path instead of forcing parallel planning.

@leaves12138
leaves12138 marked this pull request as ready for review August 14, 2026 03:39
// retaining planned raw blocks before the single ordered writer consumes them.
if (hasDeletes
&& deletes.useRowIdFilter()
&& manifestReadParallelism != null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Please preserve the scan.manifest.parallelism contract here. The documented default is the number of CPU processors, but null now disables parallel planning. In addition, add-only and non-RowID merges fall through to the serial manifest loop even when the option is explicitly greater than 1. The legacy merger passes the configured value, including null, to ManifestReadThreadPool, which resolves the default and performs bounded parallel reads. Because this merge runs on the synchronous commit path, serial object-store reads and decompression can materially increase commit/checkpoint latency. Could we keep bounded parallel manifest reads for all optimized paths and restore a concurrency assertion covering both the default and an explicit parallelism?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I fixed the null/default contract for DELETE collection and the existing RowID planning path: null now reaches ManifestReadThreadPool and uses the default processor count, while an explicit value of 1 remains serial. I benchmarked widening planning to add-only manifests on snapshot 4780 (22,614,974 ADDs, 0 DELETEs, about 582 MiB of manifests). Across five minor-compaction runs, streaming raw-block copy had a 448 ms median and 198 MiB peak RSS; 8-thread planning had a 506 ms median and 341 MiB peak RSS. Full compaction showed the same direction (about 270-282 ms / 200 MiB versus 372-380 ms / 323 MiB). Compatible add-only manifests already copy compressed Avro blocks without decompression or filtering, so parallel planning only makes stable block copies, retains multiple plans before the single ordered writer, and adds executor overhead. I therefore kept add-only on the original streaming path. Added concurrency assertions for both default DELETE collection and default RowID planning; 108 targeted Manifest tests pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the RowID/default fix looks good, and the add-only benchmark is convincing; keeping compatible add-only manifests on the streaming raw-copy path makes sense. One case still seems uncovered: when DELETEs are present and useRowIdFilter() is false, the code still falls through to the serial loop even when scan.manifest.parallelism is null or explicitly greater than 1. This affects legacy or non-data-evolution manifests and requires decoded identifier filtering, so the add-only benchmark does not cover it. Could we add a separate bounded raw-block prefetch path only for non-RowID + DELETE compaction? Each worker would open one reader, stableCopy() the compressed blocks, and close the reader; the coordinator would then consume plans in input order and perform identifier filtering and all writer, matchedEntries, and emittedDeletes mutations on a single thread. This preserves the ADD/DELETE state machine and keeps memory bounded to one manifest per planning worker, while leaving add-only streaming unchanged. A blocking FileIO test for both default and explicit parallelism would cover the remaining contract.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 07e369e. There is now a separate bounded path for DELETEs with useRowIdFilter() == false when the configured/default manifest parallelism permits parallel reads. Each worker only opens one ManifestAvroReader, stableCopy()s its compressed raw blocks, and closes the reader. The coordinator consumes the prefetched manifests in input order and exclusively owns Avro decoding, identifier filtering, the writer, matchedEntries, and emittedDeletes. Explicit parallelism 1 and single-manifest inputs still stream directly; compatible add-only compaction remains on the original streaming raw-copy path.

I added a blocking FileIO test parameterized for both default (null) and explicit parallelism 2. It verifies two manifest reads overlap and validates the exact surviving entries, including the optional-manifest rewrite/keep behavior. The targeted Manifest suite passes: 110 tests, 0 failures, 1 existing skip.

For a production-scale stress check, I routed the snapshot 8816 payload through the non-RowID identifier path by removing only the meta-level RowID ranges and forcing the selected manifests to rewrite. This is a stress test of the new branch, not the normal RowID-aware 8816 path (which remains about 6.8 s). On dev2 local/page-cached storage, identifier p1 was 43.21 s / 1.22 GiB peak RSS and p8 was 41.48 s / 1.55 GiB; current Legacy p8 was 46.07 s / 16.25 GiB. The modest local-time gain is expected because only compressed-block FileIO is prefetched, while decode/filter/write stays ordered and single-threaded; the main demonstrated gain is bounded memory, with additional latency hiding expected for object storage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I replaced the prefetch-only implementation with the stronger block-aware design in b728430. All DELETE compactions now use the same bounded parallel manifest planner when the configured/default parallelism permits it, including the non-RowID identifier path. Each worker owns its identifier scratch and block-statistics objects and only reads immutable DELETE state. Unchanged schema-compatible blocks produce EncodedBlockMeta and are copied as compressed Avro blocks; blocks containing a DELETE or a deleted ADD are decoded and filtered by the coordinator in input order. The coordinator remains the sole owner of the writer, matchedEntries, and emittedDeletes. Compatible add-only compaction remains on the faster streaming raw-copy path.

For blocks without RowID, EncodedBlockMeta keeps its original primitive constructor and uses -1/-1 as the no-RowID sentinel, so there is no boxed overload or binary signature change. I added coverage for raw block metadata without RowIDs and retained the blocking FileIO concurrency test for both default and explicit parallelism 2. Formatting/checks pass, and 56 directly related manifest tests pass.

On dev2, the production-scale snapshot 8816 stress path (26,670,786 ADDs and 11,410 DELETEs, forced through non-RowID identifier matching) completed the merge in 7.98 s with 1.66 GiB peak RSS. The earlier prefetch-only p8 result was 41.48 s / 1.55 GiB, while current Legacy p8 was 46.07 s / 16.25 GiB. Full verification read all 26,659,376 output entries and exactly matched the Legacy fingerprints: b7ef9bb1f8347726, 45f036e957cd88ce, f7b77d2fd5a0b1c1, a0f943ec46d9a7ef.

@leaves12138
leaves12138 marked this pull request as draft August 14, 2026 10:23
@leaves12138
leaves12138 marked this pull request as ready for review August 14, 2026 10:37
@JingsongLi

Copy link
Copy Markdown
Contributor

+1

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.

2 participants