Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/virtualization-benchmark-probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Virtualization benchmark probe

on:
push:
branches:
- upstream/benchmarks-virtualized-select-selection-state
pull_request:
branches:
- dev
paths:
- 'benchmarks/**'
- '.github/workflows/virtualization-benchmark-probe.yml'
workflow_dispatch:

jobs:
probe:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Run upstream-to-PR1 structural probe
shell: pwsh
run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -FixedRef "e7020d49a824a371ab3f3450255f10d9cfad8783" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results"

- name: Upload probe results
if: always()
uses: actions/upload-artifact@v4
with:
name: virtualization-benchmark-probe
path: benchmark-results
3 changes: 3 additions & 0 deletions CodeBeamMudExtensions.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<Solution>
<Folder Name="/benchmarks/">
<Project Path="benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj" />
</Folder>
<Folder Name="/docs/">
<Project Path="docs/CodeBeam.MudBlazor.Extensions.Docs.Server/CodeBeam.MudBlazor.Extensions.Docs.Server.csproj" />
<Project Path="docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/CodeBeam.MudBlazor.Extensions.Docs.Wasm.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
using MudExtensions.Services;

namespace MudExtensions.Benchmarks;

internal static class BenchmarkBunitContext
{
public static BunitContext Create()
{
var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddMudServices(options =>
{
options.SnackbarConfiguration.ShowTransitionDuration = 0;
options.SnackbarConfiguration.HideTransitionDuration = 0;
options.PopoverOptions.CheckForPopoverProvider = false;
});
context.Services.AddMudExtensions();
return context;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>MudExtensions.Benchmarks</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="bunit" Version="2.4.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\CodeBeam.MudBlazor.Extensions\CodeBeam.MudBlazor.Extensions.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using BenchmarkDotNet.Attributes;
using Bunit;

namespace MudExtensions.Benchmarks;

[MemoryDiagnoser]
[BenchmarkCategory("MudListExtended", "InitialRender")]
public class ListExtendedInitialRenderBenchmarks
{
private List<int?> _items = null!;
private int?[] _selectedValues = null!;

[Params(10, 100, 1_000, 4_000)]
public int ItemCount { get; set; }

[Params(false, true)]
public bool Virtualize { get; set; }

[GlobalSetup]
public void Setup()
{
_items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList();
_selectedValues = [1, ItemCount];
}

[Benchmark]
public async Task<int> RenderList()
{
await using var context = BenchmarkBunitContext.Create();
var cut = context.Render<MudListExtended<int?>>(parameters => parameters
.Add(x => x.ItemCollection, _items)
.Add(x => x.Virtualize, Virtualize)
.Add(x => x.MultiSelection, true)
.Add(x => x.SelectedValues, _selectedValues));

return cut.RenderCount;
}
}
10 changes: 10 additions & 0 deletions benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using BenchmarkDotNet.Running;
using MudExtensions.Benchmarks;

if (args.Length > 0 && string.Equals(args[0], "probe", StringComparison.OrdinalIgnoreCase))
{
return await ScaleProbe.RunAsync(args[1..]);
}

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
return 0;
206 changes: 206 additions & 0 deletions benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using System.Diagnostics;
using System.Globalization;
using Bunit;

namespace MudExtensions.Benchmarks;

internal static class ScaleProbe
{
public static async Task<int> RunAsync(string[] args)
{
var outputPath = GetOption(args, "--output");
var variant = Environment.GetEnvironmentVariable("BENCHMARK_VARIANT") ?? "current";

await WarmUpAsync();

var results = new List<ProbeResult>();
foreach (var scenario in Scenarios())
{
results.Add(await MeasureAsync(variant, scenario));
}

WriteTable(results);

if (!string.IsNullOrWhiteSpace(outputPath))
{
var fullPath = Path.GetFullPath(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, ToCsv(results));
Console.WriteLine($"\nWrote {fullPath}");
}

return 0;
}

private static async Task<ProbeResult> MeasureAsync(string variant, ProbeScenario scenario)
{
var items = Enumerable.Range(1, scenario.ItemCount).Select(static value => (int?)value).ToList();
var selectedValues = CreateSelectedValues(scenario.ItemCount, scenario.SelectedCount);

await using var context = BenchmarkBunitContext.Create();

GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
GC.WaitForPendingFinalizers();
var retainedBefore = GC.GetTotalMemory(forceFullCollection: true);
var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true);

var stopwatch = Stopwatch.StartNew();
var cut = context.Render<SelectBenchmarkHost>(parameters => parameters
.Add(x => x.Items, items)
.Add(x => x.SelectedValues, selectedValues)
.Add(x => x.SelectCount, scenario.SelectCount)
.Add(x => x.Virtualize, scenario.Virtualize));
stopwatch.Stop();

var allocatedAfter = GC.GetTotalAllocatedBytes(precise: true);
var retainedAfter = GC.GetTotalMemory(forceFullCollection: true);

var shadowItemCount = cut.FindAll("div[style='display: none'] div.mud-list-item-extended").Count;
var selectItemComponentCount = cut.FindComponents<MudSelectItemExtended<int?>>().Count;
var markupLength = cut.Markup.Length;

return new(
variant,
scenario.ItemCount,
scenario.SelectCount,
selectedValues.Length,
scenario.Virtualize,
stopwatch.Elapsed.TotalMilliseconds,
allocatedAfter - allocatedBefore,
retainedAfter - retainedBefore,
cut.RenderCount,
shadowItemCount,
selectItemComponentCount,
markupLength);
}

private static int?[] CreateSelectedValues(int itemCount, int selectedCount)
{
if (selectedCount <= 0)
{
return [];
}

if (selectedCount == 1)
{
return [itemCount];
}

return Enumerable.Range(0, Math.Min(itemCount, selectedCount))
.Select(index => (int?)(1 + (index * (itemCount - 1) / (Math.Min(itemCount, selectedCount) - 1))))
.Distinct()
.ToArray();
}

private static IEnumerable<ProbeScenario> Scenarios()
{
foreach (var itemCount in new[] { 10, 100, 1_000, 4_000 })
{
yield return new(itemCount, 1, 2, true);
}

yield return new(4_000, 5, 2, true);
yield return new(4_000, 20, 2, true);

foreach (var itemCount in new[] { 10, 100, 1_000, 4_000 })
{
yield return new(itemCount, 1, 2, false);
}

foreach (var selectedCount in new[] { 1, 10, 30, 100 })
{
yield return new(4_000, 1, selectedCount, true);
}
}

private static async Task WarmUpAsync()
{
var items = Enumerable.Range(1, 10).Select(static value => (int?)value).ToList();
await using var context = BenchmarkBunitContext.Create();
context.Render<SelectBenchmarkHost>(parameters => parameters
.Add(x => x.Items, items)
.Add(x => x.SelectedValues, new int?[] { 1, 10 })
.Add(x => x.Virtualize, true));
}

private static void WriteTable(IEnumerable<ProbeResult> results)
{
Console.WriteLine("Variant Items Selects Selected Virt Time ms Allocated Retained* Renders ShadowItems Components Markup");
foreach (var result in results)
{
Console.WriteLine(
$"{result.Variant,-9} {result.ItemCount,5} {result.SelectCount,7} {result.SelectedCount,8} {result.Virtualize,4} " +
$"{result.ElapsedMilliseconds,8:F2} {FormatBytes(result.AllocatedBytes),11} {FormatBytes(result.ApproxRetainedBytes),11} " +
$"{result.RenderCount,8} {result.ShadowItemCount,11} {result.SelectItemComponentCount,10} {result.MarkupLength,6}");
}

Console.WriteLine("\n* Retained is an approximate full-GC delta while the rendered component remains alive; use BenchmarkDotNet allocation/GC results for statistical comparisons.");
}

private static string ToCsv(IEnumerable<ProbeResult> results)
{
var lines = new List<string>
{
"Variant,ItemCount,SelectCount,SelectedCount,Virtualize,ElapsedMilliseconds,AllocatedBytes,ApproxRetainedBytes,RenderCount,ShadowItemCount,SelectItemComponentCount,MarkupLength"
};

lines.AddRange(results.Select(result => string.Join(',',
Csv(result.Variant),
result.ItemCount.ToString(CultureInfo.InvariantCulture),
result.SelectCount.ToString(CultureInfo.InvariantCulture),
result.SelectedCount.ToString(CultureInfo.InvariantCulture),
result.Virtualize.ToString(CultureInfo.InvariantCulture),
result.ElapsedMilliseconds.ToString("F4", CultureInfo.InvariantCulture),
result.AllocatedBytes.ToString(CultureInfo.InvariantCulture),
result.ApproxRetainedBytes.ToString(CultureInfo.InvariantCulture),
result.RenderCount.ToString(CultureInfo.InvariantCulture),
result.ShadowItemCount.ToString(CultureInfo.InvariantCulture),
result.SelectItemComponentCount.ToString(CultureInfo.InvariantCulture),
result.MarkupLength.ToString(CultureInfo.InvariantCulture))));

return string.Join(Environment.NewLine, lines) + Environment.NewLine;
}

private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";

private static string FormatBytes(long bytes)
{
var sign = bytes < 0 ? "-" : string.Empty;
var absolute = Math.Abs((double)bytes);
return absolute switch
{
>= 1024 * 1024 => $"{sign}{absolute / (1024 * 1024):F1} MB",
>= 1024 => $"{sign}{absolute / 1024:F1} KB",
_ => $"{bytes} B"
};
}

private static string? GetOption(string[] args, string name)
{
for (var index = 0; index < args.Length - 1; index++)
{
if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase))
{
return args[index + 1];
}
}

return null;
}

private sealed record ProbeScenario(int ItemCount, int SelectCount, int SelectedCount, bool Virtualize);

private sealed record ProbeResult(
string Variant,
int ItemCount,
int SelectCount,
int SelectedCount,
bool Virtualize,
double ElapsedMilliseconds,
long AllocatedBytes,
long ApproxRetainedBytes,
int RenderCount,
int ShadowItemCount,
int SelectItemComponentCount,
int MarkupLength);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace MudExtensions.Benchmarks;

internal sealed class SelectBenchmarkHost : ComponentBase
{
[Parameter, EditorRequired]
public ICollection<int?> Items { get; set; } = [];

[Parameter, EditorRequired]
public IEnumerable<int?> SelectedValues { get; set; } = [];

[Parameter]
public int SelectCount { get; set; } = 1;

[Parameter]
public bool Virtualize { get; set; } = true;

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
for (var index = 0; index < SelectCount; index++)
{
builder.OpenComponent<MudSelectExtended<int?>>(0);
builder.SetKey(index);
builder.AddAttribute(1, nameof(MudSelectExtended<int?>.ItemCollection), Items);
builder.AddAttribute(2, nameof(MudSelectExtended<int?>.Virtualize), Virtualize);
builder.AddAttribute(3, nameof(MudSelectExtended<int?>.MultiSelection), true);
builder.AddAttribute(4, nameof(MudSelectExtended<int?>.SelectedValues), SelectedValues);
builder.CloseComponent();
}
}
}
Loading
Loading