Skip to content

Commit 22fbba8

Browse files
bench: add LargeRoot retained-memory benchmark with heap snapshots; opt: clear referenceCache after read-only instantiation (toggle via MQT_KEEP_REF_CACHE); make resolveIdentifier robust without cache
Co-Authored-By: Harry Brundage <[email protected]>
1 parent cb4c043 commit 22fbba8

File tree

5 files changed

+148
-6
lines changed

5 files changed

+148
-6
lines changed

Benchmarking.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,24 @@ You can also use the profiler built into VSCode for executing scripts and profil
7474
pnpm build
7575
pnpm deoptigate dist/bench/create-many-model-class.js
7676
```
77+
78+
## Memory benchmarking
79+
80+
A memory benchmark for retained heap usage of LargeRoot instantiation is available.
81+
82+
Run it with:
83+
84+
```sh
85+
# default N=10 instances
86+
pnpm x bench/memory-large-root.benchmark.ts
87+
88+
# with GC exposed for cleaner readings
89+
node --expose-gc -r ts-node/register/transpile-only bench/memory-large-root.benchmark.ts
90+
91+
# control number of instances retained
92+
MQT_MEMORY_N=20 pnpm x bench/memory-large-root.benchmark.ts
93+
```
94+
95+
Artifacts are written to the repo root:
96+
- bench-LargeRoot-&lt;timestamp&gt;.json with before, after, and delta heap usage
97+
- bench-LargeRoot-&lt;timestamp&gt;.heapsnapshot for analysis in Chrome DevTools (Memory tab)

bench/memory-large-root.benchmark.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import findRoot from "find-root";
2+
import fs from "fs";
3+
import { LargeRoot } from "../spec/fixtures/LargeRoot";
4+
import { benchmarker, newInspectorSession } from "./benchmark";
5+
6+
const snapshot = JSON.parse(fs.readFileSync(findRoot(__dirname) + "/spec/fixtures/large-root-snapshot.json", "utf8"));
7+
8+
const roots: any[] = [];
9+
const key = Date.now();
10+
11+
export default benchmarker(
12+
async (suite) => {
13+
const N = Number(process.env.MQT_MEMORY_N || 10);
14+
15+
suite.add(`instantiate and retain LargeRoot (N=${N})`, function () {
16+
try {
17+
if ((globalThis as any).gc) (globalThis as any).gc();
18+
const before = process.memoryUsage().heapUsed;
19+
20+
for (let i = 0; i < N; i++) {
21+
roots.push(LargeRoot.createReadOnly(snapshot));
22+
}
23+
24+
if ((globalThis as any).gc) (globalThis as any).gc();
25+
const after = process.memoryUsage().heapUsed;
26+
27+
const jsonPath = `./bench-LargeRoot-${key}.json`;
28+
fs.writeFileSync(
29+
jsonPath,
30+
JSON.stringify({ N, before, after, delta: after - before }, null, 2),
31+
"utf8"
32+
);
33+
console.log(`Wrote ${jsonPath}`);
34+
35+
try {
36+
const { session, post } = newInspectorSession();
37+
const chunks: string[] = [];
38+
session.on("HeapProfiler.addHeapSnapshotChunk", (m: any) => chunks.push(m.params.chunk));
39+
post("HeapProfiler.enable")
40+
.then(() => post("HeapProfiler.takeHeapSnapshot", { reportProgress: false }))
41+
.then(() => {
42+
const hsPath = `./bench-LargeRoot-${key}.heapsnapshot`;
43+
fs.writeFileSync(hsPath, chunks.join(""), "utf8");
44+
console.log(`Wrote ${hsPath}`);
45+
})
46+
.finally(() => {
47+
void post("HeapProfiler.disable").catch(() => {});
48+
})
49+
.catch((e: any) => {
50+
console.error("Heap snapshot error", e?.message || e);
51+
});
52+
} catch (e: any) {
53+
console.error("Inspector session error", e?.message || e);
54+
}
55+
} catch (e: any) {
56+
console.error("Memory benchmark task error", e?.message || e);
57+
}
58+
});
59+
60+
return suite;
61+
},
62+
{ iterations: 1, warmupIterations: 0, warmupTime: 0, time: 0 }
63+
);

src/api.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from "mobx-state-tree";
2828
import type { FlowReturn } from "mobx-state-tree/dist/internal";
2929
import { CantRunActionError } from "./errors";
30-
import { $context, $parent, $quickType, $readOnly, $type } from "./symbols";
30+
import { $context, $parent, $quickType, $readOnly, $type, $identifier } from "./symbols";
3131
import type {
3232
CreateTypes,
3333
IAnyComplexType,
@@ -236,7 +236,57 @@ export function resolveIdentifier<T extends IAnyModelType>(
236236
if (!context) {
237237
throw new Error("can't resolve references in a readonly tree with no context");
238238
}
239-
return context.referenceCache.get(identifier) as Instance<T> | undefined;
239+
const cache = (context as any).referenceCache;
240+
if (cache && typeof cache.get === "function") {
241+
return cache.get(identifier) as Instance<T> | undefined;
242+
}
243+
const root: any = getRoot<IAnyType>(target);
244+
const visited = new Set<any>();
245+
const isTargetType = (node: any): boolean => {
246+
try {
247+
return (isType(type) ? type : (type as any)).is(node);
248+
} catch {
249+
return false;
250+
}
251+
};
252+
const stack: any[] = [root];
253+
while (stack.length) {
254+
const node = stack.pop();
255+
if (!node || typeof node !== "object" || visited.has(node)) continue;
256+
visited.add(node);
257+
if ($readOnly in node) {
258+
const id = node[$identifier];
259+
if (id === identifier && isTargetType(node)) {
260+
return node as Instance<T>;
261+
}
262+
for (const key of Object.keys(node)) {
263+
const val = node[key];
264+
if (val && typeof val === "object") {
265+
stack.push(val);
266+
}
267+
}
268+
if (Array.isArray(node)) {
269+
for (const val of node) stack.push(val);
270+
}
271+
if (node instanceof Map) {
272+
for (const val of node.values()) stack.push(val);
273+
}
274+
} else {
275+
for (const key of Object.keys(node)) {
276+
const val = node[key];
277+
if (val && typeof val === "object") {
278+
stack.push(val);
279+
}
280+
}
281+
if (Array.isArray(node)) {
282+
for (const val of node) stack.push(val);
283+
}
284+
if (node instanceof Map) {
285+
for (const val of node.values()) stack.push(val);
286+
}
287+
}
288+
}
289+
return undefined as Instance<T> | undefined;
240290
}
241291

242292
export const applySnapshot = <T extends IAnyType>(target: IStateTreeNode<T>, snapshot: SnapshotIn<T>): void => {

src/fast-instantiator.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ export class InstantiatorBuilder<T extends IClassModelType<Record<string, IAnyTy
114114
}
115115
116116
context.referencesToResolve = null; // cleanup the list of references to resolve, no need to retain them past construction
117+
if (!(process && process.env && process.env.MQT_KEEP_REF_CACHE)) {
118+
if (context.referenceCache && typeof context.referenceCache.clear === "function") {
119+
context.referenceCache.clear();
120+
}
121+
context.referenceCache = null;
122+
}
117123
118124
return instance;
119125
};

src/reference.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ export class ReferenceType<TargetType extends IAnyComplexType> extends BaseType<
3232
}
3333

3434
instantiate(snapshot: this["InputType"] | undefined, context: TreeContext, _parent: IStateTreeNode | null): this["InstanceType"] {
35-
if (!snapshot || !context.referenceCache.has(snapshot)) {
35+
const cache = context.referenceCache;
36+
if (!snapshot || !cache || !cache.has(snapshot)) {
3637
throw new Error(`can't resolve reference ${snapshot}`);
3738
}
38-
return context.referenceCache.get(snapshot);
39+
return cache.get(snapshot);
3940
}
4041

4142
is(value: any): value is this["InstanceType"];
@@ -61,10 +62,11 @@ export class SafeReferenceType<TargetType extends IAnyComplexType> extends BaseT
6162
}
6263

6364
instantiate(snapshot: string | undefined, context: TreeContext, _parent: IStateTreeNode | null): this["InstanceType"] {
64-
if (!snapshot || !context.referenceCache.has(snapshot)) {
65+
const cache = context.referenceCache;
66+
if (!snapshot || !cache || !cache.has(snapshot)) {
6567
return undefined as this["InstanceType"];
6668
}
67-
return context.referenceCache.get(snapshot);
69+
return cache.get(snapshot);
6870
}
6971

7072
is(value: any): value is this["InstanceType"];

0 commit comments

Comments
 (0)