-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Reduce Allocations in Text Rendering #20175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
|
You can test this PR using the following package version. |
|
We can try to reuse one SKFont instance and change its properties before we call some API that needs it. Not sure how costly mutating it is. If that isn't improving anything, we can cache SKFont instances per font size. Thank you for your contribution |
I am not sure this would help much, because every
This is what I was thinking as well, though, we would also need to cache it by Avalonia/src/Skia/Avalonia.Skia/GlyphRunImpl.cs Lines 135 to 144 in 0b5a828
I am not entirely sure what the best approach would be, do you think we should have |
|
Thank you for your contribution! I'll review and test in depth when I have a bit more time, but the first point seems very strange to me. Which OS, architecture and exact runtime are you using? For a few versions of the C# compiler now, A quick check with https://godbolt.org/z/KP1xnE5Yj shows that this hasn't changed at all and still works as expected in .NET 10. I'll look at Avalonia in details as soon as I can :) |
Yes, GlyphTypefaceImpl would cache them |
Yes, I also found it quite strange and something that probably would've been caught by you guys already.
After posting the PR I double-checked my build configs and it seemed I ran my tests under the DEBUG config ( |
Please, avoid native objects with mutable state in IGlyphRun and friends. Those can be used from multiple threads and it's really easy to introduce hard to track native memory corruption. |
So you suggest we can't cache the SkFont in the GlyphTypefaceImpl that already holds a SKTypeface? IGlyphRunImpl is immutable |
Even without any performance boost, it's worth doing this solely to prevent other developers from seeing the same alarming number of allocations that you did and wasting their time trying to investigate the source. Can we not get the best of both worlds like this? private static ReadOnlySpan<uint> Data { get; } = new uint[] ...I assume that this would still trigger the compiler optimisation, and it definitely avoids those million+ array allocations at runtime. |
Sadly, this is not possible because fields (and by extension, property backing fields) cannot be of type |
|
We need to keep |
|
Since getter-only property ("=>") doesn't have any state (that could hypothetically be mutated with reflection), it's easier for the compiler to assume optimizations. But I don't know if .NET 10 is better at optimizing |
|
Note regarding the |
MrJul
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aside from the ReadOnlySpan change we've discussed already, the rest looks good to me!
| get => new(Data, 0x00100000, 0x00000000); | ||
| } | ||
|
|
||
| private static ReadOnlySpan<uint> Data => new uint[] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As discussed in the comments, please revert the changes in all *.trie.cs files. Or Use collection expressions ([ ]) since they are now available, they will be more obvious than => new.
| } | ||
| private static ReadOnlySpan<uint> Data => new uint[] | ||
| private static readonly uint[] Data = new uint[] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also revert this.
I have done some additional validation on DEBUG builds, this time on a fresh Ubuntu 25.04 VM, as well as a Windows 10 x64 VM, both using a fresh .NET 10 installation (obtained through preview ppa on Ubuntu and winget on Windows). I am still seeing the same allocations of How to reproduce:
I am happy to revert the changes on EDIT: Dumping the generated x64 code using |
|
Yes you're right, not sure how I missed that last time, or if I wasn't looking at the right thing, sorry about that. While I still think that keeping things as they are for simplicity is fine (the runtime has tons of Let's generate something like this instead: |
What does the pull request do?
This PR addresses some performance regressions and reduces overall allocations when rendering a lot of (complex) text runs.
What is the current behavior?
When I upgraded to the latest version of Avalonia and .NET 10 in AvaloniaHex, I noticed a pretty significant bump in memory allocations and performance degradation in rendering of many (complex) text runs. Upon profiling, I found a couple of related hotspots:
In
Avalonia.Media.TextFormattingthere are various precompiled tries stored as raw data. This data is recreated on every access of the trie. E.g., here isUnicodeData.trie:Avalonia/src/Avalonia.Base/Media/TextFormatting/Unicode/UnicodeData.trie.cs
Lines 15 to 22 in 0b5a828
These computed tries are used quite extensively throughout the library. This results in a significant number of unnecessary allocations of very large data blobs (literally millions of instances), significantly slowing down controls that do a lot of (complex) text rendering. Below, an example of how AvaloniaHex is affected when scrolling once down and up in the example project:

FontFamily.Parseeventually callsFontFamily.GetFontSourceIdentifier, which always allocates extrastringandstring[]instances when parsing a font by name, even if the font name is a simple font without any fallback fonts:FontShaperImpl.ShapeTextinAvalonia.Skiauses a language cache with aGetOrAddconstruction that uses a non-static / closure capturing lambda for its factory.Avalonia/src/Skia/Avalonia.Skia/TextShaperImpl.cs
Lines 45 to 47 in 0b5a828
This results in a closure being created for every text run that isn't used most of the time because the used culture doesn't change in most cases:
LineBreakEnumeratoralways creates aLineBreakStateheap allocation (even if a text run does not contain line breaks). This is wasteful, especially consideringLineBreakEnumeratoris already aref structand will never appear on the heap:What is the updated/expected behavior with this PR?
This PR removes the vast majority of these allocations. Running the example project of AvaloniaHex with these changes applied to Avalonia makes scrolling much smoother.
How was the solution implemented (if it's not obvious)?
In chronological order of the issues described above:
UnicodeDataGeneratorwas updated to generate code with a readonlyDatafield as opposed to a computed property. This removes allRuntimeFieldInfoStuballocations.FontFamily.GetFontSourceIdentifierwas rewritten to avoid any array allocations and most string reallocations usingReadOnlySpan<char>s and slicing.FontShaperImpl.ShapeTextnow uses the overload ofGetOrAddthat passes an argument to the factory lambda.LineBreakEnumerator+LineBreakStatewas turned into aref structand is now passed along as arefparameter to all unicode rule methods.Checklist
Breaking changes
None. All changes are made on internal or private APIs.
Obsoletions / Deprecations
None
Fixed issues
Related to #16390
Additional Questions
Maybe out of this scope for this PR, but I am still seeing a lot of instances of
SKFont(scaling linearly with the number of text runs I create) in AvaloniaHex, even though I reuse the sameTypefaceinstances as much as possible. Is there any possibility/talks on cachingSKFontinstances?