Skip to content

[improvement](be) Decode SNII zstd and raw .prx windows faster - #69770

Merged
airborne12 merged 3 commits into
apache:masterfrom
airborne12:snii-prx-read-path
Oct 29, 2026
Merged

airborne12 merged 3 commits into
apache:masterfrom
airborne12:snii-prx-read-path

Conversation

@airborne12

@airborne12 airborne12 commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: None

Related PR: #69649, #69769

Problem Summary: A phrase query decodes the positions of the selected documents of every .prx window it reads. Timed per window on a 10M-row AgentLogs load, a zstd window cost 18.6 us against 1.4 us for a PFOR window: 11.7 us to decode the plain payload, 4.8 us to decompress, and about 2 us more because every window created and freed its own zstd context (about 96 KB) and a zero-filled output vector. Two commits:

  1. Decode the plain payload without a Status per document. decode_payload_csr_selective made at least one out-of-line call returning a Status for every document of the window, selected or not (get_varint32_fast, skip_varints, decode_delta_run). The loop now keeps the cursor in a register and handles the usual cases inline: a single-byte count, walking past the deltas of a document that is not selected, and a run of single-byte deltas. Anything else -- a multi-byte value, a run that does not fit, a prefix sum that would overflow -- goes to the checked decoders from the same cursor, so they decide and report as before. The output vector grows as before; nothing is reserved up front.
  2. Reuse one zstd context and buffer per index source. ZstdDecompressor keeps one context, created at the first zstd frame, and one output buffer that only grows and is not zero-filled (decompression writes every byte before it is read). The buffer comes from the Doris allocator (DorisUniqueBufferPtr), so the decoding task's memory tracker accounts for it while it is held. SniiIndexSource owns one, and the cursors it opens pass it to the .prx decoder through PrxDecodeContext. The cursors of a source decode on its thread one window at a time, and the decoded payload is consumed before the next window is read. A source lives for one query on one segment, so a query holds at most one context and one buffer per segment it reads, and only if it decodes a zstd window; before, each window created and released them. Compaction decodes through an allocation gate, which keeps the old path, as does a cursor given no decompressor.

Hot queries, old against new read path in one process (run-time switch in an experiment build, 6 paired cycles), on 99M AgentLogs rows written with the smallest frames, as every segment written before #69649 is (mostly zstd windows): DS7 (unfiltered phrase) 0.87x, A6 0.89x, A10 0.93x, DS4 (phrase prefix) 0.94x, A9 0.95x, DS1 0.96x; repeating the old path against itself gave 0.93-1.05. The gain follows the zstd windows a query reads: the phrase queries of AgentLogs written with #69649 read PFOR windows and do not change, and on TextBench, whose large windows stay zstd, the time per window is the decompression itself and the change is within noise. Every query returns the same result.

Release note

None

Check List (For Author)

  • Test: Unit Test
    • PrxPodTest.SelectivePlainDecodeMatchesInputForRandomWindows: 300 random windows of mixed varint widths, raw and zstd, decoded selectively and in full, also through one shared decompressor, compared with the input.
    • SniiZstdDecompressor: frames of different sizes through one decompressor; a corrupt frame is reported and the next one decodes; the retained buffer is charged to the task's memory tracker and released with the decompressor.
  • Manual test: interleaved hot A/B in one process on 10M and 99M AgentLogs and 12M TextBench rows.
  • Behavior changed: No
  • Does this need documentation: No

🤖 Generated with Claude Code

martin.foo838 and others added 2 commits October 28, 2026 10:19
…r document

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69649

Problem Summary: A phrase query decodes the positions of the selected documents of every .prx
window it reads. For a zstd or raw window that is the plain payload: per document a count, then its
position deltas, all varints. decode_payload_csr_selective made at least one out-of-line call that
returns a Status for every document of the window, selected or not (get_varint32_fast,
skip_varints, decode_delta_run). Timed per window on a 10M-row AgentLogs load, the plain decode was
11.7 us of the 18.6 us a zstd window costs; a PFOR window costs 1.4 us.

The loop now keeps the cursor in a register and handles the usual cases inline: a single-byte
count, walking past the deltas of a document that is not selected, and a run of single-byte deltas.
Anything else -- a multi-byte value, a run that does not fit, a prefix sum that would overflow --
goes to the checked decoders from the same cursor, so they decide and report as before. The output
vector grows as before; nothing is reserved up front.

Hot phrase queries on data whose position windows are mostly zstd (10M rows, interleaved A/B in one
process): 0.93x and 0.94x for the two most position-heavy queries, the others within noise.

### Release note

None

### Check List (For Author)

- Test: Unit Test (PrxPodTest.SelectivePlainDecodeMatchesInputForRandomWindows compares the decoded
  positions with the input for 300 random windows of mixed varint widths, raw and zstd)
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…urce

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69649

Problem Summary: Every zstd .prx window a query reads was decompressed with ZSTD_decompress, which
creates and frees a decompression context (about 96 KB) per call, into a zero-filled vector that is
also allocated per call. Timed per window on a 10M-row AgentLogs load, the decompression took
6.8 us with a context per call and 4.8 us with a reused one.

ZstdDecompressor keeps one context, created at the first frame, and one output buffer that only
grows and is not zero-filled (decompression writes every byte before it is read). SniiIndexSource
owns one, and the cursors it opens pass it to the .prx decoder through PrxDecodeContext; the
cursors of a source decode on its thread one window at a time, and the decoded plain payload is
consumed before the next window is read. A source lives for one query on one segment, so a query
holds at most one context and one buffer per segment it reads, created only if it decodes a zstd
window; before, each window created and released them. Compaction decodes through an allocation
gate, which keeps the old path, as does a cursor given no decompressor.

Hot phrase queries on data whose position windows are mostly zstd (10M rows, interleaved A/B in one
process, on top of the plain-decode change): 0.95-0.96x for the two most position-heavy queries,
the others within noise.

### Release note

None

### Check List (For Author)

- Test: Unit Test (SniiZstdDecompressor: frames of different sizes through one decompressor,
  corruption reported and the next frame decoded; PrxPodTest: random windows decoded through one
  shared decompressor)
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@airborne12

Copy link
Copy Markdown
Member Author

run buildall

@airborne12

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

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.

Reviewed all 11 changed files and the related MATCH, SEARCH/TopK, phrase/prefix, scoring, frame-parser and compaction paths at head 9ca7f96. One new P2 finding: the source-owned decompression buffer retains untracked query memory. No existing inline findings were present. No additional user focus was supplied.

Critical checkpoint conclusions:

  1. Goal/proof: the implementation reduces per-document decoder calls and reuses zstd state for CSR windows. New unit tests compare raw/zstd selective and full output with input and check reuse/recovery; performance and runtime correctness were not executed or measured here.
  2. Scope/clarity: focused reader/codec optimization; no unrelated source changes. One retained-buffer accounting issue is reported inline.
  3. Concurrency/locks: actual collectors decode serially and create sources per query/binding; wave callbacks execute serially and attach encoded slices. No new lock or lock-order change; no reachable scratch race found.
  4. Lifecycle/static initialization: execution contexts retain sources while cursors run. Decoded positions live in cursor-owned vectors, so later decompression cannot invalidate returned spans. Lazy RAII cleanup has no new ownership cycle or static initialization dependency; retained allocation accounting is the exception identified inline.
  5. Configuration: no new or changed settings.
  6. Compatibility: disk framing, codec IDs and persisted bytes are unchanged. Default-null constructor arguments retain the old direct-cursor path; no external ABI/protocol change identified.
  7. Thrift/protobuf: no changes.
  8. Parallel paths: both full and selective CSR receive the helper. Raw/PFOR are unaffected; deferred streaming keeps its independent buffer; compaction allocation gates override shared decompression.
  9. Conditions/error handling: comments explain fast/checked fallback and reuse. Speculative appends roll back before the checked decoder restarts at the original cursor; caps, truncation, overflow, ordinal and trailing-byte checks remain. Non-OK statuses propagate through existing query exception boundaries.
  10. Coverage: deterministic reuse/grow/shrink/empty/corruption tests and randomized valid raw/zstd windows were inspected, alongside existing PFOR, shape, stats, CRC, overflow and allocation-gate tests. Limits: random per-document counts are only 0..5, not multi-byte count headers or UINT32_MAX overflow cases; no new targeted malformed-inner-varint selective, simultaneous gate+zstd, tracker-accounting or two-cursor interleaving test. Direct parser/ownership/dispatch inspection resolved these code questions; tests were not run.
  11. Expected results: new unit-test expected values derive from generated input/oracles; no regression output files changed.
  12. Observability: successful-frame decode statistics and error propagation are preserved. No extra hot-path logging was introduced; memory accounting requires the inline fix.
  13. Transactions/persistence: no EditLog, commit, failover or visibility logic changed; committed-data/version invariants are unaffected by the scope.
  14. Writes/atomicity: no write, delete-bitmap or rowset lifecycle mutation changed; no new crash-atomicity obligation.
  15. FE/BE propagation: no cross-process variables added.
  16. Performance/memory: repeated context allocation and output zero-fill are avoided; high-water retention is intentional, but its memory must be tracked. No benchmark claims are independently confirmed. The text-only BE build-hygiene checks passed (headers, budgets/PCH, extern-template pairs and unity-skip coverage).
  17. Other issues: no additional substantiated decoding, source-lifetime, format or integration regression remained after the missed-issue recheck.

