Skip to content

Commit c9c3970

Browse files
authored
perf(ts): cache decoded FSST shared dictionaries (#1512)
## Summary MLT `SharedDict` columns store strings for multiple child property columns in one dictionary corpus. When the corpus is FSST-compressed, the TypeScript decoder exposes each child as a separate `StringFsstDictionaryVector`. Previously, the first access to each vector decoded the complete shared FSST corpus again. Consumers such as MapLibre GL JS may access several of these vectors while materializing properties for styles, filters, or `queryRenderedFeatures()`. This PR: - Adds a benchmark for ordinary and shared FSST dictionary access. - Creates one lazy decoded-dictionary cache per `SharedDict`. - Shares that cache only between the vectors belonging to that group. - Keeps ordinary FSST vectors on their existing direct decoding path. - Ties the cache lifetime to the vectors and tile without a module-level `WeakMap`. - Tests that two vectors from one FSST `SharedDict` call `decodeFsst()` only once. #1505 makes this optimization slightly less relevant by preventing some oversized shared-dictionary groups with little actual value overlap, but beneficial `SharedDict` groups remain. ## Benchmark The benchmark uses the real FSST dictionary from `14_8299_10748.mlt`, which does not contain a `SharedDict`, to construct controlled cold-access scenarios. `xlarge.mlt` contains a real shared FSST dictionary, but reusing its vectors would warm the cache after the first iteration. Re-decoding the entire 5.4 MB tile for every iteration would make full-tile decoding dominate the cache overhead being measured. Node 24.11.1 results, averaging the mean latency from two runs: | Cold access | Before | After | Result | | --- | ---: | ---: | ---: | | One ordinary FSST vector | 0.4117 ms | 0.4057 ms | No regression | | Two ordinary FSST vectors | 0.8146 ms | 0.8192 ms | No regression | | Two vectors from one `SharedDict` | 0.8048 ms | 0.4132 ms | 1.95× faster | | Five vectors from one `SharedDict` | 1.9826 ms | 0.4123 ms | 4.81× faster | ## AI notice This PR was developed with assistance from OpenAI Codex.
1 parent 4bef5ee commit c9c3970

3 files changed

Lines changed: 123 additions & 6 deletions

File tree

ts/src/decoding/stringDecoder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { decodeUnsignedInt32Stream, decodeLengthStreamToOffsetBuffer } from "./i
1111
import { type Column, ScalarType } from "../metadata/tileset/tilesetMetadata";
1212
import { decodeVarintInt32 } from "./integerDecodingUtils";
1313
import { decodeBooleanRle, skipColumn } from "./decodingUtils";
14-
import { StringFsstDictionaryVector } from "../vector/fsst-dictionary/stringFsstDictionaryVector";
14+
import {
15+
type FsstDictionaryCache,
16+
StringFsstDictionaryVector,
17+
} from "../vector/fsst-dictionary/stringFsstDictionaryVector";
1518

1619
export function decodeString(
1720
name: string,
@@ -204,6 +207,8 @@ export function decodeSharedDictionary(
204207

205208
const childFields = column.complexType.children;
206209
const stringDictionaryVectors = [];
210+
/** Shared by every FSST child-column vector in this SharedDict and populated on first access. */
211+
const sharedDictionaryCache: FsstDictionaryCache | undefined = symbolTableBuffer ? {} : undefined;
207212
let i = 0;
208213
for (const childField of childFields) {
209214
const numStreams = decodeVarintInt32(data, offset, 1)[0];
@@ -259,6 +264,7 @@ export function decodeSharedDictionary(
259264
symbolOffsetBuffer,
260265
symbolTableBuffer,
261266
presentStreamBitVector,
267+
sharedDictionaryCache,
262268
)
263269
: new StringDictionaryVector(
264270
columnName,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { readFileSync } from "node:fs";
2+
import { bench, describe } from "vitest";
3+
import decodeTile from "../../mltDecoder";
4+
import type BitVector from "../flat/bitVector";
5+
import { type FsstDictionaryCache, StringFsstDictionaryVector } from "./stringFsstDictionaryVector";
6+
7+
type FsstVectorData = {
8+
indexBuffer: Uint32Array;
9+
offsetBuffer: Uint32Array;
10+
dataBuffer: Uint8Array;
11+
symbolOffsetBuffer: Uint32Array;
12+
symbolTableBuffer: Uint8Array;
13+
nullabilityBuffer: BitVector;
14+
};
15+
16+
describe("cold FSST dictionary access", () => {
17+
const tileData = readFileSync(new URL("../../../../test/expected/tag0x01/omt/14_8299_10748.mlt", import.meta.url));
18+
const featureTable = decodeTile(new Uint8Array(tileData.buffer, tileData.byteOffset, tileData.byteLength)).find(
19+
(table) => table.name === "poi",
20+
);
21+
const dictionaryVector = featureTable?.propertyVectors.find((vector) => vector?.name === "name");
22+
if (!(dictionaryVector instanceof StringFsstDictionaryVector)) {
23+
throw new Error("FSST cache benchmark dictionary poi.name not found");
24+
}
25+
26+
// Access internal buffers to construct cold vectors around the same production dictionary.
27+
const vectorData = dictionaryVector as unknown as FsstVectorData;
28+
const valueIndex = Array.from({ length: dictionaryVector.size }, (_, index) => index).find((index) =>
29+
dictionaryVector.has(index),
30+
);
31+
if (valueIndex === undefined) throw new Error("FSST cache benchmark dictionary contains no values");
32+
33+
let decodedLengthChecksum = 0;
34+
const createVector = (dataBuffer: Uint8Array, sharedDictionaryCache?: FsstDictionaryCache) =>
35+
new StringFsstDictionaryVector(
36+
"name",
37+
vectorData.indexBuffer,
38+
vectorData.offsetBuffer,
39+
dataBuffer,
40+
vectorData.symbolOffsetBuffer,
41+
vectorData.symbolTableBuffer,
42+
vectorData.nullabilityBuffer,
43+
sharedDictionaryCache,
44+
);
45+
46+
bench(
47+
"one ordinary FSST vector",
48+
() => {
49+
// A fresh buffer keeps every benchmark iteration cold.
50+
const vector = createVector(vectorData.dataBuffer.slice());
51+
decodedLengthChecksum = (decodedLengthChecksum + (vector.getValue(valueIndex)?.length ?? 0)) | 0;
52+
},
53+
{ warmupTime: 500, time: 5_000 },
54+
);
55+
56+
bench(
57+
"two ordinary FSST vectors",
58+
() => {
59+
// Ordinary vectors do not receive a SharedDict cache, so both decode independently.
60+
for (let i = 0; i < 2; i++) {
61+
const vector = createVector(vectorData.dataBuffer.slice());
62+
decodedLengthChecksum = (decodedLengthChecksum + (vector.getValue(valueIndex)?.length ?? 0)) | 0;
63+
}
64+
},
65+
{ warmupTime: 500, time: 5_000 },
66+
);
67+
68+
bench(
69+
"two vectors from one SharedDict",
70+
() => {
71+
// Vectors from one SharedDict receive the same cache, so the second reuses the first decoded dictionary.
72+
const sharedData = vectorData.dataBuffer.slice();
73+
const sharedDictionaryCache: FsstDictionaryCache = {};
74+
for (let i = 0; i < 2; i++) {
75+
const vector = createVector(sharedData, sharedDictionaryCache);
76+
decodedLengthChecksum = (decodedLengthChecksum + (vector.getValue(valueIndex)?.length ?? 0)) | 0;
77+
}
78+
},
79+
{ warmupTime: 500, time: 5_000 },
80+
);
81+
82+
bench(
83+
"five vectors from one SharedDict",
84+
() => {
85+
// Vectors from one SharedDict receive the same cache, so four reuse the first decoded dictionary.
86+
const sharedData = vectorData.dataBuffer.slice();
87+
const sharedDictionaryCache: FsstDictionaryCache = {};
88+
for (let i = 0; i < 5; i++) {
89+
const vector = createVector(sharedData, sharedDictionaryCache);
90+
decodedLengthChecksum = (decodedLengthChecksum + (vector.getValue(valueIndex)?.length ?? 0)) | 0;
91+
}
92+
},
93+
{ warmupTime: 500, time: 5_000 },
94+
);
95+
});

ts/src/vector/fsst-dictionary/stringFsstDictionaryVector.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import type BitVector from "../flat/bitVector";
33
import { decodeFsst } from "../../decoding/fsstDecoder";
44
import { decodeString } from "../../decoding/decodingUtils";
55

6+
/** Mutable cache shared by the FSST child columns of one SharedDict. */
7+
export type FsstDictionaryCache = {
8+
/** Undefined until one member of the SharedDict group decodes the dictionary on first access. */
9+
decodedDictionary?: Uint8Array;
10+
};
11+
612
export class StringFsstDictionaryVector extends VariableSizeVector<Uint8Array, string> {
713
// TODO: extend from StringVector
814
private symbolLengthBuffer: Uint32Array;
@@ -16,18 +22,21 @@ export class StringFsstDictionaryVector extends VariableSizeVector<Uint8Array, s
1622
private readonly symbolOffsetBuffer: Uint32Array,
1723
private readonly symbolTableBuffer: Uint8Array,
1824
nullabilityBuffer: BitVector,
25+
/** Cache shared by the FSST child columns of one SharedDict. */
26+
private readonly sharedDictionaryCache?: FsstDictionaryCache,
1927
) {
2028
super(name, offsetBuffer, dictionaryBuffer, nullabilityBuffer ?? indexBuffer.length);
2129
}
2230

2331
protected getValueFromBuffer(index: number): string {
2432
if (this.decodedDictionary == null) {
25-
if (this.symbolLengthBuffer == null) {
26-
// TODO: change FsstEncoder to take offsets instead of length to get rid of this conversion
27-
this.symbolLengthBuffer = this.offsetToLengthBuffer(this.symbolOffsetBuffer);
33+
this.decodedDictionary = this.sharedDictionaryCache?.decodedDictionary;
34+
if (this.decodedDictionary == null) {
35+
this.decodedDictionary = this.decodeDictionary();
36+
if (this.sharedDictionaryCache) {
37+
this.sharedDictionaryCache.decodedDictionary = this.decodedDictionary;
38+
}
2839
}
29-
30-
this.decodedDictionary = decodeFsst(this.symbolTableBuffer, this.symbolLengthBuffer, this.dataBuffer);
3140
}
3241

3342
const offset = this.indexBuffer[index];
@@ -36,6 +45,13 @@ export class StringFsstDictionaryVector extends VariableSizeVector<Uint8Array, s
3645
return decodeString(this.decodedDictionary, start, end);
3746
}
3847

48+
private decodeDictionary(): Uint8Array {
49+
if (this.symbolLengthBuffer == null) {
50+
this.symbolLengthBuffer = this.offsetToLengthBuffer(this.symbolOffsetBuffer);
51+
}
52+
return decodeFsst(this.symbolTableBuffer, this.symbolLengthBuffer, this.dataBuffer);
53+
}
54+
3955
// TODO: get rid of that conversion
4056
private offsetToLengthBuffer(offsetBuffer: Uint32Array): Uint32Array {
4157
const lengthBuffer = new Uint32Array(offsetBuffer.length - 1);

0 commit comments

Comments
 (0)