Skip to content
Draft
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
120 changes: 114 additions & 6 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,22 @@ server, 7% on Hono, 30% smaller binaries and 0.9-2 MB less PSS.

## What is measured

Two applications, each compiled by geatsc and each also run under Node:
Three applications, each compiled by geatsc and each also run under Node:

- **Hono** (`apps/hono-hello/server.ts`): the real `hono` package with the
default `SmartRouter` stack, served through `@hono/node-server`. Under
geatsc the `serve` import resolves to node-compat's native answer for that
package (`runtime/node/node-server-adapter.ts`), so a request goes reactor →
`Request` → `app.fetch` → wire with no intermediate Node objects. The app
file is unchanged between the two runtimes.
- **NestJS** (`apps/hello-nest/server.ts`): `@nestjs/core` 12.1.3 with a
decorated controller, served through `@nestjs/platform-node`'s
`NodeAdapter` over `node:http` (`tools/nest/build-platform-node.mjs` packs
the three packages from the pinned nest commit). The Node row runs the
`tsc` output of the same file (`dist-node/`), because Node's type stripping
has no decorators or decorator metadata; the gea row is `scripts/build.mjs`
on the file itself with the Nest AOT hook in `tools/nest/aot/`. The Nest
logger is off in both.
- **Raw `node:http`** (`apps/raw-http-hello/server.ts`): `createServer((req,
res) => ...)`, `writeHead`, `end`. Under geatsc this is node-compat's
`IncomingMessage`/`ServerResponse` implementation over the C++ reactor.
Expand Down Expand Up @@ -95,8 +103,8 @@ validated by the harness before any sample is taken (72 wire-contract checks,
capture group is `''`.

Routes: `GET /` returns `text/plain`, `GET /json` returns
`{"hello":"world"}`. Hono additionally serves `/missing` as a 404, which the
harness checks.
`{"hello":"world"}`. Hono and Nest additionally serve `/missing` as a 404,
which the harness checks.

## Machine and method

Expand Down Expand Up @@ -342,7 +350,7 @@ and relocated library. `bench/pss-breakdown.py` is the instrument: it sums

Run on an otherwise idle Linux host with at least eight logical CPUs. The
harness uses `taskset`, `/proc` and `lscpu`, so it does not run on macOS.
Ports 3000 (Node Hono), 3101 (raw servers) and 3900 (gea Hono) must be free.
Ports 3000 (Node Hono), 3101 (raw servers) and 3900 (gea Hono, Nest) must be free.

Requirements: Node.js and the repository's npm dependencies, Python 3, `wrk`,
`util-linux`, clang (18 or newer; `-flto` needs its gold plugin,
Expand All @@ -356,6 +364,8 @@ built from:
```sh
npm ci # @geastack/compiler + @geastack/node-compat from npm
npm ci --prefix apps/hono-hello # hono + @hono/node-server (no workspaces: the root install does not reach it)
node tools/nest/build-platform-node.mjs # @nestjs/{common,core,platform-node} 12.1.3 tarballs from the pinned nest checkout (../nest or GEA_NEST_DIR)
npm ci --prefix apps/hello-nest # the vendored Nest packages, reflect-metadata, rxjs, typescript
bash bench/goal-http-build.sh # gea servers + C++ and Rust controls, clang++ -Os -flto
```

Expand All @@ -382,6 +392,7 @@ script emit the servers. The gea servers are emitted by the one
```sh
cd /path/to/geastack/compiler && npm run build && cd ../node-compat
npm ci --prefix apps/hono-hello # needed on this path too
node tools/nest/build-platform-node.mjs && npm ci --prefix apps/hello-nest
bash bench/goal-http-build.sh
```

Expand Down Expand Up @@ -410,8 +421,8 @@ SCRIPTC_RAW=/path/to/raw-rp python3 bench/http-matrix.py --servers scriptc-raw \
check `/sys/devices/system/cpu/cpu*/topology/thread_siblings_list` before
choosing, because sibling numbering differs between machines and the wrong
choice silently measures a saturated host. `--servers` narrows a run to any of
`hono-gea`, `hono-node`, `gea-raw`, `node-raw`, `scriptc-raw`, `cpp-drogon`,
`rust-axum`, `rust-hyper`, `cpp-epoll`. The result JSON keeps every raw `wrk`
`hono-gea`, `hono-node`, `nest-gea`, `nest-node`, `gea-raw`, `node-raw`,
`scriptc-raw`, `cpp-drogon`, `rust-axum`, `rust-hyper`, `cpp-epoll`. The result JSON keeps every raw `wrk`
report, the percentile latencies, peak RSS and PSS per process, startup
samples, machine metadata, artifact SHA-256 hashes and the wire-contract
validation record.
Expand All @@ -428,3 +439,100 @@ package.json is read as a plain file, so an `exports` map that omits
two scripts that both say "build" can name different compilers: the 09-21
numbers were clang's while the document said g++, which was only caught by
reading the binaries' `.comment` sections.

## hello-nest: Node against the compiled binary, 2026-10-19

Not the bench host. This run is `bench/http-matrix.py --servers nest-gea
nest-node` inside an arm64 Linux container (Ubuntu 24.04, clang 18.1.3,
Node v24.20.0, wrk 4.1.0) on a Colima VM with 8 vCPUs and 12 GiB on an Apple
M1 (4 performance and 4 efficiency cores; the VM's vCPUs are not pinned to
physical cores, so `--server-cpus 0-3 --load-cpus 4-7` separates server and
load generator inside the VM only). The VM ran nothing else. Raw records:
`bench/results/nest-colima-arm64-2026-10-19.json` and its `-startup` file.
Numbers here are comparable with each other and with nothing in the tables
above.

`apps/hello-nest/server.ts` was built by `scripts/build.mjs` from a compiler
checkout carrying the four hello-nest fixes (`GEA_COMPILER_DIR`, 1.0.37 plus
`fix/error-subclass-instanceof`, `fix/array-keys-over-recursive-wrapper`,
`fix/hoist-module-function-declarations`, `fix/uri-error-thrown-not-aborted`),
`-Os -flto`, stripped: 7,619,264 bytes, 61,265 emitted lines. The Node row
is `tsc` output of the same file (`dist-node/server.js`), one process or a
`node:cluster` of four. Both answered `scripts/test-hello-nest.mjs` 10 of 11
identical before the run (the known `JSON.parse` message wording).
Protocol as above: 2 s warmup, 8 s samples, `-t4 -c64`, three interleaved
rounds, startup in a separate five-launch pass.

| Server | Single `/` | Single `/json` | Single RSS MiB | Single PSS MiB | Single startup ms | 4 workers `/` | 4 workers `/json` | 4 workers RSS MiB | 4 workers PSS MiB | 4 workers startup ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| **nest-gea** | **121,131** | **111,276** | 10.8 | 9.4 | 13 | **281,100** | **270,107** | 31.4 | 10.9 | 13 |
| nest-node | 53,039 | 47,787 | 123.9 | 122.8 | 527 | 142,989 | 139,628 | 536.0 | 338.1 | 1,014 |

Per-round `GET /`: nest-gea 128.8k / 123.6k / 111.0k at one worker and
291.2k / 270.9k / 281.2k at four; nest-node 53.6k / 51.6k / 53.9k and
154.6k / 149.3k / 125.0k.

| Server | Single p50 | Single p99 | 4 workers p50 | 4 workers p99 |
| --- | ---: | ---: | ---: | ---: |
| nest-gea | 502 µs | 2.34 ms | 169 µs | 816 µs |
| nest-node | 1.13 ms | 2.27 ms | 388 µs | 38.3 ms |

Ratios, compiled over Node, means over rounds: 1.97× (`/`) and 1.93×
(`/json`) at four workers, 2.28× and 2.33× at one worker; PSS 1/31 at four
workers and 1/13 at one; startup 13 ms against 527 ms and 1,014 ms. The
compiled ratio is about half of Hono's on the bench host (3.7×), and the
single-worker rounds spread 15% for the compiled binary against 4% for Node,
so the one-worker cells carry the same caution as the bimodality note above.

### The full matrix on the same VM, later the same day

All ten servers, same container, same protocol, VM otherwise idle:
`bench/results/http-colima-arm64-2026-10-19.json` (rounds 1-2 complete for
every server, round 3 for the six gea and Node rows; the run aborted when
`cpp-drogon` at one worker timed out 63 of 64 connections in its warmup, which
the harness refuses), `http-colima-arm64-2026-10-19-controls.json` (the four
controls again, three clean rounds, 19 minutes later) and
`http-colima-arm64-2026-10-19-startup.json` (five launches each, all ten).
The gea and Node rows are the first file, the controls the second, as the
scriptc row above is a separate run of the same harness. Binaries: hono-gea
4,805,616 bytes, nest-gea 7,619,264, gea-raw 3,403,464; controls built in the
container with clang 18.1.3 and `cargo build --release --locked`.

| Server | Single `/` | Single `/json` | Single RSS MiB | Single PSS MiB | Single startup ms | 4 workers `/` | 4 workers `/json` | 4 workers RSS MiB | 4 workers PSS MiB | 4 workers startup ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| **hono-gea** | **132,018** | **97,355** | 7.3 | 6.3 | 7 | **254,633** | **211,371** | 25.9 | 8.5 | 11 |
| hono-node | 29,372 | 25,538 | 116.8 | 115.7 | 89 | 56,799 | 48,976 | 440.9 | 260.2 | 148 |
| **nest-gea** | **103,000** | **88,705** | 10.8 | 9.4 | 13 | **210,652** | **194,149** | 31.5 | 10.9 | 15 |
| nest-node | 42,828 | 38,581 | 120.2 | 119.2 | 698 | 77,146 | 81,775 | 532.8 | 334.8 | 1,700 |
| **gea-raw** | **156,805** | **153,935** | 5.5 | 4.6 | 14 | **291,858** | **278,909** | 18.1 | 5.9 | 15 |
| node-raw | 43,572 | 43,894 | 87.8 | 86.8 | 101 | 103,868 | 96,322 | 365.2 | 185.7 | 219 |
| cpp-drogon | 128,777 | 125,933 | 11.8 | 10.3 | 32 | 238,242 | 231,066 | 12.0 | 10.5 | 28 |
| rust-axum | 154,358 | 157,571 | 4.1 | 3.2 | 13 | 189,086 | 184,784 | 5.0 | 4.1 | 92 |
| rust-hyper | 186,388 | 180,580 | 3.1 | 2.2 | 10 | 208,856 | 211,222 | 3.5 | 2.6 | 6 |
| cpp-epoll | 175,417 | 170,298 | 3.2 | 2.3 | 6 | 290,664 | 288,241 | 10.0 | 3.4 | 6 |

| Server | Single p50 | Single p99 | 4 workers p50 | 4 workers p99 |
| --- | ---: | ---: | ---: | ---: |
| hono-gea | 448 µs | 910 µs | 179 µs | 880 µs |
| hono-node | 1.90 ms | 4.23 ms | 840 µs | 2.89 ms |
| nest-gea | 545 µs | 1.15 ms | 225 µs | 1.15 ms |
| nest-node | 1.33 ms | 2.71 ms | 718 µs | 7.72 ms |
| gea-raw | 363 µs | 1.42 ms | 154 µs | 990 µs |
| node-raw | 1.41 ms | 2.82 ms | 633 µs | 2.69 ms |
| cpp-drogon | 482 µs | 1.09 ms | 193 µs | 940 µs |
| rust-axum | 406 µs | 19.46 ms | 265 µs | 950 µs |
| rust-hyper | 317 µs | 542 µs | 251 µs | 1.84 ms |
| cpp-epoll | 284 µs | 652 µs | 143 µs | 1.15 ms |

Four workers, `GET /`: nest-gea 2.73× nest-node (2.40× at one worker),
hono-gea 4.48× hono-node, gea-raw 2.81× node-raw; nest-gea is 0.83× hono-gea
and 2.03× bare Node. PSS at four workers is 1/31 for all three pairs.

What this VM can resolve. The Nest rows an hour apart read 281k / 143k
(nest-gea / nest-node, four workers, the dedicated run above) and 211k / 77k
here, so the same binaries moved 25% and 46% between runs and the ratio moved
from 1.97× to 2.73×. Round-to-round spreads inside one run reach 30% for the
Node cluster rows (node-raw 109k / 117k / 86k, nest-node 94k / 66k / 72k) and
15% for cpp-epoll and cpp-drogon. The pairs hold their order in every round,
the sizes of the gaps do not; the bench-host tables above are where those
sizes are measured.
10 changes: 10 additions & 0 deletions apps/hello-nest/cluster.node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import cluster from 'node:cluster'
import os from 'node:os'

if (cluster.isPrimary) {
const n = Number(process.env.NODE_WORKERS || os.availableParallelism())
for (let i = 0; i < n; i++) cluster.fork()
console.log(`nest-node cluster: ${n} workers on http://127.0.0.1:3900`)
} else {
await import('./dist-node/server.js')
}
2 changes: 1 addition & 1 deletion apps/hello-nest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
"start": "node dist-node/server.js"
},
"dependencies": {
"@nestjs/common": "file:../../vendor/nestjs-common-12.1.3.tgz",
Expand Down
2 changes: 1 addition & 1 deletion apps/hello-nest/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"experimentalDecorators": true,
"useDefineForClassFields": false,
"skipLibCheck": true,
"outDir": "./dist"
"outDir": "./dist-node"
},
"files": ["server.ts"]
}
8 changes: 8 additions & 0 deletions bench/goal-http-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ CXX="$CXX_BIN" node scripts/build.mjs \
apps/hono-hello/server.ts \
--out apps/hono-hello/dist

echo "=== Nest Gea: NestJS on @nestjs/platform-node through the shared compiler ==="
node tools/nest/build-platform-node.mjs --check
npm --prefix apps/hello-nest run build
CXX="$CXX_BIN" node scripts/build.mjs \
apps/hello-nest/server.ts \
--out apps/hello-nest/dist

echo "=== Gea raw: node:http source through the shared compiler ==="
CXX="$CXX_BIN" node scripts/build.mjs \
apps/raw-http-hello/server.ts \
Expand All @@ -77,6 +84,7 @@ echo "=== Rust Hyper and Axum controls ==="

artifacts=(
apps/hono-hello/dist/server
apps/hello-nest/dist/server
apps/raw-http-hello/dist/server
apps/raw-http-hello/dist/cpp-epoll
apps/raw-http-hello/dist/cpp-drogon
Expand Down
47 changes: 36 additions & 11 deletions bench/http-matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
parser.add_argument('--rounds', type=int, default=2)
parser.add_argument('--duration', default='8s')
parser.add_argument('--servers', nargs='+', default=[
'hono-gea', 'hono-node', 'gea-raw', 'node-raw',
'hono-gea', 'hono-node', 'nest-gea', 'nest-node', 'gea-raw', 'node-raw',
'cpp-drogon', 'rust-axum', 'rust-hyper', 'cpp-epoll'])
parser.add_argument('--workers', nargs='+', type=int, default=[1, 8])
# `dist` is the historical build; `dist` is what `scripts/build.mjs` writes today.
Expand Down Expand Up @@ -56,10 +56,16 @@ def command(name, workers):
# adapter, kept beside the native build as `server-pkg`.
cmd = [f'apps/hono-hello/{args.gea_dist}/server-pkg']
env['GEA_WORKERS'] = str(workers)
elif name in ('hono-gea', 'gea-raw'):
app = 'hono-hello' if name == 'hono-gea' else 'raw-http-hello'
elif name in ('hono-gea', 'nest-gea', 'gea-raw'):
app = {'hono-gea': 'hono-hello', 'nest-gea': 'hello-nest', 'gea-raw': 'raw-http-hello'}[name]
cmd = [f'apps/{app}/{args.gea_dist}/server']
env['GEA_WORKERS'] = str(workers)
elif name == 'nest-node':
# The same server.ts the nest-gea row compiles, built by tsc because Node's
# type stripping has no decorators or decorator metadata.
entry = 'dist-node/server.js' if workers == 1 else 'cluster.node.mjs'
cmd = ['node', f'apps/hello-nest/{entry}']
env['NODE_WORKERS'] = str(workers)
elif name == 'hono-node-full':
# Node on the unchanged server.ts (type stripping), not the tiny bridge.
entry = 'server.ts' if workers == 1 else 'cluster.full.node.mjs'
Expand Down Expand Up @@ -98,6 +104,19 @@ def hono_gea_port():
return int(match[1]) if match else 3000


def nest_port():
source = Path('apps/hello-nest/server.ts').read_text()
match = re.search(r'listen\((\d+)\)', source)
return int(match[1]) if match else 3000


ROOT_BODY = {'hono-': 'Hello Hono!', 'nest-': 'Hello Nest!'}


def framework(name):
return next((prefix for prefix in ROOT_BODY if name.startswith(prefix)), None)


def port_free(port):
with socket.socket() as sock:
return sock.connect_ex(('127.0.0.1', port)) != 0
Expand Down Expand Up @@ -258,7 +277,13 @@ def wrk(port, path, duration, warmup=False):
artifacts = [
f'apps/hono-hello/{args.gea_dist}/server', f'apps/hono-hello/{args.gea_dist}/server-pkg',
f'apps/hono-hello/{args.gea_dist}/server.cpp',
f'apps/hono-hello/{args.gea_dist}/gea_runtime.h', f'apps/raw-http-hello/{args.gea_dist}/server',
f'apps/hono-hello/{args.gea_dist}/gea_runtime.h',
f'apps/hello-nest/{args.gea_dist}/server', f'apps/hello-nest/{args.gea_dist}/server.cpp',
f'apps/hello-nest/{args.gea_dist}/gea_runtime.h',
'apps/hello-nest/server.ts', 'apps/hello-nest/dist-node/server.js', 'apps/hello-nest/cluster.node.mjs',
'apps/hello-nest/node_modules/@nestjs/core/package.json',
'apps/hello-nest/node_modules/@nestjs/platform-node/package.json',
f'apps/raw-http-hello/{args.gea_dist}/server',
f'apps/raw-http-hello/{args.gea_dist}/server.cpp', f'apps/raw-http-hello/{args.gea_dist}/gea_runtime.h',
'apps/raw-http-hello/dist/cpp-drogon', 'apps/raw-http-hello/dist/cpp-epoll',
'runtime/gea_node.cpp', 'runtime/gea_node.hpp',
Expand Down Expand Up @@ -322,15 +347,15 @@ def save():
# 3900 (port 3000 is taken on the developer Mac), the node entry kept
# 3000, raw-http-hello listens on 3101.
port = hono_gea_port() if name in ('hono-gea', 'hono-gea-pkg', 'hono-node-full') else \
3000 if name == 'hono-node' else 3101
nest_port() if name.startswith('nest-') else 3000 if name == 'hono-node' else 3101
if not port_free(port):
raise RuntimeError(f'Port {port} occupied before {name}')
cmd, env = command(name, workers)
started = time.monotonic()
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, start_new_session=True)
try:
expected = 'Hello Hono!' if name.startswith('hono-') else 'Hello, World! GET /'
expected = ROOT_BODY.get(framework(name), 'Hello, World! GET /')
for attempt in range(1000):
if proc.poll() is not None:
raise RuntimeError(f'{name} exited: {proc.stdout.read()}')
Expand All @@ -349,9 +374,9 @@ def save():
status, content_type, body = request(port, '/json')
if status != 200 or body != '{"hello":"world"}' or not content_type.startswith('application/json'):
raise RuntimeError(f'{name} wrong JSON: {status} {content_type} {body}')
if name.startswith('hono-') and request(port, '/missing')[0] != 404:
if framework(name) and request(port, '/missing')[0] != 404:
raise RuntimeError(f'{name} wrong missing-route status')
if not name.startswith('hono-'):
if not framework(name):
for path in ('/', '/json'):
contract = validate_raw_wire(port, path)
contract.update({'server': name, 'workers': workers})
Expand All @@ -363,9 +388,9 @@ def save():
# Let all cluster/fork workers finish startup before warming up.
time.sleep(1)
before = process_memory(proc.pid)
expected_processes = workers + (1 if name in ('hono-node', 'hono-node-full', 'node-raw') else 0)
if workers > 1 and name in ('hono-gea', 'hono-gea-pkg', 'gea-raw', 'hono-node', 'hono-node-full',
'node-raw', 'cpp-epoll'):
expected_processes = workers + (1 if name in ('hono-node', 'hono-node-full', 'nest-node', 'node-raw') else 0)
if workers > 1 and name in ('hono-gea', 'hono-gea-pkg', 'nest-gea', 'gea-raw', 'hono-node', 'hono-node-full',
'nest-node', 'node-raw', 'cpp-epoll'):
if len(before['processes']) != expected_processes:
raise RuntimeError(f'{name}: expected {expected_processes} processes, got {before}')
wrk(port, '/', '2s', warmup=True)
Expand Down
Loading
Loading