Skip to content

perf: format each chat message once instead of once per recipient - #105

Merged
Rushaway merged 1 commit into
masterfrom
perf/saytext2-cache
Oct 28, 2026
Merged

Rushaway merged 1 commit into
masterfrom
perf/saytext2-cache

Conversation

@Rushaway

Copy link
Copy Markdown
Member

Summary

On css-ze, Hook_UserMessage (the SayText2 hook) uses 152 s of CPU over 7 days in our production profiler: 6.76M calls, 22.6 µs per call. Over the same period Event_PlayerSay ran 135k times, so the hook runs about 50 times per chat message.

That is how the game sends chat: Host_Say sends a separate SayText2 to each recipient, then fires player_say. Each of these copies runs the full formatting in our hook again: tag and color key lookups, the replace list, %t translation, and CFormatColor() on the final string. Each copy builds the same g_msgFinal, because the result only depends on the message fields and the author's settings, not on the recipient. Event_PlayerSay then sends that one result to everyone.

Changes:

  • Hook_UserMessage stores what it read (author, chat flag, name, sender, trimmed text) and the formatted name/sender/text. A later call in the same tick with the same fields restores these values and returns Plugin_Handled right away. g_msgFinal already holds the right result.
  • Event_PlayerSay (the end of a chat message) clears this cache. The next message is always formatted again, even if the player sends the same text again in the same tick.
  • The early returns (invalid author, empty name/sender, name change, empty text) run before the cache check and are unchanged.
  • Bump version: 8.0.22 -> 8.0.23

Expected: about 49 out of 50 calls become a few string compares and copies, so most of the hook's cost goes away.

Behaviour

Unchanged. A cache hit leaves g_msgName, g_msgSender, g_msgText and g_msgFinal exactly as a full run would, and returns the same value (Plugin_Handled). The formatting reads nothing that can change between the copies of one message: they are sent in a loop with no commands or events in between. The cache only applies within the same tick, so it is also safe for SayText2 messages sent by other plugins without USERMSG_BLOCKHOOKS, which are not followed by player_say.

Test plan

  • Compiles with spcomp 1.12.0.7259 using the same dependencies as CI (no errors, no warnings)
  • CI build passes
  • On a test server: check that chat from a player with tag + name/chat colors, say_team, /me, >greentext, replace-list words and dead chat still shows the same for every recipient. Check that a SelfMute ignore still hides the message. Then compare Hook_UserMessage in the profiler.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 00:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@WareZ57 WareZ57 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Checked what the formatting depends on: the author's tag and colors, IsClientEnabled() (author flags), the replace list, SetGlobalTransTarget(g_msgAuthor) and CFormatColor(..., g_msgAuthor). All of them depend on the author, never on the recipient. g_msgIsTeammate is set in the say/say_team command listener (Command_Say) before any SayText2, and isn't touched here. So within one Host_Say loop every copy builds the same g_msgFinal, and a hit leaves the globals exactly as a full run would.

Edge cases look fine:

  • Cache in-fields are compared after TrimString, i.e. trimmed text against trimmed text.
  • If formatting throws (e.g. a missing phrase), g_bMsgCacheValid stays false, so the next copy runs the full path again, as before.
  • Two messages from the same player in the same tick are separated by player_say, and messages from different players differ in g_msgAuthor.
  • SayText2 sent by other plugins without USERMSG_BLOCKHOOKS with different text per recipient (per-client %t) is a miss, and identical text is a correct hit.

LGTM.

@Rushaway
Rushaway merged commit 809a269 into master Oct 28, 2026
9 checks passed
@Rushaway
Rushaway deleted the perf/saytext2-cache branch October 28, 2026 07:37
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.

3 participants