UI Toolkit Performance Update in 6.5

In this post, we will cover performance improvements and new tools available to advanced users for analyzing CPU and memory usage.

Reduction in GC Allocs for Computed Styles

During the internal update of UI Toolkit for resolving styles, Computed Style instances are created and shared amongst elements that match the same USS classes. Previously, Computed Style and related data were represented using objects which leads to a potentially large amount of GC allocations.

As of 6.5, this is no longer the case. Computed Style and related data are allocated using unmanaged memory which lowers GC pressure but also reduces overhead when passing this data to native code. For example, the layout related style properties are now directly accessed by the native layout library, without additional data copies.

In real world UIs, we have observed large reductions of GC allocations from the UI Toolkit styles. For example, UI Builder went from 3531 to 929 allocations(-73%), Shader Graph went from 1681 to 837 (-50%) and App UI (using the UI Kit sample) went from 8938 to 5102 (-42%).

For the next versions of Unity, we will continue to look for performance improvements in this area.

USS Stats Profiler

Unity has a new experimental tool called the USS Stats profiler. Once enabled in the Project Settings (UI Toolkit section), you can select any UI panel (similar to other debugging tools) and collect statistics about USS selectors.

This runs a full pass against all elements currently in the panel and will output some useful information for each stylesheet and each selector within it.

The tool is currently experimental because we are still working on better Profiler integration for UI Toolkit in general and we hope to streamline everything in the future.

In the meantime, while the UX of this window is still rough around the edges, we hope it can be useful to investigate performance issues regarding selectors.

New VisualElement.Clear() Options

When constructed, a new VisualElement instance is able to reuse resources previously used by other instances. However, there weren’t any real ways for some of these resources to be explicitly returned, except for native resources which are returned by the VisualElement finalizer, which does not happen deterministically.

For those reasons, we have introduced a new overload of the VisualElement.Clear() method which allows those resources to be made reusable immediately for an entire hierarchy. [Please note the API doc link is for 6.6 due to a mistake in the 6.5 version, which is being fixed. However functionality is identical between versions].

In UI intensive projects, using VisualElementClearOptions.RecursiveReleaseResources may help reduce peaks in memory usage (for example when transitioning between two very busy screens).

The good news is also that the new PanelRenderer component uses this overload when it is destroyed, aiming to have a memory friendly behaviour by default.

Additional Memory Profiler Visibility

As mentioned previously, UI Toolkit holds onto unmanaged memory which previously didn’t have any real visibility in Unity’s memory profiling tools.

Under a Unity-wide push to surface all allocations made by the engine inside the Memory Profiler, UI Toolkit now correctly tags this kind of memory for fine grained analysis. Obviously it is also very useful for our own team to analyze memory usage and help troubleshoot customer problems.

In order to support this, we added the MemoryLabel struct to the Unity public API to make those allocations visible to the Memory Profiler.

Improved Mesh Update Performance

On WebGL, Unity prevents stalls on WebGL by preventing writes to a buffer that was read by a recent draw call, and copying the data to a new buffer instead. This previously forced any change in a UI mesh (vertex+index buffers) to reupload the entire buffer. By using a staging buffer and GPU copies, UI Toolkit will now have much better mesh update performance on WebGL. Some performance tests were improved by a factor of 20x with this new approach. As you can imagine, it can be very expensive to upload 64k vertices when changing a quad of 4 vertices, so it was a much required improvement.

Our WebGPU implementation already used a form of staging but by improving our implementation, we were able to measure improvements of up to 4x.

Also, by tweaking how our system registers and handles partial mesh updates, we measured performance gains of up to 3x on some platforms.

Improved GPU Performance

UI Toolkit uses edge antialiasing that allows to display smooth edges, even in overlay where no MSAA is available. This antialiasing increases the shading cost, compared to that of regular geometry. We modified our tessellation so that, for some large geometries, only the parts that are close to the edge will compute the antialiasing. Extra triangles are required to achieve this, but much of the GPU cost comes from fragment shading on large shapes, so the benefits should outweigh the extra cost. On some lower-end platforms, the fragment shading cost of such cases was cut by up to 40% by using this strategy. This improvement will also be backported to previous versions of Unity.

Conclusion

As UI Toolkit matures into a production-ready solution, performance is treated as a core pillar of the roadmap rather than a constraint.

Recent work focuses on removing structural bottlenecks and improving efficiency across CPU and GPU. This includes foundational changes, such as moving computed styles to unmanaged memory to reduce GC pressure, as well as rendering optimizations that unlock significant gains on platforms such as WebGL.

At the same time, we are investing in profiling and diagnostics to give developers better visibility and control. Tools like the USS Stats profiler and improved Memory Profiler integration help make performance issues more transparent and actionable.

Looking ahead, our goal is twofold: make UI Toolkit fast by default, and equip teams with the tools to confidently scale, measure, and optimize their UI.

28 Likes

Sounds great, improved performance is very welcome.

What’s the tradeoff when using VisualElementClearOptions.RecursiveReleaseResources?
The documentation only lists benefits (reduced memory usage), making it sound like you want to use that option everywhere.

I assume you don’t want to use it when reusing elements.

Awesome, UI toolkit is getting great updates every version :raising_hands:

That’s a great question. Walking the hierarchy to detach all elements from their parents and release their resources incurs additional an additional CPU cost, while allowing more memory to be reused.

When compared to just removing an entire hierarchy / clearing a root container (i.e. what UIDocument does when disabled/destroyed), where we still need to internally traverse all elements to invoke detatch callbacks and unregister from internal systems, the overhead of RecursiveReleaseResources is pretty minimal, but can increase depending on the actual complexity of UI. For example, TextElement are costlier to release because they have extra resources attached to them.

It’s also worth noting that if an element is finalized without having been explicitly released, its resources will enter a queue that will be processed on the main thread (time sliced across frames). So the cost still needs to be paid eventually.

We have judged that for PanelRenderer, it’s worth paying that cost for more predictable memory behaviour. It’s an option that we could expose in the future on the PanelRenderer component according to user feedback.

It sounds good, but so far many (including me) are worried about the question - will we have a text rendering mechanism without allocations, with the ability to pass, for example, StringBuilder? In general, how much of a priority is this moment?

It sounds good, but so far many (including me) are worried about the question - will we have a text rendering mechanism without allocations, with the ability to pass, for example, StringBuilder? In general, how much of a priority is this moment?

Hi! This is a high priority for us as well. We’re actively working on it, and support for updating text without extra allocations is planned for the 6.6 cycle.

10 Likes

Does this include accepting pointers?

2 Likes

Yeah, honestly pointer support would be really nice if they actually implement it properly.

1 Like

Yes! We’ll have SetText(ReadOnlySpan<char>), which lets you pass raw pointers, stackalloc buffers, string slices, or char arrays; all zero-allocation. Here are the supported APIs we plan to include so far, let us know if you think we’re missing anything:

public void SetText(int value);
public void SetText(System.ReadOnlySpan<char> text);
public void SetText(System.Text.StringBuilder sb);
public void SetText(float value, string format = default(string));
public void SetText(char[] sourceText, int start, int length);
7 Likes

I’m more interested in the internal working of SetText. Does it also depend on a non-alloc mechanism? In theory it doesn’t need a final string but a resizable char buffer should be better? So we can do something like TMP_Text.IncreaseCapacityTo(256) once then subsequent SetText calls won’t ever alloc.

As for missing APIs. It would be nice for methods that accept UTF-8 such as Unity.Collections FixedString or an array of bytes that could be passed directly instead of converting them into char.

Nice, is public void SetText(int value); also non-allocating or it creates a string objects underneath on every call?

Aslo, no void SetText(string str)? Perhaps one more overload that may be useful is void SetText(string str, int start, int length) but it is quite niche.

I’m more interested in the internal working of SetText. Does it also depend on a non-alloc mechanism? In theory it doesn’t need a final string but a resizable char buffer should be better? So we can do something like TMP_Text.IncreaseCapacityTo(256) once then subsequent SetText calls won’t ever alloc.

Yes, internally SetText writes into a reusable NativeArray buffer that grows with a doubling strategy. Once the buffer is large enough, subsequent calls never allocate and no intermediate string is created.

IncreaseCapacityTo can still be useful if you want to allocate ahead however. Taking note to add this API as well!

3 Likes

As for missing APIs. It would be nice for methods that accept UTF-8 such as Unity.Collections FixedString or an array of bytes that could be passed directly instead of converting them into char.

Currently the text pipeline is UTF-16 throughout. We use the ICU library, which works in UTF-16. Even if we provided a UTF-8 overload, it would still need to transcode internally, so there wouldn’t be a performance advantage over converting on your side. We’d rather be transparent about that than hide the cost behind an API that looks free but isn’t.

3 Likes

Nice, is public void SetText(int value); also non-allocating or it creates a string objects underneath on every call?

Non-allocating, none of those APIs create a string underneath. It redirects to our ReadOnlySpan implementation like this:

 Span<char> buffer = stackalloc char[12];
 if (value.TryFormat(buffer, out int charsWritten))
      SetText((ReadOnlySpan<char>)buffer.Slice(0, charsWritten));

Aslo, no void SetText(string str)? Perhaps one more overload that may be useful is void SetText(string str, int start, int length) but it is quite niche.

For string, assigning to the .text property is still the recommended path. If you want a substring without allocating, you can do SetText(myString.AsSpan(start, length)).

4 Likes

Excellent! This is just what we need.

I think it makes the API complete, in case users already know the exact maximum capacity before hand.

We already have non-allocating API to convert primitives into (fixed) strings. Look for FixedString[N]Bytes within Unity.Collections.

FixedString is in UTF-8, but the text pipeline is in UTF-16, so you’d have to convert back and forth.

1 Like