Skip to content

Avoid copying the match table for every queued then block (O(N^2) fireRules) - #12

Open
PDepaula wants to merge 1 commit into
paranim:masterfrom
PDepaula:fix-fire-rules-match-table-copy
Open

Avoid copying the match table for every queued then block (O(N^2) fireRules)#12
PDepaula wants to merge 1 commit into
paranim:masterfrom
PDepaula:fix-fire-rules-match-table-copy

Conversation

@PDepaula

@PDepaula PDepaula commented Sep 4, 2026

Copy link
Copy Markdown

Problem

In fireRules, the loop that executes then blocks does

for (node, idAttrs) in thenQueue:
  let matches = nodeToMatches[node]

Table assignment is a deep copy, so each queued then copies the whole match table of its rule. When one pass queues N then blocks of the same rule (one match per entity, the usual "many enemies / particles" shape), the rule's N-entry table is copied N times: O(N²) per fireRules, with each copied entry carrying a full MatchT.

Fix

Index nodeToMatches[node] in place (two lines). The snapshot semantics are unchanged: nodeToMatches is still filled once before any then runs, so a then that mutates matches does not affect the same pass (the "non-deterministic behavior" test still passes).

Evidence

Benchmark: one rule with (id, Pos), (id, Speed), (Global, DeltaTime); then updates Pos. Time per fireRules with N entities, -d:release --gc:orc, Nim 2.2.10:

entities before after
100 0.57 ms 0.14 ms
300 3.70 ms 0.27 ms
1000 41.31 ms 0.94 ms
3000 494.58 ms 3.50 ms
benchmark source
import pararules, times, strutils
type
  Id = enum Global
  Attr = enum DeltaTime, Pos, Speed
schema Fact(Id, Attr):
  DeltaTime: float
  Pos: float
  Speed: float
let (initSession, rules) = staticRuleset(Fact, FactMatch):
  rule move(Fact):
    what:
      (id, Pos, pos, then = false)
      (id, Speed, speed, then = false)
      (Global, DeltaTime, dt)
    then:
      session.insert(id, Pos, pos + speed * dt)
proc run(n: int): float =
  var s = initSession(autoFire = false)
  for r in rules.fields: s.add(r)
  for i in 0 ..< n:
    s.insert(i + 1, Pos, 0.0)
    s.insert(i + 1, Speed, 1.0)
  let t0 = epochTime()
  for tick in 0 ..< 20:
    s.insert(Global, DeltaTime, 0.016)
    s.fireRules()
  (epochTime() - t0) / 20 * 1000
for n in [100, 300, 1000, 3000]:
  echo n, " entities: ", run(n).formatFloat(ffDecimal, 2), " ms per fireRules"

Found while profiling a Vampire Survivors–style game on pararules (https://github.com/PDepaula/parasurvivors) where 300 enemies each have a per-entity movement rule: fireRules was ~83% of the frame in nimprof, all in this loop. That project currently ships this change via patchFile; with it upstream the workaround goes away.

nimble test passes.

🤖 Generated with Claude Code

fireRules copied nodeToMatches[node] into a local for each entry of
thenQueue. Table assignment is a deep copy, so a pass that runs N then
blocks of the same rule copied that rule's N-entry match table N times:
O(N^2) per pass. Index the table in place instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VGNJYtoUzydpJzfWTW15h
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