Skip to content

Fix out-of-order verification of cycle head dependencies#1061

Merged
MichaReiser merged 36 commits intosalsa-rs:masterfrom
MichaReiser:micha/cycle-flatten-deps
Jan 22, 2026
Merged

Fix out-of-order verification of cycle head dependencies#1061
MichaReiser merged 36 commits intosalsa-rs:masterfrom
MichaReiser:micha/cycle-flatten-deps

Conversation

@MichaReiser
Copy link
Contributor

@MichaReiser MichaReiser commented Jan 10, 2026

Problem

A foundational assumption of deep_verify_memo is that it walks query dependencies in execution order (as seen by the query being validated). This PR fixes a bug related to cyclic queries where this invariant was violated because of how Salsa tracks dependencies:

  • Salsa only stores the DatabaseKeyIndex but not the IterationCount when adding a read dependency. The result is that Salsa adds a single dependency even if a query reads a x_1 and x_2 where _i signifies the iteration.
  • We only store the last memo, making it impossible to retrieve a query's dependencies in iteration i.

The result is that deep_verify_edges verifies dependencies before the structs on which the query results are stored, which is unsound.

Here an example where this happened before (this is a new test case):

#[salsa::tracked(cycle_initial=cycle_initial)]
fn query_a<'db>(db: &'db dyn salsa::Database) -> Interned<'db> {
    let interned = query_b(db);
    let value = interned.value(db);

    if value < 10 {
        Interned::new(db, value + 1)
    } else {
        interned
    }
}

#[salsa::tracked]
fn query_b<'db>(db: &'db dyn Database) -> Interned<'db> {
    let interned = query_a(db);
    query_x(db, interned);
    interned
}

#[salsa::tracked]
fn query_x<'db>(_db: &'db dyn Database, _i: Interned<'db>) {}

fn cycle_initial(db: &dyn Database, _id: Id) -> Interned<'_> {
    Interned::new(db, 0)
}

#[salsa::interned]
struct Interned {
    value: u32,
}

Collected input/outputs by query:

  • query_a
    • query_b
    • Interned::new(1)
    • Interned::new(2)
    • Interned::new(3)
    • ...
    • Interned::new(10)
  • query_b
    • Interned::new(0)
    • query_a
    • query_x(Interned(0))
    • query_x(Interned(1))
    • query_x(Interned(2))
    • ...
    • query_x(Interned(10))
  • query_x: Not important

Traversal order in maybe_changed_after:

  1. query_a
    1. query_b
      1. Interned::new(0)
      2. query_a (returns unchanged, fixpoint initial)
      3. query_x(0)
      4. query_x(1)
      5. query_x(2)
      6. query_x(...)
      7. query_x(10)
      8. Interned::new(1)
    2. Interned::new(2)
    3. Interned::new(...)
    4. Interned::new(10)

Note how Salsa verifies query_x(1), and query_x(...) before verifying that the interned values haven't changed.

The issue is that Salsa sees a dependency on query_b (without any iteration!), but it then doesn't just validate the dependencies of query_b in iteration 0, it validates all its dependencies, even if they are from later iterations. Only once that's done does salsa verify the remaining dependencies from query_a, even though they were created before some of the dependencies that were just verified as part of query_b.

Solution

This PR flattens the dependencies of all queries with cycle handling that participate in a cycle after each execution (each iteration) so that the cycle head depends only on inputs, tracked structs, interned values, and finalized queries. This ensures that the query's dependencies form a tree, not a graph (no cycles).

Not only does this fix the bug mentioned above, but it also greatly simplifies maybe_changed_after, as it no longer needs to handle cyclic dependencies. This allows us to remove almost all fixpoint-related code from maybe_changed_after (and friends).

Downsides

There are a few downsides to this approach:

  • It inherently breaks accumulated values. Collecting the accumulated values requires walking the query's dependency tree. This is fundamentally at odds with this new approach because we intentionally trim the dependency tree. I didn't consider this a blocker because trimming dependency trees (specifically by flattening) is something that we want to introduce in other parts of Salsa, to reduce memory usage (Flatten query dependencies #909). The other reason is that accumulated values were already "broken," in the sense that they returned too many values when a cycle head depended on the accumulating function in some iterations but not all, but the result would contain the accumulated values from all iterations.
  • Flattening the dependencies for each cycle head participating in a cycle isn't cheap, as the benchmarks show. I initially worked on a version that only required the outermost cycle head's dependencies. This implementation only regressed performance by 8-9%. However, it required a much more complicated design because deep_verify_memo for a nested head needed to delegate to the outermost cycle head. This, in turn, required tracking more state to verify if the outermost cycle head is still "the same" as when the inner head was executed (a cycle finalized in the field), and it's also much more difficult to reason about in general. It also didn't work 😆 Specifically, it didn't correctly handle the case where an inner cycle converges before its outer cycle. The issue is that the inner cycle counted on the outer most cycle to do the dependency flattening. But the inner cycle now suddenly needs to know its own flattened dependencies across all previous iterations. We could probably make this approach work, but it would require that the inner cycle walks its dependencies from the previous iteration, and replaces its dependency on the outer-most query with its flattened dependencies. However, it's not clear to me how to detect the outer-most cycle without storing it in the Memo. Ultimately, this didn't seem worthwhile, given there's no meaningful performance regression on ty (one benchmark shows a 1% regression)
  • The memory overhead can be substantial for large cycles if they have many dependencies, because most dependencies are duplicated across all heads participating in the same cycle. It would be nice if there were some structural sharing to reduce the overhead, but I consider this a follow-up. I also think that there are much bigger opportunities in Salsa to reduce memory usage (immortal durability, within the same revision LRU).

Alternative solutions

I still think the ideal is for Salsa to store multiple memos for queries participating in fixpoint, and for the iteration count to be part of the QueryEdge. This would remove much of the special casing required for fixpoint iteration and fit more naturally into Salsa's overall design. It probably would also set the foundation for speculative execution, where an iteration is "one speculative execution", but we could use other keys.

I decided not to explore this further because there are some big open questions, where it was unclear to me how to resolve them in the limited time I had available:

  • How can we store multiple memos?
  • Should the iteration count be part of the DatabaseKeyIndex or separate? I think it must be a single key; the caller of fetch doesn't know what the "latest" iteration of a given query is. But it always wants the most recent value.
  • How can we include the iteration count in the QueryEdge without increasing its size?
  • How does locking work? Validating different iterations of the same query in maybe_changed_after shouldn't result in a cycle, but acquiring the lock for the key without an iteration should.

How to support diff_outputs?

I don't think there's an open issue for it, but our fixpoint handling is prone to removing outputs and tracked structs too early for queries participating in a cycle. Salsa diffs the outputs immediately after completing an iteration. However, some outputs (including tracked structs) might only have been created in later iterations. Salsa deleting them in the first iteration is overly aggressive (and it used to be unsound #980). To fix this, it would be very helpful if there were a hook to finalize all cycle participants, rather than our current lazy validation.

My initial approach to this PR used eager validation, but that didn't handle the case where a nested cycle converges before the outer cycle. So I switched to flattening all cycle head dependencies; however, that also means that walking the dependency tree during finalization no longer contains the full dependency tree, making eager finalization impossible (or we'd have to retain the query dependencies in a different way). But I think there's still a solution to this problem, but we have to defer diff_outputs to the next revision.

I don't have this fully figured out but my thinking is along these lines:

  • Store the old-memo's revision metadata on the memo when completing the first iteration and carry that information forward across all later iterations.
  • When completing a query, check if this is the initial iteration and the old_memo has prev_revision_metadata set. If so, diff the prev_revision_metadata with the old_memo.input_outputs and delete any no-longer-created outputs.

Notes for reviewer

This hopefully fixes #1023

For some more context, see the write up in https://hackmd.io/XCIIyYjlQkuVg33THEr4iQ

This PR is based on top of #1063 because it removes the need to special-case fallback-immediate.

@netlify
Copy link

netlify bot commented Jan 10, 2026

Deploy Preview for salsa-rs canceled.

Name Link
🔨 Latest commit 4b98bc0
🔍 Latest deploy log https://app.netlify.com/projects/salsa-rs/deploys/69724ad8cd53ea00085802dc

@codspeed-hq
Copy link

codspeed-hq bot commented Jan 10, 2026

Merging this PR will degrade performance by 13.81%

⚡ 1 improved benchmark
❌ 2 (👁 2) regressed benchmarks
✅ 10 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
👁 converge_diverge_nested 114.1 µs 132.3 µs -13.81%
many_tracked_structs 9.1 µs 8.1 µs +12.16%
👁 converge_diverge 148 µs 167.6 µs -11.69%

Comparing MichaReiser:micha/cycle-flatten-deps (4b98bc0) with master (186f83b)1

Open in CodSpeed

Footnotes

  1. No successful run was found on master (e1e8df6) during the generation of this report, so 186f83b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@MichaReiser

This comment was marked as outdated.

@MichaReiser

This comment was marked as resolved.

@MichaReiser MichaReiser force-pushed the micha/cycle-flatten-deps branch from d7f5cf8 to 8121cec Compare January 11, 2026 11:12
@MichaReiser MichaReiser force-pushed the micha/cycle-flatten-deps branch 3 times, most recently from 122fb9d to 0e937e2 Compare January 20, 2026 08:55
stack.extend(
origin
.inputs()
.filter_map(|input| TryInto::<DatabaseKeyIndex>::try_into(input).ok())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't know why we used TryInto here. input is a DatabaseKeyIndex, so this is a no-op conversion

// TODO: Write a test that demonstrates that backdating queries participating in a cycle isn't safe
// OR write many tests showing that it is (and fixing the case where it didn't correctly account for today).
if !revisions.cycle_heads().is_empty() {
if !revisions.cycle_heads().is_empty() || old_memo.may_be_provisional() {
Copy link
Contributor Author

@MichaReiser MichaReiser Jan 20, 2026

Choose a reason for hiding this comment

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

We can't backdate if old_memo is the memo from the last iteration (or a previous revision but never finalized). To support backdating cyclic queries, we'd have to back up some of the previous-revision memo's metadata before overriding the memo with the first iteration's result.

For now, simply skip this path. The downside is that this requires running a few extra queries, but the blast radius should still be contained, assuming those queries aren't cyclic (so that they can backdate).

@MichaReiser MichaReiser force-pushed the micha/cycle-flatten-deps branch 2 times, most recently from 3bf0aa9 to 7bbeef3 Compare January 20, 2026 10:41
db.assert_logs(expect![[r#"
[
"salsa_event(DidValidateMemoizedValue { database_key: min_panic(Id(2)) })",
"salsa_event(WillExecute { database_key: min_panic(Id(3)) })",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

d and c are both queries that don't participate in a cycle. They aren't flattened into a. a's flattened dependencies are:

  • input(a)
  • input(b)
  • c
  • d

Salsa now walks as dependency edges in deep_verify_edges, starting with input(a), followed by input(b) and c that all haven't changed. d has a changed input. Salsa re-executes it to see if it can be backdated, but it can't, which is why we later re-execute ' a `.

This test does suggest to me that the old implementation is too eager when it comes to re-executing cyclic queries, because it immediately starts executing the outer most cyclic query, even if d is later able to backdate.

[
"salsa_event(WillExecute { database_key: min_panic(Id(2)) })",
"salsa_event(WillExecute { database_key: min_panic(Id(1)) })",
"salsa_event(WillExecute { database_key: min_iterate(Id(0)) })",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This now is also more correct. Before, we re-executed c first, even though it is a cycle participant. Now, we correctly detect that an input going into a's cycle changed, and immediately start re-executing the entire cycle.

[
"salsa_event(DidValidateInternedValue { key: Interned(Id(400)), revision: R2 })",
"salsa_event(WillExecute { database_key: head(Id(0)) })",
"salsa_event(DidValidateInternedValue { key: Interned(Id(400)), revision: R2 })",
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 now avoid checking the same interned value multiple times, because we immediately see that the entire cycle needs to re-execute

"salsa_event(DidSetCancellationFlag)",
"salsa_event(DidValidateMemoizedValue { database_key: read_value(Id(400)) })",
"salsa_event(DidValidateInternedValue { key: query_d::interned_arguments(Id(800)), revision: R2 })",
"salsa_event(WillExecute { database_key: query_d(Id(800)) })",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This makes sense to me and is similar to another change we've seen above.

query_d doesn't participate in the cycle, so it doesn't get flattened out.

query_b's dependencies are:

  • Output(0) (tracked new)
  • read_value(Output(0))
  • query_d (has changed)
  • ab.value
  • Output(1) (next iteration)
  • read_value(Output(1))
  • Output(2) (next iteration)
  • read_value(Output(2))
  • Output(3) (next iteration)
  • read_value(Output(3))

Salsa detects that query_d's input changed, it executes query_d but can't backdate it because the result is different. In turn, the query_b cycle changed, so that salsa re-executes it

@MichaReiser MichaReiser changed the title Walk dependencies for fixpoint queries in execution order Fix out-of-order verification of cycle head dependencies Jan 20, 2026
@MichaReiser MichaReiser marked this pull request as ready for review January 20, 2026 18:10
@MichaReiser MichaReiser force-pushed the micha/cycle-flatten-deps branch from a761359 to c87488f Compare January 21, 2026 14:23
Copy link
Member

@ibraheemdev ibraheemdev left a comment

Choose a reason for hiding this comment

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

This makes a lot of sense to me, very nice.

@MichaReiser MichaReiser force-pushed the micha/cycle-flatten-deps branch from c87488f to d029bee Compare January 22, 2026 13:41
@MichaReiser MichaReiser enabled auto-merge January 22, 2026 13:53
@MichaReiser MichaReiser added this pull request to the merge queue Jan 22, 2026
github-merge-queue bot pushed a commit that referenced this pull request Jan 22, 2026
* Start working on maybe_changed_after

* Add `maybe_changed_after` for tracked structs

* Remove old cycle handling in maybe_changed_after

* Add tests

* Don't collect deps if there's an outer cycle

* Remove `validate_provisional`

* Compute outer most cycle

* Set edges in more branches

* More

* Improve logging

* Flatten dependencies of all heads, use lazy validation

* Properly skip `complete_iteration` in the no cycle head case

* Revert `struct_database_key_index` changes

* Revert more changes

* Revert even more

* Rename

* Fix memory leak

* Clippy

* Advoid recursing cycle heads

* Add missing inventory gating

* Only track queries in seen

* Reuse allocations

* Avoid copying current-revision input-outputs into `QueryRevisions`

* Revert "Avoid copying current-revision input-outputs into `QueryRevisions`"

This reverts commit e686db9.

* Move more out of hot path

* Reuse allocations more

* Revert some Ingredient::maybe_changed_after changes

* More cleanup

* Back out disambiguator changes

* Docs

* Small reordering

* Panic when using accumulator in fixpoint query

* Revert unnecessary test changes

* Simplify more

* Code review feedback

* Format
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 22, 2026
@MichaReiser MichaReiser added this pull request to the merge queue Jan 22, 2026
github-merge-queue bot pushed a commit that referenced this pull request Jan 22, 2026
* Start working on maybe_changed_after

* Add `maybe_changed_after` for tracked structs

* Remove old cycle handling in maybe_changed_after

* Add tests

* Don't collect deps if there's an outer cycle

* Remove `validate_provisional`

* Compute outer most cycle

* Set edges in more branches

* More

* Improve logging

* Flatten dependencies of all heads, use lazy validation

* Properly skip `complete_iteration` in the no cycle head case

* Revert `struct_database_key_index` changes

* Revert more changes

* Revert even more

* Rename

* Fix memory leak

* Clippy

* Advoid recursing cycle heads

* Add missing inventory gating

* Only track queries in seen

* Reuse allocations

* Avoid copying current-revision input-outputs into `QueryRevisions`

* Revert "Avoid copying current-revision input-outputs into `QueryRevisions`"

This reverts commit e686db9.

* Move more out of hot path

* Reuse allocations more

* Revert some Ingredient::maybe_changed_after changes

* More cleanup

* Back out disambiguator changes

* Docs

* Small reordering

* Panic when using accumulator in fixpoint query

* Revert unnecessary test changes

* Simplify more

* Code review feedback

* Format
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 22, 2026
@MichaReiser
Copy link
Contributor Author

Hmm, the new rust release is hitting.

@MichaReiser MichaReiser force-pushed the micha/cycle-flatten-deps branch from 2250e0f to 4b98bc0 Compare January 22, 2026 16:05
@MichaReiser MichaReiser enabled auto-merge January 22, 2026 16:10
@MichaReiser MichaReiser added this pull request to the merge queue Jan 22, 2026
Merged via the queue into salsa-rs:master with commit 0946cbd Jan 22, 2026
19 checks passed
@MichaReiser MichaReiser deleted the micha/cycle-flatten-deps branch January 22, 2026 16:25
@github-actions github-actions bot mentioned this pull request Jan 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky cycle fixpoint test

2 participants