Static review only: no builds, unit/regression tests, benchmarks, runtime measurements or final ELF/GLIBC validation were performed; no source files were modified. Review convergence is reported separately by the delivery helper.

Review converged after 2 round(s).

…cker

The decompressor's output buffer lasts as long as its SniiIndexSource and is
as large as the largest window decoded, but new[] is not tracked: the jemalloc
hook only forwards, and consumption and limit checks happen in the Doris
Allocator. Allocate it with DorisUniqueBufferPtr (Allocator<false>, no
zero-fill), releasing the old buffer before the new one is allocated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@airborne12

Copy link
Copy Markdown
Member Author

run buildall

@airborne12

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

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.

Reviewed all 11 changed files at 6fd35b186f24222ba3a9dd77b86a79f4617a7663, including the query callers, CSR consumers, checked decoders and allocation paths. No new actionable findings. The existing P2 memory-tracking finding is fixed: the retained buffer uses DorisUniqueBufferPtr with paired Doris allocation/release, and growth frees the prior buffer first. No existing P0/P1 finding remains to carry forward. No additional user focus was provided.

Checkpoint conclusions:

  • Goal and proof: the implementation removes ordinary per-document Status calls and reuses query-side zstd context/storage. Added unit tests inspectably cover output equivalence, reuse, corruption recovery and retained-buffer accounting; runtime correctness and speed were not measured here.
  • Scope and reuse: changes are focused on the read hot path, preserve checked fallbacks and reuse the Doris allocator.
  • Concurrency and locks: actual MATCH, SEARCH, nested and scored callers execute each source synchronously. Batched I/O callbacks do not decompress. Different scanner evaluations own different sources; no new thread, lock or lock-order change was found.
  • Lifecycle and static initialization: sources outlive their cursors on the traced production paths; borrowed plaintext is consumed into cursor-owned CSR vectors before another decode. Sources release inside the task scope and before their opened indexes. Context/buffer ownership is balanced; no new static initialization dependency or cycle.
  • Configuration: none added or changed.
  • Compatibility: on-disk codecs, frame layouts, reader limits and writer behavior are unchanged. No new system/dependency requirement was identified; binaries were not built or audited.
  • Thrift/protobuf: no generated definitions changed.
  • Parallel paths: full, selective and dense-selection CSR propagate reuse; PFOR remains unchanged. Allocation-gated compaction retains its reservation path, and streaming retains its own plaintext because views survive between calls.
  • Conditional checks: bounds, terminators, overflow and whole-run rollback preserve checked-decoder behavior. Comments explain the fast/fallback and buffer-growth branches.
  • Tests: deterministic randomized RAW/ZSTD full/selective oracle and codec reuse/error/tracker tests supplement existing format/consumer tests. Tests were inspected, not executed. New randomized counts stay at most five and do not approach UINT32_MAX; targeted malformed selective payload and gate-plus-reuse cases would strengthen automated coverage. Those branches were examined statically with no unresolved defect.
  • Test results: no regression output files changed; expected positions are derived from input rather than handwritten outputs.
  • Observability: existing corruption/allocation statuses and successful-decode statistics remain available; errors do not commit partial decode statistics. No additional logging or metric need was established.
  • Transactions and persistence: no transaction, visible-version, delete-bitmap, metadata replay or persistence change.
  • Data writes and crash behavior: the change is read-only; retained resources use owned cleanup on success and error paths.
  • FE/BE variable passing: none added or changed.
  • Performance: hot loops avoid ordinary Status overhead and repeated zero-fill/context allocation; exceptional multi-byte runs replay through checked decoding. The largest plaintext buffer remains retained and tracked until source destruction. No benchmark claim was independently verified.
  • Other BE checkpoints: no PCH, gate-table, explicit-instantiation, renamed-header or source-including-test changes. The pure-text build-support/check-build-hygiene.sh passed.

