Skip to content

Commit d52cf9b

Browse files
authored
perf(ts): optimize full FSST dictionary decoding (#1499)
## Summary Optimizes full FSST dictionary decoding without changing decoder behavior. The previous implementation accumulated decoded bytes in a JavaScript number array and then copied them into a `Uint8Array`. The new implementation: 1. Scans the compressed data to calculate the decoded size. 2. Allocates an exact-size `Uint8Array`. 3. Decodes directly into that array. FSST decoding has a significant absolute cost only for unusually large dictionaries. For typical dictionaries the cost is already very small, but this change improves performance across every tested dictionary size. This PR intentionally covers only full dictionary decoding, which benefits the current MapLibre GL JS access pattern. ## Benchmarks The benchmark uses three complete MLT fixtures with typical, large, and xlarge FSST dictionaries. Results were measured with Node 24.11.1. | Dictionary | Compressed | Decoded | Before | After | Speedup | | --- | ---: | ---: | ---: | ---: | ---: | | Typical | 4,670 B | 8,712 B | 0.0543 ms | 0.0273 ms | 1.99× | | Large | 46,300 B | 78,929 B | 1.4007 ms | 0.4116 ms | 3.40× | | xlarge | 4,545,692 B | 8,360,752 B | 144.85 ms | 32.42 ms | 4.47× | Run the benchmark with: ```sh cd ts npm ci npm run bench:fsst ``` The xlarge fixture is an OpenStreetMap-derived tile. Its provenance and license are documented alongside the fixture. ## AI assistance This PR was developed with assistance from OpenAI Codex. The implementation was reviewed, benchmarked, and tested by me.
1 parent e14089d commit d52cf9b

8 files changed

Lines changed: 125 additions & 12 deletions

File tree

test/fixtures/osm/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# OpenStreetMap fixtures
2+
3+
`xlarge.mlt` is tile `4/8/5` from an OpenStreetMap-derived hiking tileset. It is included to benchmark an unusually large shared FSST dictionary.
4+
5+
The fixture contains information from [OpenStreetMap](https://www.openstreetmap.org/copyright), which is available under the [Open Data Commons Open Database License](https://opendatacommons.org/licenses/odbl/).

test/fixtures/osm/xlarge.mlt

5.2 MB
Binary file not shown.

ts/mod.just

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ test: install
4141

4242
# Run benchmark
4343
bench: install
44-
echo "TODO: Add js benchmark command"
44+
npm run bench

ts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
],
1616
"scripts": {
1717
"build": "tsc",
18+
"bench": "vitest bench --run src",
1819
"test": "vitest run --coverage --coverage.reportOnFailure",
1920
"lint": "eslint",
2021
"format": "biome format --write ./src",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { readFileSync } from "node:fs";
2+
import { bench, describe } from "vitest";
3+
import decodeTile from "../mltDecoder";
4+
import { StringFsstDictionaryVector } from "../vector/fsst-dictionary/stringFsstDictionaryVector";
5+
import { decodeFsst } from "./fsstDecoder";
6+
7+
type FsstDictionary = {
8+
symbols: Uint8Array;
9+
symbolLengths: Uint32Array;
10+
compressed: Uint8Array;
11+
decodedLength: number;
12+
};
13+
14+
let checksum = 0;
15+
16+
function loadDictionary(tilePath: string, tableName: string, columnName: string): FsstDictionary {
17+
const tileData = readFileSync(new URL(tilePath, import.meta.url));
18+
const featureTable = decodeTile(new Uint8Array(tileData.buffer, tileData.byteOffset, tileData.byteLength)).find(
19+
(table) => table.name === tableName,
20+
);
21+
const dictionaryVector = featureTable?.propertyVectors.find((vector) => vector?.name === columnName);
22+
if (!(dictionaryVector instanceof StringFsstDictionaryVector)) {
23+
throw new Error(`FSST benchmark dictionary ${tableName}.${columnName} not found`);
24+
}
25+
// Access internal buffers so this benchmark isolates decodeFsst instead of measuring cached vector access.
26+
const {
27+
dataBuffer: compressed,
28+
symbolOffsetBuffer: symbolOffsets,
29+
symbolTableBuffer: symbols,
30+
offsetBuffer,
31+
} = dictionaryVector as unknown as {
32+
dataBuffer: Uint8Array;
33+
symbolOffsetBuffer: Uint32Array;
34+
symbolTableBuffer: Uint8Array;
35+
offsetBuffer: Uint32Array;
36+
};
37+
const symbolLengths = new Uint32Array(symbolOffsets.length - 1);
38+
for (let i = 0; i < symbolLengths.length; i++) {
39+
symbolLengths[i] = symbolOffsets[i + 1] - symbolOffsets[i];
40+
}
41+
return {
42+
symbols,
43+
symbolLengths,
44+
compressed,
45+
decodedLength: offsetBuffer[offsetBuffer.length - 1],
46+
};
47+
}
48+
49+
function consume(decoded: Uint8Array): void {
50+
checksum = (checksum + decoded[0] + decoded[decoded.length >> 1] + decoded[decoded.length - 1]) | 0;
51+
}
52+
53+
describe("decode whole FSST dictionary", () => {
54+
const typical = loadDictionary("../../../test/expected/tag0x01/amazon_here/5_16_10.mlt", "pois", "name");
55+
bench(
56+
`typical: ${typical.compressed.length} compressed bytes to ${typical.decodedLength} decoded bytes`,
57+
() => consume(decodeFsst(typical.symbols, typical.symbolLengths, typical.compressed)),
58+
{ warmupTime: 500, time: 5_000 },
59+
);
60+
61+
const large = loadDictionary("../../../test/expected/tag0x01/omt/14_8299_10748.mlt", "poi", "name");
62+
bench(
63+
`large: ${large.compressed.length} compressed bytes to ${large.decodedLength} decoded bytes`,
64+
() => consume(decodeFsst(large.symbols, large.symbolLengths, large.compressed)),
65+
{ warmupTime: 500, time: 5_000 },
66+
);
67+
68+
const xlarge = loadDictionary("../../../test/fixtures/osm/xlarge.mlt", "hiking", "name");
69+
bench(
70+
`xlarge: ${xlarge.compressed.length} compressed bytes to ${xlarge.decodedLength} decoded bytes`,
71+
() => consume(decodeFsst(xlarge.symbols, xlarge.symbolLengths, xlarge.compressed)),
72+
{ warmupTime: 500, time: 5_000 },
73+
);
74+
});

ts/src/decoding/fsstDecoder.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ describe("decodeFsst", () => {
4242
expect(new TextDecoder().decode(decoded)).toBe(inputString);
4343
});
4444

45+
it("should handle consecutive escaped bytes", () => {
46+
const symbols = new Uint8Array([65]);
47+
const symbolLengths = new Uint32Array([1]);
48+
const encoded = new Uint8Array([255, 1, 255, 2, 0, 255, 3]);
49+
50+
expect(decodeFsst(symbols, symbolLengths, encoded)).toEqual(new Uint8Array([1, 2, 65, 3]));
51+
});
52+
4553
it("should handle string with all matching symbols", () => {
4654
const inputString = "AAAA";
4755
const originalBytes = textEncoder.encode(inputString);

ts/src/decoding/fsstDecoder.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
/**
2+
* Calculates the exact output size before decoding. This allows one final
3+
* `Uint8Array` allocation and avoids growing a JavaScript number array and
4+
* copying it into a typed array afterward. Traversing the compressed data
5+
* twice is always worthwhile here because it avoids those larger temporary
6+
* allocations.
7+
*/
8+
function getDecodedLength(symbolLengths: Uint32Array, compressedData: Uint8Array): number {
9+
let decodedLength = 0;
10+
for (let i = 0; i < compressedData.length; i++) {
11+
const symbolIndex = compressedData[i];
12+
if (symbolIndex === 255) {
13+
decodedLength++;
14+
i++; // Skip the literal byte following the escape marker.
15+
} else {
16+
decodedLength += symbolLengths[symbolIndex];
17+
}
18+
}
19+
20+
return decodedLength;
21+
}
22+
123
/**
224
* Decode FSST compressed data
325
*
@@ -6,26 +28,28 @@
628
* @param compressedData FSST Compressed data, where each entry is an index to the symbols array
729
* @returns Decoded data as Uint8Array
830
*/
9-
//TODO: improve -> quick and dirty implementation
1031
export function decodeFsst(symbols: Uint8Array, symbolLengths: Uint32Array, compressedData: Uint8Array): Uint8Array {
11-
//TODO: use typed array directly
12-
const decodedData: number[] = [];
13-
const symbolOffsets: number[] = new Array(symbolLengths.length).fill(0);
32+
const symbolOffsets = new Uint32Array(symbolLengths.length);
1433

1534
for (let i = 1; i < symbolLengths.length; i++) {
1635
symbolOffsets[i] = symbolOffsets[i - 1] + symbolLengths[i - 1];
1736
}
1837

38+
const decodedData = new Uint8Array(getDecodedLength(symbolLengths, compressedData));
39+
let decodedOffset = 0;
1940
for (let i = 0; i < compressedData.length; i++) {
20-
if (compressedData[i] === 255) {
21-
decodedData.push(compressedData[++i]);
41+
const symbolIndex = compressedData[i];
42+
if (symbolIndex === 255) {
43+
i++;
44+
decodedData[decodedOffset++] = compressedData[i];
2245
} else {
23-
const symbolLength = symbolLengths[compressedData[i]];
24-
const symbolOffset = symbolOffsets[compressedData[i]];
25-
for (let j = 0; j < symbolLength; j++) {
26-
decodedData.push(symbols[symbolOffset + j]);
46+
let symbolLength = symbolLengths[symbolIndex];
47+
let symbolOffset = symbolOffsets[symbolIndex];
48+
while (symbolLength-- > 0) {
49+
decodedData[decodedOffset++] = symbols[symbolOffset++];
2750
}
2851
}
2952
}
30-
return new Uint8Array(decodedData);
53+
54+
return decodedData;
3155
}

ts/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@
2323
"exclude": [
2424
"node_modules",
2525
"**/*.spec.ts",
26+
"**/*.bench.ts",
2627
]
2728
}

0 commit comments

Comments
 (0)