dotnet / wpf

WPF is a .NET Core UI framework for building Windows desktop applications.
MIT License
7.06k stars 1.17k forks source link

Terrible performance for `GeometryGroup.Bounds`! #8601

Open Khiro95 opened 9 months ago

Khiro95 commented 9 months ago

Background

I'm working on a project where I need to handle thousands of geometry shapes and most operations applied are merging/combining and transforming. I start from using StreamGeometry and then use GeometryGroup when I need to apply general transform for multiple geometries at once. But since I rely too much on Bounds property, mainly for custom positioning and scaling, I noticed that the app take longer time compared to other project which uses ImageSharp (it does same job but not Windows-dependent).

At this point, I used Performance Profiler and found get_Bounds() in hot paths take a lot of CPU time.

Benchmark

I decided to benchmark the property and see how much impact it has on performance, so here is the setup:

GeometryBoundsBenchmark.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net8.0-windows;net6.0-windows;net5.0-windows</TargetFrameworks>
    <Nullable>enable</Nullable>
    <UseWPF>true</UseWPF>
    <ImplicitUsings>disable</ImplicitUsings>
    <OutputType>Exe</OutputType>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.13.11" />
  </ItemGroup>

</Project>

Program.cs

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Windows;
using System.Windows.Media;

namespace GeometryBoundsBenchmark
{
    [MemoryDiagnoser]
    public class GeoBounds
    {
        private StreamGeometry streamGeometry;
        private GeometryGroup geometryGroup;
        private Rect? rect;

        public GeoBounds()
        {
            // this path data is extracted from Github's SVG logo
            streamGeometry = (StreamGeometry)Geometry.Parse("M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z");
            streamGeometry.Freeze();
            geometryGroup = new GeometryGroup();
            geometryGroup.Children.Add(streamGeometry);
            geometryGroup.Children.Freeze();
            geometryGroup.Freeze();
        }

        [Benchmark]
        public double GetSumStream()
        {
            Rect bounds = streamGeometry.Bounds;
            return bounds.X + bounds.Y + bounds.Width + bounds.Height;
        }

        [Benchmark]
        public double GetSumGroup()
        {
            Rect bounds = geometryGroup.Bounds;
            return bounds.X + bounds.Y + bounds.Width + bounds.Height;
        }

        [Benchmark(Baseline = true)]
        public double GetSumCached()
        {
            Rect bounds = rect ??= streamGeometry.Bounds;
            return bounds.X + bounds.Y + bounds.Width + bounds.Height;
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            BenchmarkRunner.Run<GeoBounds>(args: args);
        }
    }
}

[!Note] I included a comparison to a cached value of Bounds because when the geometry is frozen it won't change

Run command

dotnet run -c Release --project GeometryBoundsBenchmark -f net8.0-windows --runtimes net8.0-windows net6.0-windows net5.0-windows

Results

BenchmarkDotNet v0.13.11, Windows 10 (10.0.19045.3803/22H2/2022Update)
AMD Ryzen 5 5600G with Radeon Graphics, 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.100
  [Host]     : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
  Job-YMEMYJ : .NET 5.0.17 (5.0.1722.21314), X64 RyuJIT AVX2
  Job-EAQMAC : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2
  Job-OZSWNQ : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
Method Runtime Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
GetSumStream .NET 5.0 18.1102 ns 0.0995 ns 0.0931 ns 20.09 0.13 - - - NA
GetSumGroup .NET 5.0 13,098.8602 ns 148.5757 ns 138.9778 ns 14,529.99 170.12 1.4343 0.0153 12000 B NA
GetSumCached .NET 5.0 1.7907 ns 0.0165 ns 0.0154 ns 1.99 0.02 - - - NA
GetSumStream .NET 6.0 20.5252 ns 0.0559 ns 0.0523 ns 22.77 0.16 - - - NA
GetSumGroup .NET 6.0 12,860.4566 ns 124.3227 ns 116.2915 ns 14,265.87 177.09 1.4343 0.0153 12000 B NA
GetSumCached .NET 6.0 1.7165 ns 0.0093 ns 0.0082 ns 1.90 0.01 - - - NA
GetSumStream .NET 8.0 10.6880 ns 0.0187 ns 0.0175 ns 11.86 0.07 - - - NA
GetSumGroup .NET 8.0 8,735.2409 ns 87.0670 ns 81.4425 ns 9,689.49 88.12 1.4343 0.0153 12000 B NA
GetSumCached .NET 8.0 0.9015 ns 0.0061 ns 0.0057 ns 1.00 0.00 - - - NA

As you can see, GeometryGroup.Bounds allocates too many objects and is almost 900 times slower than StreamGeometry.Bounds which is only 11 times slower than cached value.

[!Note] The sample geometry in this benchmark is not very complex but in my case there are thousands of complex geometries so the impact is huge

Expected behavior

I expect GeometryGroup.Bounds to not be that much slow, especially when it's frozen and its children are frozen as well.

My question

Is this behavior normal?! If so, what are the suggestions to avoid this bottleneck.

Thank you.

Khiro95 commented 9 months ago

At the moment, I'm using this extension method as a workaround for my specific case:

public static Rect GetBoundsFast(this System.Windows.Media.Geometry geometry)
{
    if (geometry is StreamGeometry sg)
    {
        return sg.Bounds;
    }
    else if (geometry is GeometryGroup gg)
    {
        Rect rect = Rect.Empty;

        foreach (var g in gg.Children)
        {
            rect.Union(g.GetBoundsFast());
        }

        rect.Transform(gg.Transform.Value);
        return rect;
    }

    throw new ArgumentException();
}

It cuts down my processing function time from 3 minutes to just 11 seconds!