Skip to content

Complete Performance Overhaul - #1036

Merged
Wyvest merged 21 commits into
Polyfrost:v1from
LizzyMaybeDev:perf/ui-cost
Sep 5, 2026
Merged

Complete Performance Overhaul#1036
Wyvest merged 21 commits into
Polyfrost:v1from
LizzyMaybeDev:perf/ui-cost

Conversation

@LizzyMaybeDev

Copy link
Copy Markdown
Contributor
28 performance fixes (click to expand, ranked worst first)
  1. HUD previews redrew the whole interface every frame. A preview reads live values like a clock, and reading them in the draw scope made the layer depend on them, so one ticking HUD redrew everything.
  2. Opening the HUD editor spent 2272 ms resolving every one of its own icons inline the first time. Warmed ahead of time it is 52 ms, about 43x faster.
  3. The warm-up built the UI at 854x480 against 30 of 138 configs, so it laid out the wrong size against a fraction of the data and the first real open still cost 197 ms redoing it.
  4. Reopening the UI cost ~72 ms, now ~9 ms. The scene and composition survive a close, so reopening reuses the tree rather than building it again.
  5. The warm-up landed as a single 1332 ms frame as the world appeared. It runs one frame at a time now, on the title screen.
  6. Every icon draw built and destroyed a native image. The mod list draws over a hundred a frame, and the finalisers those queued held the render thread for 320 ms.
  7. Icons were re-parsed every time a card scrolled back into view. Caching the raster took the mean scroll frame from 17.2 ms to 5.1 ms and the worst second from 64.7 ms to 19.3 ms.
  8. An icon is only rasterised the first time its card is drawn, so no prefetch reached it. The warm-up drags the grid top to bottom and back, at the size a real scroll asks for.
  9. Every open re-registered every config tree, walking the whole property graph and localising a title per tree, with nothing changed between opens. 45 ms, every time.
  10. The first colour picker or keybind row cost 40-50 ms to load its class, because the loader searches every jar and the ASM transformers rescan interfaces. Done on a thread now.
  11. Focusing the search field cost 40 ms. Compose lays out a paragraph just to report the field's rectangle, which drags in the whole Skia text stack. The warm-up does it offscreen.
  12. The loader's mod list was rebuilt once per config per frame, hundreds of times a frame, measured at 50 ms on the frame the settings list laid itself out.
  13. A classpath icon miss walks every jar the game loaded, 139 here including a 78 MB compose bundle, and nearly every icon probe misses by design. Resolved off-thread now.
  14. Only the mod grid was warmed, so arriving at any other page paid for it. Warming every page took the worst section frame from 195 ms to 48 ms.
  15. The mod grid rebuilt a card's whole node tree for each card scrolling in. A card costs ~2 ms to arrive and only 0.26 ms of that is composing it, so the grid reuses them now.
  16. Hovering a card recomposed the entire card, both gradients, both labels and the icon, for a change only the favourite star cares about. Crossing the grid did that to every card.
  17. Searching the registry copied it. upsert copied 138 entries and scanned them linearly, once per tree, on every open, and the HUD card lookups did the same once per card.
  18. The keybind conflict map compared every bound key against every other, per row, per recomposition. Computed once now and held until something actually changes.
  19. The accent colour waited on a frame forever. Compose reads a pending frame clock awaiter as pending work, so every frame came back dirty. It only waits now if the accent animates.
  20. Scroll edge gradients were rebuilt every frame on every scrollable surface in the UI. They only depend on the size, so they are built in the draw cache instead.
  21. A card drew its two decorations in two nodes, costing every card an extra layout node and an extra draw node.
  22. HUD previews were thrown away when the UI closed, so every reopen rebuilt them. They have their own clock now, ticked only while something is showing them, so keeping them is free.
  23. The HUD editor was rebuilt on every open, the most expensive open OneConfig has. It is retained like the main screen.
  24. Packet and entity render events allocated even when nothing listened. They fire thousands of times a second and every one of those was immediately garbage.
  25. Config writes threw to find out whether a type could be written through, filling in a stack trace per write for the length of a slider drag. Settled once now.
  26. The reflective collector walked up into Object, reflecting over its eleven methods for every config at every nesting level.
  27. The class warm list was 51 names typed by hand and five had gone stale, so it warmed nothing. It is read off our own jars now.
  28. The shell's glow opacity was read inside the draw, which makes the layer depend on it, and the shell background is the one thing drawn on every frame of every page.
12 bugs fixed (click to expand, ranked worst first)
  1. Upstream's Kotlin 2.4 bump renamed every internal member's JVM name, and PolyPlus stopped linking at the first frame that touched a theme. No UI, no main menu, blank screen.
  2. The retained shell changed the shape of its modifier chain when hidden. Compose indexes those nodes for hit testing, and toggling one desynchronised the index and took the scene down.
  3. Snapshot writes were applied twice inside one frame, handing new state to a tree already composed against the old. That was the shape of every remaining RectList crash.
  4. The scene could be handed a frame time earlier than the last one, which walks animations backwards and leaves the tree in a state Compose does not expect.
  5. Two callers racing for a thread pool each built one and leaked one, and the scheduled pool asked for a negative thread count on any machine with fewer than three cores.
  6. The event handler map was not concurrent. Packet events post from the netty loop, so a handler registered on the main thread could stay hidden indefinitely.
  7. Icon warming submitted a fresh background pass every call, so several went through the class loader at once, contending with each other and with the render thread doing the same lookups.
  8. Retaining the screen left the nav controller alive across a close, so returning to the start page assumed it was already there instead of navigating.
  9. The reflective collector logged the tree's id while reporting the tree was null, so the error path threw instead of explaining itself.
  10. The search results list had stopped being lazy and dropped a column.
  11. The F3 overlay blanked for one frame whenever a screen closed.
  12. Config writes asked whether the declared field type could be written through, not the actual value's. A field declared as an interface reports immutable, so collections were replaced instead of written through and anything holding the old one stopped seeing updates.

What this is

UI performance work on the Compose interface, plus the bugs found while doing it.

Why

On a 141 mod pack the interface was costing between a third and a half of every
frame while it was on screen, and single frames were reaching 190 to 327 ms.

Everything below was measured before and after by a scripted controlled run doing the same
thing every time (three opens, a tour of every page, a slow scroll and two flicks,
three HUD editor opens) on three machines. The harness itself is not part of this
PR.

Worst single frame, ms, before to after

area RTX 3060 RX 5700 XT Apple M1
opening the menu 190 → 64 223 → 105 84 → 39
moving between pages 188 → 49 184 → 33 121 → 43
scrolling the mod list 327 → 33 115 → 47 not measured
the HUD editor 116 → 52 92 → 36 38 → 18
closing the menu 33 → 47 81 → 25 24 → 18

What OneConfig cost per frame, ms

A 60 fps frame has 16.7 ms in total.

area RTX 3060 RX 5700 XT Apple M1
opening the menu 6.84 → 3.89 10.75 → 3.33 4.13 → 2.42
moving between pages 3.51 → 3.02 3.33 → 1.91 3.14 → 2.36
scrolling the mod list 4.02 → 1.93 2.11 → 1.33 not measured
the HUD editor 1.64 → 1.24 1.13 → 0.71 0.88 → 0.42
closing the menu 1.04 → 0.97 2.04 → 0.85 0.62 → 0.55

Frames past 33 ms that we caused: 32 → 5, 45 → 7, 19 → 3.
Frames past 50 ms: 28 → 2, 28 → 1, 4 → 0.

The larger pieces

The screen and its scene are reused. Composing, laying out and drawing the tree
is nearly all of what an open costs. Reopening went from ~72 ms to ~9 ms. This is
the change with the most surface area: a retained composition needs the interface
asked to raise its visibility again, the close state reset, and the shell's stale
bounds cleared so they stop answering hit tests.

The UI is built on the title screen. The old warm-up ran on the first frame it
could, when the window is still 854x480 and most mods have not registered, so the
first real open paid 197 ms to redo it. It now watches until the inputs settle and
warms a frame at a time rather than landing as one 1332 ms frame.

Icons are rasterised once. Compose's bitmap painter built and destroyed a native
image per draw and the mod list draws over a hundred a frame; the finalisers held
the render thread for 320 ms.

HUD previews stopped redrawing the whole interface. A HUD reads live values and
those reads happened inside a draw scope, which makes the layer depend on them, so
a ticking clock invalidated everything.

Bugs fixed

  • Kotlin 2.4 renamed every internal member's JVM name, breaking downstream mods
    that link against them (PolyPlus: no UI, blank main menu). The module name is
    pinned to the archives name.
  • Snapshot writes applied twice in one frame, handing new state to a tree already
    composed against the old.
  • A modifier chain that changed shape when hidden desynchronised Compose's hit
    test index and took the scene down.
  • The scene could be handed a frame time earlier than the last one, walking
    animations backwards.
  • Two callers racing for a thread pool each built one and leaked one; the
    scheduled pool asked for a negative thread count under three cores.
  • The event handler map was not concurrent, though packet events post from the
    netty loop.
  • The F3 overlay and the legacy HUD blanked for a frame on screen close.
  • The search results list had stopped being lazy and dropped a column.

Two behaviour changes

  • The HUD editor and the config screen are now single reused instances rather than
    constructed per open. OneConfigUIScreen.open() replaces OneConfigUIScreen()
    at the call sites that want the shared one; a per-mod screen is still constructed
    and is deliberately not retained.
  • Closing no longer releases the offscreen render targets. They are viewport sized
    and rebuilt on resize, so releasing them only made the next open pay for a fresh
    texture, framebuffer and Skia surface.

Known problems with the mod, not fixed

  • The mods page still redraws itself ~25 times a second after everything settles,
    where other pages sit at 0 or 1.
  • On Apple Silicon the dominant cost is the GPU submit, not drawing. macOS runs GL
    through Metal and a Skia flush there can block on the game's queued work, this is in every case and run to run variance is significant plus I dont own a damn APPLE product. That path is untouched by this PR.

… JVM names

Kotlin mangles every internal member's JVM name with the module name, and 2.4
changed that default from the archives name to the maven coordinates. That
renames a published ABI on a toolchain bump: PolyPlus, built against an earlier
release, stopped linking against ThemeRegistry.getRegistry$internal the moment
it moved, which left no UI and a blank main menu.
Packet and entity render events fire thousands of times a second and allocated
an event object every time even with no listeners; hasListeners lets the hot
call sites skip it. The handler map is now concurrent, since packet events post
from the netty loop while mods register from the main thread, so a handler could
stay invisible indefinitely.

The pools were built behind unsynchronised null checks, so two callers racing
each built one and leaked one, and the scheduled pool asked for a negative
thread count on any machine with fewer than three cores.
…hrough

overwrite() reports "cannot write through this" by throwing, and set0 runs once
a frame for the length of a slider drag, so every one of those filled in a stack
trace. Ask isImmutable first, on the value's runtime class, which is the same
question overwrite asks.

The reflective collector stopped walking into Object, which declares no config
properties and cost eleven reflected methods per config per nesting level, and
its two null guards ran in the wrong order so the error path threw instead of
explaining itself.
A composition only does work on a frame, so splitting the single host into
clocks lets the HUD previews be kept alive between opens and simply left alone
the rest of the time, instead of being rebuilt on every open.

frame() also takes notify, because sendApplyNotifications is global and firing
it twice in one frame hands new state to a tree already composed against the old.
prepare() filtered a fresh list of active HUDs on every frame; it now reuses one.
The update schedule moved off a HashMap keyed by Hud onto a field on the HUD
itself, which is a lookup and a hash per HUD per frame saved.

hasHudOfType answers what the library asks once per provider per recomposition
without building a list to do it.
Every open reloads the registry, which registered every tree again, which walked
all of them: the whole property graph plus a localised title each, measured at
45 ms on the frame the interface opened. The registrar now remembers the trees it
has walked, weakly so a discarded profile is not kept alive, and a caller that
means it can still force a rescan. loadFrom also stopped scanning each tree twice.

configList copied the whole registry on every read, and category, version and
description each scanned the entire mod list per tree. A star click no longer
puts a disk write in the middle of the frame it happened in.
… has

Compose's bitmap painter built and destroyed a native image on every draw, and
the mod list draws over a hundred icons a frame: the finalisers those queued held
the render thread for 320 ms. Nothing about the image changes between draws, so
one raster is shared by every call site and tinted at draw time.

An icon was also re-parsed every time a card scrolled back into view, because the
read lived in a remember the lazy grid throws away. Caching the raster took the
mean scroll frame from 17.2 ms to 5.1 ms and the worst second from 64.7 to 19.3 ms.

A classpath miss walks every jar the game loaded, 139 here including a 78 MB
compose bundle, and nearly every icon probe misses by design, so misses are cached
too and names are resolved off the render thread beforehand.
…rame

A HUD reads live values, and reading them inside a draw scope makes the layer
depend on them, so one ticking clock invalidated the layer and one invalidated
layer redraws the whole interface. Reading them outside observation still draws
current values on any frame that draws, and dragging, selecting and resizing all
still drive frames of their own.

The previews are also kept in a cache of their own now, since a lazy grid disposes
cards that scroll out of view and rebuilding the runtime and re-running setContent
is the most expensive thing a card does.
Waiting on the frame clock unconditionally leaves an awaiter on it forever, and
Compose reads a pending awaiter as pending work, so every frame came back dirty
and redrew the whole screen. Only a chroma accent changes on its own, and the
accent colour is a plain static rather than snapshot state, so that flag needs to
be state of its own for the effect to notice it change.
…building brushes

The grid rebuilt a card's whole subcomposition and node tree for each card
scrolling in. A card costs about 2 ms to arrive and only 0.26 ms of that is
composing it, so a contentType lets the grid reuse one scrolling out instead.
Hovering recomposed the entire card, both gradients and both labels and the icon,
for a change only the favourite star cares about; the star takes the interaction
source now rather than its state.

Scroll edge gradients were rebuilt every frame on every scrollable surface, and a
card drew its two decorations in two nodes when one will do. The shell's glow
opacity was read inside the draw, which makes the layer depend on it, and the shell
background is the one thing drawn on every frame of every page.

The search results grid had stopped being lazy: the whole grid sat in a single lazy
item, so a broad query composed most of the mod list on the keystroke that produced
it. As rows it stays lazy, and sizing in pixels stops the last column wrapping.
AnimatedVisibility disposes its content when the exit animation ends, which for a
retained scene hands back the whole cost of an open. RetainedVisibility animates
the same way and simply stops drawing.

Its modifier chain is deliberately the same shape shown or hidden: a modifier that
comes and goes adds and removes nodes Compose indexes for hit testing, and toggling
one desynchronised that index and took the scene down.

The composition holding the nav controller now outlives a close, so only the very
first one is already sitting on the start destination and every open after it has
to navigate. Grid states are published so a warm-up can scroll a list before anyone
looks at it.
Composing, laying out and drawing the tree is nearly all of what an open costs,
and all three are skipped when the composition is still there. Reopening went
from about 72 ms to about 9 ms. The HUD editor is retained too, since it is the
most expensive open OneConfig has: forty preview runtimes and the whole studio.

Three things a retained composition needs. The interface raises its visibility
from a LaunchedEffect(Unit) that runs once per composition, so a screen coming
back after a close has to ask for it as cancelClose does. A screen still carries
the close it was dismissed by, which left the opening frame computing a finished
closing animation and rendering fully faded out. And the shell's last bounds
would still answer hit tests once it is no longer laid out, which the HUD editor
asks to decide what a click is over.

A screen aimed at one mod's config is not shared: its page is fixed at
construction and there is nothing to come back to.

Holding the open key produced dozens of closes a second and therefore dozens of
whole-config writes queued behind each other; saving is coalesced and serialised.
…ally open at

The first frame that can warm up is not the frame worth warming: Minecraft's
window is still 854x480 and most mods have not registered their configs, so one
pass laid the whole UI out at the wrong size against a fraction of the data and
the first real open paid 197 ms to do it again. It watches instead, and a pass
that finds nothing changed costs three reads.

Running the whole pass at once put it on a single frame, measured at 1332 ms as
the world appeared, so it runs a frame at a time. Every page is warmed rather
than only the mod grid, which took the worst section frame from 195 ms to 48 ms,
and the grid is dragged through its whole extent because an icon is only
rasterised the first time its card is actually drawn.

Focusing the search field lays out a paragraph to report its rectangle, which
drags in the entire Skia text stack: 40 ms on the frame the interface opened.
The class warm list was 51 names typed by hand, five of them stale, so it is read
off our own jars now. An idle frame no longer copies the draw list to find out it
was empty.
The mixin reads hasContent earlier in the frame than the capture runs, so
clearing it on the way in meant a frame that failed to record had already
cancelled vanilla and then blitted nothing. Keeping the last capture makes the
worst case a slightly stale frame rather than an empty one.

Every path that fails to record now reports false so the caller falls back to
its live renderer, since returning true without setting hasContent suppresses
that renderer and leaves the HUD missing.
LizzyMaybeDev and others added 5 commits September 5, 2026 03:52
Retaining a composition and warming it offscreen turns every LaunchedEffect(Unit)
in it from "once per open" into "once, ever", and drawing without observing reads
leaves nothing to invalidate a layer. Three things broke on that.

A HUD preview mirrors a provider rather than an active instance, so nothing
advanced what it showed and nothing told its canvas to redraw. Providers are
updated on their own interval while the UI is open, and a preview frame that
changed something bumps a revision the canvas reads in its draw. One dependency
rather than every live value the HUD touches, so a ticking clock costs one redraw
a second instead of one a frame.

The editor took its pending selection from a LaunchedEffect(Unit), which had
already run against nothing during the warm-up, so clicking a HUD's settings
opened a bare editor. An open bumps a revision those effects key on, which covers
queued session commands too.
The pass was one 2430 ms frame on the title screen, which is a visible freeze on
every game open. It waits for a loading screen now: singleplayer stops on
LevelLoadingScreen, a server on ConnectScreen, and a world already loaded is
where it used to run anyway. A session that never reaches a world gives up after
ten minutes and lets the first open build the UI, as it did before any of this.

setContent builds the whole composition and was rendering on the same frame, so
it gets one of its own. The offscreen target is viewport sized and was allocated
and destroyed on every frame of the pass, thirty times at 1920x1080; it is kept
for the length of a pass instead.

Worst frame 2430 ms to 837 ms, and no longer on a screen anyone is watching.
@Wyvest
Wyvest merged commit 1beabc4 into Polyfrost:v1 Sep 5, 2026
@github-project-automation github-project-automation Bot moved this to Done in OneConfig Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants