Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust Glancer

Rust Glancer is an experimental LSP implementation that tries to use a different architecture compared to rust-analyzer in order to lower RAM usage and make editor restarts faster.

rust-analyzer keeps all the data in RAM and uses incremental model that lazily recomputes data that is needed to provide editor support.

Rust Glancer instead uses frozen workspaces: indexing is performed eagerly, and thanks to that we have all the data we need, so we can store it on the filesystem. After indexing is done, data is stored on the filesystem, and is only loaded for the duration of the LSP query.

It means that:

  1. Idle RAM usage is extremely low, <100mb even for big projects.
  2. Since we store data on filesystem, if the project was indexed and you relaunch the editor without changing the code, you get your indexed workspace nearly instantly.

It does not come for free, though:

  • Query execution on average is slower than in rust-analyzer, though it is still in an “acceptable” territory from the human perception (e.g. think that completions show up in 100s of milliseconds, not in 10s of seconds).
  • Since we eagerly index everything, indexing might be slower than one in rust-analyzer (though it depends on project and configuration).
  • In order to make editing acceptable, we need to use some hacks, making analysis of dirty buffers less precise than one of saved buffer. Indexing happens only on save, so when you’re typing we’re performing a “shallow” analysis. It’s better covered in limitations.

Another important property is that workspaces are handled by dedicated handlers and are lazy: indexing doesn’t start until you open a project. Similarly, if something goes wrong with one of the projects (rust workspace) in vs code workspace (multiple projects), other engines are not affected.

Now, obviously Rust Glancer is a much younger project than rust-analyzer, so it’s less complete:

  • Type inference doesn’t work in some cases
  • There are bugs here and there
  • Advanced features like proc macros are not supported.

For some the above can be a deal breaker, but I’m using Rust Glancer to build Rust Glancer (and for other projects I’m working on) and I find it sufficient even in its current form. And oh boy do I enjoy this <100mb RAM usage.

So if that doesn’t scare you, you are very welcome to install and try it out. Hope you’ll like it!

Project scope

LSP is an ambitious project, so it can only exist if the scope is bounded.

At least, at the time of writing, the goals of the project are (in that order):

  • Extreme memory efficiency, <100mb RSS per active engine for any realistic project.
  • Reasonably fast indexing and ~instant startup with an existing index. Launching an IDE after you booted your PC shouldn’t make you wait a minute or two with CPU going brrr.
  • Maintainable. Stupid (in a good way) code is preferred even if it makes it more verbose, rather than having overly smart code. It is important that I know the whole codebase and can orient myself there.
  • Provide enough data for day-to-day work. Normal things should work kind of well; it’s fine if some things are not implemented as long as users get ~90% of a complete LSP.

As a result, the following things are expected:

  • Type inference is not expected to be complete.
  • Trait solving is not expected to be complete.
  • No proc macro support.
  • No build script resolving.
  • New or changed module-level things (declarations, impls, derives, imports, and so on) become fully available after save. While the file is dirty, we still analyze the current function body using the last saved project, but we don’t build a second unsaved project in the background.
  • Cross-file operations may ask you to save first. They use locations from the saved project, so returning them for different open text would be worse than returning nothing.

An additional implication is:

No unneeded features. If something is added, it means that it affects a significant chunk of users. “Good to have because why not” features are not really for this project.

An example of that is unstable nightly features, especially ones that are expected to change often. At least until the project provides good enough coverage for stable, we don’t want to start working on nightly-only features (especially big ones like const generics or specialization).

Some of these things might change in the future, but only if they don’t sacrifice the goals stated above.

We already have a complete implementation of Rust LSP – rust-analyzer. So it’s OK for this project to prioritize something else over completeness; otherwise we will inevitably end up with a rust-analyzer 2.0, which is explicitly not a goal.

Contributing

Thanks for your interest in the project!

I’ll be honest: I don’t have that much time to review PRs, so I can’t promise that I’ll be able to look at every incoming PR (though I’ll try).

The best way to make sure that your PR will be reviewed is to open an issue first and indicate that you’d like to work on it (or volunteer in an already open issue), and then wait for my confirmation. The goal here is not to gatekeep, but to maximize the chances that your work will actually be merged.

Some basic rules:

PRs are welcome as long as they are meaningful

Small typo fixes, non-functional changes, unsolicited refactorings reduce my capacity to actually work on the project. I would love to see contributions, but they must be a combination of:

  1. Real reason behind them (you used rust glancer, had an issue of sort, and decided to fix it), and
  2. Personal effort (you take full responsibility for the PR).

No unsolicited features

The goal of this project is to stay small for as long as possible. This means that we’ll only be adding features that are really needed and are in demand. This, in turn, means that for features that people really want, there is an issue with some indication of support.

It is not a hard rule; if you are sure that the feature is a must-have and absolutely uncontroversial (or if you’re rustc/rust-analyzer/clippy/etc maintainer and know what you’re doing), you are free to open a PR without a prior issue. Use your best judgement.

AI use policy

You are free to use AI, but you must be responsible for it.

If I suspect that the pull request had no human review before opening, I’ll close such a PR. This is subjective, but I’m a single person and my time is limited. You must understand that the cost of generating a PR with an LLM and the cost of actually reviewing such a PR are heavily disproportionate.

It is not required, but highly recommended to disclose use of AI in PR descriptions.

Installation

Before anything else, rust-src is mandatory for the project to work correctly, so don’t forget to run rustup component add rust-src if you’re not sure if you have it installed.

Right now, the primary editor for Rust Glancer is VS Code. It should be usable with other editors that natively support LSP, and you can try following the instructions rust-analyzer provides.

Native support for more editors is expected in future.

When you install Rust Glancer, do not forget to turn rust-analyzer off. They should work together just fine (I’ve done that and did not have any conflicts), but it’s kind of meaningless to run both.

VS Code

You have two options:

  1. Install the extension from the official marketplace.
  2. Build and install VSIX from the repository.

The extension is maintained and will be updated, but given that VS Code extensions often become targets of attacks nowadays, I’d probably recommend building from source (or at least disabling auto-updates). Please do not forget to update it from time to time though. There will be good things in updates (probably).

Installing from VSIX

  1. (Optional) Install just
  2. Clone the repository
  3. Run just package-vsix (or go to editors/code and build via npm)
  4. Open VS Code, navigate to extensions tab, click on ... and choose Install from VSIX.
  5. Install the extension.
  6. ???
  7. PROFIT

Configuration

Rust Glancer comes with a configuration that is meant to be optimal for casual use:

  • All packages are offloaded to filesystem (minimal RAM usage).
  • Bodies are only indexed for the workspace (dependencies receive semantic analysis only).
  • Some tweaks that can change burst RAM/speed balance during indexing are optimized for indexing speed.

There is one caveat: cargo check (or rather, any diagnostics) are disabled by default. It is intentional: since the project is all about being non-intrusive, I think it should be a conscious choice to enable diagnostics. So if you need them, please go to settings and enable diagnostics on startup/save.

Otherwise, typically you don’t need to tweak the settings to make something work better. You can, though. Tweaking options are covered from the more “traditional” configuration options.

Configuration options

You can configure all the typical things you might want to configure:

  • Command for diagnostics and arguments for it (cargo check by default).
  • Enable/disable diagnostics on startup / on save. Keeping them disabled makes Rust Glancer indexing flow feel significantly faster, so it can be reasonable if you use something else to observe diagnostics, e.g. bacon.
  • Configure cargo features / enable all features / disable default features.
  • Configure cargo target triple, cfg atoms (e.g. custom cfg attributes), and cfg(test).
  • Extra env vars for cargo commands (e.g. RUSTFLAGS)

What’s important here is that there is a (somewhat poorly named) Cargo: Overrides config. It allows you to override cargo target and feature settings per cargo workspace. This is useful if one VS Code workspace has several cargo workspaces inside. Imagine that one project compiles to RISC-V only, another is only for linux, and they have conflicting features: you can still keep them open in one VS Code instance at the same time using this feature.

The field is an array of objects. Each object has a path for the exact cargo workspace root, absolute or relative to a VS Code workspace folder. It can override target, allFeatures, noDefaultFeatures, and features.

For example:

{
  "rust-glancer.cargo.overrides": [
    {
      "path": "firmware",
      "target": "riscv32imac-unknown-none-elf",
      "noDefaultFeatures": true,
      "features": ["board-v1"]
    }
  ]
}

Another (maybe) important configuration is Indexing: Performance Preference: lower-peak-memory makes the indexer use less concurrency, making it slower, but also reducing peak allocations during indexing. Exact reduction can vary by machine/os/project, so if the default (faster builds) does not work for you and you are affected by high peak RAM, you can try changing this option.

Tweaking options

