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

Use salsa accumulators for diagnostics #14760

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

MichaReiser
Copy link
Member

@MichaReiser MichaReiser commented Dec 3, 2024

Summary

This is a reimplementation of #14116 now that Salsa salsa accumulators are cheaper (close to "free" for queries without diagnostics).

The main benefit of salsa accumulators is that they're an easy way to emit a diagnostic from anywhere inside a salsa query, and salsa takes care to deduplicate diagnostics if the same query is called multiple times.

Performance

This still significantly regresses the incremental performance. I created another salsa PR to reduce the regression to 9%. I don't think we can do much better except reducing the number of inputs by using coarse-grained salsa dependencies because:

  • Salsa currently tracks reads on a per "field" level for salsa structs. This very fine-grained tracking costs us here because the accumulator has to iterate and resolve all of them to know if they have any accumulated values. The coarse-grained salsa feature will help with this (and reduce the overhead in other places as well)
  • We only check 4 files (plus standard library files that are all unchanged and have no diagnostics). The regression would be less proportionally if there were more files without diagnostics.
  • The diagnostics are from the largest file and, to make it worse, from the module scope, which has the most definitions.

Considering this, I'm still leaning towards migrating to salsa accumulators because of what they unlock: We can now emit diagnostics from any part of the code. This includes the module resolver (e.g. emit a warning if we find an invalid pth file?)

Other changes

This PR upgrades Salsa to a version with "cheap" accumulators. The new salsa version now requires that Db structs implement Clone for its parallel db support (which doesn't seem to work yet).

Test Plan

cargo test

@MichaReiser MichaReiser added internal An internal refactor or improvement red-knot Multi-file analysis & type inference labels Dec 3, 2024
@MichaReiser MichaReiser force-pushed the micha/salsa-accumulators branch 2 times, most recently from 7db3778 to 1f6247c Compare December 3, 2024 17:54
Copy link

codspeed-hq bot commented Dec 3, 2024

CodSpeed Performance Report

Merging #14760 will degrade performances by 10.43%

Comparing micha/salsa-accumulators (35f3815) with main (1685d95)

Summary

❌ 1 (👁 1) regressions
✅ 31 untouched benchmarks

Benchmarks breakdown

Benchmark main micha/salsa-accumulators Change
👁 red_knot_check_file[incremental] 4 ms 4.4 ms -10.43%

Copy link
Contributor

github-actions bot commented Dec 3, 2024

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@MichaReiser MichaReiser marked this pull request as ready for review December 4, 2024 10:15
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

The use of report_ everywhere makes me feel like I'm reading the pyright codebase haha. But in most cases it seems like it probably is the best verb to use. Still, there's a couple of places where I think we could use some better names:

pub struct CompileDiagnostic(std::sync::Arc<dyn Diagnostic>);

impl CompileDiagnostic {
pub fn report<T>(db: &dyn Db, diagnostic: T)
Copy link
Member

@AlexWaygood AlexWaygood Dec 4, 2024

Choose a reason for hiding this comment

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

is report the best verb here? It feels to me like we're adding a diagnostic to be reported later, rather than reporting it to the user immediately. Maybe this could be called CompileDiagnostic::add()?

format_args!("Name `{id}` used when not defined"),
);
/// Reports a diagnostic for the given node and file if diagnostic reporting is enabled for this file.
pub(crate) fn report_type_diagnostic(
Copy link
Member

Choose a reason for hiding this comment

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

similarly here, add_type_diagnostic would be a more natural name for me

@MichaReiser
Copy link
Member Author

Hmm, I'm looking into suppressions right now, specifically how we'd recognize unused suppression comments.
We could ignore inline and file-level suppression comments when deciding whether or not to emit a diagnostic and then filter them out as part of a post-processing step. The diagnostics would then allow us to "account" for the seen suppressions and list comments without any suppressions. However, this has the downside that more files have accumulated values, meaning the performance regression remains relevant for more files.

Another alternative to emitting the diagnostic is to emit a Suppressed accumulated value. That's cheaper because it can be a more lightweight value (and suppressing a diagnostic can be used to work-around a bug in the diagnostic generation), but it has the same downside as using the diagnostic accumulator: Collecting all values requires an extra traversal

diagnostic
}));
let mut diagnostics = check_types::accumulated::<CompileDiagnostic>(db, test_file.file);
// Filter out diagnostics that are not related to the current file.
Copy link
Contributor

Choose a reason for hiding this comment

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

Only tangentially related: do we have a feeling for how many unrelated-file diagnostics we generate typically? If this would be a large fraction of all diagnostics, we should probably detect that earlier?

Copy link
Member Author

Choose a reason for hiding this comment

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

This could be any number of diagnostics and salsa doesn't provide a way to detect this earlier.

Salsa accumulators work by traversing the entire dependency tree of a query. In our case, this is check_file. check_file can branch out into arbitrary files when resolving imports (and checking those files in turn). The only way for us to truncate this earlier is by not using accumulators because salsa doesn't know about the file boundaries.

I don't think this is very terrible because the CLI uses check_workspace to get all diagnostics. The LSP uses check_file today but I don't think it should. The wasm playground uses check_file... this is probably fine

use crate::Db;

type AnnotationParseResult = Result<Parsed<ModExpression>, TypeCheckDiagnostics>;
type AnnotationParseResult = Result<Parsed<ModExpression>, ()>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: Something like

Suggested change
type AnnotationParseResult = Result<Parsed<ModExpression>, ()>;
struct StringAnnotationParseError;
type AnnotationParseResult = Result<Parsed<ModExpression>, StringAnnotationParseError>;

would maybe make matches at the call sites of this function a bit easier to understand

Comment on lines +192 to +207
diagnostics.sort_unstable_by(|a, b| {
let a_file = a.file();
let b_file = b.file();

a_file.cmp(&b_file).then_with(|| {
a_file
.path(db)
.as_str()
.cmp(b_file.path(db).as_str())
.then_with(|| {
a.range()
.unwrap_or_default()
.start()
.cmp(&b.range().unwrap_or_default().start())
})
})
Copy link
Contributor

Choose a reason for hiding this comment

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

A few questions:

  • Do we really want an unstable sort here? Two different diagnostics could compare equal according to the ordering defined here if they refer to the same range, or even just the same range start.
  • Why do we order by file and then by the path of the file? Can multiple files refer to the same path?
  • If performance is not a major concern here, we could potentially do something like
    diagnostics.sort_unstable_by_key(|diagnostic| {
        let file = diagnostic.file();
        let path = file.path(db).as_str();
        let range_start = diagnostic.range().unwrap_or_default().start();
        (file, path, range_start)
    });

Copy link
Member Author

Choose a reason for hiding this comment

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

Why do we order by file and then by the path of the file? Can multiple files refer to the same path?

Oh, that's just wrong. The idea was to short-cut if the files are identical.

Do we really want an unstable sort here? Two different diagnostics could compare equal according to the ordering defined here if they refer to the same range, or even just the same range start.

I don't think it's important for us to preserve the original order if there are multiple diagnostics at the same line, for as long as the order is deterministic (Running Red Knot multiple times should give you the same result). We should probably include the rule code as well as disambiguator

Copy link
Contributor

Choose a reason for hiding this comment

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

for as long as the order is deterministic

Exactly, that was my concern.

We should probably include the rule code as well as disambiguator

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
internal An internal refactor or improvement red-knot Multi-file analysis & type inference
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants