Skip to content

Sum the four gluon current into the cubic one carrying the same colour factor - #57

Open
oliviermattelaer wants to merge 39 commits into
mainfrom
claude/gluon-amplitude-optimization-8706f5
Open

Sum the four gluon current into the cubic one carrying the same colour factor#57
oliviermattelaer wants to merge 39 commits into
mainfrom
claude/gluon-amplitude-optimization-8706f5

Conversation

@oliviermattelaer

@oliviermattelaer oliviermattelaer commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Each colour structure of the four gluon vertex carries exactly the same colour factor as the diagram obtained by splitting that vertex into two cubic ones. So the quartic current and the cubic current carrying that factor can be summed before the colour algebra is applied, and the amplitude reading the sum gets both contributions from a single call:

      CALL VVV1P0_1(W(3),W(6),GC_10,ZERO,ZERO,W(19))   ! cubic pair
      CALL SUMW_1(W(19),W(12),W(20))                   ! + quartic current W(12)
      CALL VVV1_0(W(4),W(5),W(20),GC_10,AMP(33))       ! one call, both pieces

The option, and the default to decide

set merge_quartic_vertices False   # default today — off
set merge_quartic_vertices speed   # current sums, and the diagram order that allows them
set merge_quartic_vertices slots   # the order that keeps fewest currents alive; no current sums
set merge_quartic_vertices auto    # slots for a gpu backend, speed otherwise, decided per output

True is accepted as a synonym for speed. With no set at all the generated matrix.f and CPPProcess.cc are byte-identical to main for g g > N g, N=2..5, in both backends.

speed and slots have to be set and not output options: they fix the diagram order while the diagrams are generated. auto is the exception, and only because slots is the speed order reversed — see below.

Why two modes

The two objectives pull in opposite directions, and the constraint is sharp: a current sum needs its quartic current emitted before the target amplitude, and that ordering constraint is exactly what costs slots.

g g > 5 g amplitude calls slots wavefunction store
off 7245 268 25.1 kB
speed 6813 (−6%) 259 24.3 kB
slots 7245 199 (−26%) 18.7 kB

On CPU that store is a stack frame and stays cache-resident, so the amplitude calls decide — speed wins, measured. On GPU it is per thread, ~24 kB of local memory at seven gluons, and it is what caps occupancy — so 6% more arithmetic for 23% less memory is plausibly the better trade.

I have not measured that. There is no CUDA device on the machine I ran on. Everything below is CPU/SIMD; the case for slots is an argument, not a measurement.

auto — letting each output choose

The wrinkle behind the default question is that the option is read at generate time while the backend is chosen at output time, and one generation can feed both. auto closes that: it generates the speed order and reverses it at output time, once the backend about to receive the matrix elements is known.

The choice comes from the matrix element exporter, not the output format. output madevent --me_exporter=<gpu backend> hands one _curr_matrix_elements to both exporters in a single export_processes call, so its fortran driver and its gpu matrix elements cannot carry different orders. That is the cudacpp production path — the one case the option exists for. Keying on the format would hand it the cpu order; keying on the me exporter gives it slots (NWAVEFUNCS=54 rather than 78 at six gluons).

Verified byte for byte: auto + standalone reproduces a native speed generation, auto + standalone_mg7 a native slots one, output madevent --me_exporter=standalone_mg7 a native slots madevent, and a session going standalone → standalone_mg7 → standalone reproduces its first output for the third. Processes the seed rule never applied to are untouched.

Two pre-existing behaviours had to be worked around, neither specific to this option:

  • An export mutates the diagrams it is given — 345 of 757 vertex leg records on g g > g g g g. Reversing mutated diagrams gives an equivalent but differently numbered result, so the reordering starts from a copy taken before the first export. copy.deepcopy of the amplitudes is not available (it drags the model along and trips assert type(col_obj) != array.array in color_algebra.create_copy); the diagrams alone copy cleanly, 0.011 s at seven gluons.
  • An export also drops the marks saying the diagrams came from a seed — afterwards quartic_unroll_tags has 0 entries instead of 405 — so whether an amplitude may be reordered is read before the first export, not at the output that wants to reorder it.

So what should the default be?

Still yours to pick, but auto removes the reason to compromise:

  1. Keep False (what this PR does). Opt-in, no behaviour change, nothing to decide until someone runs slots on a device.
  2. Default auto. Each backend gets the order it wants, with no per-process choice to make. This is what I would move to once there is a gpu number.
  3. Default speed. Best for cpu, but makes a choice on behalf of the gpu backend.

I still recommend (1) for this PR: auto is only worth defaulting to once the slots half of it is backed by a measurement, and there is no CUDA device on the machine I ran on — everything below is CPU/SIMD, and the case for slots remains an argument.

Results (speed against off)

Two runs each, reproducible to ~0.1%. Standalone is the shipped check driver looping SMATRIX; madmatrix is check_sa.exe perf built FPTYPE=d on cppsse4 — the default mixed-precision build rounds both to the same value and would hide any difference.

Speed

standalone (per call) madmatrix (evt/s)
g g > g g 11.00 → 11.04 s −0.4% 875150 → 878724 +0.4%
g g > g g g 34.90 → 34.99 s −0.3% 74229 → 67566 −9.0%
g g > g g g g 47.77 → 45.25 s +5.3% 2651 → 2880 +8.6%
g g > 5 g 42.19 → 40.10 s +4.9% 41.75 → 43.33 +3.8%

Four gluons is a wash — there is nothing to sum, the only quartic vertex is the whole amplitude. Five gluons loses on madmatrix: 7 sums against 7 saved amplitude calls does not cover the extra slots, and madmatrix has no amplitude folds to make up the difference. Six and seven gluons win on both.

Timings were taken in batches with ~1% of drift between them, so each row should be read against the off measured alongside it.

Memory and work — slots / wavefunction calls (+ sums) / amplitude calls

standalone Fortran:

off speed
g g > g g g 12 / 33 / 45 19 / 39+7 / 38
g g > g g g g 51 / 111 / 510 78 / 126+30 / 450
g g > 5 g 268 / 898 / 7245 259 / 925+60 / 6813

madmatrix — the slot counts are now the same as Fortran's:

off speed
g g > g g g 12 / 45 19 / 38
g g > g g g g 51 / 510 78 / 450
g g > 5 g 268 / 7245 259 / 6813

How it works

  1. Seed rule in generation. Unrolling a quartic vertex always yields two cubic vertices sharing the line that replaced it, so a diagram is reachable that way exactly when two of its cubic vertices already share a line. reduce_leglist drops any combination putting two of them on the same line: 1 / 10 / 55 / 385 diagrams instead of 4 / 25 / 220 / 2485.
  2. Expansion. The seed is unrolled back to the full diagram set — same diagrams, same count, nothing user-visible moves — but each unrolled diagram keeps the decomposition of the seed it came from, so it is rooted like the diagram it must be summed with.
  3. The current sum, where the vertex reading it is an amplitude. Nothing sits above an amplitude, so no consumer can be handed a term it must not have.
  4. Canonical leg order, so the same current is never built two ways (see below).
  5. Slot recycling and ordering, so the sums come out of the same pool as the wavefunctions and each diagram lands where it keeps lifetimes short.

Bugs fixed on the way

Four, all pre-existing and none of them about this optimisation:

  • get_color_amplitudes dropped every merge source from the JAMPs, assuming the caller writes the amplitude sums to put them back. Only the Fortran writer does — so C++/madmatrix/python output with the flag set was silently losing four fifths of the amplitude. It now takes merge_quartic_amplitudes.
  • hel_recycle.add_indices could not index a statement-initial AMP( — the pattern ate the character in front of it, and there is none at the start of a line. Latent until something emitted AMP(31) = AMP(31) + AMP(1).
  • reuse_outdated_wavefunctions handed a second slot to a wavefunction listed by more than one diagram, leaking the first.
  • An antisymmetric current merged by equality. VVV1P0_1(a,b) = −VVV1P0_1(b,a) (measured), but HelasWavefunction.__eq__ compares mothers by sorted number and calls them equal, and export_cpp renumbers wavefunctions by that equality — so the two landed on one slot inside one matrix element and one silently carried the wrong sign. Sidestepped here by building the unrolled vertices with their legs in a canonical order, so the pair never arises; the underlying __eq__/export_cpp mismatch is still there for anything else that produces both orders.

Validation

  • |M|² bit-identical at four gluons, 1e-16 at five, 1e-14 at six and seven, in both backends and for all three option values; unchanged for g g > t t~ g g and u u~ > g g g.
  • With the option unset, generated code byte-identical to main for N=2..5 in both backends.
  • madevent end to end: g g > g g g, 10000 events, three seeds — cross sections agree. The channel weights now see all 510 amplitude pieces instead of 105, because get_amp2_lines skips any diagram with a four point vertex. Measured effect on the integration: none either way — every difference is under two sigma once the seed-to-seed scatter is accounted for, and generate_events wall time swings by a factor four between seeds for the same configuration, so a fixed-work survey comparison over six seeds was added.
  • 916 unit tests including 16 new ones; the two failures are pre-existing on main (a test-ordering artefact and a missing scipy).

Not done

  • The sum is only taken where the vertex reading it is an amplitude. Deeper it is blocked by counting: expanding the seed makes 340 (seed, choice) instances for the 220 diagrams at six gluons, so a diagram is reached from several seeds, and carrying one decomposition it can be rooted to match at most one of its quartic partners.
  • madmatrix has no amplitude-level folds — there is no AMP array to fold into — which is why five gluons still loses there.
  • No GPU measurement, so slots -- and therefore the auto default -- is unproven.

Full write-up, including the pitfalls and the measurements that closed off the alternatives, in docs/gluon-quartic-plan.md.

🤖 Generated with Claude Code

oliviermattelaer and others added 22 commits August 5, 2026 00:02
Each colour structure of the four gluon vertex carries exactly the same
colour factor as the diagram obtained by splitting that vertex into two
cubic ones, so the two can be summed before the colour algebra is applied.
Verified against the colour algebra for g g > N g, N=2..5: 3/30/405/6300
links, every one landing on a single cubic diagram, and the merged count
matching (2n-5)!! throughout.

Generation now runs with the quartic vertex replaced by two cubic vertices
joined by an auxiliary line, and puts them back together afterwards. Since
the from_group rule picks the canonical decomposition from the topology
alone, never from which particle sits on a line, the diagram carrying the
auxiliary line is rooted exactly like the cubic one it has to be summed
with, and shares every current except that line. Partner coverage goes from
30/60 to 405/405 at six gluons.

The recovered vertex only carries the colour structure the two cubic
vertices reproduce, which Vertex now records. Which structure that is
depends on the order ALOHA receives the legs, so it is settled against
sorted_mothers rather than on the generated diagram.

  g g > g g g    33 -> 15 wavefunctions, ~1.42x
  g g > g g g g  111 -> 81 wavefunctions, ~1.12x

The quartic amplitudes are also summed into their cubic partner, which
shrinks the JAMP block from 1091 to 697 lines at six gluons. That one costs
nothing at runtime: the JAMP optimiser was already finding those pairs.

|M|^2 is unchanged, to the last bit at four and five gluons and to one ulp
at six where the summation order differs. All of it is behind
MG_MERGE_QUARTIC, off by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_quartic_wavefunction_merges looks for a quartic current and the cubic
current it shares a colour factor with sitting at the same node, so that the
sum can be done once on the current instead of on every amplitude it feeds.
Both carry the same 1/P^2, so the sum itself is exact.

It finds nothing, and that is the correct answer rather than a missing case.
At six gluons there are 90 candidate pairs, but the two currents do not have
matching consumers: quartic current 11 is used by amplitudes 7,9,36,38,60,62
whose images under the merge map are 1,31,55, while its cubic partner 9 is
used by 1,5,31,34,55,58. Summing would hand the contribution to all six
consumers of the partner while only three have an amplitude being dropped,
so 5,34,58 would silently gain a term. The extra consumers are the diagrams
whose own last vertex is quartic, which take their contribution from a
different node.

Attaching the contribution to the node itself, so that every consumer is
entitled to it, needs the auxiliary current to be materialised rather than
collapsed back into VVVVxP0_1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitting the quartic vertex into two cubic ones at generation time gave one
diagram per colour structure: g g > 4g went from 220 diagrams to 510, and
generation from 0.070s to 0.205s. The amplitude count was unchanged (510
either way) and so was |M|^2, but the diagram list is what the user sees, and
what drives the MadEvent multichannel, so fragmenting it is not a trade worth
making for the currents it shared.

Reverted: get_auxiliary_model, collapse_auxiliary_diagrams, the Vertex
'color_key'/'aux_pair' pinning and everything honouring it in colorize, in
the two colour expansion loops and in get_base_vertex. No auxiliary particle
is left anywhere.

Kept: unroll_quartic_vertices and the link map it builds, verified against
the colour algebra for g g > N g, N=2..5, together with the amplitude sums
which still emit 405 folds and still shrink the JAMP block from 1091 to 697
lines at six gluons.

g g > 4g is back to 220 diagrams in 0.063s, |M|^2 unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A diagram is a tree, and which of its vertices is written last decides which
internal lines become currents: everything on the far side of the final
vertex is built up as a wavefunction, while the final vertex only produces an
amplitude. Re-rooting therefore changes which currents exist without changing
the diagram, which is what will let a quartic current find the cubic current
carrying the same colour factor.

The vertices are first split into the external legs they hold and the
internal lines they share, since a leg number alone does not identify a line
-- a vertex reuses the smallest incoming number for the leg it produces. The
tree is then walked outwards from the new root, and the line pointing back at
it becomes each vertex's outgoing leg, flipped to the antiparticle where the
re-rooting reverses it.

Checked against UnrollDiagramTag for g g > N g, N=2..4: all 7, 65 and 755
possible rerootings give back the same diagram, none altered, none refused.
They are not no-ops either -- at six gluons a diagram reaches 2, 3 or 4
distinct sets of currents depending on where it is rooted.

Nothing calls this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Written so the work can be picked up in a clean session: what is established
by measurement, what is already committed, the five remaining steps, and the
seven pitfalls that cost time getting here.

The key result it rests on is the seed rule -- forbid two 3-gluon vertices
from sharing a line. Unrolling a quartic always produces two adjacent cubic
vertices, so a diagram is reachable iff it has an adjacent cubic pair to
contract back, which makes that seed necessary and sufficient. It reconstructs
the full diagram set exactly at 4, 5, 6 and 7 gluons from 25%, 40%, 25% and
15.5% of the diagrams.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unrolling a quartic vertex always yields two cubic vertices joined by the
line which replaced it, so a diagram can be put back that way exactly when
two of its cubic vertices already share a line. The diagrams which have no
such pair are the ones generation has to produce, and every other one is
reachable from them -- necessary and sufficient, hence exact coverage.

reduce_leglist now drops any combination which puts two of those cubic
vertices on the same line, tracking the lines they produce by leg number.
The closing vertex needs both cases: a real n->0 interaction takes its legs
as lines coming in, while the identity vertex states that its two legs are
the two ends of one line, which is how a 2->2 reduction ends -- missing that
one left g g > g g with its full four diagrams.

Measured against the full generation filtered by an independently written
adjacency detector, comparing the diagrams themselves and not just counts:

  g g > g g          4 -> 1
  g g > g g g       25 -> 10
  g g > g g g g    220 -> 55
  g g > 5 g       2485 -> 385
  g g > 6 g      34300 -> 4165
  g g > t t~ g g   123 -> 84

Behind MG_MERGE_QUARTIC, off by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seed generated in the previous commit stands for the whole set: every
diagram left out is one of its quartic vertices replaced by the pair of cubic
vertices one of their colour structures factorises into. Expanding it back
gives the baseline diagram list again, so nothing user-visible moves, and the
unrolled diagram keeps the decomposition of the seed it came from -- which is
the property the current sum needs, a quartic current and its cubic partner
sharing every other current.

Several seeds reach the same diagram, so the expansion dedups on the diagram
itself. That has to be done on the glued form: while the identity vertex is
still there the same diagram has several spellings, and a quartic vertex
sitting just in front of it is not yet the last one -- which is what decides
how ALOHA indexes its colour structures. Gluing it in right away, rather than
at the end of generate_diagrams, settles both. Without it the expansion
deduped nothing (40 diagrams for g g > g g g instead of 25) and the recorded
colour chains disagreed with the colour algebra on 8 links out of 30.

Expanding is confluent, so running the diagrams it produces through the same
treatment adds nothing to the set but does give the link for the quartic
vertices they have left, which is the whole map for free.

  process        diagrams (= baseline)  links     |M|^2 vs baseline
  g g > g g            4                    3     bit identical
  g g > g g g         25                   30     bit identical
  g g > g g g g      220                  405     1.5929925846563245e-04
                                                  vs ...3380e-04
  g g > 5 g         2485                 6300     6.6739867626784624e-07
                                                  vs ...4560e-07

Every link agrees, target for target, with the one the colour algebra gives
independently through unroll_quartic_vertices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A decay chain keeps its identity vertex rather than gluing it in, which is
what the expansion relies on to compare two diagrams and to index the colour
structures of a quartic vertex. Loop amplitudes were already excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Steps 1, 2, 3 and 5 are done; step 4 is not, and the reason is a counting
one rather than a bug. Expanding the seed makes 340 (seed, choice) instances
for the 220 diagrams at six gluons, so a diagram is reached from several
seeds, and since it carries one decomposition it can be rooted to match at
most one of its quartic partners -- while the current sum needs the match at
every node. Measured: of the 50 genuine current pairs at six gluons, none
pass the 1:1 consumer test, and none of the 135 at seven; only five gluons,
the size at which no diagram is reached twice, works.

The flag as it stands is worth +4.1% at seven gluons and -0.6% at six, and
nearly all of that is the amplitude sum shrinking the JAMP block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where a quartic current and the cubic current carrying the same colour factor
feed the same vertex, the two amplitudes they give differ by that one line
and by nothing else. Summing the currents once and calling the amplitude on
the sum gets both contributions out of a single call:

  W(20) = W(19)
  W(20)%W(:) = W(19)%W(:) + W(12)%W(:)
  CALL VVV1_0(W(4),W(5),W(20),GC_10,AMP(33))

instead of one VVV call for the cubic amplitude, one VVVV call for the
quartic one, and an addition. The sum is shared by every amplitude reading
it, so it pays for as many calls as it has users -- 60 amplitude calls for 30
sums at six gluons, 432 for 60 at seven.

Substituting several mothers of the same amplitude also produces the
amplitude with all of them substituted, so every subset has to be a merge
into that same target weighing the product of the coefficients; the
substitutions which do not pass are left as they were. That check is what
keeps the count honest, and it is why the two-substitution cases at seven
gluons are not taken: their other single is spelled with a different rooting
and is not the same amplitude object.

The slot reuse had to be told about it. reuse_outdated_wavefunctions works
out when a slot is free from the diagrams alone, and the sum is an extra read
it cannot see -- without that, the two currents were handed the same slot and
the line came out as W(11) + W(11).

  process        helas calls        JAMP lines     per-call time
  g g > g g g     94 ->  93 + 7      131 -> 101    34.88 -> 35.12 s
  g g > g g g g  637 -> 612 + 30    1082 -> 688    47.77 -> 46.02 s
  g g > 5 g     8159 ->7784 + 60   23672 -> 7864   42.16 -> 39.80 s

so +3.8% at six gluons and +5.6% at seven, where the seed reconstruction
alone had been worth -0.6% and +4.1%.

|M|^2 bit-identical at four and five gluons, 1e-15 at six and seven, and
unchanged for g g > t t~ g g and u u~ > g g g. With the flag off matrix.f is
byte-identical to before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 4 is done where the vertex reading the sum is an amplitude, which is
where it is safe: nothing sits above one, so no consumer can be handed a term
it must not have. Deeper it stays blocked for the counting reason already
written down.

Adds the slot-reuse trap as a pitfall -- a wavefunction number is not a slot,
and anything emitting an extra read has to extend the lifetime there -- and
corrects pitfall 7, which turns out to be wrong at seven gluons: the JAMP
block alone is worth +3.1% there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan claimed another 432 amplitude calls at seven gluons were waiting to
be picked up. They are not. Substituting two mothers of one amplitude also
produces the amplitude with both substituted, weighing the product of the two
coefficients, and the merge map has to agree -- but the sign from
diagram_colour_signature does not factorise over two contractions. Taking the
double's coefficient over the known single's, no merge source into the target
weighs what the missing single would have to weigh for 150 of the 432.

So the ceiling is at most 282, on top of the identification problem: all 432
have exactly one of the two singles present, the other being the same diagram
rooted differently. subset_is_merged already refuses these, which is what
keeps the current code sound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things had to change for the optimisation to survive the madevent path.

The sum is now written as CALL SUMW_1(W(a),W(b),W(c)) rather than as two
assignments. Helicity recycling rebuilds the whole DAG from the calls alone,
so a bare assignment was invisible to it: the summed current never entered
the graph and the amplitude reading it died on a KeyError. Written as a call
it is an ordinary internal wavefunction taking two mothers and every existing
mechanism handles it. sumw_1 and subw_1 go into aloha_functions.f; the
coefficient is restricted to +-1, which is all it has ever been.

hel_recycle.add_indices could not index an AMP( opening a statement -- the
pattern ate the character in front of it, and there is none at the start of
the line -- so the fold came out as "AMP(31) = AMP( K,31) + AMP( K,1)" and
would not compile. Looking at that character rather than eating it fixes it.
Latent until now, since nothing emitted a line starting on AMP(.

AMP2 needs no change and is left as it is: the fold lines run before it, so
the channel weight is the amplitude including the four gluon contribution,
which is what it should be. The quartic diagrams never had an AMP2 entry of
their own -- get_amp2_lines skips anything with a four point vertex -- so the
folded amplitudes are not referenced anywhere.

g g > g g g through madevent, 10000 events, three seeds each:

              cross section        rel. error   ME cpu
  flag off    3.680-3.694e+07 pb     0.326%      52.7 s
  flag on     3.684-3.694e+07 pb     0.300%      49.0 s

so 8% less error for 7% less cpu. Including the four gluon piece in AMP2
makes it a better channel weight, not a worse one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interesting part is not the matrix element speedup but the channel
weights: get_amp2_lines skips any diagram with a four point vertex, so with
the flag off four fifths of the amplitude at six gluons -- 405 of 510 pieces
-- entered no AMP2 at all. Folding puts each of them in the channel whose
colour factor it shares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four gluon contributions now reach AMP2 -- 510 amplitude pieces instead
of 105 at six gluons -- and the question was whether that helps or hurts the
phase space integration. It does neither, measurably.

generate_events wall time turned out to be a bad metric: the refine stage
adapts, and the same configuration swings by a factor four between seeds
(217 s to 877 s with the flag off at six gluons). Adding a survey only run --
the same fixed number of points both ways, so the error measures the channel
weights and nothing else -- and quoting standard errors over independent
seeds settles it:

                                     off - on   significance
  g g > g g g     error (3 seeds)      -8%        1.8 sigma
  g g > g g g     cpu   (3 seeds)      -7%        1.1 sigma
  g g > g g g g   survey error (6)    -10%        1.1 sigma
  g g > g g g g   full error   (4)     -5%        0.8 sigma
  g g > g g g g   full cpu     (4)     -1%        0.0 sigma

Every difference points the same way and none of them is established. Cross
sections agree throughout. Calling it either way would need tens of seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SUMW_1/SUBW_1 as C++ templates next to ALOHAOBJ in cpp_hel_amps_h.inc, and
the madmatrix helas call writer emits them the same way the Fortran one does
-- as soon as the later of the two currents is made -- and skips the
amplitudes they take away. They cannot use the INLINE macro: that is defined
by the ALOHA generated block further down the header.

The colour amplitudes had to be sorted out first, and this fixes a real bug
rather than adding a feature. get_color_amplitudes dropped every merge source
from the JAMPs, on the assumption that the caller writes out the amplitude
sums to put them back. Only the Fortran writer does that, so C++ and python
output with MG_MERGE_QUARTIC set was quietly losing four fifths of the
amplitude. It now takes merge_quartic_amplitudes, and a backend which writes
no sums keeps those amplitudes in the JAMPs, where their own colour
coefficients give the identical result -- the two carry the same colour
factor, which is the whole premise. So madmatrix gets the current sums, which
really do remove work, and leaves the rest alone.

One bug found on the way, in the shared writer: a wavefunction number can be
listed by more than one diagram in the madmatrix matrix element (two objects
for the same current with the mothers ordered differently), so the sum was
written twice -- 50 lines for 30 sums at six gluons. Harmless numerically,
both write the same value to the same slot, but wasted. Both writers now
write each sum once.

|M|^2, FPTYPE=d, against the same output without the flag:
  g g > g g g     1.8740711159594241e-02 vs ...317e-02
  g g > g g g g   1.5929925846563324e-04 vs ...478e-04
and byte-identical CPPProcess.cc with the flag off.

Speed is mixed, and worse than Fortran:

                  amp calls   nwf      evt/s (FPTYPE=d, sse4)
  g g > g g g     45 -> 38    12 -> 26   72950 -> 66800   -8.4%
  g g > g g g g  510 -> 450   51 -> 111   2702 ->  2726   +0.9%

The wavefunction array more than doubles, because the sums take a slot each
at the end and are never recycled, and there is no JAMP fold here to pay for
it. At five gluons that loses outright. Recycling the sum slots through
reuse_outdated_wavefunctions is the obvious next step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sums take a wavefunction slot each at the end and are never recycled,
which more than doubles NWAVEFUNCS. Fortran absorbs that because the JAMP
fold pays for it; madmatrix has no such fold and loses 8.4% at five gluons.
Recycling those slots is now the first item under where to go next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each sum used to get a wavefunction slot of its own at the end, never reused,
which more than doubled NWAVEFUNCS and was what made madmatrix lose 8.4% at
five gluons. A sum is an ordinary producer -- written as soon as the later of
its two currents is made, dead after the last amplitude reading it -- so it
can go through reuse_outdated_wavefunctions with everything else. It also
lets the cubic current die at the sum rather than at the amplitude, since the
amplitude no longer reads it.

                        NWAVEFUNCS
                        flag off   own slots   recycled
  g g > g g g              12         26          19
  g g > g g g g            51         91          66
  g g > g g g g (madmatrix) 51        111         86

One bug had to be fixed first, and it is not mine: the same wavefunction can
be listed by more than one diagram in the madmatrix matrix element (two
objects for the same current with the mothers ordered differently), and
reuse_outdated_wavefunctions handed it a slot again on the second listing.
The first one leaked, and once the sums shared the pool that was no longer
harmless -- g g > g g g g came out 0.2% wrong. A wavefunction now takes one
slot at its first appearance and keeps it until its last use. This changes
nothing with the flag off: matrix.f and CPPProcess.cc are byte-identical for
N=2..4 in both backends.

|M|^2 unchanged: bit-identical at four and five gluons, 1e-14 at six, and the
madevent run for g g > g g g gives the same cross section and error as before
(3.666e+07 +- 1.058e+05 pb, seed 33).

  per-call time      before   after
  fortran, 6 gluons  +3.8%    +3.9%     (the slot count was not the bottleneck)
  madmatrix, 5 gl.   -8.4%    -6.6%
  madmatrix, 6 gl.   +0.9%    +1.9%

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wavefunction store is what the optimisation costs and the JAMP block is
what it buys, so both are tabulated against the multiplicity. Four gluons is
a wash, five loses on madmatrix, six and seven win on both and the gain grows.
The relative memory cost falls the other way, from +58% at five gluons to +8%
at seven, which is why the trade turns positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UNITTEST_proc is what ./tests/test_manager.py leaves behind; two commits
picked it up through git add -A. Removed and added to .gitignore so it
cannot happen again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reuse_outdated_wavefunctions is a linear scan allocator over lifetimes in
emission order, so NWAVEFUNCS depends on the diagram order -- the wavefunction
set does not, being content addressed, but the slot count does. Emitting every
seed first, as the expansion does, is the worst case for it.

Placing each diagram at its last discovery rather than its first takes both
the locality and the sums: 27 slots instead of 66 at six gluons, below the 51
of the unoptimised code, with all 30 sums kept, and Fortran exact at N=2..5.
Not shipped because it makes g g > g g g g wrong in madmatrix, which has
duplicate wavefunction listings that a directly built matrix element does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

Follow-up on the diagram order (thanks @oliviermattelaer — the premise of my earlier answer was half wrong).

I claimed the wavefunction count was order-independent. That is true of the wavefunction set (content addressed, wavefunctions[wavefunctions.index(new_wf)]) but not of NWAVEFUNCS: reuse_outdated_wavefunctions is a linear-scan allocator over lifetimes taken in emission order. Emitting every seed first, as expand_seed_diagrams does, is close to the worst case for it.

Four orders built and run. A sum can only be formed when its quartic current is emitted before the target amplitude, which makes this a trade rather than a free win:

NWAVEFUNCS / sums off seeds first (shipped) by quartic count seed + own unrollings last discovery
g g > g g g 12 19 / 7 19 / 7 11 / 3 15 / 7
g g > g g g g 51 66 / 30 64 / 30 33 / 0 27 / 30
g g > 5 g 268 290 / 60 314 / 60 245 / 60 219 / 60

Emitting a seed followed by its own unrollings gives the locality but loses the sums — the fully-cubic target is usually claimed by an earlier seed, so the quartic current arrives after it. Placing each diagram at its last discovery instead of its first fixes that and takes both: all the sums, and a slot count below the flag-off baseline (27 vs 51 at six gluons, 219 vs 268 at seven). Fortran stays exact at N=2..5 and is marginally faster (+3.7% / +7.4%).

Not in this PR: it makes g g > g g g g come out wrong in madmatrix (2.43e-04 against 1.59e-04). The generated code has no read-before-write, and a matrix element built directly has no duplicate wavefunction listings — those appear only through the madmatrix exporter path, which is also what forced the allocated guard in the slot-recycling commit. So the reordering looks to be exposing something latent there rather than being wrong itself, but that needs understanding first.

Worth chasing: at six gluons it takes the wavefunction store from 6600 B to 2700 B, under the 5100 B of the unoptimised code, with every current sum kept. Written up in docs/gluon-quartic-plan.md.

g g > 5 g in madmatrix finally finished compiling: 41.75 -> 43.33 evt/s,
+3.8%, with nwf 268 -> 320 and |M|^2 agreeing to 1.6e-15. The generated
CPPProcess.cc was checked byte-identical against what the committed code
produces, since the build had been launched before the ordering experiments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

The missing row is in. g g > 5 g in madmatrix finished compiling (7245 amplitudes in one translation unit takes a while), so the speed table is now complete in both backends:

standalone (per call) madmatrix (evt/s)
g g > g g 11.00 → 11.04 s −0.4% 875150 → 878724 +0.4%
g g > g g g 34.90 → 34.99 s −0.3% 72359 → 66757 −7.7%
g g > g g g g 47.35 → 45.61 s +3.7% 2699 → 2784 +3.1%
g g > 5 g 43.05 → 39.98 s +7.1% 41.75 → 43.33 +3.8%

|M|² = 6.6739867626784836e-07 against 6.6739867626784825e-07, agreeing to 1.6e-15. Wavefunctions 268 → 320, amplitude calls 7245 → 6813, jamp lines 231850 → 218026.

So the shape is the same in both backends: a wash at four gluons, a loss at five in madmatrix, and a growing win from six on. Fortran gains roughly twice what madmatrix does at the same multiplicity, which is the amplitude-fold difference (madmatrix has no AMP array to fold into).

One caveat I checked rather than assumed: this build had been launched before the diagram-ordering experiments, so it could have been generated with an experimental order. I regenerated with the committed code and confirmed CPPProcess.cc is byte-identical, so the numbers do belong to what is in the PR.

oliviermattelaer and others added 2 commits August 5, 2026 15:56
…c one

is_unrolled_pair matched on the lines alone: same four coming in, same one
going out. A quartic vertex makes one current per colour structure and all
three take the same lines and make the same line, so the lines cannot tell
them apart -- only the pairing can, read against sorted_mothers. Nothing moves
on the shipped ordering (same 7/30/60 sums, same |M|^2 in both backends), but
it removes a class of mismatch which was only not biting by luck.

Also records where the reordering breaks madmatrix. Ruled out by measurement:
the reordering itself, slot corruption, coloramps.h, the
merge_quartic_amplitudes=False path, and the duplicate listings on their own.
What is left, from replaying both generated files symbolically: the folding is
ordering sensitive in madmatrix (40 of 450 amplitudes differ) and not in
Fortran (0 of 450). madmatrix carries two objects for the same current and
match_quartic_mothers compares mothers by number, so which copy an amplitude
holds decides which pairs are matched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rged by equality

Root cause, and it is a latent MG5 bug rather than one of this optimisation.

The reconstruction can build the same cubic current with its two mothers in
either order, and sorted_mothers leaves them alone because for two identical
gluons its key ties. VVV1P0_1 is antisymmetric under exchanging its two
inputs -- measured, VVV1P0_1(a,b) + VVV1P0_1(b,a) = 0 exactly -- so the two
objects are negatives of each other. HelasWavefunction.__eq__ compares
mothers by sorted number and calls them equal, and export_cpp renumbers
wavefunctions by that equality, so the two land on one slot inside a single
matrix element and one of them silently carries the wrong sign.

The Fortran writer never hits it because it does not renumber by equality.
With the committed diagram order the collisions happen not to matter; the
reordering moves them somewhere they do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

Root cause of the madmatrix breakage — found, and it is a latent MG5 bug rather than one of this optimisation.

I could not do the two steps in the order asked (fix the duplication, then apply the reordering), because the "duplication" turned out not to be a duplication at all. The chain:

  1. The reconstruction can build the same cubic current with its two mothers in either order — the colliding pairs are interaction 3, colour_key 0, mothers [7,3] against [3,7]. sorted_mothers leaves them alone: for two identical gluons its key ties and the sort is stable.
  2. VVV1P0_1 is antisymmetric under exchanging its two inputs. Measured, not inferred — with a fixed pair of wavefunctions, VVV1P0_1(a,b) + VVV1P0_1(b,a) = 0 exactly. So the two objects are negatives of each other and write out calls differing by a sign.
  3. HelasWavefunction.__eq__ compares mothers by sorted number and so calls them equal — its docstring says "the number for this wavefunction, the pdg code, and the interaction id are irrelevant".
  4. export_cpp.generate_process_files renumbers wavefunctions by that equality, to share them between matrix elements. The two therefore land on one number and one slot inside a single matrix element, and whichever is written last wins — with the wrong sign for the other.

The Fortran writer never hits this because it does not renumber by equality. With the committed diagram order the collisions happen not to matter; the last-discovery order moves them somewhere they do.

Ruled out along the way, each by measurement rather than argument: the reordering itself (exact with the sums off), slot corruption (no read-before-write, no sum aliasing its own input, nwf sufficient), coloramps.h, the merge_quartic_amplitudes=False path (the Fortran writer forced into madmatrix's semantics is exact), and the duplicate listings on their own.

I have not applied the reordering, because the fix is upstream of this PR and the direction is yours to choose:

  • make __eq__ compare mothers in order rather than sorted — arguably strictly safer, since for any vertex whose particles differ sorted_mothers fixes the order anyway, so only the identical-particle case changes. But it alters the wavefunction CSE everywhere and needs validating with the flag off across the suite;
  • or make the reconstruction produce only one of the two orders. Canonicalising the pair inside split_quartic_vertex is not sufficient — I tried it and the collision count stayed at 20, because the other copy can come from a vertex the reconstruction did not build.

Shipped in the meantime (65c457fd6): is_unrolled_pair now checks that the quartic's colour structure is the one separating the cubic's inner pair. It matched on the lines alone before, and all three colour structures of a quartic vertex share the same lines and the same outgoing line — that was only not biting by luck. No change to any result on the committed ordering.

oliviermattelaer and others added 2 commits August 5, 2026 20:01
Flag off, the branch before this work (3b3ed9e, amplitude merges only), and
today. The generated code at the time the pull request was opened is
byte-identical to today in both backends for N=2..5, the only code change
since being the colour structure check, so those are one column.

Records too that madmatrix had no usable numbers before this work:
get_color_amplitudes dropped every merge source unconditionally while only
the Fortran writer wrote the sums back, so any C++ output with the flag set
lost 405 of its 510 amplitudes at six gluons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reorder

The wavefunction count went *up* under the flag, which it should not have.
Measured at six gluons: 111 -> 146, and 20 of the 35 extra are the same
current built with its two mothers in the opposite order. sorted_mothers
leaves them alone, because for two identical gluons its key ties and the sort
is stable, so they stay two objects -- and being negatives of each other
(VVV1P0_1 is antisymmetric, measured: VVV1P0_1(a,b) + VVV1P0_1(b,a) = 0) they
cannot be shared. Taking the legs of both unrolled vertices in a canonical
order removes every one of them:

                        wavefunctions      of which order-flipped twins
  g g > g g g            33 -> 39                     0 -> 0
  g g > g g g g         111 -> 126 (was 146)         20 -> 0
  g g > 5 g             898 -> 925 (was 955)         30 -> 0

That also removes the reason the last-discovery diagram order could not be
used. Those twins are what HelasWavefunction.__eq__ calls equal while they
differ by a sign, and export_cpp renumbers wavefunctions by that equality, so
they landed on one slot inside one matrix element and one of them silently
carried the wrong sign. With no twins left there is nothing to collide, and
the order goes in: each diagram is placed at its last discovery, which puts
it after every seed which can reach it and therefore after every quartic
current which can be summed into it. All the sums survive.

  NWAVEFUNCS       off    before    now
  g g > g g g       12      19       19
  g g > g g g g     51      66       78
  g g > 5 g        268     290      259

  per call / evt/s          off        before      now
  fortran   g g > g g g g   47.77 s    45.61 s     45.25 s   +5.3%
  fortran   g g > 5 g       42.19 s    39.98 s     40.10 s   +4.9%
  madmatrix g g > g g g     74229      66757       67566     -9.0%
  madmatrix g g > g g g g    2651       2784        2880     +8.6%

|M|^2 stays exact: bit-identical at four gluons, 1e-16 at five, 1e-14 at six
and seven, in both backends. With the flag off matrix.f and CPPProcess.cc are
byte-identical to before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

You were right that the wavefunction count should not have gone up — chasing that fixed the madmatrix blocker as well.

At six gluons the flag took the wavefunctions from 111 to 146. Breaking that down: 20 of the 35 extra are the same current built with its two mothers in the opposite order. sorted_mothers leaves them alone (for two identical gluons its key ties and the sort is stable), so they stay two objects — and since VVV1P0_1 is antisymmetric they are negatives of each other and cannot be shared.

Taking the legs of both unrolled vertices in a canonical order removes every one:

wavefunctions order-flipped twins
g g > g g g 33 → 39 0
g g > g g g g 111 → 126 (was 146) 20 → 0
g g > 5 g 898 → 925 (was 955) 30 → 0

And those twins were exactly what broke madmatrix. They are what __eq__ calls equal while they differ by a sign, and export_cpp renumbers wavefunctions by that equality — so they landed on one slot inside one matrix element and one silently carried the wrong sign. With none left there is nothing to collide, so the last-discovery diagram order is now in, with every current sum surviving.

Where that leaves the numbers

standalone Fortran, slots / wavefunction calls + sums / amplitude calls:

flag off before this work at PR open now
g g > g g g 12 / 33 / 45 12 / 33 / 45 19 / 39+7 / 38 19 / 39+7 / 38
g g > g g g g 51 / 111 / 510 51 / 111 / 510 66 / 146+30 / 450 78 / 126+30 / 450
g g > 5 g 268 / 898 / 7245 268 / 898 / 7245 290 / 955+60 / 6813 259 / 925+60 / 6813

madmatrix, slots / amplitude calls — no "before" numbers, because with the flag set the C++ output was silently losing 405 of its 510 amplitudes at six gluons (get_color_amplitudes dropped every merge source and only the Fortran writer wrote them back; fixed in this PR):

flag off at PR open now
g g > g g g 12 / 45 19 / 38 19 / 38
g g > g g g g 51 / 510 86 / 450 78 / 450
g g > 5 g 268 / 7245 320 / 6813 259 / 6813

At seven gluons the slot count is now below the unoptimised build in both backends: 259 against 268.

Speed:

off at PR open now
fortran g g > g g g g 47.77 s 45.61 s 45.25 s (+5.3%)
fortran g g > 5 g 42.19 s 39.98 s 40.10 s (+4.9%)
madmatrix g g > g g g 74229 66757 67566 (−9.0%)
madmatrix g g > g g g g 2651 2784 2880 (+8.6%)

Six gluons roughly doubles in madmatrix (+3.1% → +8.6%) and improves in Fortran (+3.7% → +5.3%). Five gluons still loses in madmatrix — 7 sums against 7 saved amplitude calls does not cover the extra slots, and madmatrix has no amplitude folds to make up the difference.

|M|² stays exact: bit-identical at four gluons, 1e-16 at five, 1e-14 at six and seven, in both backends. With the flag off matrix.f and CPPProcess.cc are byte-identical to before. 913 unit tests, same two pre-existing failures.

Note the timings are taken in batches with about 1% of drift between them, so each column should be read against the off measured alongside it — that is why g g > 5 g reads +4.9% now against +7.1% earlier despite the faster absolute time.

oliviermattelaer and others added 6 commits August 5, 2026 21:27
Six orders measured. Reordering never changes which currents exist, only how
long each is alive, so the wavefunction count is the same throughout. The
shipped last-discovery order gives 19/78/259 and nothing beats it while
keeping the sums: reversing it gives 12/54/199, far the best, and loses every
sum, because it puts each target ahead of the quartic currents feeding it.

A register-pressure greedy under the unrolling precedence buys 19 -> 18 and
78 -> 76 and nothing at seven gluons, for an O(n^2) pass taking generation
from 0.88 s to 3.24 s there and minutes at eight gluons. Not kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MG_MERGE_QUARTIC becomes "set merge_quartic_vertices <value>", taking False
(the default), speed or slots. True is accepted as a synonym for speed. The
interface pushes it onto madgraph.merge_quartic_vertices, which is what the
generation and the exporters read; do_add syncs it as well, since the option
can also arrive from mg5_configuration.txt where the setter is not called.

It cannot be an output option. The diagram order is fixed while the diagrams
are generated, so by output time it is already too late.

The new mode is the one the gpu question asks for. The wavefunction store is
a stack frame on cpu but is per thread on gpu -- about 24 kB a thread at seven
gluons -- so which way the trade goes depends on the hardware:

  g g > 5 g   amplitude calls   slots   per thread
  off              7245          268      25.1 kB
  speed            6813          259      24.3 kB
  slots            7245          199      18.7 kB

6% more arithmetic for 23% less memory. 'slots' reverses the diagram order,
which is far the best on register pressure and puts each target ahead of the
quartic currents feeding it, so the current sums cannot be built -- the
amplitude merges, and the JAMP block they shrink, are kept.

Which one a gpu wants has not been measured: there is no device here.

|M|^2 checked for all three values at five and six gluons in both backends,
and with no 'set' at all the generated matrix.f is byte-identical to the
unoptimised one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge_quartic_vertices gains 'auto': generate in the 'speed' order and let
apply_quartic_diagram_order reverse it at output time, when the backend about
to be handed the matrix elements is known. Only possible because the two modes
differ in nothing but the order, and 'slots' is 'speed' reversed.

The choice is read off the *matrix element* exporter rather than the output
format. "output madevent --me_exporter=<gpu backend>" hands one
_curr_matrix_elements to both exporters in a single export_processes call, so
its two backends cannot carry different orders -- and that is the cudacpp
production path, the one case the option exists for. Keying on the format
would hand it the cpu order; keying on the me exporter gives it 'slots',
NWAVEFUNCS=54 rather than 78 at six gluons.

Two pre-existing behaviours had to be worked around, neither of them specific
to this option:

  - an export mutates the diagrams it is given, 345 of 757 vertex leg records
    on g g > g g g g, and reversing mutated diagrams gives an equivalent but
    differently numbered result. So the reordering starts from a copy taken
    before the first export. deepcopy of the amplitudes is not available --
    it drags the model along and trips the array.array assert in
    color_algebra.create_copy -- but the diagrams alone copy cleanly, 0.011 s
    at seven gluons.
  - an export also drops the marks saying the diagrams came from a seed, so
    whether an amplitude may be reordered is read before the first export
    rather than at the output which wants to reorder it.

Byte-identical checks: 'auto' + standalone reproduces a native 'speed'
generation, 'auto' + standalone_mg7 a native 'slots' one, "output madevent
--me_exporter=standalone_mg7" a native 'slots' madevent, and a session going
standalone -> standalone_mg7 -> standalone reproduces its first output for
the third. Processes the seed rule never applied to are untouched. 916 unit
tests, three of them new, same two pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Defaulting merge_quartic_vertices to 'auto' was tried and reverted. It breaks
NLO generation wherever the real emission has four gluons, p p > j j [QCD]
among them:

  born  g g > g g    real  g g > g g g
  link_rb_configs(born, real, 5, 4, 4)
    flag off -> [2, 5, 12]
    'auto'   -> FKSProcessError: could not link born diagram

The generation is not at fault: with the flag on, g g > g g g still has its 25
diagrams and the same tag set under both DiagramTag and UnrollDiagramTag.
link_rb_configs is order dependent by accident -- it deduplicates real_tags
but not good_diags, then walks the two in lockstep, so they only stay aligned
while the dedup drops nothing, and which representative of a duplicated tag
survives is decided by the diagram order.

Fixing that is an NLO change and nothing here validates NLO, so the default
stays False. Nine more unit tests encode the old diagram order and would have
to be re-based as well; they are listed in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Defaulting merge_quartic_vertices to 'auto' was tried and reverted. It is safe
on the paths it was built for -- UFO fortran, madmatrix, the python exporter,
each validated on |M|^2 -- but turning it on for everything found three
consumers reading the diagram or amplitude *structure* rather than the result.

  1. fks born/real linking. link_rb_configs finds the vertex splitting ij into
     i and j and takes it out; the unrolling re-roots the real diagrams and can
     put that pair in the closing vertex, where there is nothing to take out.
     p p > j j [QCD] raised FKSProcessError. FIXED here, by generating an NLO
     process with the merging off in FKSMultiProcess.__init__ -- so the option
     is now safe for an NLO user, not merely for the default. g g > g g [QCD]
     generates byte-identically with the option set and unset, where before it
     crashed.
  2. the legacy FortranHelasCallWriter. Only FortranUFOHelasCallWriter emits
     the amplitude folds which put the merged contributions back, so the
     MG4-style writer computes AMP(1..3) from GGGGXX and then leaves them out
     of the JAMPs -- silently wrong |M|^2, not a crash. NOT fixed: it wants
     merge_quartic_amplitudes=False the way export_cpp and export_python do,
     but get_JAMP_lines is on the exporter and does not know its writer.
  3. anything pinning the diagram order, which is cosmetic but wide.

So the default stays False and defaulting it on wants an audit of those
consumers rather than another round of patching outward.

Fixed independently of all that: link_rb_configs built real_tags deduplicated
but left good_diags as it was, then walked the two in lockstep, so they only
stayed aligned while the dedup dropped nothing. A no-op on every process in
the suite, but it made the result order dependent for no reason.

The loop helas sanity checker now knows a folded amplitude is left out of the
jamps on purpose; the fks, colorize and DiagramTag tests which read a diagram
by its position say so and pin the plain order.

916 unit tests, same two pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Looked for a fourth place reading the representation rather than the result.
Did not find one in the shipped tree, and the search is worth keeping because
it bounds what is left.

Every merged-JAMP consumer against every writer: get_color_amplitudes has four
call sites and three pass merge_quartic_amplitudes=False; only export_v4's
three get_JAMP_lines* take the merged default, and every fortran exporter
pairs with FortranUFOHelasCallWriter, which emits the folds. The base
get_amplitude_merge_lines returns [] and FortranHelasCallWriter does not
override get_matrix_element_calls, so it is the one writer which drops them --
selected exactly when _model_v4_path is set. No other combination reaches
merged JAMPs without folds.

A mechanical audit of the generated code -- for each AMP(n), written? read? --
run with the flag off as a control, over standalone, matchbox, madevent
grouped and not, a decay chain, helicity-recycled files, u u~ > g g g,
g g > t t~ g g, u u~ > u u~ g g and four to six gluons. Clean everywhere,
including the split order path, whose amp_orders does list folded amplitude
numbers but never reaches the code because the colour amplitudes no longer
mention them.

The two places which weight or group results are unaffected: p p > j j groups
into the same five directories with byte-identical configs.inc and
coloramps.inc, and find_symmetry keeps the same equivalence classes and
multiplicities, [3,3,3,6] at five gluons and [3,6,12,12,12,12,12,12,24] at
six, only the representative indices renumbering.

Residual risk of this class is two named things: import model_v4, and a plugin
supplying its own helas_exporter paired with export_v4's merged JAMPs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full sweep of g g > N g and g g > t t~ N g, off against speed and slots, on
generation time, source and object size, wavefunction slots, amplitude counts
and runtime. Timings are the minimum of five runs, which matters: a single run
carries about 3% of noise and most of the effects are smaller than that. The
floor of the method is 0.6%, measured on g g > t t~ where off and speed give a
byte-identical matrix.f and still time 3.975 against 4.000 us.

What the amplitude columns show, and neither was obvious before:

  - the AMP array never shrinks. It is declared COMPLEX*16 AMP(NGRAPHS) at the
    full diagram count whatever the mode, so all three builds allocate 113 kB
    at seven gluons and speed leaves 432 entries written by nobody. Only the W
    array ever gets smaller, and only under slots.
  - slots mode computes every amplitude, on all eight of its rows. With no
    current sums nothing is skipped, so it does the baseline's amplitude work
    plus the folds and buys only a shorter JAMP block and a shorter W array.
    That is why it is slower than off almost everywhere rather than a wash.

And what the optimisation is actually good for is generation time and code
size rather than speed: g g > 5 g generates in 19.4 s rather than 37.1 s, a
48% cut, with matrix.o going 5.8 MB to 3.6 MB. Runtime is a steady 5-8% above
six particles and nothing at all below.

|M|^2 agrees to 8.5e-15 or better on every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliviermattelaer

oliviermattelaer commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Full sweep of both series, all three modes, on speed / memory / generation time. This supersedes the standalone timings in the description, which were single runs on g g > N g only.

Re-measured after the AMP array was made recyclable (see the follow-up comment below) — the AMP column is now the entry count, not the amplitude count, and the two differ: at seven gluons slots runs 7245 amplitude calls through 946 entries.

Timings are the minimum of five runs of the shipped check driver looping SMATRIX. The minimum matters: a single run carries ~3% of noise and most of the effects here are smaller than that. The floor of the method is 0.6%, measured on g g > t t~, where off and speed produce a byte-identical matrix.f and still time 4.000 against 3.988 µs. |M|² agrees to 8.5e-15 or better on every row.

W slots is NWAVEFUNCS, the length of TYPE(ALOHA) W(NWAVEFUNCS) — not the number of wavefunctions computed, since reuse_outdated_wavefunctions frees an entry as soon as its last reader has run. One entry is 104 bytes, measured with storage_size; one AMP entry is 16 bytes.

g g > N g

process mode generate matrix.f matrix.o W slots W array AMP entries amps computed AMP array per call speed
g g > 2g off 1.8 s 27 kB 16 kB 5 0.5 kB 6 6 0.1 kB 5.48 us -
speed 1.5 s 27 kB 17 kB 5 0.5 kB 4 6 0.1 kB 5.52 us -1%
slots 1.6 s 27 kB 17 kB 5 0.5 kB 4 6 0.1 kB 5.55 us -1%
g g > 3g off 2.1 s 40 kB 28 kB 12 1.2 kB 45 45 0.7 kB 87.50 us -
speed 1.8 s 39 kB 29 kB 19 1.9 kB 24 38 0.4 kB 90.25 us -3%
slots 2.4 s 40 kB 29 kB 12 1.2 kB 16 45 0.2 kB 93.00 us -6%
g g > 4g off 2.8 s 181 kB 141 kB 51 5.2 kB 510 510 8.0 kB 2.37 ms -
speed 2.4 s 165 kB 146 kB 78 7.9 kB 316 450 4.9 kB 2.29 ms +4%
slots 2.4 s 168 kB 152 kB 54 5.5 kB 106 510 1.7 kB 2.41 ms -2%
g g > 5g off 35.2 s 3.3 MB 5.8 MB 268 27.2 kB 7245 7245 113.2 kB 141.00 ms -
speed 16.6 s 2.5 MB 3.4 MB 259 26.3 kB 5869 6813 91.7 kB 132.75 ms +6%
slots 18.4 s 2.5 MB 3.4 MB 199 20.2 kB 946 7245 14.8 kB 134.25 ms +5%

g g > t t~ N g

process mode generate matrix.f matrix.o W slots W array AMP entries amps computed AMP array per call speed
g g > t t~ off 1.5 s 26 kB 16 kB 5 0.5 kB 3 3 0.0 kB 4.00 us -
speed 1.5 s 26 kB 16 kB 5 0.5 kB 3 3 0.0 kB 3.99 us +0%
slots 2.0 s 26 kB 16 kB 5 0.5 kB 3 3 0.0 kB 4.09 us -2%
g g > t t~ g off 1.8 s 31 kB 20 kB 12 1.2 kB 18 18 0.3 kB 30.00 us -
speed 1.6 s 31 kB 20 kB 12 1.2 kB 15 15 0.2 kB 29.58 us +1%
slots 1.7 s 31 kB 21 kB 12 1.2 kB 15 18 0.2 kB 30.33 us -1%
g g > t t~ 2g off 2.6 s 68 kB 51 kB 26 2.6 kB 159 159 2.5 kB 377.00 us -
speed 2.4 s 65 kB 49 kB 35 3.6 kB 109 126 1.7 kB 343.00 us +9%
slots 2.4 s 66 kB 53 kB 29 2.9 kB 106 159 1.7 kB 391.00 us -4%
g g > t t~ 3g off 6.2 s 576 kB 479 kB 121 12.3 kB 1890 1890 29.5 kB 9.63 ms -
speed 4.9 s 463 kB 408 kB 213 21.6 kB 1159 1551 18.1 kB 8.97 ms +7%
slots 5.0 s 493 kB 466 kB 141 14.3 kB 946 1890 14.8 kB 9.93 ms -3%

What the columns show

The AMP array is recycled, and it is where slots wins. It used to be declared at the full diagram count in every mode — 113 kB at seven gluons, with speed leaving 432 entries written by nobody. Now slots runs 7245 amplitude calls through 946 entries at seven gluons, 14.8 kB rather than 113.2, while speed only reaches 5869 because of the order it emits them in.

slots mode computes every amplitudeamps computed equals the off-mode amplitude count on all its rows. With no current sums nothing is skipped, so it does the baseline's amplitude work plus the folds, buying a shorter JAMP block and much smaller arrays rather than less arithmetic. That is why it is slower than off on CPU almost everywhere. speed is the opposite — it skips amplitudes outright (450 of 510, 6813 of 7245, 1551 of 1890), which is where its 4–9% comes from, and pays in W slots (121 → 213 at t t~ 3g).

What it is actually good for

Generation time and code size, more than speed. g g > 5 g generates in 16.6 s rather than 35.2 s (−53%, reproducible: 385 seed diagrams unrolled beats 2485 generated), its matrix.o goes 5.8 → 3.4 MB and its matrix.f 3.3 → 2.5 MB. Runtime is 4–9% above six particles and nothing at all below. The t t~ series gains more than the pure gluon one at equal particle count: +9% at t t~ 2g against +4% at 4g. Peak RSS is flat except at seven gluons, because the wavefunction store is a stack frame and the code image dominates.

Full write-up in docs/gluon-quartic-plan.md, sections "Full sweep" and "Recycling the AMP array".

How far it generalises

Both series above are gluon-rich, so a third process was measured as a control: u u~ > z g g g g — seven legs like g g > t t~ 3g, but with a Z and a quark line, so most of its diagrams have no four-gluon vertex at all. (q is not a defined multiparticle, hence u u~.)

process mode generate matrix.f matrix.o W slots AMP entries amps computed per call speed total/call
g g > t t~ 3g off 6.2 s 576 kB 479 kB 121 1890 1890 9.63 ms 41.8 kB
speed 4.9 s 463 kB 408 kB 213 1159 1551 8.97 ms +7% 39.7 kB (−5%)
slots 5.0 s 493 kB 466 kB 141 946 1890 9.93 ms −3% 29.1 kB (−30%)
u u~ > z 4g off 2.9 s 135 kB 115 kB 76 516 516 1.58 ms 15.8 kB
speed 2.3 s 127 kB 110 kB 85 391 450 1.51 ms +4% 14.7 kB (−7%)
slots 2.3 s 132 kB 117 kB 84 384 516 1.65 ms −5% 14.5 kB (−8%)

The payoff tracks the quartic fraction, exactly. In slots mode the AMP entry count is total amplitudes − merge sources, to within one, on every process measured:

amplitudes merge sources AMP in slots saving
g g > 5 g 7245 6300 (87%) 946 −87%
g g > g g g g 510 405 (79%) 106 −79%
g g > t t~ 3g 1890 945 (50%) 946 −50%
u u~ > z 4g 516 132 (26%) 384 −26%

So u u~ > z 4g is the weakest case measured, and predictably — there is simply little to merge. Runtime follows at +4% rather than +7 to +9%, and the working set at −8% rather than −30 or −75%.

It is also the one process where slots does not reduce the wavefunctions either — 84 against off's 76, worse — breaking the pattern from the gluon-rich processes, where reversing the order always recovered them. There slots is the worst of the three: 5% slower than off and larger in W, for an AMP count it barely wins over speed (384 vs 391). speed still behaves: +4%, generation down 21%, smaller source and object — which is what the auto gate picks at seven legs anyway.

oliviermattelaer and others added 2 commits August 11, 2026 16:30
The sweep puts the turnover of the full merging at six external legs, and
shows it costing slots below that, so auto now gates on it: generate_diagrams
only takes the seed rule at madgraph.merge_quartic_min_legs legs or more, six
by measurement. speed and slots asked for by name stay unconditional, that
being the way to get the merging on a small process anyway.

What is left below the threshold is not nothing, which was worth measuring
rather than assuming. The amplitude merges do not need the seed rule -- they
come from the colour algebra through unroll_quartic_vertices -- so they still
apply, and they shrink the JAMP block without touching the wavefunctions:

  g g > g g g            slots   JAMP temporaries   per call
  off                      12          72            87.75 us
  auto (merges only)       12          42            84.25 us   +4.0%
  speed (full)             19          42            87.25 us   -1%

So below the threshold auto beats both: it keeps the JAMP fold, which is free,
and drops the reordering, which is what costs the seven extra slots. At four
and five legs elsewhere it is neutral rather than positive and never negative,
at an unchanged slot count. Above the threshold nothing moves -- auto at six
legs generates a byte-identical matrix.f to speed.

917 unit tests, one of them new, same two pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AMP was declared COMPLEX*16 AMP(NGRAPHS) at the full diagram count in every
mode, so seven gluons allocated 113 kB of it and speed left 432 entries
written by nobody. get_amplitude_slots now does for AMP what
reuse_outdated_wavefunctions does for W, and the enabling change is where the
merges are written: they used to come out in one block at the end, which kept
every source alive to the end, and each is now written as soon as both of its
amplitudes exist, which frees the source.

  process          mode     AMP entries        total per call
  g g > g g g g    off        510                13.1 kB
                   speed      316                12.9 kB   -2%
                   slots      106                 7.1 kB  -46%
  g g > 5 g        off       7245               140.4 kB
                   speed     5869               118.0 kB  -16%
                   slots      946                35.0 kB  -75%

slots reaches the floor and speed does not, and that is the diagram order
rather than the allocator. Only (2n-5)!! amplitudes are read by the JAMPs, 945
at seven gluons, and the rest are merge sources which could share a handful of
entries. speed emits every seed before its unrollings, so a source is born
early and its target arrives late and nothing can be reclaimed in between.
Reversing that puts each source beside its target, so slots lands one above
the floor. That is worth a lot to the case slots exists for: it used to buy
23% of the wavefunction store, and now buys 75% of the whole per-thread
working set.

It buys no time -- 2247 -> 2260 us on speed and 2347 -> 2373 on slots at six
gluons, both inside the 0.6% floor. The arrays were already cache resident at
this size. This is a memory change, not a speed one.

NGRAPHS only ever dimensioned AMP inside matrix.f so it becomes the entry
count, with ngraphs.inc keeping the diagram count. The JAMPs go through
map_color_amplitudes and AMP2 through get_amplitude_slot_map; AMP2 was the one
worth checking, since multichannel reads individual amplitudes, and it reads
only merge targets, which are the entries that stay put. Verified on a
madevent output at six gluons: 316 written, 316 read, no entry read that is
never written.

|M|^2 unchanged on every row. 918 unit tests, one new, same two pre-existing
failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

Two follow-ups from the sweep: a multiplicity gate on auto, and the AMP array is now recycled.

auto gates on the multiplicity

The sweep puts the turnover of the full merging at six external legs, so auto now only takes the seed rule at merge_quartic_min_legs (6) or above. speed and slots asked for by name stay unconditional.

What is left below the threshold is not nothing, and it was worth measuring rather than assuming: the amplitude merges do not need the seed rule — they come from the colour algebra through unroll_quartic_vertices — so they still apply, and they shrink the JAMP block without touching the wavefunctions.

g g > g g g slots JAMP temporaries per call
off 12 72 87.75 µs
auto (merges only) 12 42 84.25 µs, +4.0%
speed (full) 19 42 87.25 µs, −1%

So below the threshold auto beats both — it keeps the JAMP fold, which is free, and drops the reordering, which is what costs the seven extra slots. At four and five legs elsewhere it is neutral and never negative, at an unchanged slot count. At six legs and above auto generates a byte-identical matrix.f to speed.

The AMP array is recycled

AMP was declared COMPLEX*16 AMP(NGRAPHS) at the full diagram count in every mode — 113 kB at seven gluons, with speed leaving 432 entries written by nobody. get_amplitude_slots now does for AMP what reuse_outdated_wavefunctions does for W. The enabling change is where the merges are written: they used to be emitted in one block at the very end, keeping every source alive to the end, and each is now written as soon as both of its amplitudes exist.

process mode AMP entries AMP W slots W total per call
g g > g g g g off 510 8.0 kB 51 5.2 kB 13.1 kB
speed 316 4.9 kB 78 7.9 kB 12.9 kB (−2%)
slots 106 1.7 kB 54 5.5 kB 7.1 kB (−46%)
g g > t t~ g g off 159 2.5 kB 26 2.6 kB 5.1 kB
speed 109 1.7 kB 35 3.6 kB 5.3 kB (+3%)
slots 106 1.7 kB 29 2.9 kB 4.6 kB (−10%)
g g > 5 g off 7245 113.2 kB 268 27.2 kB 140.4 kB
speed 5869 91.7 kB 259 26.3 kB 118.0 kB (−16%)
slots 946 14.8 kB 199 20.2 kB 35.0 kB (−75%)

slots reaches the floor and speed does not, and the reason is the diagram order rather than the allocator. Only (2n−5)!! amplitudes are read by the JAMPs — 105 at six gluons, 945 at seven — and the rest are merge sources that could share a handful of entries. speed emits every seed before its unrollings, so a source is born early and its target arrives late and nothing can be reclaimed in between: 5869 rather than 945. Reversing that order puts each source beside its target, so slots lands one above the floor.

That changes the slots case substantially. It used to buy 23% of the wavefunction store for 6% more arithmetic; it now buys 75% of the whole per-call working set at seven gluons — which is the number that matters on a GPU, where this is per thread.

It buys no time, and I want to be clear about that: speed 2247 → 2260 µs and slots 2347 → 2373 µs across the change at six gluons, both inside the 0.6% floor and if anything marginally the wrong way. The arrays were already cache-resident at this size. This is a memory optimisation, not a speed one.

Correctness

NGRAPHS only ever dimensioned AMP inside matrix.f, so it becomes the entry count while ngraphs.inc keeps the diagram count. Everything reading AMP afterwards goes through the same map — the JAMPs via map_color_amplitudes, AMP2 via get_amplitude_slot_map. AMP2 was the one worth checking, since multichannel reads individual amplitudes: it reads only merge targets, which are exactly the entries that stay put. Verified on a madevent output at six gluons — 316 written, 316 read, nothing read that is never written.

|M|² unchanged on every row; 918 unit tests, two new, same two pre-existing failures.

oliviermattelaer and others added 2 commits August 11, 2026 17:20
The full sweep tables were taken before AMP was recycled, so their AMP columns
described an array sized at the full diagram count, and the paragraph reading
them off said the AMP array never shrinks. Both re-measured on the current
code, all 24 builds again, timings the minimum of five as before.

The AMP column is now the entry count rather than the amplitude count, and the
two are worth telling apart -- at seven gluons slots runs 7245 amplitude calls
through 946 entries.

  g g > 5 g     generate   matrix.o   W slots   AMP entries   per call
  off             35.2 s     5.8 MB      268        7245       141.0 ms
  speed           16.6 s     3.4 MB      259        5869       132.8 ms  +6%
  slots           18.4 s     3.4 MB      199         946       134.3 ms  +5%

Two cells were re-measured on their own because a single run had caught noise:
g g > t t~ g generation read 5.3 s against 1.8 s over three runs, and the
0.6% floor is now 4.000 against 3.988 us on the byte-identical pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both series in the sweep are gluon-rich, so u u~ > z g g g g was measured as a
control: seven legs like g g > t t~ 3g, but with a Z and a quark line, so most
of its diagrams have no four gluon vertex at all.

It is the weakest case measured, and predictably so. In slots mode the AMP
entry count turns out to be exactly total amplitudes minus merge sources, to
within one, on every process measured:

                     amplitudes   merge sources   AMP in slots   saving
  g g > 5 g             7245        6300 (87%)        946         -87%
  g g > g g g g          510         405 (79%)        106         -79%
  g g > t t~ 3g         1890         945 (50%)        946         -50%
  u u~ > z 4g            516         132 (26%)        384         -26%

So the payoff tracks the quartic fraction. Runtime follows at +4% rather than
+7 to +9%, and the working set at -8% rather than -30 or -75%.

It is also the one process where slots does not reduce the wavefunctions
either, 84 against off's 76, which breaks the pattern from the gluon-rich
processes where reversing the order always recovered them. There slots is the
worst of the three: 5% slower than off and larger in W, for an AMP count it
barely wins over speed. speed still behaves, +4% with generation down 21%,
which is what the auto gate picks at seven legs anyway.

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