diff --git a/docs/experiments/history.json b/docs/experiments/history.json index 559ae027..3cf395a8 100644 --- a/docs/experiments/history.json +++ b/docs/experiments/history.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-09T15:34:59.695047", + "generated": "2026-06-10T07:21:22.379316", "runs": [ { "id": "baseline-before-authorizer-hooks", @@ -19301,6 +19301,19 @@ "timestamp": "2026-06-08T07:16:55", "source": "nearby" } + }, + { + "id": "159", + "title": "`Row.containsKey` identity fast path", + "date": "2026-06-10", + "status": "rejected", + "summary": "Routing `Row.containsKey` through `RowSchema.indexOf` to extend exp 158's identity scan landed nominally at -3.4% on the `row_map_facade` `containsKey` row median (15.231 → 14.707 ms), but the candidate range (12.377 – 15.101 ms) fully overlapped the baseline range (13.930 – 15.329 ms). `HashMap.containsKey` is already at the noise floor on canonical-string keys because Dart caches `String.hashCode`. The change is behaviour-preserving and exp 158's `indexOf` fast path stands; reopen only if a workload makes `containsKey` on canonical-and-present keys a material wall-time fraction.", + "commit": null, + "problem": "Experiment 158 added the schema-name identity fast path to `RowSchema.indexOf`\nand observed a clean win on full row consumption — `row_map_facade` hot lookup\ndropped 10.750 → 5.136 ms (-52%) and `select_maps` 10K main-isolate consumption\ndropped 1.998 → 0.967 ms (-52%).\n\nThat experiment intentionally left `Row.containsKey` on the original direct\n`_indexByName.containsKey(key)` path. The `containsKey` row median in exp 158\nmoved from 17.720 → 17.701 ms, recorded as neutral.\n\nThe bounded question for this run was therefore: does extending exp 158's\nidentity fast path to `Row.containsKey` produce a measurable win on the\nsame workload, or is the existing direct `HashMap.containsKey` already at\nthe noise floor of the available benchmark?", + "hypothesis": "`Row.containsKey` currently bypasses `RowSchema.indexOf` and goes straight to\n`_indexByName.containsKey(key)`. Routing it through `indexOf` would let\ncanonical-string lookups (the common case in user code that mirrors row.keys)\nshort-circuit inside the up-to-32-column identity scan that exp 158 already\nestablished, without changing any public API and without affecting\n`Row.operator[]`, which already uses `indexOf`.\n\nPredicted ceiling: roughly the same shape as exp 158's hot-lookup delta, since\nboth paths now share the same `indexOf` call.\n\nAccept only if `row_map_facade` containsKey medians drop materially relative to\nbaseline noise. Reject if the candidate collapses to the noise floor — a wide\nidentity scan plus HashMap fallback for non-identical keys is a real fallthrough\ncost, and ma...", + "approach": "`Row.containsKey` now calls `_schema.indexOf(key) >= 0` instead of\n`_schema._indexByName.containsKey(key)`:\n\n```dart\n@override\nbool containsKey(Object? key) =>\n key is String && _schema.indexOf(key) >= 0;\n```\n\n`indexOf` was unchanged. For schemas with `≤ 32` columns it still runs the\nidentity scan first, falling back to the private `HashMap` for\nnon-identical or unknown keys. Schemas wider than 32 columns skip the identity\nloop entirely and go straight to the HashMap, identical to exp 158's behavior.\n\nNo other code, public API, or transfer surface was touched.\n\nA pre-run sanity check confirmed canonical-string identity holds in the\nbenchmark setup: `identical(\"updated_at\", schema.names[5])` returned `true` and\n`indexOf(\"updated_at\")` returned `5`. So the identity fast pa...", + "results": "Three paired runs of `dart run benchmark/experiments/row_map_facade.dart`,\nstashing the change for baseline and unstashing for candidate. `containsKey`\nrow medians (8-column schema, 500,000 inner iterations per measurement):\n\n| Run | Baseline row (ms) | Candidate row (ms) |\n|---|---:|---:|\n| 1 | 15.329 | 12.377 |\n| 2 | 15.231 | 14.707 |\n| 3 | 13.930 | 15.101 |\n\nRun-median summary:\n\n| Metric | Value |\n|---|---:|\n| Baseline median | 15.231 ms |\n| Candidate median | 14.707 ms |\n| Delta | -3.4% |\n| Baseline run-to-run range | 13.930 – 15.329 ms (1.4 ms span) |\n| Candidate run-to-run range | 12.377 – 15.101 ms (2.7 ms span) |\n\nThe candidate range fully overlaps the baseline range, and the run-3 candidate\nmedian (15.101 ms) is *above* the run-3 baseline median (13.930 ms). The\nnominal -3.4% d...", + "reasoning": "Reject.\n\nThe identity fast path is consistent with exp 158 and the change is\nbehavior-preserving, but the measured win on the available benchmark is below\nthe per-run noise floor. The most likely explanation is that\n`HashMap.containsKey` is already very fast on canonical-string\nkeys — Dart caches `String.hashCode` on canonical strings, so the bucket lookup\ncollapses to a single hash + identity-compare. The candidate path replaces that\nwith a short identity-scan loop that performs almost the same comparison count.\n\nThere is also a small downside outside the canonical case: any\n`row.containsKey(nonCanonicalKey)` call now pays an up-to-32 element identity\nscan before falling through to the HashMap. For typical user code that passes\nliteral column names this is invisible, but a..." } ], "tracked": [ diff --git a/experiments/159-row-containskey-identity-fast-path.md b/experiments/159-row-containskey-identity-fast-path.md new file mode 100644 index 00000000..2082c850 --- /dev/null +++ b/experiments/159-row-containskey-identity-fast-path.md @@ -0,0 +1,155 @@ +# Experiment 159: Row.containsKey identity fast path + +**Date:** 2026-06-10 +**Status:** Rejected +**Direction:** `result-transfer-shape` +**Benchmark Run:** Focused `row_map_facade` paired A/B +**Archive:** Not created; the candidate is a one-line private method swap (see +snippet below) and there is no surrounding scaffolding worth preserving. The +row.dart change was reverted before merge consistent with rejection. + +## Problem + +Experiment 158 added the schema-name identity fast path to `RowSchema.indexOf` +and observed a clean win on full row consumption — `row_map_facade` hot lookup +dropped 10.750 → 5.136 ms (-52%) and `select_maps` 10K main-isolate consumption +dropped 1.998 → 0.967 ms (-52%). + +That experiment intentionally left `Row.containsKey` on the original direct +`_indexByName.containsKey(key)` path. The `containsKey` row median in exp 158 +moved from 17.720 → 17.701 ms, recorded as neutral. + +The bounded question for this run was therefore: does extending exp 158's +identity fast path to `Row.containsKey` produce a measurable win on the +same workload, or is the existing direct `HashMap.containsKey` already at +the noise floor of the available benchmark? + +## Hypothesis + +`Row.containsKey` currently bypasses `RowSchema.indexOf` and goes straight to +`_indexByName.containsKey(key)`. Routing it through `indexOf` would let +canonical-string lookups (the common case in user code that mirrors row.keys) +short-circuit inside the up-to-32-column identity scan that exp 158 already +established, without changing any public API and without affecting +`Row.operator[]`, which already uses `indexOf`. + +Predicted ceiling: roughly the same shape as exp 158's hot-lookup delta, since +both paths now share the same `indexOf` call. + +Accept only if `row_map_facade` containsKey medians drop materially relative to +baseline noise. Reject if the candidate collapses to the noise floor — a wide +identity scan plus HashMap fallback for non-identical keys is a real fallthrough +cost, and matching `HashMap.containsKey` on canonical strings alone is not a +merge-worthy outcome. + +## Approach + +`Row.containsKey` now calls `_schema.indexOf(key) >= 0` instead of +`_schema._indexByName.containsKey(key)`: + +```dart +@override +bool containsKey(Object? key) => + key is String && _schema.indexOf(key) >= 0; +``` + +`indexOf` was unchanged. For schemas with `≤ 32` columns it still runs the +identity scan first, falling back to the private `HashMap` for +non-identical or unknown keys. Schemas wider than 32 columns skip the identity +loop entirely and go straight to the HashMap, identical to exp 158's behavior. + +No other code, public API, or transfer surface was touched. + +A pre-run sanity check confirmed canonical-string identity holds in the +benchmark setup: `identical("updated_at", schema.names[5])` returned `true` and +`indexOf("updated_at")` returned `5`. So the identity fast path *does* fire on +the row_map_facade workload — the experiment is measuring the speedup of +identity-scan vs HashMap on canonical strings, not the cost of an unmatched +fast path. + +## Results + +Three paired runs of `dart run benchmark/experiments/row_map_facade.dart`, +stashing the change for baseline and unstashing for candidate. `containsKey` +row medians (8-column schema, 500,000 inner iterations per measurement): + +| Run | Baseline row (ms) | Candidate row (ms) | +|---|---:|---:| +| 1 | 15.329 | 12.377 | +| 2 | 15.231 | 14.707 | +| 3 | 13.930 | 15.101 | + +Run-median summary: + +| Metric | Value | +|---|---:| +| Baseline median | 15.231 ms | +| Candidate median | 14.707 ms | +| Delta | -3.4% | +| Baseline run-to-run range | 13.930 – 15.329 ms (1.4 ms span) | +| Candidate run-to-run range | 12.377 – 15.101 ms (2.7 ms span) | + +The candidate range fully overlaps the baseline range, and the run-3 candidate +median (15.101 ms) is *above* the run-3 baseline median (13.930 ms). The +nominal -3.4% drop in the median is smaller than the per-run variance on +either side. + +`Row.operator[]` paths (`hot lookup`, `iterate keys + lookup`) were already +ahead under exp 158, and `Map.from clone`, `forEach`, `entries iteration`, and +`values iteration` do not exercise `containsKey`. None of those moved +materially across the paired runs. + +`select_maps` and `point_query` were not re-run as guardrails because internal +resqlite code does not call `Row.containsKey` (`grep -rn 'containsKey' lib/` +returns only the row.dart definition itself and the docstring), and exp 158 +already covered the shared `indexOf` path. + +## Decision + +Reject. + +The identity fast path is consistent with exp 158 and the change is +behavior-preserving, but the measured win on the available benchmark is below +the per-run noise floor. The most likely explanation is that +`HashMap.containsKey` is already very fast on canonical-string +keys — Dart caches `String.hashCode` on canonical strings, so the bucket lookup +collapses to a single hash + identity-compare. The candidate path replaces that +with a short identity-scan loop that performs almost the same comparison count. + +There is also a small downside outside the canonical case: any +`row.containsKey(nonCanonicalKey)` call now pays an up-to-32 element identity +scan before falling through to the HashMap. For typical user code that passes +literal column names this is invisible, but a workload that calls `containsKey` +with many runtime-built strings (e.g. JSON-derived keys) would lose a few +nanoseconds per call. + +Without a workload where containsKey-time dominates and the keys are usually +canonical-and-present, the change has no measurable upside on current +benchmarks and a small theoretical downside on the non-canonical path. + +## Future Notes + +- Do not retry this exact swap unless a new workload makes containsKey on + canonical-string column names a material fraction of wall time. The + obvious candidate would be a streaming consumer that filters rows by + optional-column presence on every emitted row. +- The result is *not* evidence that `RowSchema.indexOf`'s identity fast path + is unnecessary; exp 158 stands. It only shows that the cost reduction + inside the existing `containsKey` shape is below the current measurement + floor. +- If a future result-shape experiment touches `containsKey`, it should + measure on a benchmark that strips the loop-and-switch overhead currently + dominating the `row_map_facade` `containsKey` case, or pair the change + with a workload that calls containsKey on rows transferred from worker + isolates rather than constructed inline. + +## Validation + +- `dart pub get` +- `dart analyze lib/src/row.dart` +- `dart test test/database_test.dart` (49/49 pass — includes + `row.containsKey('id')` / `row.containsKey('name')` / + `row.containsKey('nonexistent')` assertions) +- Focused identity check confirming + `identical('updated_at', schema.names[5]) == true` on the benchmark schema +- Focused `row_map_facade` A/B (3 baseline + 3 candidate runs, table above) diff --git a/experiments/README.md b/experiments/README.md index eadf4246..2bc76231 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -87,6 +87,7 @@ Experiments that didn't work out. Each has valuable context on *why* — check b | # | Experiment | Why Rejected | |---|---|---| +| [159](159-row-containskey-identity-fast-path.md) | `Row.containsKey` identity fast path | Routing `Row.containsKey` through `RowSchema.indexOf` to extend exp 158's identity scan landed nominally at -3.4% on the `row_map_facade` `containsKey` row median (15.231 → 14.707 ms), but the candidate range (12.377 – 15.101 ms) fully overlapped the baseline range (13.930 – 15.329 ms). `HashMap.containsKey` is already at the noise floor on canonical-string keys because Dart caches `String.hashCode`. The change is behaviour-preserving and exp 158's `indexOf` fast path stands; reopen only if a workload makes `containsKey` on canonical-and-present keys a material wall-time fraction. | | [151](151-sync-writer-response.md) | Synchronous writer response resolution | Switching writer response futures to `Completer.sync()` was a concrete request-resolution attempt against exp 147's residual writer/request bucket, but the formal Tracelite stream-dispatch A/B did not clear the primary gate. High-cardinality fanout changed +2.92%, keyed-PK subscriptions changed +18.5% with too-noisy evidence, and many-streams writer throughput changed +14.0%. No runtime code kept. | | [148](148-reader-reply-batching.md) | Reader reply batching | Batching stream re-query replies reduced the exp 136 completion counter in a profile smoke (A11c overlap completion callbacks 4,527 → 1,425, completion wall 109.6 ms → 55.6 ms), but the formal Tracelite stream-dispatch A/B did not produce a measured-elapsed win. High-cardinality fanout changed +5.18%, many-streams writer throughput +3.28%, and keyed-PK subscriptions +13.5%. Do not merge worker-side reader-reply batching without a workload that turns the callback reduction into end-to-end wall improvement. | | [146](146-lower-batch-pack-threshold.md) | Lower batch packing threshold | Tracelite A/B run over `narrow-batch-insert` collected clean baseline and candidate histories but produced no primary improvement: resqlite changed +1.45% with neutral verdict, while the sqlite_async guardrail was too noisy. Keep the exp 125 large-wide-batch guard; small/narrow batches stay generic until a new workload proves parameter encoding is material. | diff --git a/experiments/signals.json b/experiments/signals.json index 957ef85e..813b5058 100644 --- a/experiments/signals.json +++ b/experiments/signals.json @@ -171,9 +171,9 @@ "id": "result-transfer-shape", "status": "watch", "subsystems": ["results", "isolate-transfer", "api-shape"], - "currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy.", + "currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy. Exp 159 then tried routing Row.containsKey through that same indexOf fast path and rejected the swap: the candidate moved row_map_facade containsKey nominally -3.4% (15.231 -> 14.707 ms) but the candidate range fully overlapped the baseline range, because HashMap.containsKey is already at the noise floor on canonical-string keys (Dart caches String.hashCode). The rejection bounds future containsKey-side work in this direction unless a workload makes containsKey on canonical-and-present keys a material wall-time fraction.", "keyPriors": ["008", "063", "082", "089", "158"], - "archive": ["066", "081"], + "archive": ["066", "081", "159"], "interestingIf": [ "Dart adds new deeply immutable or transfer primitives that support typed data and lists", "a change preserves the lean API while removing end-to-end work", @@ -192,7 +192,7 @@ } ], "blockedOnMeasurement": [], - "notesForExperimenters": "Be explicit about API impact. Some measured wins depend on a new public API, which is outside the lean-API goal. Use `select_maps` or a similarly full-consumer benchmark before accepting result-shape changes; setup-only transfer wins are not enough. Exp 158 is a narrow private data-structure win inside RowSchema, not evidence to revive larger ResultSet API changes." + "notesForExperimenters": "Be explicit about API impact. Some measured wins depend on a new public API, which is outside the lean-API goal. Use `select_maps` or a similarly full-consumer benchmark before accepting result-shape changes; setup-only transfer wins are not enough. Exp 158 is a narrow private data-structure win inside RowSchema, not evidence to revive larger ResultSet API changes. Exp 159 took the natural follow-up (extend the identity fast path to Row.containsKey) off the candidate list — the row_map_facade containsKey case collapses to noise because HashMap.containsKey is already fast on canonical-string keys. Do not retry that swap without a workload where containsKey time dominates." }, { "id": "measurement-system", @@ -684,6 +684,19 @@ "treat `stream_emit_us` as evidence-of-absence for subscriber-fanout optimization candidates until a workload with very many listeners per stream surfaces", "future main-isolate counters should follow the exp 136 pattern (counter + post-drain snapshot in `audit_workloads.dart`) rather than redoing the harness wiring" ] + }, + "159": { + "directions": ["result-transfer-shape"], + "outcomeClass": "rejected_below_current_signal", + "changedBeliefs": [ + "HashMap.containsKey is already at the row_map_facade noise floor on canonical-string keys, so extending exp 158's identity fast path to Row.containsKey produces no measurable win", + "Future result-transfer-shape work should not retry the containsKey side of the identity fast path unless a workload shows containsKey on canonical-and-present keys is a material wall-time fraction" + ], + "nextSignals": [ + "do not route Row.containsKey through RowSchema.indexOf without a new workload that exposes containsKey wall time", + "if a containsKey-heavy stream consumer or map-presence-filter benchmark appears, re-run the swap and confirm the identity scan actually fires (canonical-string identity holds on the row_map_facade schema, so the fast path is reachable when measured)", + "exp 158's indexOf fast path stands; this rejection only bounds the containsKey-side extension" + ] } } }