Experiment 216: Base64 loop unroll for selectBytes BLOBs

Date: 2026-07-05

Status: Accepted

Direction:result-transfer-shape

Benchmark Run: none — focused

benchmark/experiments/select_bytes_blob_base64.dart;

raw pass tables in

benchmark/results/2026-07-05T10-07-55Z-exp216-base64-loop-unroll.md.

Problem

selectBytes() serializes SQLite BLOB cells as base64 strings in

json_write_base64. The helper already writes into the

connection's persistent json_buf; there is no intermediate allocation or Dart

copy inside the base64 path itself. After exp 199

and exp 203, the remaining BLOB-specific encoder

work is mostly the scalar base64 payload loop:

 for (; i + 2 < len; i += 3) { unsigned int v = ((unsigned int)data[i] << 16) | ((unsigned int)data[i + 1] << 8) | (unsigned int)data[i + 2]; *out++ = b64_table[(v >> 18) & 0x3F]; *out++ = b64_table[(v >> 12) & 0x3F]; *out++ = b64_table[(v >> 6)  & 0x3F]; *out++ = b64_table[ v        & 0x3F]; } 

Exp 201 already tried the smaller framing

cleanup around this loop and rejected it: reserving quote + payload + quote once

did not move the many-tiny-BLOB lanes that should have carried a per-cell

framing win. That experiment's future note was specific: further BLOB

selectBytes() work should target the base64 loop, transport/copy shape, or a

measured allocation boundary rather than the quote writes.

The base64 loop is the next bounded mechanism. It should matter more as the BLOB

payload gets larger, because each 3-byte group pays the same loop-control branch

and index arithmetic.

Hypothesis

Unrolling json_write_base64 so each loop trip encodes four 3-byte groups

should reduce loop-control overhead without changing the base64 alphabet,

padding, JSON framing, or output bytes.

Prediction:

order-flipped pair, with the strongest effect on 4KB cells.

never reaches the unrolled loop.

unchanged.

Reject if medium/large BLOB lanes do not reproduce same-direction wins, or if

tiny/mixed guards show a reproduced regression large enough to erase the

payload-loop benefit.

Approach

Change only native/resqlite.c. Replace the scalar

one-triplet loop with:

lookups for one 3-byte group,

output bytes`),

The opening and closing JSON quotes stay where they are. encoded_len is

unchanged. The output is byte-identical for every length; the implementation

only changes loop shape.

Results

Focused harness:

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

median microseconds per selectBytes() query.

LaneBaseline P1Candidate P1Delta P1Candidate P2Baseline P2Delta P2Baseline P3Candidate P3Delta P3
10k rows x 8 tiny blobs (3B)23452209-5.8%22232190+1.5%22162230+0.6%
10k rows x 20 tiny blobs (3B)52394992-4.7%51775199-0.4%53415542+3.8%
10k rows x 8 small blobs (16B)28732703-5.9%27983004-6.9%42112762-34.4%*
10k rows x 4 medium blobs (128B)37513711-1.1%37654153-9.3%39733785-4.7%
1k rows x 2 large blobs (4KB)48494191-13.6%41704786-12.9%49184332-11.9%
10k rows x 8 mixed (4 blob + 2 int + 2 text)26192565-2.1%25572654-3.7%28062663-5.1%

*Pair 3's small-BLOB baseline is a slow outlier and is not load-bearing.

The 4KB large-BLOB lane is the cleanest signal: candidate medians stay in a

tight 4170-4332 us/query band while baseline stays 4786-4918 us/query, giving a

reproduced 11.9-13.6% win. Medium BLOBs also stay candidate-faster across all

passes, though the baseline drifts enough that the effect band is wider. The

16B small-BLOB lane is candidate-faster in the primary order-flipped pair.

The 3B tiny-BLOB lanes are guardrails, not the main mechanism: each cell has

only one complete triplet, so most cells never use the four-triplet loop. Those

lanes are neutral/noisy rather than reproduced regressions. The mixed row stays

candidate-faster across all passes.

Focused correctness:

 dart test test/database_test.dart --name "selectBytes encodes blobs as base64" 

The test passed against the candidate.

Decision

Accepted. Keep the loop unroll.

This is a narrow native encoder win: no public API change, no new allocation, no

format change, and no extra runtime state. It is specifically useful for BLOB

selectBytes() payloads at and above small/medium cell sizes, with the strongest

measured effect on the 4KB lane. It does not reopen exp 201's quote/framing

candidate; tiny one-triplet cells remain at the noise floor, which is exactly

what a payload-loop optimization predicts.

Future BLOB encoder work should use select_bytes_blob_base64.dart and treat

the 4KB lane as the throughput gate, with tiny 3B lanes as regression guards.

Another BLOB attempt should target a larger algorithmic or copy boundary, not

only per-cell framing.

Test plan

benchmark/experiments/select_bytes_blob_base64.dart (three passes; see

Results)