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 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:
- 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.
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:
- 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).
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
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 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-analyzerwill 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 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 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 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.
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-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 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:
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 statej_allocatedis the jemalloc stat for memory the program is actually usingj_activeis the jemalloc stat for internal allocated pages that servej_allocated. It is normal for it to be somewhat higher, since memory is allocated in pages, andj_allocatedtypically does not fully utilize each page.j_residentis the jemalloc stat that includes dirty pages and other jemalloc-internal state.j_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 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:
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.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.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.
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 jemalloc 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
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 --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.