Skip to content

perf(core): incremental session history cache + opt-in step GC knob [NOT READY FOR MERGE] - #51499

Closed
gaojunran wants to merge 3 commits into
anomalyco:v2from
gaojunran:demo/history-cache
Closed

gaojunran wants to merge 3 commits into
anomalyco:v2from
gaojunran:demo/history-cache

Conversation

@gaojunran

Copy link
Copy Markdown

This PR is a theoretical optimization demonstration and is NOT ready for merge.
It accompanies #51497 with concrete, measured code. Default behavior is unchanged (the GC knob is env-gated and off by default). The goal is feedback on direction, not review of details.

What this contains

Three commits:

  1. HistoryCache (L1 history cache) — process-local cache of decoded session messages, keyed by (sessionID, compaction boundary):
    • On a hit, read only rows appended since the last read (seq > watermark) and decode only those; merge with cached entries; replay the two pure in-memory filters (native-checkpoint retirement, replayability); advance the watermark.
    • Full rebuild on cache miss, on a newly appended compaction row (the boundary moved), and on invalidation by any non-append write: projector UPDATE of an already-read row, revert-committed DELETE, transfer import.
    • entriesForRunner (the per-request hot path) opts in; load/preview keep the uncached path.
  2. OPENCODE_GC_ON_STEP=N (default off) — Bun.gc(true) every Nth step at the runner-loop boundary, right after runStep returns, where step transients (decoded history, converted messages) are unreachable.
  3. Ad-hoc verification scripts (not wired into CI): test-history-cache.ts (10-scenario correctness suite) and bench-history.ts (tiered microbenchmark over a copy of a real session DB).

Design notes

  • Boundary keying: the compaction boundary is part of message provenance. Entries are stored per (session, boundary) so a boundary change can never serve a stale range.
  • Invalidation: the projector is the only steady-state writer of SessionMessageTable. Non-append writes (UPDATE of a settled row, revert DELETE) drop the session's cache; transfer import (direct INSERT outside the projector) invalidates as well; the one-time v1 migration runs before any cache exists.
  • Aliasing invariant (documented in history-cache.ts): the runner converts history through toLLMMessages before plugin hooks see messages, and that conversion constructs fresh objects per message, so cached SessionMessage.Info values never flow into mutable request state. The v2 plugin session API has no message-level rewrite; the request hooks (context/compaction/generate/title) mutate converted copies in place. A future conversion cache (L2) would additionally need hooks.has() gating — deliberately out of scope here.

Verification

  • Correctness suite: 10/10 scenarios pass — first load builds the cache; a cache hit returns exactly what a full load returns; incremental merge after new rows equals the full-load ground truth byte-for-byte; a rewrite below the watermark invalidates and re-reads; a compaction rebuilds under the new boundary; repeated hits are stable. Executed against a v2.0.10-produced 103-row session DB on the current v2 tip.
  • Typecheck clean (tsgo -b tsconfig.json tsconfig.tests.json after bun install --frozen-lockfile).
  • Behavioral equivalence gates: mock-provider request sequences are identical between baseline and patched binaries across all 32 benchmark runs (100-step scripted sessions), so the cache is behavior-preserving by construction.

Measurements

All numbers from self-compiled v2.0.10 baseline vs patched builds with identical build flags (OPENCODE_CHANNEL=latest, --single, --skip-web-ui), mock provider, 100 scripted steps, per-run sandbox wipe, pairwise interleaved A/B rounds. Harness: konghayao/harness-perf-benchmark.

Per-row cost of the current full reload (microbenchmark)

Stage Per row
drizzle SELECT + row parse 10.0 µs
Effect Schema decode 16.0 µs
toLLMMessages conversion 8.3 µs
JSON.stringify 4.4 µs

The cache eliminates the first two (~70% of opencode's own chain) for all but the newest rows.

Heavy scenario, 4 MB history (20 runs)

Variant CPU (core·s) RSS mean (MB) RSS peak (MB)
baseline 27.7 ± 1.5 775 ± 62 1105 ± 65
L1 27.0 (-2.7%) 781 ± 9 (+0.8%) 1107 ± 24 (+0.2%)
L1 + GC(8) 31.3 (+12.7%) 614 ± 28 (-20.8%) 994 ± 73 (-10.0%)
L1 + GC(8) + heap cap 36.2 (+30.7%) 525 ± 6 (-32.3%) 695 ± 46 (-37.1%)
L1 + GC(4) + heap cap 37.0 (+33.4%) 508 ± 5 (-34.5%) 604 ± 7 (-45.3%)

(GC(N) = OPENCODE_GC_ON_STEP=N; heap cap = BUN_JSC_forceRAMSize=536870912. A dedicated 8-run batch measured L1 alone at CPU -8.5% / wall -6.1%, 4/4 pairwise wins — the CPU win magnitude varies with machine load, the direction does not.)

Standard scenario, 400 KB history (12 runs)

Variant CPU (core·s) RSS mean (MB) RSS peak (MB)
baseline 6.3 ± 0.5 409 ± 8 683 ± 53
L1 6.0 (-4.0%) 398 ± 8 (-2.7%) 643 ± 59 (-5.8%)
L1 + GC(8) + heap cap 7.8 (+24%, +1.5 core·s) 367 ± 3 (-10.2%) 527 ± 14 (-22.7%)

Peak RSS variance collapses from σ≈53 MB to σ≈14 MB with the GC knob on — memory behavior becomes deterministic instead of GC-timing-dependent.

Mechanism notes

  • Mean and peak are controlled by different levers: the throttled GC pins the steady-state watermark (mean), the heap cap absorbs spikes (peak). GC(8) without the heap cap only reaches -10% peak; with the cap, -37%.
  • Live heap is unaffected: paired SIGUSR1 heap snapshots at the same point in matched runs differ by ~4 MB (dominated by in-flight SSE buffers). The high-water differences are garbage-accumulation timing, not retained data.
  • Why L1 alone does not lower RSS: JSC's GC is allocation-triggered. Removing the per-step decode churn (~2x history size per call) also removes the allocation that was implicitly pacing collections, so the arena high-water can stay flat or rise even though live data is unchanged. L1 is a CPU optimization; the GC knob is what makes memory deterministic, at a measured cost (~360 ms per full collection on a dirty heap; 12 forced collections at N=8 ≈ +4.3 core·s).

Honest tradeoffs / why this is not ready

  • The cache is a CPU win with flat-to-slightly-up RSS under Bun/JSC. It is not a standalone memory fix; it composes with the (opt-in) GC knob.
  • Cache lifetime is currently unbounded (per-session map). Needs an LRU/eviction bound and a cross-session cap — a policy decision for maintainers.
  • Validation is synthetic (mock provider, scripted steps). Long real sessions are the actual target; the harness underestimates provider-side effects and overestimates some others.
  • The GC knob's surface (env var name, semantics, whether it belongs in core at all) is up for discussion. It is default-off and can be dropped entirely without touching the cache.
  • Developed and measured on v2.0.10, rebased onto the current v2 tip with identical logic; the correctness suite passes on the tip.

Happy to split this into cache-only (commits 1+3) if the knob is out of scope for core.

@github-actions

Copy link
Copy Markdown
Contributor

Hey! Your PR title perf(core): incremental session history cache + opt-in step GC knob [NOT READY FOR MERGE] doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Sep 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

@dhaern

dhaern commented Oct 30, 2026

Copy link
Copy Markdown

Measured on v2.2.2 with real sessions; table and script in #51497 (comment).

With 32,153 rows since the checkpoint, one per-step chain takes 3.07 s: SessionHistory.load 1.88 s, toLLMMessages 0.39 s and JSON.stringify 0.80 s. On a hit the cache replaces the 1.88 s with a read of the rows appended since the previous call. Conversion and serialization still run over the full history. For memory planning, that session's decoded history is 186 MB as JSON, which is roughly what the cache would keep for it.

The branch no longer applies to v2.2.2: cherry-picking 86ee410 conflicts in history.ts and projector.ts.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants