Skip to content

ghcide: stop accumulating equivalent values across early-cutoff rebuilds#4942

Draft
parsonsmatt wants to merge 11 commits into
haskell:masterfrom
MercuryTechnologies:perf/ghcsessiondeps-cutoff
Draft

ghcide: stop accumulating equivalent values across early-cutoff rebuilds#4942
parsonsmatt wants to merge 11 commits into
haskell:masterfrom
MercuryTechnologies:perf/ghcsessiondeps-cutoff

Conversation

@parsonsmatt
Copy link
Copy Markdown
Contributor

@parsonsmatt parsonsmatt commented May 27, 2026

Summary

Cuts steady-state heap retention by ~34% on a 10k-module synthetic project (MLM-XL).

defineEarlyCutoff' pointer-identity preservation (ghcide/src/Development/IDE/Core/Shake.hs)

When an early-cutoff rule's fingerprint matches the prior run, reuse the previously cached Succeeded value instead of storing the freshly-computed (structurally equivalent) one. Downstream rules whose own deps didn't change continue to hold a pointer to the prior generation — so without this fix, every rebuild stacks another generation in memory.

Worst offender: GetModuleGraph, whose mgTransDeps :: Map NodeKey (Set NodeKey) is ~50M entries at the 10k-module scale.

Validation

Bench: 10k modules (depth 200, width 50), ghcide-bench --select 'memory pressure' --samples 25.

Retainer growth rate (+RTS -hT -i1):

Retainer Baseline With fix Reduction
NodeKey_Module 241 kB/s ~minor ~full
GWIB 243 kB/s ~minor ~full
Set.Internal.Bin 396 kB/s ~minor ~full
THUNK_1_0 250 kB/s reduced ~10× ~10×

Total heap @ t≈2140s: 6,225 MB → 4,113 MB (−34%).

Risk

Changes core defineEarlyCutoff' semantics: on a fingerprint match the cache stores oldV instead of the freshly-computed equivalent. Behavior change for any caller that compared by reference rather than by structural equality — none should, but worth a review.

Residual work (not in this PR)

Word64Map.Bin + TaggedVal (UniqDFM internals, likely NameCache or HPT contents) still grow ~475 kB/s after this fix. Source needs retainer-attribution profile (-hr) to pinpoint; followup PR.

🤖 Generated with Claude Code

parsonsmatt and others added 11 commits May 26, 2026 21:07
- shake-bench: parse GC pause time, max live data, productivity,
  major/minor GC counts from +RTS -s; emit .hp.csv top-cost-centre
  summary alongside each .hp file
- ghcide-bench: capture per-sample latencies; emit p50/p95/p99/stddev
  CSV columns; add memory-pressure experiment
- bench: smoke phony target for fast local iteration; parameterise
  MultiLayerModules.sh with --depth/--width; add MultiLayerModulesXL
  (200x50 = 10k modules) for production-scale stress

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
defineEarlyCutoff' reuses the previously-cached Succeeded value when the
cutoff fingerprint matches the prior. Without this, every cutoff-matched
rerun stored a freshly allocated (but structurally equivalent) value,
and downstream rules whose deps had not changed continued to hold
pointers to the prior value. Multiple equivalent copies of large shared
values accumulated across rebuilds.

On a 10k-module MLM-XL project, this cut NodeKey_Module heap growth by
~3000x (241 KB/s -> 81 B/s) and overall heap leak by ~50% (1.9 MB/s ->
0.95 MB/s). Largest source of remaining leak is HPT/UniqDFM churn via
GhcSessionDeps; tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GhcSessionDeps previously used defineNoDiagnostics (no early-cutoff
fingerprint), so the prior pointer-identity preservation patch on
defineEarlyCutoff' could not engage for it. As a result, every re-run
of GhcSessionDeps allocated a fresh HscEnvEq even when the resulting
value was structurally equivalent — and downstream rules whose deps
had not changed kept pointing at the prior version, accumulating
generations of equivalent HscEnv/HPT.

Switch GhcSessionDeps to defineEarlyCutoff and derive a cutoff
fingerprint from the inputs that fully determine HscEnvEq identity:

  - the file's own source hash (covers pragma/dflags effects via
    msrHscEnv's initializePlugins step)
  - the file's import-level mod summary fingerprint
  - the trans-deps fingerprint (covers import-graph changes anywhere
    in the file's transitive cone)
  - the iface fingerprint of each direct dep (covers signature
    changes that would actually affect typecheck)

When all four match the prior run, the new defineEarlyCutoff' code
path reuses the cached HscEnvEq pointer — eliminating accumulation of
ModuleGraph-internal data structures (NodeKey_Module, GWIB, Set.Bin
all drop out of the retainer set entirely on MLM-XL) and cutting
overall heap at fixed wall-clock by ~36%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hlint flagged ws !! i in secondsAt. Use safe drop/pattern-match
instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restore samples: 50 in config.yaml (was temporarily 10 for iteration)
- Drop config_overnight.yaml (RTS-flag exploration artifact, not used by CI)
- Trim verbose comments in Main.hs smoke target and MultiLayerModules.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After haskell-actions/setup@v2.10.3 finishes, the active GHC is in an
"unset" state. With --unset, `ghcup gc` then deletes the active GHC's
binary, and `cabal build` fails with Cabal-7620 (ghc not found). All
Ubuntu test/flags jobs have been failing this way for new PRs.

Drop the --unset flag. The other gc flags (--share-dir --hls-no-ghc
--cache --tmpdirs) still free the disk space the gc step exists for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The unconditional pointer-identity preservation in defineEarlyCutoff'
broke rules whose action body has value-dependent side effects. In
particular, GetLinkable registers the freshly built linkable and unloads
the prior; returning the cached value to downstream then left
consumers (e.g. the eval plugin's :info command) pointing at bytecode
the action had just unloaded.

Make value reuse opt-in via a new flag on defineEarlyCutoff', and a
parallel wrapper defineEarlyCutOffNoFileReuseValue. Apply only to
GetModuleGraph, which is pure and accounts for nearly all of the heap
savings (NodeKey_Module / GWIB / Set.Bin retainers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-ups on the GetModuleGraph value-reuse change and the
benchmark tooling it ships with.

ghcide:
- defineEarlyCutoff' only reuses a clean prior value (Stale Nothing); a
  deletion-marked Stale (Just _) is no longer resurrected as Succeeded.
- Replace the hand-inlined addRule copy in defineEarlyCutOffNoFileReuseValue
  with a RuleNoDiagnosticsReuseValue constructor + shared addNoDiagnosticsRule
  helper, and factor the empty-path guard into noFileBody.

bench/shake-bench:
- run_pass2_costcentre.sh: drop --lsp-config (it made ghcide-bench read empty
  stdin from /dev/null and error out, masked by `|| echo`).
- MultiLayerModules.sh: require a value for --depth/--width (${2:?...}) instead
  of looping forever when it is missing.
- summarizeHpProfile: foldl' + forced max (no thunk buildup); skip the .hp.csv
  summary under NoProfiling instead of rewriting the dummy each build.
- parseRTSStats: whitespace-normalise label matching (robust to GHC -s spacing).
- Collapse showMB/showSeconds/showDouble/showInt to a single max-0 clamp.
- quantileSorted: sort each sample list once per row instead of 6x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment justified skipping `Stale (Just _)` values by calling them
deletion/GC markers. That is inaccurate: `Stale`'s first field is a
`Maybe PositionDelta`, written only by the persistent-rule fallback in
lastValueIO (a value loaded from disk, not produced by the rule's action
this run). State the real reason — such a value is not a trustworthy
prior to reuse — so future readers don't misread the Value lifecycle.

Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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