Repository navigation
perf(recall): one prepare_context issues 178 SQL statements; batch the Atomic Memory authority filter and pin a statement ceiling #2075
Description
Summary
Measured on master at b388a18, one prepare_context call over a single Scope issues 178 SQL
statements once 16 Atomic Memories exist, and 94% of them are the same query shape re-issued once
per candidate. The batched repository primitives already exist and are already used earlier in the
same request; only the authority filter stage is per-item.
I am not proposing any DDL change. The 66 declared tables are contracted by tests (see "Why the table
count is not the lever"). The read amplification per request is not.
On embedded SQLite this currently costs ~36 ms and is therefore invisible; on a networked backend the
same 178 statements are 178 round trips, which is the budget application.py:362-364 is already
trying to leave room for. Numbers below.
Measurements
Setup, no confounders: Python 3.12, pip install -e ".[builtin,server]", in-memory SQLite
(SQLiteConfig()), no embedding model configured, no reranker configured, one Scope, seeded
with runtime.atomic_memory.for_scope(scope).create(...), then one
runtime.context.for_scope(scope).prepare(PrepareContextRequest(query="contract gateway")). Seeding
statements are excluded. Counting uses event.listen(Engine, "before_cursor_execute", ...) — the same
harness as tests/e2e/test_sqlite_vec.py:59 and tests/builtin/runtime/test_statistics.py:556.
| memories | statements | distinct tables | prepared.content_bytes |
|---|---|---|---|
| 1 | 53 | 16 | 594 |
| 2 | 64 | 16 | 824 |
| 4 | 86 | 16 | 1284 |
| 8 | 130 | 16 | 2204 |
| 16 | 178 | 16 | 2206 |
| 24 | 178 | 16 | 2209 |
| 40 | 178 | 16 | 2209 |
Two things follow from the shape of that curve:
- Marginal cost is ~11 statements per additional memory up to 8, ~6 from 8 to 16, then 0 — the
candidate pool ceiling (memory_candidate_limit = 16,runtime/prepared_context.py:147). - From 8 memories onward the output stops growing (2204 → 2209 bytes) while the statement count
keeps climbing to 178. Those additional reads do not change what the caller receives.
Statement shapes at 16 memories: 178 statements, 25 distinct shapes, 168 of 178 inside a shape issued
more than once.
| count | shape |
|---|---|
| 25x | SELECT pc_artifacts.scope_id, … pc_artifacts.content, … (whole payload, one row at a time) |
| 24x | SELECT pc_artifact_lineage_sources.… WHERE … artifact_id = ? |
| 24x | SELECT pc_artifact_lineage_artifacts.… |
| 24x | SELECT pc_artifact_publications.… |
| 17x | SELECT pc_artifact_heads.<whole row> |
| 16x | SELECT pc_artifact_heads.revision WHERE … artifact_id = ? |
| 8x | SELECT pc_sources.… (one row at a time) |
| 6x each | pc_scopes, pc_scope_context_references, pc_scope_external_references |
| 2x | SELECT pc_artifact_heads.artifact_id … LIMIT ? — executed, rows discarded |
| 3x / 3x | SAVEPOINT sa_savepoint_1 / RELEASE SAVEPOINT sa_savepoint_1 |
Why this is a backend-portability problem rather than a today-is-slow problem
Same path, same 178 statements, measured at 12 repetitions after 3 warm-ups:
| per-statement cost | backend model | prepare wall time (mean) |
|---|---|---|
| 0 ms, in-memory | embedded SQLite (default) | 36.5 ms (SQL execution inside it: 9.8 ms) |
| 0 ms, file-backed | local WAL SQLite | 33.3 ms |
| 0.5 ms | a nearby networked engine | 279 ms |
| 2 ms | a networked engine | 665 ms |
The per-statement delay column is an injected model, not a measured OceanBase or seekdb run — I do not
have those numbers, and I am labeling it accordingly. What is measured is the shape: local cost is
concentrated in statement count rather than in execution or disk IO (9.8 ms of 36.5 ms, and file-backed
is not slower than in-memory). A backend where each statement is a round trip converts that statement
count almost 1:1 into wall time.
That matters because the project already budgets for it in the opposite direction: application.py:362
comments "Leave room for database reads and assembly within the default one-second Hook request" and
then sets _CONTEXT_MEMORY_EMBEDDING_TIMEOUT_SECONDS = 0.25. With a remote engine at 2 ms per statement,
the reads alone consume ~356 ms of that one second before assembly starts. seekdb and OceanBase are
supported backends, and #2031 is provisioning dedicated databases for them, so this is the intended
deployment and not a hypothetical.
Cause
_filter_current_candidates(builtin/runtime/atomic_memory.py:673) loops the candidate tuple
(:694) and calls_current_record(:383) per item, which issues single-itemartifacts.latest(...)
(persistence/artifacts.py:461) plusservice.states.get(...). That is the 25x/24x/24x/24x/17x/16x
above.- The batched equivalents already exist and already run on the same request:
_validate_search_hits
(atomic_memory.py:551) callsartifacts.get_many(:605;persistence/artifacts.py:414, whose own
docstring reads "Load exact Revisions and ordered lineage with bounded batch queries") and
_load_lineage_many(artifacts.py:706). So this is wiring an existing owner into a second stage, not
new machinery. - The whole filter runs twice per request whenever a reranker is configured: once from
application.py:1171(_available_atomic_candidates) and again fromatomic_memory.py:729inside
_rerank. No reranker was configured in my runs, so 178 is the floor, not the worst case. - The snapshot pin executes and discards its rows:
await connection.execute(select(ARTIFACT_HEADS_TABLE.c.artifact_id).where(scope_id).limit(1))at
atomic_memory.py:688-692; same pattern at:446-450andruntime/atomic_memory_processing.py:601. - Scope resolution re-reads the same three Scope tables 6 times each within one request
(scope/repository.py:134-182).runtime/_scope_cache.pyholds leases, locks and counts only — it does
not cache descriptors.
Proposal
- Batch the authority filter over the candidate refs, mirroring
_validate_search_hits. This should
collapse the 25x/24x/24x/24x groups to one statement each.
On the constraint RFC 1803 already states for this ("Batch content completion adds a read and must use
the same versions and consistent state as retrieval",docs/en/rfcs/1803-artifact-search-projections.md
Drawbacks): the filter already runs insideapplication.database.transaction(consistent_snapshot=True)
(atomic_memory.py:685-686), so the batched version can reuse that same connection and that same pin.
The batched call would read exacthit.artifact_refrevisions, and the existing per-item equality
checks (current.ref == candidate.hit.artifact_ref,state_versionmatch,ACTIVE) would be applied
to the batched rows unchanged. I read this as a mechanical translation, but it is your call whether the
invariant should be restated rather than assumed. - Make the second authorization pass one batched re-check instead of 6 × candidates. The comment at
atomic_memory.py:728— "Reauthorize the exact candidate bodies immediately before an external rank
model" — is a real security boundary, so I am not proposing to delete the second pass, only to
batch it. I would want a maintainer view on whether the per-item form buys an ordering guarantee that
a batched form would lose; if it does, this item is dropped. - Drop the discarded pin selects, or replace them with a snapshot handle the transaction helper can
already expose, if one exists. - Add a statement-count ceiling for
prepare_context. Nothing pins this today, so the number can only
grow. Precedents in-repo:tests/builtin/runtime/test_statistics.py:553asserts each budgeted table is
read exactly once regardless of Scope count, with the docstring "A selection must not cost one query per
Scope; see issue bug(server): POST /v1/stats latency degrades sharply under low concurrency on file-backed SQLite #1599"; andtests/e2e/test_sqlite_vec.py:117asserts< 40.
No new table, no column, no migration, no new HTTP/MCP operation, no public contract change.
Why the table count is not the lever
The repo declares 66 tables (52 of them in builtin/persistence/tables.py, listed as
BUILTIN_TABLES:1334). Reducing that number is deliberately blocked:
tests/builtin/persistence/test_mysql_schema.py:152-169parses the--table '…'lists out of
docs/en/docs/operate/troubleshoot.mdanddocs/zh/…, requires en == zh, requires 4 restore layers,
requires every FK parent in a strictly earlier layer, and asserts
set(restored) == {t.name for t in BUILTIN_TABLES}. Renaming, merging or dropping one table fails on
both the code and the docs side.:171-189pinsmax(index key budget) == 2640; merging tables widens composite keys toward that ceiling.- The apparent "head + version" duplication is not duplication:
pc_artifact_headscarries a lifecycle
CHECK machine (tables.py:250-266),pc_candidate_headsa discriminated-union CHECK (:434-450), and
the Topic Memory projections are reconciled against their publications at startup, raising
TopicMemoryStorageInvariantErroron drift (persistence/topic_memory.py:105-215). - The 4 extra SQLite tables are engine-required FTS5/vector helpers; OceanBase expresses the same thing as
columns and indexes on the shared tables.
The genuine storage-side duplication is real but already adjudicated: one body is stored as
pc_artifacts.content (:199), then pc_artifact_heads.searchable_text (:234), then mirrored into FTS;
Topic Memory stores a body three times; RFC 1803's Drawbacks already concede "Wide tables may duplicate
content". Not proposed here — listed so it is visible that I looked and found it already decided.
What I did not measure
- No MySQL/OceanBase or seekdb run. The 0.5 ms and 2 ms rows are injected per-statement delays, not
observed latencies, and the 1:1 round-trip argument is reasoning on top of a measured statement count. - The reranker-on path (proposal 2),
mode="vector",tag_filter, multi-Scopeprepare, and cold caches. - Token counts, which are orthogonal and owned by feat: reduce tokens in memory extraction and context delivery #1989 / docs(rfc): propose input and output gates for token optimization #1990.
Related
#1599 (fixed doctrine, statistics path) · #1631 (merged N+1 fix, SQLite vector completeness) · #1864
(rejects "just make prepare_context cheaper" as a decision-cost optimization — this issue does not
disagree; it is about statements per admitted entry, and batching is the cheapest way to serve that same
manifest) · #1866 (its rejected alternative is "do not scan inside recall, recall is the hot path" —
agreement, not conflict) · #2039 / #2040 (timeout and token chain, no overlap; the budget arithmetic above
is offered as context, not as a claim about their findings) · #1937 / #2005 (optional rerankers, which would
make proposal 2 load-bearing) · #2031 (dedicated seekdb/OceanBase databases, which is the deployment where
the round-trip cost lands).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
test(recall): pin the prepare_context statement ceiling (#2087)
Inference1 commentedon Oct 20, 2026
Update: the count moved again, and embedded seekdb does not pay the round-trip cost
Two things I can measure now that I could not when this was written.
1. The recall path got much cheaper, so the number in the summary is stale. #2060 and #2095 together
took one
prepare_contextfrom 178 statements to 40 (SQLite, 40 Atomic Memories; 19 for a single-entryScope). A 41-statement ceiling now pins it (
tests/builtin/runtime/test_statistics.py, added in #2087 andtightened to the current measurement in #2133). The 178 → 47 → 40 sequence is all CI-measured with the
same
before_cursor_executecounter used here.2. Embedded seekdb does not make each statement a round trip. Running the identical counter on the
embedded profile (
pylibseekdb 1.4.0.post1,SeekDBConfig), one Scope per corpus size:Repeat at 40 memories, 5 runs: median 18.9 ms, min 17.6, max 19.6. That is roughly 0.4 ms per statement,
and it is faster than the in-memory SQLite path (40 statements, ~33 ms).
So the sentence "on a networked backend the same statements are round trips" is not supported by what I
can actually run. Embedded seekdb is local. The argument may still hold for a remote OceanBase server,
where each statement really is a network round trip — but I have not measured that, and I should not have
presented it as the reason this matters.
What survives the correction is backend-independent: the statement counts themselves are a stable,
comparable metric (40 SQLite / 44 seekdb at saturation, flat in corpus size), and the per-reference growth
described in #2088 — 17 statements per artifact evidence reference, 550 at the 32-reference bound — is
still there and is not a backend artifact.
If the direction is worth pursuing, the measured justification is now "fewer statements on a path that
runs per item", not "this is slow today".