enola/ Blog/ What a Code Graph Actually Costs in Memory

Blog

What a code graph actually costs in memory.

Someone says "architecture graph" and you reasonably want to know what it will do to your laptop. Here are the measured numbers, from a small service to the Linux kernel at 1.9 million facts — and why Enola is written in Go.

Enola holds your repository's architecture in memory. Not a sample of it, not an embedding of it — the whole graph: every symbol, module, route, and dependency edge it extracted, indexed so an agent can traverse it in milliseconds.

That is a deliberate design decision with a bill attached, and the bill is the most common question after "which languages do you support". This post answers it with measured numbers rather than adjectives, and then explains what produces them.

The short answer

Two rules of thumb, both derived from repositories five times apart in size:

A fact is one architectural record — a symbol, an import, a route, a call edge. Most repositories produce 10 to 25 facts per parsed source file, so counting source files is a better estimate than counting lines. A 5,000-file service is 50,000 to 125,000 facts, which is a few hundred megabytes at peak and well under 100 MB once it is serving.

The measured end points, on the two repositories Enola tracks a memory ratchet against:

Repository Language Facts Peak while generating Resident once built
dotnet/runtime C# 397,608 1,454 MiB ~300 MiB
cpp/linux C 1,892,479 5,228 MiB ~1,300 MiB

At the other end of the range, a small .NET sample service finishes in 374 ms and peaks at 12 to 13 MiB. Enola is not a heavyweight process on a normal repository. It becomes one at kernel scale, and the kernel is the largest thing it is measured on.

Measurement conditions: Apple M4 Max, 16 cores, 128 GB RAM, macOS 26.6.1, go1.25.12. Figures are Go heap high-water marks sampled through the run, not resident set size — on macOS, released pages stay resident and RSS reads several times the live heap. Results vary by machine, repository, and language mix.

Building a graph costs about six times more than holding one

Look again at the two columns. Peak is 5.7x the resident figure on dotnet/runtime and 5.8x on the kernel. That ratio is stable across a five-fold difference in size, and it is the single most useful thing to know about running Enola.

The reason is that building and serving are different jobs. Building means every source file's syntax tree, the facts extracted from it, the cross-file linking that resolves them, and the graph index all exist in the same process, some of them at the same time. Serving means holding one frozen, indexed graph and answering questions from it.

The practical consequence: the number that matters for a long-running MCP server is the resident one, and the number that matters for CI is the peak. They are different by a factor of six, and sizing a container off the wrong one is the usual way to get surprised.

Why Enola is written in Go

Three properties decided it, and none of them is raw speed.

One binary, no runtime. Enola installs with a curl and runs. There is no interpreter to match, no virtual machine to size, no native module to rebuild per platform. For a tool that has to sit inside somebody's agent loop and their CI, that matters more than a benchmark.

Cheap parallelism over files. Extraction is the dominant stage — roughly 85% of wall clock — and it is embarrassingly parallel: every file is independent until the linking stage. Goroutines make that a few lines rather than a thread-pool subsystem.

Direct access to tree-sitter. Twenty language tags are backed by tree-sitter grammars, which are C. Go's C interop makes that a binding rather than a service boundary.

What Go charges for those three is a garbage collector, and the collector's charge is not CPU. It is headroom. Go grows the heap to a goal derived from how much data is live, and by default that goal is twice the live set. So peak memory is structurally above live memory in a way it would not be in a language with no collector.

That is a real cost and it is worth being straight about it. It is also, measurably, not the dominant one.

What the garbage collector actually charges

The question "should this be rewritten in Rust to fix memory" has an answer here, and it came from measurement rather than taste.

An early version of the graph held 1.89 million kernel facts in 2,778 MiB across 39.2 million live objects — roughly 21 separate heap objects per fact. A prototype holding the same data in the same language, using interned strings, columnar records, and compressed sparse row adjacency over integer node ids, measured 365 MiB.

That is a 7.6x difference with no language change. The production graph took most of it: interning file paths and relation targets, replacing two string-keyed edge maps with a compressed adjacency index, and sharing identical property maps at publication time brought the kernel graph from 2,778 MiB to 1,211 MiB and from 39.2 million live objects to 14.2 million.

The lesson generalises past Enola. If a program's live set is several times larger than the data it represents, the problem is layout, not the runtime. Rust would have made the compact version the path of least resistance — it would not have found it.

The most recent tuning round, in August 2026, took another 33% off peak memory on the kernel and 25% on dotnet/runtime, while cutting allocation counts 42% to 48% and getting 3.5% faster across the whole 81-repository corpus. Every change that survived removed work or removed retained data. Nothing that merely re-tuned the collector survived, for a reason worth its own section below.

If you want the full engineering account — how garbage collectors stopped stopping the world, what a heap profile does and does not tell you, and the four confident conclusions that measurement killed — there is a longer, language-level write-up of the same work on menges.dev.

What twenty languages cost

Supporting Go, TypeScript, Python, Java, Kotlin, Scala, Dart, Ruby, PHP, Swift, Rust, C, C++, the .NET family, Terraform, Ansible, gRPC, OpenAPI, and GraphQL in one binary is a memory decision, not only a parsing one. Three concrete examples from the profiles:

The tree-sitter binding allocates per syntax node access. On a large C# repository, one binding call — asking a node for its type name — accounted for 180 million allocations across 669 call sites in 12 extractors, because it built a fresh string each time from a C string the grammar had already interned. Replacing it with a per-grammar lookup table cut allocation counts nearly in half. That cost existed in every language Enola supports, and it was invisible until someone profiled for it.

C and C++ pay for the preprocessor. Extracting architecture from C means dealing with macros, because a great deal of kernel structure is expressed in them. The macro table on the Linux kernel held 671 MB, 22% of everything live at the peak, and it was live for the entire parse because any file may expand a macro from any header. The fix was to store macro bodies as text and lex them only when one is actually expanded — most kernel macros are never expanded — which took 13% off the peak at no measurable time cost. No other language in the set has this shape.

Scala allocates enormously while holding almost nothing. Several Scala repositories churn through tens of gigabytes over a run while never holding more than 20 MB at any instant. zio is 57 GB allocated against an 18 MB live set. This is not a pathology; it is what parsing a dense functional codebase looks like. But it makes those repositories the most sensitive in the corpus to collector settings, which is the next point.

The honest summary: a single engine that handles twenty languages pays, in each one, for the worst-behaved shape in the set. The alternative is a per-language tool, which is a different product with a different set of problems.

Why Enola does not just turn the memory knob down

Go exposes GOGC, which controls exactly the headroom described above. Lowering it from the default trades CPU for memory, and on the two large repositories that Enola benchmarks it looked excellent: about a third off peak memory for a few percent of wall clock.

Run the full corpus and it falls apart. Seven repositories got 64% to 246% slower, every one of them Scala. zio went from 5.8 seconds to 20.3 seconds — to save 33 MiB on a repository that peaks at 111 MiB.

The predictor is the ratio of allocation to live data, not repository size. A repository that allocates 57 GB against an 18 MB live set re-triggers the collector thousands of times when the headroom shrinks. So the setting is reverted, and Enola runs the default collector pacing. The memory you save is not worth a 3.5x slowdown on somebody's Scala service.

What Enola does set is GOMEMLIMIT, at 90% of system memory, as a backstop rather than a target. On a machine large enough for the repository it never engages.

Multi-repo: memory follows the cluster

Cross-repository analysis changes the sizing arithmetic, and the reason is structural. Resolving an edge from one repository's client call to another repository's route requires both fact sets to be assembled at the same time. There is no streaming version of that question.

So for a cluster, add the facts up. Five services of 60,000 facts each behave like one 300,000-fact repository, not like five separate runs. That is still a few hundred megabytes resident, and it buys the cross-repo edges that no single-repository analysis can see.

What to do with this on a small machine

FAQ

How much RAM do I need to run Enola?

Budget roughly 3 KB per extracted fact while a snapshot is being generated, and roughly 0.7 KB per fact once it is built and being served. Most repositories produce 10 to 25 facts per parsed source file, so a 5,000-file service lands in the low hundreds of megabytes. A 400,000-fact monorepo peaks around 1.4 GB. The Linux kernel, at 1.9 million facts, peaks around 5.2 GB and is the largest thing Enola is measured on.

Does memory scale with lines of code or with facts?

With facts. A comment-heavy or generated file adds lines without adding facts, and a dense framework file can add many facts from few lines. Counting parsed source files and multiplying by 10 to 25 is a better estimate than counting lines.

Why is Enola written in Go rather than Rust?

One static binary with no runtime to install, cheap parallelism across source files, and direct bindings to the tree-sitter grammars behind twenty language tags. The collector does charge headroom that Rust would not, and that cost was measured rather than assumed: the dominant factor was data layout, and fixing it in Go cut the resident kernel graph from 2,778 MiB to 1,211 MiB.

Why does the MCP server keep using memory after the snapshot is generated?

Because it answers queries from the indexed graph in memory, which is what makes traversal and impact analysis fast. Generating costs about six times what holding costs, so the resident figure — about 300 MiB for a 400,000-fact repository — is the one to size a server against.

Why does a small Scala repository cost more than a much larger Java one?

Memory pressure follows the ratio of allocation to live data, not repository size. Some Scala repositories allocate tens of gigabytes over a run while holding under 20 MB at any instant. They are small on disk and the most collector-sensitive repositories in the corpus.

Does a bigger repository make the graph less accurate?

No. Fact counts are identical across the tuning work described here, on all 81 repositories, and every snapshot is verified to reproduce byte for byte across cold and warm runs. Memory work that changes output is a bug, not a trade.

How these numbers are protected: the peak-heap and allocations-per-fact figures above are ratcheted against pinned ceilings on every sweep. How we test Enola against 91 repositories covers that script and the seven beside it.

enola is open source under Apache 2.0 — free, local, no data leaves your machine.

shell
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh