Skip to content

[improvement](be) Let SNII skip zstd for .prx windows where it saves little over PFOR - #69649

Merged
airborne12 merged 1 commit into
apache:masterfrom
airborne12:snii-prx-zstd-min-saving
Oct 27, 2026
Merged

airborne12 merged 1 commit into
apache:masterfrom
airborne12:snii-prx-zstd-min-saving

Conversation

@airborne12

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The auto .prx codec stores each position window in the smallest of PFOR, zstd and
raw. Every read of a zstd window pays a decompression that PFOR does not, and the choice ignores
that cost. On 99M AgentLogs rows 74% of the position windows read by an unfiltered phrase query
were zstd, and decoding them took 58% of the index execution time of the query.

PrxWindowLimits gets zstd_min_saving_percent, set from the new mutable config
snii_prx_zstd_min_saving_percent. A readable PFOR frame is replaced by the zstd one only if zstd is
smaller by at least that percentage of the PFOR frame. The default is 30; 0 picks the smaller frame
as before and writes a byte-identical index (99M rows: 61,886,854,661 bytes with and without this
change). Readers handle every codec, so the setting affects only segments written afterwards, and
compaction rewrites with whatever is set then. The compression is still computed, so the write cost
does not change.

Effect of the setting, 10M rows (index size, DS7 = unfiltered phrase on the output column, position
decode time from the query profile): 10 -> +0.02% size, no change for DS7; 20 -> +0.5%, no change;
30 -> +2.1%, decode 252 ms -> 85 ms; 50 -> +6.3%, no further gain; 100 (never zstd) -> +8.5%.
30 is the smallest value that removes the zstd windows of frequent terms, which zstd beats PFOR by
20-30% on and which phrase queries read most. On 99M rows with 30: index +1.9% (63.07 GB against
61.89 GB, still below the 65.70 GB of V3), load time within 2% of a control load, hot queries in the
same process 0.63x (unfiltered phrase), 0.67x (phrase in input or output), 0.75x (phrase prefix),
0.77x, 0.87x, the others within 1.02x (one query measured 1.06x once and 1.00-1.05x in three
repeats); cold runs 10-18% faster for the phrase queries. The best value depends on the data, since
it is how much zstd beats PFOR on the window contents; it was calibrated on one corpus.

Release note

SNII position windows are stored with zstd only where it saves at least 30% over PFOR
(snii_prx_zstd_min_saving_percent): phrase queries are faster and the index is about 2% larger.
Set it to 0 for the previous behavior.

Check List (For Author)

  • Test: Unit Test on master, 1723 tests pass with the inline-read change stacked (SniiPrxPodTest: the saving boundary, PFOR unreadable, limit validation; SniiWriterGoldenBytes: the config reaches the writer; the default is 30). Manual test: index size and load time at 10M and 99M rows, interleaved hot A/B and repeated cold runs.
  • Behavior changed: Yes (newly written segments are about 2% larger; set snii_prx_zstd_min_saving_percent to 0 to restore)
  • Does this need documentation: No

🤖 Generated with Claude Code

…little over PFOR

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The auto .prx codec stores each position window in the smallest of PFOR, zstd and
raw. Every read of a zstd window pays a decompression that PFOR does not, and the choice ignores
that cost. On 99M AgentLogs rows 74% of the position windows read by an unfiltered phrase query
were zstd, and decoding them took 58% of the index execution time of the query.

PrxWindowLimits gets zstd_min_saving_percent, set from the new mutable config
snii_prx_zstd_min_saving_percent. A readable PFOR frame is replaced by the zstd one only if zstd is
smaller by at least that percentage of the PFOR frame. The default is 30; 0 picks the smaller frame
as before and writes a byte-identical index (99M rows: 61,886,854,661 bytes with and without this
change). Readers handle every codec, so the setting affects only segments written afterwards, and
compaction rewrites with whatever is set then. The compression is still computed, so the write cost
does not change.

Effect of the setting, 10M rows (index size, DS7 = unfiltered phrase on the output column, position
decode time from the query profile): 10 -> +0.02% size, no change for DS7; 20 -> +0.5%, no change;
30 -> +2.1%, decode 252 ms -> 85 ms; 50 -> +6.3%, no further gain; 100 (never zstd) -> +8.5%.
30 is the smallest value that removes the zstd windows of frequent terms, which zstd beats PFOR by
20-30% on and which phrase queries read most. On 99M rows with 30: index +1.9% (63.07 GB against
61.89 GB, still below the 65.70 GB of V3), load time within 2% of a control load, hot queries in the
same process 0.63x (unfiltered phrase), 0.67x (phrase in input or output), 0.75x (phrase prefix),
0.77x, 0.87x, the others within 1.02x (one query measured 1.06x once and 1.00-1.05x in three
repeats); cold runs 10-18% faster for the phrase queries. The best value depends on the data, since
it is how much zstd beats PFOR on the window contents; it was calibrated on one corpus.

### Release note

SNII position windows are stored with zstd only where it saves at least 30% over PFOR
(snii_prx_zstd_min_saving_percent): phrase queries are faster and the index is about 2% larger.
Set it to 0 for the previous behavior.

### Check List (For Author)

- Test: Unit Test (SniiPrxPodTest: the saving boundary, PFOR unreadable, limit validation; SniiWriterGoldenBytes: the config reaches the writer; the default is 30). Manual test: index size and load time at 10M and 99M rows, interleaved hot A/B and repeated cold runs.
- Behavior changed: Yes (newly written segments are about 2% larger; set snii_prx_zstd_min_saving_percent to 0 to restore)
- Does this need documentation: No

Co-Authored-By: Claude Sonnet 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

@hello-stephen

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

------ Round 1 ----------------------------------
============================================
q1	17574	5208	5084	5084
q2	2105	315	264	264
q3	10236	1428	811	811
q4	4683	467	339	339
q5	7440	802	535	535
q6	183	185	156	156
q7	740	805	614	614
q8	9253	1329	1137	1137
q9	5550	4228	4227	4227
q10	6819	1290	1025	1025
q11	427	260	231	231
q12	646	451	284	284
q13	18078	3684	3065	3065
q14	268	274	245	245
q15	q16	752	707	651	651
q17	1215	772	582	582
q18	7734	6184	6169	6169
q19	1133	973	628	628
q20	410	344	229	229
q21	5575	2468	2448	2448
q22	384	335	280	280
Total cold run time: 101205 ms
Total hot run time: 29004 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	6000	5963	6124	5963
q2	778	627	592	592
q3	5610	5817	5413	5413
q4	2034	2157	1576	1576
q5	5861	5807	5552	5552
q6	243	188	145	145
q7	2265	2098	1828	1828
q8	3034	2670	2660	2660
q9	8028	7633	7533	7533
q10	3654	3602	3164	3164
q11	587	406	395	395
q12	678	666	511	511
q13	3317	3602	3068	3068
q14	300	299	267	267
q15	q16	661	676	620	620
q17	7806	7023	6883	6883
q18	12965	12261	12980	12261
q19	916	804	810	804
q20	2135	2164	1929	1929
q21	5767	4638	4711	4638
q22	494	438	418	418
Total cold run time: 73133 ms
Total hot run time: 66220 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 150963 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 5a970b54175adc962d524c5d73b889a873d1d7b5, data reload: false

query5	4302	577	439	439
query6	428	212	191	191
query7	4801	450	225	225
query8	317	171	156	156
query9	8771	3109	3072	3072
query10	473	323	250	250
query11	6004	3843	3556	3556
query12	153	92	88	88
query13	1246	446	331	331
query14	6037	3996	3592	3592
query14_1	3362	3381	3395	3381
query15	202	206	178	178
query16	952	425	411	411
query17	836	638	522	522
query18	2403	421	307	307
query19	207	157	139	139
query20	83	84	83	83
query21	209	127	105	105
query22	12938	13051	12837	12837
query23	13433	12774	12447	12447
query23_1	12519	12470	12519	12470
query24	7360	818	457	457
query24_1	458	472	460	460
query25	515	397	341	341
query26	1275	256	131	131
query27	2771	437	277	277
query28	4703	1948	1973	1948
query29	1545	575	423	423
query30	297	215	180	180
query31	1006	928	806	806
query32	141	87	90	87
query33	523	291	215	215
query34	904	838	500	500
query35	723	745	646	646
query36	818	858	729	729
query37	120	99	86	86
query38	1758	1734	1672	1672
query39	789	801	760	760
query39_1	741	719	730	719
query40	218	111	95	95
query41	67	66	63	63
query42	86	82	81	81
query43	341	363	310	310
query44	1351	701	695	695
query45	174	177	163	163
query46	830	959	564	564
query47	2984	2997	2921	2921
query48	302	305	236	236
query49	586	396	284	284
query50	674	272	210	210
query51	10699	10403	10503	10403
query52	79	78	73	73
query53	183	216	150	150
query54	227	194	181	181
query55	67	66	66	66
query56	225	210	219	210
query57	1644	1609	1542	1542
query58	283	258	261	258
query59	2139	2166	2022	2022
query60	291	260	262	260
query61	148	143	141	141
query62	386	332	287	287
query63	190	158	157	157
query64	2741	918	748	748
query65	3315	3255	3275	3255
query66	1787	422	295	295
query67	20458	20203	19946	19946
query68	3111	1000	612	612
query69	385	282	252	252
query70	787	781	757	757
query71	314	240	205	205
query72	2879	2788	2458	2458
query73	519	529	299	299
query74	4615	4525	4331	4331
query75	2356	2300	1923	1923
query76	2325	1016	593	593
query77	353	382	285	285
query78	9885	9674	9109	9109
query79	972	854	519	519
query80	611	448	374	374
query81	536	326	276	276
query82	586	137	111	111
query83	274	200	190	190
query84	310	133	108	108
query85	891	498	490	490
query86	319	265	235	235
query87	1982	1948	1822	1822
query88	3612	2687	2651	2651
query89	334	276	240	240
query90	1877	171	165	165
query91	166	152	124	124
query92	97	89	91	89
query93	1004	1010	582	582
query94	457	314	261	261
query95	579	363	355	355
query96	666	477	234	234
query97	2454	2454	2307	2307
query98	166	149	151	149
query99	778	758	652	652
Total cold run time: 234533 ms
Total hot run time: 150963 ms

@hello-stephen

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

query1	0.01	0.00	0.00
query2	0.09	0.05	0.05
query3	0.26	0.09	0.09
query4	1.61	0.10	0.11
query5	0.22	0.25	0.24
query6	1.17	0.68	0.67
query7	0.04	0.01	0.00
query8	0.05	0.04	0.04
query9	0.40	0.36	0.35
query10	0.57	0.57	0.58
query11	0.20	0.15	0.15
query12	0.17	0.14	0.15
query13	0.50	0.50	0.48
query14	1.03	1.03	1.00
query15	0.62	0.60	0.61
query16	0.34	0.35	0.36
query17	1.12	1.18	1.16
query18	0.20	0.19	0.20
query19	2.07	2.02	2.06
query20	0.02	0.01	0.02
query21	15.50	0.18	0.13
query22	4.89	0.06	0.06
query23	16.26	0.32	0.13
query24	2.82	0.48	0.40
query25	0.12	0.08	0.05
query26	1.28	0.22	0.15
query27	0.04	0.04	0.03
query28	3.73	0.75	0.37
query29	12.49	4.02	3.26
query30	0.28	0.13	0.10
query31	2.78	0.54	0.33
query32	3.22	0.60	0.52
query33	3.19	3.18	3.20
query34	15.47	3.96	3.39
query35	3.35	3.33	3.36
query36	0.57	0.48	0.41
query37	0.09	0.07	0.07
query38	0.05	0.04	0.04
query39	0.04	0.02	0.02
query40	0.17	0.15	0.15
query41	0.09	0.03	0.03
query42	0.04	0.03	0.03
query43	0.04	0.03	0.03
Total cold run time: 97.2 s
Total hot run time: 24.42 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (5/5) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.16% (35977/46628)
Line Coverage 62.38% (406337/651377)
Region Coverage 58.86% (343496/583543)
Branch Coverage 59.75% (158906/265937)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (5/5) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.16% (35977/46628)
Line Coverage 62.38% (406333/651377)
Region Coverage 58.86% (343465/583543)
Branch Coverage 59.75% (158905/265937)

@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.

Static review of all eight changed files at 5a970b5 found no actionable new defect. There are no existing inline findings to carry forward. Two complete-scope reviews and an independent codec-boundary review all returned NO_NEW_VALUABLE_FINDINGS; the main review independently verified their evidence and completed the final sweep. Converged in 1 round.

Critical checkpoint conclusions:

  1. Goal and evidence: the shared selector requires ZSTD to beat the complete readable PFOR frame by the configured percentage. New unit cases cover the 14/15 transition, 0/100 endpoints, unreadable-PFOR fallback and invalid 101; the production writer test checks the setting changes output size. These tests were inspected, not executed.
  2. Scope and reuse: the change is focused on one BE setting, an existing limits value and the shared selector. Both encoders reuse that policy.
  3. Concurrency and locking: configuration uses the existing mutable scalar mechanism and is copied once into each index writer/session. No new thread, lock, lock nesting or work under locks is introduced; windows use the owned snapshot.
  4. Lifecycle and initialization: the new scalar follows adjacent registration, without a computed cross-TU initializer. Writer/session copies retain the complete limits value; ownership, cleanup and buffer lifetimes are unchanged.
  5. Configuration: default 30 is mutable, clamped to [0,100] at production resolution and observed by subsequent writes without restart. Existing segments retain their tagged codecs.
  6. Compatibility: codec IDs, frame layout, CRC and reader guards are unchanged. Existing readers handle every selected codec; the new policy value is not serialized. Zero restores the previous strict size/tie policy. No new libc API or dependency is introduced; release ELF compatibility was not executed/audited.
  7. Thrift/protobuf: no definitions or wire IDs change.
  8. Parallel paths: ordinary loads, rebuilds/ADD INDEX/schema changes and streamed compaction reach the shared resolver. Inline and windowed/split postings preserve the complete limits object through resident and replayable encoders. Intentionally inherited existing index bytes remain unchanged.
  9. Conditions and boundary safety: comments explain PFOR protection and reader independence. Exact percentage equality is accepted; unreadable PFOR bypasses the percentage, RAW fallback remains eligible, and singleton/sub-512/forced-codec paths retain their contracts. Short-circuiting bounds both uint64 products by readable PFOR size, preventing reachable arithmetic overflow.
  10. Test coverage: new selector/default/production writer cases complement existing round-trip, corruption, tie, threshold, split and spill tests. Dedicated new exact-equality, nonzero-percentage replayable/split/streamed-compaction and HTTP-update integration cases are absent; those routes were checked statically. The size test does not itself query its outputs.
  11. Test results: no golden constants or expected-output files were changed. The fixture pins zero for historical digests and saves/restores the new setting, avoiding a test-order leak. New assertions are consistent with the constructed corpus by inspection; no test pass is claimed.
  12. Observability: existing configuration visibility and PRX RAW/ZSTD/PFOR/decode-time profile counters expose the resulting codec choice. No new hot-path logging is needed.
  13. Transactions and persistence: transaction visibility, rowset versions, MoW delete bitmaps, EditLog and failover logic are outside the change; no new durable policy metadata is required.
  14. Writes and failure atomicity: only the selected existing frame representation changes. Existing checked Status propagation, limits validation, CRC framing and writer/session failure handling remain in place. No new owning allocation or partial-publication path was found.
  15. FE/BE variables: this is a BE-local writer configuration; no new transmitted variable or scattered send path needs updating.
  16. Performance and memory: the new decision adds constant-time arithmetic and a scalar snapshot. Candidate compression and buffers already existed; retaining compression at 100 is an optional optimization, not a new regression. Claimed query/size/load improvements and calibration beyond the author's corpus were not independently measured.
  17. Other issues and user focus: no additional user focus was supplied. The initial fallback/arithmetic and configuration-propagation risks are dismissed with code evidence; no unresolved correctness candidate or unreviewed changed path remains.

