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

ParameterState: Add GetState extension #8450

Merged
merged 11 commits into from
Apr 3, 2024
Merged

Conversation

ScarletKuro
Copy link
Member

@ScarletKuro ScarletKuro commented Mar 24, 2024

Description

Usage:

var expandedState = component.GetState(x => x.Expanded);
var expandedState = component.GetState<bool>(nameof(component.Expanded));

How Has This Been Tested?

None for now

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 New feature or request PR: needs review labels Mar 24, 2024
@ScarletKuro
Copy link
Member Author

Without tests for now, as first I want to gather opinions.

@ScarletKuro ScarletKuro assigned henon and unassigned henon Mar 24, 2024
@ScarletKuro ScarletKuro requested a review from henon March 24, 2024 20:20
Copy link

codecov bot commented Mar 24, 2024

Codecov Report

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

Project coverage is 89.54%. Comparing base (851eba0) to head (37b80c2).
Report is 6 commits behind head on dev.

Files Patch % Lines
...dBlazor/Base/MudComponentBase.RegisterParameter.cs 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #8450      +/-   ##
==========================================
+ Coverage   89.50%   89.54%   +0.03%     
==========================================
  Files         411      411              
  Lines       11832    11846      +14     
  Branches     2349     2358       +9     
==========================================
+ Hits        10590    10607      +17     
+ Misses        718      712       -6     
- Partials      524      527       +3     

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

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 24, 2024

I don't like this

private class DummyParameterComponentLifeCycle : IParameterComponentLifeCycle

But there is no other way with HashSet. I guess we can use Dictionary<paramterName, IParameterComponentLifeCycle>, but I think HashSet could be slightly faster due to the simpler data structure? I'm not sure how much, that requires benchmark.

I also do not like this:

var parameterState = Unsafe.As<IReadOnlyParameterState<T>>(lifeCycle);

but this is the fastest cast in C# without type checking etc and we know it implements that interface anyway, and we can't store generics anyway so I dont have better ideas. I think it's still better than boxing the value (or the class) to object or using the non generic lists, here the Unsafe.As<T> should be the less evil.

I guess this is ok

private static string GetPropertyName<TComponent, T>(Expression<Func<TComponent, T>> propertyExpression) where TComponent : MudComponentBase

It's not hardcore reflection it's just retrieving the metadata.
Alternative can use the CallerArgumentExpression but it's question what is faster Expression + reflection or using compiler name - but then you'd need to replace string / regex to extract the parameter name from the expression and I'm unsure what is faster.

@henon
Copy link
Collaborator

henon commented Mar 24, 2024

but then you need string replace / regex to extract from the parameter name from the expression and I'm unsure what is faster.

or .Split("=>")[1].Trim() ?

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 24, 2024

or .Split("=>")[1].Trim() ?

I will try to bench... Just will take more time since I gotta do a lot of work during the week.

@henon
Copy link
Collaborator

henon commented Mar 24, 2024

Yes, we definitely have to do some benchmarking as this framework will be used in many components. Don't worry, it isn't time critical at all. In the meantime we expose the state as internal and changing to .GetState<T>(...) is pretty easy. Or we merge this and the optimizations happen in another PR if any are necessary. It's up to you.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Mar 25, 2024

Another idea to avoid Unsafe.As also not sure if it's better as there is still a cast

internal interface IParameterComponentLifeCycle
{
    IReadOnlyParameterState<T> GetState<T>();
}
internal class ParameterState<T>  : IParameterComponentLifeCycle
{
    public IReadOnlyParameterState<TParameter> GetState<TParameter>()
    {
        return (IReadOnlyParameterState<TParameter>)this;
    }
}
 var parameterState = lifeCycle.GetState<T>();

Problem is here that we cannot use the existing T in the GetState the compiler will tell type parameter 'type parameter' has the same name as the type parameter from outer type 'type'. We cannot just return this because T != TParameter but we can force cast, compiler allows this. But I'm also not sure how good is this.

@henon
Copy link
Collaborator

henon commented Mar 25, 2024

I don't know much about optimization. But it all is irrelevant until we have numbers to compare. This should be very easy to measure though. Like calling GetState a million times

@ScarletKuro
Copy link
Member Author

Yeah, i just mainly use the issue as sticky note for now, because I can forget what ideas I had

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Apr 1, 2024

first benchmark

so it doesn't really matter Dictionary or HashSet. It's pretty much the same. I might use Dictionary for better code readability.

upd: nvm, somehow dotnet run was running cached version of the compiled code.

I will update results.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Apr 1, 2024

Updated benchmarks:

| Method     | Iterations | Mean              | Error            | StdDev           | Rank | Gen0      | Allocated   |
|----------- |----------- |------------------:|-----------------:|-----------------:|-----:|----------:|------------:|
| Dictionary | 1          |          52.60 ns |         0.231 ns |         0.205 ns |    1 |    0.0006 |        32 B |
| HashSet    | 1          |          69.62 ns |         0.566 ns |         0.529 ns |    2 |    0.0017 |        88 B |

| Dictionary | 10         |         527.62 ns |         3.894 ns |         3.452 ns |    3 |    0.0057 |       320 B |
| HashSet    | 10         |         702.42 ns |         6.481 ns |         6.062 ns |    4 |    0.0172 |       880 B |

| Dictionary | 100        |       5,407.43 ns |        28.885 ns |        24.121 ns |    5 |    0.0763 |      3920 B |
| HashSet    | 100        |       8,186.50 ns |        43.712 ns |        38.749 ns |    6 |    0.1831 |      9520 B |

| Dictionary | 1000       |      55,728.17 ns |       271.700 ns |       240.855 ns |    7 |    0.7935 |     39920 B |
| HashSet    | 1000       |      79,202.24 ns |     1,371.953 ns |     1,877.945 ns |    8 |    1.8311 |     95920 B |

| Dictionary | 10000      |     630,318.64 ns |    12,485.841 ns |    13,359.710 ns |    9 |    7.8125 |    399920 B |
| HashSet    | 10000      |     843,945.19 ns |    14,599.747 ns |    12,191.450 ns |   10 |   18.5547 |    959920 B |

| Dictionary | 100000     |   6,828,680.61 ns |   106,783.142 ns |   109,658.425 ns |   11 |   78.1250 |   3999921 B |
| HashSet    | 100000     |   9,645,464.91 ns |   185,549.475 ns |   247,703.220 ns |   12 |  187.5000 |   9599921 B |

| Dictionary | 1000000    |  88,727,208.16 ns | 1,395,561.273 ns | 1,237,128.718 ns |   13 |  857.1429 |  47199936 B |
| HashSet    | 1000000    | 131,838,934.67 ns | 1,888,697.015 ns | 1,766,688.360 ns |   14 | 2000.0000 | 103199933 B |

Ok, now we can clearly see Dictionary is better.
I used the ParamterState code for this tests, it's not just raw Dictionary and HashSet.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Apr 2, 2024

| Method             | Iterations | Mean             | Error            | StdDev           | Rank | Gen0     | Allocated  |
|------------------- |----------- |-----------------:|-----------------:|-----------------:|-----:|---------:|-----------:|
| InterfaceForceCast | 1          |         51.09 ns |         0.205 ns |         0.172 ns |    1 |   0.0006 |       32 B |
| UnsafeAsOneGeneric | 1          |         51.44 ns |         0.911 ns |         1.153 ns |    1 |   0.0006 |       32 B |
| UnsafeAsTwoGeneric | 1          |         52.78 ns |         1.041 ns |         1.113 ns |    2 |   0.0006 |       32 B |

| UnsafeAsOneGeneric | 10         |        515.26 ns |         2.613 ns |         2.445 ns |    3 |   0.0057 |      320 B |
| InterfaceForceCast | 10         |        524.64 ns |         7.040 ns |         5.878 ns |    4 |   0.0057 |      320 B |
| UnsafeAsTwoGeneric | 10         |        526.51 ns |        10.308 ns |        11.457 ns |    4 |   0.0057 |      320 B |

| UnsafeAsTwoGeneric | 100        |      5,426.50 ns |        47.590 ns |        44.515 ns |    5 |   0.0763 |     3920 B |
| UnsafeAsOneGeneric | 100        |      5,539.63 ns |        85.039 ns |        79.545 ns |    6 |   0.0763 |     3920 B |
| InterfaceForceCast | 100        |      6,158.03 ns |        46.889 ns |        41.566 ns |    7 |   0.0763 |     3920 B |

| UnsafeAsTwoGeneric | 1000       |     55,010.84 ns |     1,075.823 ns |     1,238.919 ns |    8 |   0.7935 |    39920 B |
| InterfaceForceCast | 1000       |     56,280.48 ns |     1,123.163 ns |     1,574.518 ns |    8 |   0.7935 |    39920 B |
| UnsafeAsOneGeneric | 1000       |     61,810.49 ns |       852.063 ns |       797.020 ns |    9 |   0.7324 |    39920 B |

| InterfaceForceCast | 10000      |    598,782.00 ns |     5,777.814 ns |     5,404.571 ns |   10 |   7.8125 |   399920 B |
| UnsafeAsOneGeneric | 10000      |    603,790.79 ns |    10,872.094 ns |    15,592.432 ns |   10 |   7.8125 |   399920 B |
| UnsafeAsTwoGeneric | 10000      |    624,195.66 ns |    10,945.506 ns |    13,442.064 ns |   11 |   7.8125 |   399920 B |

| UnsafeAsTwoGeneric | 100000     |  6,592,866.15 ns |    39,345.085 ns |    36,803.417 ns |   12 |  78.1250 |  3999920 B |
| UnsafeAsOneGeneric | 100000     |  6,605,442.69 ns |    31,423.038 ns |    27,855.705 ns |   12 |  78.1250 |  3999920 B |
| InterfaceForceCast | 100000     |  6,782,391.24 ns |    37,889.131 ns |    33,587.728 ns |   13 |  78.1250 |  3999920 B |

| InterfaceForceCast | 1000000    | 82,153,158.24 ns |   687,000.443 ns |   573,676.484 ns |   14 | 857.1429 | 47199929 B |
| UnsafeAsTwoGeneric | 1000000    | 83,217,292.86 ns | 1,434,684.540 ns | 1,409,052.053 ns |   14 | 857.1429 | 47199936 B |
| UnsafeAsOneGeneric | 1000000    | 84,137,395.56 ns | 1,490,559.220 ns | 1,394,270.019 ns |   14 | 833.3333 | 47199939 B |

InterfaceForceCast:

(IReadOnlyParameterState<TParameter>)this;

UnsafeAsOneGeneric:

Unsafe.As<ParameterState<TParameter>>(this)

UnsafeAsTwoGeneric:

Unsafe.As<ParameterState<T>, IReadOnlyParameterState<TParameter>>(ref source)

I'd say the difference is not relevant.

@henon
Copy link
Collaborator

henon commented Apr 2, 2024

Description

Usage:

var expandedState = component.GetSate(x => x.Expanded);

Usage is gonna be more like this, or similar:

if (component.GetSate(x => x.Expanded).Value) { ... }

The consumer of GetState really cares only about the value so we might as well directly return the value:

if (component.GetSate(x => x.Expanded)) { ... }

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Apr 2, 2024

Just left to bench Expression vs string replace / regex
also, I didn't bench this cast before

if (lifeCycle is ParameterState<T> parameterState)
{
	return parameterState.Value;
}

And it was slightly faster than the 3 previous ones.
I think this is the most optimal one in terms of readability / performance.

@ScarletKuro
Copy link
Member Author

ScarletKuro commented Apr 2, 2024

Just substring wins, but

| Method                                       | Iterations | Mean               | Error             | StdDev            | Rank | Gen0      | Allocated   |
|--------------------------------------------- |----------- |-------------------:|------------------:|------------------:|-----:|----------:|------------:|
| GetPropertyNameSubstring                     | 1          |           6.457 ns |         0.0375 ns |         0.0333 ns |    1 |    0.0006 |        32 B |
| GetPropertyNameRegexCallerArgumentExpression | 1          |         118.008 ns |         0.5850 ns |         0.4567 ns |    3 |    0.0082 |       416 B |
| GetPropertyNameReflectionExpression          | 1          |         316.596 ns |         1.6595 ns |         1.5523 ns |    4 |    0.0095 |       488 B |

| GetPropertyNameSubstring                     | 10         |          66.516 ns |         1.0929 ns |         1.0734 ns |    2 |    0.0063 |       320 B |
| GetPropertyNameRegexCallerArgumentExpression | 10         |       1,136.188 ns |         6.0613 ns |         5.3732 ns |    6 |    0.0820 |      4160 B |
| GetPropertyNameReflectionExpression          | 10         |       3,129.015 ns |        21.5707 ns |        20.1773 ns |    7 |    0.0916 |      4880 B |GetPropertyNameSubstring                     

| GetPropertyNameSubstring                     | 100        |         621.028 ns |         4.5087 ns |         3.9968 ns |    5 |    0.0629 |      3200 B |
| GetPropertyNameRegexCallerArgumentExpression | 100        |      12,405.488 ns |       176.7030 ns |       165.2881 ns |    9 |    0.8240 |     41600 B |
| GetPropertyNameReflectionExpression          | 100        |      30,824.392 ns |       288.1333 ns |       255.4227 ns |   10 |    0.9155 |     48800 B |

| GetPropertyNameSubstring                     | 1000       |       6,411.315 ns |       125.3450 ns |       149.2143 ns |    8 |    0.6332 |     32000 B |
| GetPropertyNameRegexCallerArgumentExpression | 1000       |     122,929.139 ns |     2,440.4850 ns |     3,726.8784 ns |   12 |    8.0566 |    416000 B |
| GetPropertyNameReflectionExpression          | 1000       |     263,057.433 ns |     5,179.1391 ns |    10,924.5622 ns |   13 |    9.2773 |    488000 B |

| GetPropertyNameSubstring                     | 10000      |      62,624.584 ns |       949.2378 ns |       792.6566 ns |   11 |    6.3477 |    320000 B |
| GetPropertyNameRegexCallerArgumentExpression | 10000      |   1,301,109.823 ns |    24,360.7642 ns |    31,675.8745 ns |   15 |   82.0313 |   4160000 B |
| GetPropertyNameReflectionExpression          | 10000      |   2,620,315.024 ns |    50,824.3786 ns |    42,440.6580 ns |   16 |   93.7500 |   4880000 B |

| GetPropertyNameSubstring                     | 100000     |     624,595.594 ns |    12,358.5368 ns |    12,691.3074 ns |   14 |   63.4766 |   3200000 B |
| GetPropertyNameRegexCallerArgumentExpression | 100000     |  12,418,025.293 ns |   247,336.7602 ns |   242,917.7703 ns |   18 |  828.1250 |  41600006 B |
| GetPropertyNameReflectionExpression          | 100000     |  26,998,344.583 ns |   522,086.4028 ns |   488,359.9451 ns |   19 |  968.7500 |  48800002 B |

| GetPropertyNameSubstring                     | 1000000    |   6,002,121.317 ns |    47,668.4667 ns |    42,256.8541 ns |   17 |  632.8125 |  32000000 B |
| GetPropertyNameRegexCallerArgumentExpression | 1000000    | 113,700,260.000 ns |   867,329.6358 ns |   724,259.5274 ns |   20 | 8200.0000 | 416000080 B |
| GetPropertyNameReflectionExpression          | 1000000    | 341,216,026.667 ns | 5,411,010.6398 ns | 5,061,462.7097 ns |   21 | 9000.0000 | 488000400 B |

The problem is that the expression is x => x.XYZ, not () => XYZ, also nesting is possible
For regex I came up with such expression (?:\w+=>\s*)*[\w.]+\.(\w+) basically it doing anything(empty,infinity)=>(empty,infinity)anything.XYZ.XYZN+1 and always takes the last one aka XYZN+1
for substring I just did this....

int lastDotIndex = propertyName.LastIndexOf('.');

if (lastDotIndex != -1)
{
    string pName = propertyName[(lastDotIndex + 1)..];

    return pName;
}

pretty simply huh, but I don't know how much cases this covers, if further enchantment will be required for GetPropertyNameSubstring the more it will lose it's positions in performance.
Current subscring is pretty fast and inexpensive for single call 6ns. I also tried to make a span version, but this didn't do anything as there is no much string manipulations, and the LastIndexOf for string already using spans and probably there are a lot of other optimizations behind the roof even if you don't explicitly use ReadOnlySpan<char>

But for extensive reading I would probably just prefer

component.GetSate<bool>(nameof(component.Expanded));

Below is the code that I used for the different get property name scenarios.

Code
  internal static partial class Extensions
  {

      [GeneratedRegex(@"(?:\w+=>\s*)*[\w.]+\.(\w+)")]
      private static partial Regex LambdaExpressionRegex();

      public static string GetPropertyNameSubstringSpan<TComponent, T>(this TComponent component, Func<TComponent, T> propertyExpression, [CallerArgumentExpression(nameof(propertyExpression))] string? propertyName = null) where TComponent : MudComponentBase
      {
          ArgumentNullException.ThrowIfNull(propertyName);

          ReadOnlySpan<char> propertyNameSpan = propertyName.AsSpan();
          int lastDotIndex = propertyNameSpan.LastIndexOf('.');

          if (lastDotIndex != -1)
          {
              ReadOnlySpan<char> pName= propertyNameSpan.Slice(lastDotIndex + 1);
              return pName.ToString();
          }

          return propertyName;
      }

      public static string GetPropertyNameSubstring<TComponent, T>(this TComponent component, Func<TComponent, T> propertyExpression, [CallerArgumentExpression(nameof(propertyExpression))] string? propertyName = null) where TComponent : MudComponentBase
      {
          ArgumentNullException.ThrowIfNull(propertyName);

          int lastDotIndex = propertyName.LastIndexOf('.');

          if (lastDotIndex != -1)
          {
              string pName = propertyName[(lastDotIndex + 1)..];

              return pName;
          }

          return propertyName;
      }

      public static string GetPropertyNameRegexCallerArgumentExpression<TComponent, T>(this TComponent component, Func<TComponent, T> propertyExpression, [CallerArgumentExpression(nameof(propertyExpression))] string? propertyName = null) where TComponent : MudComponentBase
      {
          ArgumentNullException.ThrowIfNull(propertyName);

          var match = LambdaExpressionRegex().Match(propertyName);
          
          return match.Groups[1].Value;
      }

      public static string GetPropertyNameReflectionExpression<TComponent, T>(this TComponent component, Expression<Func<TComponent, T>> propertyExpression) where TComponent : MudComponentBase
      {
          var propertyName = GetPropertyName(propertyExpression);

          return propertyName;
      }

      private static string GetPropertyName<TComponent, T>(Expression<Func<TComponent, T>> propertyExpression) where TComponent : MudComponentBase
      {
          ArgumentNullException.ThrowIfNull(nameof(propertyExpression));

          if (propertyExpression.Body is not MemberExpression body)
          {
              throw new ArgumentException(@"Invalid argument", nameof(propertyExpression));
          }

          if (body.Member is not PropertyInfo property)
          {
              throw new ArgumentException(@"Argument is not a property", nameof(propertyExpression));
          }

          return property.Name;
      }
  }

I also didn't expect GetPropertyNameReflectionExpression to lose this badly.

@henon
Copy link
Collaborator

henon commented Apr 3, 2024

But for extensive reading I would probably just prefer

component.GetSate<bool>(nameof(component.Expanded));

Yes you are absolutely right. It is equally usable as the expression syntax, still quite readable and has the best performance.

@ScarletKuro
Copy link
Member Author

Will add tests later.

@ScarletKuro
Copy link
Member Author

Added tests.

@ScarletKuro ScarletKuro requested a review from henon April 3, 2024 15:22
@henon henon merged commit 11e3202 into MudBlazor:dev Apr 3, 2024
4 checks passed
@henon
Copy link
Collaborator

henon commented Apr 3, 2024

Awesome

@ScarletKuro ScarletKuro deleted the state_v3 branch April 3, 2024 16:07
biegehydra pushed a commit to biegehydra/MudBlazor that referenced this pull request Apr 26, 2024
@ScarletKuro
Copy link
Member Author

ScarletKuro commented Apr 26, 2024

I think now that returning T was a mistake, because in some scenarios you would want to get the instance(of ParameterState<T>) once and then re-read from it whenever you need, aka you could cache the instance, but since you get T you are forced to always call the GetState which always invokes the TryGetValue. When outside component requires simultaneously read the state in different methods may degrade the performance and this is the case of the DataGrid which is very complex. Even if the FrozenDictionary is fast on read, I just don't like that something is called when it's not necessary.

@henon
Copy link
Collaborator

henon commented Apr 27, 2024

How about adding another GetParameterState for those scenarios

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants