Skip to content

fix(core): reduce Windows server CPU under parallel sessions - #42980

Closed
Hona wants to merge 19 commits into
anomalyco:devfrom
Hona:windows-cpu-perf
Closed

Hona wants to merge 19 commits into
anomalyco:devfrom
Hona:windows-cpu-perf

Conversation

@Hona

@Hona Hona commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

Making parallel OpenCode sessions cheap on Windows

TL;DR

Live Event throughput with four SSE subscribers: 77,537 -> 145,942 delivered Events/s (+88.2%), with 48.4% less CPU work.

The local 81.7-second profile spent most active samples starting processes, resolving executables, and repeatedly attempting snapshots. The table gives the evidence for each commit. Percentages are overlapping profile paths and must not be added together.

Commit What changed What it fixes Rough local impact
2f6c8dc Give every snapshot capture, preview, restore, and checkout a disposable GIT_INDEX_FILE Shared OpenCode index.lock contention and permanent failure after a crash Removes the failure source behind the 73.8% post-start process path; stale lock stays untouched
b065474 Skip git diff --name-only when start and end tree IDs match One unnecessary process on unchanged steps 1 of 7 original unchanged-step Git processes, about 14%
07e95a6 Replace repeated array membership scans with sets Quadratic filtering when a snapshot has many candidates About 5,483 candidates in the reproduced stale-index state; changes local filtering from O(n²) to O(n)
0845afe Return the immutable base tree when no candidate changed Unnecessary git write-tree for clean captures 4 to 3 Git processes per isolated clean capture, 25%
3199136 Replace diff-files plus ls-files --others with one tagged ls-files scan Two child processes for one status question 3 to 2 Git processes per clean capture, 33%
218b926 Resolve git.exe once and directly spawn absolute native executables Repeated PATH, PATHEXT, which, isexe, statSync, and shebang checks Targets 11.2% of post-start samples
508a8c0 Read worktree, Git directory, and common directory with one rev-parse Three sequential processes for repository discovery 3 to 1 processes per discovery, 67%
f913182 Compile the event encoder once, render SSE directly, and cache bytes across subscribers Schema, JSON, SSE channel, and text encoding repeated for each client Targets the 1.9% post-start SSE path and removes its per-client multiplier
f25c107 Use Effect.fnUntraced for per-delta provider and Session handlers A span, call-site Error, async context work, and export data for every streamed delta Targets the 4.4% post-start OpenTelemetry path
514728c Bind Session location once in the LLM publisher Ambient Effect service lookup and location allocation on every event Removes one context lookup and one location allocation per streamed event
3fe7972 Create the wildcard PubSub only when events.all() is consumed One unused PubSub publish in the normal bounded-listener server path Removes one PubSub operation per event
e69235b Hoist ripgrep and tool Schema codecs Repeated codec-wrapper construction for every grep row and tool call Removes repeated setup from two hot parts of the 6.2% post-start Schema path
e89d4e8 Append new directories once, prepare fuzzysort targets once, and cache the combined list Rebuilding the complete directory array for every file and preparing every target for every query Targets the 5.8% total-profile Windows search path, mainly startup and autocomplete
6ced7d8 Let each message adapter own edits; SQLite mutates its private decoded value Immer proxy creation, copy tracking, and freezing around every projected edit Removes one Immer object graph per durable message edit in the 2.4% SQLite path
b3c0c6d Select only used message fields and filter shells by callID in SQLite Loading and decoding every historical shell row Shell lookup changes from O(shell history) to one indexed result-shaped read
3f00002 Prepare three projected-message reads once Rebuilding the same Drizzle select statements for every event Removes query builder, SQL conversion, field ordering, and mapper setup from each message edit
99e676f Prepare aggregate-sequence and Event-ID reads once Two repeated SQL builds on every durable event Removes two Drizzle builds per durable publication
026c172 Pass object alternates by environment and lock only changed object publication Concurrent Windows Git writers racing to create the same read-only loose object Live result improved from 7/10 to 10/10 parallel captures; clean captures remain lock-free
1b10d35 Replace the Effect frame cache with a weak object-identity cache Cache fibers, TTL state, and strong frame retention cost more than encoding Four subscribers: 48.4% less CPU work and 88.2% more delivered throughput; peak RSS unchanged

What the profile actually showed

The profile covered 81.7 seconds and contained 4,820 samples. The first 10.4 seconds included server startup. I used sample counts for ranking because the profile contains long wall-time gaps. Assigning a multi-second gap to the next sampled frame would incorrectly make SSE or tracing look like a multi-second synchronous call.

Path All samples After startup
Complete cross-spawn ancestry 57.0% 73.8%
Native spawn 40.4% 54.8%
Windows executable statSync lookup 10.5% 11.2%
Building snapshot candidate input 2.6% 3.2%
Effect Schema ancestry 7.8% 6.2%
OpenTelemetry ancestry 6.9% 4.4%
SQLite and Drizzle ancestry 3.1% 2.4%
SSE encoding 1.4% 1.9%

The categories overlap. For example, Schema work can run inside an Event path that also has tracing.

The matching server log supplied the missing command context:

  • The profiled server attempted 36 snapshots during the capture.
  • A second server process attempted 2 more snapshots in the same period.
  • Every attempt failed on the same app-owned index.lock.
  • The lock was zero bytes and had not changed since June 2.
  • The old snapshot index saw 2,302 tracked changes and 3,181 untracked files, or about 5,483 candidates, before each failed write.

This was not a retry timer. Every normal model step asked for another start or end snapshot, so active Sessions repeatedly entered the same expensive failure path.

Why snapshots started so many processes

The runner takes one snapshot before the physical model attempt and another after provider and tool work:

Before this branch, a clean unchanged step used:

start: diff-files + ls-files + write-tree
end:   diff-files + ls-files + write-tree
diff:  diff --name-only

total: 7 Git processes

Ten Sessions that reached the same boundary could therefore request about 70 Git processes. Windows process creation made this much more visible than Linux or macOS.

The old mutable-index design

OpenCode keeps snapshot objects in an app-owned Git repository under the OpenCode data directory. The user repository and the snapshot repository have different indexes:

user:     <project>/.git/index
OpenCode: <data>/snapshot/<project>/<worktree>/index

This correctly prevents a user git add from fighting with an OpenCode git add. It does not prevent two OpenCode server processes from sharing the same OpenCode index. Git protects a mutable index by creating index.lock, writing the new index, and renaming it into place. A process crash can leave the lock behind.

The supplied machine had exactly this state. The best fix was not a larger stale-lock manager. It was to remove the shared mutable index.

Disposable indexes and immutable trees

Git supports an alternate index with GIT_INDEX_FILE. Git still creates a mandatory lock next to that alternate index, but every capture now has a unique path:

capture-a.index
capture-a.index.lock

capture-b.index
capture-b.index.lock

The lock gives one capture crash-safe index writes. It cannot block another capture, another Session, or a later server process.

The capture flow is now:

create unique temporary index path
read immutable base tree into temporary index
inspect the requested Location scope
return the base tree if nothing changed
apply changed paths to the temporary index
write an immutable tree object
delete the temporary index and its private lock

The implementation is in the deep Git boundary:

The design follows Git's plumbing model:

  • git read-tree reads an immutable tree into an index. Its -i documentation explicitly describes temporary indexes.
  • git write-tree creates an immutable tree object from a fully merged index.
  • GIT_OPTIONAL_LOCKS=0 prevents read commands from doing optional index refresh writes. It does not disable mandatory private-index locks.

The first capture starts from the source HEAD tree. Later captures start from the last successful Location tree. OpenCode does not read, modify, or delete the user's index or the legacy OpenCode index.

Shared object storage is a different concurrency problem

Git indexes are mutable. Git blobs and trees are content-addressed and immutable. The private snapshot repository still shares one object directory so that stored tree IDs remain available after a server restart.

The clean fixture tests passed with concurrent object writes. The live Windows repository found a real platform edge: two Git processes can race to create the same loose object. Git makes completed loose objects read-only on Windows. The losing writer can then receive Permission denied.

The final design keeps the common path simple:

  1. Read the base tree into a private index.
  2. Run one status scan.
  3. Return immediately when clean.
  4. Only a changed capture acquires the existing stale-safe Effect file lock before it writes blobs and trees.

See changed-object publication. Source objects are exposed with GIT_ALTERNATE_OBJECT_DIRECTORIES; no persisted alternate file is needed. Git documents persistent alternates in objects/info/alternates; the environment form gives the same read path without another mutable app file.

This lock is not a replacement index lock. Clean captures do not acquire it. It protects the one shared resource that remains: changed object publication.

Process count after the snapshot changes

For the first clean model step:

start: resolve HEAD tree + read-tree + tagged ls-files
end:   read-tree + tagged ls-files
diff:  skipped because tree IDs match

total: 5 Git processes

Later clean steps reuse the previous immutable tree and use 4 Git processes. This is down from 7. Changed captures use extra processes only for the actual add, tree write, and changed-file report.

The branch also resolves git.exe once. The native Windows fast path is deliberately narrow:

Shell commands, shebang scripts, .cmd, and .bat files still use cross-spawn. This preserves Windows script behavior while removing repeated which, isexe, and shebang reads for Git.

Removing work from every streamed event

After snapshots, the next repeated path was model output. A provider can emit many text, reasoning, and tool-input deltas for one physical attempt.

Encode once per event, not once per client

The old server route created a Schema encoder wrapper, encoded the Event, called JSON.stringify, allocated an SSE Event, ran the Effect SSE channel, and encoded text for every subscriber.

The new route:

  • compiles the Schema encoder once;
  • renders the exact data: <json>\n\n frame directly;
  • stores encoded bytes in a WeakMap keyed by the shared Event object;
  • shares one encoding across concurrent subscribers;
  • pre-encodes the heartbeat.

See the Event handler.

Keep useful spans, remove per-delta spans

Named Effect.fn handlers create trace spans and call-site stack data. That is useful for a request, step, tool, or compaction operation. It is high-cardinality noise for each token delta.

The per-delta handlers now use Effect.fnUntraced, while coarse operational spans remain. The Session location is also bound once when the publisher is created, so each Event does not search ambient Effect context and allocate another location value.

See the bound publisher and untraced event path and per-delta publication.

This intentionally reduces trace detail. It does not change Session events, ordering, or persistence.

Making the remaining CPU proportional to real work

Windows file search

Windows uses the ripgrep and fuzzysort search path. The old index callback rebuilt Array.from(directories) for every file and repeatedly sliced and joined path prefixes. Each autocomplete query also rebuilt the mixed target list and prepared targets again.

The new path appends each directory once, prepares each target when it enters the index, and invalidates the combined list only when a new file arrives. See filesystem search indexing and the cached query target list. Fuzzysort documents prepared targets in its prepare API.

Effect Schema codecs

Stable ripgrep and tool schemas now create their decoder and encoder functions once:

Schema validation remains at the same boundaries. Only repeated wrapper construction is removed.

SQLite projectors

The message updater now tells its adapter to edit an assistant or shell. The memory adapter keeps Immer and immutable replacement. The SQLite adapter owns a newly decoded private value, so it applies the edit directly and encodes it once.

The projector also:

  • selects only id, type, and data;
  • decodes the known assistant or shell member schema;
  • uses json_extract(..., '$.callID') and limit 1 for shell lookup;
  • prepares three message reads once per projector layer;
  • prepares two durable Event reads once per Event layer.

See message edit ownership, prepared projector reads, and prepared durable Event reads. Drizzle describes prepared placeholders in its prepared statement guide.

Prepared queries still execute through the current Effect transaction service. The transaction and rollback tests pass.

Live Windows verification with the broken state left in place

The final Snapshot service was run directly against C:\Repos\sst\opencode and the same app data used by the profile.

The zero-byte June 2 index.lock remained present before and after the run. Its length and timestamps did not change.

Workload Successful captures Wall time Process CPU
10 parallel captures before changed-object publication lock 7/10 9,373 ms 531 ms
10 parallel captures after final design 10/10 4,491 ms 609 ms
10 sequential captures after final design 10/10 13,217 ms 204 ms

The parallel CPU value includes real changed-object publication. The sequential run shows about 20 ms of OpenCode process CPU per capture while most wall time is Windows child-process latency. This is a focused Snapshot benchmark, not a replacement full-server profile.

Replaying a real 18-Session Event workload

The original CPU profile gave stack samples, but it could not recover Event payloads, delta boundaries, or timing. I therefore attached a local recorder to the elected OpenCode service for 10 minutes while 18 real Sessions were active.

The raw trace stayed on the local machine. It contains 4,932 SSE Events, 10.26 MB of frames, 722 tool calls, 515 reasoning deltas, 82 text deltas, and tool-success frames up to 115 KB. Nothing in the benchmark invents prompt text, tool output, delta sizes, Event order, or inter-arrival timing.

The installed service was newer than both commits under test. It had moved the old data.timestamp value to top-level created, renamed session.next.* Events to session.*, and added several operational Event types. The replay adapter therefore does only source-defined version conversions:

  • move the captured created millisecond value back to data.timestamp;
  • restore the target's session.next.* names;
  • restore callID, provider metadata, structured tool output, and reasoning IDs from captured values according to the target publisher source;
  • require the target Schema to decode and re-encode every adapted payload without another data change;
  • exclude new Event types that have no target equivalent.

This validates 4,305 of 4,931 non-connection Events, or 87.3% by count and 97.0% by captured bytes. The excluded 3.0% of bytes is mainly newer shell lifecycle, inbox, execution, instruction, and usage Events.

Harness

Each measured process uses the exact target commit, a fresh isolated SQLite database, the production EventV2.publish path, the production Server Event handler, and real streamed SSE response bodies. The harness is in-process, so it excludes TCP cost and includes both the publisher and the SSE readers in the same Bun process.

The harness performs 100 unmeasured live-Event warm-up publications, forces a full GC, then records wall time, process.cpuUsage(), and RSS every 5 ms. A result of 100% CPU means one full core. Every run verifies that every selected Event reaches every subscriber. A 128-Event delivery window prevents the production 256-Event bounded subscriber queue from overflowing.

Maximum-throughput results are medians. The aggregate test uses five alternating base/candidate runs. The live-only test uses seven alternating runs and repeats the same 607-Event live trace 100 times. Later cycles change only the Event ID so every publication has real object identity and cannot reuse an earlier cycle's cached frame.

Live delta and fan-out path

This isolates the non-durable Event and SSE path. One cycle contains the real captured delta sizes and mix; 100 cycles provide 60,700 source Events and enough CPU time for stable measurement.

Subscribers Baseline delivered Events/s Candidate delivered Events/s Throughput Baseline CPU Candidate CPU CPU work Baseline peak RSS Candidate peak RSS
1 59,235 57,608 -2.7% 1,297 ms at 125.0% 1,375 ms at 129.1% +6.0% 407.5 MiB 410.5 MiB
2 72,495 98,637 +36.1% 2,156 ms at 125.9% 1,610 ms at 126.0% -25.3% 410.9 MiB 411.5 MiB
4 77,537 145,942 +88.2% 3,907 ms at 123.0% 2,015 ms at 125.6% -48.4% 414.7 MiB 413.8 MiB

The first cache implementation used Effect Cache. Replay showed that its fibers, TTL state, and strong retention made the candidate 27% to 66% slower. The final handler uses a WeakMap<Event, Uint8Array>: one subscriber is effectively neutral, while additional subscribers share the exact encoded frame with no retained Event lifetime.

Aggregate publication path

This publishes all 4,305 compatible Events, including durable SQLite commits, and delivers the stream to each subscriber. Windows SQLite wall time varied widely between runs, so CPU work is the more stable aggregate signal.

Subscribers Baseline delivered Events/s Candidate delivered Events/s Throughput Baseline CPU Candidate CPU CPU work Baseline peak RSS Candidate peak RSS
1 389 339 -12.8% 6,000 ms at 49.9% 5,625 ms at 43.0% -6.3% 351.4 MiB 330.9 MiB
2 734 731 -0.4% 6,094 ms at 47.2% 4,422 ms at 40.1% -27.4% 384.4 MiB 355.2 MiB
4 1,462 1,576 +7.8% 5,172 ms at 43.9% 4,156 ms at 48.9% -19.6% 394.6 MiB 376.7 MiB

At four subscribers, the aggregate candidate uses 19.6% less CPU work, delivers 7.8% more frames per second, and peaks 17.9 MiB lower. The higher candidate CPU percentage is not more CPU work: the candidate finishes sooner, so its reduced CPU time is concentrated into less wall time.

Original-timing paced replay

The same 4,305 Events were also replayed once at their captured inter-arrival times for 592.38 seconds with four subscribers. The base and candidate used isolated databases and ran together at this low average load.

Metric Baseline Candidate Change
Delivered Events/s 29.069 29.069 paced workload
Process CPU time 14,297 ms 13,579 ms -5.0%
Average CPU, one core = 100% 2.41% 2.29% -0.12 points
Peak RSS 282.8 MiB 366.8 MiB +84.0 MiB
RSS growth during replay 0.85 MiB 0.23 MiB -0.62 MiB

Throughput is equal by design because the original timing controls publication. The candidate uses 5.0% less CPU work at that cadence. The single-run total RSS difference was present before measurement began, while measured replay growth was lower for the candidate. Use the repeated unpaced median for the more stable peak-RSS comparison.

Raw aggregate results remain local. The sensitive replay trace was deleted after the benchmark. The PR reports only aggregate counts and medians.

Verification

  • Pre-push workspace typecheck: 30 tasks passed.
  • Core focused suites: 73 tests passed.
  • LLM provider suites: 82 tests passed.
  • Native Windows executable and .cmd routing: 2 focused tests passed.
  • V2 HTTP Event integration: 3 tests passed.
  • Core, server, LLM, CLI, and sdk-next package typechecks passed.

Repository-wide lint remains blocked by one pre-existing octal escape error in packages/session-ui/src/v2/components/prompt-input/index.tsx. It also reports 4,854 existing warnings. No changed file produced a lint error.

Compatibility and tradeoffs

  • Public Protocol and Server HttpApi contracts do not change.
  • Snapshot tree IDs and restore behavior do not change.
  • The user Git index is never modified by snapshot capture.
  • Legacy app snapshot index and index.lock files are ignored, not migrated or deleted.
  • Clean captures do not acquire the object publication lock.
  • Changed captures serialize immutable object publication across server processes on Windows.
  • Per-delta trace spans are removed. Coarse operational spans remain.
  • SSE bytes remain standard data: frames with the existing heartbeat and response headers.

The result keeps the normal flow visible: read a base tree, inspect one scope, return immediately when clean, and publish immutable objects only when files changed.

Hona added 19 commits August 17, 2026 13:39
Replace the shared mutable snapshot index with a unique GIT_INDEX_FILE for each capture, preview, restore, and checkout. Seed captures from immutable trees and keep the legacy shared index and stale lock untouched.

This removes cross-session and cross-process index lock contention from the path that represented 73.8% of post-start profile samples. Add coverage for concurrent captures and existing user and OpenCode index locks.
Return an empty file list when the start and end snapshot tree IDs are equal instead of starting git diff --name-only.

An unchanged model step now uses six Git processes instead of seven, with no change to snapshot or revert semantics.
Replace repeated allowed.includes and skipped.includes scans with set membership while preserving candidate order for Git input.

The profiled stale index produced about 5,483 candidates per capture; this changes the local filtering path from quadratic to linear work.
Return the immutable base tree when scoped status scans find no candidates instead of starting git write-tree.

With isolated indexes, a clean capture now starts three Git processes instead of four. Tree identity and restore behavior stay unchanged.
Replace concurrent diff-files and untracked ls-files processes with one tagged ls-files scan that reports modified, deleted, and untracked paths.

A clean isolated capture now starts two Git processes instead of three. Add deletion coverage alongside modified and untracked snapshot paths.
Resolve git.exe once and route absolute native Windows executables directly through node:child_process.spawn. Keep cross-spawn for shell commands, scripts, cmd, and bat files.

This removes repeated PATH, PATHEXT, isexe, statSync, and shebang checks that represented 11.2% of post-start profile samples while preserving existing command routing.
Read the worktree, git directory, and common directory with one rev-parse invocation instead of three sequential child processes.

Every Location startup and snapshot service initialization now performs one discovery process instead of three, including linked worktrees.
Compile the event schema encoder once, render the exact SSE frame directly, and cache encoded bytes by immutable event object for concurrent subscribers.

This removes the Effect SSE channel, repeated text encoding, and per-client JSON work from the path that represented 1.9% of post-start samples and otherwise scaled with every connected client.
Use Effect.fnUntraced for the Session LLM event publisher and provider delta handlers while retaining coarse request, step, tool, and compaction spans.

This removes call-site Error allocation, span creation, AsyncLocalStorage context work, and export data for every text, reasoning, and tool-input delta in the 4.4% post-start OpenTelemetry path.
Bind the Session location when the LLM publisher is created and pass it through every text, reasoning, tool, and step event.

EventV2 now skips ambient Location service lookup and location object allocation when the caller supplies a trusted ref, removing repeated Effect context work from every streamed delta.
Create the wildcard EventV2 PubSub only when events.all() has a consumer and skip its publish and shutdown work in the normal bounded-listener server path.

Production SSE uses allBounded listeners, so every streamed event now avoids one PubSub operation while typed and wildcard subscription behavior remains covered.
Compile the stable ripgrep match decoder and each tool input, output, and structured-output codec once at definition time instead of rebuilding wrappers for every row or call.

Validation behavior stays unchanged while repeated closure and parser setup is removed from two high-frequency parts of the 6.2% post-start Schema path.
Append each directory once during ripgrep indexing, prepare fuzzysort targets at insertion time, and reuse the combined file and directory array until the index changes.

This removes files-times-directories array rebuilding and repeated fuzzy target preparation from the search path that represented 5.8% of total profile samples, mainly during Windows startup and autocomplete.
Move assistant and shell edits behind the message adapter. The memory adapter preserves immutable produce semantics, while SQLite edits its newly decoded private value in place before encoding it.

Each durable message boundary now avoids Immer proxy creation, copy tracking, and recursive freezing in the 2.4% SQLite projection path without changing projected messages.
Select only id, type, and data for projected message edits, decode the known assistant or shell member schema, and filter the newest shell by callID in SQLite.

Shell completion no longer loads and decodes every historical shell row, and all edited-message reads avoid unused columns and broad union dispatch in the 2.4% SQLite path.
Build the current-assistant, assistant-by-ID, and shell-by-call-ID Drizzle statements when the projector layer starts and execute them with typed placeholders inside the active Effect transaction.

Repeated durable message edits now skip query-builder, SQL conversion, field-ordering, and mapper setup in the 2.4% SQLite path.
Prepare aggregate-sequence and event-ID position reads once in the EventV2 layer and execute them with placeholders inside the active Effect transaction.

Every new durable event now skips two Drizzle query-builder, SQL conversion, and result-mapper setup passes in the measured SQLite and Schema paths.
Pass source object alternates through Git environment instead of persisted repository files, and serialize only changed blob and tree publication with the stale-safe Effect file lock.

Live Windows verification against the untouched June 2 index.lock improved parallel capture success from 7/10 to 10/10. Clean captures remain lock-free; 10 parallel captures used 609 ms process CPU and 10 sequential captures used 204 ms.
@Hona

Hona commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Closing this draft because it was based on upstream/dev instead of upstream/v2. The implementation and benchmark results do not apply to the current OpenCode V2 codebase. I am restarting the work from upstream/v2.

@Hona Hona closed this Aug 21, 2026
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.

1 participant