Skip to content

CascadingCompressor double-trains expensive encoders (FSST pays this worst) #394

Description

@dfa1

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions