Skip to content

perf(atomic-memory): bound related recall candidates in SQL to reduce peak memory and recall cost - #2449

Open
lizining1231 wants to merge 10 commits into
oceanbase:masterfrom
lizining1231:perf/atomic-memory-related-recall-candidate-cap
Open

lizining1231 wants to merge 10 commits into
oceanbase:masterfrom
lizining1231:perf/atomic-memory-related-recall-candidate-cap

Conversation

@lizining1231

@lizining1231 lizining1231 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes: #2448

Motivation

Related recall previously enumerated everything: the SQL had no LIMIT, tuple(mappings()) materialized every row at once, each hit was authorized twice, and heads retained the full body for every match. Peak memory grew linearly with the number of memories in the Scope—at 50,000 memories it reached 606,764 KB, while the process baseline was only about 15 MiB.

This PR adds a per-channel limit in SQL while preserving the completeness contract: invalid vectors ranked outside K are still reported.

What changes are included in this PR?

  • src/powercontext/builtin/persistence/atomic_memory_index.py:
    • Add AtomicMemoryRelatedRequest.max_candidates: int | None;
    • Add atomic_memory_channel_limit(): when the two channels may be reordered by RRF, fetch K + 60 per channel; single-channel recall fetches exactly K;
    • Add trim_atomic_memory_related(): after fusion, trim hits and the corresponding heads to K;
    • Extract atomic_memory_heads_select() / atomic_memory_heads_from_rows() so both backends share the same head/body join.
  • sqlite/atomic_memory_index.py: _related_limit_sql() appends ORDER BY score DESC, artifact_id LIMIT :result_limit; the vector channel switches to bounded=True; the readiness sentinel still covers all eligible rows, so invalid vectors outside K still trigger incomplete-vector.
  • oceanbase/atomic_memory_index.py: _related_bounded() builds bounded FTS and vector channels within a single snapshot and includes a whole-Scope readiness check; every UNION branch is parenthesized, otherwise the per-arm ORDER BY ... LIMIT is invalid.
  • runtime/config.py and runtime/atomic_memory_processing.py: expose atomic_memory_related_max_candidates (default 100, range 1..1000, rejects booleans) and wire it from RuntimeConfig into the request.
  • Tests: add tests/builtin/persistence/test_atomic_memory_candidate_cap_sql.py and tests/e2e/test_atomic_memory_candidate_cap.py, and extend tests/e2e/test_atomic_memory_related_embedding.py and tests/builtin/runtime/test_artifact_processing_config.py.
  • Compatibility: with max_candidates=None the request path is unchanged; no schema or API changes.

Are there any user-visible changes?

Yes. Related recall now returns at most the 100 most relevant memories by default, atomic_memory_related_max_candidates, default 100, range 1–1000, instead of everything in the Scope. Results are the bounded prefix of the full ordering, heads are trimmed to match, and invalid vectors outside K still raise incomplete-vector. Set the option to empty to restore complete enumeration. No API, schema, or request-format changes.

Performance comparison against baseline

Targeted tests: 85 passed, 8 skipped (SQLite, Python 3.12.16). The benchmark corpus uses identical vectors and max_distance=1.0, meaning every recall hits the entire Scope in the worst case; bodies are 2 KB; only the SQLite backend was verified, with max_candidates=60.

1. Peak memory across Scope sizes

Memory count Before (KB) After (KB) Change
1,000 15,100 5,536 ↑ -63.3%
5,000 63,432 5,560 ↑ -91.2%
10,000 123,704 5,564 ↑ -95.5%
50,000 606,764 5,564 ↑ -99.1%

After the change the curve is flat: 1k → 50k adds only 28 KB (+0.5%), compared with 591,664 KB before.
image

2. memray allocations

Metric Before After Change
Peak 124.7 MB 4.8 MB ↑ -96.1%
Allocations 563,386 349,512 ↑ -38.0%
Cumulative allocation 298,028,004 B 99,017,000 B ↑ -66.8%
image

aiosqlite/core.py:63 still accounts for 98.5 MB of cumulative allocations (99.5%), but the peak is only 4.8 MB—rows now pass through and are released instead of residing on the heap.

3. Single-recall memory curve

image

Before: resident memory rose by about 125 MB during recall, matching the 123,704 KB delta; after recall it stayed high because the allocator retained arenas.
After: resident memory rose by only about 5.5 MB, the heap dropped immediately after the call, and no plateau remained above the process baseline.

4. py-spy CPU diff

Metric Before After Change
User time 8.87 s 3.97 s ↑ -55.2%
System time 1.31 s 0.41 s ↑ -68.7%
Wall clock 9.29 s 3.33 s ↑ -64.2%
Maximum RSS 759,864 KB 157,776 KB ↑ -79.2%
Recall throughput 0.15 recalls/s 2.05 recalls/s ↑ 14.0×
image

The header reads 27 samples / 8.54% , versus 30.95% for the same line before the change. The hotspot is in the same place, but its share has clearly shrunk.

Correctness coverage

  1. test_oceanbase_bounded_related_recall_limits_both_channels_and_audits_readiness: bounded hybrid SQL limits both channels and keeps the whole-Scope readiness audit and sentinel arm
  2. test_oceanbase_unbounded_related_recall_keeps_the_complete_enumeration_sql: with max_candidates=None the SQL has no LIMIT or readiness, preserving complete enumeration
  3. test_oceanbase_bounded_fts_recall_skips_readiness_audit: single-channel FTS fetches exactly K rows without a vector readiness audit
  4. test_oceanbase_bounded_related_recall_validates_the_limit: 0, -1, 1001, booleans, floats, and strings are rejected consistently
  5. test_oceanbase_bounded_related_recall_binds_every_named_parameter: every named placeholder in the bounded SQL has a bound parameter
  6. test_oceanbase_union_arms_declare_matching_column_widths: all three UNION arms declare the same column count, catching hand-written SQL drift
  7. test_oceanbase_bounded_related_recall_rejects_incomplete_vector_rows: an invalid vector row on the bounded path still raises incomplete-vector
  8. test_sqlite_related_candidate_cap_bounds_each_mode_and_keeps_completeness: capped results equal the complete prefix for fts/vector/hybrid, with heads trimmed in step
  9. test_sqlite_related_candidate_cap_applies_scope_and_content_filters_before_the_limit: Scope, kind, and tag filters apply before LIMIT
  10. test_sqlite_hybrid_overfetch_keeps_candidates_outside_the_per_channel_cap: a candidate ranked K+1 in both channels still enters top-K after RRF fusion
  11. test_sqlite_related_candidate_cap_limits_rows_inside_sql: returned row counts are bounded by SQL LIMIT, with single channels capped at K and hybrid at the two-channel window
  12. test_source_flush_applies_the_related_candidate_limit_from_runtime_configuration: the RuntimeConfig candidate cap flows through the full pipeline and trims the recall result
  13. test_artifact_processing_configuration_defaults_match_the_rfc: the default is None, preserving complete enumeration
  14. test_artifact_processing_configuration_rejects_invalid_bounds: out-of-range values (0, 1001, booleans) are rejected
  15. test_atomic_memory_processing_config_validates_the_candidate_limit: the processing config accepts 1..1000 and rejects invalid types and out-of-range values

AI usage statement

AI-assisted (Trae, DeepSeek-V4.1-Flash) implementation, tests, and web preview; the user performed the tests and performance analysis.

Reference Links

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bounded recall path introduces correctness regressions described inline. The required upgrade policy check also fails because the persistence compatibility review was not updated; the dependent CI suites were skipped. Please also run the real OceanBase acceptance required by #2448.

atomic_memory_related_mode: Literal["auto", "fts", "vector", "hybrid"] = "auto"
atomic_memory_related_max_distance: float = Field(default=1.0, ge=0)
atomic_memory_related_max_candidates: int | None = Field(
default=DEFAULT_ATOMIC_MEMORY_RELATED_MAX_CANDIDATES, ge=1, le=1000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve all threshold-qualified memories during reconciliation. With the new default, the 101st eligible memory is silently omitted even though the processor treats the bounded result as the complete comparison set. If that memory is the existing fact that should be revised or merged, Source processing can instead create a duplicate or retain conflicting facts. RFC 1809 explicitly requires every memory meeting the threshold to enter comparison and forbids a fixed top-N cutoff. Please bound memory through batching/streaming (or otherwise preserve the complete set) rather than silently dropping qualifying memories.


if limit is None:
return None
return limit + ATOMIC_MEMORY_RRF_CONSTANT if fused else limit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The K + 60 window does not guarantee the promised full-RRF prefix. For K=1, put a at rank 62 in both channels, b at FTS rank 1 only, and c at vector rank 1 only, with all remaining IDs distinct. Full fusion gives a and b the same score (2/122 = 1/61), so the existing ID tie-break selects a; limiting each channel to 61 removes a and selects b. I reproduced this with combine_atomic_memory_channels. Use an exact stopping condition or explicitly change the claimed ranking contract; a fixed RRF constant is not a sufficient overfetch bound.

query_vector=vector,
embedding_profile=profile,
max_distance=self.config.related_max_distance,
max_candidates=self.config.related_max_candidates,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply any candidate budget to authorized memories, not before per-Artifact authorization. The index trims to this limit before _load_related_items checks read and write access; its SQL filters do not encode arbitrary authorizer decisions. For example, if the first 100 ranked memories are denied write access and rank 101 is writable, the default path returns no comparison even though an eligible writable memory exists. This can cause a duplicate or conflicting memory to be published. Continue fetching within the same consistency boundary until the authorized comparison set is complete (or an explicitly supported authorized limit is met).

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pagination change removes the fixed cap, but I reproduced two new correctness regressions involving concurrent changes, described inline. The current CI run also has two candidate schema replay failures; the upgrade acceptance job was skipped and its gate failed.

while True:
async with (
deferred_decision_audit(context),
application.database.transaction(consistent_snapshot=True) as connection,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the complete recall stable across pages, or detect changes and retry. Each iteration starts a new snapshot but the FTS cursor contains the previous snapshot's BM25 score. I reproduced this on SQLite with two matching memories and page size 1: page 1 returns memory-001 at score 0.000002; inserting 100 unrelated memories in a different Scope changes the FTS corpus score to 7.3877, and page 2 returns no rows, silently skipping unchanged memory-002. The model can then create a duplicate or miss a required revision. Please preserve one stable candidate ordering/snapshot or reliably invalidate and restart the whole comparison when it changes.

if self.origins.get(identity) == resolved:
del self.origins[identity]
if self.reads.get(identity) == read:
del self.reads[identity]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Retain every compared memory's lightweight read dependency until commit. For an unchanged memory that the model compares but does not select as a target, this deletes the entry from workset.reads, which is the read set validated by commit_window. I reproduced a flush where the reconciler reads revision 1 and pauses, another writer publishes revision 2, and the reconciler returns create: the flush succeeds after only one comparison against revision 1 instead of retrying against revision 2. This can commit a decision based on stale evidence. Release the page body if needed, but retain and revalidate all compared identities and revisions at publication.

@lizining1231

Copy link
Copy Markdown
Contributor Author

thanks review! Detailed changes:

1. cross-page ranking drift

Paging changed from "re-rank every page plus keyset filtering on the previous snapshot" to "one complete lightweight ranking plus offset slicing":

  • AtomicMemoryRelatedCursor now carries only ranks and offset; rank rows hold identity, state, kind and score/distance, never bodies.
  • The first related_page builds the complete ranking; later pages slice by offset instead of recomputing BM25 or distance, so memories inserted in another Scope can no longer reorder or truncate them.
  • fts, vector and hybrid share this contract on SQLite and OceanBase; hybrid fuses both complete lightweight channels before paging.
  • Readiness and embedding completeness still cover every eligible row on the first call, independent of page size.
  • A ranked memory that leaves the index returns stale-recall instead of KeyError, so the window conflicts and retries.
  • Regression: tests/e2e/test_atomic_memory_related_pagination.py::test_fts_pages_survive_corpus_changes_in_other_scopes uses page size 1, inserts 100 unrelated memories in another Scope after page 1, and page 2 still returns memory-002.

2. lost read dependencies at commit

  • AtomicMemoryWindowWorkset.release drops only page-local bodies from items and retains reads; origins are cleared with the item.
  • commit_window now uses the complete read_set covering every compared identity, not just the current page.
  • A revision published after comparison raises AtomicMemoryConflictError; _retry_window retries, up to 3 attempts, then fails explicitly instead of committing stale evidence.
  • Regressions: tests/e2e/test_atomic_memory_related_embedding.py::test_released_page_memory_revision_prevents_stale_commit reads revision 1, publishes revision 2 externally, returns create, and asserts a conflict retry that sees revision 2 with no duplicate write; tests/builtin/artifacts/atomic_memory/test_reconciliation.py asserts reads survive release; persistence tests cover stale-recall.

@lizining1231

Copy link
Copy Markdown
Contributor Author

The CI failures are unrelated to this PR; however, I have addressed them in PR #2483

@lizining1231

Copy link
Copy Markdown
Contributor Author

Please don't merge this yet, I'm seeing some metric regressions after re-testing with the new approach, and I'm working on a fix

Metric Before This time page_size=100 Verdict
50k Peak RSS 606,764 KB 159,932 KB ↑ -73.6%
10k memray Peak 124.7 MB 33.6 MB ↑ -73.0%
10k Allocation Count 563,386 226,487 ↑ -59.8%
User 50k 8.87 s 8.63 s ≈ Flat
Sys 50k 1.31 s 3.77 s ↓ +188%
Wall 50k 9.29 s 10.48 s ↓ +12.8%
Cumulative Allocation 10k 284.2 MB 505.3 MB ↓ +77.8%

@hidb4ai

hidb4ai commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

I replayed the two previous concurrency counterexamples on b3bb7d0: the frozen ranking retained the second result after the other Scope changed, and the stale compared revision triggered a retry.

There is also a merge conflict with current master (2772b2ab) in release/upgrade-support.json; GitHub reports this PR as conflicting, so the full CI checks have not run for this head. Please reconcile the upgrade compatibility review and rerun the required checks when the performance work is ready. I saw your note about the measured regressions and will defer re-evaluating performance until the fix is available.

@lizining1231

Copy link
Copy Markdown
Contributor Author

Conflict and CI, I merged master

Performance:

The paged implementation kept the old default page size of 100, so 50k rows meant 500 transactions and system CPU rose to 3.77s. Setting the default to 250 brings that to 200.
now:

Metric Before page_size=100 page_size=250 Verdict
50k Peak RSS 606,764 KB 159,932 KB 159,678 KB ↑ -73.7%
10k memray Peak 124.7 MB 33.6 MB 33.9 MB ↑ -72.8%
10k Allocation Count 563,386 226,487 220,722 ↑ -60.8%
User 50k 8.87 s 8.63 s 5.15 s ↑ -42.0%
Sys 50k 1.31 s 3.77 s 0.69 s ↑ -47.3%
Wall 50k 9.29 s 10.48 s 4.81 s ↑ -48.2%
Cumulative Allocation 10k 284.2 MB 505.3 MB 485.8 MB ↓ +70.9%

All three CPU numbers are now better than before the change.

The one metric that got worse. Total bytes allocated went 284 MB → 486 MB. This counts every allocation, including ones freed immediately; it is not memory held at once. Held memory (peak) went 124.7 MB → 33.9 MB. The increase exists because the corpus is now read twice, once to rank and once to fetch bodies, instead of once.

I investigated the breakdown of the overhead for this specific metric.: 87% is inside SQLite doing FTS5 scoring and the exact vector scan, not in Python; the remaining Python-side savings are about 1.4%. Removing the second pass would mean dropping the fixed ranking snapshot, which is the fix that was requested.

I'm not sure if everyone is okay with accepting this regression in total allocated bytes in exchange for the improvements in other metrics.

@hidb4ai

hidb4ai commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

On 517d3a2, I rechecked the earlier concurrency regressions and ran the hybrid pagination and vector-readiness tests against a real OceanBase instance using disposable databases; those backend cases passed.

The merge conflict has recurred after master advanced to e0bc7f8: GitHub reports the PR as conflicting, and a merge-tree check identifies release/upgrade-support.json. The latest CI also failed test_candidate_replay_sqlite[rename] on Python 3.14 and skipped the required upgrade acceptance; I see the proposed test fix in #2483. Please reconcile the current base and rerun the required gates. The updated page-size benchmark is useful, though I have not independently rerun its performance measurements.

@hireview001 hireview001 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lizining1231

Copy link
Copy Markdown
Contributor Author

The CI is still failing; it might need #2504 to be merged first.

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@hireview001 hireview001 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Resolve merge conflicts with the target branch.
  • CI failed: CI result (failure).
  • CI failed: Upgrade release gate (failure).
  • CI failed: CI preflight (failure).
  • CI failed: Main (failure). Failed steps: CI preflight / Check License Header; Upgrade release gate / Require the selected parallel acceptance workflow; CI result / Require successful selected checks.

@knqiufan knqiufan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the bounded recall changes and prior correctness findings. The reported completeness, ranking, authorization, read-dependency and snapshot regressions are addressed at this head; focused SQLite regressions passed. This review does not constitute new real-backend acceptance.

@knqiufan

knqiufan commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

CI 仍然失败;可能需要先合并#2504 。

#2504 已合并,可 rebase 一下

@hidb4ai hidb4ai removed the 1.5.1 v1.5.1 release tracking label Nov 9, 2026
Bound related recall with a max_candidates limit pushed into eligibility,
over-fetch one RRF window per channel, and trim fused hits and heads to
the cap. SQLite and OceanBase enforce the cap in SQL; OceanBase uses one
snapshot with a readiness CTE and an explicit sentinel row. Runtime
configuration defaults to 100 candidates, and explicit null keeps complete
enumeration.

Closes: oceanbase#2448
Cover SQLite channel caps, filter-before-limit ordering, the hybrid
over-fetch window, OceanBase bindings and UNION widths, sentinel handling,
runtime configuration validation, and the flush-to-reconciler chain.

Closes: oceanbase#2448
Document that related recall is bounded to 100 candidates by default in
both locales.

Closes: oceanbase#2448
Take one complete lightweight ranking per related enumeration and slice it by
offset, so corpus changes in another Scope can no longer reorder or truncate
later pages. A ranked memory that leaves the index before its page is read
reports stale recall instead of raising a bare KeyError.
Release only page-local bodies from a workset and retain every compared read,
so an artifact revision published mid-window conflicts and retries instead of
committing against stale evidence.
Document the frozen lightweight ranking, what the page size bounds, and that a
revision published after ranking conflicts and retries the window.
The inherited 100 came from the retired candidate cap, where it bounded the
result set. Paging only batches bodies and authorization, so the same number
now multiplies a fixed per-page cost across a complete enumeration.

At 50k rows, 250 turns 500 page transactions into 200: Sys 3.77s -> 0.69s,
User 8.63s -> 5.15s, Wall 10.48s -> 4.81s, with the peak delta unchanged at
159.7 MB. 250 is where the gains stop (250/500/1000 stay within noise) and it
leaves headroom below the 1000 validation limit.
Related recall pages no longer issue a rank query when the admission floor
exceeds the query's distinct terms; whole-word coverage can never reach it.
Refresh the persistence review digest for the resulting tree.
@lizining1231
lizining1231 force-pushed the perf/atomic-memory-related-recall-candidate-cap branch from 9890d4f to ce7d18e Compare November 9, 2026 07:07
@lizining1231

Copy link
Copy Markdown
Contributor Author

Rebased onto upstream/master (PR #2449).

  • Three conflicts:

    • atomic_memory_processing.py: kept master's window threshold and redundant-revision recovery, wired into the PR's compare_pending

    • atomic_memory_index.py: kept master's probe_recoverable

    • upgrade-support.json: merged the compatibility rationale

  • Extra changes: _related_fts_rank_sql returns None when required > len(terms); recomputed persistence_sha256

@AlexStocks AlexStocks added the 1.6.0 v1.6.0 release tracking label Nov 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.6.0 v1.6.0 release tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(atomic-memory): related recall peak memory grows linearly under complete enumeration

5 participants