Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: Speed up search for short associated functions, especially very common identifiers such as new #17927

Merged
merged 3 commits into from
Aug 23, 2024

Conversation

ChayimFriedman2
Copy link
Contributor

@ChayimFriedman2 ChayimFriedman2 commented Aug 18, 2024

@Veykril said in #17908 (comment) that people complain searches for new() are slow (they are right), so here I am to help!

The search is used by IDE features such as rename and find all references.

The search is slow because we need to verify each candidate, and that requires analyzing it; the key to speeding it up is to avoid the analysis where possible.

I did that with a bunch of tricks that exploits knowledge about the language and its possibilities. The first key insight is that associated methods may only be referenced in the form ContainerName::func_name (parentheses are not necessary!) (Rust doesn't include a way to use Container::func_name, and even if it will in the future most usages are likely to stay in that form.

Searching for :: will help only a bit, but searching for Container can help considerably, since it is very rare that there will be two identical instances of both a container and a method of it.

However, things are not as simple as they sound. In Rust a container can be aliased in multiple ways, and even aliased from different files/modules. If we will try to resolve the alias, we will lose any gain from the textual search (although very common method names such as new will still benefit, most will suffer because there are more instances of a container name than its associated item).

This is where the key trick enters the picture. The key insight is that there is still a textual property: a container namer cannot be aliased, unless its name is mentioned in the alias declaration, or a name of alias of it is mentioned in the alias declaration.

This becomes a fixpoint algorithm: we expand our list of aliases as we collect more and more (possible) aliases, until we eventually reach a fixpoint. A fixpoint is not guaranteed (and we do have guards for the rare cases where it does not happen), but it is almost so: most types have very few aliases, if at all.

We do use some semantic information while analyzing aliases. It's a balance: too much semantic analysis, and the search will become slow. But too few of it, and we will bring many incorrect aliases to our list, and risk it expands and expands and never reach a fixpoint. At the end, based on benchmarks, it seems worth to do a lot to avoid adding an alias (but not too much), while it is worth to do a lot to avoid the need to semantically analyze func_name matches (but again, not too much).

After we collected our list of aliases, we filter matches based on this list. Only if a match can be real, we do semantic analysis for it.

The results are promising: searching for all references on new() in base-db in the rust-analyzer repository, which previously took around 60 seconds, now takes as least as two seconds and a half (roughly), while searching for Vec::new(), almost an upper bound to how much a symbol can be used, that used to take 7-9 minutes(!) now completes in 100-120 seconds, and with less than half of non-verified results (aka. false positives).

This is the less strictly correct (but faster) branch of this patch; it can miss some (rare) cases (there is a test for that - goto_ref_on_short_associated_function_complicated_type_magic_can_confuse_our_logic()). There is another branch that have no false negatives but is slower to search (Vec::new() never reaches a fixpoint in aliases collection there). I believe it is possible to create a strategy that will have the best of both worlds, but it will involve significant complexity and I didn't bother, especially considering that in the vast majority of the searches the other branch will be more than enough. But all in all, I decided to bring this branch (of course if the maintainers will agree), since our search is already not 100% accurate (it misses macros), and I believe there is value in the additional perf.

You can find the strict branch at https://github.com/ChayimFriedman2/rust-analyzer/tree/speedup-new-usages-strict.

Should fix #7404, I guess (will check now).

@rustbot rustbot added the S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. label Aug 18, 2024
@ChayimFriedman2 ChayimFriedman2 changed the title Speed up search for short associated functions, especially very common identifiers such as new perf: Speed up search for short associated functions, especially very common identifiers such as new Aug 18, 2024
@Veykril
Copy link
Member

Veykril commented Aug 19, 2024

I did that with a bunch of tricks that exploits knowledge about the language and its possibilities. The first key insight is that associated methods may only be referenced in the form ContainerName::func_name (parentheses are not necessary!) (Rust doesn't include a way to use Container::func_name, and even if it will in the future most usages are likely to stay in that form.

This is unfortunately likely to change soon 😅 rust-lang/rfcs#3591 So we'd likely have to adjust this for trait methods then.

Speed for accuracy is an interesting tradeoff to consider here (I wonder if we can juggle that depending on what we are looking for / for what purpose we are searching etc)

(haven't reviewed this yet)

@lnicola
Copy link
Member

lnicola commented Aug 19, 2024

Testing with Vec::new(), we're also returning usages from the standard library. I think this came up before, but it doesn't feel like something we should do by default.

@ChayimFriedman2
Copy link
Contributor Author

This is unfortunately likely to change soon 😅 rust-lang/rfcs#3591 So we'd likely have to adjust this for trait methods then.

This PR doesn't consider trait methods (they have a bunch of other problems), so no, this won't change.

This will change if Rust ever adds the ability to use Struct::non_trait_func, but as I said, even then I expect that most usages won't do this, so we could just resolve every name that doesn't include :: and still remain fast.

@ChayimFriedman2
Copy link
Contributor Author

Testing with Vec::new(), we're also returning usages from the standard library. I think this came up before, but it doesn't feel like something we should do by default.

This is unrelated to this PR - this was before too (this PR returns exactly the same usages for Vec::new(), I checked that). Also, fixing that should be pretty easy, but I don't think it belongs to this PR (and also, I do think there is value in showing usages from the standard library - if I search for a symbol in the standard library, I may want to see how std uses it).

@lnicola
Copy link
Member

lnicola commented Aug 19, 2024

This is unrelated to this PR - this was before too (this PR returns exactly the same usages for Vec::new(), I checked that).

It was, but not searching the standard library might be a big speed-up in itself, arguably without trading off accuracy.

there is value in showing usages from the standard library - if I search for a symbol in the standard library

Sure in that case, but if I'm searching in the loaded workspace, I don't really want to see results from the libraries.

@Veykril
Copy link
Member

Veykril commented Aug 19, 2024

Sure in that case, but if I'm searching in the loaded workspace, I don't really want to see results from the libraries.

That is actually an interesting heuristic to consider. I do like searching for usages within my dependencies / std, but having that behavior change depending on whether you kicked of the search in a workspace member or not might be a decent trade off (though that raises the question of discoverabilty). (that is assuming we can do that, don't have the LSP request in my head for this right now)

@ChayimFriedman2
Copy link
Contributor Author

ChayimFriedman2 commented Aug 19, 2024

It was, but not searching the standard library might be a big speed-up in itself, arguably without trading off accuracy.

If you only skip std? Not by much. If you skip all deps? Probably yes, but this PR will still be needed - ide-db is in the workspace, and searching for new() in it takes 60s.

@lnicola
Copy link
Member

lnicola commented Aug 19, 2024

ide-db is in the workspace, and searching for new() in it takes 60s

Just to be clear, are you searching for RootDatabase::new or something else?

@ChayimFriedman2
Copy link
Contributor Author

ChayimFriedman2 commented Aug 19, 2024

ide-db is in the workspace, and searching for new() in it takes 60s

Just to be clear, are you searching for RootDatabase::new or something else?

Something else. Specifically, .FileChange::new() (this was the first result for fn new()). It has only one usage.

But it doesn't really matter: I imagine all new()s will have the same problem, because they will match the same elements in the textual search. What does matter is the number of true positives (since we need to verify each one), so I imagine RootDatabase::new() will be slower than FileChange::new() even with this PR, but it should still be faster considerably.

@lnicola
Copy link
Member

lnicola commented Aug 19, 2024

It feels like there's something fishy here. FileChange::new() takes about 15 s for me (eyeballing it, because if I try to enable the profiler I get drowned in debug logs for some reason), while RootDatabase::new() takes 7 s.

@ChayimFriedman2
Copy link
Contributor Author

I didn't benchmark from the IDE, because it will not be stable - anything you have done before may impact the speed. Instead, I created a benchmark to run from the command line, that loads the workspace then immediately searches. This is an upper bound on search time - it can be lower, but not higher (I can share the benchmark code).

@ChayimFriedman2
Copy link
Contributor Author

Just checked: RootDatabase::new() was also reduced from ~60s to around three seconds (with two usages).

@ChayimFriedman2
Copy link
Contributor Author

For the record, I checked what happens on real IDE. RootDatabase::new() went down from 7s to instant.

Copy link
Member

@Veykril Veykril left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reviewed to the smallest detail but given this is fairly encapsulated I'm generally on board with merging this

crates/hir/src/semantics.rs Show resolved Hide resolved
crates/ide-db/src/search.rs Outdated Show resolved Hide resolved
crates/ide-db/src/search.rs Outdated Show resolved Hide resolved
crates/ide-db/src/search.rs Outdated Show resolved Hide resolved
crates/ide-db/src/search.rs Outdated Show resolved Hide resolved
name: &str,
files: impl Iterator<Item = (Arc<str>, EditionedFileId, TextRange)>,
mut container_predicate: impl FnMut(
&SyntaxNode,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we keep this as &ast::Path given the only calls to it are with paths? Then we don't need to use the untyped descendants api and instead can traverse the typed path api for the relevant bits

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can keep it as ast::Path, but this will give us nothing. We will still need to use the raw tokens API, because if we search for plain path only, we will miss cases like type Itself<T> = T; Itself<Self>. We need to check if the identifier appears anywhere in the path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay, I misunderstood the idea behind this

crates/ide/src/references.rs Show resolved Hide resolved
ChayimFriedman2 added a commit to ChayimFriedman2/rust-analyzer that referenced this pull request Aug 22, 2024
@bors
Copy link
Contributor

bors commented Aug 22, 2024

☔ The latest upstream changes (presumably #17898) made this pull request unmergeable. Please resolve the merge conflicts.

ChayimFriedman2 added a commit to ChayimFriedman2/rust-analyzer that referenced this pull request Aug 22, 2024
… to - i.e. if we are inside a macro call

This avoids the need to analyze the file when we are not inside a macro call.

This is especially important for the optimization in the next commit(s), as there the common case will be to descent into macros but then not analyze.
…n identifiers such as `new`

The search is used by IDE features such as rename and find all references.

The search is slow because we need to verify each candidate, and that requires analyzing it; the key to speeding it up is to avoid the analysis where possible.

I did that with a bunch of tricks that exploits knowledge about the language and its possibilities. The first key insight is that associated methods may only be referenced in the form `ContainerName::func_name` (parentheses are not necessary!) (Rust doesn't include a way to `use Container::func_name`, and even if it will in the future most usages are likely to stay in that form.

Searching for `::` will help only a bit, but searching for `Container` can help considerably, since it is very rare that there will be two identical instances of both a container and a method of it.

However, things are not as simple as they sound. In Rust a container can be aliased in multiple ways, and even aliased from different files/modules. If we will try to resolve the alias, we will lose any gain from the textual search (although very common method names such as `new` will still benefit, most will suffer because there are more instances of a container name than its associated item).

This is where the key trick enters the picture. The key insight is that there is still a textual property: a container namer cannot be aliased, unless its name is mentioned in the alias declaration, or a name of alias of it is mentioned in the alias declaration.

This becomes a fixpoint algorithm: we expand our list of aliases as we collect more and more (possible) aliases, until we eventually reach a fixpoint. A fixpoint is not guaranteed (and we do have guards for the rare cases where it does not happen), but it is almost so: most types have very few aliases, if at all.

We do use some semantic information while analyzing aliases. It's a balance: too much semantic analysis, and the search will become slow. But too few of it, and we will bring many incorrect aliases to our list, and risk it expands and expands and never reach a fixpoint. At the end, based on benchmarks, it seems worth to do a lot to avoid adding an alias (but not too much), while it is worth to do a lot to avoid the need to semantically analyze func_name matches (but again, not too much).

After we collected our list of aliases, we filter matches based on this list. Only if a match can be real, we do semantic analysis for it.

The results are promising: searching for all references on `new()` in `base-db` in the rust-analyzer repository, which previously took around 60 seconds, now takes as least as two seconds and a half (roughly), while searching for `Vec::new()`, almost an upper bound to how much a symbol can be used, that used to take 7-9 minutes(!) now completes in 100-120 seconds, and with less than half of non-verified results (aka. false positives).

This is the less strictly correct (but faster) of this patch; it can miss some (rare) cases (there is a test for that - `goto_ref_on_short_associated_function_complicated_type_magic_can_confuse_our_logic()`). There is another branch that have no false negatives but is slower to search (`Vec::new()` never reaches a fixpoint in aliases collection there). I believe it is possible to create a strategy that will have the best of both worlds, but it will involve significant complexity and I didn't bother, especially considering that in the vast majority of the searches the other branch will be more than enough. But all in all, I decided to bring this branch (of course if the maintainers will agree), since our search is already not 100% accurate (it misses macros), and I believe there is value in the additional perf.
@Veykril
Copy link
Member

Veykril commented Aug 23, 2024

Thanks!
@bors r+

@bors
Copy link
Contributor

bors commented Aug 23, 2024

📌 Commit 6a910f6 has been approved by Veykril

It is now in the queue for this repository.

@bors
Copy link
Contributor

bors commented Aug 23, 2024

⌛ Testing commit 6a910f6 with merge 2025b43...

@bors
Copy link
Contributor

bors commented Aug 23, 2024

☀️ Test successful - checks-actions
Approved by: Veykril
Pushing 2025b43 to master...

@bors bors merged commit 2025b43 into rust-lang:master Aug 23, 2024
11 checks passed
@matklad
Copy link
Member

matklad commented Aug 23, 2024

Fantastic work @ChayimFriedman2, thanks!

Consider writing a blog post about it, it would be an interesting read for some!

bors added a commit that referenced this pull request Aug 25, 2024
…Veykril

fix: Don't enable the search fast path for short associated functions when a search scope is set

In most places where we set a search scope it is a single file, and so the fast path will actually harm performance, since it has to search for aliases in the whole project. The only exception that qualifies for the fast path is SSR (there is an exception that don't qualify for the fast path as it search for `use` items). It sets the search scope to avoid dependencies. We could make it use the fast path, but I didn't bother.

I forgot this while working on #17927.
darichey pushed a commit to darichey/rust-analyzer that referenced this pull request Sep 5, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
S-waiting-on-review Status: Awaiting review from the assignee but also interested parties.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Finding all references to functions called new can take a long time
6 participants