Skip to content

Commit ced53ca

Browse files
Add Effect runtime benchmark ceilings (#50)
1 parent 1350f10 commit ced53ca

7 files changed

Lines changed: 377 additions & 99 deletions

File tree

perf/runtime/README.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,18 @@ The command reports:
1515
- repeated child lookup and delivery to one running child;
1616
- machine start-and-stop throughput;
1717
- parent-with-child start-and-stop throughput;
18+
- Effect-only lifecycle reference points for a suspended fiber, a queue worker,
19+
a minimal actor shell, and a two-shell family;
20+
- Effect-only coordination reference points for an owner-only mutable snapshot,
21+
a synchronized snapshot, and a terminal `Deferred` latch;
1822
- heap and resident-memory growth at 100, 500, and 1,000 live units, including
19-
a raw managed process, an idle statechart, two independent statecharts, a
20-
parent with one child, that relationship with child-registry observation
21-
active, and an invoked child whose active snapshots are observed;
23+
a raw generic process, a raw compiled process, an idle statechart, two
24+
independent statecharts, a parent with one child, that relationship with
25+
child-registry observation active, and an invoked child whose active snapshots
26+
are observed;
2227
- lower-bound memory profiles for Effect itself: a suspended fiber, a queue
23-
with a waiting fiber, and a minimal mailbox/state/completion actor shell.
28+
with a waiting fiber, a minimal mailbox/state/completion actor shell, and a
29+
minimal two-shell family.
2430

2531
The comparison dependencies use package aliases, so XState 5 and 6 can be
2632
loaded by the same process:
@@ -62,6 +68,11 @@ registry and invoked-snapshot observation. Invoked snapshot mapping uses a
6268
direct, state-scoped delivery path; its profile measures the retained callback
6369
and mapping state rather than a general `changes` stream subscription. The
6470
Effect profiles are primitive lower bounds, not feature-equivalent competitors.
71+
The Effect throughput reference points similarly bound individual runtime
72+
operations rather than predicting a complete machine by themselves. In
73+
particular, the owner-only mutable snapshot is safe only when one process fiber
74+
owns active state writes; terminal arbitration and externally visible
75+
observation still require separate coordination.
6576
Resident memory is reported as a raw diagnostic because V8 and the
6677
operating-system allocator can reuse already committed pages. The
6778
capacity-per-GiB value is a linear estimate that excludes shared process
@@ -96,6 +107,7 @@ to update the comment.
96107

97108
The implementation lives in `scripts/runtime-performance.mjs`; the Effect
98109
Machine fixture is in `perf/runtime/counter.mjs`, and the comparison adapter is
99-
in `perf/runtime/xstate.mjs`. Add new scenarios only when every implementation
100-
performs equivalent observable work and the result is consumed and checked so
101-
the JavaScript engine cannot discard it.
110+
in `perf/runtime/xstate.mjs`. Effect runtime reference fixtures are in
111+
`perf/runtime/effect-runtime.mjs`. Add cross-library scenarios only when every
112+
implementation performs equivalent observable work and the result is consumed
113+
and checked so the JavaScript engine cannot discard it.

perf/runtime/counter.mjs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ const rawProcessLogic = {
229229
run: () => Effect.never
230230
}
231231

232+
const rawCompiledProcessLogic = {
233+
[machineRuntime.compiledProcess]: true,
234+
initial: () => Effect.succeed(0),
235+
run: () => Effect.never
236+
}
237+
232238
const startRawProcesses = (count) =>
233239
Effect.runPromise(
234240
Effect.forEach(
@@ -238,6 +244,15 @@ const startRawProcesses = (count) =>
238244
)
239245
)
240246

247+
const startCompiledRawProcesses = (count) =>
248+
Effect.runPromise(
249+
Effect.forEach(
250+
Array.from({ length: count }),
251+
() => machineRuntime.startProcess(rawCompiledProcessLogic),
252+
{ concurrency: 1 }
253+
)
254+
)
255+
241256
const startIndependentCounterPairs = (count) =>
242257
Effect.runPromise(
243258
Effect.forEach(
@@ -330,17 +345,39 @@ export const effectMachineAdapter = {
330345
stopChildCounters,
331346
stopObservedCounter,
332347
stopCounters,
348+
runtimeBenchmarks: [
349+
{
350+
id: "compiled-process-start-stop",
351+
label: "Start and stop a raw compiled process",
352+
unit: "processes/s",
353+
async: true,
354+
operations: () => 1,
355+
run: async () => {
356+
const refs = await startCompiledRawProcesses(1)
357+
try {
358+
return refs.length
359+
} finally {
360+
await stopCounters(refs)
361+
}
362+
}
363+
}
364+
],
333365
memoryProfiles: {
334366
idle: {
335367
label: "Idle machine",
336368
start: startCounters,
337369
stop: stopCounters
338370
},
339371
"raw-process": {
340-
label: "Raw managed process",
372+
label: "Raw generic managed process",
341373
start: startRawProcesses,
342374
stop: stopCounters
343375
},
376+
"compiled-raw-process": {
377+
label: "Raw compiled process",
378+
start: startCompiledRawProcesses,
379+
stop: stopCounters
380+
},
344381
"two-independent": {
345382
label: "Two independent idle machines",
346383
start: startIndependentCounterPairs,

perf/runtime/effect-memory.mjs

Lines changed: 0 additions & 81 deletions
This file was deleted.

perf/runtime/effect-runtime.mjs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { createRequire } from "node:module"
2+
import { readFileSync } from "node:fs"
3+
import { dirname, join, resolve } from "node:path"
4+
import { fileURLToPath, pathToFileURL } from "node:url"
5+
6+
const implementationRoot = resolve(
7+
process.env.EFFECT_MACHINE_BENCHMARK_ROOT ?? fileURLToPath(new URL("../..", import.meta.url))
8+
)
9+
const implementationRequire = createRequire(pathToFileURL(join(implementationRoot, "package.json")))
10+
const effectPackagePath = implementationRequire.resolve("effect/package.json")
11+
const effectPackage = JSON.parse(readFileSync(effectPackagePath, "utf8"))
12+
const effect = await import(pathToFileURL(resolve(dirname(effectPackagePath), effectPackage.exports["."])).href)
13+
const { Deferred, Effect, Fiber, MutableRef, Queue, Ref, SynchronizedRef } = effect
14+
15+
const startUnits = (count, make) =>
16+
Effect.runPromise(
17+
Effect.forEach(Array.from({ length: count }), make, { concurrency: 1 })
18+
)
19+
20+
const stopUnits = (units) =>
21+
Effect.runPromise(
22+
Effect.forEach(
23+
units,
24+
(unit) =>
25+
Fiber.interruptAll(unit.fibers).pipe(
26+
Effect.andThen(Effect.forEach(unit.queues, Queue.shutdown, { discard: true }))
27+
),
28+
{ concurrency: "unbounded", discard: true }
29+
)
30+
)
31+
32+
const makeFiber = () =>
33+
Effect.map(Effect.forkDetach(Effect.never), (fiber) => ({
34+
fibers: [fiber],
35+
queues: []
36+
}))
37+
38+
const makeMailbox = () =>
39+
Effect.gen(function*() {
40+
const queue = yield* Queue.unbounded()
41+
const fiber = yield* Effect.forkDetach(Effect.forever(Queue.take(queue)))
42+
return { fibers: [fiber], queues: [queue] }
43+
})
44+
45+
const makeActorShell = () =>
46+
Effect.gen(function*() {
47+
const queue = yield* Queue.unbounded()
48+
const state = yield* Ref.make(0)
49+
const done = yield* Deferred.make()
50+
const fiber = yield* Effect.forkDetach(
51+
Effect.forever(
52+
Queue.take(queue).pipe(
53+
Effect.flatMap((update) => Ref.update(state, update))
54+
)
55+
)
56+
)
57+
return { fibers: [fiber], queues: [queue], retained: [state, done] }
58+
})
59+
60+
const makeActorFamily = () =>
61+
Effect.all([makeActorShell(), makeActorShell()], { concurrency: 1 }).pipe(
62+
Effect.map((units) => ({
63+
fibers: units.flatMap((unit) => unit.fibers),
64+
queues: units.flatMap((unit) => unit.queues),
65+
retained: units
66+
}))
67+
)
68+
69+
const runLifecycle = async (make, expectedUnits = 1) => {
70+
const units = await startUnits(expectedUnits, make)
71+
try {
72+
if (units.length !== expectedUnits) {
73+
throw new Error(`Effect runtime lifecycle started ${units.length} units, expected ${expectedUnits}`)
74+
}
75+
return 1
76+
} finally {
77+
await stopUnits(units)
78+
}
79+
}
80+
81+
const mutableSnapshot = MutableRef.make(0)
82+
const runMutableSnapshotBatch = (size) => {
83+
const before = MutableRef.get(mutableSnapshot)
84+
for (let index = 0; index < size; index += 1) {
85+
MutableRef.update(mutableSnapshot, (value) => value + 1)
86+
}
87+
return MutableRef.get(mutableSnapshot) - before
88+
}
89+
90+
const synchronizedSnapshot = Effect.runSync(SynchronizedRef.make(0))
91+
const runSynchronizedSnapshotBatch = (size) =>
92+
Effect.runPromise(
93+
Effect.gen(function*() {
94+
const before = yield* SynchronizedRef.get(synchronizedSnapshot)
95+
for (let index = 0; index < size; index += 1) {
96+
yield* SynchronizedRef.update(synchronizedSnapshot, (value) => value + 1)
97+
}
98+
return (yield* SynchronizedRef.get(synchronizedSnapshot)) - before
99+
})
100+
)
101+
102+
const runTerminalLatchBatch = (size) =>
103+
Effect.runPromise(
104+
Effect.gen(function*() {
105+
for (let index = 0; index < size; index += 1) {
106+
const latch = yield* Deferred.make()
107+
yield* Deferred.succeed(latch, undefined)
108+
yield* Deferred.await(latch)
109+
}
110+
return size
111+
})
112+
)
113+
114+
export const makeEffectRuntimeAdapter = (version) => ({
115+
implementation: "effect-runtime",
116+
label: "Effect runtime primitives",
117+
version,
118+
runtimeBenchmarks: [
119+
{
120+
id: "effect-fiber-start-stop",
121+
label: "Start and interrupt a suspended fiber",
122+
unit: "fibers/s",
123+
async: true,
124+
operations: () => 1,
125+
run: () => runLifecycle(makeFiber)
126+
},
127+
{
128+
id: "effect-mailbox-start-stop",
129+
label: "Start and stop a queue worker",
130+
unit: "workers/s",
131+
async: true,
132+
operations: () => 1,
133+
run: () => runLifecycle(makeMailbox)
134+
},
135+
{
136+
id: "effect-actor-shell-start-stop",
137+
label: "Start and stop an actor shell",
138+
unit: "actors/s",
139+
async: true,
140+
operations: () => 1,
141+
run: () => runLifecycle(makeActorShell)
142+
},
143+
{
144+
id: "effect-actor-family-start-stop",
145+
label: "Start and stop two actor shells",
146+
unit: "families/s",
147+
async: true,
148+
operations: () => 1,
149+
run: () => runLifecycle(makeActorFamily)
150+
},
151+
{
152+
id: "effect-mutable-snapshot-update",
153+
label: "Update an owner-only mutable snapshot",
154+
unit: "updates/s",
155+
async: false,
156+
operations: (configuration) => configuration.primitiveBatchSize,
157+
run: runMutableSnapshotBatch
158+
},
159+
{
160+
id: "effect-synchronized-snapshot-update",
161+
label: "Update a synchronized snapshot",
162+
unit: "updates/s",
163+
async: true,
164+
operations: (configuration) => configuration.primitiveBatchSize,
165+
run: runSynchronizedSnapshotBatch
166+
},
167+
{
168+
id: "effect-terminal-latch",
169+
label: "Create, resolve, and await a terminal latch",
170+
unit: "latches/s",
171+
async: true,
172+
operations: (configuration) => configuration.primitiveBatchSize,
173+
run: runTerminalLatchBatch
174+
}
175+
],
176+
memoryProfiles: {
177+
"effect-fiber": {
178+
label: "Suspended Effect fiber",
179+
start: (count) => startUnits(count, makeFiber),
180+
stop: stopUnits
181+
},
182+
"effect-mailbox": {
183+
label: "Effect queue with waiting fiber",
184+
start: (count) => startUnits(count, makeMailbox),
185+
stop: stopUnits
186+
},
187+
"effect-actor-shell": {
188+
label: "Effect mailbox actor shell",
189+
start: (count) => startUnits(count, makeActorShell),
190+
stop: stopUnits
191+
},
192+
"effect-actor-family": {
193+
label: "Two Effect actor shells",
194+
start: (count) => startUnits(count, makeActorFamily),
195+
stop: stopUnits
196+
}
197+
}
198+
})

0 commit comments

Comments
 (0)