Skip to content

ParameterState: Call the handler once when parameters reference the same handler.#8384

Merged
ScarletKuro merged 12 commits intoMudBlazor:devfrom
ScarletKuro:state_optimization
Mar 17, 2024
Merged

ParameterState: Call the handler once when parameters reference the same handler.#8384
ScarletKuro merged 12 commits intoMudBlazor:devfrom
ScarletKuro:state_optimization

Conversation

@ScarletKuro
Copy link
Member

@ScarletKuro ScarletKuro commented Mar 16, 2024

Description

Helps to optimize this case: #8381
I think this is the best solution as we do not use any reflection, which is very important when amount of components and variables increase with the ParameterState, it could create a bottleneck.

How Has This Been Tested?

New unit tests.
Visual, at least that we didn't break the existing components that use ParameterState

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist:

  • The PR is submitted to the correct branch (dev).
  • My code follows the code style of this project.
  • I've added relevant tests.

@github-actions github-actions bot added enhancement Adds a new feature or enhances existing functionality (not fixing a defect) in the main library PR: needs review labels Mar 16, 2024
@ScarletKuro ScarletKuro requested a review from henon March 16, 2024 23:12
@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 16, 2024

This will be automatically applied when lambda expressions will be used too:
s

So I have to warn about when you using lambda instead of the method, because it will optimize when the expression are equals for example:

RegisterParameter(nameof(Spacing), () => Spacing, () => _increaseCounter++);
RegisterParameter(nameof(Max), () => Max, () =>  _increaseCounter++);

The increase counter will be called once, when someone might expect that the counter would increase twice, but it will be once.

But if you use bools like this

RegisterParameter(nameof(Spacing), () => Spacing, () => _childrenNeedUpdates = true);
RegisterParameter(nameof(Max), () => Max, () => _childrenNeedUpdates = true);

Then it should be fine, since you don't need to set the same boolean twice.
@henon you can add this warning to documentation if you want.

@codecov
Copy link

codecov bot commented Mar 16, 2024

Codecov Report

Attention: Patch coverage is 96.22642% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 88.95%. Comparing base (345ec7c) to head (1e3462e).

Files Patch % Lines
src/MudBlazor/Base/MudComponentBase.cs 50.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #8384      +/-   ##
==========================================
+ Coverage   88.82%   88.95%   +0.13%     
==========================================
  Files         407      410       +3     
  Lines       12226    12264      +38     
  Branches     2441     2451      +10     
==========================================
+ Hits        10860    10910      +50     
+ Misses        834      826       -8     
+ Partials      532      528       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 17, 2024

at 4am got an enlightenment....
Instead of doing this:

var parameterChangeShouldFire = new HashSet<ParameterMetadata>(ParameterHandlerUniquenessComparer.Default);
var changedParams = _parameters.Where(parameter => parameter.HasHandler && parameter.HasParameterChanged(parameters)).ToList();

await baseSetParametersAsync(parameters);

foreach (var changedParam in changedParams)
{
	if (parameterChangeShouldFire.Add(changedParam.Metadata))
	{
		await changedParam.ParameterChangeHandleAsync();
	}
}

Which works great, but I can just do:

#if NET8_0_OR_GREATER
        var parametersHandlerShouldFire = _parameters
            .Where(parameter => parameter.HasHandler && parameter.HasParameterChanged(parameters))
            .ToFrozenSet(ParameterHandlerUniquenessComparer.Default);
#else
        var parametersHandlerShouldFire = _parameters
            .Where(parameter => parameter.HasHandler && parameter.HasParameterChanged(parameters))
            .ToHashSet(ParameterHandlerUniquenessComparer.Default);
#endif

        await baseSetParametersAsync(parameters);

        foreach (var parameterHandlerShouldFire in parametersHandlerShouldFire)
        {
            await parameterHandlerShouldFire.ParameterChangeHandleAsync();
        }

With extending the ParameterHandlerUniquenessComparer and allowing to compare the IParameterComponentLifeCycle too.
This way we have only one collection, and we can use the FrozenSet for .net8 which is 17% faster than the normal one because it's fixed size, an optimization never hurts especially in such important part.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 17, 2024

So I have to warn about when you using lambda instead of the method, because it will optimize when the expression are equals for example:

Oh and if you will do

RegisterParameter(nameof(Spacing), () => Spacing, OnParameterChanged());
RegisterParameter(nameof(Max), () => Max, () => OnParameterChanged());

This will also be considered different, but I think it's all fine compromises considering that this string expressions are generated at compilation, we could just cover this with documentation. After all if we really need we could trim it down with regex in future,

@henon
Copy link
Contributor

henon commented Mar 17, 2024

Yes, passing an anonymous func calling the handler method ()=>OnParameterChanged() is useless complication anyway. Passing a method group OnParameterChanged is the best way to register a shared change handler and I'll document it that way.

@henon
Copy link
Contributor

henon commented Mar 17, 2024

So I have to warn about when you using lambda instead of the method, because it will optimize when the expression are equals for example:

RegisterParameter(nameof(Spacing), () => Spacing, () => _increaseCounter++);
RegisterParameter(nameof(Max), () => Max, () =>  _increaseCounter++);

The increase counter will be called once, when someone might expect that the counter would increase twice, but it will be once.

This would indeed be quite unexpected. Maybe we only optimize when a method group is passed? We can recognize anonymous functions because they contain =>.

@ScarletKuro
Copy link
Member Author

This would indeed be quite unexpected. Maybe we only optimize when a method group is passed? We can recognize anonymous functions because they contain =>.

Yeah, we can add this to "exclusion rule" if you'd like

@ScarletKuro
Copy link
Member Author

updated the code

@henon
Copy link
Contributor

henon commented Mar 17, 2024

It works. I never doubted that it would ;)
param state shared opt

public partial class AbcXyzTestComp : MudComponentBase
{
    public AbcXyzTestComp() : base() {
        // abc shared handler group
        _a=RegisterParameter(nameof(A), ()=>A, OnAbcChanged);
        _b=RegisterParameter(nameof(B), ()=>B, OnAbcChanged);
        _c=RegisterParameter(nameof(C), ()=>C, OnAbcChanged);
        // xyz shared handler group
        _x=RegisterParameter(nameof(X), ()=>X, OnXyzChanged);
        _y=RegisterParameter(nameof(Y), ()=>Y, OnXyzChanged);
        _z=RegisterParameter(nameof(Z), ()=>Z, OnXyzChanged);
    }

    private ParameterState<int> _a;
    private ParameterState<int> _b;
    private ParameterState<int> _c;
    private ParameterState<int> _x;
    private ParameterState<int> _y;
    private ParameterState<int> _z;

    private void OnAbcChanged()
    {
        AbcHandlerCallCount++;
    }

    private void OnXyzChanged()
    {
        XyzHandlerCallCount++;
    }

    public int AbcHandlerCallCount { get; private set; }
    public int XyzHandlerCallCount { get; private set; }
    
    [Parameter]
    public int A { get; set; }

    [Parameter]
    public int B { get; set; }

    [Parameter]
    public int C { get; set; }
    
    [Parameter]
    public int X { get; set; }

    [Parameter]
    public int Y { get; set; }

    [Parameter]
    public int Z { get; set; }

}

@henon
Copy link
Contributor

henon commented Mar 17, 2024

I'll add a test for the lambda exception in the afternoon

@ScarletKuro
Copy link
Member Author

Thanks for the bUnit tests, I was kind of lazy to do them 😭.

@ScarletKuro
Copy link
Member Author

Wait you can use @bind-A="_a"

<AbcXyzTestComp @bind-A="_a" @bind-B="_b" @bind-C="_c" @bind-X="_x" @bind-Y="_y" @bind-Z="_z"/>

when there is actually no

[Parameter]
public EventCallback<int> AChanged { get; set; }

?
I always though it was necessary for two way binding? And it's the examples I always saw.
I learned something new today.

@henon
Copy link
Contributor

henon commented Mar 17, 2024

Hmm, you are right, there is no need to use @bind- here, I'll remove it.

@henon
Copy link
Contributor

henon commented Mar 17, 2024

Thanks for the bUnit tests, I was kind of lazy to do them 😭.

No worries, I did promise to contribute the tests

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 17, 2024

Hmm, you are right, there is no need to use @bind- here, I'll remove it.

Oh don't worry, u can leave as it.
It was about that I'm surprised that the EventCallback is not necessary to use this syntax, I always thought the opposite.

@ScarletKuro
Copy link
Member Author

Do we merge? I don't think I have anything to add.

@henon
Copy link
Contributor

henon commented Mar 17, 2024

I think it's ready to merge

@ScarletKuro ScarletKuro merged commit 1607b06 into MudBlazor:dev Mar 17, 2024
@ScarletKuro ScarletKuro deleted the state_optimization branch March 17, 2024 14:37
@jspuij
Copy link

jspuij commented Mar 23, 2024

at 4am got an enlightenment.... Instead of doing this:

var parameterChangeShouldFire = new HashSet<ParameterMetadata>(ParameterHandlerUniquenessComparer.Default);
var changedParams = _parameters.Where(parameter => parameter.HasHandler && parameter.HasParameterChanged(parameters)).ToList();

await baseSetParametersAsync(parameters);

foreach (var changedParam in changedParams)
{
	if (parameterChangeShouldFire.Add(changedParam.Metadata))
	{
		await changedParam.ParameterChangeHandleAsync();
	}
}

Which works great, but I can just do:

#if NET8_0_OR_GREATER
        var parametersHandlerShouldFire = _parameters
            .Where(parameter => parameter.HasHandler && parameter.HasParameterChanged(parameters))
            .ToFrozenSet(ParameterHandlerUniquenessComparer.Default);
#else
        var parametersHandlerShouldFire = _parameters
            .Where(parameter => parameter.HasHandler && parameter.HasParameterChanged(parameters))
            .ToHashSet(ParameterHandlerUniquenessComparer.Default);
#endif

        await baseSetParametersAsync(parameters);

        foreach (var parameterHandlerShouldFire in parametersHandlerShouldFire)
        {
            await parameterHandlerShouldFire.ParameterChangeHandleAsync();
        }

With extending the ParameterHandlerUniquenessComparer and allowing to compare the IParameterComponentLifeCycle too. This way we have only one collection, and we can use the FrozenSet for .net8 which is 17% faster than the normal one because it's fixed size, an optimization never hurts especially in such important part.

Unfortunately, using a Frozenset on .NET 8 on IOS (for a maui hybrid app), makes the application crash atm when using AOT in combination with the interpreter. Until this is fixed, I'd propose using the normal hashset for .NET 8 as well.

see:

CommunityToolkit/Maui#1752 (comment)

The workaround mentioned in this thread unfortunately makes the app crash completely.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 23, 2024

Hi

Unfortunately, using a Frozenset on .NET 8 on IOS (for a maui hybrid app), makes the application crash atm when using AOT in combination with the interpreter. Until this is fixed, I'd propose using the normal hashset for .NET 8 as well.

see:

CommunityToolkit/Maui#1752 (comment)

The workaround mentioned in this thread unfortunately makes the app crash completely.

That's very unfortunate and big surprise that it's not working even with interpreter.
I will probably fix it in this #8416 PR as I'm about to merge it anyway.

Tho I have a question, have people tried only:

<PropertyGroup Condition="$(Configuration.Contains('Release')) And $(TargetFramework.Contains('ios'))">
	<MtouchInterpreter>-all,System.Collections.Immutable</MtouchInterpreter>
</PropertyGroup>

but not:

<MtouchLink>SdkOnly</MtouchLink>
<UseInterpreter>True</UseInterpreter>

?
upd: I left a comment - CommunityToolkit/Maui#1752 (comment)

@ScarletKuro
Copy link
Member Author

<MtouchLink>SdkOnly</MtouchLink>
<UseInterpreter>True</UseInterpreter>

If people will report this is working for them, I will probably not change to normal HashSet, because this project properties are a must for iOS when using with MudBlazor for smooth experience anyway. We use some reflection, and without UseInterpreter it will not work, and this is documented in our repository.
If people say it's still crashing, then obviously this will be immediately changed.

@jspuij
Copy link

jspuij commented Mar 24, 2024

<UseInterpreter> works but completely disables AOT compilation. That is quite the performance regression.

<MtouchInterpreter> leaves AOT enabled, but includes the interpreter for all stuff that cannot be AOT compiled.

However, I can now attest that it is the combination of UseInterpreter and that makes it fail. I did not have the conditions included. UseInterpreter is default in Debug builds, but not in Release builds.

So this statement works (both in debug and in release):

<PropertyGroup Condition="$(Configuration.Contains('Release')) And $(TargetFramework.Contains('ios'))">
	<MtouchInterpreter>-all,System.Collections.Immutable</MtouchInterpreter>
</PropertyGroup>

However this causes a crash:

<PropertyGroup>
	<MtouchInterpreter>-all,System.Collections.Immutable</MtouchInterpreter>
</PropertyGroup>

and this causes an exception (InvalidCastException)

<PropertyGroup>
	<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 24, 2024

UseInterpreter is default in Debug builds, but not in Release builds.

dotnet/maui#13019

That is quite the performance regression

What are the performance regression except the startup? I'm sorry, but I cannot imagine full AOT to be prod ready in MAUI for now, more drawbacks than benefits. Once you start to use reflection, expression etc you will need to use UseInterpreter (at least it was the case a year ago, hard to imagine that anything changed since then).

So this statement works (both in debug and in release):

<PropertyGroup Condition="$(Configuration.Contains('Release')) And $(TargetFramework.Contains('ios'))">
	<MtouchInterpreter>-all,System.Collections.Immutable</MtouchInterpreter>
</PropertyGroup>

Great, I think you should share it in the CommunityToolkit.

@jspuij
Copy link

jspuij commented Mar 24, 2024

UseInterpreter is default in Debug builds, but not in Release builds.

dotnet/maui#13019

That is quite the performance regression

What are the performance regression except the startup? I'm sorry, but I cannot imagine full AOT to be prod ready in MAUI for now, more drawbacks than benefits. Once you start to use reflection, expression etc you will need to use UseInterpreter (at least it was the case a year ago, hard to imagine that anything changed since then).

UseInterpreter enables the interpreter and disables AOT compilation. Since iOS cannot JIT compile, this means that the entire application will be interpreted.

MtouchInterpreter will enable the interpreter for iOS only, and in addition will still allow AOT compilation. the -all indicates that all assemblies will be AOT compiled. System.Collection.Immutable will be interpreted (since it does not have the - in front of it.

<MtouchInterpreter>-all,System.Collections.Immutable</MtouchInterpreter>

see also:

https://learn.microsoft.com/en-us/dotnet/maui/macios/interpreter?view=net-maui-8.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Adds a new feature or enhances existing functionality (not fixing a defect) in the main library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants