Gopls scales by replacing whole-program state with package summaries
Gopls v0.12 changes how the language server stores program knowledge. Package summaries and persistent indexes reduce memory use while finer invalidation keeps edits from triggering unnecessary work.
Keeping a program's typed syntax in memory makes many editor queries easy to implement. It also makes the editor's memory requirement grow with the program. Gopls reached that limit as Go repositories grew and developers increasingly worked in constrained environments. The September 8 architecture account explains a rewrite that replaces whole-program state with separately computed package information.
The rewrite shipped in gopls v0.12 earlier this summer. September's post reports subsequent work to keep interactive queries responsive while retaining much less state. It describes a year-long effort, rather than a new language feature or a compiler release. The relevant change is how the language server represents the code it helps a developer edit.
I value Go's readability, but understanding a large program also requires usable navigation. Finding references and implementations should not require a language server to retain an expanded representation of an entire repository indefinitely. This design makes those queries possible with a different storage strategy.
Pointer identity made queries convenient
The old design used compiler building blocks to parse and type-check packages. In that representation, one symbol object stands for a particular declaration, such as a function. References to the declaration point to that object. Comparing pointers then tells an algorithm whether two references denote the same symbol.
That is efficient within one connected in-memory program representation. Finding references can amount to locating uses of the same pointer. A long-running language server also avoids repeating the initial work for every editor request, which was an advantage over separate command-line tools that started afresh on each invocation.
The difficulty is the size of the retained representation. The architecture post says typed syntax trees are typically about thirty times larger than source text. Holding all those trees and symbols can require much more memory than simply holding the repository's files. A stateful session saves repeated computation by retaining data whose size eventually becomes a problem.
The pressures were also changing on both sides. More code lived in larger repositories, and Go workspaces made it practical to work across modules. Containerized development could put the language server in a smaller environment. The design had to support more source with less memory available per editing session.
Packages become independent units of retained information
The new design borrows separate compilation from the compiler. A package needs information about imported declarations, but it does not need every implementation detail of every dependency at once. Compact exported summaries provide the information needed to process a package after its imports have been processed.
Gopls v0.12 reuses the compiler's package-summary format. Algorithms that once traversed a representation of the whole program now operate package by package and save their results. The important change is the boundary of retained computation. A package can be processed, summarized and discarded without losing every useful answer about its declarations.
The v0.12.0 release notes describe typed syntax trees as ephemeral in the new design. Derived indexes persist in a file-based cache. They include declaration and reference locations, exported declarations and their types, interface method sets, and analysis results. Those are selected products of type checking, rather than the entire transient representation used to compute them.
This selection is specific to editor questions. A references query needs to connect source locations with declarations. An implementations query needs facts about method sets. Persisting those facts lets gopls answer global queries without keeping every package's full typed tree live.
References need names that survive a process
A pointer can identify a symbol within a running process, but it is unsuitable as the durable identity in a cache file. The redesigned references algorithm builds an index mapping identifier locations to the names of the symbols they reference. At query time, gopls loads and searches the indexes.
That change requires more than serializing an old heap. Algorithms must express the information they need in a form that survives independent package processing. The architecture post says other global queries use similar techniques. A design convenient for one connected object graph has to become a design for independently retained summaries.
The resulting file cache also survives language-server restarts. A second session can reuse information computed by an earlier one. Multiple gopls processes can share the cache, so starting another editor does not necessarily require recomputing every unchanged package result independently.
I find this a useful example of reducing memory by changing the unit of work. A smaller object representation alone would retain the same whole-program dependency. Package summaries let the server avoid retaining the object at all once the required facts have been extracted.
The release notes expose the cache location through gopls stats and allow an alternative directory through GOPLSCACHE. That makes the disk-backed part of the design an inspectable resource with its own location, alongside the memory used by an active session.
Invalidation follows what a change can affect
A persistent cache only helps if the server knows which results remain valid. Recomputing a changed package is necessary. Recomputing every direct and indirect importer after every edit is often unnecessary, particularly when the edit changes a function body without altering the information exposed to importers.
The architecture post describes pruning through import summaries. If rebuilding a package leaves its summary unchanged, the change need not propagate to its importers. If its summary changes, direct importers need attention, but their own summaries may remain the same. Propagation can stop before reaching every indirect dependency.
Gopls adds a faster syntactic heuristic based on a simplified graph of symbol references. That graph helps determine whether a change in one package can affect another through the references that actually connect them. An import dependency alone does not establish that every declaration in the importer depends on the changed declaration.
The consequence is less repeated work for ordinary edits. A low-level package may have many transitive importers, yet a local change can affect only a small subset of them. The server's work can follow the scope of that change instead of the full size of the transitive import graph.
This is also an architectural obligation. A language server must preserve correct answers while pruning computation. The dependency information exists to establish which cached results remain valid. Treating the optimization as simply skipping importers would miss the role of summaries and the reference graph in justifying that decision.
The measurements include an already populated cache
September's article reports measurements across twenty-eight popular Go repositories. The authors open a randomly selected Go file and wait for loading to finish. They measure the second opening, because they treat initial indexing as a cost spread across many editing sessions. That condition is essential to understanding the startup result.
Across those repositories, reported savings average around seventy-five percent. Larger repositories show a greater relative memory reduction. The explanation is that active type-checking state now depends on open packages and their direct imports, rather than requiring all packages to remain represented as before.
The release notes also identify remaining costs. Source contents, package-dependency metadata and an in-memory cache layer still consume memory. Moving derived package facts to disk does not imply that memory use becomes independent of repository size in every respect. The reduction concerns the expensive representation that used to dominate retained state.
For a developer, a warm-cache startup and an initial index are different experiences. The design benefits repeated sessions through persistence, while first-time processing still has real work to perform. Reporting those conditions makes the numbers useful without turning them into a promise about every opening of every project.
Lower memory use permits more complete analysis
The new representation also removes a constraint on diagnostics. Previously, the analysis driver could not analyze dependencies without pulling too much code into the in-memory representation. The v0.12 driver analyzes dependencies and can use their results to improve precision.
The article gives formatting checks as an example. Gopls can now diagnose formatting mistakes through user-defined wrappers around fmt.Printf. The command-line go vet had supported that precision already, but the language server had been unable to provide it after each edit under the earlier memory model.
That is the part of the rewrite I find most consequential. Reduced resource use changes which analysis is feasible during editing. The same package boundaries that allow state to be discarded also let more dependency information participate in diagnostics. The result is a language server that can answer a broader question without restoring the old whole-program memory requirement.