Skip to content

Read a full scan's vectors through one blob handle per chunk - #1

Merged
mceachen merged 1 commit into
photostructure:mainfrom
MayCXC:fullscan-chunk-reads-photostructure
Oct 23, 2026
Merged

mceachen merged 1 commit into
photostructure:mainfrom
MayCXC:fullscan-chunk-reads-photostructure

Conversation

@MayCXC

@MayCXC MayCXC commented Oct 22, 2026 •

Copy link
Copy Markdown

A vec0 full scan reads each row's vector through vec0_get_vector_data(), which looks up the row's chunk, opens the chunk's vectors blob, reads one vector and closes the blob again. A chunk's blob holds chunk_size vectors (3 MiB for 1024 float[768]), and each sqlite3_blob_open() seeks through the blob's overflow pages to reach the row's offset, so one read costs more the larger the chunk, and the opens dominate the scan.

On a 10,000-row float[768] table in a database file:

select sum(length(embedding)) from v;                  -- 844 ms
select sum(vec_distance_cosine(embedding, ?)) from v;  -- 807 ms

The scan already visits rows in (chunk_id, chunk_offset) order. Its rowid statement now also returns each row's chunk_id and chunk_offset, and the cursor keeps one blob handle per vector column, moved to the next chunk with sqlite3_blob_reopen(). A write that replaces the chunk's row expires the handle (sqlite3_blob_read() answers SQLITE_ABORT); the read is then retried once on a newly opened handle, so the scan reads the chunk as it is now. With this change the two queries take 11 ms and 30 ms. Measured again on an idle machine (medians of 3 interleaved runs of 7 queries), they take 10.2 ms instead of 778.9 ms and 28.4 ms instead of 772.0 ms; in memory, 3.5 ms instead of 95.8 ms and 23.7 ms instead of 111.6 ms.

tests/test-fullscan.py checks every row's vectors against point lookups across chunks with deleted rows and two vector columns, and reads a chunk whose shadow row is rewritten in the middle of a scan. Both pass on main too, since the results are unchanged; with the retry removed, the second fails on the first row read after the rewrite. The full suite and make test-asan pass. The commit adds a ### Changed entry under [Unreleased] in CHANGELOG.md.

The same change is open on vlasky/sqlite-vec as vlasky#17 and upstream as asg017#336, where it reads flat columns by chunk and leaves rescore, IVF and DiskANN columns, which keep their vectors in tables of their own, to be read row by row.

…e per chunk

A vec0 full scan read each row's vector through vec0_get_vector_data(),
which looks up the row's chunk, opens the chunk's vectors blob, reads
one vector and closes the blob again. A chunk's blob holds chunk_size
vectors (3 MiB for 1024 float[768]), and each open seeks through its
overflow pages to reach the row's offset, so the cost of one read grew
with the chunk and dominated the scan: selecting every vector of a
10,000-row float[768] table took 844 ms, and computing
vec_distance_cosine() against each 807 ms.

The scan already visits rows in (chunk_id, chunk_offset) order. Its
rowid statement now also returns each row's chunk_id and chunk_offset,
and the cursor keeps one blob handle per vector column, moved to the
next chunk with sqlite3_blob_reopen(). A write that replaces the
chunk's row expires the handle; the read is then retried once on a
newly opened handle, so the scan reads the chunk as it is now.

Measured on the same table: selecting every vector takes 11 ms, and the
vec_distance_cosine() scan 30 ms. tests/test-fullscan.py checks every
row's vectors against point lookups across chunks with gaps and two
vector columns, and reads a chunk rewritten in the middle of a scan;
with the retry removed, that test fails on the first row read after the
rewrite.
mceachen added a commit that referenced this pull request Oct 23, 2026
…eading it

#1 took each row's chunk position from the scan's sorted rowid statement, so
a delete, insert or optimize on the same connection during the scan returned
a zeroed vector, another row's vector, or an error. It also replaced the blob
handle after any failed sqlite3_blob_reopen(), which dropped an interrupt, and
did not recognize SQLITE_ABORT_ROLLBACK with extended result codes on.
@mceachen
mceachen merged commit 76d1c0f into photostructure:main Oct 23, 2026
@mceachen

Copy link
Copy Markdown
Member

Merged, thanks. Review turned up three problems, fixed in f793711:

  • The scan's chunk_id and chunk_offset come from the sort SQLite does when the scan starts: v_rowids has no index on them, so EXPLAIN QUERY PLAN shows USE TEMP B-TREE FOR ORDER BY. A delete, insert or optimize on the same connection during the scan then read stale positions. A deleted row came back with a zeroed vector, a row whose slot was reused came back with the new row's vector, and optimize failed with opening blob failed. The fix looks up each row's position with vec0_get_chunk_position(), which cost about 4 ms on the 10,000-row float[768] scan.
  • A failed sqlite3_blob_reopen() was retried on a new handle, which dropped an interrupt raised by a progress handler. Only SQLITE_ABORT is retried now.
  • With extended result codes on, a rollback that changed the schema makes sqlite3_blob_read() and sqlite3_blob_reopen() return SQLITE_ABORT_ROLLBACK, which rc == SQLITE_ABORT misses. The checks now compare rc & 0xff.

vlasky#17 and asg017#336 have the same code.

MayCXC added a commit to MayCXC/sqlite-vec that referenced this pull request Oct 23, 2026
A vec0 full scan read each row's vector through vec0_get_vector_data(),
which looks up the row's chunk, opens the chunk's vectors blob, reads one
vector and closes the blob again. A chunk's blob holds chunk_size vectors
(3 MiB for 1024 float[768]), and the first read through a newly opened
handle walks the blob's overflow pages up to the row's offset, so the opens
dominated the scan.

The scan already visits rows in (chunk_id, chunk_offset) order, and the
cursor now keeps one blob handle per vector column, moved to the next chunk
with sqlite3_blob_reopen(). Each row's chunk position is looked up when its
vector is read: the scan's rowid statement sorts every row when the scan
starts, so its positions would miss a delete, insert or optimize the same
connection runs during the scan. A write that replaces the chunk's row
expires the handle, and a rollback of a transaction that changed the schema
invalidates it (SQLITE_ABORT, or SQLITE_ABORT_ROLLBACK with extended result
codes on); the read is then retried once on a newly opened handle. Any other
failure to move the handle, such as an interrupt, fails the read.

The position lookup and that failure handling follow
photostructure/sqlite-vec@f793711 by Matthew McEachen, which corrected this
change after it merged there as photostructure#1.

On a 10,000-row float[768] table (medians of 3 interleaved runs of 7
queries), selecting every vector takes 11.7 ms instead of 760.8 ms in a
database file and 5.5 ms instead of 88.1 ms in memory, and
vec_distance_cosine() against each 32.2 ms instead of 777.1 ms and 24.6 ms
instead of 110.6 ms. tests/test-fullscan.py checks every row's vectors
against point lookups across chunks with gaps and two vector columns, reads
a chunk rewritten in the middle of a scan, fails on a row deleted during the
scan instead of returning another row's vector, reads rows that optimize
moves during the scan, and fails a scan cancelled while its handle moves to
the next chunk; tests/test-unit.c scans across a rollback that changed the
schema, with extended result codes off and on.
MayCXC added a commit to MayCXC/sqlite-vec that referenced this pull request Oct 23, 2026
A vec0 full scan read each row's vector through vec0_get_vector_data(),
which for a flat column looks up the row's chunk, opens the chunk's vectors
blob, reads one vector and closes the blob again. A chunk's blob holds
chunk_size vectors (3 MiB for 1024 float[768]), and the first read through a
newly opened handle walks the blob's overflow pages up to the row's offset,
so the opens dominated the scan.

The scan already visits rows in (chunk_id, chunk_offset) order, and for a
flat column the cursor now keeps one blob handle per vector column, moved to
the next chunk with sqlite3_blob_reopen(). Each row's chunk position is
looked up when its vector is read: the scan's rowid statement sorts every
row when the scan starts, so its positions would miss a delete or insert the
same connection runs during the scan. A write that replaces the chunk's row
expires the handle, and a rollback of a transaction that changed the schema
invalidates it (SQLITE_ABORT, or SQLITE_ABORT_ROLLBACK with extended result
codes on); the read is then retried once on a newly opened handle. Any other
failure to move the handle, such as an interrupt, fails the read. Rescore,
IVF and DiskANN columns keep their vectors in tables of their own and are
read row by row as before.

The position lookup and that failure handling follow
photostructure/sqlite-vec@f793711 by Matthew McEachen, which corrected this
change after it merged there as photostructure#1.

On a 10,000-row float[768] table (medians of 3 interleaved runs of 7
queries), selecting every vector takes 12.6 ms instead of 762.4 ms in a
database file and 5.4 ms instead of 104.7 ms in memory, and
vec_distance_cosine() against each 33.8 ms instead of 853.5 ms and 24.5 ms
instead of 111.8 ms. tests/test-fullscan.py checks every row's vectors
against point lookups across chunks with gaps and two vector columns, scans
rescore and DiskANN columns beside a flat one, reads a chunk rewritten in
the middle of a scan, fails on a row deleted during the scan instead of
returning another row's vector, and fails a scan cancelled while its handle
moves to the next chunk; tests/test-unit.c scans across a rollback that
changed the schema, with extended result codes off and on.
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.

2 participants