Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions perf/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ The command reports:
- repeated child lookup and delivery to one running child;
- machine start-and-stop throughput;
- parent-with-child start-and-stop throughput;
- Effect-only lifecycle reference points for a suspended fiber, a queue worker,
a minimal actor shell, and a two-shell family;
- Effect-only coordination reference points for an owner-only mutable snapshot,
a synchronized snapshot, and a terminal `Deferred` latch;
- heap and resident-memory growth at 100, 500, and 1,000 live units, including
a raw managed process, an idle statechart, two independent statecharts, a
parent with one child, that relationship with child-registry observation
active, and an invoked child whose active snapshots are observed;
a raw generic process, a raw compiled process, an idle statechart, two
independent statecharts, a parent with one child, that relationship with
child-registry observation active, and an invoked child whose active snapshots
are observed;
- lower-bound memory profiles for Effect itself: a suspended fiber, a queue
with a waiting fiber, and a minimal mailbox/state/completion actor shell.
with a waiting fiber, a minimal mailbox/state/completion actor shell, and a
minimal two-shell family.

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

The implementation lives in `scripts/runtime-performance.mjs`; the Effect
Machine fixture is in `perf/runtime/counter.mjs`, and the comparison adapter is
in `perf/runtime/xstate.mjs`. Add new scenarios only when every implementation
performs equivalent observable work and the result is consumed and checked so
the JavaScript engine cannot discard it.
in `perf/runtime/xstate.mjs`. Effect runtime reference fixtures are in
`perf/runtime/effect-runtime.mjs`. Add cross-library scenarios only when every
implementation performs equivalent observable work and the result is consumed
and checked so the JavaScript engine cannot discard it.
39 changes: 38 additions & 1 deletion perf/runtime/counter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ const rawProcessLogic = {
run: () => Effect.never
}

const rawCompiledProcessLogic = {
[machineRuntime.compiledProcess]: true,
initial: () => Effect.succeed(0),
run: () => Effect.never
}

const startRawProcesses = (count) =>
Effect.runPromise(
Effect.forEach(
Expand All @@ -238,6 +244,15 @@ const startRawProcesses = (count) =>
)
)

const startCompiledRawProcesses = (count) =>
Effect.runPromise(
Effect.forEach(
Array.from({ length: count }),
() => machineRuntime.startProcess(rawCompiledProcessLogic),
{ concurrency: 1 }
)
)

const startIndependentCounterPairs = (count) =>
Effect.runPromise(
Effect.forEach(
Expand Down Expand Up @@ -330,17 +345,39 @@ export const effectMachineAdapter = {
stopChildCounters,
stopObservedCounter,
stopCounters,
runtimeBenchmarks: [
{
id: "compiled-process-start-stop",
label: "Start and stop a raw compiled process",
unit: "processes/s",
async: true,
operations: () => 1,
run: async () => {
const refs = await startCompiledRawProcesses(1)
try {
return refs.length
} finally {
await stopCounters(refs)
}
}
}
],
memoryProfiles: {
idle: {
label: "Idle machine",
start: startCounters,
stop: stopCounters
},
"raw-process": {
label: "Raw managed process",
label: "Raw generic managed process",
start: startRawProcesses,
stop: stopCounters
},
"compiled-raw-process": {
label: "Raw compiled process",
start: startCompiledRawProcesses,
stop: stopCounters
},
"two-independent": {
label: "Two independent idle machines",
start: startIndependentCounterPairs,
Expand Down
81 changes: 0 additions & 81 deletions perf/runtime/effect-memory.mjs

This file was deleted.

198 changes: 198 additions & 0 deletions perf/runtime/effect-runtime.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { createRequire } from "node:module"
import { readFileSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"

const implementationRoot = resolve(
process.env.EFFECT_MACHINE_BENCHMARK_ROOT ?? fileURLToPath(new URL("../..", import.meta.url))
)
const implementationRequire = createRequire(pathToFileURL(join(implementationRoot, "package.json")))
const effectPackagePath = implementationRequire.resolve("effect/package.json")
const effectPackage = JSON.parse(readFileSync(effectPackagePath, "utf8"))
const effect = await import(pathToFileURL(resolve(dirname(effectPackagePath), effectPackage.exports["."])).href)
const { Deferred, Effect, Fiber, MutableRef, Queue, Ref, SynchronizedRef } = effect

const startUnits = (count, make) =>
Effect.runPromise(
Effect.forEach(Array.from({ length: count }), make, { concurrency: 1 })
)

const stopUnits = (units) =>
Effect.runPromise(
Effect.forEach(
units,
(unit) =>
Fiber.interruptAll(unit.fibers).pipe(
Effect.andThen(Effect.forEach(unit.queues, Queue.shutdown, { discard: true }))
),
{ concurrency: "unbounded", discard: true }
)
)

const makeFiber = () =>
Effect.map(Effect.forkDetach(Effect.never), (fiber) => ({
fibers: [fiber],
queues: []
}))

const makeMailbox = () =>
Effect.gen(function*() {
const queue = yield* Queue.unbounded()
const fiber = yield* Effect.forkDetach(Effect.forever(Queue.take(queue)))
return { fibers: [fiber], queues: [queue] }
})

const makeActorShell = () =>
Effect.gen(function*() {
const queue = yield* Queue.unbounded()
const state = yield* Ref.make(0)
const done = yield* Deferred.make()
const fiber = yield* Effect.forkDetach(
Effect.forever(
Queue.take(queue).pipe(
Effect.flatMap((update) => Ref.update(state, update))
)
)
)
return { fibers: [fiber], queues: [queue], retained: [state, done] }
})

const makeActorFamily = () =>
Effect.all([makeActorShell(), makeActorShell()], { concurrency: 1 }).pipe(
Effect.map((units) => ({
fibers: units.flatMap((unit) => unit.fibers),
queues: units.flatMap((unit) => unit.queues),
retained: units
}))
)

const runLifecycle = async (make, expectedUnits = 1) => {
const units = await startUnits(expectedUnits, make)
try {
if (units.length !== expectedUnits) {
throw new Error(`Effect runtime lifecycle started ${units.length} units, expected ${expectedUnits}`)
}
return 1
} finally {
await stopUnits(units)
}
}

const mutableSnapshot = MutableRef.make(0)
const runMutableSnapshotBatch = (size) => {
const before = MutableRef.get(mutableSnapshot)
for (let index = 0; index < size; index += 1) {
MutableRef.update(mutableSnapshot, (value) => value + 1)
}
return MutableRef.get(mutableSnapshot) - before
}

const synchronizedSnapshot = Effect.runSync(SynchronizedRef.make(0))
const runSynchronizedSnapshotBatch = (size) =>
Effect.runPromise(
Effect.gen(function*() {
const before = yield* SynchronizedRef.get(synchronizedSnapshot)
for (let index = 0; index < size; index += 1) {
yield* SynchronizedRef.update(synchronizedSnapshot, (value) => value + 1)
}
return (yield* SynchronizedRef.get(synchronizedSnapshot)) - before
})
)

const runTerminalLatchBatch = (size) =>
Effect.runPromise(
Effect.gen(function*() {
for (let index = 0; index < size; index += 1) {
const latch = yield* Deferred.make()
yield* Deferred.succeed(latch, undefined)
yield* Deferred.await(latch)
}
return size
})
)

export const makeEffectRuntimeAdapter = (version) => ({
implementation: "effect-runtime",
label: "Effect runtime primitives",
version,
runtimeBenchmarks: [
{
id: "effect-fiber-start-stop",
label: "Start and interrupt a suspended fiber",
unit: "fibers/s",
async: true,
operations: () => 1,
run: () => runLifecycle(makeFiber)
},
{
id: "effect-mailbox-start-stop",
label: "Start and stop a queue worker",
unit: "workers/s",
async: true,
operations: () => 1,
run: () => runLifecycle(makeMailbox)
},
{
id: "effect-actor-shell-start-stop",
label: "Start and stop an actor shell",
unit: "actors/s",
async: true,
operations: () => 1,
run: () => runLifecycle(makeActorShell)
},
{
id: "effect-actor-family-start-stop",
label: "Start and stop two actor shells",
unit: "families/s",
async: true,
operations: () => 1,
run: () => runLifecycle(makeActorFamily)
},
{
id: "effect-mutable-snapshot-update",
label: "Update an owner-only mutable snapshot",
unit: "updates/s",
async: false,
operations: (configuration) => configuration.primitiveBatchSize,
run: runMutableSnapshotBatch
},
{
id: "effect-synchronized-snapshot-update",
label: "Update a synchronized snapshot",
unit: "updates/s",
async: true,
operations: (configuration) => configuration.primitiveBatchSize,
run: runSynchronizedSnapshotBatch
},
{
id: "effect-terminal-latch",
label: "Create, resolve, and await a terminal latch",
unit: "latches/s",
async: true,
operations: (configuration) => configuration.primitiveBatchSize,
run: runTerminalLatchBatch
}
],
memoryProfiles: {
"effect-fiber": {
label: "Suspended Effect fiber",
start: (count) => startUnits(count, makeFiber),
stop: stopUnits
},
"effect-mailbox": {
label: "Effect queue with waiting fiber",
start: (count) => startUnits(count, makeMailbox),
stop: stopUnits
},
"effect-actor-shell": {
label: "Effect mailbox actor shell",
start: (count) => startUnits(count, makeActorShell),
stop: stopUnits
},
"effect-actor-family": {
label: "Two Effect actor shells",
start: (count) => startUnits(count, makeActorFamily),
stop: stopUnits
}
}
})
Loading