Static review only: no builds, unit/regression tests, benchmarks or source modifications. Both normal reviewers and the independent risk reviewer returned NO_NEW_VALUABLE_FINDINGS; all assigned scope and risk dispositions are complete. Review converged in 1 round with no unresolved candidates.

Review converged after 1 round(s).

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (4/4) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.43% (36265/46836)
Line Coverage 62.70% (409792/653617)
Region Coverage 59.11% (346690/586471)
Branch Coverage 60.01% (160536/267510)

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28738 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 6fd35b186f24222ba3a9dd77b86a79f4617a7663, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17593	5106	5024	5024
q2	2121	346	248	248
q3	10211	1384	809	809
q4	4675	464	334	334
q5	7440	811	520	520
q6	183	187	157	157
q7	760	797	619	619
q8	9260	1297	1051	1051
q9	5498	4238	4224	4224
q10	6845	1282	1030	1030
q11	429	249	232	232
q12	647	410	290	290
q13	18066	3598	3017	3017
q14	278	266	247	247
q15	q16	707	709	664	664
q17	1203	749	576	576
q18	6993	6217	6107	6107
q19	1111	953	611	611
q20	403	344	225	225
q21	5310	2523	2473	2473
q22	357	319	280	280
Total cold run time: 100090 ms
Total hot run time: 28738 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	6003	5895	5826	5826
q2	709	577	570	570
q3	5604	5783	5341	5341
q4	2042	2137	1556	1556
q5	5762	5765	5786	5765
q6	239	181	138	138
q7	2241	2012	1758	1758
q8	2959	2454	2445	2445
q9	7498	7476	7432	7432
q10	3635	3568	3176	3176
q11	565	398	372	372
q12	653	701	512	512
q13	3367	3698	3070	3070
q14	285	281	260	260
q15	q16	646	664	611	611
q17	7814	7037	6861	6861
q18	13021	12128	12852	12128
q19	899	813	802	802
q20	2141	2136	1916	1916
q21	5633	4690	4757	4690
q22	493	460	409	409
Total cold run time: 72209 ms
Total hot run time: 65638 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 151223 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 6fd35b186f24222ba3a9dd77b86a79f4617a7663, data reload: false

