Experiment 158: Row schema hash index

Date: 2026-06-09

Status: In Review

Direction:result-transfer-shape

Benchmark Run: Focused row_map_facade, select_maps, point_query

Problem

The shipped ResultSet shape keeps transfer lean by sharing one flat values

list and one RowSchema across lazily-created Row views. That is still the

right public API shape, but full row consumption pays one column-name lookup for

every accessed cell:

 for (final row in rows) { for (final key in row.keys) { row[key]; } } 

RowSchema built its column index with a default map literal. For this fixed

lookup table we do not need insertion-order semantics; the query column order is

already preserved in RowSchema.names. The index only needs fast name-to-offset

lookup for Row.operator[] and containsKey.

Hypothesis

Use a schema-name identity fast path plus a HashMap<String, int> fallback for

RowSchema.indexOf, but only on schemas where a short identity scan stays

cheap.

This should reduce main-isolate full-consumption cost without changing the

public List<Row> / Map<String, Object?> surface. Accept for review if

select_maps improves on resqlite's 1K/10K full-consumption paths and hot

single-row point queries stay neutral, since point queries construct a schema

per select and would expose any construction-cost regression.

Approach

RowSchema.indexOf now first checks whether the lookup key is one of the

schema's own column-name objects by identity when the schema has at most 32

columns. That catches the common full-consumption pattern:

 for (final key in row.keys) { row[key]; } 

When the caller supplies an equal but non-identical string, or when the schema

is wider than 32 columns, the method falls back to a private `HashMap<String,

int> index. Query column order remains stored in RowSchema.names`; the

private lookup map does not need insertion order. No public API changes and no

result transport changes are introduced.

A local width sweep showed the identity scan winning through 32 columns and

losing by 48 columns on this machine. The repo's standard schema is 6 columns,

the row facade benchmark is 8 columns, and the release-facing wide schema is 20

columns, so the guard preserves the measured win while avoiding the obvious

downside for unusually wide selects.

An earlier sub-candidate tried a custom ResultSet iterator. It did not hold up

under paired select_maps runs, so that code was removed before this PR.

Results

Focused paired runs compared origin/main at c14bbbd against the candidate

branch in fresh worktrees with generated Drift files.

row_map_facade isolates Row map operations. The winning candidate was the

combined <= 32 column identity fast path plus HashMap fallback:

CaseBaseline row medianCandidate row medianDelta
hot lookup10.750 ms5.136 ms-52.2%
iterate keys + lookup20.602 ms10.650 ms-48.3%
containsKey17.720 ms17.701 msneutral

select_maps consumes every field in every returned row. The clean paired pass

after the combined candidate was:

RowsBaselineCandidateDelta
1,000 wall0.869 ms0.606 ms-30.3%
1,000 main0.169 ms0.081 ms-52.1%
10,000 wall14.671 ms10.087 ms-31.2%
10,000 main1.998 ms0.967 ms-51.6%

The first 10K baseline/candidate pair was noisy, so the decision does not rely

on it. The final paired run above lines up with the isolated row facade

measurement and shows the main-isolate consumer cost moving in the targeted

direction.

Point-query guardrail:

MetricBaselineCandidateDelta
resqlite qps44,58742,006-5.8%, CI overlap
resqlite per query0.022 ms0.024 msneutral/noisy

The point-query run constructs schemas but does not consume rows, so it is a

guardrail for construction overhead. The candidate CI overlapped the baseline

CI; there is no clear small-select regression signal.

Decision

Accept for review.

This is a small internal data-structure change that preserves the lean public

API and removes lookup overhead from full row consumption. It directly uses the

existing select_maps workload that measures end-to-end Map/Row access

rather than only transfer setup, and the isolated row facade benchmark confirms

the intended lookup path moved.

Future Notes

Do not reopen larger result-shape changes from this result alone. The win is

specific to the private schema index used by the current Row facade. The

identity scan is intentionally capped at 32 columns; revisit that threshold only

with a width sweep and a full-consumption benchmark for wider schemas. New

result-transfer experiments should still prove full consumer cost, not just

worker transfer or decode setup.

Validation