Skip to content

Commit 757c10c

Browse files
Add runtime performance benchmarks (#32)
1 parent 9eac202 commit 757c10c

13 files changed

Lines changed: 1239 additions & 2 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Add local and pull request runtime benchmark reporting for pure planning, end-to-end event drainage, machine lifecycle throughput, and idle-machine memory growth against the compiled package, XState 5, and the published XState 6 alpha.

.github/pull_request_template.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
- [ ] `pnpm check`
1313
- [ ] Relevant example checks, when examples changed
1414
- [ ] Reviewed the automated type-performance report, when the public TypeScript API or inference changed
15+
- [ ] Reviewed the automated runtime-performance report, when runtime behavior changed
1516

1617
<!--
17-
The type-performance workflow posts and updates the base-versus-PR comparison automatically.
18+
The type- and runtime-performance workflows post and update their base-versus-PR comparisons automatically.
1819
Do not copy a manually measured table into this description.
1920
-->
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Runtime performance comment
2+
3+
on:
4+
workflow_run:
5+
workflows: [Runtime performance]
6+
types: [completed]
7+
8+
concurrency:
9+
group: runtime-performance-comment-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
actions: read
14+
contents: read
15+
issues: write
16+
pull-requests: write
17+
18+
jobs:
19+
comment:
20+
if: >-
21+
github.event.workflow_run.event == 'pull_request' &&
22+
github.event.workflow_run.conclusion == 'success'
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Check out trusted reporting code
26+
uses: actions/checkout@v7
27+
28+
- name: Download performance report
29+
uses: actions/download-artifact@v8
30+
with:
31+
name: runtime-performance-report
32+
path: report
33+
github-token: ${{ secrets.GITHUB_TOKEN }}
34+
run-id: ${{ github.event.workflow_run.id }}
35+
36+
- name: Render report from validated benchmark data
37+
run: >-
38+
node scripts/compare-runtime-performance.mjs
39+
report/base
40+
report/head
41+
> "$RUNNER_TEMP/report.md"
42+
43+
- name: Create or update pull request comment
44+
uses: actions/github-script@v9
45+
env:
46+
REPORT_PATH: ${{ runner.temp }}/report.md
47+
with:
48+
script: |
49+
const fs = require("node:fs")
50+
const marker = "<!-- effect-machine-runtime-performance -->"
51+
const report = fs.readFileSync(process.env.REPORT_PATH, "utf8")
52+
53+
if (Buffer.byteLength(report, "utf8") > 60_000) {
54+
core.setFailed("Runtime-performance report exceeds the safe comment size")
55+
return
56+
}
57+
58+
const run = context.payload.workflow_run
59+
let pull = run.pull_requests?.[0]
60+
61+
if (pull === undefined) {
62+
try {
63+
const associated = await github.rest.repos.listPullRequestsAssociatedWithCommit({
64+
...context.repo,
65+
commit_sha: run.head_sha
66+
})
67+
pull = associated.data.find((candidate) =>
68+
candidate.base.repo.full_name === `${context.repo.owner}/${context.repo.repo}`
69+
)
70+
} catch (error) {
71+
core.warning(`Unable to look up a pull request for ${run.head_sha}: ${error.message}`)
72+
}
73+
}
74+
75+
if (pull === undefined) {
76+
core.notice("No pull request is associated with this workflow run")
77+
return
78+
}
79+
80+
const body = `${marker}\n${report}`
81+
const comments = await github.paginate(github.rest.issues.listComments, {
82+
...context.repo,
83+
issue_number: pull.number,
84+
per_page: 100
85+
})
86+
const previous = comments.find((comment) =>
87+
comment.user?.type === "Bot" && comment.body?.startsWith(marker)
88+
)
89+
90+
if (previous === undefined) {
91+
await github.rest.issues.createComment({
92+
...context.repo,
93+
issue_number: pull.number,
94+
body
95+
})
96+
} else {
97+
await github.rest.issues.updateComment({
98+
...context.repo,
99+
comment_id: previous.id,
100+
body
101+
})
102+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Runtime performance
2+
3+
on:
4+
pull_request:
5+
6+
concurrency:
7+
group: runtime-performance-${{ github.event.pull_request.number }}
8+
cancel-in-progress: true
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
runtime-performance:
15+
name: runtime-performance
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 15
18+
steps:
19+
- name: Check out base
20+
uses: actions/checkout@v7
21+
with:
22+
path: base
23+
ref: ${{ github.event.pull_request.base.sha }}
24+
25+
- name: Check out pull request
26+
uses: actions/checkout@v7
27+
with:
28+
path: head
29+
repository: ${{ github.event.pull_request.head.repo.full_name }}
30+
ref: ${{ github.event.pull_request.head.sha }}
31+
32+
- uses: pnpm/action-setup@v6
33+
with:
34+
package_json_file: head/package.json
35+
36+
- uses: actions/setup-node@v7
37+
with:
38+
node-version: 24
39+
cache: pnpm
40+
cache-dependency-path: |
41+
base/pnpm-lock.yaml
42+
head/pnpm-lock.yaml
43+
44+
- name: Install and build pull request
45+
run: |
46+
pnpm --dir head install --frozen-lockfile
47+
pnpm --dir head build
48+
49+
- name: Install and build base when benchmarkable
50+
if: ${{ hashFiles('base/scripts/runtime-performance.mjs') != '' }}
51+
run: |
52+
pnpm --dir base install --frozen-lockfile
53+
pnpm --dir base build
54+
55+
- name: Measure base and pull request
56+
shell: bash
57+
run: |
58+
set -euo pipefail
59+
reports="$RUNNER_TEMP/runtime-performance"
60+
mkdir -p "$reports/base" "$reports/head"
61+
62+
measure() {
63+
local checkout="$1"
64+
local output="$2"
65+
(
66+
cd "$checkout"
67+
node --expose-gc scripts/runtime-performance.mjs --json
68+
) > "$output"
69+
}
70+
71+
if [[ -f base/scripts/runtime-performance.mjs ]]; then
72+
measure base "$reports/base/1.json"
73+
measure head "$reports/head/1.json"
74+
measure head "$reports/head/2.json"
75+
measure base "$reports/base/2.json"
76+
measure base "$reports/base/3.json"
77+
measure head "$reports/head/3.json"
78+
else
79+
measure head "$reports/head/1.json"
80+
measure head "$reports/head/2.json"
81+
measure head "$reports/head/3.json"
82+
fi
83+
84+
node head/scripts/compare-runtime-performance.mjs \
85+
"$reports/base" \
86+
"$reports/head" \
87+
> "$RUNNER_TEMP/runtime-performance-report.md"
88+
cat "$RUNNER_TEMP/runtime-performance-report.md" >> "$GITHUB_STEP_SUMMARY"
89+
90+
- name: Upload report for the comment workflow
91+
uses: actions/upload-artifact@v7
92+
with:
93+
name: runtime-performance-report
94+
path: ${{ runner.temp }}/runtime-performance
95+
if-no-files-found: error
96+
retention-days: 7

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"test:types": "tstyche",
5151
"typecheck": "tsc -p tsconfig.json --noEmit",
5252
"perf:types": "pnpm build && node scripts/type-performance.mjs",
53+
"perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs",
5354
"format": "dprint fmt",
5455
"format:check": "dprint check",
5556
"test:consumer": "node scripts/test-consumer.mjs",
@@ -68,9 +69,12 @@
6869
"@types/node": "25.7.0",
6970
"dprint": "0.55.2",
7071
"effect": "4.0.0-beta.102",
72+
"tinybench": "2.9.0",
7173
"tstyche": "7.2.1",
7274
"typescript": "6.0.3",
73-
"vitest": "4.1.10"
75+
"vitest": "4.1.10",
76+
"xstate-v5": "npm:xstate@5.32.5",
77+
"xstate-v6": "npm:xstate@6.0.0-alpha.27"
7478
},
7579
"packageManager": "pnpm@10.17.1",
7680
"engines": {

perf/runtime/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Runtime performance
2+
3+
Run the local runtime benchmark suite against the compiled package and the
4+
pinned XState comparison versions:
5+
6+
```sh
7+
pnpm perf:runtime
8+
```
9+
10+
The command reports:
11+
12+
- pure `Machine.plan` counter-transition throughput;
13+
- end-to-end useful-increment throughput for a burst sent to one running machine;
14+
- machine start-and-stop throughput;
15+
- idle heap and resident-memory growth at 100, 500, and 1,000 live machines.
16+
17+
The comparison dependencies use package aliases, so XState 5 and 6 can be
18+
loaded by the same process:
19+
20+
- `xstate-v5`: `xstate@5.32.5`, the stable v5 baseline;
21+
- `xstate-v6`: `xstate@6.0.0-alpha.27`, the latest published v6 alpha available
22+
when the harness was added.
23+
24+
All implementations use the same flat counter topology, immutable events, and
25+
terminal fence. The XState adapter uses `assign` in v5 and the v6 transition
26+
function API because `assign` is not exported by that alpha.
27+
28+
The burst benchmark reports useful counter increments per second. It enqueues
29+
one final fence event after all counter events and awaits the machine's terminal
30+
output, so the measured duration also amortizes that fence and terminal
31+
cleanup. This measures complete queue drainage, not only the enqueue time
32+
returned by `MachineRef.send`.
33+
34+
Results are informational. Compare runs on the same machine while it is idle,
35+
using the same Node.js and dependency versions. Tinybench warms each scenario
36+
before collecting samples, and the memory measurements force garbage
37+
collection before every observation. Each implementation's memory curve runs
38+
in a fresh child process so garbage from one library cannot distort another
39+
library's baseline.
40+
41+
These scenarios compare observable work, not identical internals. Effect
42+
Machine plans through an `Effect`, validates schema-backed state and events,
43+
and its running machine provisions Effect queues, fibers, synchronization,
44+
change publication, and child/invoke lifecycle machinery. XState's counter is
45+
a smaller synchronous actor. Treat the comparison as an application-level
46+
cost baseline, not a claim that the libraries provide the same runtime
47+
guarantees.
48+
49+
The fitted heap slope is the primary idle-capacity metric. Resident memory is
50+
reported as a raw diagnostic because V8 and the operating-system allocator can
51+
reuse already committed pages. The capacity-per-GiB value is a linear estimate
52+
that excludes shared process overhead; it is not a run-until-OOM limit.
53+
54+
Use a shorter smoke run while changing the harness:
55+
56+
```sh
57+
pnpm perf:runtime -- --quick
58+
```
59+
60+
Display the complete versioned report as JSON, or write a machine-readable JSON
61+
file alongside the terminal report:
62+
63+
```sh
64+
pnpm perf:runtime -- --json
65+
pnpm perf:runtime -- --output runtime-performance.json
66+
```
67+
68+
Prefer `--output` for scripts: pnpm and the preceding build may add their own
69+
lines to standard output before `--json` is printed.
70+
71+
Pull requests run the suite three times for both the base and pull request
72+
revisions on the same GitHub-hosted runner. The workflow publishes the median
73+
of those process-level results to the job summary and a sticky pull request
74+
comment. The benchmark workflow has read-only repository access; a separate
75+
trusted `workflow_run` workflow validates the uploaded JSON before receiving
76+
permission to update the comment.
77+
78+
The implementation lives in `scripts/runtime-performance.mjs`; the Effect
79+
Machine fixture is in `perf/runtime/counter.mjs`, and the comparison adapter is
80+
in `perf/runtime/xstate.mjs`. Add new scenarios only when every implementation
81+
performs equivalent observable work and the result is consumed and checked so
82+
the JavaScript engine cannot discard it.

0 commit comments

Comments
 (0)