Skip to content

perf: avoid transform work that scales with compression history (#384) - #385

Merged
ranxianglei merged 3 commits into
masterfrom
2026-09-11_perf-transform-history
Sep 12, 2026
Merged

perf: avoid transform work that scales with compression history (#384)#385
ranxianglei merged 3 commits into
masterfrom
2026-09-11_perf-transform-history

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #384 — per-transform work scaled with total compression history instead of staying bounded by visible context + active blocks. Candidate planning at 1,000 messages measured ~13.6 s on master; after this change it is ~1.2 ms.

Root causes & fixes (all verified in code before changing anything)

# Root cause Fix
RC1 resolveBoundaryIds() rebuilt the global boundary lookup (O(entire ref history)) per candidate draft Request-scoped memo: SearchContext.boundaryLookup built once per compress call (buildSearchContext), lazy ??= fallback keeps hand-built contexts working
RC1b resolveSelection() ran the real Anthropic BPE tokenizer per message (~27 ms/KB measured) New estimateAllMessageTokensFast() = chars/4 (existing estimation convention); exact BPE kept where correctness requires it
RC2 T1 nudge analysis (context composition / protected refs / compressible ranges — each O(all messages) and stringifies tool outputs) ran every transform even when no nudge could fire Gate: heavy analysis only when nudgeAllowed || emergencyOverride || tierTriggerPossible; all downstream consumers null-guarded
RC3 syncCompressionBlocks() replayed ALL blocks (active + inactive) every transform; hideConsumedCompressCalls() rebuilt immutable consumed-call indexes every transform Transient structureVersion bumped at exactly the 3 block-mutation sites (applyCompressionState, mergeMarkedBlocks, deactivateCompressionTarget — full mutation-site audit done); sync skips full replay when unchanged, hide-consumed caches its derived index keyed by version
RC4 Fire-and-forget whole-file saves raced (stale-overwrite possible) and bursts wrote N times Ordered, coalescing per-session save queue: snapshot captured at enqueue, setImmediate drain, batch writes only the latest snapshot, FIFO across batches, failure isolation

Benchmark evidence

Same machine/harness (scripts/bench-candidate-planning.ts, new — workload: N visible messages with realistic tool outputs, H≈N/2 historical blocks, newest 20 active, full un-reclaimed ref history), median of 7 reps. Baseline = pristine master run in a throwaway worktree same day.

msgs  hist  A baseline(ms)  A fixed(ms)   B+C baseline  B+C fixed
 100    50       1608.62        0.40        0.10 ms      0.04 ms
 500   250       6798.66        1.09        0.58 ms      0.20 ms
1000   500      13618.01        1.16        0.76 ms      0.14 ms

A = candidate planning (buildSearchContext + resolveRanges × 10 drafts); B = steady-state sync; C = hide-consumed. Acceptance target ≤ 20 ms @ 1000 met with ~17× headroom. Absolute values differ from the issue's isolated probe (3.2/34/105 ms) because this harness models a denser workload; before/after are identical-harness comparisons.

Compatibility

  • Persisted-state format unchanged: all new fields (structureVersion, lastSyncedStructureVersion, hideConsumedIndex, boundaryLookup) are transient and excluded from serialization; first transform after load does one full replay (version defaults to 0) — identical behavior to pre-change code.
  • Candidate executor validation, protection semantics (Bug 39 hard-exclusion), decompression, fork recovery: untouched.

Tests

  • Full suite: 1151 tests, 0 failures (up from 1131). npm run typecheck clean, npm run build clean.
  • New: sync (+4 full-replay-vs-incremental equivalence, shared-anchor ordering, invalidation), hide-consumed (+3 cache reuse/invalidation identity), compress-search (+4 memoization, lazy backward-compat, fast-estimate exactness), token-counting (+3), persistence (+4 burst coalescing / no-stale-overwrite / EISDIR failure isolation / storageDir isolation), inject (+2 §5.7 multi-turn production-config growth cycle asserting both shouldInjectThisTurn AND baseline bookkeeping per turn + emergency-path preservation).

Review

Dual-agent review completed (AGENTS.md §5.3/§5.6): both reviewers APPROVE-WITH-NITS, zero blocking findings; nits addressed in follow-up commit (DESIGN.md committed, comment accuracy, gate-redundancy documented).

中文摘要:修复了长会话中随压缩历史增长的每次 transform 开销(候选规划 1000 条消息从 13.6 s 降到 1.16 ms,达标 ≤20 ms),通过请求级索引复用、惰性 nudge 分析、结构版本号失效机制和有序合并的状态保存队列实现;持久化格式不变,1151 个测试全部通过,双代理审查无阻断问题,可以合并。

ework-agent added 3 commits September 11, 2026 10:11
- RC1: request-scoped boundary lookup memo in SearchContext (built once per
  compress call instead of once per candidate draft)
- RC1b: resolveSelection token estimates now use chars/4 fast estimator
  instead of the Anthropic BPE tokenizer per message
- RC2: nudge analysis (context composition / protected refs / compressible
  ranges) gated behind needsNudgeAnalysis so quiet turns skip it entirely
- RC3: structureVersion-based invalidation for syncCompressionBlocks
  incremental path and hideConsumedCompressCalls derived-index cache
- RC4: ordered, coalescing per-session save queue in persistence
  (latest-snapshot-wins bursts, FIFO across batches, failure isolation)
+ scripts/bench-candidate-planning.ts benchmark harness
+ tests: sync (+4), hide-consumed (+3), compress-search (+4),
  token-counting (+3), persistence (+4), inject (+2, multi-turn #5.7 cycle)
+ devlog 2026-09-11_perf-transform-history (REQ/WORKLOG/DESIGN)

Candidate planning @1000 msgs: 13618ms -> 1.16ms (~11700x); target <=20ms met.
Full suite: 1151 tests, 0 failures.
- commit devlog DESIGN.md (required by AGENTS.md §5.1.2, was untracked)
- persistence.ts: correct save-queue comment — snapshot payloads are
  captured by reference at enqueue and serialized at write time; late
  serialization sees only strictly-newer values (inter-save mutations are
  additive/monotonic), which is safe
- inject.ts: document that emergencyOverride is intentionally kept in the
  needsNudgeAnalysis gate despite being subsumed by nudgeAllowed
- REQ.md: drop lib/state/rebuild.ts from affected modules (zero diff)
- persistence.ts: correct save-queue comment — snapshot payloads captured by
  reference at enqueue, serialized at write time; late serialization sees only
  strictly-newer values (inter-save mutations are additive/monotonic)
- inject.ts: document that emergencyOverride is intentionally kept in the
  needsNudgeAnalysis gate despite being subsumed by nudgeAllowed
- REQ.md: drop lib/state/rebuild.ts from affected modules (zero diff)
@ranxianglei
ranxianglei force-pushed the 2026-09-11_perf-transform-history branch from 4977cde to 428b256 Compare September 11, 2026 02:13
@github-actions

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-11_perf-transform-history (428b256)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-385 --global

Each push to this PR publishes a new version under the pr-385 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-11_perf-transform-history" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr385.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei
ranxianglei merged commit 220bd86 into master Sep 12, 2026
6 checks passed
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.

perf: avoid transform work that scales with compression history

1 participant