perf(daemon): lean idle memory — kill the GC churn, bound the envelope, scope the global passes#291
Merged
Merged
Conversation
EffectiveExclude re-read and re-parsed the repo root .gitignore on every call — a fresh 64 KiB scanner buffer plus a string per line, on the indexer-walk / per-file-reconcile firehose. Live pprof of a long-running daemon attributed 47% of all allocations to loadRepoGitignore, which kept the GC cycling even at idle. Memoize both layers on the ConfigManager: - the .gitignore parse is cached per repo root and invalidated by an os.Stat mtime+size comparison, so a steady-state call does one stat and no file read - the fully layered exclude list is cached per repo prefix, versioned by pointer identity of the global/workspace configs (they are only ever swapped, never mutated in place) plus the .gitignore stat - the returned slice is shared and immutable by contract, clipped to len == cap so an appending caller reallocates instead of writing through the shared backing array Cache-hit path: 34147 -> 2182 ns/op, 68672 -> 432 B/op, 30 -> 3 allocs/op.
The daemon ran with no memory envelope: GOGC defaults, no GOMEMLIMIT, and the only GC tuning scoped to the cold-index window. One allocation burst (warmup passes, an hourly janitor tick, a whole-graph analysis) pinned the process footprint at its peak indefinitely — observed at 16.2 GB on a 16 GiB machine, with the GC then burning 27-77% CPU at idle marking the bloated heap. Boot now installs a standing soft limit — GOMEMLIMIT respected verbatim when set, else GORTEX_DAEMON_MEMLIMIT, else daemon.memory_limit from config, else hostRAM/4 clamped to [1 GiB, 8 GiB] — and the four burst boundaries (warmup complete, janitor ticks that did work, cold-index window close, MCP whole-graph analysis) hand the high-water heap back to the OS via a forced scavenge, logged with the freed byte count. GORTEX_DAEMON_MEMRELEASE=0 disables the releases. The cold-index tuning window captures and restores the standing limit, so the two compose.
The bundle cache admitted 50,000 entries regardless of size. An entry holds a decoded node plus its full in/out edge lists with meta, so sizes span ~1 KB for a leaf symbol to multiple MB for a hub node — a count cap therefore admitted an unbounded byte footprint on a long-lived daemon. The primary bound is now a byte budget (64 MiB default, GORTEX_BUNDLE_CACHE_MAX_MB override, <= 0 disables the cache) tracked via a deliberately over-counting per-entry estimator, with the count cap kept as a generous secondary guard against floods of tiny entries. Overflow keeps the existing wholesale-clear semantics — entries are cheap to recompute and the cache had no LRU ordering to preserve.
…evicts it The watcher's delete/rename patch evicted a file's nodes but left its persisted mtime row behind. Every warm restart then read the row back, found the path gone from disk, and re-ran a scoped reconcile for a file that was already correct — phantom deletions re-processed on every boot. The patch paths (debounced and storm-drain) now prune the mtime after the eviction lands: the in-memory map first, then the store row through the FileMtimeDeleter capability — the delete-side mirror of the add-side rule that a row may exist only while the file's graph state is durable. A delete event for a path still present on disk is downgraded to a modify before this runs, so a revert or atomic-rename replace never loses its row.
…repo set The end-of-batch passes re-scanned the whole multi-repo graph whenever at least one file changed — at warmup and again on every hourly janitor tick. Measured on a 26-repo workspace with one changed repo: 503-1601 s of passes, fn-value-callback synthesis alone 264-276 s, and the clone passes materializing every node in the graph (meta included) three times. Node decodes from these sweeps accounted for 107 GB of cumulative allocations in one process lifetime. The passes now consume the changed-repo scope the reconcile batch already arms. A nil scope (fresh full index) is byte-identical to the old whole-graph behavior; with a scope armed: - implements/overrides run their scoped variants over the changed repos' type and interface IDs — a pair survives when either endpoint is in scope, so cross-repo pairs re-derive from the changed side alone - capability, test-edge, and external-call sweeps drive off per-repo edge readers instead of whole-graph kind scans; cross-repo field targets resolve through a lazy per-id fallback instead of a whole-graph map - clone detect/rebuild read GetRepoNodes(prefix) instead of AllNodes(), keeping the whole-graph path only for the empty-prefix single-repo mode - the two heaviest synthesizers, fn-value-callback and value-ref, scan candidates per repo while resolving against the whole graph, so a changed repo's callback still binds to a handler in an unchanged repo value-ref additionally builds its per-file declarator maps only for files that actually carry candidates — replacing a whole-graph scan of the largest node population even on the unscoped path — and the fn-value gate memoizes name lookups for the duration of a pass.
HeapReleased can shrink between the two reads when concurrent allocation re-acquires released pages faster than the forced scavenge returns them; the raw delta then logs as a negative byte count. Clamp to zero — the honest reading is that nothing was net-released.
Owner
Author
|
Live before/after on the reporting machine — 16 GiB Apple Silicon, 26 tracked repos, sqlite store, measured with Baseline (pre-branch binary, same store, same workload):
This branch (deployed at
Peak transient footprint during the changed-repo warmup stayed under 3 GB (resolve + enrichment overlap), versus 6.7–16.2 GB before. Notes:
|
…ents The memoization refactor left the non-memo wrappers resolveFnValueCrossModule / uniqueFnValueMatch / resolveFnValueSelfMember with no callers — the gate now calls the *Memo variants directly. Remove them and fold their doc comments onto the surviving implementations. Replace the '_ = sink; sink = nil' idiom in the two scavenge smoke tests with runtime.KeepAlive(sink): it prevents the write loop from being elided and drops the reference at the same point, without the ineffectual nil assignment ineffassign flags.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Make the daemon lean at idle: kill the GC churn, bound the envelope, scope the global passes
Problem
On a 16 GiB machine the daemon's Activity Monitor footprint sat at 13.7–16.2 GB (pinned at its peak, 96% of it swapped/compressed) with 27–77% CPU while nominally idle. Symbolicated CPU samples showed ~95% of that burn inside the garbage collector (mark/sweep), with SQLite page-cache fetches as the allocation driver: every MCP query allocated enough to keep the GC cycling over a multi-gigabyte heap, and the heap's high-water mark was never returned to the OS.
Live pprof of a running daemon identified the causes this PR fixes:
loadRepoGitignore, called throughConfigManager.EffectiveExcludeon every indexer walk and per-file reconcile, re-opened and re-parsed the repo.gitignoreon each call — a fresh 64 KiB scanner buffer every time. Measured: 47% of all process allocations (170 GB alloc_space in 17 minutes of life).fn-value-callbackalone 264–276 s, and clone detect/rebuild materializing every node in the graph (with meta) three times to process a single changed repo.store_sqlite.scanNodeaccounted for 107 GB of cumulative allocations.GOMEMLIMITunset) — the only tuning lived inside the cold-index window — and nothing ever called a scavenge at burst boundaries, so one balloon pinned the footprint at peak indefinitely.Fixes (one commit per cause)
EffectiveExclude— the.gitignoreparse keyed by repo root and invalidated by stat (mtime+size), the layered merge keyed by repo prefix and versioned by config pointer identity. Hot path: 68,672 → 432 B/op, 30 → 3 allocs/op (the residual is oneos.Stat). Returned slice is shared, immutable, and clipped so appenders reallocate.GOMEMLIMIT(respected verbatim if set) >GORTEX_DAEMON_MEMLIMIT>daemon.memory_limitconfig > defaulthostRAM/4clamped to [1 GiB, 8 GiB] — plusdebug.FreeOSMemory()at the four burst boundaries (warmup complete, janitor ticks that did work, cold-index window close, MCP whole-graph analysis), so a burst's high-water is handed back instead of pinning the footprint. The cold-index tuning window captures and restores the standing limit. Kill-switch:GORTEX_DAEMON_MEMRELEASE=0.GetRepoNodes(prefix)instead ofAllNodes(), and the two worst synthesizers (fn-value-callback,value-ref) scan candidates per-repo while resolving against the whole graph.value-refalso builds its declarator maps only for files that actually carry candidates (a win even unscoped), andfn-valuememoizes its name lookups per pass. A nil scope (fresh index) is byte-identical to the old behavior.GORTEX_BUNDLE_CACHE_MAX_MB, ≤0 disables) with a deliberately over-counting size estimator; the 50k count cap remains as a secondary guard.Verification
go build ./...andgo test -racegreen across every touched package (config, platform, cmd/gortex incl. the wire-contract golden, indexer, resolver, mcp, graph conformance suites) on the assembled branch.Operator notes
daemon.memory_limit(config),GORTEX_DAEMON_MEMLIMIT,GORTEX_DAEMON_MEMRELEASE=0,GORTEX_BUNDLE_CACHE_MAX_MB. Defaults need no configuration.GOMEMLIMITin the daemon's environment always wins; the daemon then sets nothing.