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:
- Idle RAM usage is extremely low, <100mb even for big projects.
- 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 “traditional” proc macro support via remote code execution.
- No proactive/interactive 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:
- Real reason behind them (you used rust glancer, had an issue of sort, and decided to fix it), and
- 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.
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:
- Install the extension from the official marketplace.
- 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).
Forks (cursor, vscodium, etc)
Install the extension from OpenVSX
Installing from VSIX
- (Optional) Install just
- Clone the repository
- Run
just package-vsix(or go toeditors/codeand build vianpm) - Open VS Code, navigate to extensions tab, click on
...and chooseInstall from VSIX. - Install the extension.
- ???
- PROFIT
Zed
Install the Rust Glancer extension from the marketplace.
Minimal required configuration in settings (you need to disable rust-analyzer, which is enabled by default, and enable Rust Glancer):
"languages": {
"Rust": {
"language_servers": ["rust-glancer", "!rust-analyzer"],
},
}
nvim
There exists a nvim-lspconfig configuration contributed by @h-michael.
At the time of writing, it does not support automatic fetching of binaries, so you can either get a prebuilt binary from the releases page or build it yourself.
Other editors
Any editor that supports LSP protocol, should work with Rust Glancer.
Typically all you need is to configure Rust Glancer binary to be the Rust LSP, no special configuration should be required. If you’ll notice any quirks, please create an issue in the repository.
Also, contributions with configuration guides for other editors are welcome (but please make sure that they work; do not submit untested LLM-generated PRs).
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 checkby 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
cfgattributes), andcfg(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 indexes packages in batches. It finishes all main indexing phases for one
package group, saves packages that can be offloaded, and then starts the next group. This keeps less
unfinished indexing data in RAM, but the editor will not have deferred indexing, and thus it will take
longer for initial indexing to finish.
Indexing: Package Batch Size controls the size of a package batch and is used only by
lower-peak-memory. Smaller values normally lower peak memory, but make indexing slower. A value of
1 processes one package at a time when possible; this is the slowest option but has the lowest peak RSS.
If dependencies form a cycle, that cycle and packages waiting on it may have to stay in one larger batch.
The default size is 512: the value has been chosen empirically as a good enough tradeoff, on a very big
workspace it resulted in ~4% slowdown and 2x less memory (but keep in mind that 4% slowdown is compared to
a full indexing, and with lower peak memory you don’t have deferred indexing, you must wait until indexing
is fully complete).
Exact memory and indexing-time differences vary by machine, operating system, and project. If the
default faster-builds mode uses too much RAM, try lower-peak-memory first and adjust the batch
size only if needed.
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-analyzerwill probably work better for you.
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+Pand sendingRust Glancer: Reindex workspacecommand. - Try restarting the server: click on
Rust Glanceron 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 cheat a bit: when hover/completion/etc is requested in a dirty buffer, we take the current entity around the cursor and analyze its current state against the saved project.
This is already enough for typing, and in this example ($ denotes the cursor) you will see
the completions:
#![allow(unused)]
fn main() {
fn calculate_something() {
let foo = SomeComplicatedType::new();
foo.do_some$
}
}
Local variables, types, methods are also available.
However, it gets tricky with new impl blocks. We try to recover new items based on syntax,
but we don’t add them to the project-wise analysis, so, for example, you will see methods from a
newly typed impl block as you type them, same for the trait impls, but they will only work while
you are inside of the corresponding block. If you will move to a different impl, it will not see
the changes from the impl you edited before until you save.
Module-level changes, such as adding a new import, are also not automatically resolved, they only take effect after saving a file.
So for almost all the normal flows it should be convenient, but you will need to save the file when you need a change you made to take effect outside of the place where you type.
If it sounds scary, just try it – it really isn’t, and you can get used to it pretty quickly. And thanks to that, editing experience remains smooth.
Build scripts
Rust Glancer has an intentional policy where aims not to execute code. Part of it is performance-related, part of it is security-related. This is also the reason why diagnostics are disabled by default – the project philosophy is that enabling diagnostics must be a user’s decision, not something shipped by default.
Because of that, we don’t proactively execute build scripts for projects. Instead, we try to observe the existing artifacts and use them. This stage is optional, so failure to discover cargo build artifacts does not stop or fail indexing.
So the logic is roughly:
- if the code has never been built or
cargo cleanhas been run, the analysis will not contain any build script outputs, and most likely it will result in some imports not being resolved. The analysis will still finish and be available to the possilbe extent. - if the code has been built a while ago, outdated artifacts may be used for analysis. In many cases it’s fine, since build script outputs do not change that often.
- if newest possible build script outputs should be used for indexing, a manual reindexing command can be sent
to the server after running
cargo check/cargo build(e.g. throughctrl/cmd+shift+Pin VS Code and “Rust Glancer: Reindex Workspace” chosen there). This way user will first make sure that cargo build artifacts are updated, and then will prompt the LSP to observe them.
Note that if newer artifacts appear during normal reindexing flow, they will NOT necessarily be utilized. Standard reindex-on-save does not check if there is a newer cargo build output. The reason for that is because in most cases reusing past outputs is acceptable, and eager reindexing could make user experience significantly worse.
This is a somewhat manual workflow where user is responsible for providing the outputs that need to be used by Rust Glancer. This model is a compromose to make build scripts supported, while not making LSP responsible for building the actual project to work.
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 metadataand 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.
Mimalloc
We use mimalloc by default, since it reduces memory fragmentation and eagerly returns
unused memory to the system, while having great performance.
Jemalloc
jemalloc was used as the allocator initially, but is only left as an optional configuration
now, since mimalloc is better in overwhelming majority of cases and supports more platforms.
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-glancerdefines the binarycrates/lspdefines the components of the LSP server, e.g. the server implementation itself, engine LSP adapter, and shared protocol.crates/enginedefines the engine components.crates/libdefines the shared semi-general-purpose libraries.editors/codedefines 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 an allocator feature that lets us force the allocator to return
memory to the system. System allocators are usually do not eagerly return
the memory to the OS, but customized allocators give us a way to do it.
This project supports two different allocators: mimalloc and jemalloc,
both built with purging in mind. For jemalloc, purging is an on-demand action,
which we make use of when the project is built with jemalloc; while mimalloc
itself tries to return the memory to the system once it’s not needed. I still
refer to mimalloc built-in behavior as “purging”, since it does basically the same
thing automatically.
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.
Allocator choice
Rust Glancer supports two main allocators:
jemallocmimalloc
mimalloc is the default one, since benchmarks show that it provides significantly
better performance in nearly all the cases, while being generally as memory efficient
as jemalloc. We use it without arenas and do not use purging: with arenas enabled, RSS
is actually noticeably higher and performance boost is very modest, while purging, even
though supported, does not change the situation much (most likely because purging in
mimalloc does not purge process-wide, it only purges thread-local memory).
Additionally, unlike jemalloc, mimalloc supports more platforms, e.g. Windows and OpenBSD.
jemalloc was used to build the application up until its first release, and for now it
remains a supported option. It can offer lower peak RSS with lower-peak-memory configuration,
but it’s generally its only advantage right now. If mimalloc allocations are too high for
you even with lower-peak-memory, you might consider building the app with jemalloc and try it.
Otherwise, it’s slower, and is mostly left “just in case”; there is a good chance that support
will be removed in the future.
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
Allocator 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.
Note that alocator stats are bound to the allocator in use; jemalloc supports
more metrics than mimalloc. This section primarily described jemalloc use case,
since it supports more metrics, but remember that switching allocators might not
transfer the behavior: what’s optimal for jemalloc might not be optimal for mimalloc.
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 a_allocated a_backed a_resident a_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:
checkpointdenotes the “logical checkpoint”phaseis the duration of this step onlyelapsedis the duration since the start of the indexingrg_sampledis memory measured for the checkpoint’s main data structurerg_totalis memory measured for all known live build statea_allocatedis the allocator stat for memory the program is actually usinga_backedis the allocator stat for internal allocated pages that servea_allocated. It is normal for it to be somewhat higher, since memory is allocated in pages, anda_allocatedtypically does not fully utilize each page.a_residentis the allocator stat that includes allocator-internal state.a_mappedis 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 a_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:
a_allocated << a_backed ~= a_resident– likely active memory fragmentation issue. Rough explanation: there are few allocations, but they take a lot of allocated pages.a_allocated < a_backed << a_resident– purging might help; should generally not be the case withmimalloc. 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.a_resident << a_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.
MemorySizeallows 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.Shrinkallows recursively shrinking allocations, where possible. It is mainly relevant for storedstd-backed containers with ashrink_to_fitmethod. 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 thedefaultalias, which records build checkpoints.--profile allcollects every registered profile.-m/--memoryadds retained-memory and allocator stats to build checkpoints, plus the final retained-memory breakdown.--profile memory:def-maprecords a detailed memory breakdown for the def-map checkpoint. Other available aliases and selectors are listed injust analyze --help.--package-residency <policy>controls what remains resident after indexing.all-offloadableis useful for checking idle-memory behavior, whileall-residentis useful when you want to remove offloading from the experiment.-l/--loadenables startup cache loading, so matching offloadable package artifacts can be reused instead of rebuilt.--indexing-preference <preference>switches betweenlower-peak-memoryandfaster-builds.--profile macrosprints defmap macro-expansion counters, timings, and by-name tables.--format jsonis 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
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 --memoryPrints the report data as text output. - HTML, e.g.
just analyze . --profile --memory --format htmlGenerates a timestamped HTML report intarget/rust_glancer/reportWorks 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.
Capturedmeans “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 asUnknownuntil 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.