Skip to content

fix: keep loop_Nc_power out of the process-wide colour memo - #84

Open
oliviermattelaer wants to merge 2 commits into
mainfrom
claude/loop-color-memo-nc
Open

fix: keep loop_Nc_power out of the process-wide colour memo#84
oliviermattelaer wants to merge 2 commits into
mainfrom
claude/loop-color-memo-nc

Conversation

@oliviermattelaer

@oliviermattelaer oliviermattelaer commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The bug

One loop generation poisons every later one in the same process:

set crash_on_error True
import model loop_sm
generate g g > h [sqrvirt=QCD]
output standalone d1
generate g g > h [noborn=QCD]
output madevent d2
File "madgraph/iolibs/export_v4.py", line 2132, in get_icolamp_lines
    max_Nc = max(sum([[(v[4]-v[5]) for v in val] for val in
TypeError: unsupported operand type(s) for -: 'int' and 'NoneType'

Pre-existing; reproduced on 28fe3cef1 (current main, after the 3.7.3 merge).
Each generation on its own works fine.

Root cause

ColorBasis._canonical_dict (madgraph/core/color_amp.py:51) is a class
attribute — a memo from a canonical colour string to its simplified
ColorFactor. ColorBasis.__init__ shadows it with a fresh instance dict, but
LoopColorBasis.__init__ never calls its parent's __init__, so every
LoopColorBasis in the process reads and writes the class-level dict.

The cached ColorFactor's strings carry loop_Nc_power, which
create_copy (color_algebra.py:940) copies out on a hit.
loop_Nc_power is set per diagram by LoopColorBasis.closeColorLoop and
depends on compute_loop_nc: an integer when it is true, None when it is
false (loop_color_amp.py:104, deliberately a sentinel). It is neither part of
the cache key nor recomputed on a hit, so whichever generation fills an entry
first pins its loop_Nc_power for the process lifetime.

Instrumented on main:

run LoopColorBasis that fills the memo stored loop_Nc_power poisoned hits
1 — [sqrvirt=] + output standalone loop_helas_objects.py:2627, compute_loop_nc=False [None], [0], [None] 0
2 — [noborn=] + output madevent group_subprocs.py:222, compute_loop_nc=True (all hits) 4 → crash

Run 2 genuinely asks for compute_loop_nc=True and genuinely gets a fresh
matrix element; it just reads run 1's None back out of the shared memo.
get_icolamp_lines is the only consumer of that field (export_v4.py:2132,
:2143), which is why the crash is specific to loop-induced madevent output.

The same collision is latent within one generation: born strings
(loop_Nc_power = 0) and closed-fermion-loop strings (= 1) share the memo
too.

The fix

Not super().__init__(): that gives every matrix element its own memo and
loses the cross-ME sharing (measured below).

Not keying the memo on compute_loop_nc either — that key is still incomplete
(it does not separate born from loop strings) and it doubles the memo.

Instead, stop storing loop_Nc_power in the memo at all. The colour-algebra
simplification is a pure function of the canonical colour string; loop_Nc_power
is per-diagram metadata that merely rides along. So the cached copy is stored
with loop_Nc_power = 0, and on every path the value is stamped back from the
incoming string. The cache key stays canonical_rep, and it is now complete:
nothing in the stored value depends on anything else.

This is exactly equivalent on the miss path. ColorFactor.full_simplify only
ever copy.copys a string (ColorString.__copy__ = create_copy, which copies
loop_Nc_power) and products another one into it (product does not touch
loop_Nc_power), so every string it produces already carries the incoming
string's value. Verified by instrumenting main: over g g > t t~ [virt=QCD]
and u u~ > t t~ [virt=QCD], 51/51 miss-path simplifications produced strings
whose loop_Nc_power equalled the input's — zero exceptions.

~10 lines in update_color_basis, plus a per-instance _list_color_dict in
LoopColorBasis.__init__ (the other piece of parent state it was silently
sharing; always rebound before use, so latent) and a comment recording that
skipping super().__init__() is deliberate.

Byte-identity

Full generated trees, base 28fe3cef1 vs branch, PYTHONHASHSEED=0, one
mg5_aMC process per row. Compared after normalising the two worktree/output
roots; skipping py3_model.pkl, *.o/.a/.so, me5_configuration.txt,
index.html and proc_card_mg5.dat (git-describe banner + output path);
madevent.tar.gz compared member by member rather than as a gzip blob.

# process output files result
1 p p > t t~ madevent 360 IDENTICAL
2 p p > j j (merged particles) madevent 466 IDENTICAL
3 g g > t t~ [virt=QCD] standalone 147 IDENTICAL
4 g g > h [sqrvirt=QCD] standalone 103 IDENTICAL
5 g g > h [noborn=QCD] madevent 389 IDENTICAL
6 p p > t t~ [QCD] (FKS) aMC@NLO 898 IDENTICAL

Stronger check on the case that actually crashes: coloramps.inc produced by
the branch's poisoned two-generation run is byte-identical to the one
produced by base's clean single-generation run of the same process. The fix
restores exactly the value the un-poisoned path computes, it does not invent one.

Performance

The memo is what makes multi-subprocess loop generation tractable, so the fix
had to keep it shared. p p > t t~ j [virt=QCD] (4 subprocesses: gu, gu~,
uu~, gg), PYTHONHASHSEED=0, update_color_basis timed in-process:

arm colour time (s) memo hits memo misses shared memo size
base 28fe3cef1 1.11 / 1.16 1667 457 423
this branch 1.22 / 1.13 1667 457 423
naive super().__init__() 1.32 / 1.45 1516 608 0

Hit/miss counts and memo size are identical to base — the fix does not
perturb the memo at all, it only changes what is stored in each value. The
colour time difference between base and branch is inside run-to-run noise
(another agent was running madevent jobs on this machine throughout).

The naive per-instance fix, by contrast, loses the cross-matrix-element
sharing: 33% more full simplifications (457 → 608) at this multiplicity,
and the gap grows with the number of subprocesses. That is why this PR does not
take it.

p p > t t~ [QCD] end to end (5 alternating reps per arm, order swapped
half-way): base 45 / 50 / 24 / 17 / 52 s, branch 35 / 23 / 17 / 16 / 17 s. The
spread is machine contention (another agent was running madevent jobs on this
box); warm steady state is ~17 s on both arms. The in-process colour timing
above is the measurement to trust.

Tests

Both fail on 28fe3cef1, pass here.

  • tests/unit_tests/core/test_color_amp.py::LoopNcMemoTest — drives two
    LoopColorBasis objects through the shared memo with different
    loop_Nc_power. On base: AssertionError: [None, None] != [1, 1].
    A second test pins the memo staying process-wide, so a future
    super().__init__() would be caught.
  • tests/acceptance_tests/test_cmd_madloop.py::test_loop_nc_memo_not_poisoned_across_generations
    — the real reproducer, both generations in one MasterCmd. On base it errors
    with the exact int - NoneType traceback. ~10 s once CutTools is cached.

Registered in the existing unittest.yml / acceptancetest.yml slots.

Full unit suite on the branch: 1491 tests, two non-green results, both
pre-existing and unrelated — testIO_UnitProcOutputIOTests is a model-parameter
write-order golden that needs PYTHONHASHSEED=0 (passes on both arms with it
set), and test_DensityMatrixObservables22 errors with
ModuleNotFoundError: No module named 'scipy' identically on base and branch.

Elsewhere in the codebase?

An AST sweep over madgraph/ for "subclass whose __init__ skips the parent's,
where the parent's __init__ shadows a mutable class attribute" returns exactly
one hit: LoopColorBasis / _canonical_dict. The pattern is unique to this class.

(Unrelated observation, not touched here: ColorString.complex_conjugate
rebuilds the string without loop_Nc_power, silently resetting it to the class
default. It is not on any path reached from update_color_basis.)

Note for reviewers

#81 works around this bug — its tests build matrix elements with
compute_loop_nc=True specifically to stay clear of the poisoned cache, with a
comment saying so. That workaround can be dropped once this lands; this PR does
not touch that branch.

🤖 Generated with Claude Code

oliviermattelaer and others added 2 commits August 26, 2026 23:43
LoopColorBasis does not call ColorBasis.__init__, so _canonical_dict stays
the class-level dict and is shared by every loop matrix element in the
process. The cached ColorFactor carried loop_Nc_power, which is set per
diagram by closeColorLoop and depends on compute_loop_nc - not on the
canonical color string the memo is keyed on. The first generation to fill
an entry pinned its loop_Nc_power for the process lifetime, so a
compute_loop_nc=False run (e.g. [sqrvirt=] + output standalone) stored None
and any later loop-induced madevent output hit it and crashed in
get_icolamp_lines with 'int - NoneType'.

The color-algebra simplification is a pure function of the canonical string,
so drop loop_Nc_power when storing and stamp it back from the incoming
string on every path. full_simplify only ever copies/multiplies strings, so
it propagates loop_Nc_power unchanged - the stamp is a no-op on the miss
path and restores the correct value on a hit. The memo stays shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unit test drives two LoopColorBasis objects through the shared memo with
different loop_Nc_power; acceptance test runs the real two-generation
reproducer ([sqrvirt=] standalone then [noborn=] madevent) in one process.
Both fail on the parent commit. Registered in the existing CI slots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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