Skip to content

Pool render data nodes to reduce per-frame GC pressure#20885

Open
ZehMatt wants to merge 3 commits intoAvaloniaUI:masterfrom
ZehMatt:render-pooling
Open

Pool render data nodes to reduce per-frame GC pressure#20885
ZehMatt wants to merge 3 commits intoAvaloniaUI:masterfrom
ZehMatt:render-pooling

Conversation

@ZehMatt
Copy link

@ZehMatt ZehMatt commented Mar 13, 2026

What does the pull request do?

Adds object pooling for all IRenderDataItem node types created by RenderDataDrawingContext. Instead of allocating new node objects every frame, nodes are returned to a RenderDataNodePool<T> after the server-side render data is consumed and reused on subsequent frames. Pools automatically reclaim unused items during idle periods via a single shared cleanup timer.

Related to #19363

What is the current behavior?

Every draw call (DrawRectangle, DrawLine, DrawGlyphRun, PushClip, PushTransform, etc.) allocates a new IRenderDataItem node via new. These nodes are created on the UI thread, serialized to the render thread, consumed once, then abandoned to the GC. In high-frequency rendering scenarios (e.g. 26K+ rectangles per frame), this creates significant GC pressure and frame stutter.

What is the updated/expected behavior with this PR?

After the first frame, all render data nodes are served from per-type object pools. Nodes are returned to pools when ServerCompositionRenderData.Reset() or CompositionRenderData.Dispose() runs. Per-frame allocations of render data nodes drop to near zero. When rendering activity stops, pooled items are gradually released (~1/3 per second) so memory is reclaimed during idle periods.

How was the solution implemented (if it's not obvious)?

  • Added IPoolableRenderDataItem interface with a ReturnToPool() method
  • Added RenderDataItemPoolHelper.DisposeAndReturnToPool() which recurses into push node children, then either returns poolable items to their pool or disposes non-poolable items
  • Added RenderDataNodePool<T> — a lightweight array-backed object pool with idle-based reclamation. All pool instances register with RenderDataNodePoolCleanup which runs a single shared System.Threading.Timer. During active use the pool retains all items for reuse; once idle, Reduce() gradually releases a third of excess items per cycle. Pools are tracked via weak references so they can be garbage collected if no longer referenced.
  • Each node type gets a static RenderDataNodePool<T>, a Get() factory method, and a ReturnToPool() that resets state and returns to pool. For nodes with disposable resources (GlyphRun, Bitmap, CustomOperation), ReturnToPool() calls Dispose() before returning
  • RenderDataDrawingContext uses NodeType.Get() instead of new NodeType

Pooled node types: RenderDataRectangleNode, RenderDataEllipseNode, RenderDataLineNode, RenderDataGeometryNode, RenderDataGlyphRunNode, RenderDataBitmapNode, RenderDataCustomNode, RenderDataClipNode, RenderDataPushMatrixNode, RenderDataOpacityNode, RenderDataOpacityMaskNode, RenderDataGeometryClipNode, RenderDataRenderOptionsNode, RenderDataTextOptionsNode

Breaking changes

None. Internal classes only, no public API changes.

Obsoletions / Deprecations

None.

Fixed issues

Addresses some points of #19363 in regards to rendering.

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0063327-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@cla-avalonia
Copy link
Collaborator

cla-avalonia commented Mar 13, 2026

  • All contributors have signed the CLA.

@ZehMatt
Copy link
Author

ZehMatt commented Mar 13, 2026

@cla-avalonia agree

@kekekeks
Copy link
Member

Note that this would mean that the memory used by nodes is never reclaimed.

We probably need some opt-in flag for that or employ a strategy similar to one used in batch stream pools where we collect item usage statistics and release unused items on timer tick.

This will probably require a specialized pool implementation. ThreadSafeObjectPool is rather simple and is designed for types that won't really have lots of instances.

@kekekeks
Copy link
Member

(not a suggestion to go and rewrite everything)

We could also use WPF's approach where it doesn't use proper nodes and instead serializes operations into binary representation: https://github.com/dotnet/wpf/blob/5599cc923d6a464ea0afc7864e722a7b57ab4281/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Generated/RenderDataDrawingContext.cs#L55-L69.

So instead of lots of individual drawing operation nodes our render data would consists of a byte array with render commands and object array with used resources like brushes.

@ZehMatt
Copy link
Author

ZehMatt commented Mar 13, 2026

Note that this would mean that the memory used by nodes is never reclaimed.

We probably need some opt-in flag for that or employ a strategy similar to one used in batch stream pools where we collect item usage statistics and release unused items on timer tick.

This will probably require a specialized pool implementation. ThreadSafeObjectPool is rather simple and is designed for types that won't really have lots of instances.

Would you like me to use BatchStreamPoolBase instead of ThreadSafeObjectPool?

@ZehMatt
Copy link
Author

ZehMatt commented Mar 13, 2026

(not a suggestion to go and rewrite everything)

We could also use WPF's approach where it doesn't use proper nodes and instead serializes operations into binary representation: https://github.com/dotnet/wpf/blob/5599cc923d6a464ea0afc7864e722a7b57ab4281/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Generated/RenderDataDrawingContext.cs#L55-L69.

So instead of lots of individual drawing operation nodes our render data would consists of a byte array with render commands and object array with used resources like brushes.

I wouldn't mind doing this also, since I use Avalonia now in one of my projects I have personal interest in making this as smooth as possible, the project simulates thousands of things and renders them which is how I ran into this issue also.

@ZehMatt
Copy link
Author

ZehMatt commented Mar 13, 2026

I've updated the implementation. After experimenting with BatchStreamPoolBase, I wasn't happy with how it worked, each pool instance spins up its own timer, and the DispatcherTimer dependency means pools created on threads without a Dispatcher fall back to reclaimImmediately, defeating the purpose entirely. Instead I introduced a dedicated RenderDataNodePool<T> backed by a single shared System.Threading.Timer that calls Reduce() on all registered pools. Pools are only trimmed during idle periods so the timer doesn't fight active reuse. I've updated the PR description with the full details.

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0063414-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0063637-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants