Skip to content

perf(opt): close the btopt dict-band gap via upstream getAllMatches shape#448

Merged
polaz merged 13 commits into
mainfrom
perf/btopt-no-match-fastpath
Jun 25, 2026
Merged

perf(opt): close the btopt dict-band gap via upstream getAllMatches shape#448
polaz merged 13 commits into
mainfrom
perf/btopt-no-match-fastpath

Conversation

@polaz

@polaz polaz commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Closes the bulk of the btopt / btultra optimal-parser gap against libzstd on the
per-label-dictionary path, resolves the SIMD kernel tier once per encoder invoke
instead of per block, and fixes two correctness bugs (a Huffman height-limiter
panic and an out-of-range compression-level mis-resolution).

Perf: reshape the optimal-parse per-position find toward upstream's shape

Upstream's ZSTD_insertBtAndGetAllMatches is a FORCE_INLINE_TEMPLATE: the whole
per-position match-finder (rep probe + hash3 + binary-tree walk) is one inlined body,
called once per position, match-length template resolved once per block. Our path had
drifted into a chain of nested out-of-line calls paying per-position argument
marshalling upstream never pays.

  • Find monolith inlined into collect (upstream ZSTD_btGetAllMatches shape): rep
    probe + hash3 probe + BT tree walk folded into one out-of-line body, one call per
    position, replacing a nested call chain.
  • Cost profile passed by reference into the deepest per-position crossing (the
    24-byte struct was stack-copied every call).
  • DP scratch buffers taken once per block and shared across both btultra2 passes.
  • No-match literal path trimmed: deferred price-cache / stamp / base-node setup
    past the no-match seed, dropped a discarded price recompute, skipped the no-op
    plan-stats update on no-match segments.

Output is byte-identical to before across the suite; the encoder makes the same match
decisions, the work is just marshalled the way upstream marshals it.

Measured (i9-9900K, BMI2 + AVX2; L16 btopt, small-10k-random, compress-dict; libzstd
control flat at ~388 µs):
pure Rust 932 µs -> 657 µs (-29%), 2.40x -> 1.70x vs
libzstd.

Refactor: resolve the SIMD kernel tier once per invoke, not per block

The HC / optimal / btlazy2 strategy entries and the BT-walker dispatchers called
select_kernel() per block (btultra2 twice: seed + main pass), re-reading the cached
OnceLock atomic inside the hot strategy path. The Fast / Row / Dfast matchers already
cache the resolved tier in a field at construction; the binary-tree path now mirrors
that. MatchTable caches the tier, set once via select_kernel() at construction (once
per encoder invoke, since the matcher is built once per FrameCompressor); every
per-block strategy entry and BT dispatcher reads the cached field. select_kernel() is
now called exactly once per matcher, never inside a strategy or hot loop. Byte-identical
(the tier is constant for the process lifetime).

Fix: Huffman height limiter could leave a non-power-of-two Kraft sum

The height limiter (upstream HUF_setMaxHeight equivalent) walked the cost-repair fill
in the wrong direction on certain <=128 KB literal histograms, leaving a code whose
Kraft sum was not a power of two. The table builder rejects such a code, surfacing as a
panic. The limiter now fills downward correctly and validates the Kraft sum, falling
back to the distributed-weight construction when it cannot reach the bit-length cap; the
dead repair helper is removed.

Fix: out-of-range compression levels mis-resolved

resolve_level_params special-cased only Level(22) for the btultra2 source-size
resolver; Level(23+) fell through to the generic cParams path and resolved a different
configuration than the max level. Now Level(n) for n >= MAX_LEVEL clamps through the
same resolver.

Perf: hold the decode dictionary by borrowed pointer (mirror C zero-refcount apply)

Upstream zstd's ZSTD_copyDDictParameters stores RAW POINTERS into a caller-owned DDict
every frame (dctx->LLTptr = ddict->entropy.LLTable, prefixStart = dictContent) — no
refcount, no per-frame clone. We held an owned Arc<Dictionary> in three places (FSE +
Huffman scratch + decode buffer) and re-cloned all three each frame, dropping them at
teardown: six atomic refcount ops per frame on a value the dictionary owner already keeps
alive for the whole decode. The three scratch sites now hold Option<NonNull<Dictionary>>,
set from a live &DictionaryHandle each frame (one pointer store) and read through a
checked unsafe deref while the COW table-source is Dict. The pointee is kept alive
across the decode by the dictionary owner (the FrameDecoder registry, or the
&DictionaryHandle the caller holds across decode_all_with_dict_handle). NonNull
suppresses auto-Send/Sync, so unsafe impl Send/Sync is restored on each scratch type
(read-only borrow of Send + Sync data), with a compile-time assert pinning
FrameDecoder: Send + Sync. Byte-identical. i9 small-10k-random decompress-dict, flat
c_ffi control: L3 dfast -2.3%, L16 btopt -1.8%.

Tests

  • bt_optimal_all_kernel_tiers_emit_identical_sequences — forces each cached kernel tier
    and asserts identical sequence streams (scalar/SIMD bit-identity + per-tier wrapper
    coverage on one machine).
  • levels_above_max_resolve_identically_to_max — regression for the level-clamp fix.
  • level_params_strategy_and_search_method_agree_across_tiers — strategy/search-method
    consistency across the cParams source-size tiers.
  • build_limited_weights_handles_degenerate_alphabets — single-symbol / all-zero
    height-limiter early-out.
  • Huffman power-of-two invariant: deterministic trigger histogram in the default suite
    plus a 300k-case #[ignore] stress sweep.

Validation: full suite green (851 on x86 incl. dict_builder), cargo clippy -D warnings
clean, cargo fmt --check clean.

Summary by CodeRabbit

  • New Features

    • Improved BT/HC match-finding and BT optimal-parse candidate probing (including consolidated rep and optional hash3 probing) for more consistent parsing decisions.
    • Refined encoder level-parameter resolution, including tighter clamping for out-of-range level requests.
  • Bug Fixes

    • Fixed hash3 computation for borrowed/no-copy scenarios.
    • Corrected BT tree update cursor advancement behavior.
    • Strengthened Huffman height limiting to preserve Kraft-sum canonical validity.
    • Improved dictionary support during decoding by using call-scoped dictionary borrowing.
  • Tests

    • Added/updated regressions for Huffman Kraft-sum invariants, level-resolution/BT-optimal consistency, kernel-tier equivalence, and dictionary-aware decoder behavior.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@polaz, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 52 minutes and 5 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 22091739-88ae-436f-8c9e-fee47bf1a1f3

📥 Commits

Reviewing files that changed from the base of the PR and between 0e55352 and 1fb9932.

📒 Files selected for processing (2)
  • zstd/src/decoding/frame_decoder.rs
  • zstd/src/decoding/frame_decoder/tests.rs
📝 Walkthrough

Walkthrough

The PR updates HC/BT match collection and optimal-parse dispatch, refactors decoder dictionary handling across decode paths, and changes Huffman height-limiting validation with new tests.

Changes

Encoder match and optimal-parse flow

Layer / File(s) Summary
Level parameter resolution
zstd/src/encoding/levels/config.rs, zstd/src/encoding/match_generator/tests.rs
Upstream cparams now build LevelParams directly, the BT min_match clamp shifts to 3..=6, and levels above MAX_LEVEL resolve through the btultra2 source-size path.
BT match collection and tree stepping
zstd/src/encoding/match_table/storage.rs, zstd/src/encoding/hc/generator.rs, zstd/src/encoding/hc/mod.rs
The match table caches the selected kernel, BT insertion forwards explicit rep/hash3 probe inputs, the macro merges rep and hash3 probing before BT walking, and dict descent and tree updates change compare and cursor handling.
Optimal DP workspace
zstd/src/encoding/hc/optimal.rs
build_optimal_plan_impl_body! now reuses shared buffers, defers price-cache setup to the priced path, and changes the no-match return path and DP borrowing shape.
Optimal orchestration and candidate collection
zstd/src/encoding/hc/optimal.rs, zstd/src/encoding/strategy.rs
start_matching_optimal and run_btultra2_seed_pass reuse one plan-buffer set across seed and main passes, dispatch kernel-specific DP wrappers directly, update the scalar run-loop warning suppression, and align the dispatch-flow docs with the new call path. collect_optimal_candidates_initialized_body! also rewrites the BT catch-up loop and BT/HC fallback wiring around the new insert-step helpers.

Decoder dictionary handling

Layer / File(s) Summary
Decode buffer and scratch dictionary borrows
zstd/src/decoding/decode_buffer.rs, zstd/src/decoding/scratch.rs, zstd/src/decoding/scratch/tests.rs
Decode buffers and decoding scratch now read dictionary data through call-scoped borrows, remove stored dictionary handles, and update the table access, attachment, and tests to use borrowed dictionaries.
Block, literal, and sequence decode dictionary flow
zstd/src/decoding/block_decoder.rs, zstd/src/decoding/block_decoder/tests.rs, zstd/src/decoding/literals_section_decoder.rs, zstd/src/decoding/literals_section_decoder/*, zstd/src/decoding/sequence_section_decoder.rs, zstd/src/decoding/sequence_section_decoder/tests.rs, zstd/src/decoding/seq_decoder_*
BlockDecoder, literal-section decoding, and sequence-section decoding now accept Option<&Dictionary> and forward it through compressed-block, literal-table, and sequence execution paths across kernel wrappers and pipelined helpers.
Frame decoder dictionary state
zstd/src/decoding/frame_decoder.rs, zstd/src/decoding/frame_decoder/tests.rs
FrameDecoder stores an active dictionary handle per frame, resolves a borrowed dictionary for block decoding and the direct path, and updates the direct decode lifetime management.

Huffman height limiting

Layer / File(s) Summary
Height limiting and regression coverage
zstd/src/huff0/huff0_encoder.rs, zstd/src/huff0/huff0_encoder/tests.rs
limited_weights_into now validates the Kraft sum directly, repair_limited_lengths is removed, enforce_max_height rewrites the negative-cost correction loop, and the regression tests cover the power-of-two invariant on fixed, fuzzed, and degenerate histograms.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related issues

Possibly related PRs

Poem

A rabbit hopped through code so bright,
Past BT trails and buffer light.
I nibbled reps, then bounded free,
While Huffman sums held true for me.
And dicts went by on borrowed air 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly points to the btopt match-finder refactor and upstream getAllMatches-style change.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/btopt-no-match-fastpath

Comment @coderabbitai help to get the list of available commands.

…hape

Reshape the optimal-parser per-position find toward upstream's
ZSTD_insertBtAndGetAllMatches shape (one inlined body, one call per
position, match-length template resolved once per block), and fix a
Huffman height-limiter panic reachable on realistic literal histograms.

Perf (byte-identical output across the suite):

- Inline the find monolith into collect (upstream ZSTD_btGetAllMatches
  shape): rep probe + hash3 probe + BT tree walk folded into one
  out-of-line body, one call per position, replacing a nested call chain
  that paid per-position argument marshalling upstream never pays.
- Resolve the SIMD kernel tier once per block, not per segment: the
  per-position loop runs under one kernel-monomorphized expansion instead
  of re-entering the runtime CPU-tier dispatch on every call.
- Pass the cost profile by reference into the deepest per-position
  crossing (the 24-byte struct was stack-copied every call).
- Take the DP scratch buffers once per block and share them across both
  btultra2 passes, dropping a take + restore round-trip per segment.
- Trim the no-match literal path: defer price-cache / stamp / base-node
  setup past the no-match seed, drop a discarded price recompute, skip
  the no-op plan-stats update on no-match segments.

i9-9900K (BMI2 + AVX2), L16 btopt small-10k-random compress-dict,
libzstd control flat at 388 us: pure Rust 932 us -> 657 us (-29%),
2.40x -> 1.70x vs libzstd.

Huffman fix:

The height limiter (upstream HUF_setMaxHeight equivalent) walked the
cost-repair fill in the wrong direction on certain <=128 KB literal
histograms, leaving a code whose Kraft sum was not a power of two. The
table builder rejects such a code, surfacing as a panic. The limiter now
fills downward correctly and validates the Kraft sum, falling back to the
distributed-weight construction when it cannot reach the bit-length cap;
the dead repair helper is removed. Adds a regression test (pinned trigger
histogram plus 300k fuzzed histograms capped at the 128 KB literal limit).
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR optimizes compression and dictionary-backed decoding paths. The main changes are:

  • Reshapes BT optimal and btlazy2 match collection into a more monolithic per-position flow.
  • Caches the selected SIMD kernel tier on the match table instead of resolving it per block.
  • Threads frame-scoped dictionary borrows through literal, sequence, and repeat-from-dictionary decoding.
  • Fixes max-level resolution so levels at or above MAX_LEVEL use the max-level btultra2 resolver.
  • Adds Kraft-sum validation and fallback for Huffman height limiting.

Confidence Score: 0/5

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex executed an initial repository state check and created the trex-artifacts directory in /home/user/repo, recording the head commit 1fb9932.
  • T-Rex attempted to run the deterministic dictionary-compression repro via multibash, but subsequent attempts failed with Not connected.
  • T-Rex ran the requested verification, but its local artifact references were not uploaded.
  • The huffman-height-limit-02-after.log artifact captures the attempted head run and its exit status, and the blocker that every subsequent multibash call returned Not connected.
  • The decode-dict-borrow-before/after artifacts show the base command failure and the head command errors, and neither base nor head completed a valid dictionary decode test.
  • Instrumentation diffs were generated for the kernel-tier cache instrumentation, but cargo tests could not be executed due to tool restrictions, and no before/after proof logs were produced.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (6): Last reviewed commit: "fix(decode): cover forced-dict, lsm snap..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
zstd/src/encoding/hc/generator.rs (1)

877-902: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate dictionary descent on dms.is_primed().

Line 877 walks $table.dms.table() directly; use the canonical primed predicate before descending so a present-but-invalid/stale DMS table is not searched.

🐛 Proposed fix
-        if let Some(dms) = $table.dms.table().filter(|_| compares_left > 0) {
+        if compares_left > 0 && $table.dms.is_primed() {
+            let dms = $table.dms.table().expect("primed dms has table");
             let region = $table.dms.region_len();

Based on learnings, use table.dms.is_primed() as the canonical validity predicate before walking/reusing DMS state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zstd/src/encoding/hc/generator.rs` around lines 877 - 902, The dictionary
descent in the HC generator is using $table.dms.table() directly, which can walk
stale or invalid DMS state. Update the check around the existing
MatchTable::hash_position_at and dms.hash_table traversal to first require
table.dms.is_primed() as the canonical predicate, then only descend when that
returns true and compares_left > 0. Keep the rest of the
dcur/common_smaller/common_larger logic unchanged.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@zstd/src/encoding/levels/config.rs`:
- Around line 1160-1162: The clamping logic in the level-resolution path is
bypassing the special Level(22) resolver for values above the max level, so
route any clamped high level through the same Level::Level22 handling used by
resolve_level_params_for_source_size and level22_btultra2_params_for_source_size
instead of directly calling default_cparams with 22; update the
cparams_tier/default_cparams flow so Level(23)+ resolves identically to
Level(22).

In `@zstd/src/huff0/huff0_encoder/tests.rs`:
- Around line 54-101: The large randomized sweep in the huff0_encoder tests is
too expensive for normal runs, so keep the fixed TRIGGER regression in this test
but reduce the default iteration count or move the 300k-case loop into an
ignored/stress test. Update the loop in the tests around build_limited_weights
and HuffmanTable::build_from_counts_gated so regular unit tests stay fast while
still preserving the high-coverage fuzz path separately.

---

Outside diff comments:
In `@zstd/src/encoding/hc/generator.rs`:
- Around line 877-902: The dictionary descent in the HC generator is using
$table.dms.table() directly, which can walk stale or invalid DMS state. Update
the check around the existing MatchTable::hash_position_at and dms.hash_table
traversal to first require table.dms.is_primed() as the canonical predicate,
then only descend when that returns true and compares_left > 0. Keep the rest of
the dcur/common_smaller/common_larger logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 602ccde7-7fae-469e-ac17-a93e415fcc2e

📥 Commits

Reviewing files that changed from the base of the PR and between 73cd616 and 5e371b0.

📒 Files selected for processing (8)
  • zstd/src/encoding/hc/generator.rs
  • zstd/src/encoding/hc/optimal.rs
  • zstd/src/encoding/levels/config.rs
  • zstd/src/encoding/match_generator/tests.rs
  • zstd/src/encoding/match_table/storage.rs
  • zstd/src/encoding/strategy.rs
  • zstd/src/huff0/huff0_encoder.rs
  • zstd/src/huff0/huff0_encoder/tests.rs

Comment thread zstd/src/encoding/levels/config.rs
Comment thread zstd/src/huff0/huff0_encoder/tests.rs Outdated
polaz added 6 commits June 25, 2026 09:16
The HC / optimal / btlazy2 strategy entries and the BT-walker dispatchers
called select_kernel() per block (btultra2 twice: seed + main pass),
re-reading the cached OnceLock atomic inside the hot strategy path. The
Fast / Row / Dfast matchers already cache the resolved tier in a field at
construction; mirror that for the binary-tree path.

MatchTable now caches the resolved tier in a field, set once via
select_kernel() at construction (once per encoder invoke, since the matcher
is built once per FrameCompressor). Every per-block strategy entry and BT
dispatcher reads the cached field instead of re-detecting. select_kernel()
is now called exactly once per matcher (the documented "once at the entry
point of each encoder call" contract), never inside a strategy or hot loop.

Byte-identical: the cached value equals what select_kernel() returned per
block, because the CPU tier is constant for the process lifetime.
Forcing the cached table.kernel runs each per-CPU tier's monomorphized
BT-collect / DP wrapper on one machine, pinning the scalar-vs-SIMD
bit-identity invariant and covering the per-tier wrappers the runtime
dispatch leaves cold (only the selected tier would otherwise execute).
… alphabets

- level_params_strategy_and_search_method_agree_across_tiers: resolves
  positive levels over every source-size tier, asserting each derived
  strategy pairs with its search method; exercises the cParams -> LevelParams
  strategy arms the existing level tests (which used StrategyTag::for_level)
  left cold.
- build_limited_weights_handles_degenerate_alphabets: single-symbol and
  all-zero histograms hit the height limiter's leaves.len() <= 1 early-out.
resolve_level_params routes Level(22) through the btultra2-specific
source-size resolver but lets Level(23+) fall through to the generic
cParams path, so an out-of-range level resolves to a DIFFERENT config
than the max level. This test asserts Level(MAX_LEVEL + k) == Level(MAX_LEVEL)
across source sizes and fails until the clamp routes through the same path.
resolve_level_params special-cased only Level(22) for the btultra2
source-size resolver; Level(23+) fell through to the generic cParams
derivation and produced a different config than Level(22). Match
Level(n) for n >= MAX_LEVEL so any out-of-range level clamps to the
same max-level configuration.
Keep the deterministic TRIGGER regression plus a 4k randomized sweep in
the default suite (fast), and move the full 300k sweep behind #[ignore]
so the normal run stays quick; invoke with --run-ignored for the deep pass.

Also gate the HC dictionary-match descent on dms.is_primed() (the canonical
validity predicate) instead of walking dms.table() directly, so a
present-but-stale DMS table is never searched.
@polaz polaz force-pushed the perf/btopt-no-match-fastpath branch from 5e371b0 to 6a783d5 Compare June 25, 2026 06:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@zstd/src/huff0/huff0_encoder/tests.rs`:
- Around line 123-153: The test comment in
build_limited_weights_handles_degenerate_alphabets claims the all-zero histogram
case is covered, but the test body only checks single-symbol and two-symbol
inputs. Either add an explicit assertion for the all-zero histogram behavior in
build_limited_weights and huffman_weight_sum_is_power_of_two, or revise the
comment to remove that claim; use the existing build_limited_weights helper and
the degenerate-case setup in this test to keep the coverage consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5a0c82be-7df8-49ba-ac23-5364ef349040

📥 Commits

Reviewing files that changed from the base of the PR and between 5e371b0 and 6a783d5.

📒 Files selected for processing (9)
  • zstd/src/encoding/hc/generator.rs
  • zstd/src/encoding/hc/mod.rs
  • zstd/src/encoding/hc/optimal.rs
  • zstd/src/encoding/levels/config.rs
  • zstd/src/encoding/match_generator/tests.rs
  • zstd/src/encoding/match_table/storage.rs
  • zstd/src/encoding/strategy.rs
  • zstd/src/huff0/huff0_encoder.rs
  • zstd/src/huff0/huff0_encoder/tests.rs

Comment thread zstd/src/huff0/huff0_encoder/tests.rs
The degenerate-alphabets test comment claimed all-zero coverage but the
body only checked single/two-symbol inputs. Add an explicit all-zero
assertion for the early-out's actual behavior (all weights stay zero) and
reword the comment: an all-zero histogram never reaches production (a real
block always has >= 1 literal) and cannot meet the power-of-two invariant a
zero-symbol code has no terms for, so only the symbol-bearing cases assert it.
@polaz

polaz commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…nt apply

Upstream zstd's ZSTD_copyDDictParameters stores RAW POINTERS into a
caller-owned DDict every frame (dctx->LLTptr = ddict->entropy.LLTable,
prefixStart = dictContent, ...) — no refcount, no per-frame clone. We held
an owned Arc<Dictionary> in three places (FSE + Huffman scratch + decode
buffer) and re-cloned all three each frame, dropping them at teardown: six
atomic refcount ops per frame on a value the dictionary owner already keeps
alive for the whole decode. On a tiny frame that fixed per-frame cost is a
measurable share of decode time.

Mirror C: the three scratch sites now hold Option<NonNull<Dictionary>>, set
unconditionally from a live &DictionaryHandle each frame (one pointer store,
no atomic) and read through a checked unsafe deref while the COW source is
Dict. The pointee is kept alive across the whole decode by the dictionary
owner — the FrameDecoder owned_dicts/shared_dicts registry, or the
&DictionaryHandle the caller holds across decode_all_with_dict_handle — so
the borrow is valid for every read (documented contract on each dict field).

NonNull suppresses auto-Send/Sync, so unsafe impl Send/Sync is restored on
each scratch type (the pointee is Send+Sync, read-only, owner-kept-alive);
a compile-time assert pins FrameDecoder: Send + Sync against regression.

Byte-identical (same dict tables; only the reference's ownership changed
from owned-Arc to borrowed-pointer). i9 small-10k-random decompress-dict,
flat c_ffi control: L3 dfast -2.3%, L16 btopt -1.8%.
@polaz

polaz commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@zstd/src/decoding/decode_buffer.rs`:
- Around line 221-222: `DecodeBuffer::set_dict` currently stores only a raw
dictionary pointer, so temporary `DictionaryHandle` values can be dropped while
`dict_content` and `repeat_from_dict` still use them. Update `DecodeBuffer` to
own the dictionary handle or another guard alongside the `NonNull` pointer, or
thread an explicit lifetime through the buffer/scratch path so the handle
outlives all reads. Adjust the `set_dict` API and any call sites that pass
temporaries like `into_handle` or `DictionaryHandle::from_dictionary`
accordingly.

In `@zstd/src/decoding/decode_buffer/tests.rs`:
- Line 25: The decode_buffer tests are passing temporary dictionary handles into
buf.set_dict, but set_dict only keeps a raw pointer, so the handle can be
dropped too early and later repeat/dictionary reads may use freed memory. Update
the affected test cases to bind each dictionary handle to a local variable
before calling buf.set_dict, and keep that variable alive for the full scope
where the buffer is used. Apply this fix in the repeated set_dict call sites in
the decode_buffer tests.

In `@zstd/src/decoding/scratch.rs`:
- Around line 258-263: Update the ownership comment in scratch::attach_dict to
match the current zero-clone behavior: it should no longer mention cloning on
fresh/changed dictionaries, since the implementation now stores borrowed NonNull
pointers only. Clarify that no handle is retained by attach_dict and that the
caller/registry must keep the dictionary alive for the duration of use, so the
comment accurately reflects the lifetime-sensitive unsafe contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 929dc440-accb-4fc8-889a-873e5b786b44

📥 Commits

Reviewing files that changed from the base of the PR and between 6a783d5 and 07f85a9.

📒 Files selected for processing (6)
  • zstd/src/decoding/decode_buffer.rs
  • zstd/src/decoding/decode_buffer/tests.rs
  • zstd/src/decoding/frame_decoder.rs
  • zstd/src/decoding/frame_decoder/tests.rs
  • zstd/src/decoding/scratch.rs
  • zstd/src/huff0/huff0_encoder/tests.rs

Comment thread zstd/src/decoding/decode_buffer.rs Outdated
Comment thread zstd/src/decoding/decode_buffer/tests.rs Outdated
Comment thread zstd/src/decoding/scratch.rs Outdated
polaz added 2 commits June 25, 2026 13:54
`set_dict` now stores a borrowed `NonNull` into the handle's dictionary
(07f85a9), so the handle must outlive the `DecodeBuffer`. Four tests passed a
temporary handle (`&dict()`, `&dict.into_handle()`,
`&DictionaryHandle::from_dictionary(...)`) whose drop at the end of the
statement left the stored pointer dangling; the subsequent `repeat_from_dict`
reads then hit freed memory, surfacing as an access violation (0xc0000005) on
Windows and silent use-after-free elsewhere. Bind each handle to a local that
outlives the buffer's reads.
Replace the raw-pointer dictionary hold (NonNull stored in the decode
scratch + buffer, with `unsafe impl Send/Sync` to restore the auto
traits) with a borrow-checked design that the compiler proves sound and
that mirrors C's per-frame pointer hand-off without any refcount churn.

- The decode scratch / buffer no longer OWN or POINT AT the dictionary.
  Every `Dict`-sourced table read (FSE ll/of/ml, HUF, dict content)
  takes the dictionary as a call-scoped `dict: Option<&Dictionary>`
  argument, threaded through the whole decode pipeline (block decoder,
  literals, sequence executors, per-tier SIMD monoliths). Source flags
  on the scratch still select Local vs Dict; the borrow supplies the
  bytes only when the flag says Dict.
- `FrameDecoderState` gains a single owned liveness root,
  `active_dict: Option<DictionaryHandle>`, set at dict-apply via
  `set_active_dict` with a `ptr::eq` skip so re-applying the SAME
  dictionary frame-over-frame (the reuse hot path) clones zero times;
  one clone only on a genuine dictionary swap. The block loop borrows
  `&Dictionary` from it, disjoint from the mutable decoder scratch.
- Removes `NonNull` dict fields, the `unsafe impl Send/Sync` blocks,
  `DecodeBuffer::set_dict`, and the handle-alive test scaffolding the
  raw-pointer API forced on callers. `FrameDecoder: Send + Sync` now
  auto-derives.

byte-identical decode output; 794 tests pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
zstd/src/decoding/frame_decoder.rs (2)

1391-1405: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update force_dict to install active_dict.

force_dict arms Dict-sourced scratch tables but never calls set_active_dict, so later reads either panic on None or use a stale dictionary handle from a previous frame.

Proposed fix
         let dict = owned_dicts
             .get(&dict_id)
@@
             })
             .ok_or(err::DictNotProvided { dict_id })?;
         state.decoder_scratch.init_from_dict(dict);
+        state.set_active_dict(dict);
         state.using_dict = Some(dict_id);

Based on learnings, dictionary ownership should be stored once on decoder state and passed through the decode pipeline as a borrow-checked, call-scoped dictionary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zstd/src/decoding/frame_decoder.rs` around lines 1391 - 1405, `force_dict` in
`frame_decoder::force_dict` only initializes `decoder_scratch` and updates
`using_dict`, but never installs the active dictionary handle. Update this path
to call `set_active_dict` with the resolved `dict` before returning, so later
decode steps always see the current dictionary instead of `None` or a stale
handle. Keep the borrow-scoped ownership flow consistent with the existing
`owned_dicts`/`shared_dicts` lookup and the `state` fields used by the decoder.

Source: Learnings


675-695: 🎯 Functional Correctness | 🔴 Critical

Thread dict through the LSM entropy snapshot calls

FSEScratch::reinit_from and HuffmanScratch::reinit_resolved_from now take a dictionary borrow, but FrameDecoder::export_entropy / restore_entropy still call them with the old arity. This breaks the lsm build and leaves Dict-sourced tables unresolved in resume snapshots.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zstd/src/decoding/frame_decoder.rs` around lines 675 - 695, Thread the
dictionary borrow through the LSM entropy snapshot flow by updating
FrameDecoder::export_entropy and FrameDecoder::restore_entropy to pass the dict
argument into FSEScratch::reinit_from and HuffmanScratch::reinit_resolved_from.
Use the existing FrameDecoder/ResumeState paths to ensure both snapshot creation
and restore use the same dictionary-backed table resolution, and update any
helper calls in the Ring and Flat branches to match the new arity.
zstd/src/decoding/sequence_section_decoder.rs (1)

139-155: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep active_dict in sync with dict-attached scratch tables.

These dict-aware table lookups now require every caller that has attached dictionary FSE state to pass the same active dictionary borrow. The supplied FrameDecoder::force_dict context initializes scratch with state.decoder_scratch.init_from_dict(dict) but does not call state.set_active_dict(dict), while block decode later derives dict from state.active_dict; forced-dictionary frames can therefore enter here with Dict-mode table sources and dict == None.

Suggested fix in the caller
 state.decoder_scratch.init_from_dict(dict);
+state.set_active_dict(dict);
 state.using_dict = Some(dict_id);

Based on learnings, dictionary ownership should be stored once on decoder state and threaded through as a call-scoped dict: Option<&Dictionary> argument.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zstd/src/decoding/sequence_section_decoder.rs` around lines 139 - 155, The
dictionary state used by sequence decoding is getting out of sync:
`FrameDecoder::force_dict` initializes `decoder_scratch` from a dictionary but
never updates `active_dict`, so `decode_sequence_section` can reach
`SeqFSEDecoder::new` and `fse.*_table(dict)` with `dict == None`. Update the
caller to set the active dictionary whenever scratch is initialized from a dict,
and thread the same call-scoped `dict: Option<&Dictionary>` through the sequence
decode path so `SequenceSectionDecoder::decode` and the `fse.*_table` lookups
always use the same borrow.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@zstd/src/decoding/block_decoder.rs`:
- Around line 297-306: The forced-dictionary path is missing the state update
that makes the borrow visible to block decoding, so
`decompress_block_inplace_with_parts` can still see `dict` as `None` when
scratch tables expect a dictionary. Update `FrameDecoder::force_dict` to call
`state.set_active_dict(dict)` after initializing scratch from the provided
dictionary, and verify the `dict_ref` lookup in block decode continues to use
the decoder state’s active dictionary consistently. Add coverage for the
forced-dict Treeless/Repeat decode path to ensure dictionary-backed blocks do
not panic or reject matches.

In `@zstd/src/decoding/frame_decoder.rs`:
- Around line 915-921: `FrameDecoder::reset_with_format` leaves `active_dict`
populated, but the decode paths still derive `dict_ref` from it even after a
no-dict frame. Update the borrow flow in `FrameDecoder` so ext-dict match copies
only use `active_dict` when the current frame is actually dictionary-backed (for
example by checking `using_dict`/current frame state before building
`dict_ref`), or clear `active_dict` during reset; keep the change consistent
across the other decode sites that reuse the same `dict_ref` logic.

In `@zstd/src/decoding/frame_decoder/tests.rs`:
- Around line 9-15: Add a regression test for the forced-dictionary path in
FrameDecoder::force_dict to cover dict-backed frames that use the live borrow
from state.active_dict. Update force_dict so it calls
state.set_active_dict(dict) alongside decoder_scratch.init_from_dict(dict) and
using_dict, then extend the tests in frame_decoder/tests.rs to verify forced
dict decoding still works when the dictionary is owned by decoder state rather
than passed as a separate runtime argument.

---

Outside diff comments:
In `@zstd/src/decoding/frame_decoder.rs`:
- Around line 1391-1405: `force_dict` in `frame_decoder::force_dict` only
initializes `decoder_scratch` and updates `using_dict`, but never installs the
active dictionary handle. Update this path to call `set_active_dict` with the
resolved `dict` before returning, so later decode steps always see the current
dictionary instead of `None` or a stale handle. Keep the borrow-scoped ownership
flow consistent with the existing `owned_dicts`/`shared_dicts` lookup and the
`state` fields used by the decoder.
- Around line 675-695: Thread the dictionary borrow through the LSM entropy
snapshot flow by updating FrameDecoder::export_entropy and
FrameDecoder::restore_entropy to pass the dict argument into
FSEScratch::reinit_from and HuffmanScratch::reinit_resolved_from. Use the
existing FrameDecoder/ResumeState paths to ensure both snapshot creation and
restore use the same dictionary-backed table resolution, and update any helper
calls in the Ring and Flat branches to match the new arity.

In `@zstd/src/decoding/sequence_section_decoder.rs`:
- Around line 139-155: The dictionary state used by sequence decoding is getting
out of sync: `FrameDecoder::force_dict` initializes `decoder_scratch` from a
dictionary but never updates `active_dict`, so `decode_sequence_section` can
reach `SeqFSEDecoder::new` and `fse.*_table(dict)` with `dict == None`. Update
the caller to set the active dictionary whenever scratch is initialized from a
dict, and thread the same call-scoped `dict: Option<&Dictionary>` through the
sequence decode path so `SequenceSectionDecoder::decode` and the `fse.*_table`
lookups always use the same borrow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6264f0a4-2e75-42df-a690-2d110569a89c

📥 Commits

Reviewing files that changed from the base of the PR and between 07f85a9 and 0e55352.

📒 Files selected for processing (17)
  • zstd/src/decoding/block_decoder.rs
  • zstd/src/decoding/block_decoder/tests.rs
  • zstd/src/decoding/decode_buffer.rs
  • zstd/src/decoding/decode_buffer/tests.rs
  • zstd/src/decoding/frame_decoder.rs
  • zstd/src/decoding/frame_decoder/tests.rs
  • zstd/src/decoding/literals_section_decoder.rs
  • zstd/src/decoding/literals_section_decoder/burst_gate_tests.rs
  • zstd/src/decoding/literals_section_decoder/zerocopy_robustness_tests.rs
  • zstd/src/decoding/scratch.rs
  • zstd/src/decoding/scratch/tests.rs
  • zstd/src/decoding/seq_decoder_avx2.rs
  • zstd/src/decoding/seq_decoder_bmi2.rs
  • zstd/src/decoding/seq_decoder_scalar.rs
  • zstd/src/decoding/seq_decoder_vbmi2.rs
  • zstd/src/decoding/sequence_section_decoder.rs
  • zstd/src/decoding/sequence_section_decoder/tests.rs

Comment thread zstd/src/decoding/block_decoder.rs
Comment thread zstd/src/decoding/frame_decoder.rs
Comment thread zstd/src/decoding/frame_decoder/tests.rs
Comment thread zstd/src/decoding/frame_decoder.rs Outdated
polaz added 2 commits June 25, 2026 18:23
A dictless-header frame decoded with a runtime dictionary applied via
force_dict arms Dict-sourced scratch tables but, before the fix, never
installs the owning dictionary handle (active_dict). The block loop then
derives a None dictionary borrow while the scratch source says Dict, so
the first dict-table read panics. This test fails on the current code.
… paths

Three gaps in the borrow-checked dictionary threading, all surfaced by
review of the prior refactor:

- `force_dict` armed `Dict`-sourced scratch tables but never installed
  the owning `active_dict` handle (the other two dict-apply sites did).
  A dictless-header frame decoded with a runtime dictionary then derived
  a `None` borrow while the scratch said `Dict`, panicking on the first
  dict-table read. Add the missing `set_active_dict`. (regression test in
  the preceding commit)

- The `lsm` resume-snapshot path (`export_entropy` / `restore_entropy`)
  still called `FSEScratch::reinit_from` / `HuffmanScratch::reinit_resolved_from`
  with the old arity, breaking the `lsm` build. Thread the dictionary
  borrow through: export passes the live frame's dictionary (the snapshot
  resolves Dict axes into self-contained Local bytes); restore passes
  `None` (the snapshot is already Local).

- A reused decoder keeps `active_dict` across a no-dict frame so the
  per-apply `ptr::eq` reuse-skip works, but the decode loop derived the
  dictionary borrow from it unconditionally. A no-dict frame following a
  dict frame could then resolve a stray out-of-window offset against the
  stale dictionary content instead of erroring. Gate every `dict_ref`
  derivation (all four decode sites) on `using_dict.is_some()` so only a
  genuinely dict-backed frame sees the held dictionary.

default + lsm test suites green (795 / 846); byte-identical decode output.
@polaz

polaz commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@polaz polaz merged commit 66ea51f into main Jun 25, 2026
28 checks passed
@polaz polaz deleted the perf/btopt-no-match-fastpath branch June 25, 2026 16:15
This was referenced Jun 25, 2026
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.

1 participant