Files
OpenFrontIO/tests/perf/StructureIconsLayerLookupPerf.ts
Skigim 60f69a6408 perf: remove O(n) StructureIconsLayer render lookups (#3305)
Begins work on #3207

## Description:

This PR is the first optimization slice for #3207: it removes O(n)
render lookups in `StructureIconsLayer` by replacing array-first render
state with a unit-id keyed map, and tightens hot-path execution to
reduce per-tick allocations.

### What changed
- Refactored render state from array-first to `rendersByUnitId:
Map<number, StructureRenderInfo>`.
- Replaced O(n) lookup/delete paths with O(1) `Map#get` / `Map#delete`.
- Replaced `seenUnits` object-identity tracking with `seenUnitIds:
Set<number>`.
- Removed `tick()` array/closure chain (`map(...).forEach(...)`) and
switched to index-based loop.
- Reduced ghost-path allocation pressure by reusing a layer-level `Set`
for connected ally IDs instead of allocating `filter` + `map` + `new
Set` per ghost query.
- Added dirty-flag caching for structure visibility focus
(`visibilityStateDirty`) so expensive visibility-state scans recompute
only when toggles change.

### Performance validation (before/after)
Benchmark added: `tests/perf/StructureIconsLayerLookupPerf.ts`

Command:
`npm run perf`

Observed result:
- `StructureIconsLayer BEFORE (array O(n) lookup/delete) x 0.33 ops/sec
±13.28%`
- `StructureIconsLayer AFTER (unit-id map O(1) lookup/delete) x 95.65
ops/sec ±2.46%`
- Fastest implementation: AFTER (unit-id map)

#### Profiler screenshots are too noisy to be useful for such a focused
change

### Verification
- `npx tsc --noEmit` 
- `npx eslint src/client/graphics/layers/StructureIconsLayer.ts
tests/perf/StructureIconsLayerLookupPerf.ts` 
- `npm run perf` 

## Please complete the following:

~~- [ ] I have added screenshots for all UI updates~~ 
~~- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file~~
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

skigim
2026-03-02 17:40:15 -08:00

110 lines
3.0 KiB
TypeScript

import Benchmark from "benchmark";
const STRUCTURE_COUNT = 50000;
const LOOKUP_COUNT = 50000;
const UPGRADE_LOOKUP_COUNT = 5000;
interface StructureRenderSample {
unitId: number;
ownerId: number;
level: number;
}
const rendersArray: StructureRenderSample[] = Array.from(
{ length: STRUCTURE_COUNT },
(_, index) => ({
unitId: index + 1,
ownerId: (index % 5) + 1,
level: (index % 4) + 1,
}),
);
const rendersMap = new Map<number, StructureRenderSample>();
for (const render of rendersArray) {
rendersMap.set(render.unitId, render);
}
const activeLookupIds = Array.from(
{ length: LOOKUP_COUNT },
(_, index) => ((index * 97) % STRUCTURE_COUNT) + 1,
);
const inactiveLookupIds = Array.from(
{ length: LOOKUP_COUNT },
(_, index) => ((index * 193) % STRUCTURE_COUNT) + 1,
);
const canUpgradeIds = Array.from(
{ length: UPGRADE_LOOKUP_COUNT },
(_, index) => ((index * 389) % STRUCTURE_COUNT) + 1,
);
const myOwnerId = 3;
const results: string[] = [];
new Benchmark.Suite()
.add("StructureIconsLayer BEFORE (array O(n) lookup/delete)", () => {
const localRenders = rendersArray.map((render) => ({ ...render }));
for (const unitId of activeLookupIds) {
const render = localRenders.find((entry) => entry.unitId === unitId);
if (render) {
render.level = render.level + 1;
}
}
for (const canUpgradeId of canUpgradeIds) {
const potentialUpgrade = localRenders.find(
(entry) => entry.unitId === canUpgradeId && entry.ownerId === myOwnerId,
);
if (potentialUpgrade) {
potentialUpgrade.level = potentialUpgrade.level + 1;
}
}
for (const unitId of inactiveLookupIds) {
const index = localRenders.findIndex((entry) => entry.unitId === unitId);
if (index !== -1) {
localRenders.splice(index, 1);
}
}
})
.add("StructureIconsLayer AFTER (unit-id map O(1) lookup/delete)", () => {
const localRenders = new Map<number, StructureRenderSample>();
for (const [unitId, render] of rendersMap) {
localRenders.set(unitId, { ...render });
}
for (const unitId of activeLookupIds) {
const render = localRenders.get(unitId);
if (render) {
render.level = render.level + 1;
}
}
for (const canUpgradeId of canUpgradeIds) {
const potentialUpgrade = localRenders.get(canUpgradeId);
if (potentialUpgrade && potentialUpgrade.ownerId === myOwnerId) {
potentialUpgrade.level = potentialUpgrade.level + 1;
}
}
for (const unitId of inactiveLookupIds) {
localRenders.delete(unitId);
}
})
.on("cycle", (event: Benchmark.Event) => {
results.push(String(event.target));
})
.on("complete", function () {
console.log("\n=== StructureIconsLayer Lookup Benchmark Results ===");
for (const result of results) {
console.log(result);
}
const fastest = this.filter("fastest").map("name");
console.log(`\nFastest implementation: ${fastest.join(", ")}`);
})
.run({ async: true });