Clast.Fsst
0.3.1
dotnet add package Clast.Fsst --version 0.3.1
NuGet\Install-Package Clast.Fsst -Version 0.3.1
<PackageReference Include="Clast.Fsst" Version="0.3.1" />
<PackageVersion Include="Clast.Fsst" Version="0.3.1" />
<PackageReference Include="Clast.Fsst" />
paket add Clast.Fsst --version 0.3.1
#r "nuget: Clast.Fsst, 0.3.1"
#:package Clast.Fsst@0.3.1
#addin nuget:?package=Clast.Fsst&version=0.3.1
#tool nuget:?package=Clast.Fsst&version=0.3.1
Clast.Fsst
A C# implementation of FSST (Fast Static Symbol Table) string compression. FSST is a lightweight, byte-oriented dictionary compressor designed for columnar databases — it produces small, randomly-accessible compressed strings with very fast decompression.
This library is part of the clast-project.
Features
- FSST8 — 1-byte codes (up to 255 symbols) plus an escape byte for unmatched literals.
- FSST12 — 12-bit codes packed two-per-three-bytes (up to 4096 symbols, no escape).
- FSST16 — 2-byte codes (up to 65,535 symbols, symbols up to 16 bytes), the variant the Parquet FSST proposal calls
FSST_16. - Single-string and batch compress / decompress.
- Versioned binary format for serializing symbol tables.
Target frameworks
| TFM | Notes |
|---|---|
net10.0 |
Primary development target. |
net8.0 |
LTS. |
netstandard2.0 |
Pulls in System.Memory; ships with internal polyfills for BitOperations, Index/Range, KeyValuePair.Deconstruct, etc. |
The test suite multi-targets net48, net8.0, and net10.0 so the netstandard2.0 build is exercised end-to-end through a .NET Framework 4.8 host.
Quick start
using Clast.Fsst;
using System.Text;
var corpus = new[]
{
Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy dog"),
Encoding.UTF8.GetBytes("the lazy dog sleeps in the shade"),
};
// Build a symbol table from a representative corpus.
SymbolTable table = FsstEncoder.BuildSymbolTable(corpus);
// Compress / decompress a single value.
byte[] compressed = FsstEncoder.Compress(table, corpus[0]);
FsstDecoder decoder = FsstDecoder.FromSymbolTable(table);
byte[] roundtrip = decoder.Decompress(compressed);
// Or compress / decompress a batch into caller-supplied buffers (Arrow-style prefix-sum offsets):
var (data, lengths) = FsstEncoder.CompressBatch(table, corpus);
var dst = new byte[FsstDecoder.MaxDecompressedLength(data.Length)];
var offsets = new int[corpus.Length + 1];
decoder.TryDecompressBatch(data, lengths, dst, offsets, out int totalWritten);
// Item i is now in dst[offsets[i]..offsets[i+1]].
FSST12
Fsst12Encoder / Fsst12Decoder follow the same shape but use 12-bit codes (no escape, 1.5 bytes per code on average). Prefer FSST12 when the input has a large effective symbol vocabulary; prefer FSST8 when codes must be byte-aligned for cheap random-access decoding.
SymbolMap map = Fsst12Encoder.BuildSymbolTable(corpus);
byte[] compressed = Fsst12Encoder.Compress(map, corpus[0]);
Fsst12Decoder decoder = Fsst12Decoder.FromSymbolMap(map);
byte[] roundtrip = decoder.Decompress(compressed);
FSST16
Fsst16Encoder / Fsst16Decoder use 16-bit codes written as little-endian uint16, with symbols
of up to 16 bytes. It buys a wider code space and longer symbols at 2 bytes per code, so it needs
symbols longer than 2 bytes to pay for itself, and training is slower.
Which variant wins is data-dependent, and FSST8 is a strong default: when 8-byte symbols cover the data well it spends half the bits per code. On a 1.4 MB corpus of synthetic URLs, FSST8 reaches 3.11x against FSST16's 2.27x; on short name-like values the gap is wider still. FSST16 earns its place where the effective symbol vocabulary is too large for 255 codes but the repeated substrings are long — and it is the variant the Parquet proposal specifies, which is reason enough when writing that format. Measure on your own data.
SymbolTable16 table = Fsst16Encoder.BuildSymbolTable(corpus);
byte[] compressed = Fsst16Encoder.Compress(table, corpus[0]);
Fsst16Decoder decoder = Fsst16Decoder.FromSymbolTable(table);
byte[] roundtrip = decoder.Decompress(compressed);
BuildSymbolTable takes an optional maxSymbolLength (default 16). The
Parquet FSST proposal contradicts itself on
the FSST_16 cap — §1.2 says symbols are 1-8 bytes while §3.3, §3.5 and §3.6 all describe 16 — so
pass 8 to stay valid under the stricter reading:
SymbolTable16 table = Fsst16Encoder.BuildSymbolTable(corpus, maxSymbolLength: 8);
Two properties are worth knowing when writing FSST16 into a container format:
- Tables from
BuildSymbolTablealways contain all 256 single-byte symbols. With 65,535 codes there is no reason to leave a byte to a 4-byte escape sequence, so these tables never escape and never expand beyond 2x. The escape code — 65,535, followed by the literal byte as a little-endianuint16, as the Parquet spec's §8.3 decode algorithm reads it — is still decoded, for tables produced elsewhere. - Codes are assigned in ascending symbol-length order, so
ExportRawemits non-decreasing lengths and a consumer can derive a per-length histogram — and use the codes as-is — without rewriting every code in the compressed stream.
Malformed input
All three decoders reject a corrupt code stream rather than decoding as much of it as they can. The
Decompress(compressed, destination, out written) overload returns a System.Buffers.OperationStatus
so a caller can tell corruption from a destination that is merely too small:
switch (decoder.Decompress(compressed, dst, out int written))
{
case OperationStatus.Done: /* dst[..written] */ break;
case OperationStatus.DestinationTooSmall: /* retry, larger */ break;
case OperationStatus.InvalidData: /* reject the value */ break;
}
TryDecompress returns false for either, and the allocating overloads (Decompress(compressed),
DecompressString) throw InvalidDataException on corruption. written is 0 unless the status is
Done — but decoding writes as it goes, so the destination may already hold bytes decoded before
the problem was detected. Treat its contents as undefined unless the status is Done.
Rejected: a code at or beyond the symbol count, an escape marker with no literal after it, and — for
FSST16 — an odd-length stream or a literal above 255. These are the cases the Parquet FSST spec's
§8.3 decode algorithm calls errors, and §8.1 requires a reader to fail on. Validate one value at a
time: TryDecompressBatch / DecompressBatch slice per value first, so an escape at the end of one
value can never consume the next value's bytes.
Persisting a symbol table
byte[] tableBytes = FsstSerializer.ExportFsst8(table);
// ... store it, send it over the wire ...
SymbolTable restored = FsstSerializer.ImportFsst8(tableBytes);
// Or skip straight to a decoder:
FsstDecoder decoder = FsstSerializer.ImportFsst8Decoder(tableBytes);
The FSST8 export uses the cwida/fsst fsst_export() on-disk format (17-byte header
followed by raw symbol bytes in code order), so payloads are interoperable with the
reference C++ implementation.
ExportFsst12 / ImportFsst12 / ImportFsst12Decoder are the FSST12 equivalents
and use a separate length-prefixed framing (cwida does not publish an FSST12 export
format).
Bring-your-own framing
Other consumers (Lance, for example) wrap symbol tables in their own container.
FsstDecoder.FromSymbols skips the wire format entirely and takes pre-extracted
symbols indexed by code:
// lengths[i] is the byte length of the symbol for code i (0 = unused slot).
// packedValues holds 8 little-endian bytes per code.
FsstDecoder decoder = FsstDecoder.FromSymbols(lengths, packedValues);
Parse your container's framing yourself, hand over the per-code lengths and 8-byte slots, and you get back a decoder.
SymbolTable16.ExportRaw / Fsst16Decoder.FromSymbols are the FSST16 pair. They use 16-byte
slots rather than 8, since FSST16 symbols can be twice as long:
var lengths = new byte[table.SymbolCount];
var values = new byte[table.SymbolCount * 16];
table.ExportRaw(lengths, values);
Fsst16Decoder decoder = Fsst16Decoder.FromSymbols(lengths, values);
There is no FsstSerializer support for FSST16: cwida publishes no such format, and the Parquet
symbol-table page framing belongs in the consumer that writes Parquet.
Project layout
src/Fsst/ library (Clast.Fsst.dll)
test/Fsst.Tests/ xUnit test suite
benchmarks/Fsst.Benchmarks/ BenchmarkDotNet harness
Build & test
dotnet build
dotnet test
References
- FSST: Fast Random Access String Compression — Peter Boncz, Thomas Neumann, Viktor Leis. PVLDB Vol. 13, 2020.
- Reference C++ implementation: https://github.com/cwida/fsst
License
Licensed under the Apache License, Version 2.0. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- System.Memory (>= 4.5.5)
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Clast.Fsst:
| Package | Downloads |
|---|---|
|
EngineeredWood.Lance
A reader and writer for the Lance columnar file format (v2.0 / v2.1 / v2.2) that speaks Apache Arrow. Preliminary 0.3.0 — APIs may change before 1.0.0. |
|
|
EngineeredWood.Vortex
A reader, writer, and predicate-based zone-pruning API for the Vortex columnar file format that speaks Apache Arrow. Preliminary 0.3.0 — APIs may change before 1.0.0. |
GitHub repositories
This package is not used by any popular GitHub repositories.
0.3.1 fixes FSST8 data corruption on write. Upgrade from 0.3.0 is strongly recommended.
- SymbolTable.Finalize could assign one code to two symbols, whenever a trained table held both a length-2 symbol that is a prefix of a longer symbol and a length-3 symbol. One symbol was lost, and the compressor emitted its code for input matching the other — so compressing and decompressing with the same table returned altered bytes. Present since 0.1.0, but only reachable from tables 0.3.0's ratio fixes started producing.
- FSST8 data written by 0.3.0 cannot be recovered and must be re-encoded from source; the lost symbol is absent from the exported table. An affected table also exports a length histogram inconsistent with its symbol payload, so ImportFsst8 on it throws rather than loading. Reading data written by 0.2.0 and earlier was never affected, nor was FSST12 or FSST16, which have no code-renumbering pass.
- Recovering the lost symbols also recovers the compression they were costing: 3.50x to 4.58x on a synthetic URL corpus.
0.3.0 adds FSST16 (2-byte codes, up to 65,535 symbols, symbols up to 16 bytes) — the variant the Parquet FSST proposal calls FSST_16.
Breaking:
- TryDecompress now returns false for malformed input as well as a too-small destination, where it previously decoded as much as it could. The new Decompress overload returns OperationStatus so the two are distinguishable, and the allocating overloads throw InvalidDataException on corruption.
- FSST8 and FSST12 produce different compressed bytes, as a result of the ratio fixes below. Data written by earlier versions still decodes; no reader change is needed.
Fixes, all affecting compression ratio:
- FSST8 length-2 symbols were in the table but unreachable when compressing.
- Probe symbols carried a zeroed code field, making any symbol whose length matched the available input unreachable unless its code was 0 — every length-8 symbol, and every symbol at the end of a short value. Affected FSST8 and FSST12.
- The pair-frequency counter overflowed past 3,840 occurrences into an adjacent pair's slot, inventing symbols absent from the input.
Measured on a synthetic URL corpus: FSST8 1.93x to 3.11x, FSST12 2.07x to 2.71x. Compression is 2.7-3.0x faster and FSST12 training 3.1x.