query5	4317	592	430	430
query6	425	203	190	190
query7	4806	451	236	236
query8	322	163	151	151
query9	8813	3246	3247	3246
query10	468	314	253	253
query11	6031	3860	3558	3558
query12	149	93	88	88
query13	1256	454	328	328
query14	6153	3961	3578	3578
query14_1	3378	3388	3425	3388
query15	204	198	180	180
query16	963	429	378	378
query17	865	675	551	551
query18	2441	427	322	322
query19	199	176	141	141
query20	90	99	86	86
query21	212	131	110	110
query22	12984	12878	12906	12878
query23	13416	12705	12476	12476
query23_1	12573	12507	12508	12507
query24	7385	836	487	487
query24_1	505	494	486	486
query25	541	424	363	363
query26	1263	257	144	144
query27	2764	444	282	282
query28	5439	2075	2078	2075
query29	1658	611	479	479
query30	310	225	190	190
query31	1007	931	815	815
query32	145	106	93	93
query33	542	312	250	250
query34	954	863	514	514
query35	734	745	661	661
query36	837	821	695	695
query37	134	105	85	85
query38	1788	1737	1669	1669
query39	797	798	754	754
query39_1	738	730	741	730
query40	221	123	100	100
query41	78	70	69	69
query42	89	86	84	84
query43	352	366	310	310
query44	1347	720	723	720
query45	179	175	171	171
query46	829	982	578	578
query47	3000	2885	2726	2726
query48	294	313	222	222
query49	582	399	294	294
query50	686	285	212	212
query51	10578	10635	10213	10213
query52	78	82	75	75
query53	187	211	150	150
query54	236	208	194	194
query55	69	69	63	63
query56	231	220	228	220
query57	1630	1602	1568	1568
query58	283	263	265	263
query59	2074	2137	1982	1982
query60	299	253	225	225
query61	155	149	144	144
query62	381	325	276	276
query63	185	155	164	155
query64	2754	976	778	778
query65	3318	3253	3259	3253
query66	1782	420	298	298
query67	19986	20248	20093	20093
query68	2911	1004	623	623
query69	392	290	251	251
query70	851	777	752	752
query71	308	224	211	211
query72	2739	2585	2319	2319
query73	516	528	307	307
query74	4597	4496	4328	4328
query75	2302	2283	1911	1911
query76	2323	1008	601	601
query77	355	373	278	278
query78	9786	9659	9077	9077
query79	1177	860	563	563
query80	1290	443	339	339
query81	592	324	280	280
query82	558	131	110	110
query83	311	197	183	183
query84	315	120	104	104
query85	849	409	364	364
query86	398	235	234	234
query87	1981	1950	1827	1827
query88	3913	2828	2798	2798
query89	336	292	242	242
query90	1868	172	189	172
query91	168	153	128	128
query92	104	90	80	80
query93	1122	1008	591	591
query94	641	309	265	265
query95	577	323	361	323
query96	691	503	242	242
query97	2444	2439	2329	2329
query98	162	161	144	144
query99	777	748	650	650
Total cold run time: 236486 ms
Total hot run time: 151223 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.36 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 6fd35b186f24222ba3a9dd77b86a79f4617a7663, data reload: false

query1	0.01	0.01	0.00
query2	0.09	0.05	0.05
query3	0.26	0.08	0.07
query4	1.61	0.09	0.10
query5	0.22	0.23	0.25
query6	1.17	0.69	0.69
query7	0.04	0.01	0.01
query8	0.05	0.04	0.04
query9	0.40	0.35	0.34
query10	0.57	0.59	0.60
query11	0.20	0.14	0.14
query12	0.18	0.14	0.14
query13	0.51	0.50	0.49
query14	1.02	1.01	1.00
query15	0.62	0.61	0.60
query16	0.31	0.35	0.35
query17	1.14	1.15	1.08
query18	0.19	0.18	0.20
query19	2.09	2.02	1.97
query20	0.02	0.01	0.01
query21	15.49	0.18	0.14
query22	4.92	0.05	0.06
query23	16.29	0.31	0.13
query24	3.15	0.61	0.49
query25	0.11	0.06	0.04
query26	1.18	0.22	0.16
query27	0.05	0.04	0.04
query28	3.85	0.80	0.36
query29	12.51	4.27	3.28
query30	0.29	0.13	0.10
query31	2.77	0.55	0.33
query32	3.22	0.59	0.50
query33	3.10	3.21	3.18
query34	15.35	3.96	3.37
query35	3.34	3.33	3.33
query36	0.56	0.43	0.43
query37	0.10	0.07	0.06
query38	0.05	0.04	0.03
query39	0.04	0.03	0.03
query40	0.18	0.15	0.15
query41	0.09	0.03	0.03
query42	0.03	0.02	0.02
query43	0.04	0.04	0.04
Total cold run time: 97.41 s
Total hot run time: 24.36 s

@airborne12
airborne12 marked this pull request as ready for review October 28, 2026 23:38
airborne12 added a commit that referenced this pull request Oct 29, 2026
…prx windows (#69769)

### What problem does this PR solve?

Issue Number: None

Related PR: #69649, #69770

Problem Summary: #69649 stores a .prx window as PFOR instead of zstd
when zstd saves less than `snii_prx_zstd_min_saving_percent` (30) of the
PFOR frame, because every read of a zstd window pays a decompression.
What that costs and buys depends on how much zstd saves on the data.
Replaying the 1.6M windows of 10M AgentLogs rows, 30 spends 8% more
bytes on the windows zstd compresses and cuts their decode time by 42%;
on the windows of TextBench service logs it spends 5% and cuts 6%; on
data whose windows zstd shrinks by just under 30% it stores every window
as PFOR. No single percentage fits all data.

Each index of a segment now spends a size budget instead:
`PrxZstdBudget`, owned by its `LogicalIndexWriter` and reached through
`PrxWindowLimits`, so the resident and the spilled encoder, and the
ordinary and the compaction build, all use it. A window whose smallest
frame is zstd is stored in its faster frame (PFOR, or raw when smaller)
when
- its saving is below the share whose trades, over the windows the index
has seen, fit 8% of their smallest frames, which puts the budget on the
windows zstd shrinks least, and
- the bytes the index has actually traded, this window included, still
fit 8% of the smallest frames seen. This makes 8% a bound for any order
of windows: fed the TextBench windows from the largest saving to the
smallest, the share alone spends 36%.

Until it has seen 64 windows the share is 30, the previous default. The
windows counted are those that get a zstd frame (a plain payload of at
least 512 bytes), the only ones whose codec a share decides; singleton
and short windows are written as before and counted neither in the
allowance nor in the warmup. The choice uses the frames the writer
builds anyway: nothing is compressed twice or scanned ahead, and the
same input gives the same bytes. `snii_prx_zstd_min_saving_percent` now
defaults to -1 (the budget); 0-100 keeps a fixed share, 0 the smallest
frame.

The budget is 8% rather than less: with it the AgentLogs phrase queries
below read nearly the same frames as with 30, while in the replay a 5%
budget cuts the decode time by 31% instead of 39%.

Same binary, one stream load per table, size against the smallest frames
(0):

| data | index, 30 | index, budget | table, 30 | table, budget |
|---|---|---|---|---|
| AgentLogs 99M rows | +4.13% | +3.65% | +1.92% | +1.69% |
| AgentLogs 10M rows | +4.59% | +3.37% | +2.13% | +1.57% |
| TextBench 12M rows | +0.89% | +0.79% | +0.51% | +0.45% |

- The phrase queries read the same frames with 30 and with the budget:
at 99M rows all PFOR for DS1, DS6, DS7, A6 and A9 (for example 107,307
frames for DS7), and about 11,170 zstd frames of about 259,000 for A10
with both; on TextBench most windows stay zstd either way (582 against
583 zstd frames of 599 for 'connection reset').
- Hot queries in one process, budget data against 30 data with the same
read path (paired cycles: 6 for the 25 AgentLogs queries at 10M rows, 10
for the 15 TextBench queries): 0.96-1.06 and 0.97-1.05; every query
returns the same result.
- Against the data written before #69649 (smallest frames), the 11
AgentLogs phrase queries at 99M rows take 0.83x in geometric mean with
the budget data (DS7 0.63x, A6 0.67x, A9 0.70x, DS4 0.74x, A10 0.77x),
as with 30 (0.83x). This run also had the read-path change of #69770,
which does not touch the PFOR windows these queries read.
- Data bytes are identical, and the index written with 0 and with 30 has
exactly the size the previous binary writes.
- Write cost: the choice adds about 48 ns per window, 79 ms over the
1.6M AgentLogs windows.

### Release note

SNII now chooses per index how many position windows to store in the
faster but larger PFOR form, spending at most 8% of the size of the
windows zstd compresses; `snii_prx_zstd_min_saving_percent` defaults to
-1 for this, and 0-100 still sets a fixed percentage.
@airborne12
airborne12 merged commit 6f6a826 into apache:master Oct 29, 2026
51 checks passed
@airborne12
airborne12 deleted the snii-prx-read-path branch October 29, 2026 01:10
github-actions Bot pushed a commit that referenced this pull request Oct 29, 2026
### What problem does this PR solve?

Issue Number: None

Related PR: #69649, #69769

Problem Summary: A phrase query decodes the positions of the selected
documents of every .prx window it reads. Timed per window on a 10M-row
AgentLogs load, a zstd window cost 18.6 us against 1.4 us for a PFOR
window: 11.7 us to decode the plain payload, 4.8 us to decompress, and
about 2 us more because every window created and freed its own zstd
context (about 96 KB) and a zero-filled output vector. Two commits:

1. **Decode the plain payload without a Status per document.**
`decode_payload_csr_selective` made at least one out-of-line call
returning a Status for every document of the window, selected or not
(`get_varint32_fast`, `skip_varints`, `decode_delta_run`). The loop now
keeps the cursor in a register and handles the usual cases inline: a
single-byte count, walking past the deltas of a document that is not
selected, and a run of single-byte deltas. Anything else -- a multi-byte
value, a run that does not fit, a prefix sum that would overflow -- goes
to the checked decoders from the same cursor, so they decide and report
as before. The output vector grows as before; nothing is reserved up
front.
2. **Reuse one zstd context and buffer per index source.**
`ZstdDecompressor` keeps one context, created at the first zstd frame,
and one output buffer that only grows and is not zero-filled
(decompression writes every byte before it is read). The buffer comes
from the Doris allocator (`DorisUniqueBufferPtr`), so the decoding
task's memory tracker accounts for it while it is held.
`SniiIndexSource` owns one, and the cursors it opens pass it to the .prx
decoder through `PrxDecodeContext`. The cursors of a source decode on
its thread one window at a time, and the decoded payload is consumed
before the next window is read. A source lives for one query on one
segment, so a query holds at most one context and one buffer per segment
it reads, and only if it decodes a zstd window; before, each window
created and released them. Compaction decodes through an allocation
gate, which keeps the old path, as does a cursor given no decompressor.

Hot queries, old against new read path in one process (run-time switch
in an experiment build, 6 paired cycles), on 99M AgentLogs rows written
with the smallest frames, as every segment written before #69649 is
(mostly zstd windows): DS7 (unfiltered phrase) 0.87x, A6 0.89x, A10
0.93x, DS4 (phrase prefix) 0.94x, A9 0.95x, DS1 0.96x; repeating the old
path against itself gave 0.93-1.05. The gain follows the zstd windows a
query reads: the phrase queries of AgentLogs written with #69649 read
PFOR windows and do not change, and on TextBench, whose large windows
stay zstd, the time per window is the decompression itself and the
change is within noise. Every query returns the same result.

### Release note

None
yiguolei pushed a commit that referenced this pull request Oct 29, 2026
yiguolei pushed a commit that referenced this pull request Oct 30, 2026
… on faster .prx windows #69769 (#69796)

### What problem does this PR solve?

Issue Number: None

Related PR: #69769, #69649 (backport #69795), #69770 (backport #69788)

Problem Summary: Backport of #69769 to branch-4.2, stacked on #69795
(the backport of #69649). #69649 stores a .prx window as PFOR instead of
zstd when zstd saves less than `snii_prx_zstd_min_saving_percent` (30)
of the PFOR frame, because every read of a zstd window pays a
decompression. What that costs and buys depends on how much zstd saves
on the data. Replaying the 1.6M windows of 10M AgentLogs rows, 30 spends
8% more bytes on the windows zstd compresses and cuts their decode time
by 42%; on the windows of TextBench service logs it spends 5% and cuts
6%; on data whose windows zstd shrinks by just under 30% it stores every
window as PFOR. No single percentage fits all data.

Each index of a segment now spends a size budget instead:
`PrxZstdBudget`, owned by its `LogicalIndexWriter` and reached through
`PrxWindowLimits`, so the resident and the spilled encoder, and the
ordinary and the compaction build, all use it. A window whose smallest
frame is zstd is stored in its faster frame (PFOR, or raw when smaller)
when
- its saving is below the share whose trades, over the windows the index
has seen, fit 8% of their smallest frames, which puts the budget on the
windows zstd shrinks least, and
- the bytes the index has actually traded, this window included, still
fit 8% of the smallest frames seen. This makes 8% a bound for any order
of windows: fed the TextBench windows from the largest saving to the
smallest, the share alone spends 36%.

Until it has seen 64 windows the share is 30, the previous default. The
windows counted are those that get a zstd frame (a plain payload of at
least 512 bytes), the only ones whose codec a share decides; singleton
and short windows are written as before and counted neither in the
allowance nor in the warmup. The choice uses the frames the writer
builds anyway: nothing is compressed twice or scanned ahead, and the
same input gives the same bytes. `snii_prx_zstd_min_saving_percent` now
defaults to -1 (the budget); 0-100 keeps a fixed share, 0 the smallest
frame.

The budget is 8% rather than less: with it the AgentLogs phrase queries
below read nearly the same frames as with 30, while in the replay a 5%
budget cuts the decode time by 31% instead of 39%.

Same binary, one stream load per table, size against the smallest frames
(0):

| data | index, 30 | index, budget | table, 30 | table, budget |
|---|---|---|---|---|
| AgentLogs 99M rows | +4.13% | +3.65% | +1.92% | +1.69% |
| AgentLogs 10M rows | +4.59% | +3.37% | +2.13% | +1.57% |
| TextBench 12M rows | +0.89% | +0.79% | +0.51% | +0.45% |

- The phrase queries read the same frames with 30 and with the budget:
at 99M rows all PFOR for DS1, DS6, DS7, A6 and A9 (for example 107,307
frames for DS7), and about 11,170 zstd frames of about 259,000 for A10
with both; on TextBench most windows stay zstd either way (582 against
583 zstd frames of 599 for 'connection reset').
- Hot queries in one process, budget data against 30 data with the same
read path (paired cycles: 6 for the 25 AgentLogs queries at 10M rows, 10
for the 15 TextBench queries): 0.96-1.06 and 0.97-1.05; every query
returns the same result.
- Against the data written before #69649 (smallest frames), the 11
AgentLogs phrase queries at 99M rows take 0.83x in geometric mean with
the budget data (DS7 0.63x, A6 0.67x, A9 0.70x, DS4 0.74x, A10 0.77x),
as with 30 (0.83x). This run also had the read-path change of #69770,
which does not touch the PFOR windows these queries read.
- Data bytes are identical, and the index written with 0 and with 30 has
exactly the size the previous binary writes.
- Write cost: the choice adds about 48 ns per window, 79 ms over the
1.6M AgentLogs windows.

### Branch adaptations

None. `git cherry-pick 0571eb6` applied cleanly on top of the
backport of #69649, which it needs: it changes the setting #69649 adds,
and the automatic pick to branch-4.2 failed because branch-4.2 does not
have #69649. This PR is stacked on that backport, #69795, so it shows
both commits until #69795 merges; merge #69795 first.

### Validation

- Built and run with both backports (this one and the stacked #69769
backport) applied to branch-4.2 at 711db43, the commit before #69625:
its lance-c update is not in the local third-party build, and the 16
commits after it touch no SNII file. `PrxPod*`, `Snii*`,
`DorisSniiFileReader*`, `*InvertedIndex*`, `*IndexFileWriter*` and
`*IndexFileReader*`: 1710 tests passed, including the new budget, codec
choice, writer and configuration tests.
- On master both changes passed CI.
- Not built locally on the branch-4.2 tip; this PR's BE UT run covers
that.

### Release note

SNII now chooses per index how many position windows to store in the
faster but larger PFOR form, spending at most 8% of the size of the
windows zstd compresses; `snii_prx_zstd_min_saving_percent` defaults to
-1 for this, and 0-100 still sets a fixed percentage.

### Check List (For Author)

- Test: Unit Test
- SniiPrxZstdBudget: the prior share, the windows zstd shrinks least are
traded first, the bound under a descending order, windows with nothing
to trade.
- SniiPrxPodTest: the faster frame (PFOR or raw) and when the budget is
asked.
- SniiStreamedWriterSessionTest: windows that save 1-29% through the
ordinary and the streamed (compaction) build; 30 trades all of them, the
budget at most 8% of the smallest frames.
    - SniiWriterPrxZstdSaving: the default and the config mapping.
- Manual test: index size of 10M and 99M AgentLogs and 12M TextBench
loads with 0, 30 and the budget; frames read per codec from query
profiles; interleaved hot A/B in one process.
- Behavior changed: Yes (the default .prx codec choice of newly written
segments; existing segments are read as before)
- Does this need documentation: No

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: martin.foo838 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants