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

Coarse-grained tracked structs #657

Merged
merged 4 commits into from
Feb 5, 2025
Merged

Conversation

ibraheemdev
Copy link
Contributor

This PR makes tracked structs coarse-grained by default. To declare a field independently tracked, you now use the #[tracked] attribute. This is the opposite of the previous behavior, where #[id] effectively declared a field untracked. The #[id] attribute now has no effect. Importantly, reads of untracked fields do not require a read dependency to be declared.

There is some duplicated code in the macros as we need to generate separate accessors for tracked vs. untracked fields and I would like these to be static (as opposed to branching at runtime).

I believe the guide documentation also needs to be updated.

Resolves #598.

Copy link

netlify bot commented Jan 17, 2025

Deploy Preview for salsa-rs canceled.

Name Link
🔨 Latest commit c710526
🔍 Latest deploy log https://app.netlify.com/sites/salsa-rs/deploys/67a170026bb268000809a146

Copy link

codspeed-hq bot commented Jan 17, 2025

CodSpeed Performance Report

Merging #657 will not alter performance

Comparing ibraheemdev:coarse-deps (c710526) with master (88dc597)

Summary

✅ 9 untouched benchmarks

@ibraheemdev ibraheemdev force-pushed the coarse-deps branch 4 times, most recently from c331b84 to 3e2d7f3 Compare January 17, 2025 03:05
pub name: FunctionId<'db>,

#[tracked]
name_span: Span<'db>,
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 tried not adding this #[tracked] attribute because it seems unnecessary, but I actually got a test failure that I don't really understand:

---- type_check::fix_bad_variable_in_function stdout ----
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: parse_statements(Id(0)) } }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: type_check_program(Id(1400)) } }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: type_check_function(Id(1800)) } }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: type_check_function(Id(1801)) } }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: find_function(Id(1c00)) } }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: find_function(Id(1c01)) } }
Event: Event { thread_id: ThreadId(11), kind: DidSetCancellationFlag }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillExecute { database_key: parse_statements(Id(0)) } }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
Event: Event { thread_id: ThreadId(11), kind: WillCheckCancellation }
thread 'type_check::fix_bad_variable_in_function' panicked at /home/ibraheem/dev/sync/salsa/src/table.rs:281:9:
assertion `left == right` failed: page has hidden type `"salsa::table::Page<salsa::tracked_struct::Value<calc::ir::Function>>"` but `"salsa::table::Page<salsa::tracked_struct::Value<calc::ir::Program>>"` was expected
  left: TypeId(0xbeb3ca6f8846f92e6ae4fc7569b4bf5f)
 right: TypeId(0xbbdce2561688eb1bfc9fa5f5006a8342)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Copy link
Contributor

Choose a reason for hiding this comment

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

I added some debug statements to function::maybe_chagned_after to log the dependencies and compared the logs between a run that succeeds and a run that fails.

The most interesting difference is

Successful

[src/tracked_struct/tracked_field.rs:58:9] self = salsa::tracked_struct::tracked_field::FieldIngredientImpl<calc::ir::Program> {
    ingredient_index: IngredientIndex(
        13,
    ),
    field_index: 0,
}
Event: Event { thread_id: ThreadId(2), kind: WillCheckCancellation }
[src/function/maybe_changed_after.rs:154:9] &database_key_index = type_check_function(Id(1800))
[src/function/maybe_changed_after.rs:156:15] &old_memo.revisions.origin = Derived(
    QueryEdges {
        input_outputs: [
            Input(
                Function.args(Id(1000)),
            ),
            Input(
                Program(Id(1000)),
            ),
            Input(
                Span.start(Id(406)),
            ),
            Input(
                Span.end(Id(406)),
            ),
        ],
    },
)

With Panic

[src/tracked_struct/tracked_field.rs:58:9] self = salsa::tracked_struct::tracked_field::FieldIngredientImpl<calc::ir::Program> {
    ingredient_index: IngredientIndex(
        12,
    ),
    field_index: 0,
}
Event: Event { thread_id: ThreadId(2), kind: WillCheckCancellation }
[src/function/maybe_changed_after.rs:154:9] &database_key_index = type_check_function(Id(1800))
[src/function/maybe_changed_after.rs:156:15] &old_memo.revisions.origin = Derived(
    QueryEdges {
        input_outputs: [
            Input(
                Program(Id(1000)),
            ),
            Input(
                Program.statements(Id(1000)),
            ),
            Input(
                Span.start(Id(406)),
            ),
            Input(
                Span.end(Id(406)),
            ),
        ],
    },
)

The main difference is that the ingredient on which we run maybe_changed_after is different, AND so are the inputs. Looking at type_check_function then the inputs of the panic case make no sense. The query never calls Program.statements but it does call Function.args. I don't understand how it is happening but I suspect that we're incorrectly reusing a memo and turn a Function memo into a Program which would align with the panic

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, I think I know what the issue is...

You changed the tracked struct's Configuration::create_ingredients to only create ingredients for tracked fields. I think this change is correct. However, you call Ingredient::Index::successor in tracked_field with the field_index which is the absolute offset including all fields -- including untracked fields. What this means is that we now add incorrect dependencies as soon as we have a tracked struct where a tracked field follows an untracked field.

I hope that helps. I let you take a stab at correctly mapping the field indices. It would be great to have a unit test demonstrating the behavior and possibly an assertion somewhere that would help us catch this sooner.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, that is a tricky. I tried a couple things here:

  • Creating the ingredients with the absolute indices directly. Unfortunately this causes a panic, as some of the ingredient code relies on indices being create sequentially.
  • Passing the correct relative index to successor in tracked_field. Unfortunately this also did not seem to work correctly and didn't result in the correct fields being tracked, but I'm not exactly sure why. The logging code also relies on the indices being absolute in order to print field names, so this was a little awkward.

Eventually I think the easiest solution is just creating ingredients for all fields. I added a failing test that is now passing with this fix (and also fails with the second idea I tried).

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, that's unfortunate but seems reasonable. Would it be possible to avoid creating any field ingredients if the structure only has tracked fields? Doing this optimization seems worthwhile, considering that all-fields-untracked is the new default

Copy link
Contributor

Choose a reason for hiding this comment

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

I assume that keeping a mapping table from field_index to relative_ingredient_index (a [Option<usize>] where the value is Some for all tracked fields) is annoying to write the macro code for? If not, maybe that's worth a try. I otherwise suggest we keep it as is for now (but optimize for the no-tracked fields case)

Copy link
Contributor

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

This looks great to me. I think the idea of tracked and untracked fields is easier to understand than id (I'm fairly certain we used id incorrectly in Red Knot).

I think I have an understanding why the panic occurs and I added an inline comment with an explanation. I let you take a stab at fixing it.

pub name: FunctionId<'db>,

#[tracked]
name_span: Span<'db>,
Copy link
Contributor

Choose a reason for hiding this comment

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

I added some debug statements to function::maybe_chagned_after to log the dependencies and compared the logs between a run that succeeds and a run that fails.

The most interesting difference is

Successful

[src/tracked_struct/tracked_field.rs:58:9] self = salsa::tracked_struct::tracked_field::FieldIngredientImpl<calc::ir::Program> {
    ingredient_index: IngredientIndex(
        13,
    ),
    field_index: 0,
}
Event: Event { thread_id: ThreadId(2), kind: WillCheckCancellation }
[src/function/maybe_changed_after.rs:154:9] &database_key_index = type_check_function(Id(1800))
[src/function/maybe_changed_after.rs:156:15] &old_memo.revisions.origin = Derived(
    QueryEdges {
        input_outputs: [
            Input(
                Function.args(Id(1000)),
            ),
            Input(
                Program(Id(1000)),
            ),
            Input(
                Span.start(Id(406)),
            ),
            Input(
                Span.end(Id(406)),
            ),
        ],
    },
)

With Panic

[src/tracked_struct/tracked_field.rs:58:9] self = salsa::tracked_struct::tracked_field::FieldIngredientImpl<calc::ir::Program> {
    ingredient_index: IngredientIndex(
        12,
    ),
    field_index: 0,
}
Event: Event { thread_id: ThreadId(2), kind: WillCheckCancellation }
[src/function/maybe_changed_after.rs:154:9] &database_key_index = type_check_function(Id(1800))
[src/function/maybe_changed_after.rs:156:15] &old_memo.revisions.origin = Derived(
    QueryEdges {
        input_outputs: [
            Input(
                Program(Id(1000)),
            ),
            Input(
                Program.statements(Id(1000)),
            ),
            Input(
                Span.start(Id(406)),
            ),
            Input(
                Span.end(Id(406)),
            ),
        ],
    },
)

The main difference is that the ingredient on which we run maybe_changed_after is different, AND so are the inputs. Looking at type_check_function then the inputs of the panic case make no sense. The query never calls Program.statements but it does call Function.args. I don't understand how it is happening but I suspect that we're incorrectly reusing a memo and turn a Function memo into a Program which would align with the panic

pub name: FunctionId<'db>,

#[tracked]
name_span: Span<'db>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, I think I know what the issue is...

You changed the tracked struct's Configuration::create_ingredients to only create ingredients for tracked fields. I think this change is correct. However, you call Ingredient::Index::successor in tracked_field with the field_index which is the absolute offset including all fields -- including untracked fields. What this means is that we now add incorrect dependencies as soon as we have a tracked struct where a tracked field follows an untracked field.

I hope that helps. I let you take a stab at correctly mapping the field indices. It would be great to have a unit test demonstrating the behavior and possibly an assertion somewhere that would help us catch this sooner.

let id = C::deref_struct(s);
let data = Self::data(zalsa.table(), id);

data.read_lock(zalsa.current_revision());
Copy link
Contributor Author

@ibraheemdev ibraheemdev Jan 18, 2025

Choose a reason for hiding this comment

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

Is the read_lock necessary here for an untracked field? It seems not because leak_fields does not call it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Not a 100% but the comment on Value::updated_at and the inline comment inside update does suggest to me that the lock is required before accessing any field (handing out references to field) and it not being present in leak_field seems like a bug.

Copy link
Contributor

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

thanks. This is great. I think we should be able to optimize the implementation to avoid creating any field-ingredients if all fields are tracked.

@MichaReiser
Copy link
Contributor

Thanks. This looks good to me. I'll give @nikomatsakis a chance to review

Copy link
Member

@nikomatsakis nikomatsakis left a comment

Choose a reason for hiding this comment

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

This PR looks pretty good, but I'm a bit about why the indexing has to be absolute. I want to dig a bit into it.

@ibraheemdev
Copy link
Contributor Author

I realized where I missed remapping when I tried relative indices. I think I can get it to work now.

@nikomatsakis
Copy link
Member

@ibraheemdev OK

@nikomatsakis
Copy link
Member

I think my preference would be to either create ingredients only for tracked fields or ALWAYS create ingredients no matter what. The current "create nothing or create extra ingredients" feels a bit confusing.

@ibraheemdev ibraheemdev force-pushed the coarse-deps branch 2 times, most recently from 136e285 to 59c5172 Compare February 4, 2025 01:36
@ibraheemdev
Copy link
Contributor Author

I updated the implementation to only create ingredients for tracked fields. This should be good to go now.

Copy link
Contributor

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

This is great. I've one possible follow up improvement that we could make. Let me know if you want to tackle this as part of this PR or as a follow up. I otherwise think this is good to merge

@@ -668,7 +679,6 @@ where
&'db self,
db: &'db dyn crate::Database,
s: C::Struct<'db>,
_field_index: usize,
Copy link
Contributor

Choose a reason for hiding this comment

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

You removing the _field_index here makes me wonder if we should update revisions to only store the revisions for tracked fields because they seem wasted for untracked fields. Removing the bookkeeping for untracked fields could improve memory usage because it reduces the metadata stored for every tracked struct instance. It may also reduce the need to pass both relative and absolute indices to tracked_field.

However, this might also be something that's fine to follow up in a separate PR

@MichaReiser
Copy link
Contributor

I'll go ahead and merge it as is. We can follow up on the revision optimization in a separate PR

@MichaReiser MichaReiser added this pull request to the merge queue Feb 5, 2025
Merged via the queue into salsa-rs:master with commit ae416f1 Feb 5, 2025
10 checks passed
Comment on lines -371 to +384
hash: crate::hash::hash(&C::id_fields(&fields)),
hash: crate::hash::hash(&C::untracked_fields(&fields)),
Copy link
Member

@Veykril Veykril Feb 8, 2025

Choose a reason for hiding this comment

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

Note that IdentityHash's docs state

This includes the ingredient index of that struct type plus the hash of its id fields.

So if I see this right we have flipped the fields that we hash here now right? before we hashed the tracked fields, not hash the untracked fields which sounds wrong to me as the tracked ones are what gives us identity

Copy link
Contributor

Choose a reason for hiding this comment

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

Flipping here is correct. We should have updated the documentation.

The identity of a tracked struct is now defined by all untracked fields. If any untracked field changes, then we just assume it's a different tracked struct. This "trick" ensures that we get away without tracking dependencies on individual fields because the id from rev a and rev b now no longer compare equal

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah "id fields" have effectively been renamed to "untracked fields".

MichaReiser added a commit to astral-sh/ruff that referenced this pull request Feb 11, 2025
## Summary

Transition to using coarse-grained tracked structs (depends on
salsa-rs/salsa#657). For now, this PR doesn't
add any `#[tracked]` fields, meaning that any changes cause the entire
struct to be invalidated. It also changes `AstNodeRef` to be
compared/hashed by pointer address, instead of performing a deep AST
comparison.

## Test Plan

This yields a 10-15% improvement on my machine (though weirdly some runs
were 5-10% without being flagged as inconsistent by criterion, is there
some non-determinism involved?). It's possible that some of this is
unrelated, I'll try applying the patch to the current salsa version to
make sure.

---------

Co-authored-by: Micha Reiser <[email protected]>
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.

Coarse-grained tracked structs
4 participants