Skip to content

ALOHA: merge multi-coupling routines, and make FD gauge routines self-contained - #58

Open
oliviermattelaer wants to merge 11 commits into
mainfrom
claude/loving-brattain-e38d35
Open

ALOHA: merge multi-coupling routines, and make FD gauge routines self-contained#58
oliviermattelaer wants to merge 11 commits into
mainfrom
claude/loving-brattain-e38d35

Conversation

@oliviermattelaer

Copy link
Copy Markdown
Contributor

Merged multi-coupling routines

ALOHAWriterForFortran.write_combined used to write FFV2_4_3 & co as a wrapper: one call per Lorentz structure into a <type>tmp, then a component-by-component accumulation. Such a routine has no expression of its own — no contracted temporaries, no propagator DENOM — which costs a call plus an accumulation loop per use, and leaves nothing for the helicity-recycling batching to hoist out of its loop.

The combined routine is now computed like any other one: its body is the sum of the structures, each multiplied by its own coupling, sharing the momenta, the propagator denominator and the contracted temporaries. This re-uses CombineRoutineBuilder, i.e. the mechanism the loop (explicit-combine) routines already go through, and the writing goes through the standard write() path. The wrapper stays as write_combined_wrapper for the cases where the merge is not possible, and is reachable with MG_ALOHA_COMBINE_WRAPPER for A/B checks.

madmatrix already merged these (explicit_combine=True in export_cpp), so this brings the two backends in line.

FD gauge

Two things made FD routines the exception, on both backends:

  • multiply_propagator_factor was called at the end of every offshell V/S routine, recomputing for each helicity the part that only depends on the momentum (the 5-momentum q, the gauge direction n, n.q);
  • structures acting on different spins — in FD gauge a leg is a massive vector in one structure and its Goldstone in the next — cannot share an ALOHA expression, so every FD combination was a wrapper (7 out of 7 for w+ w- > w+ w-).

Both are now written like a single-coupling routine: the propagator factor is emitted in the routine (q/n/n.q with the momenta, js1/js2 and the update after the components), and the mixed-spin combinations are assembled term by term, sharing the momenta, the declarations, the contracted temporaries and one propagator factor. define_gauge_dir stays a call: it is a pure function of the momentum and sits in the invariant part.

For madmatrix the scratch wavefunction disappears with the wrapper, so the FIXME: hack to avoid a bug in the FD code that fed aloha_obj_tmp/amp_tmp_fp at the call site goes away too. w+ w- > w+ w- in FD keeps 17 calls in its DHELAS (all DEFINE_GAUGE_DIR), down from 48; its HelAmps_sm.h goes from 75 scratch references and 34 inner calls to 0.

Three pre-existing madmatrix bugs found on the way

Independent of the above, in aloha/template_files/madmatrix/helas_fd.h:

  1. vxxxxx, scalar backends — the FD Goldstone component of a longitudinal massive vector had the wrong sign (+nsv*cI instead of -nsv*cI, against fortran vc%W(5) = -nsv*ci and the python and SIMD implementations). Every matrix element with a longitudinal massive vector was ~0.7% off with BACKEND=cppnone, which is also what cppauto falls back to without SIMD. FD u u~ > w+ w- went from 5.0762980332466311e-03 to 5.1093407366301505e-03, against 5.1093405664217962e-03 for the fortran standalone at the same phase-space point.
  2. vxxxxx, SIMD backends — the FD block built its vector constants as const fptype_sv one{1.}, which sets the first lane and zero-fills the others. Everything selected with it was right in lane 0 only; the other lanes got n = 0, nk = 0 and -vmass/nk = nan. check_sa matrix reads event 0, so single points looked fine, but a real multi-event run did not: MeanMatrixElemValue = ( nan +- 0 ) becomes ( 0.339313 +- 0.306598 ), and the multi-event mean now agrees between cppnone and cppsse4. The gauge direction there is now selected lane by lane with fpternary (dividing by the existing ppDENOM) instead of being combined arithmetically, since nan*0 is nan; and the |nhel|=1 choice is a plain if, nhel being a scalar.
  3. define_gauge_dir — the scalar and SIMD branches disagreed on n[3] when the propagator has no spatial momentum. n is a gauge direction, so no matrix element changes (verified), but the choice should not depend on the backend; the SIMD branch now follows fortran, python and the scalar branch.

Speed

Fortran standalone, best of 5 runs of ./check 1000 N:

process before after
p p > e- ve~ mu+ vm QCD=0 5.96 s 5.73 s +4.0%
u u~ > w+ w- 13.04 s 12.30 s +6.0%
e+ e- > e+ e- z 16.66 s 13.81 s +20.6%
g g > t t~ g (control, no combined routine) 2.99 s 2.99 s 0.0%
FD u u~ > w+ w- 2.15 s 1.54 s +39.6%
FD w+ w- > w+ w- 170.8 s 66.5 s 2.57x

madmatrix FD w+ w- > w+ w- (check_sa.exe perf): 24053 to 49487 evt/s, 2.06x. ALOHA generation costs 3-8% more on the processes measured.

Validation

  • Per routine, every component (20 pseudo-random inputs, matching and rejected flavour configurations): FFV2_4, FFV2_5, FFV1_2_4, FFS1_2, VVVV1_3_4 (3 couplings), plus the C1, P0 and P1N tags — max 1e-15 relative, i.e. the reassociation of the sum. Same check on the 48 FD routines of w+ w- > w+ w- and e+ e- > e+ e- z.
  • Matrix elements: 16 process/model configurations A/B'd through check_sa, worst 7e-15 — including p p > z j j (7 subprocesses, 30 flavour matrix elements), MSSM, heft and decay chains. FD: bit-identical for u u~ > w+ w-, w+ w- > w+ w-, u u~ > z z, e+ e- > e+ e- z, 1 ulp for e+ e- > w+ w- z.
  • madmatrix: bit-identical on the FD processes and byte-identical output for non-FD; both cppnone and cppsse4 built and compared, in matrix and perf mode; vxxxxx driven directly on a SIMD register holding a boson at rest next to a moving one, every lane finite and equal to the scalar backend.
  • NLO: p p > e+ e- [QCD] generates and compiles, including the quadruple-precision MP_FFV2_4_3; loop routines keep their own explicit-combine path.
  • Tests: test_madevent_ufo_aloha, test_madevent_ufo_aloha_merged, test_mg7_ufo_aloha, test_standalone_madevent_consistency (9), test_madevent_flavor_zud/zjj, test_madevent_merged_flavor_uq, test_single_qcd_order_mg7, test_group_subprocess_mg7 all green. The FFV2_4_3 reference text in check_aloha_file is updated to the merged routine.

Not covered: cppavx2/cpp512* and CUDA (no GPU here), and the plain ALOHAWriterForCPP (standalone_cpp) backend, which still emits the FD wrapper.

🤖 Generated with Claude Code

oliviermattelaer and others added 11 commits August 5, 2026 21:20
ALOHAWriterForFortran.write_combined used to write FFV2_4_3 & co as a
wrapper: one call per Lorentz structure into a <type>tmp, then a component
by component accumulation. The routine therefore had no expression of its
own -- no contracted temporaries, no propagator DENOM -- which costs one
call plus one accumulation loop per use, and leaves nothing for the
helicity-recycling batching to hoist out of its loop.

The combined routine is now computed like any other one: its body is the
sum of the structures, each multiplied by its own coupling, sharing the
momenta, the propagator denominator and the contracted temporaries. This
re-uses CombineRoutineBuilder, i.e. the mechanism the loop (explicit
combine) routines already go through, and the writing goes through the
standard write() path so the routine picks up whatever a single coupling
routine gets. The wrapper form stays as write_combined_wrapper, used when
the merge is not possible (loop routine, unknown Lorentz structure,
structures on different spins) and reachable with MG_ALOHA_COMBINE_WRAPPER
for A/B checks.

Two supporting fixes:
 - get_coupling_def handles several matrix couplings: each MCOUPi has its
   own PARTNER map, so the routine gives up only when none of them pairs
   the flavours, the first coupling fixes the outgoing flavour index and
   the others are dropped when they disagree with it. Output for a single
   coupling is unchanged.
 - CombineRoutineBuilder expands the formfactors of every structure it
   combines (only those of the first one were, and that expansion was then
   overwritten).

Validated per routine (every component, matching and rejected flavours,
FFV/FFS/VVVV groups, C1/P0/P1N tags: max 1e-15 relative, i.e. the
reassociation of the sum) and on |M|^2 for 16 process/model combinations
including p p > e- ve~ mu+ vm QCD=0, u u~ > w+ w- and p p > z j j.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In FD gauge an offshell V/S routine ended with
    CALL MULTIPLY_PROPAGATOR_FACTOR(V3, M3, V3)
so the wavefunction it returns was only finished by a helper. That call is
opaque: the helicity-recycling batching cannot look inside it, and the
helper recomputes for every helicity the part that only depends on the
momentum (the 5-momentum q, the gauge direction n from define_gauge_dir,
and n.q), even though all the copies of a batched slot share their
momentum.

The factor is now emitted by the writer, split at its natural seam:
 - get_fd_gauge_txt: q (its 5th component carries the mass) and n, written
   with the momenta, hence loop invariant;
 - get_fd_propagator_txt: js1, js2 and w -> w - q*js1 - n*js2, appended to
   the expression since it is what the routine builds.
define_gauge_dir stays a call: it is a pure function of the momentum (with
a branch that is not worth generating) and it sits in the invariant part.

The generated statements mirror the helper operation by operation, so the
result is bit identical: checked component by component (5 components,
momenta and flavour index, 20 pseudo-random inputs) on the 48 routines of
w+ w- > w+ w- and e+ e- > e+ e- z, and on |M|^2 for u u~ > w+ w-,
w+ w- > w+ w-, u u~ > z z, p p > w+ w-, e+ e- > e+ e- z and
e+ e- > w+ w- z. Nothing outside FD gauge changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In FD gauge a massive vector and its Goldstone are the same wavefunction
(components 1-4 and 5 of one 5-slot object), so the Lorentz structures of
a vertex see a given leg sometimes as V and sometimes as S: the
combinations are VVV1_VVS1_VSV2_VSS1, VVS1_VSS1_SVS1_SSS1,
VVVV2_SSSS1_VSVS1_VSSV1_SVVS1_SVSV1 ... Structures with different spins
can not share one ALOHA expression (a rank 1 and a rank 0 tensor do not
add), so every single FD combination was left on the wrapper form -- 7 out
of 7 for w+ w- > w+ w-.

They do not need one expression though: they read one wavefunction per leg
and write into one output, only under different names and into different
slots. They are now assembled into a single routine which shares the
momenta, the declarations, the contracted temporaries and the FD
propagator factor, each structure adding its own coupling times its own
expression into the slots it feeds.

What this needs from the writer, beyond the assembler itself:
 - rename_wf: a term is written with the name the assembled routine gives
   each leg, while the slot stays the one the structure's own spin picks
   (S1_1 -> V1 % W(5));
 - coup_name/combined_part: a term multiplies by its own COUPi and adds up
   into the output instead of assigning it. The 'is there a coupling to
   apply' test that used to compare coup_name to 'COUP' now compares it to
   1, so that COUP2 & co are not silently dropped.
The same TMP is often rebuilt by several structures (they share the
contraction cache): duplicate definitions are dropped.

w+ w- > w+ w- keeps 17 calls in its DHELAS, all of them the momentum-only
DEFINE_GAUGE_DIR, down from 48. Validated component by component against
the wrapper form (34 routines, 20 pseudo-random inputs: 4.4e-16, the
projector now applied once on the sum) and on |M|^2 (bit identical for
u u~ > w+ w-, w+ w- > w+ w-, u u~ > z z, e+ e- > e+ e- z; 1 ulp for
e+ e- > w+ w- z). Output outside FD gauge is byte identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merged multiple coupling routine has always been there for madmatrix:
export_cpp builds the ALOHA model with explicit_combine=True, so
CombineRoutineBuilder gives it one expression per combination and the
wrapper writer is never reached (a non-FD HelAmps_sm.h has 0 scratch
wavefunctions and 0 inner calls). FD gauge was the exception, for the same
two reasons the fortran output had:

 - multiply_propagator_factor was called at the end of every offshell V/S
   routine, recomputing the momentum-only part (the 5-momentum q, the
   gauge direction n from define_gauge_dir, n.q) for each helicity;
 - structures acting on different spins (a leg is a vector in one and its
   Goldstone in the next) can not share an ALOHA expression, so every FD
   combination was written as a wrapper: 4 to 6 calls, unrolled 5
   component accumulations, and an extra scratch wavefunction dragged
   through the signature and the call site.

Both are now written like the fortran ones: the propagator factor is
emitted in the routine (q/n/n.q with the momenta, js1/js2 and the update
after the components), and the mixed-spin combinations are assembled term
by term, sharing the momenta, the declarations, the contracted temporaries
and one propagator factor. The scratch wavefunction disappears with the
wrapper, so the "FIXME: hack to avoid a bug in the FD code" that fed
aloha_obj_tmp/amp_tmp_fp at the call site goes away too.

has_fd_propagator and rename_wf move to WriteALOHA (they are language
independent) and each assembled term gets its own denom, since the C++
writer declares it const.

w+ w- > w+ w- in FD gauge: 75 scratch references and 34 inner calls become
0. |M|^2 from check_sa.exe is bit identical for FD u u~ > w+ w-,
w+ w- > w+ w- and e+ e- > w+ w- z, and the non-FD HelAmps_sm.h /
CPPProcess.cc are byte identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The inlined FD propagator factor wrote the 5th component of q as -cI * M,
which is a scalar complex: it converts to cxtype_sv only when the backend
is scalar. With BACKEND=cppsse4 (and any other SIMD backend) cxtype_sv is
cxtype_v and the build failed with

  no viable conversion from 'cxsmpl<double>' to 'cxtype_sv'

Build it the way multiply_propagator_factor does, from reals broadcast to
the vector type, which is also correct for the scalar backends.

cppnone output is unchanged (bit identical |M|^2 for FD u u~ > w+ w-,
w+ w- > w+ w- and e+ e- > w+ w- z), cppsse4 now builds and gives, for
those same three processes, exactly what the unmodified generator gives
under cppsse4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In FD gauge, the longitudinal polarisation of a massive vector carries a
5th (Goldstone) component. helas_fd.h builds it twice, once for the scalar
backends and once for the SIMD ones, and the two disagreed on its sign:

  #ifndef MGONGPU_CPPSIMD
    w[4] =  static_cast<fptype>(nsv)*cI;
  #else
    w[4] = -static_cast<fptype>(nsv)*cI;

The minus is the right one: fortran vxxxxx has vc%W(5) = -nsv*ci
(aloha_functions_fd.f) and so does the python one (wavefunctions.py).

So every |M|^2 with a longitudinal massive vector was wrong by ~0.7% with
BACKEND=cppnone -- which is also what cppauto falls back to when no SIMD
is available -- while cppsse4 was right. It is only visible in FD gauge
(no 5th component otherwise) and only for |nhel| != 1, which is why the
backends agree everywhere else.

For the same phase-space point as the fortran standalone,
FD u u~ > w+ w- now gives 5.1093407366301505e-03 with both cppnone and
cppsse4 (it gave 5.0762980332466311e-03 with cppnone before), against
5.1093405664217962e-03 for fortran -- the remaining 3e-8 is -ffast-math.
Same for w+ w- > w+ w- and e+ e- > w+ w- z; non-FD output is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
define_gauge_dir also builds n twice, and the two branches disagreed on
n[3] when the propagator has no spatial momentum: -sign(q0) for the scalar
backends, +sign(q0) for the SIMD ones. Everything else in the codebase
uses -sign(q0):

  fortran define_gauge_dir  n(3) = sign(-1.d0, dble(q(0)))
  python  define_gauge_dir  n[3] = sign(-1., q[0].real)
  helas_fd.h vxxxxx         n[3] = -sign(p0), in both of its branches

so the SIMD define_gauge_dir was the odd one out and now follows suit.

n is the gauge direction, so this changes no |M|^2: the branch is reached
(poisoning it gives nan for an s-channel propagator at rest, which is what
the check_sa phase-space point produces), but the summed amplitude is
invariant under it -- FD u u~ > w+ w-, w+ w- > w+ w- and e+ e- > w+ w- z
give bit identical results before and after, with both cppnone and
cppsse4. What it removes is a gauge choice that silently depended on the
backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The FD block of vxxxxx built its vector constants with

    const fptype_sv zero{0.};
    const fptype_sv one{1.};

A braced initialiser sets the first lane and zero fills the others, so
'one' was {1,0,...} instead of a broadcast. Everything the block selects
with it was therefore right in the first lane only: for every other lane
b_A and b_B were both 0, hence n = 0, nk = 0 and -vmass/nk = inf/nan.

check_sa's 'matrix' mode only reads the first event, which is why the
single point agreed with fortran; a real multi event run did not. For
FD u u~ > w+ w- with BACKEND=cppsse4:

    MeanMatrixElemValue = ( nan +- 0 )       -> ( 0.339313 +- 0.306598 )

and the multi event mean now agrees between cppnone and cppsse4 (checked
also on w+ w- > w+ w- and e+ e- > w+ w- z, where the two backends give the
same mean to the last printed digit).

Two more things in that block, while there:
 - the gauge direction is now selected lane by lane with fpternary, like
   define_gauge_dir does, instead of being combined arithmetically as
   nA*b_A + nB*b_B. The division is by ppDENOM (already defined above,
   =1 where pp==0): a select discards the value of the branch not taken,
   an arithmetic combination does not, since nan*0 is nan;
 - nhel is a scalar, so the choice between the |nhel|=1 and the
   longitudinal case is a plain if, not a cxternary on a bool_v built from
   a single value (which, again, would only have set the first lane).

Checked in situ with a driver calling vxxxxx on a register holding a boson
at rest next to a moving one: every lane is finite, and each lane matches
what the scalar backend gives for that momentum. The single point |M|^2 of
the three FD processes is unchanged, and non-FD output is byte identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_short_multiple_lorentz_subset checked FFV1_2C1_0 for the wrapper
form (a COMPLEX*16 TMP scratch and a call to each single coupling
routine). It is now one merged expression, so the check becomes: no call
at all, and both couplings in the same expression. The temporaries are
left out of the comparison, since their names depend on what the kernel
contracted before.

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

There was an FD gauge cross-backend test already
(test_standalone_cpp_fd_output_consistency), but it cannot fail on a wrong
value: its process gives matrix elements of 1e-15 down to 1e-21 while it
asserts with atol=1e-7, so the comparison is satisfied by any pair of
numbers, including 0 against anything. That is how three madmatrix FD bugs
reached the released templates.

Two numerical tests, both verified to fail on the bug they are meant to
hold (and to pass once it is fixed):

 - test_standalone_mg7_fd_vs_fortran: u u~ > w+ w- in FD gauge, madmatrix
   against the fortran standalone, per flavour, with a relative tolerance
   and no absolute floor. The W's give a longitudinal polarisation (hence
   the Goldstone component), the massless initial state makes both drivers
   evaluate the same RAMBO point, and |M|^2 is O(1e-3). It is run once per
   madmatrix backend, since helas_fd.h writes the FD wavefunctions once for
   the scalar types and once for the vector ones and which one a plain
   'make' builds depends on the host. The magnitude is asserted too, so the
   test cannot quietly become vacuous the way the existing one did.
   Catches the Goldstone sign bug: 0.005076 vs 0.005109.

 - test_standalone_mg7_fd_simd_lanes: the mean matrix element over a
   multi-event run must be the same for a scalar and for a vectorised
   build. 'matrix' mode reports the first event only, so it cannot see a
   routine that is right in the first lane and wrong in the others; 'perf'
   mode covers them all. Catches the lane-broadcast bug: 0.131645 vs
   0.131656 (it gave nan before the surrounding code used selects).

And two IO tests, one per backend, holding the FD output itself:
IOTestFDGauge covers the generated DHELAS routines (the verbatim template
copy aloha_functions.f is vetoed) and madmatrix's HelAmps_sm.h, which
carries both the generated routines and the FD helpers. This is what pins
down the third bug, the gauge direction that differed between the scalar
and the vector branch: n is a gauge choice, no matrix element can see it,
only a reference file can. Verified by reintroducing it: the madmatrix IO
test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FDgauge_standalone_fortran held the whole generated DHELAS, 13 files whose
content largely repeats: the FD specifics are the propagator factor written
into every offshell routine and the merged/assembled combinations. Keep
FFV6M_3.f alone -- it carries both, the inlined propagator factor and
FFV6_2M_3, a combination merged into one expression -- and drop the rest.

Checked that the reduced reference still moves for an edit to either half:
perturbing the fortran FD template and the inlined propagator emitted by the
writer both make it fail.

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