opt: tabulate the optimal parser's per-code price components - #4732
Open
matevz-kovacic wants to merge 1 commit into
Open
opt: tabulate the optimal parser's per-code price components#4732matevz-kovacic wants to merge 1 commit into
matevz-kovacic wants to merge 1 commit into
Conversation
Within a stretch group the entropy statistics are frozen, so the price of a symbol is a pure function of its code alone: 36 litLength codes, 53 matchLength codes, 32 offset codes. The parser nevertheless derived each price from the raw frequency arrays at every single evaluation -- an array load plus a fixed-point log approximation, twice per candidate match length -- inside a loop that runs once per (match, length) pair. At level 19 that loop can run hundreds of times per position. Store the per-code half of each price instead, and add the running *SumBasePrice term at the point of use. Excluding the sum term is what keeps the tables cheap to maintain: a statistics update changes one frequency (one entry) plus one running total (one scalar), so ZSTD_updateStats() refreshes the affected entries in place, and a full rebuild is only needed where ZSTD_rescaleFreqs() replaces every statistic at once, i.e. once per block. Also split the match price into an offset half and a match-length half. The parser scans many candidate lengths against one fixed offset, so the offset half is loop-invariant there -- but the compiler cannot hoist it, because optPtr->offCodeFreq[] and the opt[].price the loop stores to are both U32 lvalues and type-based alias analysis cannot prove the store does not clobber the load. Hoisting it by hand, along with the priceType test and the matchLengthSumBasePrice term, is the other half of the change. Every transformation only regroups a sum of U32 terms, so each price is reproduced bit for bit and the parse is unchanged. Compressed output is byte-identical to the parent commit at levels 1/3/9/19 under both gcc and clang, on all 12 Silesia files, on two machines. Level-19 encode throughput, full Silesia: AMD Ryzen 7 9700X : +12.1% (gcc), +6.7% (clang) Intel i5-13400 : +9.0% (gcc), +6.4% (clang) Retired instructions at level 19 fall by 30.2% (gcc) and 21.4% (clang).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ZSTD_getMatchPrice()andZSTD_litLengthPrice()inlib/compress/zstd_opt.cre-derive each price from the raw frequency arrays on every call, although within a stretch group the statistics are frozen and the price is a pure function of the symbol code alone — 36 litLength codes, 53 matchLength codes, 32 offset codes. At level 19 the match-price loop runs once per (match, candidate length) pair, so that derivation is repeated hundreds of times per position for values that cannot have changed.This patch stores the per-code half of each price and adds the running
*SumBasePriceterm at the point of use, and splits the match price so its offset half can be hoisted out of the length loop.Compressed output is byte-identical at every level under both compilers on both test machines, so there is no ratio trade-off to weigh. Level-19 encode throughput rises +12.1% (gcc) / +6.7% (clang) on an AMD Ryzen 7 9700X and +9.0% / +6.4% on an Intel i5-13400, and retired instructions at level 19 fall by 30.2% and 21.4% respectively — the work removed is measured directly, not inferred from timing. Levels below 13 never enter this file and are unchanged, as the counters confirm.
Is the parse affected?
No, and this is checkable rather than a matter of judgement. Every transformation only regroups a sum of
U32terms:oc*BM + (offSum - W(offFreq[oc]))offCodeSumBasePrice + offCodePriceNoSum[oc]ML_bits[mc]*BM + (mlSum - W(mlFreq[mc])) + BM/5matchLengthSumBasePrice + matchLengthPriceNoSum[mc]LL_bits[lc]*BM + (llSum - W(llFreq[lc]))litLengthSumBasePrice + litLengthPriceNoSum[lc]U32addition is associative, so each price is reproduced bit for bit, the DP compares identical prices, selects an identical path, and emits an identical byte stream. TheBITCOST_MULTIPLIER/5constant and the long-offset handicap are folded into the stored entries for the same reason.Stored entries are deliberately allowed to wrap: each is a difference that is only meaningful once its
*SumBasePriceis added back, and modular arithmetic restores the original value exactly.The identity was checked exhaustively offline over all code pairs at both optLevels and 63 frequency tables including adversarial ones — 218,232 cases, all exact, including the 177,440 in which a stored entry wraps past 2^31.
Where the tables are maintained
The statistics these tables depend on are written at exactly two places in the library:
ZSTD_rescaleFreqs()replaces all of them once per block, andZSTD_updateStats()increments one entry of each per emitted sequence.That is why the
*SumBasePriceterm is excluded from the stored value: a statistics update then invalidates exactly one entry per table, soZSTD_updateStats()refreshes in place and a full rebuild is only needed inZSTD_rescaleFreqs(). An earlier version that stored the whole price had to rebuild all 121 entries after every stretch group; clang compiled that rebuild to 1015 instructions and it consumed most of the gain (+1.9% instead of +4.7% under clang on the first test machine).Generated code
Retired instructions and related counters at level 19, full Silesia, AMD Ryzen 7 9700X, medians of 3 runs. The bracketed figure is the run-to-run spread of the baseline, which is why these are quoted in preference to wall time — retired instructions is essentially deterministic for a deterministic workload.
The unchanged DRAM-fill figure is the expected result and worth stating: this change is arithmetic only and does not alter memory behaviour.
Effect on compressed output: none
Compressed size is identical, as exact integers, in every cell tested — 4 levels × 2 compilers × 2 machines, all 12 Silesia files:
Also identical through the CLI path (which streams, so the totals differ from the one-shot library figures above but likewise match exactly).
Effect on other levels: none
Levels below 13 never enter
zstd_opt.c. The counters confirm it rather than assuming it — retired instructions at level 9 change by +0.00% (gcc) and −0.00% (clang), against a baseline spread of 0.01%.Timing at levels 1/3/9 lands within ±0.6% with overlapping interquartile ranges on both machines, i.e. below what the measurement resolves. Decode is unaffected at every level.
Correctness
fuzzer -i2000 -s1andzstreamtest -i1500 -s1pass under both compilers on both machines.-Wall -Wextra -Wcast-qual -Wcast-align -Wshadow -Wstrict-aliasing=1 -Wswitch-enum -Wdeclaration-after-statement -Wstrict-prototypes -Wundef -Wpointer-arith -Wvla -Wformat=2 -Winit-self -Wfloat-equal -Wwrite-strings -Wredundant-decls -Wmissing-prototypes -Wc++-compat) at DEBUGLEVEL 0 and 2, both compilers.Benchmarks
Level-19 encode, full Silesia (211,938,580 B), single-threaded, pinned to one core, machine otherwise idle, baseline and candidate runs alternated, first repetition discarded, medians over 8 repetitions, with non-overlapping interquartile ranges required before a win is claimed.
Measurement floor: running the unmodified baseline against itself through the identical path gives a worst cell of 1.18% on the Ryzen, with 0 of 16 cells showing disjoint interquartile ranges. On the i5-13400 the same check gives 3.40%, which is why the Ryzen figures are the primary ones.
Notes
optState_tgrows by 484 bytes (three tables: 36 + 53 + 32U32entries), which growsZSTD_MatchState_tand therefore the valuesZSTD_estimateCCtxSize()andZSTD_sizeof_CCtx()report. Every such size derives fromsizeof, so the accounting stays self-consistent, but it is a visible change in reported context size. If you would prefer the tables in the cwksp alongsidelitFreqet al. for consistency, that is a small change; I kept them inline to avoid a pointer indirection in the hot path.fuzzercovers, and levels other than 1/3/9/19.