Skip to content

perf(daemon): lean idle memory — kill the GC churn, bound the envelope, scope the global passes#291

Merged
zzet merged 7 commits into
mainfrom
perf/daemon-memory-envelope
Jul 11, 2026
Merged

perf(daemon): lean idle memory — kill the GC churn, bound the envelope, scope the global passes#291
zzet merged 7 commits into
mainfrom
perf/daemon-memory-envelope

Conversation

@zzet

@zzet zzet commented Jul 11, 2026

Copy link
Copy Markdown
Owner

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:

  1. An allocation firehose in config. loadRepoGitignore, called through ConfigManager.EffectiveExclude on every indexer walk and per-file reconcile, re-opened and re-parsed the repo .gitignore on 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).
  2. Whole-graph global passes for one-repo changes. The end-of-batch passes (framework dispatch synthesis, clones, capability edges, implements/overrides, test edges, external calls) re-scanned the entire 26-repo graph whenever ≥1 file changed — at warmup and again on every hourly janitor tick. Measured on a live warmup: 503–1601 s of passes, fn-value-callback alone 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.scanNode accounted for 107 GB of cumulative allocations.
  3. No memory envelope. The daemon set no soft memory limit (GOGC defaults, GOMEMLIMIT unset) — 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.
  4. Count-capped caches with unbounded bytes. The sqlite symbol-bundle cache admitted 50,000 entries regardless of size; hub-node bundles run to multiple MB each.
  5. Phantom mtime rows. The watcher's delete/rename patch evicted a file's nodes but left its persisted mtime row behind, so every warm restart re-discovered vanished paths as phantom deletions.

Fixes (one commit per cause)

  • perf(config): two-layer cache for EffectiveExclude — the .gitignore parse 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 one os.Stat). Returned slice is shared, immutable, and clipped so appenders reallocate.
  • feat(daemon): a standing soft memory limit at boot — GOMEMLIMIT (respected verbatim if set) > GORTEX_DAEMON_MEMLIMIT > daemon.memory_limit config > default hostRAM/4 clamped to [1 GiB, 8 GiB] — plus debug.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.
  • perf(indexer,resolver): the global passes take the already-armed changed-repo scope: implements/overrides run their scoped variants (a pair survives when either endpoint is in scope, so cross-repo pairs are kept), capability/test-edge/external-call sweeps drive off per-repo edge readers, clone detect/rebuild read GetRepoNodes(prefix) instead of AllNodes(), and the two worst synthesizers (fn-value-callback, value-ref) scan candidates per-repo while resolving against the whole graph. value-ref also builds its declarator maps only for files that actually carry candidates (a win even unscoped), and fn-value memoizes its name lookups per pass. A nil scope (fresh index) is byte-identical to the old behavior.
  • fix(store): the bundle cache is bounded by bytes (64 MiB default, GORTEX_BUNDLE_CACHE_MAX_MB, ≤0 disables) with a deliberately over-counting size estimator; the 50k count cap remains as a secondary guard.
  • fix(indexer): the watcher's delete/rename patch now prunes the file's mtime row (in-memory first, then the store, mirroring the durability order of the add side), so warm restarts stop re-processing vanished files.

Verification

  • go build ./... and go test -race green across every touched package (config, platform, cmd/gortex incl. the wire-contract golden, indexer, resolver, mcp, graph conformance suites) on the assembled branch.
  • New tests: cache-hit-never-rereads (counting reader) + invalidation + clip safety; memory-limit policy resolution table; release kill-switch; byte-budget eviction boundary + concurrent race hammer; scoped-vs-unscoped parity fixtures for cross-repo implements/overrides, fn-value binding into an unchanged repo, and clone reads never materializing a sibling repo; watcher delete-path mtime pruning (5 tests).
  • Live measurements on the reporting machine (16 GiB, 26 tracked repos): see the PR discussion for the before/after warmup + idle profile from the deployed build.

Operator notes

  • New knobs: daemon.memory_limit (config), GORTEX_DAEMON_MEMLIMIT, GORTEX_DAEMON_MEMRELEASE=0, GORTEX_BUNDLE_CACHE_MAX_MB. Defaults need no configuration.
  • An explicit GOMEMLIMIT in the daemon's environment always wins; the daemon then sets nothing.
  • The hourly janitor still runs every pass — it just reads the changed repos instead of the whole graph. A fresh full index is intentionally unscoped.

zzet added 5 commits July 11, 2026 20:29
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.
@zzet zzet changed the title perf(config): cache per-repo .gitignore parse and layered exclude merge perf(daemon): lean idle memory — kill the GC churn, bound the envelope, scope the global passes Jul 11, 2026
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.
@zzet

zzet commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

Live before/after on the reporting machine — 16 GiB Apple Silicon, 26 tracked repos, sqlite store, measured with /usr/bin/footprint, sample, and the daemon's own pprof endpoint.

Baseline (pre-branch binary, same store, same workload):

metric value
physical footprint at idle 13.7–16.2 GB, pinned at the process peak (96% swapped/compressed)
idle CPU 27–77% — samples ~95% GC mark/sweep
warmup, ≥1 repo changed 44 min total: parse 460–496 s, global passes 503–1601 s
worst passes fn-value-callback 264–276 s · clones 621 s · capability edges 278 s
top allocator config.loadRepoGitignore — 47% of all allocations (170 GB in 17 min)

This branch (deployed at f6da4fb6, daemon restarted twice to measure both boot shapes):

metric value
standing memory limit applied at boot: 4 GiB (hostRAM/4, source=default)
boot, nothing changed graph queryable in 9.3 s, global passes skipped
boot, 1 repo changed ~6 min total: parse 11.8 s, queryable 109 s, global passes 175.8 s
scoped passes fn-value-callback 175 ms · value-ref 90 ms · clones 0.49 s · capability 5.8 s · implements+overrides 3.1 s
remaining pass cost the deliberately-unscoped tail: macro-expansion 46.3 s + ~40 small synthesizers at 1–6 s each
post-warmup release released heap to OS reason=warmup_complete fired; footprint settled within a minute
footprint at idle 1.35 GB (stable across samples)
idle CPU ~0%

Peak transient footprint during the changed-repo warmup stayed under 3 GB (resolve + enrichment overlap), versus 6.7–16.2 GB before.

Notes:

  • The negative freed_bytes value visible in the first deployed build's release log (concurrent allocation re-acquiring pages between the two MemStats reads) is clamped by 471adbba, which landed after that binary was cut.
  • The gitignore cache benchmark in the PR body (68,672 → 432 B/op) is the microbenchmark counterpart of the alloc_space drop; the live confirmation is the idle CPU falling to ~0%.

…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.
@zzet zzet merged commit 3723fc6 into main Jul 11, 2026
10 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.

1 participant