Validation limits: static inspection only, as required by this review contract. No builds, tests, benchmarks or runtime validation were run, and no repository source was modified.

Review converged after 1 round(s).

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (5/5) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.16% (35977/46628)
Line Coverage 62.38% (406333/651377)
Region Coverage 58.86% (343465/583543)
Branch Coverage 59.75% (158905/265937)

@airborne12
airborne12 marked this pull request as ready for review October 27, 2026 14:58

@eldenmoon eldenmoon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@airborne12
airborne12 merged commit 428c241 into apache:master Oct 27, 2026
43 checks passed
@airborne12
airborne12 deleted the snii-prx-zstd-min-saving branch October 27, 2026 23:41
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 added 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
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 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]>
airborne12 pushed a commit to airborne12/apache-doris that referenced this pull request Oct 30, 2026
…little over PFOR

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The auto .prx codec stores each position window in the smallest of PFOR, zstd and
raw. Every read of a zstd window pays a decompression that PFOR does not, and the choice ignores
that cost. On 99M AgentLogs rows 74% of the position windows read by an unfiltered phrase query
were zstd, and decoding them took 58% of the index execution time of the query.

PrxWindowLimits gets zstd_min_saving_percent, set from the new mutable config
snii_prx_zstd_min_saving_percent. A readable PFOR frame is replaced by the zstd one only if zstd is
smaller by at least that percentage of the PFOR frame. The default is 30; 0 picks the smaller frame
as before and writes a byte-identical index (99M rows: 61,886,854,661 bytes with and without this
change). Readers handle every codec, so the setting affects only segments written afterwards, and
compaction rewrites with whatever is set then. The compression is still computed, so the write cost
does not change.

Effect of the setting, 10M rows (index size, DS7 = unfiltered phrase on the output column, position
decode time from the query profile): 10 -> +0.02% size, no change for DS7; 20 -> +0.5%, no change;
30 -> +2.1%, decode 252 ms -> 85 ms; 50 -> +6.3%, no further gain; 100 (never zstd) -> +8.5%.
30 is the smallest value that removes the zstd windows of frequent terms, which zstd beats PFOR by
20-30% on and which phrase queries read most. On 99M rows with 30: index +1.9% (63.07 GB against
61.89 GB, still below the 65.70 GB of V3), load time within 2% of a control load, hot queries in the
same process 0.63x (unfiltered phrase), 0.67x (phrase in input or output), 0.75x (phrase prefix),
0.77x, 0.87x, the others within 1.02x (one query measured 1.06x once and 1.00-1.05x in three
repeats); cold runs 10-18% faster for the phrase queries. The best value depends on the data, since
it is how much zstd beats PFOR on the window contents; it was calibrated on one corpus.

### Release note

SNII position windows are stored with zstd only where it saves at least 30% over PFOR
(snii_prx_zstd_min_saving_percent): phrase queries are faster and the index is about 2% larger.
Set it to 0 for the previous behavior.

### Check List (For Author)

- Test: Unit Test on master, 1723 tests pass with the inline-read change stacked (SniiPrxPodTest: the saving boundary, PFOR unreadable, limit validation; SniiWriterGoldenBytes: the config reaches the writer; the default is 30). Manual test: index size and load time at 10M and 99M rows, interleaved hot A/B and repeated cold runs.
- Behavior changed: Yes (newly written segments are about 2% larger; set snii_prx_zstd_min_saving_percent to 0 to restore)
- Does this need documentation: No

Source-PR: apache#69649
Source-Head: 428c241

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants