Skip to content

Interference in mg7: madevent helicity convention, IDWTUP -4, signed-weight fixes - #244

Merged
oliviermattelaer merged 18 commits into
mainfrom
claude/interference-computation-madmatrix-madspace-6423f4
Oct 10, 2026
Merged

oliviermattelaer merged 18 commits into
mainfrom
claude/interference-computation-madmatrix-madspace-6423f4

Conversation

@oliviermattelaer

@oliviermattelaer oliviermattelaer commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Phase 1 of doc/interference_madmatrix_madspace_plan.md.

Where things stood

  • madmatrix (CPU) has supported squared split orders since madmatrix: squared split orders, so the interference syntax stops returning the total #125.
  • madspace already integrates and unweights signed weights correctly: it keeps a signed and an |w| accumulator per channel, accepts on |w| and restores the sign with copysign.
  • Cross sections agree with madevent:
    • p p > u u~ QCD^2==2: −9481(84) vs −9532(63) pb;
    • p p > j j QCD^2==2: 5.283(87)e4 vs 5.263(53)e4 pb.
  • What was still wrong for an interference (negative total, signed per-helicity contributions) is fixed here.

Changes

madmatrix: helicity choice.

  • The per-event helicity was chosen on the signed running sum over helicities, divided by the signed total. With mixed signs that is not a distribution, so the helicity column of the events was wrong, even though the totals were right. For a Z/γ interference, the per-helicity σ was off by up to 16σ from madevent.
  • It now follows madevent: pick helicity i with probability |T_i|/Σ|T| and return sign(T_i)·Σ|T|.
  • This is compiled in only when nampso > 1, so every other process compiles exactly as before.
  • It is switched on only when the caller gives a random helicity number (umami, i.e. event generation). Systematics re-evaluation, check_sa and fbridge keep the helicity sum ΣT.
  • The GPU kernel gets the same rule; see the GPU section below.

Opt-out: interference_helicity. The run card [generation] interference_helicity = "exact" | "summed" controls the helicity choice; the default, "exact", is the madevent convention above.

  • "summed" keeps the helicity sum as the weight, which means lower variance; on the heft sample it brings σ_abs from 5.57 back to 4.16 pb.
  • The helicities of the interference subprocesses are then written as 9 on every LHE line.
  • It reaches the library as the umami parameter interference_helicity_summed, sent only when asked for, because older libraries refuse unknown parameters.

LHE <init>. Negative-weight samples now write IDWTUP = -4, as madevent does, with XMAXUP = σ_abs. Previously it was +3 with XMAXUP = σ. It has to be −4 and not −3: Pythia8 8.317 reads −3 as |XSECUP|·⟨sign⟩.

p p > u u~ QCD^2==2 σ (pb)
mg7 −9635(40)
Pythia8, IDWTUP −3 −6405
Pythia8, IDWTUP −4 −9678(75)

Positive samples are unchanged.

Smaller fixes:

  • systematics percentages are measured against |nominal|;
  • channel pruning ranks channels by the integral of |w|;
  • the MadNIS BufferUnweighter takes the quantile of |w| (see the buffer section below);
  • the relative error is printed against |mean|;
  • the weight histogram is in units of σ_abs, with a symmetric range for squared-order processes;
  • a plot with a negative bin keeps a linear axis;
  • the generic xsec acceptance test divides by |reference|, since a negative reference always passed.

Validation

  • Per-helicity values. On heft g g > t t~ HIG^2==1 (m_H = 500 GeV), at five fixed points with mixed-sign helicities, the per-helicity values drawn equal Fortran SMATRIXHEL to the grid step, with the same labels and signs.
    • The total cross section is unchanged: −0.405(16) vs −0.402(12) pb.
    • σ_abs goes from 4.16 to 5.57 pb.
  • Builds. The d, f, m and scalar builds all compile; the mixed-precision second page is checked too.
  • New test_cmd.py::test_standalone_interference_helicity_choice (u u~ > t t~ g QED^2==2). It drives the library through umami via ctypes, and fails with the old choice.
  • New test_mg7_interference.py (p p > u u~ QCD^2==2). It checks the cross section against −12253(10) pb, the <init> block and the signed events.
    • The reference comes from three mg7 seeds of 200k events.
    • madevent gives −12340(42) pb with the same settings, 0.7% / 2σ away. That is within the tolerance and was not chased.
  • Existing suites:
    • unit: banner, mg7_launch, plots, hwu, systematics_summary;
    • madspace pytest: 1706 passed, 11 skipped;
    • six positive mg7 xsec regression tests (ppjjj, ttx1j) pass.

MadNIS buffer

BufferUnweighter, the partial unweighting of the MadNIS replay buffer, accepts on |w| but took its maximum weight from the quantile of the signed weights.

Synthetic batches. It now has a Python binding and madspace/tests/test_buffer_unweighter.py. The table gives the fraction of events kept:

batch signed quantile (old) |w| quantile (new)
all positive 28.5% 28.5%
all negative 100% (no unweighting at all) 28.5%
mostly negative, short positive tail 94% < 50%

The all-negative and mixed tests fail on the old code. The buffer stayed unbiased either way, because the rescaled adaptive_prob holds for any maximum weight.

Effect on training. It is not measurable on our samples. With MadNIS forced on for 4000 batches, the buffers reach capacity within about 1200 batches either way:

process RSD, signed RSD, |w|
p p > u u~ QCD^2==2 0.1081 0.1085
heft g g > t t~ HIG^2==1 0.0886 0.0880

With the default card the buffer is never used: buffer_skip_batches equals train_batches, both 1000.

GPU (phase 4): validated on CUDA (manneback)

The GPU backend refused nampso > 1 at compile time. It now follows the CPU implementation:

  • calculate_jamps keeps njampso = ncolor × nampso jamps. DeviceAccessJamp and every device jamp buffer use njampso as the plane length, so nothing changes without split orders.
  • color_sum_kernel pairs the jamps over all ordered pairs of amplitude orders, as color_sum_cpu_splitorders does.
  • sqSoIndex and chosenSqso are generated __device__ constexpr, which is an empty macro on CPU.
  • The runtime cuBLAS colour sum is refused for split orders.
  • add_and_select_hel uses the madevent helicity convention, behind the same flag as on CPU.
  • GPU umami's helicity filtering now uses the per-helicity jamp buffer as scratch.

Validation on CUDA. Run 37917456030, on an NVIDIA A10 through the vmadgraph runner. This branch includes claude/vmadgraph-parallel-tasks-7fe363.

check result
u u~ > u u~ QED^2==2 −5.5828746494657217e−02, against Fortran −5.5828746494657265e−02 (8.7e−16)
QED=0+2+4 against QED^2<=4 1.6e−16
FPTYPE=m 8.5e−9
umami helicity choice 11 of 16 points with mixed signs, as on CPU
mg7 p p > u u~ QCD^2==2 −12248(109) pb, IDWTUP −4, 4191 of 5000 events negative

Pre-existing GPU bug, fixed here. The first run found it. A process whose couplings are all flavour couplings, with none depending on α_s, declared zero-sized coupling arrays, which nvcc refuses in device code. One example is u u~ > u u~ QED^2==4 with flavour grouping. This was not specific to split orders.

lemaitre4 (HIP) has not been run yet.

CI job. The new interference job of gpu_runner_ci.yml (.github/gpu_ci/interference_gpu.sh and interference_checks.py) runs on "Run workflow" on manneback and lemaitre4. It is off on pushes to main until validated. It checks:

  • standalone u u~ > u u~: the Fortran interference, the QED=0+2+4 sum rule, and FPTYPE=m;
  • the helicity choice through umami, using device memory;
  • mg7 p p > u u~ QCD^2==2 against the CPU reference.

On a CPU backend the script passes every check.

Not in this PR

  • α_s power of the kept squared orders (phase 2): today qcd_power = -1 forces the systematics to re-evaluate the matrix element.
  • [treextree] on mg7, which needs Interference processes: [LIxtree=QCD] and [treextree] left/right syntax #243.
  • Massive-leg helicities in mg7 are lab-frame helicities, since there is no me_frame boost on main, while madevent's are partonic-CM ones. This predates this PR.

With squared split orders that keep an interference (nampso > 1), a
helicity can contribute negatively. The event-by-event helicity choice
compared the random number with the signed running sum over the good
helicities, divided by the signed total: with mixed signs that is not a
distribution, and a negative total turns it upside down. The |M|^2 and
the cross sections were right, but the helicity written to the events
was not (Z/photon interference in mg7: per-helicity sigma off by up to
16 sigma from madevent).

Follow madevent (matrix_madevent_group_v4.inc) instead: pick helicity i
with probability |T_i| / sum|T| and return sign(T_i) * sum|T|, whose
average over the choice is sum T. select_helicity_signed in the cpu and
simd SigmaKin.cc, compiled in only when nampso > 1. It is switched on by
a new trailing sigmaKin argument (default false), which umami sets when
the caller gives a random helicity number, i.e. event generation; the
callers without one (systematics re-evaluation, check_sa, fbridge) keep
the helicity sum. The GPU backend still refuses nampso > 1.

Checked on heft g g > t t~ HIG^2==1 (mH = 500 GeV): the per-helicity
contributions drawn at five mixed-sign points equal Fortran SMATRIXHEL
to the grid step. New acceptance test
test_standalone_interference_helicity_choice drives the library through
umami (ctypes) on u u~ > t t~ g QED^2==2; it fails with the old choice.
madspace already integrates and unweights signed weights correctly; what
an interference (negative total) still got wrong around it:

- LHE <init>: IDWTUP was hard-coded to +3 and XMAXUP to the signed cross
  section. A sample with negative weights now declares IDWTUP = -4, as
  madevent does, and XMAXUP is the unit weight sigma_abs (launch.py and
  gridpack.py). -4, not -3: Pythia8 8.317 reads -3 as |XSECUP| times the
  mean sign (-6405 pb for a -9572 pb sample); with -4 the showered cross
  section is -9678(75) pb. Positive samples are unchanged.
- systematics_summary: percentages against |nominal| / |central| (the box
  printed "Scale variation: +-24.1% --30%").
- channel pruning (simplify_phasespace) ranks the channels by the integral
  of |w| rather than |signed integral|, and survives a zero total.
- MadNIS BufferUnweighter: max weight from the quantile of |w|.
- the relative error is printed against |mean|; the weight histogram is in
  units of sigma_abs, with a symmetric range for processes with a
  squared-order constraint; a plot with a negative bin keeps a linear axis.
- test_check_xsec_processes_mg7: relative difference against |reference|
  (a negative reference always passed).

New acceptance test test_mg7_interference.py: p p > u u~ QCD^2==2 against
-12253(10) pb (three mg7 seeds of 200k events; madevent -12340(42) pb),
the <init> block and the signed events.
Audit of the squared-order, [treextree] and [LIxtree] interference support in
madmatrix and madspace on main, the measurements, the defects found and the
phased plan (phase 1 done).
BufferUnweighter (the partial unweighting of the MadNIS replay buffer)
took its maximum weight from the quantile of the signed weights while it
accepts on |w|; f8d5c18 made it the quantile of |w|. Expose it to
Python and test it on synthetic batches, the event index carried along so
that the per-event relations are checked exactly: signs kept, weight
max(|w|, w_max), w_max the |w| quantile, adaptive_prob rescaled by the
acceptance probability times N / N_kept; the kept count and the estimator
of the mean weight to 5 sigma. With the signed quantile an all-negative
batch kept every event at its full weight and a mostly negative one 94%
(the positive equivalent keeps 28.5%): those two tests fail on it.

The buffer stayed unbiased either way, and on interference samples the
effect on the training is not measurable (MadNIS forced on, 4000 batches:
p p > u u~ QCD^2==2 RSD 0.1081 vs 0.1085, heft g g > t t~ HIG^2==1
0.0886 vs 0.0880); with the default card the buffer is not used at all
(buffer_skip_batches = train_batches = 1000).

The class comment described an event-file unweighter against a running
maximum; it now says what the class does.
… CI job

The GPU backend refused nampso > 1 at compile time: the device jamp buffers
held one ncolor vector per helicity, while the generated diagram code (shared
with the CPU backends) offsets each amplitude onto the vector of its
amplitude split order. Port the CPU implementation:

- calculate_jamps keeps njampso = ncolor * nampso jamps; DeviceAccessJamp, the
  device jamp buffers (MatrixElementKernelDevice, umami) and the memset use
  njampso as the length of the real and imaginary planes (== ncolor without
  split orders, so nothing changes for the other processes);
- color_sum_kernel pairs them as color_sum_cpu_splitorders does: all ORDERED
  pairs of amplitude orders, masked on sqSoIndex/chosenSqso, the color
  contraction triangular, in the color algebra precision;
- sqSoIndex and chosenSqso are generated __device__ constexpr (as icolamp; an
  empty macro on the CPU backends) so that the kernel can read them;
- the runtime cuBLAS color sum (CUDACPP_RUNTIME_BLASCOLORSUM) cannot pair
  split orders: refused with a warning, and asserted in color_sum_gpu;
- add_and_select_hel chooses the helicity of an interference as madevent
  does, behind the sigmaKin flag the CPU backends have (umami sets it when it
  is given a random helicity number);
- GPU umami filters the helicities in the per-helicity jamp buffer rather
  than the ncolor_flow one, which could hold at most 8 amplitude orders.

Not compiled here (no CUDA/HIP toolchain): .github/gpu_ci/interference_gpu.sh
and interference_checks.py add an `interference` job to gpu_runner_ci.yml,
on by default for "Run workflow" on manneback (CUDA) and lemaitre4 (HIP), off
on pushes to main until validated. It checks the standalone u u~ > u u~
squared-order components (the Fortran interference, the QED=0+2+4 sum rule,
FPTYPE=m), the helicity choice through umami on u u~ > t t~ g QED^2==2
(device memory through the CUDA/HIP runtime with ctypes) and mg7
p p > u u~ QCD^2==2 against the CPU reference. The script passes on a CPU
backend (--backend simd_128).
- start_runner runs on [self-hosted, vmadgraph, vmadgraph-<cluster>], so a run waiting
  in one cluster's Slurm queue does not block the other cluster
- ceci_ssh: the key-check error file was the fixed /tmp/keycheck, which another Unix
  user on the same host had created, so the check failed and was reported as a bad key
- README: start_runner runs on vmadgraph, not on GitHub-hosted
…the color sum

First GPU CI run of the split orders (manneback, NVIDIA A10): the interference,
the umami helicity choice and mg7 p p > u u~ QCD^2==2 passed, but
u u~ > u u~ QED^2==4 did not compile: with flavor grouping all its couplings
are flavor couplings (nIPC = 0) and none depends on alpha_s (ndcoup = 0), so
calculate_jamps declared allCOUPs[0] and COUPs[0], which nvcc refuses in device
code ("zero-sized variable ... is not allowed"; the host compilers take it as
an extension). Size them at least 1, as cIPD/cIPC already are. Not specific to
split orders: any such process failed to compile on the GPU backends.

color_sum_kernel: the one-order color sum is the else branch of the split-order
one rather than code after a return, which nvcc reported as unreachable
(warning #128) for the split-order processes.
…U mg7 check, no zero-sized coupling arrays on CPU

- test_standalone_interference_helicity_choice (and the GPU CI checker) loaded
  libmadmatrix_common with RTLD_GLOBAL before the process library. In the shared
  acceptance-test process that leaks this process's symbols into every library
  loaded afterwards (an in-process mg7 launch of another output). The process
  library finds its common library through its own loader-relative rpath: load
  it alone, and free the umami handle.
- The GPU interference job failed its mg7 check whenever the workflow's pdf_set
  input was not the reference's NNPDF23_lo_as_0130_qed. The check now always
  runs with the PDF set of its reference: interference_checks.py names it
  (--print-reference-pdf) and interference_gpu.sh downloads it into the cache;
  the job no longer reads PDF_SET.
- backend/{cpu,simd}/SigmaKin.cc: allCOUPs/COUPs sized at least 1, as the GPU
  backend now is. With nxcoup == 0 (u u~ > u u~ QED^2==4 with flavor grouping)
  they were zero-sized arrays, a compiler extension only.
…es 9 when summed

The madevent convention for the helicity of an interference (helicity i drawn
on |T_i|, weight sign(T_i) * sum|T|) gives exact helicities at the cost of a
larger sum|w|, about twice the events for the same precision. Make it a
choice:

- run card [generation] interference_helicity: "exact" (the default, as
  before) or "summed", which keeps the helicity sum as the weight;
- "summed" is passed to the matrix-element libraries as the umami parameter
  interference_helicity_summed (cpu, simd and gpu umami.cc, per instance),
  only when asked for: a library made before it refuses an unknown parameter;
- the helicities of those events then mean nothing: export_mg7 flags the
  subprocesses that keep an interference (squared split orders dropping a
  component, subprocesses.json "interference"), and launch.lhe_helicities
  gives the LHE completer a table of 9 for them, so every particle line has
  helicity 9 (also through npy_to_lhe and the gridpack, which reuse the
  completer). The run says so when it starts.

export_mg7 had no module logger, so its debug messages in the except
branches would have raised NameError: define it.

Tests: unit test_mg7_interference_helicity (card default, me_parameters,
lhe_helicities, round trip); the umami helicity test and the GPU CI checker
set the parameter and check that every draw returns the helicity sum;
acceptance test_interference_summed_helicity_mg7 (same cross section, every
helicity 9), and the exact run checks that its helicities are not 9.
… error)

First HIP run (lemaitre4, MI300A): every process with a single amplitude order
(p p > t t~, u u~ > u u~ QED^2==0) failed to link color_sum.o:

  lld: error: undefined hidden symbol: double& KernelAccessHelper<
    madmatrix::MemoryAccessMatrixElementsBase, true, double>::kernelAccessField<>(double*)

color_sum_kernel stored deltaMEs with E_ACCESS::kernelAccess in both branches of
the `if constexpr( nampso > 1 ) ... else`, and for nampso == 1 the first one, in
the discarded branch, is what clang/HIP saw first: the template was then never
instantiated for the device although the live branch uses it (nvcc is fine).
Accumulate deltaMEs in both branches and store it once, after them. The split
order builds (u u~ > t t~ g QED^2==2: the umami helicity checks) already passed
on HIP.
…rence run

interference_gpu.sh also runs interference_diagnose.py: the failing mg7 run with
serialized kernels and HIP logging, and the variants split orders with positive
weights, a single flavor, the summed helicity with a dynamical scale, and the CPU
of the node. Results as diag_* lines in summary.txt, log tails as diag_*.log.
…omputation-madmatrix-madspace-6423f4

# Conflicts:
#	.github/workflows/gpu_runner_ci.yml
Validated on both clusters (manneback CUDA run 37967270581, lemaitre4 HIP run
38026859945, the whole lemaitre4 allocation in 9 minutes, inside the 20 minute
limit of a push run). On "Run workflow" the input stays, ticked by default.
…omputation-madmatrix-madspace-6423f4

# Conflicts:
#	madgraph/iolibs/template_files/mg7/run_card.toml
#	madgraph/various/banner.py
@oliviermattelaer
oliviermattelaer merged commit 600a9aa into main Oct 10, 2026
177 checks passed
@oliviermattelaer
oliviermattelaer deleted the claude/interference-computation-madmatrix-madspace-6423f4 branch October 10, 2026 20:05
oliviermattelaer added a commit that referenced this pull request Oct 10, 2026
…alone

Since PR #244 (now in main) the madmatrix color sum pairs the amplitude split
orders on every backend, CPU and GPU, and madspace integrates and unweights
the signed weights of an interference. Allow 'output' (mg7) and 'output
standalone' for treextree; madevent and standalone_fortran stay, and LIxtree
still needs a loop backend (madevent, standalone_fortran). The madmatrix
message announcing CPU-only builds comes corrected with the merge of main.

Checked:
- u u~ > z > e+ e- [treextree] u u~ > a > e+ e-, C++ standalone built in
  double precision: equal to |Z+a|^2 - |Z|^2 - |a|^2 from the same code to
  2e-16, and to the Fortran value to 1e-15 (new acceptance test; the default
  mixed-precision build agrees to 7e-8 only);
- mg7, p p > e+ e-, m(ee) > 200 GeV: interference 0.0650(9) pb over four
  seeds, |Z+a|^2 - |Z|^2 - |a|^2 0.0649(9) pb; the Z+a, Z and a cross
  sections agree with madevent. madevent's interference comes out 7.5%
  lower (0.0601(17) pb, 2.6 sigma), its known iteration-combination bias on
  sign-changing integrands; same matrix element (agrees to 1e-15).

Full unit suite 2499 OK, interference acceptance tests OK.
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