Skip to content

Fix quadratic slowdown - #304

Open
samestep wants to merge 2 commits into
maralorn:mainfrom
samestep:quadratic
Open

Fix quadratic slowdown#304
samestep wants to merge 2 commits into
maralorn:mainfrom
samestep:quadratic

Conversation

@samestep

@samestep samestep commented Aug 4, 2026

Copy link
Copy Markdown

This PR fixes #303 by dropping the invariant that a NOMState's forestRoots is sorted, and instead just sorting in the printer, allowing us to change it from a Seq DerivationId with linear removal time to a DerivationSet with logarithmic removal time.

As a result, nom's time-to-first-build in that issue's original npb example drops from 57m17s to 11m53s on my machine, and the synthetic example goes from quadratic to roughly linear:

     1000       287ms
     2000       361ms
     4000       589ms
     8000      1067ms
    16000      2021ms
    32000      3960ms

It's worth noting that this PR does introduce a minor performance regression in specific situations, since it causes the roots to be sorted every time we print a new state, which can cause more CPU usage if there are a lot of roots but the graph isn't changing much as we repeatedly print over a long period of time.

To test this, we can use the same example.nix from #303. First, generate JSON to feed to nom:

n=32000
{
  printf '@nix {"action":"msg","level":3,"msg":"these %d derivations will be built:"}\n' "$n"
  nix-instantiate example.nix --arg n "$n" 2>/dev/null | sed 's|.*|@nix {"action":"msg","level":3,"msg":"  &"}|'
  printf '@nix {"action":"start","id":1,"level":6,"parent":0,"text":"building","type":105,"fields":["%s","localhost",1,1]}\n' "$(nix-instantiate example.nix --arg n 1 2>/dev/null)"
} > plan.jsonl

Then feed it to nom followed by an endless stream of "evaluating file" messages (triggering withChange), so that nom keeps redrawing at its 60ms ceiling instead of slowing to 1000ms per frame:

{ cat plan.jsonl
  q=\'
  i=0
  while :; do
    printf '@nix {"action":"msg","level":5,"msg":"evaluating file %s/some/path/file-%d.nix%s"}\n' "$q" "$i" "$q"
    i=$((i + 1))
    sleep 0.02
  done
} | nom --json

And finally, in a separate terminal, measure nom's CPU usage for a minute:

p=$(pgrep -x nom)
ps -o cputime= -p "$p"; sleep 60; ps -o cputime= -p "$p"

Note that you should wait until nom has finished ingesting the entire build graph before running the measurement; the wait is only about 4 seconds using this PR, but roughly 40 seconds with version 2.2.0 of nom.

When I ran this on my machine using nom 2.2.0, I saw 17.05 seconds of CPU time:

  0:42.87
  0:59.92

And using this PR, I saw 23.28 seconds of CPU time:

  0:05.11
  0:28.39

So, roughly 40% more CPU usage in this example.

Finally, here's a script to print nom's last frame that contains a tree:

n=200
{
  printf '@nix {"action":"msg","level":3,"msg":"these %d derivations will be built:"}\n' "$n"
  nix-instantiate example.nix --arg n "$n" 2>/dev/null | sed 's|.*|@nix {"action":"msg","level":3,"msg":"  &"}|'
  sleep 3
} | nom --json 2>&1 | awk -v RS='\033\\[\\?2026h' '/Dependency Graph/ {last = $0} END {printf "%s", last}' | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g; /Finished at/d'
I got the same output with nom v2.2.0 and with this PR, showing that the sort order is preserved.
┏━ Dependency Graph showing 20 of 200 roots:
┃ ⏸ example-180
┃ ⏸ example-181
┃ ⏸ example-182
┃ ⏸ example-183
┃ ⏸ example-184
┃ ⏸ example-185
┃ ⏸ example-186
┃ ⏸ example-187
┃ ⏸ example-188
┃ ⏸ example-189
┃ ⏸ example-190
┃ ⏸ example-191
┃ ⏸ example-192
┃ ⏸ example-193
┃ ⏸ example-194
┃ ⏸ example-195
┃ ⏸ example-196
┃ ⏸ example-197
┃ ⏸ example-198
┃ ⏸ example-199
┣━━━ Builds

The regression check scripts in this PR description were written by Claude Opus 5.

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.

Building the graph is quadratic in the build plan size

1 participant