Experiment 220: reject fast_i64_to_str 0..9999 direct-write fast path

Date: 2026-07-08

Status: Rejected

Direction:result-transfer-shape

Benchmark Run: none — focused

benchmark/experiments/select_bytes_int_heavy.dart;

raw pair tables in

benchmark/results/2026-07-08T11-15-15Z-exp220-fast-i64-small-direct.md.

Archive:archive/exp-220

Problem

selectBytes() serialises every SQLITE_INTEGER cell through

fast_i64_to_str inside write_json_to_buf. After

exp 192 replaced the exp-023 single-digit loop with a

two-digit [00..99] lookup, the tightest small-int path looks roughly like:

 if (val == 0) { buf[0] = '0'; return 1; } char tmp[20]; int pos = 20, negative = 0; unsigned long long uval = ...; // sign-normalise (LLONG_MIN safe) while (uval >= 100) { ...pos -= 2; memcpy(tmp+pos, ..., 2); } if (uval >= 10)   { pos -= 2; memcpy(tmp+pos, ..., 2); } else              { tmp[--pos] = '0' + uval; } int digits = 20 - pos; if (negative) buf[len++] = '-'; memcpy(buf + len, tmp + pos, digits); 

For the typical row-id / small-key / small-count shape (0..9999) the code

still writes the digits into a stack scratch tmp[20], computes pos, then

memcpys from tmp+pos back to buf. Every cell pays one write to tmp,

one memcpy from tmp to buf, and the sign-normalisation branch even

though it is never taken.

Row IDs, foreign-key indices, denormalised counts, and status enums are among

the most common INTEGER column shapes in real schemas, and many of them

naturally live in 0..9999 (small tables, small counts, low-cardinality

enums, small timestamp derivatives). A direct-write fast path that skips the

tmp[] scratch, sign branch, and the trailing memcpy should be the next

bounded step after exp 192 and

exp 194 inside the same write_json_to_buf

per-cell chain.

Hypothesis

A 0..9999 direct-write branch at the top of fast_i64_to_str should reduce

per-cell integer JSON encoding wall time on workloads where every cell hits

that range. Larger magnitudes and negatives fall through unchanged; output

bytes stay byte-identical across the whole int64 range.

Predictions:

10k rows x 8 small non-neg ints (0..9999) lanes should reproduce

candidate-faster across an order-flipped pair.

through the fast-path gate unchanged.

either stay neutral (if the extra compare-and-branch is below the effect

floor) or reproduce a small regression (if the added per-cell instruction

count is measurable).

Reject if the primary target lanes fail to reproduce, or if the exp-192

baseline lanes reproduce a regression above the sub-1% guards that recent

encoder wins (exp 190 / 192 / 194 / 195 / 216) have shipped with.

Approach

Change only native/resqlite.c. Insert the fast path

between the existing val == 0 shortcut and the exp-192 general path:

 if ((unsigned long long)val < 10000ULL) { unsigned uv = (unsigned)val; if (uv < 10)   { buf[0] = '0' + uv; return 1; } if (uv < 100)  { memcpy(buf, kTwoDigits + uv*2, 2); return 2; } if (uv < 1000) { /* one digit + one pair */ return 3; } /* 1000..9999: two pairs */ return 4; } // exp-192 general path unchanged. 

Casting a signed long long to unsigned long long treats negatives as very

large unsigned values, well above 10000ULL, so no explicit sign check is

needed inside the fast path; negatives, LLONG_MIN, and |val| >= 10000 all

fall through to the existing implementation.

Extend

benchmark/experiments/select_bytes_int_heavy.dart

with two new lanes at the top of the run that isolate the fast path

(10k x 20 and 10k x 8, every cell drawn from rng.nextInt(10000)). The

existing exp-192 lanes below them keep the mixed-magnitude uniform

[-2^29, 2^29) distribution so any per-cell overhead added to the general

path is visible.

The runtime prototype is archived at archive/exp-220 and reverted from the

final branch. No runtime code is kept.

Results

Focused harness:

dart run benchmark/experiments/select_bytes_int_heavy.dart. Values are

median microseconds per selectBytes() query.

LaneB1C1Delta 1C2B2Delta 2
10k rows x 20 small non-neg ints (0..9999)48974331-11.6%43364842-10.4%
10k rows x 8 small non-neg ints (0..9999)21831945-10.9%19762189-9.7%
10k rows x 8 small ints25722645+2.8%26572589+2.6%
10k rows x 20 small ints57905985+3.4%59535783+2.9%
10k rows x 20 big ints (~18 digits)72287284+0.8%73507301+0.7%
10k rows x 8 mixed (4 int + 2 text + 2 real)86648654-0.1%86378684-0.5%
1k rows x 2 ints98980.0%10799+8.1%

The fast path reproduces its target win on both new lanes with the same sign

across the order-flipped pair: 10k x 20 small non-neg moves -11.6% and

-10.4%, 10k x 8 small non-neg moves -10.9% and -9.7%. Every cell in those

lanes hits the direct-write branch, so the win magnitude is the pure per-cell

saving from the eliminated tmp[] write, memcpy from tmp to buf, and

sign-normalisation branch.

The exp-192 baseline small ints lanes reproduce a small regression in the

same direction across the flip. Their distribution is uniform in

[-2^29, 2^29), so essentially every cell (>99.999%) falls through the

fast-path gate. Branch prediction is not the driver — the predictor cleanly

learns always fall through — so the ~3% reproduced cost is instruction-

count overhead from the added compare-and-branch per cell, not a mispredict.

The ~18-digit big ints regression guard stays neutral (+0.8% / +0.7%,

below the 3% effect floor), and the 4-int-plus-text-plus-real mixed row is

neutral (-0.1% / -0.5%). The 1k x 2 ints sub-100us lane took a slow

outlier in pair 2 but is not load-bearing at that resolution.

Focused correctness:

 dart test test/database_test.dart -n "selectBytes" 

All nine selectBytes tests passed against the candidate — including the

selectBytes encodes int64 extremes test that already covers 0, 1, 9, 10,

99, 100, 999, -999, 1000, 9999, 10000, 12345, -12345, deep magnitudes, and

LLONG_MIN / LLONG_MAX, i.e. every boundary of the new fast path — before

the runtime change was reverted.

Decision

Rejected.

The primary target reproduces cleanly, but the reproduced ~3% regression on

the load-bearing small ints lane sits well above the sub-1% guards that

exp 190 / 192 / 194 / 195 / 216 shipped with. The mixed-magnitude

distribution represents a real workload shape (signed row IDs, foreign

keys, epoch-derived timestamps), and without a production profile showing

0..9999 INTEGER columns dominate a shipped app's selectBytes wall, adding

a per-cell compare-and-branch that slows the general path by ~3% for a

~10% win on a narrow range is not a clear trade.

Would reopen if any of the following changes:

meaningful selectBytes wall time in fast_i64_to_str on columns that

live entirely in 0..9999.

fall through — for example, a compile-time-specialised encoder chosen per

column-type hint on resqlite_cached_stmt, or PGO on a workload known to

be small-non-neg-heavy.

10k x 20 small ints clear the acceptance gate together — the second

gate is what this run failed.

The runtime prototype is preserved at archive/exp-220. The extended

select_bytes_int_heavy.dart harness is the durable contribution: its two

new small-non-neg lanes plus the existing exp-192 baseline lanes are now the

acceptance gate for any future small-int JSON encoder work in the

result-transfer-shape direction.

Test plan

including int64 extremes and REAL integer-valued numbers)

benchmark/experiments/select_bytes_int_heavy.dart (two pairs; see

Results)