Skip to content

perf: avoid transform work that scales with compression history #384

Closed

Description

@drexb-ops

Problem\n\nOpenCode becomes slow in long ACP sessions, especially after many compressions. A local isolated probe measured adaptive candidate planning at approximately 3.2 ms for 100 messages, 34 ms for 500, and 105 ms for 1,000.\n\n## Root causes\n\n- Candidate validation rebuilds global boundary and active-summary lookup structures for each draft.\n- T1 nudge analysis calculates full context/range data even when no nudge can be emitted.\n- Stable transforms replay historical inactive compression blocks and rebuild immutable consumed-call indexes.\n- Asynchronous state saves need ordered coalescing to prevent redundant writes and stale overwrites.\n\n## Proposed fix\n\nUse request-scoped search indexes, lazy no-nudge analysis, verified-state synchronization, transient derived indexes, and ordered state-save coalescing. Preserve candidate executor validation, protection semantics, decompression, fork recovery, and persisted-state compatibility.\n\n## Acceptance\n\nTarget candidate planning at 1,000 messages of 20 ms or less, with benchmark evidence and full regression coverage.

Activity

  1. ranxianglei commented on Sep 11, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Picking this up. Plan before I touch code:

    1. Verify each claimed hotspot with an isolated benchmark (candidate planning at 100/500/1,000 messages) so the fix targets measured costs, not guesses — I'll report baseline numbers here.
    2. Triage the four root causes against the code paths (resolveBoundaryIds/buildBoundaryLookup per-draft rebuilds, T1 nudge analysis, per-transform block replay, fire-and-forget saveSessionState races) and confirm which are real and which are symptoms of a deeper issue.
    3. Implement per your proposed direction (request-scoped search indexes, lazy no-nudge analysis, verified-state sync, transient derived indexes, ordered save coalescing), preserving executor validation, protection semantics, decompression, fork recovery, and persisted-state compatibility.
    4. Ship with benchmark evidence (target: ≤20 ms candidate planning at 1K messages) + regression coverage + devlog, as a PR.

    I'll post verification results as soon as the baseline benchmarks are in.

  2. ranxianglei commented on Sep 11, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Done — PR ready for review: #385

    What was verified first (all four root causes confirmed in code before touching anything):

    • resolveBoundaryIds() rebuilt the global boundary lookup (O(entire ref history)) per candidate draft — the dominant cost
    • resolveSelection() ran the real Anthropic BPE tokenizer per message (~27 ms/KB measured in micro-bench)
    • T1 nudge analysis (composition / protected refs / compressible ranges) ran every transform even when no nudge could fire
    • sync replayed all blocks (active + inactive) every transform; hide-consumed rebuilt immutable indexes every transform; saves were independent fire-and-forget whole-file writes (stale-overwrite possible)

    Fixes: request-scoped boundary-lookup memo · chars/4 fast estimate in selection · needsNudgeAnalysis gate with null guards · transient structureVersion invalidation (bumped at exactly the 3 block-mutation sites, full mutation-site audit) · ordered coalescing per-session save queue (latest-snapshot-wins bursts, FIFO across batches, failure isolation). Persisted-state format unchanged; protection semantics, decompression, and fork recovery untouched.

    Benchmark evidence (scripts/bench-candidate-planning.ts, new harness, median of 7 reps; baseline = pristine master run same day):

    msgs A baseline A fixed B+C steady-state
    100 1608.6 ms 0.40 ms 0.10 → 0.04 ms
    500 6798.7 ms 1.09 ms 0.58 → 0.20 ms
    1000 13618.0 ms 1.16 ms (~11,700×) 0.76 → 0.14 ms

    Acceptance target ≤ 20 ms @ 1000 met with ~17× headroom. Full suite: 1151 tests, 0 failures (+20 new tests covering each regression mode, incl. §5.7 multi-turn production-config nudge cycle). Dual-agent review complete: both reviewers APPROVE-WITH-NITS, zero blocking findings; nits addressed in a follow-up commit.

    Merge is a human-only operation — please merge when you're satisfied: #385

    中文摘要:修复了长会话中随压缩历史增长的每次 transform 开销(候选规划 1000 条消息从 13.6 s 降到 1.16 ms,达标 ≤20 ms),持久化格式不变,1151 个测试全过,双代理审查无阻断问题,PR #385 可以合并。

  3. ranxianglei commented on Sep 11, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Update: PR #385 rebased onto current master (v1.17.0) after two releases landed while it was in flight. One trivial import conflict (lib/gc/merge.ts) and one test-file append collision (tests/inject.test.ts — both sides' tests kept intact, verified by running the merged file). Re-verified on the rebased tree: typecheck clean, 1227/0 tests (includes v1.17.0's new suite), benchmark A = 1.81 ms @ 1000 messages (target ≤ 20 ms). All three CI checks now green and the PR reports mergeable: clean — ready for you to merge whenever: #385

  4. ranxianglei commented on Sep 11, 2026

    @ranxianglei
    Owner

    Triage: the profiled code paths are not in released ACP

    Audited against master (v1.17.0):

    • "adaptive candidate planning" / "candidate validation rebuilds global boundary and active-summary lookup structures for each draft" — master has no adaptive-candidate planner. The planner (reason=no_executable_candidates, per-draft candidate validation) exists only on the unmerged #341 / 2026-07-09_more-compression-candidates branch. The 3.2ms@100 / 34ms@500 / 105ms@1000 numbers therefore profile a build carrying that patch — same as the fix: recover compression blocks when forks omit historical compress inputs #375 report (the reporter's build emits that log line too).

    What this means:

    1. If your build carries feat: add adaptive compression candidates #341: the perf work belongs on that branch — we'll fold your acceptance target (≤20ms @ 1000 msgs, request-scoped indexes, ordered save coalescing) into feat: add adaptive compression candidates #341's review before it can merge. Nothing to fix on master for that part.
    2. If you can reproduce on released v1.17.0 (npm i -g opencode-acp@latest equivalent): please attach the reproduction (session length, /acp stats timing, CPU profile) and we'll treat it as a master bug.

    The remaining sub-points (T1 nudge analysis cost, async state-save coalescing) do exist on master but are O(messages) per turn and debounced respectively — no quadratic behavior observed in master's paths. Happy to re-measure with evidence from a stock build.

  5. ranxianglei commented on Sep 11, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Thanks for the audit — you're right about the planner, but the hot path being fixed is in released master. Evidence:

    Where you're right: master has no adaptive-candidate planner (no_executable_candidates appears nowhere in master's lib/) — that's #341-only, so the issue's original 3.2/34/105 ms log numbers did come from a #341-carrying build (consistent with #375).

    Where it differs: the per-draft lookup rebuild isn't part of the planner — it's in the core compress pipeline that every released build runs:

    • lib/compress/search.ts:60 — resolveBoundaryIds() calls buildBoundaryLookup(context, state) on every call, rebuilding the global boundary + active-summary index over the entire ref history
    • lib/compress/range-utils.ts:64 — resolveRanges() invokes it once per range entry in a compress tool call
    • lib/compress/search.ts:328 — resolveSelection() runs the real BPE tokenizer (countAllMessageTokens) per message during selection

    No #341 code involved — the harness imports only those core files.

    Repro on stock v1.17.0 (as requested): fresh checkout of current master (85c6382, package.json 1.17.0), PR's harness scripts/bench-candidate-planning.ts, 10 range entries per compress call, median of 7 reps:

    msgs A: planning (stock) A: planning (PR branch)
    100 1797 ms 0.4 ms
    500 7831 ms 1.09 ms
    1000 15250 ms 1.81 ms

    So on stock v1.17.0 a single multi-range compress call in a long session costs ~15 s of CPU — superlinear in history size. That's the "slow after many compressions" symptom from the issue title, reproducible without #341.

    On the other two sub-points (conceding framing): T1 nudge analysis is O(messages) per transform, not quadratic — agreed; on stock master it runs unconditionally every LLM transform (inject.ts:371/380/384) even when no nudge can fire, so the gate removes wasted linear work rather than fixing a blowup. Save coalescing is likewise about redundancy/correctness (independent full-file writes per trigger, no ordering guarantee → stale-overwrite risk), not speed scaling.

    Suggestion: RC1/RC2 fix shared core code (search.ts, token-utils.ts) used by both master's resolveRanges and #341's planner — landing them on master makes #341 strictly faster when it eventually merges. If you'd rather keep the candidate-planning pieces out of this PR, I can split them into a follow-up scoped to #341; say the word and I'll restructure. Otherwise #385 stands as-is with the stock-build repro above.

  6. added a commit that references this issue on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions