Skip to content

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

Closed
ZehMatt wants to merge 5 commits into
AvaloniaUI:masterfrom
ZehMatt:render-pooling
Closed

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

Conversation

@ZehMatt

@ZehMatt ZehMatt commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

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
Copy Markdown

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

cla-avalonia commented Mar 13, 2026

Copy link
Copy Markdown
Collaborator
  • All contributors have signed the CLA.

@ZehMatt

ZehMatt commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

@cla-avalonia agree

@kekekeks

Copy link
Copy Markdown
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
Copy Markdown
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

ZehMatt commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

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

ZehMatt commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

(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

ZehMatt commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

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
Copy Markdown

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
Copy Markdown

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]

@avaloniaui-bot

Copy link
Copy Markdown

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

@avaloniaui-bot

Copy link
Copy Markdown

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

Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodePool.cs Outdated
if (_count == 0)
return;

var release = Math.Max(1, _count / 3);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we release a bit more than 1 item at a time when the pool has few items remaining? Releasing 1 item per second for 5 seconds does not sound optimal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not what this does. It divides the total in 3 and uses a minimum of 1 in case _count is smaller than 3.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify the above: Math.Max(1, _count / 3) is 1 for values from 1 to 5, so that is 1 item released per second in those cases. At some arbitrary threshold, we should just release everything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean, but I don't think we need an arbitrary threshold, if we make it the minimum of the dividing number then the list will be empty in 2 cycles at most, does that sound reasonable to you?

return;
}

if (_count == 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we always keep a minimal reasonable number of items?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to reduce GC pressure when there is a lot of activity and not maintain a cache, when there is no activity it should fully drain it in my opinion. The problem with such things is, what is the reasonable number? Different applications do different things.

return _items[--_count];
}

return new T();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Activator.CreateInstance<T> is way faster than in the .NET Framework times, but it's still twice as slow as a standard instantiation. Consider passing a factory instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, not worth it, this adds complexity for a couple nanoseconds and would only happen if the pool adds new elements, in a hot loop it will typically re-use and not instantiate.

@MrJul
MrJul requested a review from kekekeks April 8, 2026 18:06
@avaloniaui-bot

Copy link
Copy Markdown

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

@avaloniaui-bot

Copy link
Copy Markdown

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

@avaloniaui-bot

Copy link
Copy Markdown

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

@ZehMatt

ZehMatt commented May 14, 2026

Copy link
Copy Markdown
Contributor Author

#21366 supersedes this.

@ZehMatt ZehMatt closed this May 14, 2026
@ZehMatt
ZehMatt deleted the render-pooling branch May 14, 2026 21:06
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