Skip to content

perf.md — S0 measurements and decisions

Measurement environment: Apple M3 Pro, macOS, MySQL 8.4.11 local Unix socket (/tmp/mysql.sock), orm_bench.battle 100,000 rows (two aes_hex_* columns), Go 1.27, Rust 1.98.1 (sqlx 0.9, wasmtime 48), PHP 8.5.10 (mysqlnd, msgpack). One connection, p50. Raw data: docs/perf-raw-*.txt. PHP plan-cache measurements below are historical APCu measurements and are not a runtime dependency.

1. Engine compilation cost (Go in-process, JSON in → JSON out)

Workloadns/opallocs
Compile list (two-level WHERE tree + three IN + order + limit, 1.9KB plan)10,98548
Compile pk5,03313

Runs once per shape (plan cache), so the hot-path contribution is zero.

2. Language execution path (cold path, once per shape)

PathlistpkNotes
Go in-process11.0µs5.0µsfunction call
Rust libloading (.dylib 2.7MB)11.4µs5.4µsdlopen 366ms (once), Go runtime and signals embedded in the host process
Rust wasmtime (.wasm 4.6MB)50.6µs24.0µsmodule compilation 390ms(디스크 after caching 22ms), instantiation 1.9ms, no runtime embedding
PHP persistent UDS → ormd26.5µs17.4µswire overhead ≈12–15µs; +22µs with a new connect per request
PHP local cache hit0.25µshistorical APCu benchmark; current runtime uses the same bounded local lookup without APCucached plan json_decode 8.3µs → array storage avoids decoding

Decision R1 — Rust execution path = wasmtime. Both paths have a 100x margin under the budget (≤2ms per shape). The 4.4x difference occurs only on the cold path, and FFI puts the Go runtime in the tokio process, adding signal, thread, and 366ms dlopen operational risk. One artifact covers every OS and architecture, including the fourth language (TS/edge).

3. PHP wire format (100 rows × 20 columns result decode)

EncodingbytesDecode p50
JSON associative array33.7KB139.1µs
JSON positional16.9KB69.8µs
msgpack positional11.6KB28.7µs
msgpack associative array24.5KB69.9µs

Decision R2 — PHP wire = msgpack with positional rows and column headers. The model stores row arrays and shared column indexes, so conversion cost is zero (array_combine would add 60µs).

4. Native baseline (prepared statement reuse, one connection)

WorkloadGo database/sqlRust sqlxPHP PDO
Single row by PK (25 columns, AES 2)38.0µs81.4µs30.5µs
100-row list448µs472µs446µs
INSERT175µs191µs181µs
Four-level relation (20 parents + 3 child IN queries, ≤200 rows + assembly)6.43ms6.68ms7.45ms

Finding F1 — Prepared statement caching is required in the executor. In Go, QueryContext(args) (prepare+exec+close, three round trips) takes 100µs for PK and 38µs after caching. ormd shows the same change from 112 to 51µs. Finding F2 — sqlx PK is 81µs, twice Go/PDO. The likely causes are pool checkout, tokio scheduling, and per-connection cache lookup. In the S1 Rust executor, verify a dedicated connection and persistent, then remeasure (added to T1.19 DoD).

5. PHP execution location — three-path comparison (main decision)

Workload(i) Direct PDO + PHP assembly(iii) ormd execution (prepared) + msgpackDifference
Single row by PK30.5µs51.4µs+69%
100-row list446µs1,122µs+152%
100 rows + associative-array conversion1,448µs
Four-level relation7.45ms (PHP assembly)7.87ms (Go assembly)+6%

The check (remote single row ≤+25%, list ≤+15%, four-level relation at or below baseline) failed completely. The list +676µs is row transfer cost, not the 15µs hop cost (Go generic []any scan and copy + msgpack encoding + PHP decode). Go assembly was not faster than PHP assembly because PDO+mysqlnd already performs C decoding and assembly itself takes tens of microseconds on both sides. The comparison baseline (i) is hand-written PDO assembly, so this measurement is the lower bound for execution-path cost.

Decision R3 — PHP uses the native PDO executor. ormd is compile-only. The plan-v2 §1 decision that PHP uses an ormd execution sidecar is reversed by measurement. The Q1 review hypothesis (Go assembly > PHP assembly and only hop cost) was not supported by measurement. The identified drift risks (key type tags, integer possible, and unserialize) are covered by conformance fixtures and checklist T2.16. Valid reversal conditions: a new method avoids row transfer, such as FrankenPHP in-process calling the Go executor directly, or PHP becomes the primary client and maintaining three assemblies becomes costly.

