Skip to content

Refactor per-draw render data allocations with a binary opcode stream - #21366

Merged
MrJul merged 16 commits into
AvaloniaUI:masterfrom
ZehMatt:refactor/render-data-binary-stream
Jun 15, 2026
Merged

MrJul merged 16 commits into
AvaloniaUI:masterfrom
ZehMatt:refactor/render-data-binary-stream

Conversation

@ZehMatt

@ZehMatt ZehMatt commented May 14, 2026 •

Copy link
Copy Markdown
Contributor

This work stems from the comment #20885 (comment) , this is not exactly how WPF is doing it, the unsafe keyword for example has been avoided, also I wasn't a huge fan of where #20885 was going.

What does the pull request do?

Replaces the render data representation. Until now every DrawingContext draw or push call recorded a heap-allocated node object (RenderDataLineNode, RenderDataRectangleNode, the push nodes, …) into a PooledInlineList.
This PR replaces those objects with a flat binary opcode stream plus a resource table. This is the approach WPF uses for its MILCMD render data.

It is an internal change: no public API, rendering output, or behaviour changes.

What is the current behavior?

Every recorded draw/push allocates a node object. For a visual whose content changes each frame (animations, custom-drawn controls) that is one GC-tracked allocation per draw call, every frame - gen0 churn proportional to the draw-call count.

What is the updated/expected behavior with this PR?

Render data is recorded as a byte[] opcode stream plus a resource table. Recording, server-side replay, hit-testing and bounds calculation all walk the byte stream directly, with no per-draw object allocation.

Rendering, hit-testing and visual bounds are unchanged, covered by the render-data contract tests merged in #21341 and the existing render tests, plus new unit tests added here, resource table, the three walkers, serialization round-trip, deep-nesting fallback.

The changes were measured locally with a stress test of ~9k draw calls per frame using mixed primitives.
master:

Draw calls/frame:     9,271
Measured window:      10.0s
Frames rendered:      404
Average FPS:          40.4
Total allocated:      498.74 MB
Allocated per frame:  1264.1 KB
Allocated per second: 49.85 MB/s
GC gen0 / gen1 / gen2: 83 / 83 / 0
Managed heap (end):   9.7 MB

PR:

Draw calls/frame:     9,271
Measured window:      10.0s
Frames rendered:      403
Average FPS:          40.3
Total allocated:      22.99 MB
Allocated per frame:  58.4 KB
Allocated per second: 2.30 MB/s
GC gen0 / gen1 / gen2: 4 / 0 / 0
Managed heap (end):   9.7 MB

The code for the test: https://gist.github.com/ZehMatt/6d033ecd016335b8b02f649a6666286c , its quite evident that this saves quite a bit of memory, in my testing there was no GC pressure anymore in the rendering path, now the major contributor to GC cycles is elsewhere.

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

New types under Rendering/Composition/Drawing/:

  • RenderDataOpcode - one value per draw/push operation, plus Pop.
  • RenderDataWriter / RenderDataReader - the byte codec. Blittable payload structs (Point, Rect, RoundedRect, Matrix, BoxShadow, RenderOptions, …) are bulk-copied through a where T : unmanaged generic helper; the constraint is a compile-time guard against a payload type silently becoming non-blittable.
  • RenderDataResources - interns the non-blittable operands (brushes, pens, geometries, bitmaps, custom ops) to int handles referenced from the payloads.
  • RenderDataStream - owns the opcode stream and resource table, with the recording API and the replay / hit-test / bounds walkers. Push/Pop are inline opcodes; the walkers stackalloc their scope stack, sized from a max-push-depth tracked while
    recording.

The four consumers were switched over: RenderDataDrawingContext (recording), CompositionRenderData (client + hit-testing + serialization), ServerCompositionRenderData (server replay + bounds), ImmediateRenderDataSceneBrushContent and the old node classes and IRenderDataItem deleted.

The branch is structured as small standalone commits, so it should be easy to review the changes commit by commit.

Checklist

Fixed issues

Addresses some points of #19363 in regards to rendering.

Final Note

I'm glad that #20885 wasn't merged, this is definitely the cleaner solution to the problem and its backed up by the data.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065403-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-cibuild0065435-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@kekekeks kekekeks left a comment •

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.

Haven't had a thorough read yet, some questions.

Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataResources.cs Outdated
Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Bounds.cs Outdated
Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataWriter.cs Outdated
Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Bounds.cs Outdated
@ZehMatt

ZehMatt commented May 17, 2026

Copy link
Copy Markdown
Contributor Author

I have addressed the review comments, let me know if there is anything else you would like to be changed.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065455-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-cibuild0065593-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-cibuild0065629-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@kekekeks kekekeks added this to the 12.1 milestone May 30, 2026
@ZehMatt
ZehMatt force-pushed the refactor/render-data-binary-stream branch from 0b20c25 to 7fb34d8 Compare May 31, 2026 00:51
@ZehMatt

ZehMatt commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

I've rebased the PR and added serialization for the effect support. It would be nice to know if anything else needs to be changed, I don't want to keep up with rebasing and plucking in things that are being added in the master branch.

@avaloniaui-bot

Copy link
Copy Markdown

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

Comment on lines +19 to +27
if (_buffer is null)
_buffer = ArrayPool.Shared.Rent(Math.Max(required, 256));
else if (_buffer.Length < required)
{
var grown = ArrayPool.Shared.Rent(Math.Max(required, _buffer.Length * 2));
Array.Copy(_buffer, grown, _length);
ArrayPool.Shared.Return(_buffer);
_buffer = grown;
}

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.

I don't have a preference here, just a question. Should we use shared pool here or create a dedicated one for rendering? Dedicated one can be enough, if it's only reused between frames, and not globally in the app

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 benefit of shared is that it can re-use memory and keep the memory footprint small across the app for when things just need a scratch buffer, but I also don't exactly have a strong preference here assuming this question was directed at me?

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.

Note that a shared pool has an app-wide cap of memory that could be returned into it without being GC-freed. This might have unexpected effects in actual apps where some other library uses the same pool, we'll essentially fall back to always allocate.

This is probably out of scope of this PR, but we could use the same approach as with our batch streams, where we track sustained usage counters over time and free memory after some time passes.

Another thing we could explore here is ref-counted native memory, but, again, out of scope.

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.

Makes sense, I can follow up on this if this if/when PR lands, for now I somewhat doubt someone will ever run into the issue of pooled memory exhaustion, if this happens then quite frankly it's more of a problem that it's being used wrong.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065939-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-cibuild0066047-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

MrJul
MrJul previously approved these changes Jun 12, 2026

@MrJul MrJul left a comment

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.

Substantial gains, comprehensive tests, clean code, this is a great PR. I've reviewed it and this looks very good to me. I've left a couple of very minor comments.

Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs
Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataReader.cs Outdated
Comment thread src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataWriter.cs Outdated
@ZehMatt
ZehMatt force-pushed the refactor/render-data-binary-stream branch from b97057b to 953f823 Compare June 12, 2026 17:09
@avaloniaui-bot

Copy link
Copy Markdown

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

@MrJul MrJul left a comment

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.

LGTM!

@MrJul
MrJul added this pull request to the merge queue Jun 15, 2026
Merged via the queue into AvaloniaUI:master with commit aa1fcb4 Jun 15, 2026
11 checks passed
@ZehMatt
ZehMatt deleted the refactor/render-data-binary-stream branch June 15, 2026 13:33
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