-
Notifications
You must be signed in to change notification settings - Fork 155
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
[RFC] fixpoint iteration support #603
base: master
Are you sure you want to change the base?
Conversation
✅ Deploy Preview for salsa-rs canceled.
|
CodSpeed Performance ReportMerging #603 will degrade performances by 25.11%Comparing Summary
Benchmarks breakdown
|
This is very cool! (Admittedly, I say this pre-review.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great. I left a few comments where I struggled understanding the implementation or had smaller suggestions.
@@ -179,12 +182,16 @@ macro_rules! setup_tracked_fn { | |||
$inner($db, $($input_id),*) | |||
} | |||
|
|||
fn cycle_initial<$db_lt>(db: &$db_lt dyn $Db) -> Self::Output<$db_lt> { | |||
$($cycle_recovery_initial)*(db) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suspect that it's possible that the initial value function or the recover functions itself could create new cycles. Is this indeed the case and if so, what's salsa's behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. In the use cases I have in mind (e.g. for red-knot) there should not be any reason to call a query from within one of these functions. And it seems like this could be quite difficult to deal with. So unless we have clear use cases, I would rather consider this out of scope. We could just document "don't do that," or we could add some kind of explicit prevention.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. I'm not suggesting that users should do that. I only wonder about what happens if a user does it regardless.
Ideally, we wouldn't provide a Db
but we can't do that because a user might want to create a tracked struct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the simplest approach here would be to add some state on Runtime
that causes an error if you try to push a new active query. I'll wait for Niko review before doing that, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a TODO comment on this to get Niko's feedback.
tests/cycle_fixpoint.rs
Outdated
} | ||
} | ||
|
||
#[salsa::tracked(cycle_fn=cycle_recover, cycle_initial=cycle_initial)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we define a custom CycleRecovery
trait that defines recover
and initial
methods. It would give us a good point to put cycle handling documentation and avoids the risk for incorrect-macro use when only specifiying one but not boht values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I thought about this as well. It's not clear to me what is actually the better UX. Implementing a trait is somewhat more boilerplate, and in practice there's not much difference in the experience if you fail to implement one of the methods. Either way the compiler catches the error for you, because its an error in macro expansion if you don't provide both cycle_fn
and cycle_initial
, with a trait it would be a compiler error that you didn't fully implement the trait.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess with a trait your IDE might give you the right signatures of the methods for free, which is kind of nice...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I normally don't like traits, but I feel like a trait would be preferable because the current annotation seems a little too wordy for my tastes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, going to still wait for Niko's feedback on this point before updating, but the trait idea makes sense to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I'm okay with a non-trait based approach. rust-analyzer uses a #[salsa::cycle(path::to::function)]
-style annotation, so I think to ease the transition, avoiding a trait would be nice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rust-analyzer uses a #[salsa::cycle(path::to::function)]-style annotation, so I think to ease the transition, avoiding a trait would be nice.
Is cycle handling common in ra? I do see how it reduces the diff size because it isn't necessary to make the function a trait-method but you would still have to change every use because you now have to specify both the cycle and cycle initial functions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a TODO comment in the code for this question as well.
In writing more comprehensive tests for this, I realized that it needs some changes to correctly handle multi-revision scenarios; taking it to Draft mode until I get that fixed. |
Ok, multiple-revision cases are now fixed, and we now populate the initial provisional value only lazily, in case a cycle is actually encountered, which should reduce the number of memos created by quite a lot. Also added a bunch of tests, including multiple-revision cases and one test involving durability. Still need to add cross-thread cycle tests. |
tests/cycle/main.rs
Outdated
// Diagram nomenclature for nodes: Each node is represented as a:xx(ii), where `a` is a sequential | ||
// identifier from `a`, `b`, `c`..., xx is one of the four query kinds: | ||
// - `Ni` for `min_iterate` | ||
// - `Xi` for `max_iterate` | ||
// - `Np` for `min_panic` | ||
// - `Xp` for `max_panic` | ||
// | ||
// and `ii` is the inputs for that query, represented as a comma-separated list, with each | ||
// component representing an input: | ||
// - `a`, `b`, `c`... where the input is another node, | ||
// - `uXX` for `UntrackedRead(XX)` | ||
// - `vXX` for `Value(XX)` | ||
// - `sY` for `Successor(Y)` | ||
// |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are admittedly obscure-looking chicken scratches. I'm not claiming they are super readable, but they are concise enough to put into an ASCII graph diagram, and (once you get familiar with them) give a lot of information about the behavior of the test. They were really helpful to me in writing and debugging the tests.
Open to feedback that I should do this differently for better readability by future maintainers...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lazy creation of the initial value is a neat improvement. Nice for taking the time to work on it !
The benchmarks show a 4-5% regression. It seems that we're now resizing some hash maps more often. Are we reporting more tracked reads than before? Could you take a look what's causing it? |
Initial experiments using this in the red-knot type checker are promising: astral-sh/ruff#14029 Not yet using it for loopy control flow in that PR, but there are cycles in the core type definitions of Python builtins and standard library, which we previously had a hacky fallback in place for using Salsa's previous cycle fallback support. Moving over to fixpoint iteration just worked, and fixed the type of a builtin impacted by the cycle. On the downside, it is a performance regression. Need to do more work there. |
@@ -73,22 +114,29 @@ where | |||
); | |||
|
|||
// Check if the inputs are still valid and we can just compare `changed_at`. | |||
if self.deep_verify_memo(db, &old_memo, &active_query) { | |||
return Some(old_memo.revisions.changed_at > revision); | |||
let active_query = zalsa_local.push_query(database_key_index); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From looking at the red knot benchmarks, the regression mainly comes from the extra push_query
calls here (that probably also applies for queries not participating in cycles) and the constructed hash set in deep_verify_memo
(specific to red knot?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we move the push_query
call into deep_verify_memo
after the second shallow_verify_memo
or does that result in deadlocks? Just so that we can avoid pushing queries unless it's absolutely necessary
{ | ||
return false; | ||
loop { | ||
let mut cycle_heads = FxHashSet::default(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it intentional that we create a new cycle_heads
in every iteration? Could we re-use the cycle_heads
and instead call clear
to avoid re-allocating the hash set on every iteration?
@@ -38,7 +39,7 @@ pub trait Ingredient: Any + std::fmt::Debug + Send + Sync { | |||
db: &'db dyn Database, | |||
input: Option<Id>, | |||
revision: Revision, | |||
) -> bool; | |||
) -> VerifyResult; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this change will also come handy for the "faster accumulator" work because we'll need to also return whether the ingredient had any accumulated values.
This PR removes the existing unwind-based cycle fallback support (a plus for WASM compatibility), and replaces it with support for fixpoint iteration of cycles.
To opt in to fixpoint iteration, provide two additional arguments to
salsa::tracked
on the definition of a tracked function:cycle_initial
andcycle_fn
. The former is a function which should provide a provisional starting value for fixpoint iteration on this query, and the latter is a function which has the opportunity, after each iteration that failed to converge, to decide whether to continue iterating or fallback to some fixed value. See the added test incycle_fixpoint.rs
for details.Usability points that should be covered in the documentation:
cycle_fn
andcycle_initial
on every query that might end up as the "head" of a cycle (that is, queried for its value while it is already executing.)cycle_fn
andcycle_initial
so as to cause iteration to diverge and never terminate; it's up to the user to avoid this. Techniques to avoid this include a) ensuring that cycles will converge, by defining the initial value and the queries themselves monotonically (for example, in a type-inference scenario, the initial value is the bottom, or empty, type, and types will only widen, never narrow, as the cycle iterates -- thus the cycle must eventually converge to the top type, if nowhere else), and/or b) with a larger hammer, by ensuring thatcycle_fn
respects the iteration count it is given, and always halts iteration with a fallback value if the count reaches some "too large" value.cycle_fn
andcycle_initial
such that memoized results can vary depending only on the order in which queries occur. Avoid this by minimizing the number of tracked functions that support fixpoint iteration and ensuring initial values and fallback values are consistent among tracked functions that may occur in a cycle together.This is an RFC pull request to get initial reviewer feedback on the design and implementation. Remaining TODO items: