Skip to content

Commit 6b3fb0e

Browse files
feat(mobx): make 2022.3 @computed decorator lazy (mobxjs#4639)
* fix(mobx): make 2022.3 @computed decorator lazy ComputedValue is now created on first read of the decorated getter instead of eagerly during instance construction, so getters that are never accessed on a given instance no longer allocate a ComputedValue. Closes mobxjs#4616 * perf(mobx): collapse lazy-computed read to one map lookup Address review on mobxjs#4639: - getObservablePropValue_ / setObservablePropValue_ / getAtom now do values_.get(key) ?? materializeLazyComputed_(key), so the hot path (computed already materialised, plain observable, or non-lazy class) is a single Map lookup instead of two. - materializeLazyComputed_ returns the freshly-built ComputedValue (or undefined when the key isn't lazy) so callers don't have to look it up again. Drops the unused boolean return. * test(mobx): add @computed decorator perf benchmark Adds a standalone perf benchmark for the lazy `@computed` decorator (mobxjs#4639): 50k instances × 10 getters with 1 read/instance — the realistic "wide class, sparse access" shape from mobxjs#4616. Run with `yarn perf-decorator` (requires a prior `yarn build`). * chore(changeset): mark lazy @computed decorator as feat/minor Per maintainer review on mobxjs#4639: the lazy `@computed` decorator is a perf improvement worth a minor bump rather than a patch. Reframes the entry as `feat:` and notes the construction heap / time savings measured by the benchmark added in the previous commit. --------- Co-authored-by: Michel Weststrate <mweststrate@gmail.com>
1 parent 0e8fbd6 commit 6b3fb0e

10 files changed

Lines changed: 270 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mobx": minor
3+
---
4+
5+
feat(mobx): make the 2022.3 `@computed` decorator lazy. `ComputedValue` is now created on first read of the decorated getter rather than eagerly during instance construction, avoiding wasted allocations for getters that are never used. On a 50k-instance × 10-getter class with one read per instance this cuts construction heap by ~50% and construction time by ~25%; the steady-state read path is unchanged. Closes #4616.

packages/mobx/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ README.md
33
LICENSE
44

55
temp
6-
perf_report
6+
perf_report
7+
__tests__/perf/compiled

packages/mobx/__tests__/decorators_20223/stage3-decorators.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
runInAction,
2525
makeObservable
2626
} from "../../src/mobx"
27-
import { type ObservableArrayAdministration } from "../../src/internal"
27+
import { $mobx, type ObservableArrayAdministration } from "../../src/internal"
2828
import * as mobx from "../../src/mobx"
2929

3030
const testFunction = function (a: any) {}
@@ -1150,6 +1150,64 @@ test("#2159 - computed property keys", () => {
11501150
])
11511151
})
11521152

1153+
test("4616 - @computed decorator should be lazy", () => {
1154+
let computeCount = 0
1155+
1156+
class Order {
1157+
@observable accessor price: number = 3
1158+
1159+
@computed
1160+
get unused() {
1161+
computeCount++
1162+
return this.price * 2
1163+
}
1164+
1165+
@computed
1166+
get used() {
1167+
return this.price * 3
1168+
}
1169+
}
1170+
1171+
const o = new Order()
1172+
// Sanity check via public API: both should report as observable props
1173+
t.equal(isObservableProp(o, "unused"), true)
1174+
t.equal(isObservableProp(o, "used"), true)
1175+
1176+
// Internal check: ComputedValue is not yet allocated for either getter
1177+
const adm: any = (o as any)[$mobx]
1178+
expect(adm.values_.has("unused")).toBe(false)
1179+
expect(adm.values_.has("used")).toBe(false)
1180+
expect(adm.lazyComputedKeys_.has("unused")).toBe(true)
1181+
expect(adm.lazyComputedKeys_.has("used")).toBe(true)
1182+
expect(computeCount).toBe(0)
1183+
1184+
// First access materialises the ComputedValue
1185+
t.equal(o.used, 9)
1186+
expect(adm.values_.has("used")).toBe(true)
1187+
expect(adm.lazyComputedKeys_.has("used")).toBe(false)
1188+
1189+
// The unused computed remains lazy and never ran
1190+
expect(adm.values_.has("unused")).toBe(false)
1191+
expect(computeCount).toBe(0)
1192+
})
1193+
1194+
test("4616 - observe on @computed before first read materialises it", () => {
1195+
class Order {
1196+
@observable accessor price: number = 3
1197+
1198+
@computed
1199+
get total() {
1200+
return this.price * 2
1201+
}
1202+
}
1203+
1204+
const o = new Order()
1205+
const events: number[] = []
1206+
observe(o, "total", ev => events.push((ev as any).newValue))
1207+
o.price = 4
1208+
t.deepEqual(events, [8])
1209+
})
1210+
11531211
test(`decorated field can be inherited, but doesn't inherite the effect of decorator`, () => {
11541212
class Store {
11551213
@action
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Benchmark for the lazy `@computed` decorator (#4616 / #4639).
2+
//
3+
// Shape: many instances of a class with several `@computed` getters where only
4+
// one getter is read per instance — the realistic "wide class, sparse access"
5+
// pattern. Compares construction heap, construction time, first-read time, and
6+
// re-read time. Run with `yarn perf-decorator` (requires a prior `yarn build`).
7+
8+
/* eslint-disable @typescript-eslint/no-require-imports */
9+
import * as path from "path"
10+
const distPath = path.resolve(__dirname, "..", "..", "..", "dist", "mobx.cjs.development.js")
11+
const mobx = require(distPath) as {
12+
computed: any
13+
makeObservable: any
14+
observable: any
15+
}
16+
const { computed, makeObservable, observable } = mobx
17+
18+
const INSTANCES = 50_000
19+
const GETTERS_PER_INSTANCE = 10
20+
const RUNS = 3
21+
const RE_READS = 5
22+
23+
class Wide {
24+
@observable accessor v = 1
25+
26+
@computed get c0() {
27+
return this.v + 0
28+
}
29+
@computed get c1() {
30+
return this.v + 1
31+
}
32+
@computed get c2() {
33+
return this.v + 2
34+
}
35+
@computed get c3() {
36+
return this.v + 3
37+
}
38+
@computed get c4() {
39+
return this.v + 4
40+
}
41+
@computed get c5() {
42+
return this.v + 5
43+
}
44+
@computed get c6() {
45+
return this.v + 6
46+
}
47+
@computed get c7() {
48+
return this.v + 7
49+
}
50+
@computed get c8() {
51+
return this.v + 8
52+
}
53+
@computed get c9() {
54+
return this.v + 9
55+
}
56+
57+
constructor() {
58+
makeObservable(this)
59+
}
60+
}
61+
62+
const GETTERS = ["c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9"] as const
63+
if (GETTERS.length !== GETTERS_PER_INSTANCE) throw new Error("getter list mismatch")
64+
65+
function forceGc() {
66+
if (typeof global.gc === "function") global.gc()
67+
}
68+
69+
function heapMB(): number {
70+
return process.memoryUsage().heapUsed / (1024 * 1024)
71+
}
72+
73+
type Sample = {
74+
constructHeapMB: number
75+
constructMs: number
76+
firstReadMs: number
77+
reReadMs: number
78+
}
79+
80+
function bench(): Sample {
81+
forceGc()
82+
const heapBefore = heapMB()
83+
84+
const t0 = performance.now()
85+
const instances: Wide[] = new Array(INSTANCES)
86+
for (let i = 0; i < INSTANCES; i++) instances[i] = new Wide()
87+
const t1 = performance.now()
88+
89+
forceGc()
90+
const heapAfter = heapMB()
91+
92+
const t2 = performance.now()
93+
let sink = 0
94+
for (let i = 0; i < INSTANCES; i++) {
95+
sink += (instances[i] as any)[GETTERS[i % GETTERS_PER_INSTANCE]]
96+
}
97+
const t3 = performance.now()
98+
99+
const t4 = performance.now()
100+
for (let r = 0; r < RE_READS; r++) {
101+
for (let i = 0; i < INSTANCES; i++) {
102+
sink += (instances[i] as any)[GETTERS[i % GETTERS_PER_INSTANCE]]
103+
}
104+
}
105+
const t5 = performance.now()
106+
107+
if (sink === Number.NEGATIVE_INFINITY) console.log("unreachable")
108+
109+
return {
110+
constructHeapMB: heapAfter - heapBefore,
111+
constructMs: t1 - t0,
112+
firstReadMs: t3 - t2,
113+
reReadMs: t5 - t4
114+
}
115+
}
116+
117+
function fmt(n: number, digits = 1): string {
118+
return n.toFixed(digits).padStart(8)
119+
}
120+
121+
function main() {
122+
console.log(
123+
`\nLazy @computed decorator benchmark — ${INSTANCES} instances × ` +
124+
`${GETTERS_PER_INSTANCE} @computed getters, 1 read/instance, ${RE_READS} re-reads.`
125+
)
126+
console.log(`Node ${process.version}, ${RUNS} timed runs after 1 warmup.\n`)
127+
128+
bench() // warmup
129+
130+
const samples: Sample[] = []
131+
for (let r = 0; r < RUNS; r++) samples.push(bench())
132+
133+
console.log("run | construct heap MB | construct ms | first-read ms | re-read ms")
134+
console.log("----+-------------------+--------------+---------------+-----------")
135+
samples.forEach((s, i) => {
136+
console.log(
137+
` ${i + 1} | ${fmt(s.constructHeapMB)} | ${fmt(s.constructMs)} | ${fmt(
138+
s.firstReadMs
139+
)} | ${fmt(s.reReadMs)}`
140+
)
141+
})
142+
143+
const avg = (pick: (s: Sample) => number) =>
144+
samples.reduce((a, s) => a + pick(s), 0) / samples.length
145+
console.log(
146+
`avg | ${fmt(avg(s => s.constructHeapMB))} | ${fmt(
147+
avg(s => s.constructMs)
148+
)} | ${fmt(avg(s => s.firstReadMs))} | ${fmt(avg(s => s.reReadMs))}`
149+
)
150+
}
151+
152+
main()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "CommonJS",
5+
"moduleResolution": "Node",
6+
"useDefineForClassFields": true,
7+
"experimentalDecorators": false,
8+
"esModuleInterop": true,
9+
"strict": false,
10+
"skipLibCheck": true,
11+
"outDir": "./compiled",
12+
"rootDir": "./"
13+
},
14+
"include": ["./lazy-computed-decorator.ts"]
15+
}

packages/mobx/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@
6868
"perf": "scripts/perf.sh",
6969
"perf-legacy": "node --expose-gc ./__tests__/perf/index.js legacy",
7070
"perf-proxy": "node --expose-gc ./__tests__/perf/index.js proxy",
71-
"test:performance": "npm run perf -- proxy && npm run perf -- legacy",
71+
"perf-decorator": "tsc -p ./__tests__/perf/tsconfig.decorator.json && node --expose-gc ./__tests__/perf/compiled/lazy-computed-decorator.js",
72+
"test:performance": "npm run perf -- proxy && npm run perf -- legacy && npm run perf-decorator",
7273
"test:mixed-versions": "npm test -- --testRegex mixed-versions",
7374
"test:types": "tsc --noEmit",
7475
"test:flow": "flow check",

packages/mobx/src/api/isobservable.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ function _isObservable(value, property?: PropertyKey): boolean {
2121
)
2222
}
2323
if (isObservableObject(value)) {
24-
return value[$mobx].values_.has(property)
24+
const adm = value[$mobx]
25+
return adm.values_.has(property) || !!adm.lazyComputedKeys_?.has(property)
2526
}
2627
return false
2728
}

packages/mobx/src/types/computedannotation.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,23 @@ function decorate_20223_(this: Annotation, get, context: ClassGetterDecoratorCon
5454
const ann = this
5555
const { name: key, addInitializer } = context
5656

57+
// Defer ComputedValue creation until first access — avoids allocating
58+
// ComputedValues for getters that are never read on a given instance.
59+
// The factory is materialised by ObservableObjectAdministration on demand.
5760
addInitializer(function () {
5861
const adm: ObservableObjectAdministration = asObservableObject(this)[$mobx]
59-
const options = {
60-
...ann.options_,
61-
get,
62-
context: this
63-
}
64-
options.name ||= __DEV__
65-
? `${adm.name_}.${key.toString()}`
66-
: `ObservableObject.${key.toString()}`
67-
adm.values_.set(key, new ComputedValue(options))
62+
const target = this
63+
;(adm.lazyComputedKeys_ ??= new Map()).set(key, () => {
64+
const options = {
65+
...ann.options_,
66+
get,
67+
context: target
68+
}
69+
options.name ||= __DEV__
70+
? `${adm.name_}.${key.toString()}`
71+
: `ObservableObject.${key.toString()}`
72+
return new ComputedValue(options)
73+
})
6874
})
6975

7076
return function () {

packages/mobx/src/types/observableobject.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export class ObservableObjectAdministration
9898
isPlainObject_: boolean
9999
appliedAnnotations_?: object
100100
private pendingKeys_: undefined | Map<PropertyKey, ObservableValue<boolean>>
101+
lazyComputedKeys_: undefined | Map<PropertyKey, () => ComputedValue<any>>
101102

102103
constructor(
103104
public target_: any,
@@ -119,11 +120,24 @@ export class ObservableObjectAdministration
119120
}
120121

121122
getObservablePropValue_(key: PropertyKey): any {
122-
return this.values_.get(key)!.get()
123+
// Hot path: single map lookup. Lazy computeds (rare) take the materialise branch.
124+
const observable = this.values_.get(key) ?? this.materializeLazyComputed_(key)
125+
return observable!.get()
126+
}
127+
128+
materializeLazyComputed_(key: PropertyKey): ComputedValue<any> | undefined {
129+
const factory = this.lazyComputedKeys_?.get(key)
130+
if (!factory) {
131+
return undefined
132+
}
133+
this.lazyComputedKeys_!.delete(key)
134+
const computed = factory()
135+
this.values_.set(key, computed)
136+
return computed
123137
}
124138

125139
setObservablePropValue_(key: PropertyKey, newValue): boolean | null {
126-
const observable = this.values_.get(key)
140+
const observable = this.values_.get(key) ?? this.materializeLazyComputed_(key)
127141
if (observable instanceof ComputedValue) {
128142
observable.set(newValue)
129143
return true

packages/mobx/src/types/type-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export function getAtom(thing: any, property?: PropertyKey): IDepTreeNode {
4747
if (!property) {
4848
return die(26)
4949
}
50-
const observable = (thing as any)[$mobx].values_.get(property)
50+
const adm = (thing as any)[$mobx]
51+
const observable = adm.values_.get(property) ?? adm.materializeLazyComputed_(property)
5152
if (!observable) {
5253
die(27, property, getDebugName(thing))
5354
}

0 commit comments

Comments
 (0)