Following settings can be changed, but probably shouldn’t.

  • Cache: Package Residency: you can make it so that not everything is offloaded to the filesystem. In theory, it can make Rust Glancer faster. In practice, if you don’t care about memory usage, rust-analyzer will probably work better for you.
  • Server: Purge Memory After Build: probably shouldn’t even be a configuration option. Returns unused memory back to OS with no visible downsides.

Limitations

Rust Glancer is incomplete, and has a bunch of quirks that are worth knowing about.

Ultimate advice

If something unexpected happens: you start getting a lot of errors, index is messed up, LSP stops responding, etc:

  • Try hitting ctrl/cmd+shift+P and sending Rust Glancer: Reindex workspace command.
  • Try restarting the server: click on Rust Glancer on the bottom left of VS Code.
  • If that doesn’t help, stop the editor, remove target/rust_glancer, and start again.

If you know how to reproduce the issue, it would be great if you also report it.

It shouldn’t happen often, but you know how it is with young software. I intentionally don’t implement any sophisticated recovery mechanisms: the idea is that the LSP should never fail, so when it does, it has to be loud. So if you meet a crash or indexing issue – sorry, but I hope that it will help us build a very reliable project long term.

Dirty buffers

Frozen workspace analysis can work on each keystroke, and it is actually usable, but it falls into a category that is workable but annoying enough to drive one insane. So to mitigate that, dirty buffers use partial analysis: we recompute the bodies that were affected, and we perform a somewhat “shallow” analysis.

It means that we can infer types as you type inside of the function, the completions for already known structures/functions/traits will work, but we cannot see new items. If you type the following without saving:

#![allow(unused)]
fn main() {
struct FooBar;

impl Foo$
}

you will not see FooBar in completions, because adding structure to analysis will require doing a lot of extra work. Similarly, we cannot add imports to the scope as you type, so the HashMap will not be suggested if you will type the following without saving:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn foo(h: HashMa$)
}

So it’s important to obtain a mental model where you need to save whenever you add something meaningful – struct, trait, import, function.

It might take a bit of time to adjust, but if your flow wasn’t like this already, I can promise that it might feel pretty natural after a bit of time.

Architecture overview

This project implements an LSP server and VS Code LSP client extension. The server supports multiple workspace folders per project, and at a high level it works as follows:

  • The client talks to a single LSP server implementation.
  • The LSP server implementation acts as an orchestrator over different engines, one engine per “real” workspace.
  • An engine owns the implementation of LSP “functionality”, e.g. indexing a workspace and answering queries.

Whenever a Rust document is opened, the extension activates and asks the LSP server to initialize. Upon a document opening, the LSP server resolves the workspace root for it, checks that there is no active engine for this workspace, and starts a new engine.

Then, when a new document is opened in the same LSP project, it will either be routed to an existing engine, or, if the document doesn’t belong to any open workspace, a new engine will be spawned.

For dependencies, we try not to spawn an engine, since engine analysis already covers them. Right now, we don’t have any fancy logic there, and we assume that Rust files outside of the project folder should be handled by the last active engine; e.g. the expectation is that typically one gets outside of the project folder in the editor when they’re following goto definition or some similar query.

Each engine is spawned as a subprocess, and communication between engines and the LSP server is implemented using tarpc. The communication is quasi-bi-directional: each engine exposes a tarpc service that the LSP server can interact with; the service is not LSP, it’s a domain-specific implementation, though obviously heavily LSP-shaped. Additionally, the LSP server itself exposes a service that all the engines can use to send notifications, e.g. diagnostics or progress reports. So, the LSP server can send queries and notifications to engines, while engines can only send notifications to the LSP server.

Engine

The engine is built to be used by the LSP server, but it is not tied to one; the implementation is protocol-agnostic, and there exists an integration layer that converts the engine data to LSP data.

The engine normally starts by indexing the workspace. Indexing has the following phases:

  • Metadata inspection: call cargo metadata and extract dependency graph and other useful things.
  • Parsing: going through every crate and converting raw text to AST.
  • Item tree building: converting raw AST nodes to the basics of our internal representation, without any attempt to analyze them yet.
  • DefMap building: resolving modules, imports, and item “locations”. At this stage, we know what exists where. Additionally, at this stage we resolve declarative macros.
  • Semantic IR building: resolving information on the item level, e.g. struct declarations, traits, which structures implement which traits, etc. This includes the macro-generated items too.
  • Body IR building: resolving information on the body level, e.g. within a function body, which variable has which type. Body data is the heaviest across everything else, both in terms of size and complexity, so by default we only analyze bodies in the workspace, assuming that you don’t need information about bodies in your dependencies. It is configurable, though.

All of this information is collected into a Project, which represents a frozen snapshot of collected data.

Additionally, for a Project, we can create an Analysis, which can be used for queries, e.g. to ask which traits this type implements or what completions are available at this cursor.

To support Analysis, we have a bunch of helper crates that provide abstract interfaces and query surface, such as ir-storage / ty / ir-view.

Frozen workspaces

Probably the most important fact about the engine is that it is frozen, not incremental. This means that we index the workspace once, and then run the queries against the data we’ve managed to collect. If something changes, we have to reindex (at least partially); we cannot update the data in place.

Open editor buffers are captured as exact text values. Routing keeps both the raw editor path and the source identity selected from the frozen Project; a later rename or removal does not make an open session rediscover that identity from the filesystem.

The important part here is that there is still only one global Project, and it is made from saved files. Saving a file (or seeing an external filesystem change) may replace it with a new project generation. Typing does not create a slightly different project after every key press.

This does not mean that everything in a dirty buffer is ignored. For queries about the current document, the server sends the text that is actually open in the editor. The engine can rebuild the current function, const, or static body and analyze it using declarations, traits, impls, and indexes from the saved project. So newly typed locals and expressions work without a save. The rebuilt body is thrown away after the request; it is not a tiny unsaved project or a long-lived document cache.

The tradeoff is that new or significantly changed module-level things may need a save. Imagine that you add a new struct and immediately use it in the same dirty buffer: syntax-based features can see the struct, but global semantic features may still know only the previously saved project.

Cross-file operations, such as references and rename, are stricter because they return saved source locations or edits. Before running one, the engine checks all relevant open Rust documents. If one of them differs from the saved project (or isn’t part of it yet), it asks you to save instead of returning a location that may now point at something else. This rule is implemented in the server/engine boundary, so VS Code, Zed, Vim, and other clients get the same behavior.

It is a limitation, but working with frozen workspaces enables really cool memory optimizations that we make heavy use of. These are described below.

Memory efficiency

Memory efficiency is the main goal of this project. A lot of optimizations are implemented for that.

Allocation optimizations

Wherever possible, we use arenas for allocations, and we try to only keep things in memory that are really needed. So, for example, during indexing we drop ItemTreeDb once we’ve collected all the information we needed from it.

The approach to memory allocations is twofold:

  • If you don’t need to allocate, do not allocate.
  • If you allocate, try to do it in a way that minimizes fragmentation.

Jemalloc

We use jemalloc, since it reduces memory fragmentation, exposes memory statistics, and gives control over the allocator.

Using jemalloc-ctl, we try to purge memory each time we assume that we’ve done a bunch of allocations that are no longer used. It’s fast enough for users to not notice during normal LSP interaction flow, and it keeps RSS as low as possible.

Because of that approach, memory fragmentation can be a huge issue: if we allocate a bunch of things in random locations, the allocated memory can stay low, but active will be high, meaning that we cannot return memory to the OS.

Package offloading

This is the biggest win. Since we’re using frozen queries, the index can be offloaded to the file system. And the engine can work on top of an offloaded index, e.g. the index will only be loaded in a transaction for the relevant packages, and only for the duration of the query. Then, the transaction will be dropped, and memory is freed.

As a cool side effect, the engine starts immediately if the index is not invalidated, so you get instant or almost instant restarts.

The offloading is per-package, so invalidation of a single package does not mean invalidation of the whole index. The intended approach is to offload everything, but it’s possible to choose what you actually want to offload.

Engines in separate processes

Separate processes are not technically required for the engines, but if we had multiple engines in the same process, we could get into scenarios where a new engine is spawned while another engine is indexing, meaning that allocations will happen in random places, and the memory will be extremely fragmented.

Keeping each engine in a separate process makes it much easier to ensure that the memory layout is stable.

Code layout

  • crates/rust-glancer defines the binary
  • crates/lsp defines the components of the LSP server, e.g. the server implementation itself, engine LSP adapter, and shared protocol.
  • crates/engine defines the engine components.
  • crates/lib defines the shared semi-general-purpose libraries.
  • editors/code defines the VS Code extension.

Development guidelines

Prerequisites

just and cargo-nextest.

Development commands

The recommended way to work with the project is via the project Justfile and the extension Justfile.

Extension just commands can be invoked via submodules, e.g. just client build.

Comments philosophy

I don’t believe in “code must be self-documenting”; if your code is at least somewhat non-trivial, annotate it. The documentation should be concise; it’s mostly a hint to the reader on what they should anticipate, and should focus on the context rather than on actions.

Testing philosophy

Snapshot tests are preferred when possible. Snapshot tests tend to both be more declarative, allow testing more behavior, and survive refactoring much better than usual tests.

We use expect-test for snapshot tests in most cases.

Benchmarking, profiling and measuring memory

This project has commands to measure memory usage, profile, and benchmark the project.

For profiling and measuring memory, check out:

just analyze --help

Note that measuring memory affects profiling, so don’t interpret the phase timings as a source of truth when also measuring memory. Treat these as two different modes.

Benchmarks can be run with

just bench

Memory approach

This project cares a lot about memory allocations. This document outlines how this is done, and what tooling and techniques are commonly used for that purpose.

Offloading & purging

The two biggest memory savers in this project are offloading and memory purging.

Offloading lets us write computed analysis data to disk and only load the relevant bits for the duration of the query.

Purging is a jemalloc feature that lets us force the allocator to return memory to the system. Typically, even if some allocations were freed, the allocator will not rush to return memory to the system, since this memory may be reused for new allocations. In our case, however, we care about idle RAM much more than about peak performance: LSP is human-driven, so the queries appear on the “seconds” timeline, not “microseconds”, and we don’t really worry about a few more syscalls.

That said, while these techniques reduce idle memory usage, they are not a replacement for optimizing the phase itself. Purging can reduce RSS across phases, but it does not reduce peak RSS within a phase per se.

One of the caveats, for example, is memory fragmentation: if memory is significantly fragmented, purging will not help much, since the allocator will not be able to give big enough chunks back to the OS.

So, the rest of the document is focused on the particular memory optimization techniques we use outside of purging and offloading.

Startup cache loading

Honorary mention: we don’t only offload cache while LSP is active; since the cache is already on the filesystem, we try to reuse it on relaunch. Cache is fingerprinted, so on startup we’re evaluating whether the cache is compatible with the current state of the project, and if so, we can get indexed workspace at startup for free.

This is intentionally opportunistic and per-package: the indexing remains reasonably fast anyway, so if package cache probing fails for any reason, we treat it as a miss and re-index the corresponding package from source.

Memory optimizations

Jemalloc stats & notation

The core command for analyzing memory allocations is just analyze, and it prints plenty of stats. The “default fixture” for that is rust-analyzer on a specific commit, since it’s a big enough, stable, and complex “real-world” project. It can be fetched by running test_targets/bench_fixtures/fetch-rust-analyzer.sh.

One key part of the output is the project.build.checkpoints profile table:

$ just analyze path/to/rust-analyzer --profile --package-residency all-offloadable -m
# .. some parts omitted
profile snapshot:
 phase     elapsed    rg_sampled      rg_total   j_allocated      j_active    j_resident      j_mapped  checkpoint
150 ms      150 ms      60.8 MiB      60.8 MiB      70.6 MiB      76.9 MiB      83.3 MiB      85.7 MiB  after parse
  0 ms      151 ms       2.3 KiB      60.8 MiB      70.6 MiB      76.9 MiB      83.3 MiB      85.7 MiB  after cache probe
1.21 s      1.36 s     227.2 MiB     285.0 MiB     282.7 MiB     500.7 MiB     602.9 MiB     628.0 MiB  after item-tree
 58 ms      1.42 s      26.0 MiB     274.8 MiB     273.0 MiB     493.8 MiB     504.8 MiB     529.8 MiB  after item-tree syntax eviction
157 ms      1.58 s      16.5 KiB     274.8 MiB     273.3 MiB     494.1 MiB     512.6 MiB     537.6 MiB  after cache source fingerprints
3.80 s      5.38 s     126.8 MiB     402.4 MiB     396.9 MiB     641.8 MiB     660.4 MiB     683.8 MiB  after def-map
253 ms      5.63 s     159.7 MiB     562.1 MiB     543.5 MiB     783.2 MiB     831.3 MiB     854.7 MiB  after semantic-ir
145 ms      5.77 s             -     332.4 MiB     331.1 MiB     421.6 MiB     831.3 MiB     854.7 MiB  after item-tree drop
1.92 s      7.70 s     278.1 MiB     613.4 MiB     610.0 MiB     691.7 MiB     711.0 MiB     733.7 MiB  after body-ir
140 ms      7.84 s      26.0 MiB     600.5 MiB     593.1 MiB     663.1 MiB     714.5 MiB     737.2 MiB  after parse syntax eviction
 91 ms      7.93 s     603.7 MiB     603.7 MiB     591.8 MiB     660.8 MiB     714.5 MiB     737.2 MiB  before package cache write
1.34 s      9.27 s     603.7 MiB     603.7 MiB     599.1 MiB     675.0 MiB     911.6 MiB    1001.6 MiB  after package cache write
214 ms      9.48 s      36.0 MiB      36.0 MiB      46.2 MiB     110.5 MiB     675.8 MiB     698.2 MiB  after package payload offload
 17 ms      9.50 s       7.3 MiB       7.3 MiB      10.6 MiB      38.2 MiB     675.8 MiB     698.2 MiB  after package offload cleanup
 23 ms      9.52 s       7.3 MiB       7.3 MiB       7.8 MiB      33.3 MiB      53.0 MiB      75.3 MiB  after project

The information should be interpreted as follows:

  • checkpoint denotes the “logical checkpoint”
  • phase is the duration of this step only
  • elapsed is the duration since the start of the indexing
  • rg_sampled is memory measured for the checkpoint’s main data structure
  • rg_total is memory measured for all known live build state
  • j_allocated is the jemalloc stat for memory the program is actually using
  • j_active is the jemalloc stat for internal allocated pages that serve j_allocated. It is normal for it to be somewhat higher, since memory is allocated in pages, and j_allocated typically does not fully utilize each page.
  • j_resident is the jemalloc stat that includes dirty pages and other jemalloc-internal state.
  • j_mapped is the mapped OS range, not necessarily fully allocated.

Note: rg_total is an estimate, since we can’t count everything perfectly, but it should be close enough to j_allocated. The reason why it’s important is because our internal harness also records what is allocated and where: as long as it’s accurate, we can make educated guesses on where we can save on allocations themselves.

Here are some heuristics that can be used to reason about this table:

  1. j_allocated << j_active ~= j_resident – likely active memory fragmentation issue. Rough explanation: there are few allocations, but they take a lot of allocated pages.
  2. j_allocated < j_active << j_resident – purging might help. Rough explanation: we have experimented with different jemalloc configurations, but the default configuration appears to be a good fit for this project, so in most cases explicit purging is the lever we care about.
  3. j_resident << j_mapped – not necessarily bad, but might be worth investigation, might signal excessive transient allocations in the past and could signal unexpected peak RSS.

Important caveat: this table prints data after the phase, so it does not represent peak allocation, which can be much higher during the phase because of transient allocations. For peak RSS, extra profiling might be required.

Layered allocations

The core memory optimization we have is layered allocations. A typical expected flow for lowering would be per-package, e.g. do “item tree -> defmap -> semantic -> body” for each package. This is expected, but it has a flaw: interleaving allocation lifetimes.

Some of the data during lowering/resolution is transient, and it will be removed after the phase. If we do all the phases for each package separately, it will leave a lot of “holes” in memory, which will be filled by allocations from the newly processed packages, which… Well, in the end we will end up with highly fragmented memory.

What we do instead is optimize for memory allocation lifetime. We lower the same phase for all the packages first, and then we shrink and compact it.

During lowering, the most important bit to think about is whether the allocations from this phase will intersect with allocations from the next phase: it’s bad if they will.

An example: during item tree lowering, we should not do parsing in parallel, since two phases have different lifetimes. We should first parse files, and then perform item tree lowering, since item tree lives longer than syntax, and parse syntax eviction should free up a continuous chunk of memory, not slots between item tree allocations.

Compacting

Compacting is important: imagine that we have a Vec of interim Builder objects and we need to “freeze” it into a Vec of “built”/“frozen” objects. If we shrink the object and then allocate the “final” object right away, it will likely go to the just-freed memory space. And if we repeat it for every object, we will end up with a lot of holes between objects.

Instead, we can shrink all the objects, and while the old objects are alive allocate a vector of new objects: these will be compact in memory, since we will allocate the whole chunk right away with no gaps. After that, it will be safe to drop the “old” objects – they will free up all the capacity. Given that this will happen at the end of the stage and will be followed by a purge, we’ll end up with cleaner memory layout.

Note: bump allocators could help with transient storage, but they have their own costs; we’ve briefly experimented with these, and the result was that they yield similar results to manually optimized allocations, but have much worse ergonomics, and in some cases can worsen peak RSS due to arena allocation overhead.

Eager eviction

It was already mentioned above, but eager eviction is also one of the major strategies: we keep data only while it’s needed. For example, parse syntax globally is only needed until we lower item tree. After that, we can lower semantic tree based on item tree only. For body layer, we will need syntax again, but on a per-file basis: we parse it for the file only, lower all the bodies, and then drop it.

Parallelism tweaking

We use parallelism heavily for indexing, but while a higher thread count can make things faster, it can also increase the volume of transient allocations: more threads will consume more pages, while not keeping them fully utilized. This will be freed after the phase, of course, but it can increase the peak RSS quite heavily.

The main current offender is declarative macro expansion: it allocates a lot of transient syntax trees, and on my current machine unconstrained macro expansion uses 16 threads and processes the defmap stage in ~3.2s with ~3.1GB peak RSS, while capping macro expansion to 2 threads increases defmap time to ~3.9s, but lowers peak RSS to ~1.8GB.

Here it’s a trade-off, so we let users choose what they value more: peak RSS or indexing speed.

Memory tooling & techniques

This section outlines the strategies you can use to analyze memory allocation patterns during development.

MemorySize & Shrink

These traits and derive macros are core workspace infrastructure for memory measurement and compaction.

  • MemorySize allows recording the size of the object, including not only the shallow size of the object itself, but also the size of its children and approximate overhead. It can distinguish certain and approximate allocations, and it can record what is being allocated and where (e.g. object type and object scope). These measurements are not automatic, but derive support makes them convenient to add in all the necessary places. It is possible to forget it somewhere, but so far it’s pretty consistent, and through profiling it is possible to detect if counting becomes very inaccurate.
  • Shrink allows recursively shrinking allocations, where possible. It is mainly relevant for stored std-backed containers with a shrink_to_fit method. During indexing, quite a lot of containers might have unpredictable size and end up with spare allocated capacity. Based on experiments, so far it has proven to be a better solution ergonomically than bump allocators.

MemorySize and Shrink usually go hand in hand – if you want to measure something, you probably also want to shrink it, and if you want to shrink something, it is certainly worth measuring.

just analyze

This is the baseline command. For details, run just analyze --help.

Useful flags:

  • --profile [selectors] collects dynamic profile data. Without a value, it uses the default alias, which records build checkpoints. --profile all collects every registered profile.
  • -m / --memory adds retained-memory and jemalloc stats to build checkpoints, plus the final retained-memory breakdown.
  • --profile memory:def-map records a detailed memory breakdown for the def-map checkpoint. Other available aliases and selectors are listed in just analyze --help.
  • --package-residency <policy> controls what remains resident after indexing. all-offloadable is useful for checking idle-memory behavior, while all-resident is useful when you want to remove offloading from the experiment.
  • -l / --load enables startup cache loading, so matching offloadable package artifacts can be reused instead of rebuilt.
  • --indexing-preference <preference> switches between lower-peak-memory and faster-builds.
  • --profile macros prints defmap macro-expansion counters, timings, and by-name tables.
  • --format json is useful if you want to compare reports with a script.

Note: this command works for both performance and memory analysis, but memory analysis is not free – it makes indexing slower. So for accurate data on performance, use this command without -m/--memory.

Samples:

“Default go-to command” – analyze with full offloading

just analyze path/to/rust-analyzer --profile --package-residency all-offloadable -m

Exclude offloading from analysis:

just analyze path/to/rust-analyzer --profile --package-residency all-resident -m

Check what a real startup with cache hits looks like:

just analyze path/to/rust-analyzer --profile --package-residency all-offloadable --load -m

Inspect one suspicious checkpoint in more detail:

just analyze path/to/rust-analyzer --profile memory:def-map --package-residency all-offloadable -m

Compare the speed/memory trade-off for macro expansion:

just analyze path/to/rust-analyzer --profile --package-residency all-offloadable --indexing-preference faster-builds -m
just analyze path/to/rust-analyzer --profile --package-residency all-offloadable --indexing-preference lower-peak-memory -m

If defmap is suspicious, add macro stats:

just analyze path/to/rust-analyzer --profile default,macros --package-residency all-offloadable -m

To disable memory purging, you can set RUST_GLANCER_PURGE_MEMORY_AFTER_BUILD=0 environment variable.

Peak RSS

The above commands will not give you peak RSS. For peak RSS, a good idea is to use time.

For zsh:

TIMEFMT='%J  %U user %S system %P cpu %*E total %M max_rss'
time just analyze path/to/rust-analyzer --profile --package-residency all-offloadable -m

Note that depending on platform, max_rss might be in KiB or MiB.

Profiling rust-glancer

This project has pretty good infrastructure for profiling the indexing pipeline. It comes in three flavors:

  • Indexing checkpoints
  • Memory profiling
  • Stats gathering.

Profiling is done primarily with rg_profile crate. It provides a way to declare profiling descriptors of different kinds, e.g. counters, gauges, named metrics families, and checkpoints.

Checkpoints can be seen as a series of measurements, where measurement is a row with fixed set of columns.

Each profiling metric has a scope in form of foo.bar.baz, which enables reverse filtering: foo.bar enables [foo.bar, foo], but not foo.bar.baz, which is convenient for enabling profiling up to a certain level.

The best way to learn the syntax is to look for examples, primarily in the rg_project crate, and to see doc-comment on the declare_metrics macro.

Memory

Memory profiling is explained in greater detail in MEMORY.md.

Reporting

We support several options for profile report generation:

  • text (default), e.g. just analyze . --profile --memory Prints the report data as text output.
  • HTML, e.g. just analyze . --profile --memory --format html Generates a timestamped HTML report in target/rust_glancer/report Works best for full analysis, e.g. just analyze . --profile all --memory --format html
  • JSON, e.g. just analyze . --profile --memory --format json

Important: profiling is not without overhead. If you collect more data (e.g. retained memory for each phase, or unresolved macros), the run will become slower, but it is not indicative of slower indexing. For the most accurate measurements, use just analyze . --profile, as it mostly just records timings per phase, without doing any expensive measurements.

Project vocabulary

This project uses a set of terms and naming conventions that are expected to be internally consistent, but do not necessarily match the strict formal definitions used elsewhere.

This document aims to provide better intel on how these are used.

Source and request state

  • Saved project generation: the globally indexed state built from saved files. It contains packages, modules, declarations, traits, impls, indexes, and saved bodies. A save or an external filesystem change can replace it; typing one more character cannot.
  • Captured source: source text and its path fixed at one boundary and then carried together. We don’t read the path again later and accidentally get different bytes. Captured means “already fixed”, not “waiting to be applied”.
  • Editor document snapshot: the text and identity of one open document revision. LSP ingress owns the live document and gives async handlers snapshots that cannot change underneath them.
  • Current body: a function, const, or static body rebuilt from an editor document snapshot for one request. It can contain newly typed locals and expressions, while module-level declarations, traits, and impls still come from the saved project. It disappears when the request is done.
  • Current body build summary: records which editor bodies were rebuilt for one request and why another crate interpretation could not be rebuilt. This is diagnostic information about the build, not another kind of query result.
  • Global operation: a cross-file query such as references, rename, or goto implementation. Its identities and source locations come from the saved project, so it checks relevant open documents first and asks for a save if those locations cannot be used safely.

Object families

There are several consistent “families” identified by their suffix:

  • *Id: object indices for arenas, used for lookup.
  • *Ref: stable references to objects across storage boundaries. A ref usually carries enough origin context to route lookup to the right store before applying the underlying id.
  • *Data: stored shape of an entity. Data answers “what is this thing?”, owns the structure needed for traversal or lookup, and should be valid immediately after the entity is lowered or collected.
  • *Facts: pass-derived knowledge attached to an existing entity. Facts answer “what have later phases concluded about this thing?”, can usually be recomputed from data plus surrounding context, and may start as Unknown until the relevant pass fills them.
  • *View: read-only projection over stored or indexed data. Views are usually query-facing and may combine several lower-level stores into the shape needed by downstream analysis.
  • *Store: owning indexed storage for a phase, target, or lexical boundary. Stores usually hold arenas, maps, or other lookup tables and are the main place where ids become addressable data.
  • *Query: algorithm object that carries the sources and context needed to answer one class of lookup question. Queries keep routing and temporary resolution state close to the operation.
  • *Builder: mutable construction-phase counterpart to frozen data or stores. Builders collect, allocate, and connect entities before producing the immutable shape used by later phases.
  • *Signature: compact declaration header retained for semantic queries and display. Signatures preserve the parts of an item header that affect type, navigation, hover, or completion behavior.
  • *Resolution: selected target or targets of name, path, or type lookup. Resolution values describe what lookup concluded, including explicit unknown or ambiguous shapes when needed.
  • Resolve*Result: richer result shape returned by a resolution algorithm. These results may include the selected targets together with traversal metadata, partial-resolution state, or failure position details.