lance-format / lance-format/lance
Feature Request: Use zstd trained dictionaries for miniblock compression
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 7.1k
- Forks
- 852
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 272
Description
It might be interesting to see if you could use a fixed trainable dictionary for zstd column encoding.
I think that zstd offers the ability to train a dictionary you can then utilize to compress/decompress your dataset with (https://github.com/facebook/zstd?tab=readme-ov-file#the-case-for-small-data-compression).
If enabled, this could increase both compression and efficiency by enabling either dict per file, or dict per dataset.
Here's an AI generated summary of some ideas I looked into:
Summary
Lance compresses miniblock chunks independently at ~4 KiB granularity using zstd, but each chunk starts with zero history. Zstd's trainable dictionary feature is designed exactly for this scenario — many small, structurally similar data items — and could significantly improve compression ratios at no additional I/O cost by following the existing FSST precedent.
Problem
Lance's dominant compression unit is the miniblock chunk, which targets ~4 KiB (hard max ~8 KiB):
ValueEncoderaims for 4 KiB chunks (e.g., 1024 x Int32, 512 x Float64)BinaryMiniBlockEncodertargets 4 KiB viaAIM_MINICHUNK_SIZE- Each chunk is compressed as an independent zstd frame — no shared context across chunks
The current ZstdBufferCompressor reuses the zstd::bulk::Compressor object across chunks within a page, but this only avoids memory re-allocation — it does not carry forward any match history or statistical knowledge between chunks. Each 4 KiB chunk compresses from scratch.
At 4 KiB, zstd has very little data to learn patterns from. This is well within the range (256 bytes – ~8 KB) where zstd's documentation states trained dictionaries provide the most benefit:
Zstd can use dictionaries to improve compression of small data, on both compression speed and compression ratio. The dictionary is trained from a set of samples. [...] Compression gains are mostly effective in the first few KB.
Additionally, GeneralMiniBlockCompressor skips compression entirely when buffers are under MIN_BUFFER_SIZE_FOR_COMPRESSION (4 KiB), and a test comment in block.rs:808 explicitly notes: "Need to use large pages as small pages might be too small to compress". A trained dictionary could make compression viable even at these small sizes.
Proposed solution
Train a zstd dictionary per page, per column, and store it inline in the encoding protobuf — exactly how FSST stores its symbol table today.
Why per-page, inline in the encoding proto
| Scope | Extra I/O at read | Storage | Fit |
|---|---|---|---|
| Per-page (inline in proto) | 0 — rides with column metadata bulk read | ~32–112 KB per page in metadata section | Best — exact FSST precedent |
| Per-column (column buffer) | 1 IOP per column per scan | One dict per column per file | Adds latency, especially on cloud |
| Per-dataset | 1 IOP per column per dataset open | One dict per column, shared across fragments | Fragile; stale on distribution shift; management overhead |
The per-page approach has zero additional I/O overhead. The column metadata section is already read in bulk as a mandatory step before any page decode (typically captured in the single tail read). A dictionary stored as bytes in the encoding proto is available the moment the metadata is parsed — identical to how FsstMiniBlockDecompressor loads description.symbol_table today.
Training data is already in memory
When encoding a page, Lance has already accumulated ~8 MiB of raw column data (cache_bytes_per_column default). That's ~2,000 chunks of ~4 KiB — well above zstd's recommendation of "a few thousand samples." The dictionary is trained from a sample of those chunks, then all chunks are compressed with it. No extra I/O on the write path either.
Proto change
Following the FSST precedent (encodings_v2_1.proto:341–356):
message General {
BufferCompression compression = 1;
CompressiveEncoding values = 2;
bytes dictionary = 3; // optional trained zstd dictionary
}
Write path
In GeneralMiniBlockCompressor::compress:
- Collect a sample of raw miniblock chunks from the page (already in memory)
- Call
zstd::dict::from_samples()(Rust) /ZDICT_trainFromBuffer()to train a dictionary (~6 MB working memory, fast) - Create a
zstd::bulk::Compressorwith the dictionary viaCompressor::with_dictionary() - Compress all chunks using the dictionary-aware compressor
- Store the dictionary bytes in the
General.dictionaryproto field
Read path
In GeneralMiniBlockDecompressor:
- On construction, read
General.dictionaryfrom the already-parsed encoding proto (zero I/O — it's in the column metadata) - Create a
zstd::bulk::Decompressorwith the dictionary (or useZSTD_decompress_usingDDict) - Decompress chunks as today, but with the dictionary providing initial context
Backward compatibility
- Files without
General.dictionary(all existing files) continue to work — the field is optional, and the decompressor falls back to standard zstd - This could be gated behind a file version check (V2.2+ or a new minor version) and/or a feature flag
- The
CompressionParams/ field metadata system already supports per-column configuration, so dictionary training could be opt-in initially
Costs and tradeoffs
| Dimension | Impact |
|---|---|
| Read I/O | Zero additional — dictionary is in the metadata bulk read |
| Read CPU | One ZSTD_createDDict call per page (sub-millisecond; could be cached in LanceCache) |
| Write CPU | One ZDICT_trainFromBuffer per page (~milliseconds on ~8 MiB of samples) |
| Metadata size | +32–112 KB per page per column (tunable via maxdict parameter) |
| Complexity | Modest — follows established FSST pattern; proto change is additive |
For a file with 100 pages of one column, a 100 KB dictionary per page adds ~10 MB to the metadata section, against ~800 MB of raw data. This is proportional and likely offset by improved compression of the data section.
Prior art in the codebase
- FSST (
encodings/physical/fsst.rs): Trains a symbol table (~2 KB) per page, stores it asbytes symbol_tablein theFsstproto, loads it from the encoding proto at decode time with zero extra I/O. This is the exact pattern proposed here. - ZstdBufferCompressor (
encodings/physical/block.rs): Already caches theCompressorcontext viaOnceLock<Mutex<Compressor>>for memory reuse. Adding dictionary support would extend this to also load the dictionary into the context. - Rust
zstdcrate (v0.13, already a dependency): SupportsCompressor::with_dictionary(),zstd::dict::from_samples(), andzstd::bulk::Decompressorwith dictionary — no new dependencies needed.
Open questions
- What dictionary size gives the best size/speed tradeoff for Lance's typical chunk sizes? (Benchmark needed — 32 KB vs 64 KB vs 112 KB)
- Should dictionary training be opt-in via
CompressionParamsor automatic when zstd is selected? - Should the
MIN_BUFFER_SIZE_FOR_COMPRESSIONthreshold (4 KiB) be lowered when a dictionary is available, since dictionaries make small-buffer compression viable? - Is there value in a per-column (per-file) dictionary for cases where metadata size is a concern? This would add 1 IOP per column but reduce dictionary storage from O(pages) to O(1) per column.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with encodings/physical/block.rs, encodings/physical/fsst.rs, and encodings_v2_1.proto to understand existing miniblock compression and inline metadata patterns. Benchmark dictionary sizes and training scope against current zstd behavior, then clarify the configuration, compatibility, and metadata tradeoffs; done requires an agreed design and validated compression results.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- data-engineering, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100