Skip to content

[WIP][POC] Add FSST encoding - #3784

Draft
prtkgaur wants to merge 10 commits into
apache:masterfrom
prtkgaur:fsstEncoding
Draft

[WIP][POC] Add FSST encoding#3784
prtkgaur wants to merge 10 commits into
apache:masterfrom
prtkgaur:fsstEncoding

Conversation

@prtkgaur

@prtkgaur prtkgaur commented Sep 8, 2026

Copy link
Copy Markdown

In the email threads Arnav wanted someone to help with the Java impl of FSST, took a stab at it over the last week.
cc @ArnavBalyan , @emkornfield , @julienledem, @RussellSpitzer, @alamb

Rationale for this change

FSST (Fast Static Symbol Table) is a proposed Parquet encoding for string/binary columns
(parquet-format issue #531). It has a C++ reference implementation on an Arrow branch and
a WIP draft in arrow-rs, but no Java implementation exists — no file, commit, issue, PR,
or branch on the proposal author's fork mentions it as of 2026-09-08. A parquet-format
change needs reference implementations in the ecosystem's major languages before it can
be approved, so the empty Java slot is one of the things keeping the proposal at Draft.
This PR fills that slot.

Two follow-ups are planned and shape the design from the start: FSST_16 (16-bit codes,
already required by the interop conformance fixtures) and OnPair. Both attach as new
implementations behind the seams this PR adds, not as refactors of this PR's code.

What changes are included in this PR?

Scope is parquet-column only, mirroring how PFOR encoding was landed: no new Thrift
page type, no footer fields, no ParquetFileWriter/ParquetFileReader plumbing.
ParquetMetadataConverter.getEncoding() still throws for Encoding.FSST, so a
.parquet file cannot be written with an FSST column that no released reader can open
while the format proposal remains unratified. The property defaults to false.

  • The codec port (values/symboltable/fsst/), ported from CWI's reference
    implementation (cwida/fsst @ 89f49c580c6388acf3b6ed2a49e1bfde6c05e616, MIT — the
    same commit Arrow vendored): the trainer's five-round gain loop, the split-plane
    Counters, the deterministic sampler, and the scalar 8-bit compressor. AVX-512 and the
    native table export/import are dropped; Parquet defines its own table serialization.
  • Codec-agnostic seams (values/symboltable/): SymbolTable, SymbolTableTrainer,
    SampleReservoir, CodeStreamEncoder/Decoder, SymbolTablePayload — shared by
    FSST now, and by FSST_16/OnPair when they land.
  • Column wiring: SymbolTableValuesWriter/Reader implementing
    ValuesWriter/ValuesReader, reusing FallbackValuesWriter/RequiresFallback
    unchanged so a column falls back to dictionary/PLAIN when FSST doesn't shrink the
    page, the usesSymbolTable()/getSymbolTableBasedValuesReader(...) hooks on
    Encoding, the enabling ColumnProperty, and the factory slot (dictionary's fallback
    writer, so low-cardinality columns keep RLE_DICTIONARY and FSST takes the
    high-cardinality tail).
  • The symbol-table transport: SymbolTablePage beside DictionaryPage, default
    methods added to PageReader/PageWriter (so no existing implementor breaks), with
    the publish moved onto the writer's chunk-finalize hook rather than the hot
    getBytes() path. This is also where a real defect got fixed along the way: a column
    that fell back to PLAIN/dictionary used to still publish a symbol table nobody would
    read.

This transport is intentionally the seam the eventual Thrift SYMBOL_TABLE_PAGE page
type attaches to — nothing above it should need to change once parquet-format ratifies
the proposal.

Are these changes tested?

  • Byte-exactness against the C++ format. Symbol table bodies and data page payloads
    extracted from the interop file in apache/parquet-testing PR PARQUET-187: Replace JavaConversions.asJavaList with JavaConversions.seqAsJavaList #121 and committed as
    parquet-column test fixtures. Java matches C++ byte-for-byte on all five extracted
    chunks, including 255-symbol saturation, chunk-boundary crossings, and all-empty
    values.

  • Encode-direction check for the packed offset section: decoding each fixture's
    offsets and re-encoding them through the same DeltaBinaryPackingValuesWriterForInteger
    path the writer uses matches the fixture's own bytes byte-for-byte on all 26
    packed-offset pages across the five cases.

  • End-to-end through real column machinery: values written through a
    ColumnWriteStoreV1 and read back through ColumnReadStoreImpl/ColumnReader,
    covering multi-page chunks, nulls/empty strings, values that expand under FSST (must
    fall back, with no symbol table page published), both offset encodings, and a
    row-group boundary retraining the table. A negative control (strip the symbol table
    page from a hand-built page store) fails at getColumnReader(path) itself, confirming
    the test would catch a missing table rather than silently falling back to PLAIN.

  • Ratio parity on real corpora. Round-tripped byte-exact on ~10M values across 20
    real string columns (TPC-H, ClickBench, and variant-JSON derived corpora, ~305 MB
    raw). Compressed/raw ratios ranged 0.14 (repeated short strings) to 0.96 (short,
    high-entropy address columns, where FSST buys almost nothing) — 0.36 overall.

  • Benchmark against DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY and RLE_DICTIONARY,
    each with and without a zstd second pass. On a synthetic corpus with realistic
    repeated structure (URL templates, repeated vocabulary):

    encoding ratio ratio+zstd
    FSST 0.208 0.119
    DELTA_BYTE_ARRAY 0.878 0.178
    DELTA_LENGTH_BYTE_ARRAY 1.012 0.178
    RLE_DICTIONARY 1.087 1.087

Are there any user-facing changes?

Yes, opt-in only. A new ParquetProperties flag (fsstEnabled) and matching
ParquetOutputFormat conf key turn FSST on for BINARY columns, following the same
per-column property pattern used for PFOR. It defaults to false, so existing writers
and files are unaffected. PageReader/PageWriter gain new default methods, so no
existing implementor of those interfaces needs to change.

Please Note
There is no file-format change: a file written with FSST enabled uses only existing
Parquet page types, and ParquetMetadataConverter deliberately throws for
Encoding.FSST in the Thrift-facing path, so this cannot silently produce a file that a
released reader would fail to open through the normal encoding-mapping route.

FSST is proposed for Parquet in parquet-format#531, with a C++ implementation
on an Arrow branch and nothing in Java. A format change needs a second
implementation, so this is the codec core it needs: a symbol table, a trainer,
and a compressor and decompressor for the 8-bit code stream.

The trainer is a port of FSST's reference implementation rather than a
reimplementation, because a writer's ratio has to match what other writers
produce for the same input, and the symbol table a reader rebuilds has to be
the table the writer chose. Seven constructs did not survive a literal port and
are commented where they appear: the symbol descriptor needs 64 bits because
the length field alone overflows an int at length 8; the reference hash needs
an unsigned shift, which matters because the sampler chains it over full 64-bit
values; every byte read needs masking; the hash table becomes parallel long
arrays rather than 1024 objects; the pair counter reuses one array across pages
with the reference's lazy reset; and the compressor's unconditional eight-byte
read needs a padded buffer, since Java cannot read past an array.

The file's code space is a permutation of the trainer's. The trainer numbers
symbols in an order its own shortcuts depend on, while the file orders them by
length so a reader can rebuild the table from a length histogram alone. Rather
than renumber and break the shortcuts, the trainer's numbering is kept and
translated at the emit site, folded into the compressor's inner loop so there
is no scratch buffer and no second pass.

Everything here sits behind interfaces that name the concept rather than the
codec, because two more codecs are planned against them: FSST_16, which the
proposal's own conformance files already contain, and OnPair. Each is a new
symbol table, trainer and code stream implementation, not a change to these.

Two test classes. One round-trips through serialization on adversarial input:
every byte value, values that straddle the compressor's chunk boundary,
incompressible bytes, empty values, and a corpus large enough to exercise the
sampler. The other compares against the reference implementation directly: for
six corpora it asserts that the serialized symbol table and the code stream are
byte-identical to what the reference produces. The corpora are generated from a
spelled-out generator rather than stored, and each one's digest is asserted
before the comparison, so a drifting generator reports itself as a generator
problem instead of as a codec regression.

Not here yet: the writer and reader that put this on a page, and the symbol
table's file-level home, which is thrift-gated.
The page body is the part of this encoding two implementations have to agree on
byte for byte, so it is one class with the framing in one place, and it is the
same class for every codec built on a symbol table: a code stream plus one end
offset per value, behind a fixed nine-byte header.

End offsets rather than lengths, because that is what makes a value readable
without expanding the ones in front of it, which is the property a codec over a
page cannot give and the reason for preferring this over compressing the page
whole. The offset section is written through the encoders this module already
has rather than by hand, since plain int32 and delta binary packing are both
spelled out here already and a second spelling of either would be a second
thing to keep compatible.

Delta offsets are offered alongside plain ones because on a page of short values
the offsets are a large fraction of the payload and almost all of it is the same
increment repeated; a test measures that, and it is the argument for making
delta the default when the writer lands.

The read side validates before it trusts: the header against the page header's
own value count, the offset section's length against what the encoding implies,
and the offsets against the code section they partition, including the case
where they stop short and leave bytes no value can reach. Every later read
depends on those bounds, so they are checked once rather than per value. Twelve
tests cover the layout byte for byte and each rejection.

A symbol table belongs to a column chunk rather than to a page, and a values
writer cannot write anything outside its own page, so the writer publishes the
trained table through a sink and the reader takes it from a source. Both are
one method. That is a test harness today and a page of its own once the format
carries one, and neither choice reaches the codec. Alongside them, one class
maps a table representation to the code that implements it, so a second
representation is a new implementation plus two lines of dispatch rather than a
change to anything above.
Adds the values writer and reader that turn buffered binary values into a
code stream over a trained symbol table, and back.

One writer and one reader serve every symbol table representation. The
representation decides how a table is trained, serialized and framed, and all
three already sit behind seams, so nothing about them reaches these two
classes beyond the type they are asked for.

The awkward part of the encoding is that a table belongs to a column chunk
while a writer only ever sees a page. The writer trains at the first page,
publishes the table through the sink, and keeps it for the rest of the chunk;
a later page must not train its own or the reader would decode it wrongly.
Values are buffered raw because a trainer reads them in an order of its own
choosing and more than once, and because a fallback to another encoding has to
replay them.

Compressing a page can also make it bigger, which is what the fallback
contract is for: whether the codes came out smaller than the values is the
only question worth asking, and it is asked once. A page of single bytes with
plain offsets is the case that answers no -- and the same page with packed
offsets answers yes, which is the argument against writing offsets plain.

Encoding.FSST hands out the reader for BINARY and rejects every other type.
The reader it hands out has nowhere to get its table from, because the format
has nowhere to put one yet; it says so rather than failing later.
Turning the encoding on is a per-column property, off by default, and only
BINARY columns honour it. The writer factories give the symbol table the first
attempt at a column the dictionary has given up on, and hand back the columns
whose codes come out no smaller than the values, so the writer stack is
dictionary, then symbol table, then plain or delta byte array.

The offsets into a page's code stream default to delta packing rather than four
plain bytes a value. On text the encoding halves gets to roughly twenty bytes a
value, so plain offsets are about a fifth of the page and delta packing takes
them to near one byte; plain offsets are worth having only for a reader that
wants them addressable without decoding.

A writer built from the format's own settings gets a sink that refuses the
table, because the format has nowhere to keep it and the pages would not be
readable. It fails at the first page, while the failure can still name the
reason. For the same reason converting the encoding to a footer value throws,
which is why the two tests that convert every encoding value now skip it.
parquet-column's tests have been migrated off JUnit 4, and an enforcer rule
bans the old imports outright: only the four benchmark classes are exempt,
because they still need @rule. These four files were written against JUnit 4
and only ever run with the enforcer skipped, so the ban was never checked
against them.

Converted mechanically, then the comparisons that had become a boolean plus
a message were rewritten as the assertion they were describing -
isLessThanOrEqualTo, isBetween, isPositive - so a failure prints both sides
instead of "expected true".
The page layout in this package is only worth having if it is the same layout
everyone else writes. Nothing in the tree proved that: every test so far
encoded with this code and decoded with this code, which passes just as well
for a private format.

The C++ implementation's interop file supplies the missing side. Its pages are
lifted out and committed as fixtures, and the reader is required to reproduce
what the file's plain reference columns hold - so the fixture does not depend
on any FSST decoder being correct, including this one. Five cases: high
cardinality values over fourteen pages sharing one table, zero-length values
and short pages, the escape code, and a chunk whose offsets are stored plainly
rather than packed. Both offset section encodings appear, checked from the page
bytes rather than through the reader, so a reader that ignored the mode byte
could not hide it.

The expected values are kept as digests rather than as payloads: the values
themselves would have been fifty kilobytes in a module whose test resources
total under three hundred. The header of expected.txt records where the file
came from, how the fixture is laid out, and how the digest is computed, so it
can be regenerated.

The 16-bit columns are skipped. Nothing reads them yet, and the table body
they carry is a different shape from the one the format currently describes.
…nery

Mirrors the dictionary page seam for seam: a SymbolTablePage alongside
DictionaryPage, default methods on PageReader/PageWriter so no existing
implementor breaks, and usesSymbolTable()/getSymbolTableBasedValuesReader
on Encoding beside the dictionary equivalents.

The publish moves off the writer's hot path and onto the chunk-finalize
hook (toSymbolTablePageAndClose(), the toDictPageAndClose() analogue),
which also fixes a real defect: FallbackValuesWriter.getBytes() used to
call the initial writer's getBytes() - which published the table - before
deciding whether to fall back, so a column that fell back to PLAIN or
dictionary still published a table nobody would read. Publishing now only
happens when the initial writer was actually used.

SymbolTableSink and SymbolTables.rejectingSink() are retired: the page
hook is a strictly better version of the same seam, and nothing depends
on the old one since it was never wired to a real writer.

ColumnReaderBase reads the symbol table page once per chunk and fails
loudly if a column needs one that is not there, rather than guessing.
Values through the column machinery, not just through the values
writer/reader pair: a MemPageStore, a real ColumnWriteStoreV1, a real
ColumnReadStoreImpl. Covers a high-cardinality column that keeps the
encoding and publishes a table, a multi-page chunk sharing one table,
nulls and empty strings on an optional column, a column that expands
and falls back with no table published, skip() and re-reading a column
from the same store, both offset encodings, two row groups each
training their own table, and the negative control the plan asked for:
strip the symbol table out of the page reader and confirm the column
reader refuses to proceed rather than silently decoding as if the
encoding were something else.
The existing interop test only proves this implementation can read a
packed offset section the C++ writer produced. It does not prove a
packed section this implementation writes comes out byte-identical,
since delta-binary-packing has framing choices a byte-compatible
decoder does not have to make the same way.

Decode each fixture page's packed offsets and re-encode them through
the same DeltaBinaryPackingValuesWriterForInteger path
SymbolTablePayloadWriter uses, then compare against the fixture's own
offset bytes. All 26 packed-offset pages across the five interop cases
re-encode byte-for-byte.
Compares FSST to DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY and
RLE_DICTIONARY, with and without a zstd second pass, on a synthetic
corpus with the shared structure (repeated URL templates and
vocabulary) that lets each encoding actually exploit redundancy.
Reports ratio, encode and decode separately.
@prtkgaur prtkgaur changed the title Fsst encoding [WIP][POC] Add FSST encoding Sep 8, 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.

2 participants