Skip to content

Commit 11c56cd

Browse files
authored
perf(fmt): optimize cache serialization (#341)
1 parent 8b18ff6 commit 11c56cd

10 files changed

Lines changed: 304 additions & 156 deletions

File tree

packages/rstack/src/fmt/cacheIdentity.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hash } from 'node:crypto';
1+
import { hash as createDigest } from 'node:crypto';
22
import { isAbsolute } from 'node:path';
33
import stableStringify from 'fast-json-stable-stringify';
44
import { fmtCacheVersion } from './cacheStore.ts';
@@ -12,7 +12,9 @@ type CacheKeyResolver = (filePath: string) => string | undefined;
1212
type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined;
1313
type PluginFingerprints = ReadonlyMap<string, string>;
1414

15-
const sha256 = (content: string | Uint8Array): string => hash('sha256', content, 'hex');
15+
const cacheHashLength = 16;
16+
const createCacheHash = (content: string | Uint8Array): string =>
17+
createDigest('sha256', content, 'base64url').slice(0, cacheHashLength);
1618

1719
/** Identifies formatter behavior shared by all cache entries in this process. */
1820
const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]);
@@ -55,7 +57,7 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa
5557
}
5658
value = { ...options, plugins: fingerprints };
5759
}
58-
hash = sha256(stableStringify(value));
60+
hash = createCacheHash(stableStringify(value));
5961
} catch {
6062
// Circular or unreadable options cannot be cached.
6163
}
@@ -65,4 +67,10 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa
6567
};
6668
};
6769

68-
export { cacheNamespace, createCacheKeyResolver, createOptionsHasher, sha256 };
70+
export {
71+
cacheHashLength,
72+
cacheNamespace,
73+
createCacheHash,
74+
createCacheKeyResolver,
75+
createOptionsHasher,
76+
};

packages/rstack/src/fmt/cacheStore.ts

Lines changed: 162 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,40 @@ import { randomUUID } from 'node:crypto';
22
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
33
import path from 'node:path';
44

5-
const fmtCacheFileName = 'v1.json';
6-
const fmtCacheVersion = 1;
5+
const fmtCacheFileName = 'cache.json';
6+
const fmtCacheVersion = 2;
77

8-
type FmtCacheState = 'clean' | 'dirty' | 'unsupported';
9-
type FmtCacheEntry =
10-
| readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty']
11-
| readonly [contentHash: string | null, optionsHash: string, state: 'unsupported'];
8+
const fileEntryWidth = 4;
9+
const contentHashOffset = 1;
10+
const optionsIndexOffset = 2;
11+
const stateOffset = 3;
12+
13+
const fmtCacheStates = ['clean', 'dirty', 'unsupported'] as const;
14+
type FmtCacheState = (typeof fmtCacheStates)[number];
15+
type FmtCacheStateId = 0 | 1 | 2;
16+
17+
const fmtCacheStateIds = {
18+
clean: 0,
19+
dirty: 1,
20+
unsupported: 2,
21+
} as const satisfies Record<FmtCacheState, FmtCacheStateId>;
22+
23+
type FmtCacheFileValue = string | number;
24+
type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState];
1225

1326
interface FmtCacheFile {
1427
version: typeof fmtCacheVersion;
1528
namespace: string;
16-
files: Record<string, FmtCacheEntry>;
29+
options: string[];
30+
/** Repeated tuples of file path, content hash, options index, and numeric state. */
31+
files: FmtCacheFileValue[];
32+
}
33+
34+
interface ParsedFmtCacheFile {
35+
cache: FmtCacheFile;
36+
fileOffsets: Map<string, number>;
37+
optionsIndexes: Map<string, number>;
38+
optionsUseCounts: number[];
1739
}
1840

1941
interface FmtCacheStore {
@@ -23,66 +45,61 @@ interface FmtCacheStore {
2345
save(): Promise<boolean>;
2446
}
2547

26-
const createEmptyCache = (namespace: string): FmtCacheFile => ({
27-
version: fmtCacheVersion,
28-
namespace,
29-
files: Object.create(null) as Record<string, FmtCacheEntry>,
48+
const createEmptyCache = (namespace: string): ParsedFmtCacheFile => ({
49+
cache: {
50+
version: fmtCacheVersion,
51+
namespace,
52+
options: [],
53+
files: [],
54+
},
55+
fileOffsets: new Map(),
56+
optionsIndexes: new Map(),
57+
optionsUseCounts: [],
3058
});
3159

32-
const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => {
33-
if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') {
34-
return;
35-
}
36-
37-
if (value[2] === 'unsupported') {
38-
return value[0] === null || typeof value[0] === 'string'
39-
? [value[0], value[1], value[2]]
40-
: undefined;
41-
}
42-
if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) {
43-
return;
44-
}
45-
46-
return [value[0], value[1], value[2]];
47-
};
48-
49-
const parseCacheFile = (content: string): FmtCacheFile | undefined => {
60+
const parseCacheFile = (content: string): ParsedFmtCacheFile | undefined => {
5061
let value: unknown;
5162
try {
5263
value = JSON.parse(content);
5364
} catch {
5465
return;
5566
}
5667

68+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
69+
return;
70+
}
71+
72+
const cache = value as FmtCacheFile;
73+
const { version, namespace, options, files } = cache;
5774
if (
58-
typeof value !== 'object' ||
59-
value === null ||
60-
Array.isArray(value) ||
61-
!('version' in value) ||
62-
value.version !== fmtCacheVersion ||
63-
!('namespace' in value) ||
64-
typeof value.namespace !== 'string' ||
65-
!('files' in value) ||
66-
typeof value.files !== 'object' ||
67-
value.files === null ||
68-
Array.isArray(value.files)
75+
version !== fmtCacheVersion ||
76+
typeof namespace !== 'string' ||
77+
!Array.isArray(options) ||
78+
!Array.isArray(files) ||
79+
files.length % fileEntryWidth !== 0
6980
) {
7081
return;
7182
}
7283

73-
const files = Object.create(null) as Record<string, FmtCacheEntry>;
74-
for (const [filePath, rawEntry] of Object.entries(value.files)) {
75-
const entry = parseCacheEntry(rawEntry);
76-
if (!entry) {
77-
return;
78-
}
79-
files[filePath] = entry;
84+
const optionsIndexes = new Map<string, number>();
85+
for (let index = 0; index < options.length; index++) {
86+
optionsIndexes.set(options[index], index);
87+
}
88+
89+
const fileOffsets = new Map<string, number>();
90+
const optionsUseCounts = new Array<number>(options.length).fill(0);
91+
for (let offset = 0; offset < files.length; offset += fileEntryWidth) {
92+
const filePath = files[offset] as string;
93+
const optionsIndex = files[offset + optionsIndexOffset] as number;
94+
fileOffsets.set(filePath, offset);
95+
optionsUseCounts[optionsIndex]++;
8096
}
8197

8298
return {
83-
version: fmtCacheVersion,
84-
namespace: value.namespace,
85-
files,
99+
cache,
100+
fileOffsets,
101+
optionsIndexes,
102+
optionsUseCounts,
86103
};
87104
};
88105

@@ -100,43 +117,124 @@ const getTemporaryPath = (filePath: string): string =>
100117
class FmtCacheStoreImpl implements FmtCacheStore {
101118
readonly #filePath: string;
102119
readonly #cache: FmtCacheFile;
120+
readonly #fileOffsets: Map<string, number>;
121+
readonly #optionsIndexes: Map<string, number>;
122+
readonly #optionsUseCounts: number[];
103123
#savedContent: string | undefined;
104124
#changed: boolean;
105125

106126
constructor(
107127
filePath: string,
108-
cache: FmtCacheFile,
128+
parsed: ParsedFmtCacheFile,
109129
savedContent: string | undefined,
110130
changed: boolean,
111131
) {
112132
this.#filePath = filePath;
113-
this.#cache = cache;
133+
this.#cache = parsed.cache;
134+
this.#fileOffsets = parsed.fileOffsets;
135+
this.#optionsIndexes = parsed.optionsIndexes;
136+
this.#optionsUseCounts = parsed.optionsUseCounts;
114137
this.#savedContent = savedContent;
115138
this.#changed = changed;
116139
}
117140

118141
get(filePath: string): FmtCacheEntry | undefined {
119-
return this.#cache.files[filePath];
142+
const offset = this.#fileOffsets.get(filePath);
143+
if (offset === undefined) {
144+
return;
145+
}
146+
147+
const { files, options } = this.#cache;
148+
const contentHash = files[offset + contentHashOffset] as string;
149+
const optionsHash = options[files[offset + optionsIndexOffset] as number];
150+
const state = fmtCacheStates[files[offset + stateOffset] as FmtCacheStateId];
151+
return [contentHash, optionsHash, state];
120152
}
121153

122154
set(filePath: string, entry: FmtCacheEntry): void {
123-
const current = this.#cache.files[filePath];
124-
if (current?.[0] === entry[0] && current[1] === entry[1] && current[2] === entry[2]) {
125-
return;
155+
const { files, options } = this.#cache;
156+
const [contentHash, optionsHash, state] = entry;
157+
const stateId = fmtCacheStateIds[state];
158+
const offset = this.#fileOffsets.get(filePath);
159+
160+
if (offset !== undefined) {
161+
const currentOptionsIndex = files[offset + optionsIndexOffset] as number;
162+
if (
163+
files[offset + contentHashOffset] === contentHash &&
164+
options[currentOptionsIndex] === optionsHash &&
165+
files[offset + stateOffset] === stateId
166+
) {
167+
return;
168+
}
169+
170+
const optionsIndex = this.#getOrCreateOptionsIndex(optionsHash);
171+
if (currentOptionsIndex !== optionsIndex) {
172+
this.#optionsUseCounts[currentOptionsIndex]--;
173+
this.#optionsUseCounts[optionsIndex]++;
174+
files[offset + optionsIndexOffset] = optionsIndex;
175+
}
176+
files[offset + contentHashOffset] = contentHash;
177+
files[offset + stateOffset] = stateId;
178+
} else {
179+
const optionsIndex = this.#getOrCreateOptionsIndex(optionsHash);
180+
const nextOffset = files.length;
181+
files.push(filePath, contentHash, optionsIndex, stateId);
182+
this.#fileOffsets.set(filePath, nextOffset);
183+
this.#optionsUseCounts[optionsIndex]++;
126184
}
127185

128-
this.#cache.files[filePath] =
129-
entry[2] === 'unsupported'
130-
? [entry[0], entry[1], 'unsupported']
131-
: [entry[0], entry[1], entry[2]];
132186
this.#changed = true;
133187
}
134188

189+
#getOrCreateOptionsIndex(optionsHash: string): number {
190+
const current = this.#optionsIndexes.get(optionsHash);
191+
if (current !== undefined) {
192+
return current;
193+
}
194+
195+
const index = this.#cache.options.length;
196+
this.#cache.options.push(optionsHash);
197+
this.#optionsIndexes.set(optionsHash, index);
198+
this.#optionsUseCounts.push(0);
199+
return index;
200+
}
201+
202+
#compactUnusedOptions(): void {
203+
if (!this.#optionsUseCounts.includes(0)) {
204+
return;
205+
}
206+
207+
const { files, options } = this.#cache;
208+
const nextOptions: string[] = [];
209+
const nextUseCounts: number[] = [];
210+
const remappedIndexes = new Int32Array(options.length).fill(-1);
211+
for (let index = 0; index < options.length; index++) {
212+
const useCount = this.#optionsUseCounts[index];
213+
if (useCount > 0) {
214+
remappedIndexes[index] = nextOptions.length;
215+
nextOptions.push(options[index]);
216+
nextUseCounts.push(useCount);
217+
}
218+
}
219+
for (let offset = 0; offset < files.length; offset += fileEntryWidth) {
220+
const currentIndex = files[offset + optionsIndexOffset] as number;
221+
files[offset + optionsIndexOffset] = remappedIndexes[currentIndex];
222+
}
223+
224+
options.splice(0, options.length, ...nextOptions);
225+
this.#optionsUseCounts.splice(0, this.#optionsUseCounts.length, ...nextUseCounts);
226+
this.#optionsIndexes.clear();
227+
for (let index = 0; index < options.length; index++) {
228+
this.#optionsIndexes.set(options[index], index);
229+
}
230+
}
231+
135232
async save(): Promise<boolean> {
136233
if (!this.#changed) {
137234
return false;
138235
}
139236

237+
this.#compactUnusedOptions();
140238
const content = serializeCache(this.#cache);
141239
if (content === this.#savedContent) {
142240
this.#changed = false;
@@ -164,13 +262,13 @@ const loadFmtCacheStore = async (filePath: string, namespace: string): Promise<F
164262

165263
try {
166264
const content = await readFile(filePath, 'utf8');
167-
const cache = parseCacheFile(content);
168-
if (!cache) {
265+
const parsed = parseCacheFile(content);
266+
if (!parsed) {
169267
return new FmtCacheStoreImpl(filePath, emptyCache, undefined, true);
170268
}
171269

172-
return cache.namespace === namespace
173-
? new FmtCacheStoreImpl(filePath, cache, content, false)
270+
return parsed.cache.namespace === namespace
271+
? new FmtCacheStoreImpl(filePath, parsed, content, false)
174272
: new FmtCacheStoreImpl(filePath, emptyCache, undefined, true);
175273
} catch (error) {
176274
const missing = isFileNotFoundError(error);

packages/rstack/src/fmt/runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ const isCachedUnsupported = ({ file, cache }: FmtFileRunTask): boolean => {
116116
return false;
117117
}
118118
return (
119-
cache.entry[0] === null &&
119+
cache.entry[0] === '' &&
120120
cache.entry[1] === cache.optionsHash &&
121121
cache.entry[2] === 'unsupported' &&
122122
hasDottedBasename(file.path)

packages/rstack/src/fmt/worker.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ interface FormatFileTask {
1212
cache?: FmtFileCache;
1313
}
1414

15-
const hashContent = (content: string | Uint8Array): string => hash('sha256', content, 'hex');
15+
const hashContent = (content: string | Uint8Array): string =>
16+
hash('sha256', content, 'base64url').slice(0, 16);
1617

1718
/**
1819
* Use synchronous direct I/O inside the dedicated worker to avoid libuv
@@ -44,7 +45,7 @@ const formatFile = async ({
4445
if (cache?.entry && cache.entry[1] === cache.optionsHash) {
4546
const { entry } = cache;
4647
if (entry[2] === 'unsupported') {
47-
if (entry[0] === null) {
48+
if (entry[0] === '') {
4849
if (hasDottedBasename(file.path)) {
4950
return { status: 'unsupported' };
5051
}
@@ -72,7 +73,7 @@ const formatFile = async ({
7273
status: 'unsupported',
7374
cacheEntry: [
7475
hasDottedBasename(file.path)
75-
? null
76+
? ''
7677
: (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))),
7778
cache.optionsHash,
7879
'unsupported',

0 commit comments

Comments
 (0)