Skip to content

Skip redundant sqlite3_clear_bindings and long long disambiguation on hot insert path - #445

Merged
ospfranco merged 3 commits into
mainfrom
perf/skip-redundant-clear-bindings
Aug 21, 2026
Merged

Skip redundant sqlite3_clear_bindings and long long disambiguation on hot insert path#445
ospfranco merged 3 commits into
mainfrom
perf/skip-redundant-clear-bindings

Conversation

@ospfranco

@ospfranco ospfranco commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Skip the unconditional sqlite3_clear_bindings call in opsqlite_bind_statement when the statement was just freshly prepared (execute/executeSync/reactiveExecute paths) — bindings are already unset on a fresh sqlite3_prepare_v2, so this was pure overhead on every call. PreparedStatementHostObject::bind/bindSync, which reuse a persistent statement across multiple calls, keep the clear via a new should_clear_bindings flag (defaults to true, so callers I didn't touch are unaffected).
  • Simplify to_variant's number branch: the long long disambiguation always fell through to sqlite3_bind_double downstream anyway (same as the plain double branch), so it was doing an extra cast + comparison per numeric parameter for no behavioral difference.
  • Cache the PropNameIDs for "rowsAffected"/"insertId"/"rows" per jsi::Runtime, reused the same way column_prop_ids are already reused across rows within a call — except these are reused across calls too, since they're set on every execute() result regardless of query. Object::setProperty(rt, const char*, ...) allocates a fresh jsi::String via String::createFromAscii on every call, which this avoids. Keyed by Runtime* (not a single global) since runtime generations can briefly overlap during a bridgeless reload — see the generation_alive comment in OPTypes.hpp.

Why

Found while benchmarking op-sqlite against react-native-nitro-sqlite on 1000 sequential single-row inserts — op-sqlite was unexpectedly slower on both sync and async inserts despite having fewer abstraction layers. These changes remove fixed per-call overhead on the bind and result-marshaling paths that scales with insert volume.

Test plan

  • Existing test suite passes (sync/async execute, transactions, prepared statements)
  • Re-run insert benchmark to confirm the throughput gap narrows
  • Sanity-check PreparedStatementHostObject.bind() reused across multiple execute() calls with varying param values still binds correctly (this is the one path that still clears bindings)
  • Sanity-check a bridgeless reload (two runtime generations briefly overlapping) doesn't hit a stale/cross-runtime PropNameID

… hot insert path

opsqlite_bind_statement always cleared bindings before binding, even
though the plain execute/executeSync/reactiveExecute paths bind
immediately after a fresh sqlite3_prepare_v2, where bindings are
already unset. Added a should_clear_bindings flag (default true) so
PreparedStatementHostObject's bind/bindSync, which reuse a persistent
statement across calls, keep the clear, while the fresh-prepare paths
skip it.

Also dropped to_variant's long long branch: it always ended up calling
sqlite3_bind_double downstream anyway (same as the double branch), so
the extra cast/comparison per numeric param had no behavioral effect.

Benchmarked ~1000 sequential single-row inserts against
react-native-nitro-sqlite; these were part of closing an unexplained
insert throughput gap.
res.setProperty(rt, "rowsAffected", ...) and friends go through the
Object::setProperty(Runtime&, const char*, ...) overload, which calls
String::createFromAscii on every single call -- allocating a fresh JS
String just to set a property whose name never changes across calls.

Cache the PropNameIDs for "rowsAffected"/"insertId"/"rows" once and
reuse them, the same way column_prop_ids are already reused across
rows within a single call -- except these are reused across calls too.

PropNameID is scoped to the jsi::Runtime that created it, and runtime
"generations" can briefly overlap during a bridgeless reload (see the
comment on generation_alive in OPTypes.hpp), so a single global cache
isn't safe. Keyed the cache by Runtime* instead (thread_local, no lock
needed since JSI runtime access is always confined to one thread at a
time).

Wired into create_js_rows (the executeSync/execute hot path),
create_result (executeWithHostObjects), and create_raw_result.
@ospfranco
ospfranco merged commit 6df605e into main Aug 21, 2026
10 checks passed
@ospfranco
ospfranco deleted the perf/skip-redundant-clear-bindings branch August 21, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant