Skip to content

perf: faster prop gathering in roomGetProps (quadratic to linear) - #83

Open
andrewcaluzzi wants to merge 1 commit into
murkantor:mainfrom
andrewcaluzzi:perf/roomgetprops
Open

andrewcaluzzi wants to merge 1 commit into
murkantor:mainfrom
andrewcaluzzi:perf/roomgetprops

Conversation

@andrewcaluzzi

@andrewcaluzzi andrewcaluzzi commented Oct 24, 2026 •

Copy link
Copy Markdown

Goal

NB: PC builds only; N64 builds compile the original code unchanged.

I've run up against performance issues while testing scenarios (in my fork) that include 1000+ characters/weapons (alive and persisted corpses). The bottleneck I found was when the engine gathers props in a list of rooms (by calling roomGetProps) and becomes increasingly slower as the number of props increases.

The goal is to pre-emptively improve the performance of this function ahead of modding/scripting support. The base game running on modern hardware doesn't encounter any performance issues because of the low prop count (dozens at most) and disappearing character corpses, but becomes the dominant cost once a room holds a large number of props.

I didn't open an issue first (as this can't be reproduced with the base game/engine) but I'm happy to do so instead.

Description

The roomGetProps function originally scanned a continually growing output list before inserting each prop, making each call O(N^2). A perf profile (in my fork test scenario) put about 98% of game-code time under the chr move test, and most of that was roomGetProps's duplicate scan, called twice per chr per frame. Runtime spawning by mods and scripts, and more chrs in co-op/netplay, will run into the same thing.

This change introduces a small, static lookup to replace the rescan approach, so each call is O(N). A bounds check is also performed to avoid writing past the list, along with a range check for the propnum.

One intended behaviour difference: past 511 distinct props in one call, the original wrote past the end of the list. Now the extra props are omitted from that query instead. "This isn't expected to happen on stock stages.

As disclaimed below, I've used Claude Opus 5.5 to diagnose/fix the issue, benchmark thoroughly, and interpret/provide the results.

Type of Change

  • Bug fix (non-breaking change which fixes an issue): the unbounded list write
  • Other: performance (O(n²) to O(n) per call)

How Has This Been Tested?

  • Both platforms build: Windows (mingw i686) and Linux (i686), from scratch, with the readme's steps.
  • Attract-demo replays are byte-identical to unmodified main (9062f9f).
    • Run GE_DEMO=dam1 and GE_DEMO=facility1 with GE_DEMODIAG=1 GE_TICKHZ=60 GE_FRAMES=2400 GE_NOINISAVE=1, keeping the ^ge: demo tick lines.
    • This PR and main produce identical output: 453 lines for dam1 and 419 for facility1, with cmp silent.
    • To reproduce: run the same command on both builds and cmp the outputs.
  • Linux smoke run: Facility, 3000 frames, no errors.
  • Differential fuzz of the old and new function: a standalone harness with both bodies verbatim, over random and realistic room/block states (built through copies of the real register/deregister/defrag code). About 9.9 million cases on 64-bit ASan+UBSan and 32-bit -O2 builds, with 0 differences. Every in-bounds case matched exactly; every overflow case gave the original's first 511 entries. Three planted bugs were all caught.
  • N64 build: gcc -E -DTARGET_N64 of chrprop.c with the Makefile's include paths and defines is token-identical before and after, for US, EU and JP.
  • No new warnings: gcc and mingw, with and without -Wall -Wextra, give the same counts before and after.
  • Played in game: Dam, Facility, Runway, Surface 1, Bunker 1 on Agent
  • Benchmarks: see below.

Ways I tried to break it:

  • Duplicate props across rooms: a prop in two rooms (a doorway) must appear once. The mark covers that, and the identical demo replays show the gathered lists behave the same.
  • Order-dependent consumers: the order is unchanged, because the append order is the same.
  • Leftover state between calls: the marks are cleared every call.
  • Overflow: with >511 props, the list is now capped instead of overrunning.

Benchmarks

All on an i7-8700K @ 3.7 GHz (Docker/WSL2, 32-bit gcc, the project's -O2 flags).

1. Function micro-benchmark. The original and new function bodies run on identical room states built through the real register logic. Each figure is the median of 9 runs, and the output lists were checked identical. The original does about N²/2 comparisons per call; the new one is linear, at roughly 2 ns per prop.

distinct props rooms queried original ns/call new ns/call speed-up
10 1 38 26 1.5x
20 1 97 44 2.2x
50 1 589 102 5.8x
100 1 2,444 188 13.0x
200 1 8,103 382 21.2x
300 1 17,107 497 34.4x
500 1 41,636 881 47.3x
100 4 (10% in two rooms) 2,632 191 13.7x
500 4 (10% in two rooms) 43,093 1,098 39.3x

It is faster at every size and never slower, including the few-dozen-prop rooms of the stock stages.

2. In game, at scale. This uses my fork's arena: one room holding about 1,300 props (crowd, kept corpses and live enemies). I compared the same fork build with and without only this change. The figure is game-code time per frame (the longest sim stretch).

scene with this change without
crowd 1000 + 250 corpses, idle 1.65 ms 2.38 ms 1.4x
+ 18 live enemies moving and fighting 4.41 ms (max 17.8) 56.73 ms (max 175.1) 12.9x

The cost comes from moving chrs: each move test gathers every prop in the room, twice per chr per frame. With the original scan, 18 enemies in a 1,300-prop room took the frame from about 4 ms to about 57 ms. The fork also has fork-only changes that stop idle and dead chrs doing that work; those are in both builds above, so the difference is this change alone.

3. Stock stage. The dam1 demo plays the same with and without the change; no difference is visible. On stock stages the function costs microseconds per frame, so this is a correctness and scaling fix that costs nothing, not a stock-game speed-up.

The micro-benchmark source and the reproduction commands are available on request.

Checklist

  • My code has been thoroughly tested and is free of bugs
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • The commits I've made a clean, small and easy to understand
  • My changes generate no compiler warnings
  • My Code is AI-Generated or AI-assisted (please specify which AI model you are using): Claude Opus 5.5
  • My PR contains no AI-generated images, textures, models, sound or music (these are never accepted)
  • I am an AI (please specify which AI model you are)
  • I have reviewed my PR to make sure the above is truthful and accurate. I understand my PR can be rejected if it doesn't meet this project's standards.

The roomGetProps function originally scanned a continually growing output list before inserting each prop, making each call O(N^2). This change introduces a small, static lookup to replace the rescan approach, so each call is O(N). A bounds check is also performed to avoid writing past the list, along with a range check for the propnum.
@andrewcaluzzi andrewcaluzzi changed the title src/game/chrprop.c: make roomGetProps linear and bound its prop list perf: faster prop gathering in roomGetProps (quadratic to linear) Oct 26, 2026
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.

1 participant