Skip to content

Commit 175ae77

Browse files
Merge pull request #76 from wieslawsoltes/agent/skiasharp-perf-continuation
Optimize Avalonia Skia hot paths
2 parents 987a651 + e671b6e commit 175ae77

23 files changed

Lines changed: 1949 additions & 248 deletions

docs/SKIASHARP_API_PARITY.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,80 @@ main build and resolves the packaged RID-native WebGPU directory on Linux,
979979
macOS, and Windows. This fixes the prior Ubuntu `libwgpu_native` loader failure
980980
without skipping the GPU workload or relaxing comparison evidence.
981981

982+
### Avalonia immutable-image upload and deferred-draw checkpoint
983+
984+
The source-built Avalonia 12 `WriteableBitmapImpl` creates an immutable image
985+
with `SKImage.FromPixels(info, address, rowBytes)` whenever its writable pixel
986+
version changes, then reuses that image across draws. ProGPU now copies common
987+
RGBA, sRGBA, and BGRA rows directly into one tight immutable portable snapshot
988+
and uploads that same snapshot to WebGPU. The former temporary `SKBitmap`
989+
wrapper and its second row walk are gone; arbitrary supported formats keep the
990+
conservative conversion fallback. Snapshot work remains `O(P)` time and
991+
storage for `P` pixels because the public pointer is caller-owned and the image
992+
must remain immutable after the writable framebuffer changes.
993+
994+
Whole images drawn in the same WebGPU device now cross the retained-command
995+
boundary through `IProGpuContextTextureLeaseSource`. The first draw records one
996+
bounded lifetime lease and every subsequent draw in that context reuses the
997+
same `GpuTexture`, texture view, and bindable identity. Disposal of the public
998+
`SKImage` releases its ownership but cannot destroy the texture while a
999+
deferred context or picture still holds a lease. Subsets, cross-device images,
1000+
and mipmap generation retain their normalized materialization paths. This makes
1001+
ordinary same-device recording `O(C)` command work for `C` draws with one GPU
1002+
texture and one lease, rather than `O(C * P)` texture allocation and copy
1003+
bandwidth.
1004+
1005+
The clean-room design follows Skia's public
1006+
[immutable image contract](https://api.skia.org/classSkImage.html), WebGPU's
1007+
[texture ownership and copy model](https://www.w3.org/TR/webgpu/#textures),
1008+
Direct2D's
1009+
[device-context bitmap drawing contract](https://learn.microsoft.com/windows/win32/direct2d/id2d1devicecontext-drawbitmap-overload),
1010+
Win2D's
1011+
[CanvasBitmap contract](https://learn.microsoft.com/uwp/api/microsoft.graphics.canvas.canvasbitmap),
1012+
WebRender's
1013+
[external-image frame split](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html),
1014+
and Vello's
1015+
[explicit wgpu scene-to-texture pipeline](https://github.com/linebender/vello).
1016+
ProGPU adopts immutable CPU ownership at the public pointer boundary and typed
1017+
same-device leases at the deferred GPU boundary; it rejects borrowed pointer
1018+
lifetime assumptions, per-draw GPU copies, reflection, and backend-specific
1019+
public handles. Text shaping remains unchanged at the reusable CPU-result
1020+
boundary established by SkParagraph, DirectWrite, Parley, and HarfBuzz.
1021+
1022+
On the Apple M3 Pro Release baseline, the 16-by-16 Avalonia snapshot workload
1023+
improved from `13,356.445` to `10,934.730` ns/op and from `1,568` to `1,424`
1024+
managed B/op with the exact native checksum. The new 1,000-draw retained-picture
1025+
workload isolates reuse of that immutable image: replacing one GPU texture copy
1026+
per draw with one lifetime lease reduced ProGPU from `69,164.500` to `608.354`
1027+
ns/draw and from `2,831.500` to `2,486.000` managed B/draw. Native measured
1028+
`48.479` ns/draw and `2` managed B/draw because its retained command storage is
1029+
native and outside the managed counter. The remaining ProGPU command-storage
1030+
and snapshot gaps are explicit optimization targets; these shared-machine
1031+
figures establish the direction and do not claim final cross-platform parity.
1032+
1033+
Matched final-binary macOS profiling compared exact pre-lease commit
1034+
`1c60239b` with exact candidate `79d86548` on the same Apple M3 Pro, macOS
1035+
26.4.1, and .NET 10.0.5 workload. Time Profiler measured `327,088.874` versus
1036+
`816.041` median ns/draw; Allocations plus VM Tracker measured `82,988.745`
1037+
versus `929.165`; Metal System Trace measured `49,527.290` versus `797.290`;
1038+
and EventPipe measured `60,615.875` versus `627.041`. EventPipe retained the
1039+
exact checksum while managed allocation fell from `2,831` to `2,486` B/draw
1040+
(`12.2%`). Profiler overhead perturbs the absolute latency, so the ordinary
1041+
Release process numbers above remain the throughput result and these matched
1042+
captures provide causal evidence.
1043+
1044+
The Metal capture reduced target resource-allocation rows from `188` to `53`
1045+
and target application command-buffer submission rows from `5,627` to zero.
1046+
The baseline target stack contains WebGPU `copy_texture_to_texture`; the
1047+
candidate target stack does not. Both captures reported zero Metal
1048+
command-buffer errors, compiler spills, and hang risks. Completion and
1049+
`currentAllocatedSize` row counts include process/device sampling and are not
1050+
interpreted as bytes or per-draw totals. The Allocations template did not
1051+
export a native retained-byte table on this Xcode version, so no unsupported
1052+
native-memory claim is made. Compact results are recorded here; the 221 MiB of
1053+
raw trace and EventPipe data, temporary publishes, packages, and exact-baseline
1054+
worktree were removed after the audit.
1055+
9821056
### Retained canvas contract and empty-clip checkpoint
9831057

9841058
`SKCanvas` now closes all 45 missing entries in its official 4.151.0 owner

src/ProGPU.Tests/SkBlenderCompatibilityTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,42 @@ public void PaintBlendModeAndBlenderShareNativeStateSemantics()
6767
Assert.Equal(SKBlendMode.SrcOver, paint.BlendMode);
6868
}
6969

70+
[Fact]
71+
public void ReusedPaintBlendModeStateIsAllocationFreeUntilBlenderIsObserved()
72+
{
73+
using var paint = new SKPaint();
74+
_ = CycleBlendModeState(paint, 1);
75+
76+
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
77+
var checksum = CycleBlendModeState(paint, 10_000);
78+
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
79+
80+
Assert.NotEqual(0u, checksum);
81+
Assert.Equal(0, allocated);
82+
83+
paint.BlendMode = SKBlendMode.DstIn;
84+
Assert.NotNull(paint.Blender);
85+
Assert.Equal(SKBlendMode.DstIn, paint.BlendMode);
86+
Assert.Throws<ArgumentOutOfRangeException>(
87+
() => paint.BlendMode = (SKBlendMode)(-1));
88+
}
89+
90+
private static uint CycleBlendModeState(SKPaint paint, int count)
91+
{
92+
var checksum = 2166136261u;
93+
for (var index = 0; index < count; index++)
94+
{
95+
paint.BlendMode = (index & 1) == 0
96+
? SKBlendMode.DstIn
97+
: SKBlendMode.Src;
98+
checksum = (checksum ^ (uint)paint.BlendMode) * 16777619u;
99+
paint.Reset();
100+
checksum = (checksum ^ (uint)paint.BlendMode) * 16777619u;
101+
}
102+
103+
return checksum;
104+
}
105+
70106
[Fact]
71107
public void BuiltInBlenderRecordsExistingGpuBlendScope()
72108
{

src/ProGPU.Tests/SkCanvasStateTests.cs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,8 +1670,11 @@ void OnTextureDisposed(ulong id)
16701670

16711671
Assert.Empty(drawingContext.Commands);
16721672
Assert.Equal(0, drawingContext.RetainedResourceCount);
1673-
Assert.True(retainedTextureDisposed);
1673+
Assert.False(retainedTextureDisposed);
16741674
Assert.True(layerTextureDisposed);
1675+
1676+
image.Dispose();
1677+
Assert.True(retainedTextureDisposed);
16751678
}
16761679
finally
16771680
{
@@ -1817,7 +1820,7 @@ public void CanvasDisposeKeepsSaveLayerTextureAliveForDeferredDrawingContext()
18171820
}
18181821

18191822
[Fact]
1820-
public void RestoreLayerReleasesRetainedImageTexturesAfterOffscreenRender()
1823+
public void RestoreLayerReleasesRetainedImageLeaseAfterOffscreenRender()
18211824
{
18221825
using var surface = SKSurface.Create(new SKImageInfo(32, 32, SKColorType.Rgba8888, SKAlphaType.Premul));
18231826
using var layerPaint = new SKPaint();
@@ -1849,7 +1852,7 @@ void OnTextureDisposed(ulong id)
18491852
{
18501853
surface.Canvas.RestoreToCount(restoreCount);
18511854

1852-
Assert.True(retainedTextureDisposed);
1855+
Assert.False(retainedTextureDisposed);
18531856
Assert.Empty(layerContext.Commands);
18541857
Assert.Equal(0, layerContext.RetainedResourceCount);
18551858
Assert.Equal(1, GetSurfaceDrawingContext(surface).RetainedResourceCount);
@@ -1862,7 +1865,7 @@ void OnTextureDisposed(ulong id)
18621865
}
18631866

18641867
[Fact]
1865-
public void RestoreLayerClearsRetainedImageTexturesWhenLayerBoundsAreSkipped()
1868+
public void RestoreLayerClearsRetainedImageLeaseWhenLayerBoundsAreSkipped()
18661869
{
18671870
using var surface = SKSurface.Create(new SKImageInfo(32, 32, SKColorType.Rgba8888, SKAlphaType.Premul));
18681871
using var layerPaint = new SKPaint();
@@ -1894,7 +1897,7 @@ void OnTextureDisposed(ulong id)
18941897
{
18951898
surface.Canvas.RestoreToCount(restoreCount);
18961899

1897-
Assert.True(retainedTextureDisposed);
1900+
Assert.False(retainedTextureDisposed);
18981901
Assert.Empty(layerContext.Commands);
18991902
Assert.Equal(0, layerContext.RetainedResourceCount);
19001903
Assert.Empty(GetOwnedLayerTextures(surface.Canvas));
@@ -2296,7 +2299,7 @@ public void DrawImagePushesPaintAlphaOpacity()
22962299
draw =>
22972300
{
22982301
Assert.Equal(RenderCommandType.DrawTexture, draw.Type);
2299-
Assert.NotSame(image.Texture, draw.Texture);
2302+
Assert.Same(image.Texture, draw.Texture);
23002303
AssertNear(10f, draw.Rect.X);
23012304
AssertNear(20f, draw.Rect.Y);
23022305
AssertNear(20.5f, draw.Rect.Width);
@@ -2349,7 +2352,7 @@ public void DrawImageRetainsSourceTextureForDeferredFlush()
23492352

23502353
var command = GetDrawTextureCommand(context.Commands);
23512354
var retainedTexture = command.Texture!;
2352-
Assert.NotSame(image.Texture, retainedTexture);
2355+
Assert.Same(image.Texture, retainedTexture);
23532356
Assert.Equal(1, context.RetainedResourceCount);
23542357

23552358
var retainedTextureDisposed = false;
@@ -2379,6 +2382,25 @@ void OnTextureDisposed(ulong id)
23792382
}
23802383
}
23812384

2385+
[Fact]
2386+
public void RepeatedWholeImageDrawsShareOneTextureLeaseWithoutGpuCopies()
2387+
{
2388+
var context = new DrawingContext();
2389+
using var canvas = new SKCanvas(context, 16f, 16f);
2390+
using var bitmap = new SKBitmap(1, 1);
2391+
using var image = SKImage.FromBitmap(bitmap);
2392+
2393+
canvas.DrawImage(image, 0f, 0f);
2394+
canvas.DrawImage(image, 1f, 1f);
2395+
2396+
var draws = context.Commands
2397+
.Where(command => command.Type == RenderCommandType.DrawTexture)
2398+
.ToArray();
2399+
Assert.Equal(2, draws.Length);
2400+
Assert.All(draws, command => Assert.Same(image.Texture, command.Texture));
2401+
Assert.Equal(1, context.RetainedResourceCount);
2402+
}
2403+
23822404
[Fact]
23832405
public void GpuPictureKeepsRetainedImageTextureAfterRecorderReuse()
23842406
{
@@ -2417,6 +2439,8 @@ void OnTextureDisposed(ulong id)
24172439

24182440
picture.Dispose();
24192441

2442+
Assert.False(retainedTextureDisposed);
2443+
image.Dispose();
24202444
Assert.True(retainedTextureDisposed);
24212445
}
24222446
finally
@@ -2466,8 +2490,10 @@ void OnTextureDisposed(ulong id)
24662490

24672491
target.Clear();
24682492

2469-
Assert.True(retainedTextureDisposed);
2493+
Assert.False(retainedTextureDisposed);
24702494
Assert.Equal(0, target.RetainedResourceCount);
2495+
image.Dispose();
2496+
Assert.True(retainedTextureDisposed);
24712497
}
24722498
finally
24732499
{

src/ProGPU.Tests/SkMatrixCompatibilityTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,21 @@ public void RectangleMappingClipsPerspectiveAtNativeNearPlane()
138138
SKMatrix.CreateScale(2f, 3f).MapRect(new SKRect(5f, 8f, 1f, 2f)));
139139
}
140140

141+
[Fact]
142+
public void AffineRectangleMappingPreservesSkewAndNegativeScaleBounds()
143+
{
144+
var skewed = new SKMatrix(2f, 0.5f, 10f, -0.25f, 3f, 20f, 0f, 0f, 1f);
145+
Assert.Equal(
146+
new SKRect(13f, 24.5f, 26f, 43.75f),
147+
skewed.MapRect(new SKRect(1f, 2f, 6f, 8f)));
148+
149+
var reflected = SKMatrix.CreateScale(-2f, -3f).PostConcat(
150+
SKMatrix.CreateTranslation(10f, 20f));
151+
Assert.Equal(
152+
new SKRect(-2f, -4f, 8f, 14f),
153+
reflected.MapRect(new SKRect(1f, 2f, 6f, 8f)));
154+
}
155+
141156
[Fact]
142157
public void RadiusEqualityHashAndInternalMatrixConversionMatchNativeValues()
143158
{

src/ProGPU.Tests/SkPathBuilderCompatibilityTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ namespace ProGPU.Tests;
77

88
public sealed class SkPathBuilderCompatibilityTests
99
{
10+
[Fact]
11+
public void DetachedPackedBuilderComputesTightBoundsOnlyWhenRequested()
12+
{
13+
using var builder = new SKPathBuilder();
14+
builder.MoveTo(0f, 0f);
15+
builder.QuadTo(10f, 100f, 20f, 0f);
16+
using var path = builder.Detach();
17+
18+
Assert.Equal(new SKRect(0f, 0f, 20f, 100f), path.Bounds);
19+
Assert.Equal(new SKRect(0f, 0f, 20f, 50f), path.TightBounds);
20+
Assert.Equal(new SKRect(0f, 0f, 20f, 50f), path.TightBounds);
21+
}
22+
1023
[Fact]
1124
public void PackedDetachAndBoundsStayBoundedIndependentOfSegmentCount()
1225
{

src/ProGPU.Tests/SkPathMutationCompatibilityTests.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,70 @@ namespace ProGPU.Tests;
55

66
public sealed class SkPathMutationCompatibilityTests
77
{
8+
[Fact]
9+
public void CommonLegacyVerbStreamKeepsTightBoundsAllocationBounded()
10+
{
11+
static float MeasureOnce()
12+
{
13+
using var path = new SKPath { FillType = SKPathFillType.EvenOdd };
14+
path.MoveTo(0f, 0f);
15+
for (var segment = 0; segment < 8; segment++)
16+
{
17+
var x = segment * 6f;
18+
var y = segment * 3f;
19+
path.LineTo(x + 1f, y + 2f);
20+
path.QuadTo(x + 2f, y - 1f, x + 3f, y + 3f);
21+
path.CubicTo(x + 3.5f, y + 4f, x + 4.5f, y - 2f, x + 5f, y + 1f);
22+
}
23+
24+
path.Close();
25+
var bounds = path.TightBounds;
26+
return bounds.Left + bounds.Top + bounds.Right + bounds.Bottom;
27+
}
28+
29+
_ = MeasureOnce();
30+
const int iterations = 1_000;
31+
var checksum = 0f;
32+
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
33+
for (var index = 0; index < iterations; index++)
34+
{
35+
checksum += MeasureOnce();
36+
}
37+
38+
var allocatedPerPath =
39+
(GC.GetAllocatedBytesForCurrentThread() - allocatedBefore) / iterations;
40+
Assert.True(float.IsFinite(checksum));
41+
Assert.InRange(allocatedPerPath, 0, 128);
42+
}
43+
44+
[Fact]
45+
public void TightCurveBoundsPreserveFractionalTranslation()
46+
{
47+
static SKRect Build(float offset)
48+
{
49+
using var path = new SKPath();
50+
path.MoveTo(offset, -offset);
51+
path.QuadTo(2f + offset, -1f - offset, 3f + offset, 3f - offset);
52+
path.CubicTo(
53+
3.5f + offset,
54+
4f - offset,
55+
4.5f + offset,
56+
-2f - offset,
57+
5f + offset,
58+
1f - offset);
59+
return path.TightBounds;
60+
}
61+
62+
const float offset = 0.125f;
63+
var baseline = Build(0f);
64+
var translated = Build(offset);
65+
66+
Assert.Equal(baseline.Left + offset, translated.Left);
67+
Assert.Equal(baseline.Right + offset, translated.Right);
68+
Assert.Equal(baseline.Top - offset, translated.Top);
69+
Assert.Equal(baseline.Bottom - offset, translated.Bottom);
70+
}
71+
872
[Fact]
973
public void RelativeCommandsUseOneNativeCurrentPointPerVerb()
1074
{

src/ProGPU.Tests/SkRoundRectCompatibilityTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public void ConstructionKeepsCornerRadiiInSingleManagedAllocation()
7979
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
8080
Assert.Equal(85_000f, checksum);
8181
Assert.True(
82-
allocated <= 96L * iterations,
82+
allocated <= 72L * iterations,
8383
$"Expected one bounded SKRoundRect allocation per construction, but measured {allocated / (double)iterations:F3} B/op.");
8484
}
8585

0 commit comments

Comments
 (0)