Feasibility: a carray-style extension over Mojo memory
Whether SQLite virtual tables are reachable from Mojo through the m0-sqlite bindings, and what was tried.
Measured 2026-08-13 against SQLite 3.45.1 and Mojo 1.0.0, x86-64 Linux.
Spike code: experiments/sqlite-vtab/.
The question
Whether to port SQLite's ext/misc/carray.c and modify it to read Mojo struct
memory, so N values could be handed to a query without building N placeholders.
Verdict
Build the virtual table for bulk ingest. Do not build it for IN clauses.
Do not write any C, and do not model Mojo structs.
Bulk ingest is a 2.96x win and clears the bar. The IN-clause case — the
thing carray exists for — does not: it is 1.63–1.99x against rebuilt
placeholders but only 1.16–1.34x against json_each, which is built into
SQLite and needs no new code at all. If the vtab gets built for ingest, the
IN-clause path comes along for free; it is not worth the machinery on its own.
Three premises in the original framing turned out to be wrong, and all three make the work cheaper:
- No C is needed.
libsqlite3exportssqlite3_create_module_v2,declare_vtab,bind_pointer, andvalue_pointer; Mojo 1.0 can supply C-callable function pointers. The module registers in-process on a liveConnection. No C file, no loadable extension, no new build step, nothing new in CI. (carrayitself is not compiled into the system libsqlite3 —SELECT * FROM carray(0,0)givesno such table— so vendoring it would have meant new native machinery either way.) - Do not model Mojo structs. One bound pointer per column beats one strided
pointer per struct. It sidesteps Mojo layout guarantees entirely and matches
the parallel-
ListlayoutCLAUDE.mdalready mandates. - carray's headline benefit mostly is not there. Its big win in Python
bindings is avoiding per-value FFI trampolines. Mojo's
external_callis a direct call, so that saving largely evaporates — which is why theIN-clause numbers are unremarkable and the ingest numbers are not.
Numbers
IN-clause, against a 200k-row table, best of 20:
| N | rebuilt IN (?,…) |
json_each(?) |
Mojo vtab | vs placeholders | vs json_each |
|---|---|---|---|---|---|
| 10 | 6.93 µs | 4.53 µs | 3.47 µs | 1.99x | 1.30x |
| 100 | 45.4 µs | 35.0 µs | 26.2 µs | 1.74x | 1.34x |
| 1000 | 754 µs | 536 µs | 464 µs | 1.63x | 1.16x |
Bulk ingest, one transaction, best of 5:
| Rows | bind/step/reset loop | INSERT … SELECT over vtab |
Speedup |
|---|---|---|---|
| 10,000 | 2.01 ms | 0.68 ms | 2.97x |
| 200,000 | 42.2 ms | 14.3 ms | 2.96x |
The ingest win is real because it collapses 3N sqlite3_bind_*/step/reset
calls plus N statement resets into a single step that pulls rows through
xColumn. The ratio holds flat across a 20x change in row count.
The blocker, and how it was resolved
sqlite3_bind_pointer lends SQLite raw Mojo memory for the life of the
statement, and Mojo frees a value at its last syntactic use — which is
routinely before step() runs:
bind_array(q, 1, data)
q.bind_int(2, len(data)) # last use of `data`; freed here
_ = q.step() # SQLite reads freed memory
This does not crash. The allocator writes a freelist pointer into the block's
first word, so element 0 returns a heap address and every other element is
fine — it surfaced as a wrong sum beside a correct count(*).
A guard object does not fix this. The guard would itself be destroyed at its own last use, still before the step. Its destructor could unbind, turning freed memory into an empty result — safe, but silently wrong, which is barely an improvement.
What does fix it is shape. The array is passed as an argument to the call that also finishes the statement, so it is alive for the whole call by construction and there is nothing for a caller to hold correctly:
db.register_array_module()
var ins = db.prepare("INSERT INTO t SELECT value FROM m0_array(?1)")
ins.execute_over(1, values) # binds, steps to completion, unbinds
The binding is dropped before returning, so a later step on the same
statement sees an empty table rather than a stale pointer. The cost is that
these calls do not compose with incremental stepping — the right way round for
bulk ingest, which was the case worth having.
Shipped in packages/m0-sqlite/src/vtab.mojo with Statement.execute_over and
Statement.fetch_ints_over, opt-in per connection via
Connection.register_array_module, covered by 20 tests. The single-column
IN-clause path came along for free, as predicted; it is still not the reason
to use this.
Portability of the struct offsets — checked
The spike treats four SQLite structs as flat word buffers, so its correctness
rests on offsets the Mojo compiler cannot see. Those are now re-derived from the
real headers via offsetof and asserted at compile time in
packages/m0-sqlite/test/verify_layout.c
(moved there from experiments/ once it became a build gate — poe test-sqlite
now runs it for the host triple before any Mojo test):
| Target | Result |
|---|---|
x86_64-linux-gnu |
pass |
aarch64-linux-gnu |
pass |
arm64-apple-macos |
pass |
aarch64-unknown-linux-musl |
pass |
i386-linux-gnu |
fails, as intended |
Every offset is identical across the three 64-bit targets — sqlite3_module
slots, the sqlite3_index_info word indices, the 12-byte
sqlite3_index_constraint, the 8-byte sqlite3_index_constraint_usage, and the
one-word sqlite3_vtab_cursor. That is the expected outcome (all the fields are
naturally aligned and LP64 is LP64), but it is the kind of thing this repo has
been burned assuming: lightbug_http/c/epoll.mojo:48-70 documents a layout that
was right on x86-64 and silently corrupted every event on aarch64.
Two controls establish the check is not vacuous. The i386 build fails on the
pointer-width and module-slot assertions, and long double measures 16 bytes on
x86_64-linux-gnu but 8 on arm64-apple-macos — so clang really is applying
per-target ABI rules during semantic analysis, not reusing the host's.
The Mojo side also cross-compiles: mojo build --target-triple aarch64-unknown-linux-gnu --emit object on spike_s2.mojo produces a valid
aarch64 ELF object.
Still unverified
- Execution on aarch64. No qemu-user and no aarch64 runner here, so the
layout is proven but the runtime is not. Specifically untested: whether Mojo's
abi("C")lowering is correct under AAPCS, and how the vtab interacts with macOS's system SQLite (which already diverges from Linux's onsqlite3_steppastSQLITE_DONE— seestmt.mojo). The related open Mojo bug modular/modular#6567 is SysV-specific and concerns by-value aggregates; every callback here passes only pointers and ints, so it should not apply, but the AAPCS equivalent has not been exercised. - macOS's own
sqlite3.h. Thearm64-apple-macoscheck compiled against the Linux SQLite 3.45.1 headers installed here, since no macOS SDK is present. It validates the ABI rules, not Apple's header text.
Scope note
m0-sqlite advertises itself as "a thin, honest layer over the SQLite C API —
no ORM, no query builder, no connection pool" (src/__init__.mojo:1-6). A vtab
module would be the largest piece of native machinery in the repo. For
comparison, a statement cache measured at ~10% and was deliberately not built
(README.md:152-154). A 2.96x ingest win is well clear of that bar; a 1.2x
IN-clause win is not.