Skip to content

Ensure we cache the previous item when two items are considered equal#57888

Merged
RikkiGibson merged 1 commit intodotnet:mainfrom
chsienki:source-generators/fix-modified-cache
Nov 29, 2021
Merged

Ensure we cache the previous item when two items are considered equal#57888
RikkiGibson merged 1 commit intodotnet:mainfrom
chsienki:source-generators/fix-modified-cache

Conversation

@chsienki
Copy link
Copy Markdown
Member

@chsienki chsienki commented Nov 19, 2021

Fixes a subtle issue for Razor:

When the razor generator is running in 'suppressed' mode, they effectively treat all comparisons as being true, so that the driver just uses what is in the cache without re-calculating anything. Today, in the generator driver we take the new item and place it in the cache when this happens. For compound objects, this can mean that a comparer is not stable across iterations.

Consider:

We have the following compound object: (bool check, object state)

With a comparer like: (old, @new) => @new.check || old.state.Equals(new.State)

In the first run the values are (false, null) and we have no cache yet, so the comparer will be ignored and we write into the state table: (false, null) and the generator logic runs, producing downstream results from the (false, null) values.

In the second iteration we have (true, object1). The comparer will run, return true, and the driver will write in the new state of (true, object1). However because the comparer returned true, the actual driver logic is not invoked and the cached values of the previous run are used.

In the third iteration we have (false, object1). The comparer will run, and use the second branch of the logic: however, we will be comparing object1 to object1 which will return true. Once again the cached version of the results are used which were generated from (false, null) which is incorrect.

Instead when a comparer returns that two object are equal, we return the original item to the cache.

States:

Incorrect:

Input CompareTo State Downstream based on
(false, null) null New (false, null)
(true, object1) (false, null) Cached (false, null)
(false, object1) (true, object1) Cached (false, null)

Fixed:

Input CompareTo State Downstream based on
(false, null) null New (false, null)
(true, object1) (false, null) Cached (false, null)
(false, object1) (false, null) Modified (false, object1)

@jaredpar
Copy link
Copy Markdown
Member

We have the following compound object: (bool check, object state)

You say "We" here but is this the way the Razor team is designing their comparers / state?

Instead when a comparer returns that two object are equal, we return the original item to the cache.

I agree in general you should prefer "previous" over the "new" one but given a correct implementation of comparison this shouldn't be matter. I'm still a bit unclear what the problem is here. Based on the code change my intuition is that Razor has a non-conformant comparer here. Am I missing a subtlty of the change?

@chsienki
Copy link
Copy Markdown
Member Author

chsienki commented Nov 23, 2021

@jaredpar Yeah, its kind of an odd one. Ultimately the comparer that the razor team is breaking transitivity.

In a simpler example, if we have a comparer where A == B, B == C, but A != C we'll hit this problem. Obviously one option is to just say 'don't do that', but by changing the behavior to keeping the previous we flip the issue of broken comparers around from a false positive to a false negative.

Current:
Run with A, store it. Run with B, determine equal, store it, use cache. Run with C determine equal (to B), store it, incorrectly use cached value that was generated from A.

Fixed:
Run with A, store it. Run with B, determine equal, keep A, use cache. Run with C determine not equal (to A), store C run new generation pass.

While this might mean we occasionally run when we don't need to (if the user has a dud comparer) we'll no longer be in the situation where we should run, but didn't, which can lead to broken code.

@jaredpar
Copy link
Copy Markdown
Member

jaredpar commented Nov 23, 2021

Ultimately the comparer that the razor team is breaking transitivity.

Do we have a bug on the Razor team for them to fix this?

In theory I agree keeping previous is better than new as a general rule in the cache (better for GC purposes as we're promoting less values to higher generations). Hence I'm okay with the change overall. That should be an tiny performance detail though, not a correctness one. Doing this for correctness feels wrong, it's not a bug in our implementation, it's a bug in Razor they should be fixing in parallel.

Any non-transitive comparer here is going to cause problems.

@chsienki
Copy link
Copy Markdown
Member Author

I'm not sure if there's a single bug for razor. The fix is to unify the design time and build time experiences, until they do that they need to be able to compare in the way that they're doing to achieve any sort of perf.

Agree we shouldn't encourage it, but my point was with this approach a broken comparer produces correct code slowly, rather than wrong code quickly.

@RikkiGibson
Copy link
Copy Markdown
Member

The explanation at #57888 (comment) made the issue immediately obvious. Thanks.

AssertTableEntries(previousTable, expected);

builder = previousTable.ToBuilder();
Assert.True(builder.TryModifyEntry(1, EqualityComparer<int>.Default)); // ((1, EntryState.Cached))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So I keep forgetting: is the entry we're trying to modify denoted implicitly by the order of calls to TryModifyEntry?


var entryState = comparer.Equals(previous, replacement) ? EntryState.Cached : EntryState.Modified;
modified.Add(replacement, entryState);
(var chosen, var state) = GetModifiedItemAndState(previous, replacement, comparer);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: can be var (chosen, state) ;)

{
// when comparing an item to check if its modified we explicitly cache the *previous* item in the case where its
// considered to be equal. This ensures that subsequent comparisons are stable across future generation passes.
return comparer.Equals(previous, replacement)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wouldn't dive into any rabbit holes here but I do wonder if there is a practical way to observe that the user's comparer is not transitive and give a warning. Seems like whatever you could do to check that would introduce undesirable overhead in a release build, though.

@pranavkm
Copy link
Copy Markdown
Contributor

Is it cool if I merged this change since it's ready to go? I think @chsienki is out for a couple more days and I'd like to give this change a try before I have to take off for the holidays.

@RikkiGibson
Copy link
Copy Markdown
Member

Sure.

@RikkiGibson RikkiGibson merged commit b871419 into dotnet:main Nov 29, 2021
@ghost ghost modified the milestones: 17.1, Next Nov 29, 2021
@jaredpar
Copy link
Copy Markdown
Member

@pranavkm can you link me the bug on the razor side tracking that the comparer you're using has incorrect semantics? That seems like a bigger issue here that we need to track down.

@allisonchou allisonchou modified the milestones: Next, 17.1.P2 Nov 30, 2021
jkoritzinsky added a commit to jkoritzinsky/roslyn that referenced this pull request Dec 3, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants