Skip to content

perf: halve what an open center HTML menu costs per tick - #1401

Open
makadore wants to merge 4 commits into
roflmuffin:mainfrom
makadore:perf/center-html-menu
Open

perf: halve what an open center HTML menu costs per tick#1401
makadore wants to merge 4 commits into
roflmuffin:mainfrom
makadore:perf/center-html-menu

Conversation

@makadore

Copy link
Copy Markdown

CenterHtmlMenuInstance.Display is registered as an OnTick listener, and the panel only stays on screen while it is being resent, so an open menu rebuilds and resends its markup 64 times a second for every player looking at it. Two things in that path do not need to happen more than once.

Measurements

Taken on a live CS2 server with a 12 item menu, timing the two halves of Display separately.

before after
build markup 31.3us 3.7us
send panel 35.6us 29.0us
per render 67us 33us

With twenty players holding a menu open that is 0.66ms of every tick instead of 1.34ms.

What changed

Labels and the builder — the previous, next and close labels each go through the localization stack, and a StringBuilder is allocated, on every one of those 64 renders a second. None of it changes while the menu is open, so the labels are read when the menu is opened and one builder is reused.

The panel eventPrintToCenterHtml creates a game event, fires it and frees it. Doing that every tick is what most of an open menu costs. The event is kept for as long as the menu is and refilled with the new markup instead, then freed in Close. Sending is guarded the same way PrintToCenterHtml guards it.

The second measurement was taken by alternating the two send paths on every other render, so both meet the same conditions: 36.8us over 904 sends against 29.0us over 903. I first measured them one after the other and got a similar looking gain, but the untouched build time moved just as much between those two runs, so that pair said nothing and the interleaved one is what the number above comes from.

Things I tried that did not work

Worth writing down so nobody repeats them.

Resending less often than every tick. The panel drops off screen almost immediately, at any interval above 1. Every tick really is required, so the resend itself cannot be avoided — only made cheaper, which is what the second commit does.

Changing the duration passed to the event. No observable difference at 1, 2, 5, 10 or 30, so whatever that field does it is not keeping the panel alive.

Two things noticed while working on this, not addressed here

The panel flickers. It does so before these changes as well, at any resend rate, and it does the same on public servers running their own menu implementations, so it looks like it is how the game draws that panel when it is refreshed every tick rather than anything on this side.

Menus are not closed when a player disconnects. ActiveMenus is only cleaned by CloseActiveMenu, so an instance for a player who left keeps its OnTick listener and keeps rendering. That is pre-existing, and the event added here is freed in Close like the rest of the instance state, so it is not made worse, but it is why the guard is there.

Happy to split these into separate PRs if you would rather take them one at a time.

@makadore
makadore requested a review from roflmuffin as a code owner August 25, 2026 16:25
public CenterHtmlMenuInstance(BasePlugin plugin, CCSPlayerController player, IMenu menu) : base(player, menu)
{
_plugin = plugin;
_previousLabel = Application.Localizer["menu.button.previous"];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think caching these will cause some buggy behaviour if a user changes language when they have a menu open. I don't think these are a source of a significant performance uplift, I would think most of the gains come from the event re-use and not creating a new string builder on tick. Could you remove the label caching and re-test the performance?

… one per tick

Display runs on every tick for as long as the menu is open, and each call built
a fresh StringBuilder. Keep one per menu instance and clear it instead.
The panel has to be resent on every tick to stay on screen, and each send built
a game event and freed it again. Keep the event for as long as the menu is open
and refill it instead, freeing it when the menu closes.
SteamID rejects a zero id, and a bot passes the validity check with one, so
player.GetLanguage() threw ArgumentOutOfRangeException for any fake client.
Fall back to the server language for them, matching how AdminPermissions
already treats bots and HLTV.
…er tick

Display runs on every tick and each label goes through the localization stack.
Read them under the player's own language and keep them until it changes.

Display runs outside of command handling, where the thread culture is the
server language rather than the player's, so this also gets the labels into
the language the player picked.
@makadore

Copy link
Copy Markdown
Author

Removed the label caching and re-measured. You were right that the event reuse carries a real part of this, and right about the risk in caching the labels, though checking that second point turned up something I did not expect.

Measured on a live server with the paths interleaved, alternating on every render so all of them see identical conditions. Nine option menu, averaged over five runs:

per render vs master
master 39.8 us
builder + event reuse 26.8 us 33%
plus reading the labels once 16.6 us 58%

So the event and builder reuse is a third of it on its own, and the labels are the rest.

On the caching risk, I set a player to es and looked at what the server actually sends rather than at the screen. Display runs on tick, outside command handling, so the thread culture there is the server language and never the player's. Hooking the outgoing panel on clean master:

menu opened for MAKADORE (bot=False), ForPlayer gives "Cerrar"
panel sent to client: button8=Next button9=Close

The labels have never been in the player's language, so freezing them at construction would have been a step forward rather than a regression. There is a better option though, so I did not do that either.

They are now read under the player's own language and kept until it changes, which is a reference comparison per tick. Same capture on this branch, switching language with the menu open:

panel sent to client: button8=Siguiente button9=Cerrar
panel sent to client: button9=Close
panel sent to client: button9=Cerrar

Right language, updates without reopening, still 58% faster than master.

One thing fell out of this. The menu is now the first thing to call GetLanguage outside a command, and it threw for bots, since SteamID rejects a zero id and a fake client passes the validity check with one. That also breaks Localizer.ForPlayer, which documents a fallback to the server language for an invalid player but throws instead: four bots out of four on master, four out of four working after. It is a separate commit using the same bot and HLTV check AdminPermissions already does, and I am happy to pull it into its own PR if you would rather keep this one to the menu.

@makadore
makadore force-pushed the perf/center-html-menu branch from 68ad9fa to 37b585b Compare August 26, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants