Terrible performance for `GeometryGroup.Bounds`!
- Dominant language
- C#
- Stars
- 7.7k
- Forks
- 1.3k
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 61
Description
### 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**
```xml
net8.0-windows;net6.0-windows;net5.0-windows
enable
true
disable
Exe
```
**Program.cs**
```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(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.
Contributor guide
Assessment
This issue has not been assessed yet.