Finding
Profiling JavaVsJniFsstBenchmark.javaFsstEncode with JMH's stack sampler
(-prof "stack:lines=20;detailLine=true;excludePackages=false", after all 15 fixes from
#393 landed) shows Compressor.compress — which triggers the full 5-generation FSST
training pass — under two distinct call paths within the same chunk:
measureBestChild → measureStep → competeAndEncode:265 → FsstEncodingEncoder.compress (measurement)
spliceResult:310 → ... → FsstEncodingEncoder.compress (final encode)
CascadingCompressor.competeAndEncode (CascadingCompressor.java:222-279) runs a
sample-and-measure competition for every candidate encoder, then unconditionally re-runs
the winner's encodeCascade on the full data:
- Measurement (line 258):
enc.encodeCascade(dtype, sample, ctx) on a stratified
sample — sampleSize = max(minSampleSize, ceil(n * sampleFraction)), defaults
minSampleSize=4096, sampleFraction=0.05 (EncodeContext.java:61,88).
- Final (line 278, via
spliceResult): winner.encodeCascade(dtype, data, ctx) on
the full chunk, from scratch.
For a 50,000-row chunk this samples down to 4,096 rows for measurement — well short of
data, so the existing sample == data short-circuit (CascadingCompressor.java:248)
never fires and both calls really do redundant work.
Why this hurts FSST specifically
FsstEncodingEncoder.compress calls new CompressorBuilder().train(byteArrays)
unconditionally on both calls. CompressorBuilder's own Sample.draw internally caps
training at Sample.TARGET_SAMPLE_BYTES (~16 KB) regardless of how many rows it's
handed (Sample.java:28-30). So whether FSST trains on the dispatcher's 4,096-row
measurement sample or the full 50,000-row chunk, it draws a similarly-sized ~16 KB
training sample either way — training cost is already near-fixed, and this pattern pays
it twice. The compress() step that follows training does scale with row count (cheap
on the sample, real work on the full data), so it isn't free to skip either, but training
is the dominant fixed cost (see #393's #4/#6/#7 fixes) and is the part being duplicated
for no benefit.
This applies to every encoder in the cascade, but hurts FSST disproportionately: its
training cost is the highest of any candidate (vs. e.g. Bitpacked or Constant, which are
nearly free to "measure"), so paying it twice costs more in absolute terms.
Suggested fix
Requires a real (but backward-compatible) extension to the shared EncodingEncoder/
CascadeStep abstraction so a winning candidate's measurement-phase trained state can
carry over into the final encode, instead of CascadingCompressor always discarding it
and calling encodeCascade again from scratch:
CascadeStep gains an opaque reusable-model handle, e.g. Object reusableModel()
defaulting to null.
EncodingEncoder gains an overload, e.g.
CascadeStep encodeCascade(DType dtype, Object data, EncodeContext ctx, Object reuseModel),
with a default that ignores reuseModel and delegates to the existing 3-arg method —
every encoder except FSST (and any other train-then-compress encoder, e.g. Zstd's
dictionary training) keeps its current behavior with zero code changes.
CascadingCompressor.competeAndEncode threads the winning candidate's measurement-phase
reusableModel() into the final spliceResult call for that same encoder.
FsstEncodingEncoder trains only when reuseModel == null; otherwise it compresses the
full data directly with the reused Compressor, skipping CompressorBuilder.train()
entirely on the final pass.
Note this changes which rows the final symbol table is trained from (the dispatcher's
stratified sample instead of the full chunk) — expected to be a non-issue in practice
since FSST's own internal sampling already bounds training to ~16 KB regardless, but it
is an observable behavior change (different exact compressed bytes for a given input)
worth calling out explicitly, not just an invisible perf win.
A simpler, narrower alternative was considered and rejected for now: have
FsstEncodingEncoder cache its last-trained Compressor in a ThreadLocal and reuse it
whenever encodeCascade is called twice in quick succession for compatible input. Lower
implementation cost, but relies on call-ordering assumptions rather than an explicit
contract between CascadingCompressor and the encoder — more fragile as the dispatch
logic evolves.
Scope
Out of scope for #393 (the FSST-specific review this was found while verifying) — this is
CascadingCompressor dispatch architecture, shared by every cascading encoder, not an
FSST bug.
Finding
Profiling
JavaVsJniFsstBenchmark.javaFsstEncodewith JMH's stack sampler(
-prof "stack:lines=20;detailLine=true;excludePackages=false", after all 15 fixes from#393 landed) shows
Compressor.compress— which triggers the full 5-generation FSSTtraining pass — under two distinct call paths within the same chunk:
CascadingCompressor.competeAndEncode(CascadingCompressor.java:222-279) runs asample-and-measure competition for every candidate encoder, then unconditionally re-runs
the winner's
encodeCascadeon the full data:enc.encodeCascade(dtype, sample, ctx)on a stratifiedsample —
sampleSize = max(minSampleSize, ceil(n * sampleFraction)), defaultsminSampleSize=4096,sampleFraction=0.05(EncodeContext.java:61,88).spliceResult):winner.encodeCascade(dtype, data, ctx)onthe full chunk, from scratch.
For a 50,000-row chunk this samples down to 4,096 rows for measurement — well short of
data, so the existingsample == datashort-circuit (CascadingCompressor.java:248)never fires and both calls really do redundant work.
Why this hurts FSST specifically
FsstEncodingEncoder.compresscallsnew CompressorBuilder().train(byteArrays)unconditionally on both calls.
CompressorBuilder's ownSample.drawinternally capstraining at
Sample.TARGET_SAMPLE_BYTES(~16 KB) regardless of how many rows it'shanded (
Sample.java:28-30). So whether FSST trains on the dispatcher's 4,096-rowmeasurement sample or the full 50,000-row chunk, it draws a similarly-sized ~16 KB
training sample either way — training cost is already near-fixed, and this pattern pays
it twice. The
compress()step that follows training does scale with row count (cheapon the sample, real work on the full data), so it isn't free to skip either, but training
is the dominant fixed cost (see #393's #4/#6/#7 fixes) and is the part being duplicated
for no benefit.
This applies to every encoder in the cascade, but hurts FSST disproportionately: its
training cost is the highest of any candidate (vs. e.g. Bitpacked or Constant, which are
nearly free to "measure"), so paying it twice costs more in absolute terms.
Suggested fix
Requires a real (but backward-compatible) extension to the shared
EncodingEncoder/CascadeStepabstraction so a winning candidate's measurement-phase trained state cancarry over into the final encode, instead of
CascadingCompressoralways discarding itand calling
encodeCascadeagain from scratch:CascadeStepgains an opaque reusable-model handle, e.g.Object reusableModel()defaulting to
null.EncodingEncodergains an overload, e.g.CascadeStep encodeCascade(DType dtype, Object data, EncodeContext ctx, Object reuseModel),with a default that ignores
reuseModeland delegates to the existing 3-arg method —every encoder except FSST (and any other train-then-compress encoder, e.g. Zstd's
dictionary training) keeps its current behavior with zero code changes.
CascadingCompressor.competeAndEncodethreads the winning candidate's measurement-phasereusableModel()into the finalspliceResultcall for that same encoder.FsstEncodingEncodertrains only whenreuseModel == null; otherwise it compresses thefull data directly with the reused
Compressor, skippingCompressorBuilder.train()entirely on the final pass.
Note this changes which rows the final symbol table is trained from (the dispatcher's
stratified sample instead of the full chunk) — expected to be a non-issue in practice
since FSST's own internal sampling already bounds training to ~16 KB regardless, but it
is an observable behavior change (different exact compressed bytes for a given input)
worth calling out explicitly, not just an invisible perf win.
A simpler, narrower alternative was considered and rejected for now: have
FsstEncodingEncodercache its last-trainedCompressorin aThreadLocaland reuse itwhenever
encodeCascadeis called twice in quick succession for compatible input. Lowerimplementation cost, but relies on call-ordering assumptions rather than an explicit
contract between
CascadingCompressorand the encoder — more fragile as the dispatchlogic evolves.
Scope
Out of scope for #393 (the FSST-specific review this was found while verifying) — this is
CascadingCompressordispatch architecture, shared by every cascading encoder, not anFSST bug.