6. Hot-path check — Go measurements (S1, generated client)

WorkloadNative (prepared)Generated clientNotes
Single row by PK38.0µs / 39 allocs32.9µs / 75 allocsincluding a 1.7µs plan-cache hit
100-row list448µs / 2,176 allocs369µs / 4,517 allocsincluding typed struct mapping
Plan cache hit (without compilation)1.7µs / 14 allocsIR JSON serialization + FNV
Measured loss is zero and within measurement error. Allocations are doubled (positional []any scan to struct); this has no CPU impact and typed scanning can reduce it later. G0/G1 Go check passed.

PHP measurements (generated client, ormd compilation + PDO execution, historical APCu benchmark)

WorkloadDirect PDOGenerated clientNotes
Single row by PK30.5µs31.4µs (+3%)including a 1.8µs plan-cache hit
100-row list446µs390µspositional fetch + lazy access (410µs including getName ×100)
PHP check (≤5%) passed. ormd is called once per shape.

Rust measurements (generated client, wasmtime engine thread + sqlx execution, baseline remeasured in the same session)

WorkloadDirect sqlxGenerated clientNotes
Single row by PK77.7µs78.3µs (+1%)including a 0.7µs plan-cache hit
100-row list407µs378µspositional Vec<Val> to typed struct (strings are moved)
Plan cache hit (without compilation)0.7µsIR JSON serialization + hash
Rust check (≤5%) passed. Raw data: docs/perf-raw-rust-client.txt, docs/perf-raw-rust-native-2.txt.

Finding F3 — A failed sqlx try_get creates a formatted error per cell. The first measurement was 615µs p50 and 1.6ms p90 for 100 rows (bimodal distribution). Cause: integer columns were read as i64 and retried as u64 after failure; each of 12 unsigned columns × 100 rows caused sqlx to format a ColumnDecode error. Branching on the type name to read signed and unsigned values once stabilized the result at 378µs (p90 396µs). Executor rule: do not use a failed try_get for flow control.

F2 closed — sqlx PK at 78µs is sqlx overhead. The generated client has the same value (+1%), unchanged with pool size 1 and a dedicated connection. The twofold difference from Go/PDO is tokio task switching and protocol parsing, rather than this layer. The layer-loss check passed; absolute sqlx improvement is out of scope (S7 candidate: driver replacement comparison).

6b. Fixed cost — three-row query (S1 demo, examples/thin-slice)

Compare the same plan SQL executed again by the native driver in the same process (500 p50 samples, minimal fetch without type mapping).

Generated clientNative minimal fetchFixed cost per statement
Go66µs60µs+6µs
PHP64µs55µs+9µs
Rust107µs91µs+16µs
With only three rows, fixed costs from IR construction, JSON serialization, hashing, plan lookup, and typed-row creation are more visible. The 100-row check in §6 passed, but these fixed costs can be reduced: build the IR shape key without serializing all JSON and remove the Rust Vec<Val> intermediate stage. The demo native path does not perform typed mapping, so it is a less favorable comparison than §6.

6c. Rust generated crate compile time (T1.24, five tables, 2,191 lines, 1,259 methods)

Time
cargo check -p gen (incremental, warm dependencies)0.25s
cargo build --release -p gen (incremental)1.25s
First dev build including dependencies38.6s (mostly sqlx, wasmtime, tokio)
No issue was observed with five tables. By linear extrapolation, the incremental release build is about 40s at 150 tables (T2.15); decide then whether to split with --tables.

Fixed-cost reduction results (S5 T5.3b)

BeforeAfterMethod
Go three-row demo+8µs (+13%)+5µs (+8%)Hash IR shape key without JSON, cache scan facts per plan, reuse scan cells (PK 82→67 allocs, list100 5,118→3,323 allocs)
PHP three-row demo+9µs (+16%)+6µs (+11%)Cache the plan by builder signature (no IR re-encoding), precompute styled/plan_id values per step
Rust three-row demo+4.5µswithin errorDirect typed decode from MySqlRow (remove Vec<Val>); list100 476→428µs, PK p99 210→102µs
Only Rust reaches the ≤+5% target. Remaining Go/PHP cost is builder object creation and the two-stage []any to struct scan; typed direct scanning requires a generator redesign and is deferred to S7.

PHP prepared 방식 (S5 T5.4)

PDO::ATTR_EMULATE_PREPARES = true is fixed. Basis (p50, off → on): cold path (PHP-FPM sees a shape for the first time) PK 72→48µs, IN(8) 107→75µs, 100 rows 460→382µs; warm path PK 33→49µs, 100 rows 435→400µs. Web requests usually execute a shape once, so the cold path is the decision basis. Types (IP strings, JSON, floats, booleans) and conformance output are identical in both modes.

6d. Remeasurement (S6 end, 2026-09-11 — after adding IP, decimal, five styled columns, and fulltext index)

MySQL 8.4, local socket, same hardware. Native means direct execution of the same SQL with each language driver (prepared reuse).

WorkloadGo nativeGo clientPHP PDOPHP clientRust sqlxRust client
Single row by PK40.4µs44.9µs (+11%)29.6µs49.3µs (+66%)¹77.3µs80.9µs (+5%)
100-row list382µs416µs (+9%)410µs425µs (+4%)453µs410µs (−9%)
INSERT180µs128µs169µs
Plan cache hit (without DB)1.1µs1.3µs0.7µs
allocs/op (PK / 100 rows)39 / 2,17670 / 3,326

¹ The PHP client PK result is slower because S5 fixed EMULATE_PREPARES=true (see T5.4: cold path 72→48µs at the cost of warm PK 33→49µs). The benchmark repeats the same statement 3,000 times on the warm path, so it measures only that cost. Real requests usually execute each shape once.

For the 100-row workload, all three implemented clients are within ±10% of native and Rust is faster because it decodes typed values directly. A single row exposes fixed per-statement cost (§6b: IR construction, hashing, plan lookup, and row mapping). Absolute values increased from S1 because battle gained seven columns (IP, decimal, and five styled columns) and a fulltext index, increasing SELECT width and INSERT cost. Native values from the same measurement point are compared.

6e. Regression check (make perf-check)

The check measures the generated client and an equivalent native result for 300 p50 samples in one process. Both sides select the same non-lazy columns, including price and ip. The PHP baseline also decodes and constructs the same generated row result. The previous Go and PHP native SQL omitted price and ip; T7.11 corrected both benchmarks.

Three consecutive local MySQL 8.4 runs on 2026-09-12 produced median ratios of Go PK 0.48x, Go 100 rows 1.05x, PHP PK 1.12x, and PHP 100 rows 0.96x. Limits remain PK 1.35 and 100 rows 1.25. CI fails above a limit. A limit change requires measurement and an update to this document.

Go uses generated typed scans for the default flat projection. Other projections and plans use positional assembly. PHP keeps the PDO::FETCH_NUM array as row storage; row-by-row PDO fetching measured about 511µs for 100 rows versus about 425µs for fetchAll, so that implementation was removed.

The ratio varies by hardware and approaches 1 as round-trip time increases. TestHotPathGate requires ORM_RUN_PERF_GATE=1, and scripts/perf-test.sh starts an isolated compiler socket before running the Go and PHP checks. make check runs the script.

6f. Rust MySQL driver comparison (T7.9)

One release process compared sqlx 0.9 with mysql_async 0.37.1 using one connection per pool, identical prepared SQL and binds, the same typed four-field result, 200 warmup operations, and 1,000 measured operations. Each paired query result was equal. Environment: Apple M3 Pro, MySQL 8.4 local Unix socket, 2026-09-12.

Workloadsqlx meanmysql_async meanRatio (mysql_async/sqlx)
Primary-key row58.840µs61.825µs1.051
100-row list1.180ms1.113ms0.943

Run cargo run --release --locked --bin driver_compare -- 1000 in bench/rust. The existing replacement rule requires a 2x measured improvement. Neither workload meets it, so the ORM retains sqlx. CI compiles this program with make rust-driver-check; latency is not a CI pass condition.

7. S0 decision summary

IDDecisionBasis
R1Rust execution path = wasmtime (.wasm embedded)§2
R2PHP wire = msgpack positional§3
R3PHP execution = native PDO; ormd compile-only§5
F1Prepared statement cache in every executor§4
F2Remeasure Rust executor PK latency → closed as intrinsic sqlx cost§4, §6
F3Do not use failed sqlx try_get for flow control (formatted error per cell)§6

MIT License · Go / PHP / Rust / TypeScript