diff --git a/.github/gpu_ci/README.md b/.github/gpu_ci/README.md index 8b59a68d20..ce4941ec22 100644 --- a/.github/gpu_ci/README.md +++ b/.github/gpu_ci/README.md @@ -24,18 +24,28 @@ allocation per workflow run, and every CI job of that run executes in it: | `pp_jj` | cluster | the same with `generate p p > j j` (`PROCESS`/`TAG` of `pp_ttx_mg7.sh`): its jet cuts act on several objects, which `p p > t t~` has none of | | `crossing_folding` | cluster | `p p > w+ j`, `p p > j j` and `p p > w+ j j` generated `--use_crossing=True`, each written folded and `--use_crossing=False` expanded, each run on the GPU and on cpu with one seed, then the cpu-made `p p > w+ j` gridpacks on the GPU (`crossing_folding_mg7.sh`): every run must agree with its counterpart (folded = expanded, GPU = cpu, gridpack = its run) | | `madspace_tests` | cluster | the madspace unit tests, `madspace/tests`, with `MADSPACE_TEST_DEVICE=cuda` or `hip` (`madspace_tests_gpu.sh`): the tests put their torch tensors on the GPU, so madspace runs them on its GPU runtime. Host-side tests (marker `cpu_only`) are skipped. pytest and scipy are installed in the cache when the modules lack them, and so is a CUDA torch wheel when the PyTorch module is a CPU build (manneback). The log and `junit.xml` are uploaded as an artifact | +| `acceptance` | cluster | the acceptance tests that need a GPU, through `tests/test_manager.py` (`acceptance_gpu.sh`): they skip without `nvcc`/`hipcc` or a device, so a skip fails the job. Now `test_standalone_umami_first_call_gpu` (the good helicities chosen at `umami_initialize`, with device buffers). To add a test, append it to `DEFAULT_TESTS` in `acceptance_gpu.sh` | | `interference` | cluster | squared split orders on the GPU backend of madmatrix (`interference_gpu.sh`, `interference_checks.py`): standalone `u u~ > u u~` constraints, the helicity choice of an interference matrix element through umami, with `check_sa.exe` (exact and summed), and `p p > u u~ QCD^2==2` with mg7 against its CPU reference. Input `interference`, on by default and on push | -| `madmatrix` | cluster | the GPU backend of madmatrix against its scalar CPU backend (`madmatrix_gpu.sh`, `madmatrix_checks.py`), with the `check_sa.exe` of each backend fed the same events from files: MSSM `p p > go go` event by event (running flavour couplings), a subprocess with no good helicity (`b b~ > ta+ ta- / z a`, ymtau = 0), and a non-blocking stream handed to umami (`g g > t t~ g`, `--nonblocking-stream`) | +| `madmatrix` | cluster | the GPU backend of madmatrix against its scalar CPU backend (`madmatrix_gpu.sh`, `madmatrix_checks.py`), with the `check_sa.exe` of each backend fed the same events from files: MSSM `p p > go go` event by event (running flavour couplings), MSSM `p p > go go j` over every crossing of the subprocess that folds them (extended flavour ids, helicities and colours included), the same for `u g > u z` with `u u~ > z g` (a massive leg crossed into a massless slot) for `p p > z j --axial_gauge=True` (a crossed axial-gauge gluon) and for `p p > j j` and `p p > t t~ j` at `QED^2==2` (a crossed interference, whose helicities are signed: helicity sum and signed draws), a subprocess with no good helicity (`b b~ > ta+ ta- / z a`, ymtau = 0) and |M|^2 = 0 with good helicities (`p p > w+ j` at alpha_s = 0, after a call at 0.118): no helicity or colour, and a non-blocking stream handed to umami (`g g > t t~ g`, `--nonblocking-stream`) | | `stop_runner` | cluster | clean up, then create the stop file: `runner_batch.sh` stops the runner and the allocation ends | | `watchdog` | vmadgraph | from the start of the runner to the end of the allocation: if the allocation ends before `stop_runner` completed, cancel the run (`ceci-gate watch-runner`, `remote_watch_runner.sh`) | +Without a GPU, `host_hip/` runs the madmatrix GPU code on the host: with `host_hip/bin` first on +the `PATH`, `make BACKEND=hip` builds the GPU templates as host C++ (its `hipcc` rewrites the kernel +launches, `include/hip/hip_runtime.h` runs every kernel serially with host memory as device +memory), so `madmatrix_checks.py --backend hip` compares the logic of the GPU code with the CPU +backend. The acceptance test `test_gpu_backend_host_emulation` does that for the crossing checks. +It is no substitute for an nvcc/hipcc build on a device. + To add a CI job, give it `needs: [start_runner, ...]`, `if: needs.start_runner.outputs.attempt == github.run_attempt` (see *Re-runs* below) and `runs-on: [self-hosted, "${{ inputs.runner_label }}"]`, and add it to the `needs` of `stop_runner`. It runs in the same allocation, after the others, so `time_limit` must cover all the jobs together. The first madspace build (OpenBLAS + GPU code) is the slow part. The default is kept short so that Slurm can backfill the allocation: -20 minutes on lemaitre4 (successful runs take 1-12 minutes) and 30 minutes on manneback -(usually 2-17 minutes, but the runner set-up and the checkout are sometimes slow there). +30 minutes on both clusters. On lemaitre4 a run takes about 19 minutes with the madspace +build cached (the madmatrix and acceptance jobs brought it there from 15), and a madspace +rebuild adds about 3. On manneback runs usually take 2-17 minutes, but the runner set-up and +the checkout are sometimes slow there. Raise it from the Actions tab for a run that needs more, or in the caller workflow when a new job makes every run longer. diff --git a/.github/gpu_ci/acceptance_gpu.sh b/.github/gpu_ci/acceptance_gpu.sh new file mode 100755 index 0000000000..91740d9500 --- /dev/null +++ b/.github/gpu_ci/acceptance_gpu.sh @@ -0,0 +1,74 @@ +#!/bin/bash -l +# Runs on the self-hosted runner (GPU node): job acceptance of gpu_runner_ci.yml. +# The acceptance tests that need a GPU, through tests/test_manager.py. Such a test skips +# without nvcc/hipcc or without a device, which on a GPU node is a failure (test_manager +# exits 1 on a skip, and summary.txt says which). +# Environment: as pp_ttx_mg7.sh (BACKEND, MODULES, GPU_ARCH, VENV, WORKDIR); no madspace +# and no PDF set needed (standalone outputs only). GPU_TESTS: the tests to run, space +# separated (test names, or test_manager expressions), by default the list below. +set -eo pipefail +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/../.." && pwd) +section() { echo; echo "=================== $* ($(date +%T))"; } + +# the good helicities chosen at umami_initialize, with device buffers +DEFAULT_TESTS="test_standalone_umami_first_call_gpu" + +section "Environment" +if [ -n "$MODULES" ]; then source "$HERE/load_modules.sh"; fi +source "$VENV/bin/activate" +python3 --version +case $BACKEND in + cuda) nvidia-smi + GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader | sed -n 1p) ;; + hip) rocm-smi || true + GPU_NAME=$(rocminfo 2> /dev/null | awk -F': *' '/Marketing Name/ {name=$2} /Device Type: *GPU/ {print name; exit}' || true) ;; + *) echo "::error::the GPU acceptance tests need a GPU backend, not '$BACKEND'"; exit 2 ;; +esac +export GPU_NAME +if [ -n "$GPU_ARCH" ]; then + export MADGRAPH_CUDA_ARCHITECTURE=$GPU_ARCH MADGRAPH_HIP_ARCHITECTURE=$GPU_ARCH +fi +# the tests call make without -j +export MAKEFLAGS="-j${SLURM_CPUS_PER_TASK:-4}" + +# default configuration, as .github/actions/checkout_mg5 does for the other CI jobs +cp "$REPO/input/.mg7_configuration_default.txt" "$REPO/input/mg7_configuration.txt" +cp "$REPO/Template/LO/Source/.make_opts" "$REPO/Template/LO/Source/make_opts" +cat >> "$REPO/input/mg7_configuration.txt" << EOF +auto_update = 0 +automatic_html_opening = False +notification_center = False +nb_core = ${SLURM_CPUS_PER_TASK:-4} +EOF + +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +cd "$WORKDIR" +STATUS=0 +: > summary.txt +for test in ${GPU_TESTS:-$DEFAULT_TESTS}; do + section "$test" + log=$WORKDIR/${test//[^A-Za-z0-9_.-]/_}.log + if (cd "$REPO" && python3 ./tests/test_manager.py -p A -t0 -v 2 "$test") 2>&1 | tee "$log"; then + result=ok + else + result=FAILED + STATUS=1 + fi + # unittest's own verdict ("Ran 1 test in 95.2s", "OK"), and why a test skipped: the + # verdict of test_manager's runner says OK for a skip + ran=$(grep -E '^Ran [0-9]+ tests? in ' "$log" | tail -1 || true) + verdict=$(grep -E '^(OK|FAILED)( \(.*\))?$' "$log" | tail -1 || true) + skipped=$(grep -oE '\.\.\. skipped .*' "$log" | sed 's/^\.\.\. //' | tail -1 || true) + echo "$test=$result (${ran:-no test ran}, ${verdict:-no verdict}${skipped:+, $skipped})" >> summary.txt +done +{ + echo "node=$(hostname)" + echo "gpu=${GPU_NAME:-none}" + echo "backend=$BACKEND" +} >> summary.txt + +section "Summary" +cat summary.txt +exit $STATUS diff --git a/.github/gpu_ci/host_hip/bin/hipcc b/.github/gpu_ci/host_hip/bin/hipcc new file mode 100755 index 0000000000..f38da41a7a --- /dev/null +++ b/.github/gpu_ci/host_hip/bin/hipcc @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""hipcc of the host emulation of HIP (see ../include/hip/hip_runtime.h). + +With this directory first on the PATH, 'make BACKEND=hip' in a madmatrix output +builds the GPU templates (backend/gpu) as host C++: a source is preprocessed, +its kernel launches kernel<<<...>>>( args ) are rewritten into calls of +host_hip::Launch, and the result is compiled by the host compiler ($CXX, else +c++). The GPU-only options of madmatrix.mk (--offload-arch, -target, -x hip) are +dropped. Only for checking the GPU code without a GPU toolchain. +""" + +import os +import re +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LAUNCH = re.compile(r'([A-Za-z_][\w:]*(?:<[^<>;]*>)?)\s*<<<(.*?)>>>', re.S) + + +def main(argv): + args, sources, out, compile_only = [], [], None, False + i = 0 + while i < len(argv): + arg = argv[i] + if arg.startswith('--offload-arch') or arg.startswith('-fgpu-rdc'): + pass + elif arg in ('-target', '-x'): + i += 1 # the language is C++ once the launches are rewritten + elif arg == '--shared': + args.append('-shared') + elif (sys.platform == 'darwin' and arg == '-Xlinker' and i + 3 < len(argv) + and argv[i + 1] == '-soname'): + args.append('-Wl,-install_name,@rpath/' + argv[i + 3]) + i += 3 + elif arg == '-o': + out = argv[i + 1] + i += 1 + elif arg == '-c': + compile_only = True + elif not arg.startswith('-') and re.search(r'\.(cc|cpp|cu)$', arg): + sources.append(arg) + else: + args.append(arg) + i += 1 + cxx = os.environ.get('CXX') or 'c++' + base = ['-D__HIPCC__', '-D__HIP_PLATFORM_AMD__', '-DMGONGPU_HAS_NO_HIPRAND', + '-I' + os.path.join(ROOT, 'include')] + if not compile_only: + return subprocess.call([cxx] + base + args + sources + (['-o', out] if out else [])) + (source,) = sources + pre = subprocess.run([cxx, '-E', '-x', 'c++'] + base + args + [source], + stdout=subprocess.PIPE, universal_newlines=True) + if pre.returncode: + return pre.returncode + text = LAUNCH.sub(r'host_hip::Launch(\2)(\1)', pre.stdout) + fd, expanded = tempfile.mkstemp(suffix='.ii') + try: + with os.fdopen(fd, 'w') as fsock: + fsock.write(text) + return subprocess.call([cxx, '-c', '-x', 'c++-cpp-output'] + base + args + + [expanded, '-o', out]) + finally: + os.unlink(expanded) + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/.github/gpu_ci/host_hip/bin/hipconfig b/.github/gpu_ci/host_hip/bin/hipconfig new file mode 100755 index 0000000000..3740424643 --- /dev/null +++ b/.github/gpu_ci/host_hip/bin/hipconfig @@ -0,0 +1,6 @@ +#!/bin/sh +# hipconfig of the host emulation of HIP: madmatrix.mk takes HIP_HOME from it +case "$1" in + --rocmpath|--path) cd "$(dirname "$0")/.." && pwd ;; + *) echo "host emulation of HIP (.github/gpu_ci/host_hip)" ;; +esac diff --git a/.github/gpu_ci/host_hip/include/hip/hip_runtime.h b/.github/gpu_ci/host_hip/include/hip/hip_runtime.h new file mode 100644 index 0000000000..3a97c10ed0 --- /dev/null +++ b/.github/gpu_ci/host_hip/include/hip/hip_runtime.h @@ -0,0 +1,172 @@ +// Serial host emulation of the HIP runtime, for the madmatrix GPU templates +// (backend/gpu) on a machine without a GPU toolchain: bin/hipcc compiles them as +// host C++ with this header, every kernel launch runs its grid x block threads one +// after the other on the host, and device memory is host memory. It checks the +// logic of the GPU code (indexing, crossing tables, good helicities, selection) +// against the CPU backends; it cannot replace a real nvcc/hipcc build and run. +// The GPU code has no __syncthreads and no shared memory, so a serial run of the +// threads is a valid schedule; atomicAdd is then a plain addition. +// Compiled with clang -x cuda (__CUDA__), the same header only declares the +// device attributes: a device-side semantic check of the sources. +#ifndef HOST_HIP_RUNTIME_H +#define HOST_HIP_RUNTIME_H 1 + +#include +#include +#include +#include + +#ifdef __CUDA__ +#define __device__ __attribute__( ( device ) ) +#define __global__ __attribute__( ( global ) ) +#define __host__ __attribute__( ( host ) ) +#define __constant__ __attribute__( ( constant ) ) +#define __shared__ __attribute__( ( shared ) ) +#define __forceinline__ __attribute__( ( always_inline ) ) inline +#include <__clang_cuda_builtin_vars.h> +#define HOST_HIP_HD __attribute__( ( host ) ) __attribute__( ( device ) ) +#else +#define __device__ +#define __global__ +#define __host__ +#define __constant__ +#define __shared__ +#define __forceinline__ inline +#define HOST_HIP_HD +#endif + +struct dim3 +{ + unsigned int x, y, z; + HOST_HIP_HD constexpr dim3( unsigned int x_ = 1, unsigned int y_ = 1, unsigned int z_ = 1 ) + : x( x_ ), y( y_ ), z( z_ ) {} +}; + +#ifdef __CUDA__ +extern "C" __host__ int cudaConfigureCall( dim3 grid, dim3 block, size_t shared = 0, void* stream = 0 ); +#else +inline dim3 threadIdx( 0, 0, 0 ), blockIdx( 0, 0, 0 ), blockDim( 1, 1, 1 ), gridDim( 1, 1, 1 ); +#endif + +typedef enum +{ + hipSuccess = 0, + hipErrorInvalidValue = 1 +} hipError_t; +typedef enum +{ + hipMemcpyHostToHost = 0, + hipMemcpyHostToDevice = 1, + hipMemcpyDeviceToHost = 2, + hipMemcpyDeviceToDevice = 3, + hipMemcpyDefault = 4 +} hipMemcpyKind; +typedef void* hipStream_t; +#define hipStreamNonBlocking 1 + +inline const char* hipGetErrorString( hipError_t e ) { return e == hipSuccess ? "hipSuccess" : "hipError"; } +inline hipError_t hipPeekAtLastError() { return hipSuccess; } +inline hipError_t hipGetLastError() { return hipSuccess; } +inline hipError_t hipSetDevice( int ) { return hipSuccess; } +inline hipError_t hipDeviceSynchronize() { return hipSuccess; } +inline hipError_t hipDeviceReset() { return hipSuccess; } + +// A fresh allocation is filled with 0xff bytes (NaN doubles, -1 ints), so that a +// read of memory the GPU code never wrote shows up instead of reading zeros +inline void* host_hip_alloc( size_t n ) +{ + void* p = nullptr; + if( posix_memalign( &p, 256, n ? n : 1 ) != 0 ) return nullptr; + memset( p, 0xff, n ); + return p; +} +template +inline hipError_t hipMalloc( T** p, size_t n ) { *p = (T*)host_hip_alloc( n ); return *p ? hipSuccess : hipErrorInvalidValue; } +template +inline hipError_t hipHostMalloc( T** p, size_t n, unsigned int = 0 ) { return hipMalloc( p, n ); } +template +inline hipError_t hipMallocAsync( T** p, size_t n, hipStream_t ) { return hipMalloc( p, n ); } +inline hipError_t hipFree( void* p ) { free( p ); return hipSuccess; } +inline hipError_t hipHostFree( void* p ) { free( p ); return hipSuccess; } +inline hipError_t hipFreeAsync( void* p, hipStream_t ) { free( p ); return hipSuccess; } +inline hipError_t hipMemcpy( void* d, const void* s, size_t n, hipMemcpyKind ) { memmove( d, s, n ); return hipSuccess; } +inline hipError_t hipMemcpyAsync( void* d, const void* s, size_t n, hipMemcpyKind, hipStream_t = nullptr ) { memmove( d, s, n ); return hipSuccess; } +inline hipError_t hipMemset( void* d, int v, size_t n ) { memset( d, v, n ); return hipSuccess; } +inline hipError_t hipMemsetAsync( void* d, int v, size_t n, hipStream_t = nullptr ) { memset( d, v, n ); return hipSuccess; } +template +inline hipError_t hipMemcpyToSymbol( T& sym, const void* s, size_t n, size_t off = 0, hipMemcpyKind = hipMemcpyHostToDevice ) +{ + if( off + n > sizeof( T ) ) + { + fprintf( stderr, "host hip: hipMemcpyToSymbol of %zu bytes at %zu into a %zu-byte symbol\n", n, off, sizeof( T ) ); + abort(); + } + memcpy( (char*)&sym + off, s, n ); + return hipSuccess; +} +template +inline hipError_t hipMemcpyFromSymbol( void* d, const T& sym, size_t n, size_t off = 0, hipMemcpyKind = hipMemcpyDeviceToHost ) +{ + if( off + n > sizeof( T ) ) + { + fprintf( stderr, "host hip: hipMemcpyFromSymbol of %zu bytes at %zu from a %zu-byte symbol\n", n, off, sizeof( T ) ); + abort(); + } + memcpy( d, (const char*)&sym + off, n ); + return hipSuccess; +} +template +inline hipError_t hipGetSymbolAddress( P** p, T& sym ) { *p = (P*)&sym; return hipSuccess; } +inline hipError_t hipStreamCreate( hipStream_t* s ) { *s = (hipStream_t)0x1; return hipSuccess; } +inline hipError_t hipStreamCreateWithFlags( hipStream_t* s, unsigned int ) { *s = (hipStream_t)0x2; return hipSuccess; } +inline hipError_t hipStreamDestroy( hipStream_t ) { return hipSuccess; } +inline hipError_t hipStreamSynchronize( hipStream_t ) { return hipSuccess; } + +template +HOST_HIP_HD inline T atomicAdd( T* a, T v ) +{ + T old = *a; + *a += v; + return old; +} + +#ifndef __CUDA__ +namespace host_hip +{ + // bin/hipcc rewrites kernel<<>>( args ) into + // host_hip::Launch( grid, block, shared, stream )( kernel )( args ) + struct Launch + { + dim3 grid, block; + Launch( dim3 g, dim3 b, size_t = 0, hipStream_t = nullptr ) : grid( g ), block( b ) {} + template + auto operator()( K kernel ) const + { + const dim3 g = grid, b = block; + return [=]( auto&&... args ) { + if( g.x * g.y * g.z == 0 || b.x * b.y * b.z == 0 ) + { + // a real GPU fails the launch (invalid configuration) + fprintf( stderr, "host hip: invalid launch configuration\n" ); + abort(); + } + gridDim = g; + blockDim = b; + for( unsigned bz = 0; bz < g.z; bz++ ) + for( unsigned by = 0; by < g.y; by++ ) + for( unsigned bx = 0; bx < g.x; bx++ ) + for( unsigned tz = 0; tz < b.z; tz++ ) + for( unsigned ty = 0; ty < b.y; ty++ ) + for( unsigned tx = 0; tx < b.x; tx++ ) + { + blockIdx = dim3( bx, by, bz ); + threadIdx = dim3( tx, ty, tz ); + kernel( args... ); + } + }; + } + }; +} +#endif + +#endif // HOST_HIP_RUNTIME_H diff --git a/.github/gpu_ci/interference_checks.py b/.github/gpu_ci/interference_checks.py index 3f8d491734..e41fdf2982 100755 --- a/.github/gpu_ci/interference_checks.py +++ b/.github/gpu_ci/interference_checks.py @@ -7,7 +7,7 @@ 1. standalone ``u u~ > u u~`` with QED^2==0, ==2, ==4 and <=4, FPTYPE=d, at the classic RAMBO point (1000 GeV) of check_sa.exe: - QED^2==2, the QCD x EW interference alone, is the Fortran split-order value - -5.5828746494657265e-02 (an unmasked colour sum would give the total, +2.776); + -5.5828747824035893e-02 (an unmasked colour sum would give the total, +2.776); - the three components add up to the QED^2<=4 total of the same build: this pins the pair loop of the colour sum (all ORDERED pairs of amplitude orders, not a triangle); and QED^2==2 again with FPTYPE=m (colour algebra in single precision). @@ -44,8 +44,9 @@ GPU_BACKENDS = ('cuda', 'hip') # u u~ > u u~ QED^2==2 at the check_sa RAMBO point, from the Fortran split-order -# standalone (madmatrix CPU: -5.5828746494657258e-02) -UUX_INTERFERENCE = -5.5828746494657265e-02 +# standalone (madmatrix CPU simd_128: -5.5828747824035928e-02); the t-channel Z has +# no width (zerowidth_tchannel, the default: -5.5828746494657265e-02 with it) +UUX_INTERFERENCE = -5.5828747824035893e-02 # mg7 p p > u u~ QCD^2==2, fixed scale 91.188 GeV, NNPDF23_lo_as_0130_qed, default cuts: # three CPU runs of 200k events (tests/acceptance_tests/test_mg7_interference.py) UUX_MG7_CROSS, UUX_MG7_ERROR = -12253., 10. diff --git a/.github/gpu_ci/madmatrix_checks.py b/.github/gpu_ci/madmatrix_checks.py index 797d9f78fc..5ea1d5e86c 100644 --- a/.github/gpu_ci/madmatrix_checks.py +++ b/.github/gpu_ci/madmatrix_checks.py @@ -17,18 +17,48 @@ bounds past the first event page), and scale as alpha_s^2 (--alphas 0.10 and 0.12). 2. zero_me: ``b b~ > ta+ ta- / z a`` (s-channel Higgs only): |M|^2 > 0 with the default param card, then the tau Yukawa coupling set to 0, so that no helicity survives the - helicity filtering: both backends return |M|^2 = 0 and no helicity (-1), and for the - colour either none (-1) or the only colour flow (0, what a -ffast-math build makes of - 0/0), never stale memory; the GPU used to abort on the invalid launch configuration + helicity filtering: both backends return |M|^2 = 0, no helicity and no colour (-1), + never stale memory; the GPU used to abort on the invalid launch configuration (gridDim.y = 0) of the helicity loop. -3. nonblocking_stream: ``g g > t t~ g``, 4 calls of 16384 events, with umami handed a + zero_me_good_hel: ``p p > w+ j``, the subprocess that folds the crossings + (P1_gQ_wpQ), with a good-helicity list: a first call at alpha_s = 0.118 (|M|^2 > 0, + a helicity and a colour for every extended flavour id), then a call at alpha_s = 0, + where |M|^2 = 0 exactly and no helicity or colour (-1) may be reported on either + backend: a -ffast-math build let the 0/0 of the selection pass and reported the first + good helicity (relabelled for the crossed ids) and the first colour flow. +3. mssm_crossed_gpu_vs_cpu: MSSM ``p p > go go j``, the subprocess that folds crossings + (P1_gQ_gogoQ: g q > go go q also serves q q~ > go go g, ...), with running flavour + couplings: events drawn over every extended flavour id K*nmaxflavor + flavour + (crossing-table row K), a random alpha_s and random numbers per event: the GPU |M|^2, + helicities and colours equal the CPU ones event by event (the crossed helicity code + of a moved leg included). + crossed_massive_gpu_vs_cpu: SM ``u g > u z`` and ``u u~ > z g`` in one generation, + folded into P1_ug_uz, whose crossing moves the Z into a massless slot (xonshell_tab 0): + umami_initialize chooses the good helicities of that crossing on points of its own + masses (choose_good_helicities, getGoodHel). The events of every row are on shell for + that row, in a randomly boosted frame: the GPU |M|^2, helicities and colours equal + the CPU ones event by event, so the GPU kept the same good-helicity list. + axial_crossed_gpu_vs_cpu: SM ``p p > z j --axial_gauge=True``, the subprocess that + folds crossings (P1_gQ_zQ): the gluon wavefunction is vxxxxxr in the axial gauge of + another external leg, whose momentum a crossed event reads from its own input slot + (xperm of the reference leg): the same comparison as above. + signed_crossed_gpu_vs_cpu: SM ``p p > j j QED^2==2`` and ``p p > t t~ j QED^2==2``, + the subprocesses that fold crossings (P1_QQ_QQ, P1_gQ_ttxQ), interferences of the QCD + and electroweak amplitudes (nampso 2) whose |M|^2 per helicity is signed: the same + comparison as the helicity sum and with one signed helicity per event (--rnd-hel, + negative |M|^2 and helicities of both signs included), on the events above the + rounding residue of the interferences that vanish (colour, flavour). +4. nonblocking_stream: ``g g > t t~ g``, 4 calls of 16384 events, with umami handed a non-blocking stream (--nonblocking-stream, as a torch side stream, which does not synchronise with the default stream) gives the same |M|^2 and helicities as on the default stream: every memset, kernel and copy of the matrix element must go to the stream umami is given, the helicity filtering of the first call included. Any CPU backend but scalar (simd_128, ...) works too, to try the script without a GPU; -check 3 is then skipped. Writes summary.txt (key=value lines) in the current directory; +check 4 is then skipped. Without a GPU, --backend hip with host_hip/bin first on the +PATH builds and runs the GPU code itself on the host (serial emulation of HIP, see +host_hip/include/hip/hip_runtime.h): the logic of the GPU templates against the CPU +backend, not a real GPU build. Writes summary.txt (key=value lines) in the current directory; exit status 1 if a check fails. """ @@ -46,7 +76,9 @@ GPU_BACKENDS = ('cuda', 'hip') CPU_BACKEND = 'scalar' -CHECKS = ('mssm_gpu_vs_cpu', 'zero_me', 'nonblocking_stream') +CHECKS = ('mssm_gpu_vs_cpu', 'zero_me', 'zero_me_good_hel', 'mssm_crossed_gpu_vs_cpu', + 'crossed_massive_gpu_vs_cpu', 'axial_crossed_gpu_vs_cpu', 'signed_crossed_gpu_vs_cpu', + 'nonblocking_stream') results = {} @@ -80,17 +112,66 @@ def read(path, typecode): return values -def output(repo, name, model, process, backends): - """output standalone + make for these backends in every subprocess, keeping the - check_sa.exe of each as check_sa_.exe: the subprocess directories.""" +def ncross(proc_dir): + """The number of rows of the crossing table of a subprocess (1: no crossing).""" + tables = open(os.path.join(proc_dir, 'ProcessTables.h')).read() + return int(re.search(r'constexpr int ncross = (\d+);', tables).group(1)) + + +def table(proc_dir, name): + """A host table of ProcessTables.h (xonshell_tab, xmass_slot_tab), as a list.""" + tables = open(os.path.join(proc_dir, 'ProcessTables.h')).read() + match = re.search(r'constexpr int %s\[[^]]*\] = \{([^}]*)\};' % name, tables) + if not match: + raise RuntimeError('no %s in %s/ProcessTables.h' % (name, proc_dir)) + return [int(v) for v in match.group(1).split(',')] + + +def boosted_two_to_two(rng, masses, energy): + """An on-shell 2 > 2 point, legs (E, px, py, pz): the beams along z in the partonic + rest frame at sqrt(s) = energy, then boosted along a random axis (|beta| < 0.9).""" + def pcm(m_a, m_b): + s = energy ** 2 + return math.sqrt(max((s - (m_a + m_b) ** 2) * (s - (m_a - m_b) ** 2), 0.)) / (2 * energy) + def leg(m, p): + return [math.sqrt(m * m + sum(c * c for c in p))] + p + p_in, p_out = pcm(masses[0], masses[1]), pcm(masses[2], masses[3]) + cos, phi = rng.uniform(-1, 1), rng.uniform(0, 2 * math.pi) + sin = math.sqrt(1 - cos * cos) + out = [p_out * sin * math.cos(phi), p_out * sin * math.sin(phi), p_out * cos] + legs = [leg(masses[0], [0., 0., p_in]), leg(masses[1], [0., 0., -p_in]), + leg(masses[2], out), leg(masses[3], [-c for c in out])] + cos, phi, beta = rng.uniform(-1, 1), rng.uniform(0, 2 * math.pi), rng.uniform(0, 0.9) + sin = math.sqrt(1 - cos * cos) + b = [beta * sin * math.cos(phi), beta * sin * math.sin(phi), beta * cos] + gamma = 1 / math.sqrt(1 - beta * beta) + boosted = [] + for e, *p in legs: + bp = sum(bi * pi for bi, pi in zip(b, p)) + boosted.append([gamma * (e + bp)] + + [pi + bi * (gamma * gamma / (gamma + 1) * bp + gamma * e) for bi, pi in zip(b, p)]) + return boosted + + +def output(repo, name, model, process, backends, select=None, defines=(), options=''): + """output standalone + make for these backends in every subprocess (those for which + select(proc_dir) is true, if given), keeping the check_sa.exe of each as + check_sa_.exe: the subprocess directories. defines: multiparticle + definitions ('b1 = g u~') made before the generation; options: of the output line.""" with open(name + '.mg5', 'w') as f: - f.write('import model %s\ngenerate %s\noutput standalone %s -f\n' % (model, process, name)) + f.write('import model %s\n' % model) + f.write(''.join('define %s\n' % define for define in defines)) + f.write('generate %s\noutput standalone %s %s -f\n' % (process, name, options)) if run([sys.executable, os.path.join(repo, 'bin', 'madgraph'), name + '.mg5'], log=name + '.log') != 0: raise RuntimeError('madgraph failed, see %s.log' % name) proc_dirs = sorted(glob.glob(os.path.join(name, 'SubProcesses', 'P*'))) if not proc_dirs: raise RuntimeError('no subprocess directory for %s' % process) + if select: + proc_dirs = [proc_dir for proc_dir in proc_dirs if select(proc_dir)] + if not proc_dirs: + raise RuntimeError('no selected subprocess directory for %s' % process) jobs = os.environ.get('SLURM_CPUS_PER_TASK', '4') for proc_dir in proc_dirs: for backend in backends: @@ -215,19 +296,260 @@ def check_zero_me(backend, repo, nevt=64): read(dump(prefix, 'col'), 'i')) if len(me) != nevt or any(v != 0 for v in me): problems.append('%s: |M|^2 not 0: %s' % (run_backend, list(me[:4]))) - if set(hel) != {-1} or not set(col) <= {-1, 0}: - problems.append('%s: helicities %s, colours %s (expected -1, and -1 or 0)' + if set(hel) != {-1} or set(col) != {-1}: + problems.append('%s: helicities %s, colours %s (expected -1)' % (run_backend, sorted(set(hel))[:4], sorted(set(col))[:4])) - seen.append('%s colour %s' % (run_backend, sorted(set(col)))) if len(me_default) != nevt or not all(v > 0 for v in me_default): problems.append('|M|^2 with the default card not > 0: %s' % list(me_default[:4])) check('zero_me', not problems, '; '.join(problems) or - '%d events: |M|^2 = 0, helicity -1, %s (default card: %.3e)' - % (nevt, ', '.join(seen), me_default[0])) + '%d events: |M|^2 = 0, helicity and colour -1 on %s and %s (default card: %.3e)' + % (nevt, backend, CPU_BACKEND, me_default[0])) + + +def check_zero_me_good_hel(backend, repo, nevt=64): + section('|M|^2 = 0 with good helicities: p p > w+ j at alpha_s = 0 (%s, %s)' + % (backend, CPU_BACKEND)) + name = 'zero_me_wj' + (proc_dir,) = output(repo, name, 'sm', 'p p > w+ j', (CPU_BACKEND, backend), + select=lambda proc_dir: ncross(proc_dir) > 1) + sub = os.path.basename(proc_dir) + data = open(os.path.join(proc_dir, 'ProcessData.h')).read() + nflavor = int(re.search(r'constexpr int nmaxflavor = (\d+);', data).group(1)) + nids = nflavor * ncross(proc_dir) + rng = random.Random(23) + # two calls (iterations) of nevt events in one run: the helicity filtering of the + # first call, at alpha_s = 0.118, keeps its good helicities for the second, at 0 + batch = [1, nevt, 2] + momenta = dump(name, 'momenta') + check_sa(proc_dir, CPU_BACKEND, ['--energy', 1000, '--dump-momenta', momenta] + batch, + name + '_rambo.log') + ids = [ievt % nids for ievt in range(nevt)] * 2 + inputs = ['--momenta', momenta, + '--alphas', write(name + '_alphas.bin', 'd', [0.118] * nevt + [0.] * nevt), + '--flavors', write(name + '_flavors.bin', 'I', ids), + '--rnd-hel', write(name + '_rndhel.bin', 'd', [rng.random() for _ in range(2 * nevt)]), + '--rnd-col', write(name + '_rndcol.bin', 'd', [rng.random() for _ in range(2 * nevt)])] + problems = [] + for run_backend in (backend, CPU_BACKEND): + prefix = '%s_%s' % (name, run_backend) + files = {what: dump(prefix, what) for what in ('me', 'hel', 'col')} + check_sa(proc_dir, run_backend, inputs + ['--dump-me', files['me'], '--dump-hel', files['hel'], + '--dump-col', files['col']] + batch, + prefix + '.log') + me, hel, col = read(files['me'], 'd'), read(files['hel'], 'i'), read(files['col'], 'i') + if not len(me) == len(hel) == len(col) == 2 * nevt: + problems.append('%s: output sizes %d/%d/%d for %d events' + % (run_backend, len(me), len(hel), len(col), 2 * nevt)) + continue + first = [ievt for ievt in range(nevt) if not (me[ievt] > 0 and hel[ievt] >= 0 and col[ievt] >= 0)] + if first: + ievt = first[0] + problems.append('%s: %d events of the alpha_s = 0.118 call without |M|^2 > 0, a helicity ' + 'and a colour, first event %d (id %d): %.3e, helicity %d, colour %d' + % (run_backend, len(first), ievt, ids[ievt], me[ievt], hel[ievt], col[ievt])) + zero = [ievt for ievt in range(nevt, 2 * nevt) if not (me[ievt] == 0 and hel[ievt] == -1 and col[ievt] == -1)] + if zero: + ievt = zero[0] + problems.append('%s: %d events of the alpha_s = 0 call not |M|^2 = 0, helicity -1 and ' + 'colour -1, first event %d (id %d): %.3e, helicity %d, colour %d' + % (run_backend, len(zero), ievt - nevt, ids[ievt], me[ievt], hel[ievt], col[ievt])) + check('zero_me_good_hel', not problems, '; '.join(problems[:3]) or + '%s, %d extended ids: |M|^2 > 0 at alpha_s = 0.118, then |M|^2 = 0, helicity and colour -1 ' + 'at alpha_s = 0 on %s and %s' % (sub, nids, backend, CPU_BACKEND)) + + +# --------------------------------------------------------------------------- +# 3. crossed subprocesses with running flavour couplings (MSSM p p > go go j) +# --------------------------------------------------------------------------- + +def crossed_gpu_vs_cpu(check_name, backend, name, proc_dirs, energy, seed, nevt, own_points=False, + signed=False): + """Events over every extended flavour id K*nmaxflavor + flavour of each subprocess + (crossing-table row K), a random alpha_s and random numbers per event: the GPU |M|^2, + helicities and colours equal the CPU ones event by event. own_points: the momenta of + each event on shell for its own row (2 > 2 only), else the RAMBO points of the base. + signed: an interference |M|^2 (nampso > 1), which can be negative, run twice, as the + helicity sum (no --rnd-hel: umami uses 0.5) and as one signed helicity per event + (--rnd-hel: helicity i drawn on |T_i|, the event gets sign(T_i) sum_j |T_j|); the + draws must give negative |M|^2 and, in some subprocess, differ from the helicity sum + (helicities of both signs). Only the events whose |M|^2 is above 1e-10 of the + largest one compare: where the interference vanishes (colour or flavour), |M|^2 is a + rounding residue that differs between the backends, and so does the helicity drawn + from it (the GPU one must stay a residue). Every crossing row must have |M|^2 != 0 + rather than > 0. + The helicities compare event by event only because every subprocess checked here + violates C parity (chiral or electroweak couplings): on a C-symmetric one (g g > q q~, + g g > t t~) the CPU counts each helicity row once for itself and its C mirror and draws + between the two, the GPU orders every row, so the same random number selects other + rows (same frequencies). Build the CPU reference with -DMGONGPU_NOCSYM there, or + compare helicity frequencies.""" + rng = random.Random(seed) + problems, details = [], [] + mixed = 0 # signed draws that differ from the helicity sum + for proc_dir in proc_dirs: + sub = os.path.basename(proc_dir) + data = open(os.path.join(proc_dir, 'ProcessData.h')).read() + ndpf = int(re.search(r'constexpr int nDPF = (\d+);', data).group(1)) + nflavor = int(re.search(r'constexpr int nmaxflavor = (\d+);', data).group(1)) + nampso = re.search(r'constexpr int nampso = (\d+);', data) + nampso = int(nampso.group(1)) if nampso else 1 + if signed and nampso < 2: + problems.append('%s: nampso = %d, no interference |M|^2 to check' % (sub, nampso)) + nids = nflavor * ncross(proc_dir) + prefix = '%s_%s' % (name, sub) + batch = [1, nevt, 1] + momenta = dump(prefix, 'momenta') + check_sa(proc_dir, CPU_BACKEND, ['--energy', energy, '--dump-momenta', momenta] + batch, + prefix + '_rambo.log') + # every extended id at least once, then random ones + ids = list(range(nids)) + [rng.randrange(nids) for _ in range(nevt - nids)] + rng.shuffle(ids) + if own_points: + # the base masses from the RAMBO points, then the masses of every row + base = read(momenta, 'd') + npar = len(base) // nevt // 4 + if npar != 4: + raise RuntimeError('%s: own_points needs a 2 > 2, not %d legs' % (sub, npar)) + masses = [math.sqrt(max(base[4 * k] ** 2 - sum(c * c for c in base[4 * k + 1:4 * k + 4]), 0.)) + for k in range(npar)] + masses = [m if m > 1e-6 * energy else 0. for m in masses] + slots = table(proc_dir, 'xmass_slot_tab') + points = [] + for xid in ids: + row = xid // nflavor + for p in boosted_two_to_two(rng, [masses[slots[row * npar + k]] for k in range(npar)], energy): + points.extend(p) + momenta = write(prefix + '_own_momenta.bin', 'd', points) + inputs = ['--momenta', momenta, + '--alphas', write(prefix + '_alphas.bin', 'd', [rng.uniform(0.09, 0.14) for _ in range(nevt)]), + '--flavors', write(prefix + '_flavors.bin', 'I', ids)] + rndhel = ['--rnd-hel', write(prefix + '_rndhel.bin', 'd', [rng.random() for _ in range(nevt)])] + inputs += ['--rnd-col', write(prefix + '_rndcol.bin', 'd', [rng.random() for _ in range(nevt)])] + modes = (('sum', []), ('draw', rndhel)) if signed else (('', rndhel),) + me_sum = None + for mode, mode_inputs in modes: + tag = sub + (' ' + mode if mode else '') + out = {} + for run_tag, run_backend in (('cpu', CPU_BACKEND), ('gpu', backend)): + run_tag += '_' + mode if mode else '' + files = {what: dump(prefix, '%s_%s' % (what, run_tag)) for what in ('me', 'hel', 'col')} + check_sa(proc_dir, run_backend, inputs + mode_inputs + + ['--dump-me', files['me'], '--dump-hel', files['hel'], '--dump-col', files['col']] + batch, + '%s_%s.log' % (prefix, run_tag)) + out[run_backend] = (read(files['me'], 'd'), read(files['hel'], 'i'), read(files['col'], 'i')) + (me_cpu, hel_cpu, col_cpu), (me_gpu, hel_gpu, col_gpu) = out[CPU_BACKEND], out[backend] + if not len(me_cpu) == len(me_gpu) == len(hel_gpu) == len(col_gpu) == nevt: + problems.append('%s: output sizes %d/%d/%d/%d for %d events' + % (tag, len(me_cpu), len(me_gpu), len(hel_gpu), len(col_gpu), nevt)) + continue + # the events that compare: all, or those above the rounding residue (a nan + # included, which fails the comparison below) + if signed: + floor = 1e-10 * max((abs(v) for v in me_cpu if not math.isnan(v)), default=0.) + events = [ievt for ievt in range(nevt) if not abs(me_cpu[ievt]) <= floor] + residue = [ievt for ievt in sorted(set(range(nevt)) - set(events)) + if not abs(me_gpu[ievt]) <= 1e2 * floor] + if residue: + first = residue[0] + problems.append('%s: %d events with a CPU residue |M|^2 are not one on %s, first event ' + '%d (id %d) %.10e vs %.10e' % (tag, len(residue), backend, first, + ids[first], me_gpu[first], me_cpu[first])) + else: + events = list(range(nevt)) + rels = {ievt: abs(me_cpu[ievt] - me_gpu[ievt]) / abs(me_cpu[ievt]) if me_cpu[ievt] != 0 + else (0. if me_gpu[ievt] == 0 else float('inf')) for ievt in events} + bad = [ievt for ievt in events if not rels[ievt] < 1e-9] + if bad: + first = bad[0] + problems.append('%s: %d of %d events differ, first event %d (id %d) %s %.10e vs %s %.10e' + % (tag, len(bad), len(events), first, ids[first], backend, me_gpu[first], + CPU_BACKEND, me_cpu[first])) + bad_hel = [ievt for ievt in events if hel_cpu[ievt] != hel_gpu[ievt]] + bad_col = [ievt for ievt in events if col_cpu[ievt] != col_gpu[ievt]] + if bad_hel or bad_col: + first = (bad_hel or bad_col)[0] + problems.append('%s: %d helicities and %d colours differ, first event %d (id %d): ' + 'helicity %d vs %d, colour %d vs %d' + % (tag, len(bad_hel), len(bad_col), first, ids[first], hel_gpu[first], + hel_cpu[first], col_gpu[first], col_cpu[first])) + # every crossing row evaluated: some event of each row has |M|^2 > 0 (!= 0 + # for an interference) + rows = {ids[ievt] // nflavor for ievt in events + if (me_cpu[ievt] != 0 if signed else me_cpu[ievt] > 0)} + if len(rows) != nids // nflavor: + problems.append('%s: |M|^2 %s 0 only for crossing rows %s of %d' + % (tag, '!=' if signed else '>', sorted(rows), nids // nflavor)) + negative = sum(1 for ievt in events if me_cpu[ievt] < 0) + if mode == 'sum': + me_sum = me_cpu + elif mode == 'draw': + if not 0 < negative < len(events): + problems.append('%s: %d negative |M|^2 of %d events, not a signed helicity draw' + % (tag, negative, len(events))) + own = 0 if me_sum is None else sum( + 1 for ievt in events if not abs(me_cpu[ievt] - me_sum[ievt]) <= 1e-9 * abs(me_sum[ievt])) + mixed += own + worst = max((rel for rel in rels.values() if not math.isnan(rel)), default=0.) + details.append('%s nDPF %d, %d extended ids, ' % (tag, ndpf, nids) + + ('nampso %d, %d of %d events compared, %d negative, ' + % (nampso, len(events), nevt, negative) if signed else '') + + ('%d differ from the sum, ' % own if mode == 'draw' else '') + + 'max rel diff %.1e' % worst) + print(' ' + details[-1]) + if signed and not mixed: + problems.append('no signed draw differs from the helicity sum: no helicities of both signs') + check(check_name, not problems, '; '.join(problems[:3]) or '; '.join(details)) + + +def check_mssm_crossed_gpu_vs_cpu(backend, repo, nevt=1024): + section('MSSM p p > go go j, crossed subprocesses: %s against %s' % (backend, CPU_BACKEND)) + name = 'mssm_gogoj' + # only the subprocesses that fold crossings (the others are check 1 with one more jet) + proc_dirs = output(repo, name, 'MSSM_SLHA2', 'p p > go go j', (CPU_BACKEND, backend), + select=lambda proc_dir: ncross(proc_dir) > 1) + crossed_gpu_vs_cpu('mssm_crossed_gpu_vs_cpu', backend, name, proc_dirs, 3000, 17, nevt) + + +def check_crossed_massive_gpu_vs_cpu(backend, repo, nevt=1024): + section('u g > u z with u u~ > z g, a massive leg crossed into a massless slot: %s against %s' + % (backend, CPU_BACKEND)) + name = 'crossed_massive' + # u u~ > z g is folded onto u g > u z: its Z sits in the slot of the outgoing u + proc_dirs = output(repo, name, 'sm', 'u b1 > c1 d1 QED=1 QCD=1', (CPU_BACKEND, backend), + select=lambda proc_dir: 0 in table(proc_dir, 'xonshell_tab'), + defines=('b1 = g u~', 'c1 = u z', 'd1 = z g')) + crossed_gpu_vs_cpu('crossed_massive_gpu_vs_cpu', backend, name, proc_dirs, 1000, 29, nevt, + own_points=True) + + +def check_axial_crossed_gpu_vs_cpu(backend, repo, nevt=1024): + section('p p > z j --axial_gauge=True, crossed axial-gauge gluon: %s against %s' + % (backend, CPU_BACKEND)) + name = 'axial_zj' + # g q > z q also serves q q~ > z g: the gluon polarisation is vxxxxxr in the axial + # gauge of another leg, whose momentum a crossed event reads from its own slot + proc_dirs = output(repo, name, 'sm', 'p p > z j', (CPU_BACKEND, backend), + select=lambda proc_dir: ncross(proc_dir) > 1, options='--axial_gauge=True') + crossed_gpu_vs_cpu('axial_crossed_gpu_vs_cpu', backend, name, proc_dirs, 1000, 31, nevt) + + +def check_signed_crossed_gpu_vs_cpu(backend, repo, nevt=1024): + section('p p > j j and p p > t t~ j QED^2==2, crossed interference |M|^2 (signed helicities): ' + '%s against %s' % (backend, CPU_BACKEND)) + # interferences of the QCD and electroweak amplitudes (nampso 2), whose |M|^2 per + # helicity is signed, in the subprocesses that fold crossings: P1_QQ_QQ (q q > q q, + # q q~ > q q~, ...: 11 rows, many with a vanishing interference) and P1_gQ_ttxQ + # (g q > t t~ q with q q~ > t t~ g, whose helicities have both signs) + proc_dirs = [] + for name, process in (('signed_jj', 'p p > j j QED^2==2'), ('signed_ttj', 'p p > t t~ j QED^2==2')): + proc_dirs += output(repo, name, 'sm', process, (CPU_BACKEND, backend), + select=lambda proc_dir: ncross(proc_dir) > 1) + crossed_gpu_vs_cpu('signed_crossed_gpu_vs_cpu', backend, 'signed', proc_dirs, 1000, 41, nevt, + signed=True) # --------------------------------------------------------------------------- -# 3. a non-blocking stream +# 4. a non-blocking stream # --------------------------------------------------------------------------- def check_nonblocking_stream(backend, repo, ncalls=4): diff --git a/.github/workflows/acceptancetest.yml b/.github/workflows/acceptancetest.yml index c3cd03473c..c05f4d0314 100644 --- a/.github/workflows/acceptancetest.yml +++ b/.github/workflows/acceptancetest.yml @@ -139,6 +139,8 @@ jobs: cd $GITHUB_WORKSPACE ./tests/test_manager.py test_standalone_cpp_output_consistency -pA -t0 -l INFO ./tests/test_manager.py test_madmatrix_vs_fortran -pA -t0 -l INFO + # MadSpin's density matrices and |M|^2 from madmatrix against the f2py ones + ./tests/test_manager.py test_madmatrix_density_matches_fortran -pA -t0 -l INFO ./tests/test_manager.py test_standalone_cpp_perf_alpha_s_from_param_card -pA -t0 -l INFO # ---- acceptancetest_33 ---- diff --git a/.github/workflows/gpu_lemaitre4_hip.yml b/.github/workflows/gpu_lemaitre4_hip.yml index 3c9b5adbc8..fc48789a63 100644 --- a/.github/workflows/gpu_lemaitre4_hip.yml +++ b/.github/workflows/gpu_lemaitre4_hip.yml @@ -36,7 +36,7 @@ on: time_limit: description: 'Slurm --time of the allocation (all CI jobs together)' type: string - default: '00:20:00' + default: '00:30:00' extra_sbatch: description: 'Extra sbatch options, space separated, no quotes (e.g. --qos=preemptible)' type: string @@ -73,7 +73,7 @@ jobs: gres: ${{ inputs.gres || 'gpu:1' }} gpu_arch: ${{ inputs.gpu_arch }} nevents: ${{ inputs.nevents || '10000' }} - time_limit: ${{ inputs.time_limit || '00:20:00' }} + time_limit: ${{ inputs.time_limit || '00:30:00' }} extra_sbatch: ${{ inputs.extra_sbatch }} keep_workdir: ${{ inputs.keep_workdir || false }} # on push (no inputs) always; on "Run workflow" as ticked (default ticked) diff --git a/.github/workflows/gpu_runner_ci.yml b/.github/workflows/gpu_runner_ci.yml index 89cd439392..84edd07aff 100644 --- a/.github/workflows/gpu_runner_ci.yml +++ b/.github/workflows/gpu_runner_ci.yml @@ -10,6 +10,7 @@ # crossing_folding cluster runner p p > w+ j, j j, w+ j j folded (--use_crossing=True) vs # expanded, on the GPU and on cpu; cpu-made gridpacks (w+ j) # madspace_tests cluster runner the madspace unit tests on the GPU, .github/gpu_ci/madspace_tests_gpu.sh +# acceptance cluster runner the acceptance tests that need a GPU, .github/gpu_ci/acceptance_gpu.sh # interference cluster runner squared split orders (interference), .github/gpu_ci/interference_gpu.sh # madmatrix cluster runner GPU against CPU backend of madmatrix, .github/gpu_ci/madmatrix_gpu.sh # ... cluster runner more CI jobs: same needs, if and runs-on, and add them to @@ -253,8 +254,10 @@ jobs: if-no-files-found: warn madmatrix: - # the GPU backend of madmatrix against its CPU backend with check_sa.exe: running flavour - # couplings (MSSM), a subprocess with no good helicity, a non-blocking stream + # the GPU backend of madmatrix against its CPU backend with check_sa.exe (madmatrix_checks.py): + # running flavour couplings (MSSM), every crossing of folded subprocesses (extended ids, + # helicities, colours; massive, axial-gauge and signed interference crossings), + # |M|^2 = 0 with and without good helicities, a non-blocking stream needs: [start_runner, build_madspace] if: needs.start_runner.outputs.attempt == github.run_attempt runs-on: [self-hosted, "${{ inputs.runner_label }}"] @@ -452,9 +455,56 @@ jobs: path: results/ if-no-files-found: warn + acceptance: + # the acceptance tests that need a GPU (tests/test_manager.py), which skip elsewhere; + # here a skip fails the job. The list is in .github/gpu_ci/acceptance_gpu.sh + needs: [start_runner, build_madspace] + if: needs.start_runner.outputs.attempt == github.run_attempt + runs-on: [self-hosted, "${{ inputs.runner_label }}"] + timeout-minutes: 90 + defaults: + run: + shell: bash -l -eo pipefail {0} + env: + BACKEND: ${{ inputs.backend }} + MODULES: ${{ inputs.modules }} + GPU_ARCH: ${{ inputs.gpu_arch }} + VENV: ${{ needs.build_madspace.outputs.venv }} + steps: + - uses: actions/checkout@v5 + - name: run directory + run: echo "WORKDIR=${GLOBALSCRATCH:-$HOME}/mg7-gpu-ci/runs/$RUN_TAG/acceptance" >> "$GITHUB_ENV" + - name: acceptance tests on the GPU + run: .github/gpu_ci/acceptance_gpu.sh + - name: collect logs + if: always() + run: | + mkdir -p results + (cd "$WORKDIR" && tar -cf - --ignore-failed-read summary.txt *.log 2> /dev/null) | tar -xf - -C results || true + find results -type f | sort + - name: job summary + if: always() + run: | + { + echo "### acceptance tests on ${{ inputs.cluster }} ($BACKEND)" + if [ -f results/summary.txt ]; then + echo "| | |" + echo "|---|---|" + sed 's/^\([^=]*\)=\(.*\)$/| \1 | \2 |/' results/summary.txt + else + echo "No summary.txt: the job stopped early, see the log of the 'acceptance tests on the GPU' step." + fi + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gpu-acceptance-${{ inputs.cluster }}-${{ inputs.backend }} + path: results/ + if-no-files-found: warn + stop_runner: # must list every job that runs on the self-hosted runner - needs: [start_runner, build_madspace, pp_ttx, pp_jj, crossing_folding, madspace_tests, interference, madmatrix] + needs: [start_runner, build_madspace, pp_ttx, pp_jj, crossing_folding, madspace_tests, interference, madmatrix, acceptance] if: always() && needs.start_runner.result == 'success' # In an attempt that did not start the runner (a re-run of failed jobs, or of one job), a job # for the runner label would wait 24 hours, holding the concurrency group of the cluster. diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 2382827317..13d8837e0e 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -342,6 +342,8 @@ jobs: cd $GITHUB_WORKSPACE ./tests/test_manager.py test_decay_chain_different_order1 test_decay_chain_different_order2 test_decay_chain_different_order3 test_decay_chain_different_pdgs test_decay_chain_process_overall_orders test_decay_processes_different_is_particles test_equal_decay_chains test_helas_multi_process test_helas_multiprocess_pp_nj test_majorana_decay_chain_process test_multistage_decay_chain_process test_multistage_symmetryfactor test_non_combine_processes test_setget_wavefunction_exceptions test_values_for_prop test_identify_me_tag_qq_qqg test_non_identify_me_tag_qq_qqg test_identify_me_tag_qq_qg test_extract_info test_get_final_state_particle test_get_proc_with_decay_LO test_get_proc_with_decay_NLO test_madspin_event test_find_symmetry_uu_tt test_find_symmetry_uu_tt_with_subprocess_group test_trimmed_decay_keeps_external_leg_order test_gluino_decay_chain_leg_numbering test_tree_pass_matches_enumeration test_tree_pass_error_handling test_tree_pass_ttbar_nested_decays test_tree_pass_vbs_same_sign_ww -t0 ./tests/test_manager.py tests.unit_tests.madspin.test_madspin -t0 + # madmatrix.density, the default density backend of MadSpin + ./tests/test_manager.py tests.unit_tests.iolibs.test_madmatrix_density -t0 # ---- unittest_density_29 ---- - name: "unittest_density_29" diff --git a/MadSpin/decay.py b/MadSpin/decay.py index b7591aeab3..2a4bfad476 100755 --- a/MadSpin/decay.py +++ b/MadSpin/decay.py @@ -49,7 +49,7 @@ os.sys.path.append("../.") import string import itertools -#import madgraph.core.base_objects as base_objects +import madgraph.core.base_objects as base_objects #import madgraph.core.helas_objects as helas_objects #import madgraph.core.diagram_generation as diagram_generation @@ -71,6 +71,7 @@ import math from madgraph import MG5DIR, MadGraph5Error import madgraph.various.misc as misc +import madmatrix.density as madmatrix_density #import time MAX_COMPAT_FLAVS = 500 @@ -94,13 +95,17 @@ def without_crossing(commandline): """Pin --use_crossing=False on every generate / add process of `commandline` (';'-separated commands, as MadSpin hands them to exec_cmd). - MadSpin reaches each matrix element per event flavor, through the entry - points taking a FLAVOR array (GET_DENSITY, SMATRIX), and finds it by the - processes the matrix element lists: neither can see a crossing folded onto - a base. Its production lines are copied from the banner's proc card, so a - --use_crossing=True there came back here and folded those subprocesses - away. Any flag already on the line is dropped first; [...] (perturbative) - lines are left alone, crossing is never applied to them. + For the matrix elements of the madspin_v1 mode (output standalone_msP / + standalone_msF): their Fortran (GET_FLAVOR_MS_PROD, the FLAV_TABLE of + matrix_prod.f) has no crossing dimension, so a crossing folded onto a base + could not be reached at all. Its production lines are copied from the + banner's proc card, so a --use_crossing=True there would come back here + and fold those subprocesses away. Also for the Fortran production matrix + elements with polarised beams (crossed_ok), which the Fortran refuses at + a crossed flavor index. The other modes + fold the crossings (decay_all_events_onshell, crossed_processes_with_decays). + Any flag already on the line is dropped first; [...] (perturbative) lines + are left alone, crossing is never applied to them. """ out = [] for cmd in commandline.split(';'): @@ -111,6 +116,27 @@ def without_crossing(commandline): out.append(cmd) return ';'.join(out) +def crossed_processes_with_decays(matrix_element): + """The crossed processes folded onto matrix_element (--use_crossing, the + standalone outputs evaluate them through the extended flavor index of + the base), with the decays of the base attached: a decay-chain base + records its crossings at the production level, and the decays never + cross. Their legs (get_legs_with_decays) are in the order the base's + crossing table serves them, which is the order the pdg-keyed entry + points (smatrixhel, py_get_density) take them in.""" + if 'crossed_processes' not in matrix_element: + return [] + decays = matrix_element.get('processes')[0].get('decay_chains') + out = [] + for process, _, _ in matrix_element.get('crossed_processes') or []: + if decays: + process = copy.copy(process) + process.set('decay_chains', decays) + process.set('legs_with_decays', base_objects.LegList()) + out.append(process) + return out + + class MadSpinError(MadGraph5Error): pass @@ -5561,6 +5587,16 @@ def generate_all_matrix_element(self): # return # 6. generate decay only part ------------------------------------------ + def leg_order(process): + """((initial ids), (final ids)) in the leg order of process, and + the same sorted: the tag an event is looked up by.""" + initial = [] + final = [l.get('id') for l in process.get_legs_with_decays()\ + if l.get('state') or initial.append(l.get('id'))] + order = (tuple(initial), tuple(final)) + initial.sort(), final.sort() + return order, (tuple(initial), tuple(final)) + def fill_all_me(self, prod_or_decay): # store information about matrix element if prod_or_decay not in ["production", "decay"]: @@ -5569,44 +5605,70 @@ def fill_all_me(self, prod_or_decay): me_string = matrix_element.get('processes')[0].shell_string() for me in matrix_element.get('processes'): # get the orignal order: - initial = [] - final = [l.get('id') for l in me.get_legs_with_decays()\ - if l.get('state') or initial.append(l.get('id'))] - order = (tuple(initial), tuple(final)) - initial.sort(), final.sort() - tag = (tuple(initial), tuple(final)) + order, tag = leg_order(me) self.all_me[tag] = {'pdir': "P%s" % me_string, 'order': order, 'type': prod_or_decay} + # The crossed processes folded onto a matrix element: evaluated by + # the library of the base, which the pdg-keyed entry points + # (smatrixhel, py_get_density, of the f2py module and of + # madmatrix.density alike) reach from their pdg codes, the momenta + # and the open legs in the crossed process's own leg order. The + # module lists the processes of its libraries only: base_order + # names the base the crossed tag finds there (get_pdir). A tag a + # process of its own already has (a beam swap) stays the + # process's: MadSpin puts the event legs in its order. + for matrix_element in mgcmd._curr_matrix_elements.get_matrix_elements(): + base = matrix_element.get('processes')[0] + base_order, _ = leg_order(base) + for process in crossed_processes_with_decays(matrix_element): + order, tag = leg_order(process) + if tag not in self.all_me: + self.all_me[tag] = {'pdir': "P%s" % base.shell_string(), + 'order': order, 'type': prod_or_decay, + 'base_order': base_order} mgcmd = self.mgcmd self.all_me = {} + # the crossings are folded (fill_all_me), unless the beams are polarised + # and the Fortran evaluates the matrix elements (crossed_ok) + def generate_line(commandline, backend): + commandline = commandline.replace('add process', 'generate', 1) + if self.crossed_ok(backend): + return commandline + return without_crossing(commandline) # legacy options 'onshell_v1' and 'madspin_v1' store both the production and the decay in a single folder if self.options['spinmode'] in ['onshell_v1', 'madspin_v1']: commandline += self.get_decay_command() - commandline = without_crossing(commandline.replace('add process', 'generate',1)) + commandline = generate_line(commandline, 'fortran') mgcmd.exec_cmd(commandline, precmd=True) - commandline = 'output standalone_fortran %s --prefix=int' % pjoin(path_me, ms_me_subdir) + # MadSpin calls only the dispatching f2py entry points: the + # per-process ones would cost f2py an hour for ~300 subprocesses + commandline = ('output standalone_fortran %s --prefix=int ' + '--f2py_per_process=False' % pjoin(path_me, ms_me_subdir)) logger.info(commandline) mgcmd.exec_cmd(commandline, precmd=True) fill_all_me(self, "production") else: - commandline_production = without_crossing(commandline.replace('add process', 'generate',1)) - commandline_production += 'output standalone_fortran %s --prefix=int --density=1' % pjoin(path_me, ms_me_subdir) - + backend = self.density_backend(commandline) + commandline_production = generate_line(commandline, backend) logger.info(commandline_production) mgcmd.exec_cmd(commandline_production, precmd=True) + self.output_density_me(pjoin(path_me, ms_me_subdir), backend, 'p', + generation=commandline_production) # store information about the production matrix elements fill_all_me(self, "production") commandline_decay = self.get_decay_command() - commandline_decay += 'output standalone_fortran %s --prefix=int --density=1 -f' % pjoin(path_me, ms_me_decay_subdir) #we add -f, else it would ask us if we want to clean the folder madspin_decay and madspin_me - commandline_decay = without_crossing(commandline_decay.replace('add process', 'generate',1)) + backend = self.density_backend(commandline_decay) + # a 1 -> N decay has no beam: the polarisation never reaches it + commandline_decay = generate_line(commandline_decay, 'decay') logger.info(commandline_decay) mgcmd.exec_cmd(commandline_decay, precmd=True) + self.output_density_me(pjoin(path_me, ms_me_decay_subdir), backend, 'd') # store information about the decay matrix elements fill_all_me(self, "decay") @@ -5615,6 +5677,99 @@ def fill_all_me(self, prod_or_decay): return self.all_me + def polarised_beams(self): + """Whether the beams are polarised (beampol, madevent convention).""" + try: + pol = self.options.beampol_me() + except Exception: + return False + return any(abs(p) > 1. for p in pol) + + def crossed_ok(self, backend): + """Whether the matrix elements generated for `backend` ('madmatrix', + 'fortran', or 'decay' for the 1 -> N decays) fold their crossings. + Not for production matrix elements evaluated by the Fortran with + polarised beams: SMATRIX and GET_DENSITY_IDX refuse a crossed flavor + index then (the helicity of a beam a crossing moved is not the one of + its base slot). The madmatrix density (Density.cc) weighs the + physical helicity of the crossed process's beams, and a decay has no + beam. A madmatrix output that falls back to the Fortran is generated + again without crossing (output_density_me).""" + if backend in ('madmatrix', 'decay') or not self.polarised_beams(): + return True + logger.info('MadSpin: polarised beams with the Fortran matrix elements, ' + 'which do not fold their crossings (--use_crossing=False)') + return False + + def density_backend(self, commandline): + """'madmatrix' or 'fortran': what evaluates the density matrices and + |M|^2 of the processes commandline generates (option density_backend).""" + try: + backend = self.options['density_backend'] + except Exception: + backend = 'madmatrix' + if backend == 'fortran': + return 'fortran' + if '[' in commandline: + # a loop-induced production (get_LO_definition_from_NLO leaves its + # [sqrvirt=...]/[virt=...] in place): MadLoop, only in the Fortran + return 'fortran' + mgcmd = getattr(self, 'mgcmd', None) + if mgcmd is not None and mgcmd.options.get('complex_mass_scheme'): + # the complex-mass scheme (set from the banner's proc card): the + # parameters madmatrix writes for it do not compile, and its + # output only fails at the build, which output_density_me cannot + # catch + logger.info('MadSpin: complex-mass scheme, the density matrices ' + 'come from the Fortran matrix elements') + return 'fortran' + return 'madmatrix' + + def density_libtag(self, side): + """The LIBTAG of the madmatrix libraries of one density tree (side 'p' + production, 'd' decay). Production and decay live in one process, as do + the trees of a second MadSpin run, and the loader tells libraries apart + by their name only.""" + return '_ms%d%s' % (getattr(self.mscmd, '_ms_run_id', 1), side) + + def output_density_me(self, path, backend, side, generation=None): + """Write the matrix elements just generated to path, for the density + modes (side 'p' production, 'd' decay). A madmatrix output is built + right away, while these processes are still the current ones: one that + cannot be written or does not compile is replaced by the Fortran one. + That one is generated again from `generation` (the generate command + just run) without crossing when the Fortran cannot evaluate the crossings + it folds (crossed_ok: polarised beams). Returns the backend written; + MadSpinInterface.create_f2py_module tells the two apart from what it + finds in path.""" + if backend == 'madmatrix': + commandline = 'output standalone %s -f --noeps=True' % path + logger.info(commandline) + try: + self.mgcmd.exec_cmd(commandline, precmd=True) + misc.compile(madmatrix_density.make_variables( + libtag=self.density_libtag(side)), + cwd=pjoin(path, 'SubProcesses'), mode='cpp', + nb_core=self.mgcmd.options['nb_core']) + return backend + except Exception as error: + logger.warning('MadSpin could not write or build its density matrix ' + 'elements with madmatrix (%s): falling back to the ' + 'Fortran standalone output.', + str(error).strip().split('\n')[0]) + logger.debug('madmatrix failure: %s', error) + if os.path.exists(path): + shutil.rmtree(path) + if generation and not self.crossed_ok('fortran'): + generation = without_crossing(generation) + logger.info(generation) + self.mgcmd.exec_cmd(generation, precmd=True) + commandline = ('output standalone_fortran %s --prefix=int --density=1 ' + '--f2py_per_process=False -f' % path) + logger.info(commandline) + self.mgcmd.exec_cmd(commandline, precmd=True) + return 'fortran' + @misc.mute_logger() def handle_model(self): mgcmd = self.mgcmd @@ -5813,20 +5968,28 @@ def compile(self): decay_args.insert(0, 'PROCNAME=_ms%d' % ms_run_id) #my_env = os.environ.copy() #os.environ["GFORTRAN_UNBUFFERED_ALL"] = "y" - self.use_global_flag(pjoin(self.path_me, ms_me_subdir)) - misc.compile(cwd=pjoin(self.path_me, ms_me_subdir, 'Source'), - nb_core=self.mgcmd.options['nb_core']) - misc.compile(prod_args, - cwd=pjoin(self.path_me, ms_me_subdir, 'SubProcesses'), - nb_core=self.mgcmd.options['nb_core']) + trees = [(ms_me_subdir, prod_args, 'p')] #Valentin: not sure the decay_folder exists in all cases, so I check if os.path.exists(pjoin(self.path_me, ms_me_decay_subdir)): - self.use_global_flag(pjoin(self.path_me, ms_me_decay_subdir)) - misc.compile(cwd=pjoin(self.path_me, ms_me_decay_subdir, 'Source'), - nb_core=self.mgcmd.options['nb_core']) - misc.compile(decay_args, - cwd=pjoin(self.path_me, ms_me_decay_subdir, 'SubProcesses'), - nb_core=self.mgcmd.options['nb_core']) + trees.append((ms_me_decay_subdir, decay_args, 'd')) + for subdir, fortran_args, side in trees: + tree = pjoin(self.path_me, subdir) + if madmatrix_density.is_output(tree): + # The C++ libraries madmatrix.density loads, which + # output_density_me already built (make has nothing left to do + # then); named per tree for the same reason the Fortran side + # changes MENUM and PROCNAME above (density_libtag). + misc.compile(madmatrix_density.make_variables( + libtag=self.density_libtag(side)), + cwd=pjoin(tree, 'SubProcesses'), mode='cpp', + nb_core=self.mgcmd.options['nb_core']) + continue + self.use_global_flag(tree) + misc.compile(cwd=pjoin(tree, 'Source'), + nb_core=self.mgcmd.options['nb_core']) + misc.compile(fortran_args, + cwd=pjoin(tree, 'SubProcesses'), + nb_core=self.mgcmd.options['nb_core']) def save_to_file(self, *args): import sys diff --git a/MadSpin/interface_madspin.py b/MadSpin/interface_madspin.py index 33d2df8cb1..330b6f6a4d 100755 --- a/MadSpin/interface_madspin.py +++ b/MadSpin/interface_madspin.py @@ -52,6 +52,7 @@ import models.import_ufo as import_ufo import models.check_param_card as check_param_card import MadSpin.decay as madspin +import madmatrix.density as madmatrix_density logger = logging.getLogger('decay.stdout') # -> stdout logger_stderr = logging.getLogger('decay.stderr') # ->stderr @@ -142,6 +143,17 @@ class MadSpinStaleParameters(madspin.MadSpinError): pass +class MadSpinStaleDirectory(madspin.MadSpinError): + """A reused ``ms_dir``/``use_old_dir`` directory was built by another + MadSpin version (or by one that did not record its version). + + What such a directory caches -- the maximum weights of the unweighting, + the matrix-element libraries, the pickled setup -- is only valid for the + code that computed it: a new version may bound another weight, or load + libraries the old one never built.""" + pass + + class MadSpinZeroBranchingRatio(madspin.MadSpinError): """The branching ratio MadSpin is about to apply to every event is zero (or not a number). Raised instead of writing the events. @@ -347,6 +359,8 @@ def default_setup(self): self.add_param('decay_event_mult', 1E0, comment='Produce more events than needed so that MadSpin does not have to regenerate decay events') self.add_param('nb_core', 0, comment='Number of cores for the MadSpin parallel unweighting (0 = use the global MG5 nb_core). nb_core>1 enables the process-parallel unweighting path.') self.add_param('density_keep_jacobian', True, comment='PA spinmode only: fold the offshell-reshuffling phase-space jacobian into the accept/reject weight (default) instead of applying the reshuffle as a post-acceptance kinematic dressing (False). Ignored by the madspin/full spinmodes, which always include that jacobian.') + self.add_param('offshell_min_recoil', 0.5, + comment="madspin/full spinmodes only: the smallest fraction of its momentum the production may keep when it is reshuffled onto the sampled virtualities (RAMBO's chi, the factor every production three-momentum is scaled by in the partonic rest frame). Only applied when a massless particle recoils against the resonances: a virtuality drawn up to sqrt(shat) then pushes that particle soft, far below the cuts the production was generated with, and the weight grows like 1/chi -- its mean over the decay phase space diverges logarithmically, so no maximum weight can bound it. A draw below the floor gets weight zero. The floor never reaches the pole: chi >= 1/2 still allows m^2 up to (shat + M^2)/2. 0 disables it.") self.add_param('unweighting', 'auto', allowed=['auto', 'joint', 'sequential', 'sequential_global_retry', @@ -362,6 +376,11 @@ def default_setup(self): self.add_param('sequential_debug', False, comment='the up-front-mass unweighting schemes (sequential, sequential_global_retry): on every accepted chain, recompute the joint weight for the same production event, virtualities and decays and check that the product of the stage weights reproduces it (times the number of helicity states). Deterministic check of the decomposition itself -- the tabulated factor cancels out of it -- at roughly the cost of a joint trial per event. Debugging only.') self.add_param('decay_generator', 'mg7', allowed=['mg7', 'madevent'], comment='which backend generates the decay-event pools: mg7 (madmatrix/madspace) or the legacy Fortran madevent') + self.add_param('density_backend', 'madmatrix', allowed=['madmatrix', 'fortran'], + comment='density spin modes (madspin/full, PA, onshell): what evaluates the production and decay ' + 'density matrices and |M|^2. madmatrix: the C++ libraries of a madmatrix standalone output ' + '(madmatrix.density); fortran: the f2py modules of a standalone_fortran --density output. ' + 'A loop-induced production always takes fortran (madmatrix has no loop matrix elements).') def __setitem__(self, name, value, change_userdefine=False, raiseerror=False): """Let an old card keep an unweighting scheme we no longer advertise. @@ -1180,6 +1199,9 @@ def do_import(self, inputfile): logger.critical("value of bwcutoff set to %s from the input file. This is much too large value for Madspin and the validity of the Narrow-width-Approximation. Please ensure that you overwrite that value via \"set BW_cut X\" to a smaller value (like X=10)", self.options['BW_cut']) self.frame_and_beampol_from_run_card(self.options, run_card) + # only aMC@NLO puts massless partons on a Monte-Carlo mass shell + # (see _project_massless) + self._input_on_mc_mass_shell = isinstance(run_card, banner.RunCardNLO) else: if not self.options['Nevents_for_max_weight']: @@ -2299,8 +2321,10 @@ def do_launch(self, line): self._warn_ignored_decay_groups(spinmode) # Before any mode is dispatched, and before anything cached in a reused - # directory is read back: everything in there was computed from a - # param_card, and none of it is re-measured on reuse. + # directory is read back: everything in there was computed by one + # MadSpin version from one param_card, and none of it is re-measured + # on reuse. + self._check_reused_version() self._check_reused_param_card() if spinmode in ["none"]: @@ -3086,6 +3110,13 @@ def generate_events_mg7(self, decay_dir, nb_event, run_name='run_01', # says whether the cache may be reused at all -- see # _check_reused_param_card. PARAM_CARD_STAMP = 'ms_param_card.dat' + # The version a reused directory was built by (_check_reused_version). + # MS_DIR_FORMAT is bumped whenever what such a directory caches changes + # meaning without the MadGraph version changing: 1 = density matrices + # from madmatrix, offshell max-weight scan evaluated per draw, + # offshell_min_recoil. + VERSION_STAMP = 'ms_version.dat' + MS_DIR_FORMAT = 1 def _reused_directory(self): """The directory whose already-built content this run would reuse, or @@ -3124,6 +3155,64 @@ def _holds_cached_material(self, directory): return any(misc.glob(pattern, directory) for pattern in self.CACHED_MATERIAL) + def _ms_dir_version(self): + """What a reused directory must have been built by: the format of what + MadSpin caches there, and the MadGraph version.""" + return 'format %d\nmadgraph %s\n' % (self.MS_DIR_FORMAT, + misc.get_pkg_info()['version']) + + def _check_reused_version(self): + """Refuse to reuse a directory built by another MadSpin version. + + The directory is stamped (VERSION_STAMP) when it is first built; one + that holds cached material and a different stamp -- or none, i.e. a + MadSpin older than the stamp -- is not reused: its maximum weights may + bound another weight than this version computes, its libraries may not + be the ones this version loads. Same life cycle as the param_card + stamp of _check_reused_param_card.""" + reusing = self._reused_directory() + directory = reusing or (self.options['curr_dir'] and + os.path.realpath(self.options['curr_dir'])) + if not directory: + return + stamp = pjoin(directory, self.VERSION_STAMP) + if not reusing and not os.path.exists(stamp): + return # nothing reuses this directory; do not leave a file behind + current = self._ms_dir_version() + if reusing and self._holds_cached_material(directory): + try: + with open(stamp) as fsock: + recorded = fsock.read() + except (IOError, OSError): + recorded = None + if recorded != current: + describe = lambda text: ', '.join(text.split('\n')).strip(', ') + raise MadSpinStaleDirectory( + "MadSpin is reusing %s, which was built by %s; this run " + "is %s.\n" + "\n" + "What that directory caches -- the maximum weights of the " + "unweighting, the matrix-element libraries, the pickled " + "setup -- is only valid for the version that computed it.\n" + "\n" + "Point 'ms_dir'/'use_old_dir' at a fresh directory (or " + "remove %s) and rerun, so everything is rebuilt by this " + "version." + % (directory, + 'MadSpin ' + describe(recorded) if recorded else + 'a MadSpin that did not record its version', + describe(current), directory)) + return + # first use of this directory, or a rebuild of one already stamped + try: + if not os.path.isdir(directory): + os.makedirs(directory) + with open(stamp, 'w') as fsock: + fsock.write(current) + except (IOError, OSError) as error: + logger.debug('could not record the version of %s: %s', + directory, error) + def _check_reused_param_card(self): """Refuse to reuse a directory that was built with other parameters. @@ -5607,7 +5696,7 @@ def _unweight_range(self, prod_source, evt_decayfile, output_lhe, ctx): sequential_stats[key] += value nb_try += sum(v for k, v in seq_stats.items() if k.startswith('nb_try_')) - full_evt = lhe_parser.Event(str(production)) + full_evt = production.copy() full_evt = full_evt.add_decays(decays) if density_needs_reshuffle: # the decays already carry their sampled virtualities; this @@ -5687,7 +5776,11 @@ def _unweight_range(self, prod_source, evt_decayfile, output_lhe, ctx): # jacobian (mass_shuffle's chi telescopes, so the kinematics are # unchanged, but the jacobian and MEdenom_prod are not). offshell_density = density_method and not density_pole_approximation - prod_trial = lhe_parser.Event(str(production)) if offshell_density else production + prod_trial = production.copy() if offshell_density else production + if offshell_density: + # what the trial does not change is worked out once, on + # production (_hoisted_production) + prod_trial._ms_hoist_source = production if prod_density_cached is None or not density_pole_approximation: full_evt, wgt, prod_density_cached = self.get_onshell_evt_and_wgt( @@ -5704,7 +5797,7 @@ def _unweight_range(self, prod_source, evt_decayfile, output_lhe, ctx): # trial, including rejected ones (must not mutate the shared # production event). The offshell/madspin path does NOT enter # here: its reshuffle jacobian is already inside wgt. - full_evt = lhe_parser.Event(str(production)) + full_evt = production.copy() full_evt = full_evt.add_decays(decays) jac = full_evt.reshuffle_production() @@ -5787,7 +5880,7 @@ def _unweight_range(self, prod_source, evt_decayfile, output_lhe, ctx): # get_onshell already built it (fixed_order / density_debug), # reuse that event rather than build the same one twice. if full_evt is None: - full_evt = lhe_parser.Event(str(prod_trial)) + full_evt = prod_trial.copy() full_evt = full_evt.add_decays(decays) elif (density_needs_reshuffle and density_pole_approximation @@ -5802,13 +5895,13 @@ def _unweight_range(self, prod_source, evt_decayfile, output_lhe, ctx): # accept/reject test. For 2 -> 1 production no mass was # sampled and reshuffle_production short-circuits # (NWA-style no-op). - full_evt = lhe_parser.Event(str(production)) + full_evt = production.copy() full_evt = full_evt.add_decays(decays) jac = full_evt.reshuffle_production() elif full_evt is None: # No-reshuffle density mode still needs a concrete event to write out. if density_method and density_pole_approximation: - full_evt = lhe_parser.Event(str(production)) + full_evt = production.copy() else: full_evt = production full_evt = full_evt.add_decays(decays) @@ -8007,8 +8100,19 @@ def _joint_maxwgt_range(self, events, start, stop, evt_decayfile, decay_dict, # per-draw onshell copy so repeated draws don't compound and so the # reshuffle jacobian (now folded into wgt) is taken from the onshell # reference each draw. - prod_draw = lhe_parser.Event(str(base_event)) if offshell_density else base_event - if density_matrix_prod is None: + prod_draw = base_event.copy() if offshell_density else base_event + if offshell_density: + prod_draw._ms_hoist_source = base_event + # Offshell, every draw reshuffles the production to its own mass + # set, so its density has to be evaluated again -- as the decay + # loop does on every trial. Reusing the first draw's measured the + # bound on another weight than the one the accept/reject tests: + # t t~ leptonic, 3 overweight events of 10^4 up to a factor 3.05, + # against 1 at 1.06 once evaluated per draw; on u u~ > z g one + # first draw near sqrt(shat) put a whole production event at 172x + # the mean weight. Evaluated per draw, the bound is the true one, + # which a soft recoil leaves unbounded but for offshell_min_recoil. + if density_matrix_prod is None or offshell_density: _, wgt, density_matrix_prod = self.get_onshell_evt_and_wgt( prod_draw, decays, decay_dict, build_event=False) # This first trial populated production._ms_density_static, @@ -8029,7 +8133,7 @@ def _joint_maxwgt_range(self, events, start, stop, evt_decayfile, decay_dict, # PA with explicit jacobian tracking: reshuffle to expose the # jacobian in the max weight. Offshell/madspin does NOT enter # here -- its reshuffle jacobian is already inside wgt. - full_evt = lhe_parser.Event(str(base_event)) + full_evt = base_event.copy() full_evt = full_evt.add_decays(decays) jac = full_evt.reshuffle_production() maxwgt = max(abs(wgt*jac) if signed else wgt*jac, maxwgt) @@ -9922,7 +10026,7 @@ def _production_jacobian_for(production, slot_to_index, slot_masses): is left to reshuffle_production, which is why this is not a function of the top-level masses alone. """ - probe = lhe_parser.Event(str(production)) + probe = production.copy() finals = [p for p in probe if int(p.status) == 1] for slot, (new_mass, info) in slot_masses.items(): particle = finals[slot_to_index[slot]] @@ -9974,7 +10078,7 @@ def _decay_reshuffle_jacobian(self, decay): feeds the Lorentz map applied afterwards -- so this probe is what enters the accept/reject weight and the final ``reshuffle_production`` merely recomputes it.""" - probe = lhe_parser.Event(str(decay)) + probe = decay.copy() probe[0].new_mass = decay[0].new_mass probe[0].reshuffle_info = decay[0].reshuffle_info try: @@ -9982,10 +10086,31 @@ def _decay_reshuffle_jacobian(self, decay): except Exception: return 0 + def _decay_lab_boost(self, parent, frame_boost): + """(E, px, py, pz) of parent when the decay density can be evaluated on + the decay still at rest, the library boosting it along parent: madmatrix + decays (built with madmatrix_density_boosted) and no frame_id boost. + None otherwise, and the decay event is boosted here. A rejected trial + then never boosts its decays in python; add_decays boosts the accepted + ones, which it does for a decay at rest.""" + if frame_boost is not None: + return None + module = self.f2py_module[1] + if not isinstance(module, madmatrix_density.MadDensityLibrary) \ + or not module.supports_boost: + return None + return (parent.E, parent.px, parent.py, parent.pz) + def _slot_density(self, decay, parent, hel, frame_boost=None): """The decay density matrix of one slot, in the lab frame of its parent (and then in the ``frame_id`` frame, when there is one -- see ``get_density``).""" + lab_boost = self._decay_lab_boost(parent, frame_boost) + if lab_boost is not None: + # the decay stays at rest (add_decays boosts the accepted one) + return self.get_density(decay, position=[1], allow_hel=hel, + ncomb=len(hel), dimension=len(hel), + lab_boost=lab_boost) rest_leg = None if frame_boost is not None: rest_leg = self._decay_frame_rest_leg(parent, frame_boost) @@ -10063,6 +10188,49 @@ def _mass_window(self, pdg, budget): gap += math.atan((max_mass**2-pole**2)/pole/width) return pole, width, min_mass, max_mass, gap/math.pi + def _offshell_min_recoil(self): + """The ``offshell_min_recoil`` floor (0 when disabled), for + reshuffle_production, which applies it only when one of the particles + it rescales -- the top-level final state of the production -- is a + massless recoil (_recoil_massless). + + That is the case where the offshell weight diverges: for ``u u~ > z + g`` at fixed shat and angle, |M_prod|^2 ~ 1/(shat - m^2)^2 while the + reshuffling jacobian only goes like chi = (shat - m^2) / (shat - M^2), + so w ~ 1/chi and its mean over the Breit-Wigner diverges for every + event whose window reaches sqrt(shat). Measured on that process + (BW_cut = 15): 25% of the production events, and a max-weight bound + anywhere between 200 and 3600 times the mean weight over 8 production + samples. With the floor at 0.5, which removes 0.4% of the draws, all 8 + decay at 8.9 to 11.9 trials per event with at most 2 of 10000 events + over the bound (largest factor 1.14), and the decayed distributions in + the region the floor keeps are unchanged. With only massive particles + rescaled (a t t~ threshold, or t t~ with the top already decayed in the + event: its decay products move rigidly with it) the weight stays + bounded, and the floor is not applied. + """ + floor = self.options['offshell_min_recoil'] + if not floor or floor <= 0: + return 0. + return float(floor) + + def _recoil_massless(self, particle): + """Whether a particle reshuffle_production rescales recoils as a + massless one: massless in the event, or in the model -- aMC@NLO writes + its partons on the shower's Monte-Carlo mass shell (0.32 GeV quarks, a + 0.75 GeV gluon with HERWIG6), and such a recoil goes soft all the + same.""" + return abs(particle.mass) < 1e-6 or self._model_massless(particle.pid) + + def _model_massless(self, pid): + """lhe_parser._model_leg_is_massless for MadSpin's model, cached per + pdg (it runs on every offshell trial).""" + cache = self.__dict__.setdefault('_ms_model_massless', {}) + if pid not in cache: + cache[pid] = lhe_parser._model_leg_is_massless( + getattr(self, 'model', None), pid) + return cache[pid] + def _draw_mass_value(self, pdg, budget): """Sample one resonance virtuality from its Breit-Wigner, capped at the remaining ``budget`` (what is left of sqrt(shat)). Returns @@ -10157,7 +10325,7 @@ def _upfront_production(self, production, order, particles, slot_to_index, return (density_prod, jac_prod, slot_mass, prod_static['init_part'], frame_boost) - prod_off = lhe_parser.Event(str(production)) + prod_off = production.copy() finals = [p for p in prod_off if int(p.status) == 1] for slot, (mass, info, _) in slot_mass.items(): part = finals[slot_to_index[slot]] @@ -10166,7 +10334,12 @@ def _upfront_production(self, production, order, particles, slot_to_index, # _allow_retry=False so the drawn masses stay put (a retry would resample # them and diverge from the masses we reshuffle each decay to); an # impossible set is reported as -1 and the caller restarts. - jac_reshuffle = prod_off.reshuffle_production(_allow_retry=False) + # a mass set below the recoil floor is refused like an infeasible one: + # the caller redraws the set, which is what a zero mass-set weight + # amounts to in a chain that redraws until it accepts + jac_reshuffle = prod_off.reshuffle_production( + _allow_retry=False, min_recoil=self._offshell_min_recoil(), + recoil_massless=self._recoil_massless) if jac_reshuffle in (0, -1): return None # the frame is derived from the *reshuffled* production, since that is @@ -10199,9 +10372,8 @@ def _onshell_production_norm(self, production, prod_static): value is a different projection from the one the numerator is built from, so the trace of the on-shell rho *in that frame* is taken instead. - Evaluated on a round-tripped copy, like ``_upfront_production``'s - ``prod_off``, so the two sides of the ratio see the same %.10e - truncation. + Evaluated on a copy, like ``_upfront_production``'s ``prod_off``, so + the two sides of the ratio see the same momenta. Any residual inaccuracy of the boosted density evaluation is already inside the numerator of every ``me_frame`` run; taking the denominator @@ -10210,7 +10382,7 @@ def _onshell_production_norm(self, production, prod_static): frame_boost = self._frame_boost(production) if frame_boost is None: return self.calculate_matrix_element(production) - prod_on = lhe_parser.Event(str(production)) + prod_on = production.copy() rho_on = self.get_density(prod_on, prod_static['position'], prod_static['allowed_hel'], prod_static['ncomb'], @@ -10519,18 +10691,17 @@ def _mass_stage_bound_compute(self, production, order, particles, if keep_jac: frame = getattr(production, '_ms_shuffle_frame', None) if frame is None: - # from the *round-tripped* event, because that is what - # _production_jacobian_for reshuffles: str() truncates every - # momentum to %.10e, and the bound has to dominate the weight - # the accept/reject actually computes, not an idealised one. - probe = lhe_parser.Event(str(production)) + # from a copy, because that is what _production_jacobian_for + # reshuffles: the bound has to dominate the weight the + # accept/reject actually computes, not an idealised one. + probe = production.copy() finals = [p for p in probe if int(p.status) == 1] frame = lhe_parser.Event.mass_shuffle_frame( [lhe_parser.FourMomentum(p) for p in finals], probe.sqrts) production._ms_shuffle_frame = frame production._ms_shuffle_sqrts = probe.sqrts # the undrawn slots keep their nominal mass, and it is the - # round-tripped one reshuffle_production reads + # copied one reshuffle_production reads production._ms_shuffle_masses = [p.mass for p in finals] masses = list(production._ms_shuffle_masses) for slot, mass in corner.items(): @@ -10688,7 +10859,7 @@ def _check_weight_identity(self, production, decays, decay_dict, w_seq, deterministic: any scheme whose per-chain weight product is not the joint weight is wrong, whatever a lineshape comparison happens to show. """ - prod_copy = lhe_parser.Event(str(production)) + prod_copy = production.copy() decays_copy = collections.defaultdict(list) jac_bw = 1.0 # ``slot`` below is a free-running index over the grouped walk of @@ -10713,7 +10884,7 @@ def _check_weight_identity(self, production, decays, decay_dict, w_seq, slot = 0 for pdg, decay_list in decays.items(): for decay in decay_list: - copy = lhe_parser.Event(str(decay)) + copy = decay.copy() if not offshell and parents is not None: assert parents[slot].pid == pdg, \ ('sequential_debug: slot %d of the accepted chain is a ' @@ -10770,11 +10941,11 @@ def _check_weight_identity(self, production, decays, decay_dict, w_seq, rebuilt = collections.defaultdict(list) for pdg, decay_list in decays.items(): for decay in decay_list: - copy = lhe_parser.Event(str(decay)) + copy = decay.copy() copy[0].new_mass = decay[0].new_mass copy[0].reshuffle_info = decay[0].reshuffle_info rebuilt[pdg].append(copy) - full_evt = lhe_parser.Event(str(production)).add_decays(rebuilt) + full_evt = production.copy().add_decays(rebuilt) jac_full = full_evt.reshuffle_production(_allow_retry=False) if jac_full in (0, -1): # the chain kept this mass set, so this should not happen; skip @@ -11227,7 +11398,7 @@ def sequential_accept_reject(self, production, evt_decayfile, maxwgts, # reshuffle_production rebuild consistent kinematics. # Reshuffling/boosting it in place leaves it on the # offshell parent and add_decays then rejects it. - dcopy = lhe_parser.Event(str(decay)) + dcopy = decay.copy() dcopy[0].new_mass = mass[0] dcopy[0].reshuffle_info = mass[1] # jac_dec_k: the decay reshuffling jacobian. Joint @@ -11568,14 +11739,19 @@ def get_onshell_evt_and_wgt(self, production, decays, decay_dict, decay_me = 1.0 decay_me_debug = 1.0 jac = 1.0 - tag, order = production.get_tag_and_order(self._revert_merged or None) - try: - info = self.generate_all.all_me[tag] - except: - misc.sprint(self.generate_all.all_me) - misc.sprint(production) - misc.sprint(decays) - raise + # a production event (all of whose offshell trial copies share its + # flavours) is checked once, not on every trial + checked = getattr(production, '_ms_hoist_source', None) or production + if not checked.__dict__.get('_ms_tag_checked'): + tag, order = production.get_tag_and_order(self._revert_merged or None) + try: + info = self.generate_all.all_me[tag] + except: + misc.sprint(self.generate_all.all_me) + misc.sprint(production) + misc.sprint(decays) + raise + checked._ms_tag_checked = True # Calculate decay ME if self.generate_all.mode == 'onshell': @@ -11601,14 +11777,21 @@ def get_onshell_evt_and_wgt(self, production, decays, decay_dict, # mismatch out before the two are combined -- see # _decay_chain_identical_factor. iden_ratio = self._decay_chain_identical_factor(production, decays) - full_event = lhe_parser.Event(str(production)) + full_event = production.copy() full_event = full_event.add_decays(decays) #print(f"full_event = {full_event}") full_me = self.calculate_matrix_element(full_event) * iden_ratio #print(f"full_me = {full_me}") else: #offshell mode - full_dqrts = production.sqrts + source = getattr(production, '_ms_hoist_source', None) + if source is None: + full_dqrts = production.sqrts + else: + # the trial copies all have the sqrt(shat) of their source + full_dqrts = source.__dict__.get('_ms_sqrts') + if full_dqrts is None: + full_dqrts = source._ms_sqrts = source.sqrts jac = 1 # 2 -> 1 production: the single resonance virtuality is fully fixed # by sqrt(shat); there is no recoil phase space, so RAMBO cannot @@ -11650,7 +11833,7 @@ def get_onshell_evt_and_wgt(self, production, decays, decay_dict, if build_event or self.options['density_debug']: # Create full event from production and decays if density_pole_approximation: - full_event = lhe_parser.Event(str(production)) + full_event = production.copy() else: full_event = production # add_decays is non-destructive: ``decays`` survives this, which @@ -11660,10 +11843,13 @@ def get_onshell_evt_and_wgt(self, production, decays, decay_dict, full_event = full_event.add_decays(decays) #print(f"full event 2 = {full_event}") - if self.options['density_debug']: + # a mass set the recoil floor refused (jac_reshuffle 0) has + # weight zero and nothing reshuffled to compare with + if self.options['density_debug'] and jac_reshuffle != 0: me1 = self.calculate_matrix_element(full_event) #print(f"me1 = {me1} , me2 = {full_me} , ratio = {me1/full_me}") - if abs(1-me1/full_me) > self.options['density_tolerance']: + if (abs(full_me - me1) > self.options['density_tolerance'] * abs(full_me) + if full_me else me1 != 0): print(f"full = {me1} , density = {full_me} , ratio = {me1/full_me}") print(full_event) print(production) @@ -11801,10 +11987,18 @@ def create_f2py_module(self, sp_path, prod_or_decay, all_prefix, all_pdg, all_pr else: raise ValueError("The only acceptable values of prod_or_decay are 'prod' and 'decay'") - # production and decay are built with distinct MENUM (2 vs 1) so - # their f2py modules / dependent libraries don't clash in-process - # (see decay_all_events_onshell.compile). - mymod = self._load_f2py_matrix_module(sp_path, menum=menum) + tree = os.path.dirname(sp_path) + if madmatrix_density.is_output(tree): + # density_backend = madmatrix: the C++ libraries of a madmatrix + # standalone output, with the f2py module's method names (see + # madmatrix/density.py), built with a LIBTAG of their own by + # decay_all_events_onshell.compile + mymod = madmatrix_density.MadDensityLibrary(tree) + else: + # production and decay are built with distinct MENUM (2 vs 1) so + # their f2py modules / dependent libraries don't clash in-process + # (see decay_all_events_onshell.compile). + mymod = self._load_f2py_matrix_module(sp_path, menum=menum) self.f2py_module[i] = mymod all_prefix[i] = self.f2py_module[i].get_prefix() @@ -11886,9 +12080,15 @@ def calculate_matrix_element_from_density(self, production, decays, decay_dict, prod_static = getattr(production, '_ms_density_static', None) density_pole_approximation = self._density_pole_approximation() density_do_reshuffle = self._density_do_reshuffle() + hoisted_norm = None if not density_pole_approximation or \ (not prod_static or prod_static.get('decays_key') != decays_key): - prod_static = self._density_basis(production, decays_key) + hoist_source = getattr(production, '_ms_hoist_source', None) + if hoist_source is not None: + prod_static, hoisted_norm = self._hoisted_production( + hoist_source, production, decays_key) + else: + prod_static = self._density_basis(production, decays_key) production._ms_density_static = prod_static use_new_mass = ( @@ -11936,8 +12136,8 @@ def calculate_matrix_element_from_density(self, production, decays, decay_dict, # the frame axis on. That is safe: the denominator is a constant # per production event, identical across its joint trials, so it # cancels out of the accept/reject. - MEdenom_prod = self._onshell_production_norm(production, - prod_static) + MEdenom_prod = hoisted_norm if hoisted_norm is not None else \ + self._onshell_production_norm(production, prod_static) MEdenom_decay = 1.0 for key in decays: for dec in decays[key]: @@ -11945,7 +12145,23 @@ def calculate_matrix_element_from_density(self, production, decays, decay_dict, # now doing the reshuffling # doing the reshuffling for each part: jac = 1.0 - jac *= production.reshuffle_production() + jac *= production.reshuffle_production( + min_recoil=self._offshell_min_recoil(), + recoil_massless=self._recoil_massless) + if jac == 0: + # below offshell_min_recoil (reshuffle_production retries + # every other failure): a zero weight, i.e. an ordinary + # rejection for the accept/reject and a zero-weight event + # for decay_output = weighted. Nothing was reshuffled, so + # the decays are left as they came. + if getattr(self, '_pi_probe_c', False): + self._pi_unrestricted_me = 0.0 + if self._polarization_weights_enabled(): + combinations = self._polarization_combinations( + prod_static) or [] + self._pol_weight_ratios = dict( + (wid, 0.0) for wid, _ in combinations) or None + return 0.0, None, MEdenom_prod, MEdenom_decay, 0.0 # reshuffle_production may have resampled a resonance's new_mass # (its jac==-1 retry) so the set of masses fits sqrt(shat). Push the # (possibly resampled) production masses back onto the matching decay @@ -12046,6 +12262,17 @@ def calculate_matrix_element_from_density(self, production, decays, decay_dict, if decay_densities is not None: # Boosted and evaluated by the caller, in one batched call. density_dec_tmp = decay_densities[decaying_idx + i_decay_event] + elif self._decay_lab_boost(init_part[decaying_idx + i_decay_event], + frame_boost) is not None: + # evaluated at rest, boosted by the library; the event itself + # is boosted by add_decays if this trial is accepted + part = init_part[decaying_idx + i_decay_event] + density_dec_tmp = self.get_density( + current_decay_event, position=[1], + allow_hel=helicities[decaying_idx + i_decay_event], + ncomb=len(helicities[decaying_idx + i_decay_event]), + dimension=len(helicities[decaying_idx + i_decay_event]), + lab_boost=(part.E, part.px, part.py, part.pz)) else: # boost to lab frame using corresponding production particle momentum part = init_part[decaying_idx + i_decay_event] @@ -12141,6 +12368,29 @@ def calculate_matrix_element_from_density(self, production, decays, decay_dict, return me, density_prod, prod_diag, dec_diag, jac_reshuffle + def _hoisted_production(self, source, trial, decays_key): + """The production-event part of calculate_matrix_element_from_density + for ``trial``, a copy of ``source`` that the trial reshuffles: the + density basis and the on-shell |M_prod|^2 depend on the production + event and on which pdgs decay only, so they are worked out + once on ``source`` and handed to every copy -- the decaying particles + re-pointed to the copy's own, which are the ones the trial reshuffles. + """ + cache = source.__dict__.get('_ms_hoisted') + if cache is None: + cache = source._ms_hoisted = {} + entry = cache.get(decays_key) + if entry is None: + static = self._density_basis(source, decays_key) + index = [next(i for i, q in enumerate(source) if q is part) + for part in static['init_part']] + entry = cache[decays_key] = (static, index, + self._onshell_production_norm(source, static)) + static, index, norm = entry + trial_static = dict(static) + trial_static['init_part'] = [trial[i] for i in index] + return trial_static, norm + def get_density_matrix_indices(self, nhel_decay): #print("------") #print(f"get_density_matrix_indices , nhel_decay = {nhel_decay}") @@ -12402,9 +12652,56 @@ def to_rest(momenta, frame): out[rest_leg - 1] = (out[rest_leg - 1][0], 0., 0., 0.) return out + def _density_flavour(self, event): + """What get_density needs of the flavours of event, for every event + with the same particles (pid, status) in the same order, which is all + that get_pdir, get_mapping (no decay_chain) and get_pdg look at: + (orig_order, tag, legs, pdgs), legs[k] = (index in the event, crossed) + of the k-th leg of the matrix element -- what get_momenta reads off + get_mapping -- and pdgs the raw ones when the legs are merged + particles, as get_pdg finds them. None (and get_density takes its + general path) when no single assignment exists. + + The trial copies of a production event and the decays of a channel + all share one entry: get_pdir, get_mapping and get_pdg were redone on + every trial (~5 us per call).""" + key = tuple((part.pid, part.status) for part in event) + cache = self.__dict__.setdefault('_ms_density_flavours', {}) + try: + return cache[key] + except KeyError: + pass + _, orig_order, _, _, tag = self.get_pdir(event) + try: + event_pos2order, _ = event.get_mapping( + orig_order, merged_map=self._revert_merged or None) + except Exception: + cache[key] = None + return None + ninitial = len(orig_order[0]) + legs = [None] * (ninitial + len(orig_order[1])) + position = -1 + for index, part in enumerate(event): + if abs(part.status) != 1: + continue + position += 1 + leg = event_pos2order[position] + legs[leg] = (index, (part.status == -1 and leg >= ninitial) or + (part.status == 1 and leg < ninitial)) + if None in legs: + cache[key] = None + return None + pdgs = list(orig_order[0]) + list(orig_order[1]) + merged_particles = self.model.get('merged_particles') or {} + if self._revert_merged and any(abs(pid) in merged_particles for pid in pdgs): + pdgs = [-event[index].pid if crossed else event[index].pid + for index, crossed in legs] + cache[key] = (orig_order, tag, legs, pdgs) + return cache[key] + def get_density(self, event, position, allow_hel, ncomb, dimension, frame_boost=None, frame_rest_leg=-1, hel_restriction=None, - hel_restriction_trace=None): + hel_restriction_trace=None, lab_boost=None): """``frame_boost`` is the momentum whose rest frame ``frame_id`` picks (see ``_frame_boost``); the momenta are boosted there before the matrix element sees them, which is what defines the axis the initial-state @@ -12420,52 +12717,65 @@ def get_density(self, event, position, allow_hel, ncomb, dimension, callers pass ``_decay_frame_rest_leg``'s answer instead. """ - orig_order = getattr(event, '_ms_orig_order_for_density', None) - if orig_order is None: - _, orig_order, _, _, tag = self.get_pdir(event) - event._ms_orig_order_for_density = orig_order - # cache the tag get_pdir resolved rather than recomputing it: it is - # the MERGED-pdg tag (all_me is keyed by 81/-81, not by the raw - # event pdgs, so a bare get_tag_and_order() raises KeyError on the - # lookup below -- e.g. ((-1, 1), (23, 23)) against ((-81, 81), - # (23, 23))), and get_pdir also owns the 1 -> N antiparticle - # fallback, which no recomputation here would reproduce. - event._ms_tag_for_density = tag - else: #in any case, we need tag to differentiate between production and decay - tag = getattr(event, '_ms_tag_for_density', None) - if tag is None: - tag, _ = event.get_tag_and_order( - merged_particle=self._revert_merged or None) - - - # Fast path: single-point momentum extraction without permutation - # construction. With apply_flavor_grouping, orig_order contains the - # merged-particle PDGs (e.g. 81) while the event carries the raw - # PDGs (e.g. 1, 2, 3): get_momenta must be told about the merge so - # that final.index(pdg) succeeds. - try: - p = event.get_momenta(orig_order, merged_map=self._revert_merged or None) - except Exception: - # Safety fallback for unusual event structures. - all_p = event.get_all_momenta(orig_order, merged_map=self._revert_merged or None) - assert len(all_p) == 1, "Error: get_density can only be called for a single phase-space point" - p = all_p[0] - # f77_density runs `flavormapping` on the pdgs we pass: when the ME - # legs use merged-particle IDs, we must pass the concrete raw PDGs - # from the event for this permutation so the flavormapping picks - # the right per-particle flavor index for GET_DENSITY. Same logic - # as calculate_matrix_element's pdg_for_call handling. - # This has to happen while `p` is still the event's own momenta: - # get_pdg identifies particles by exact momentum equality, so it must - # run before the projection below and before any frame boost. - pdg_template = list(orig_order[0]) + list(orig_order[1]) - merged_particles = self.model.get('merged_particles') or {} - need_raw_pdg = (self._revert_merged and - any(abs(pid) in merged_particles for pid in pdg_template)) - if need_raw_pdg: - pdgs = event.get_pdg(p) + # The flavour structure of the event -- the matrix element's order, its + # tag, where each of its legs sits in the event, the pdgs to pass -- is + # shared by every event with the same particles in the same order: + # the trial copies of one production event, the decays of a channel + # (_density_flavour). + flavour = self._density_flavour(event) + if flavour is not None: + orig_order, tag, legs, flavour_pdgs = flavour + p = [(-part.E, -part.px, -part.py, -part.pz) if flip else + (part.E, part.px, part.py, part.pz) + for part, flip in [(event[i], flip) for i, flip in legs]] + pdgs = list(flavour_pdgs) else: - pdgs = pdg_template + orig_order = getattr(event, '_ms_orig_order_for_density', None) + if orig_order is None: + _, orig_order, _, _, tag = self.get_pdir(event) + event._ms_orig_order_for_density = orig_order + # cache the tag get_pdir resolved rather than recomputing it: it is + # the MERGED-pdg tag (all_me is keyed by 81/-81, not by the raw + # event pdgs, so a bare get_tag_and_order() raises KeyError on the + # lookup below -- e.g. ((-1, 1), (23, 23)) against ((-81, 81), + # (23, 23))), and get_pdir also owns the 1 -> N antiparticle + # fallback, which no recomputation here would reproduce. + event._ms_tag_for_density = tag + else: #in any case, we need tag to differentiate between production and decay + tag = getattr(event, '_ms_tag_for_density', None) + if tag is None: + tag, _ = event.get_tag_and_order( + merged_particle=self._revert_merged or None) + + + # Fast path: single-point momentum extraction without permutation + # construction. With apply_flavor_grouping, orig_order contains the + # merged-particle PDGs (e.g. 81) while the event carries the raw + # PDGs (e.g. 1, 2, 3): get_momenta must be told about the merge so + # that final.index(pdg) succeeds. + try: + p = event.get_momenta(orig_order, merged_map=self._revert_merged or None) + except Exception: + # Safety fallback for unusual event structures. + all_p = event.get_all_momenta(orig_order, merged_map=self._revert_merged or None) + assert len(all_p) == 1, "Error: get_density can only be called for a single phase-space point" + p = all_p[0] + # f77_density runs `flavormapping` on the pdgs we pass: when the ME + # legs use merged-particle IDs, we must pass the concrete raw PDGs + # from the event for this permutation so the flavormapping picks + # the right per-particle flavor index for GET_DENSITY. Same logic + # as calculate_matrix_element's pdg_for_call handling. + # This has to happen while `p` is still the event's own momenta: + # get_pdg identifies particles by exact momentum equality, so it must + # run before the projection below and before any frame boost. + pdg_template = list(orig_order[0]) + list(orig_order[1]) + merged_particles = self.model.get('merged_particles') or {} + need_raw_pdg = (self._revert_merged and + any(abs(pid) in merged_particles for pid in pdg_template)) + if need_raw_pdg: + pdgs = event.get_pdg(p) + else: + pdgs = pdg_template # aMC@NLO writes its LHE with the partons on the Monte-Carlo mass # shell while the matrix element is a massless one; undo that here, in # the lab, *before* the frame boost (see @@ -12486,40 +12796,40 @@ def get_density(self, event, position, allow_hel, ncomb, dimension, # moves an ordinary reweight weight by ~1e-6 -- enough to break a # 6-significant-figure comparison, for no gain where there is no # frame to be consistent with. - p = lhe_parser.project_massless_partons( - p, pdgs, self.model, n_initial=len(orig_order[0]), - final_state=True) + p = self._project_massless(p, pdgs, len(orig_order[0]), final_state=True) if frame_boost is not None: p = self._boost_momenta(p, frame_boost, rest_leg=frame_rest_leg) - P = rwgt_interface.ReweightInterface.invert_momenta(p) n_changing = len(position) if n_changing == 0: raise ValueError("Error in get_density: 'position' must contain at least one position index") if len(allow_hel) % n_changing != 0: raise ValueError("Error in get_density: inconsistent 'allow_hel' and 'position' lengths") - - # PY_GET_DENSITY(PDGS, PROCID, P, POS, ALLOW_HEL, ALPHAS, SCALE2) - if self.all_me[tag]['type'] == 'production': - # misc.sprint("Computation of the production density matrix") - density_array = self.f2py_module[0].py_get_density(pdgs=pdgs, - procid=-1, - p=P, - pos=position, - allow_hel=allow_hel, - alphas=event.aqcd, - scale2=event.scale**2) + if self.all_me[tag]['type'] == 'production': + module = self.f2py_module[0] elif self.all_me[tag]['type'] == 'decay': - # misc.sprint("Computation of the decay density matrix") - density_array = self.f2py_module[1].py_get_density(pdgs=pdgs, - procid=-1, - p=P, - pos=position, - allow_hel=allow_hel, - alphas=event.aqcd, - scale2=event.scale**2) + module = self.f2py_module[1] else: raise ValueError("The key 'type' of sel.all_me can only take as values 'production' or 'decay'.") + if isinstance(module, madmatrix_density.MadDensityLibrary): + # the momenta as they are, (E, px, py, pz) per leg; lab_boost (a + # decay still at rest, see _decay_lab_boost) is applied by the + # library + density_array = module.density(pdgs, p, position, allow_hel, event.aqcd, + boost=lab_boost) + else: + if lab_boost is not None: + raise ValueError('get_density: lab_boost needs the madmatrix backend') + # PY_GET_DENSITY(PDGS, PROCID, P, POS, ALLOW_HEL, ALPHAS, SCALE2) + P = rwgt_interface.ReweightInterface.invert_momenta(p) + density_array = module.py_get_density(pdgs=pdgs, + procid=-1, + p=P, + pos=position, + allow_hel=allow_hel, + alphas=event.aqcd, + scale2=event.scale**2) + self._check_f2py_density(density_array, pdgs) #print(f"density_array = {density_array}") @@ -12541,6 +12851,18 @@ def get_density(self, event, position, allow_hel, ncomb, dimension, return density_matrix + @staticmethod + def _check_f2py_density(density_array, pdgs): + """Refuse the NaN that the f2py density answers for PDG codes no + matrix element of the module evaluates in this order + (f77_no_process in all_matrix.f), as the madmatrix backend raises + MadDensityError in that case. A NaN density would otherwise spread + into the MadSpin weights of every event contracted against it.""" + if any(z != z for z in density_array): + raise madmatrix_density.MadDensityError( + 'no matrix element of the f2py module evaluates the process ' + '%s (NaN density)' % (list(pdgs),)) + def get_density_batch(self, events, position, allow_hel, ncomb, dimension): """get_density for a list of events sharing one helicity structure. @@ -12604,12 +12926,23 @@ def get_density_batch(self, events, position, allow_hel, ncomb, dimension): pdgs[k] = event.get_pdg(p) if need_raw_pdg else pdg_template # same Monte-Carlo-mass projection as get_density, initial and # final state, on a copy - momenta[k] = lhe_parser.project_massless_partons( - p, pdgs[k], self.model, n_initial=len(orig_order[0])) + momenta[k] = self._project_massless(p, pdgs[k], len(orig_order[0])) groups.setdefault((len(p), len(pdgs[k]), module_index), []).append(k) out = [None] * len(events) for (next_, npdg, module_index), idx in groups.items(): + module = self.f2py_module[module_index] + if isinstance(module, madmatrix_density.MadDensityLibrary): + inter = module.density_batch([pdgs[k] for k in idx], + [momenta[k] for k in idx], + position, allow_hel, + [events[k].aqcd for k in idx]) + for c, k in enumerate(idx): + out[k] = madspin.DensityMatrix(inter[c], + n_changing, + allow_hel, + dimension) + continue nbatch = len(idx) # (0:3, next, nbatch) fortran-ordered. get_momenta hands back one # (E,px,py,pz) tuple per particle, so the transpose that @@ -12625,7 +12958,6 @@ def get_density_batch(self, events, position, allow_hel, ncomb, dimension): scale2[c] = events[k].scale ** 2 # PY_GET_DENSITY_BATCH(PDGS, PROCID, P, POS, ALLOW_HEL, ALPHAS, # SCALE2, NBATCH, NEXT) - module = self.f2py_module[module_index] inter = module.py_get_density_batch(pdgs=all_pdgs, procid=-1, p=P, @@ -12636,6 +12968,7 @@ def get_density_batch(self, events, position, allow_hel, ncomb, dimension): nbatch=nbatch, next=next_) for c, k in enumerate(idx): + self._check_f2py_density(inter[:, c], pdgs[k]) out[k] = madspin.DensityMatrix(inter[:, c], n_changing, allow_hel, @@ -12681,6 +13014,20 @@ def get_iden(self, event): return idens[pos] + def _all_momenta(self, event, orig_order): + """event.get_all_momenta(orig_order): the momenta for every assignment + of identical final-state particles to the legs. With no (merged) pdg + repeated in the final state there is only the one get_momenta gives, + and that is what get_all_momenta returns then too, after redoing the + same mapping to find nothing to permute.""" + merged = self._revert_merged or None + finals = [p.pid for p in event if p.status == 1] + if merged: + finals = [merged.get(pid, pid) for pid in finals] + if len(set(finals)) == len(finals): + return [event.get_momenta(orig_order, merged_map=merged)] + return event.get_all_momenta(orig_order, merged_map=merged) + def get_pdir(self,event): # Use the merged-PDG tag (same as calculate_matrix_element). MadSpin's # all_me is keyed by the merged-particle representation when @@ -12712,17 +13059,42 @@ def get_pdir(self,event): pdir = self.all_me[tag]['pdir'] if self.all_me[tag]['type'] == 'production': - prefix, pos = self.pdg2prefix[0][tuple(list(orig_order[0]) + list(orig_order[1]))] + pdg2prefix = self.pdg2prefix[0] elif self.all_me[tag]['type'] == 'decay': - prefix, pos = self.pdg2prefix[1][tuple(list(orig_order[0]) + list(orig_order[1]))] + pdg2prefix = self.pdg2prefix[1] else: raise ValueError("The key 'type' of self.all_me can only take as values 'production' or 'decay'.") + # a crossed process folded onto a base (decay.crossed_processes_with_decays) + # is not in the module's process list: its library and slot are the base's + # (the iden of that slot cancels in the density normalisation, see + # _density_basis); its density and |M|^2 come from its own pdg codes + key = tuple(list(orig_order[0]) + list(orig_order[1])) + if key not in pdg2prefix and 'base_order' in self.all_me[tag]: + base_order = self.all_me[tag]['base_order'] + key = tuple(list(base_order[0]) + list(base_order[1])) + prefix, pos = pdg2prefix[key] #misc.sprint(f"get_pdir: pdir = {pdir} , orig_order = {orig_order} , prefix = {prefix}") # Cache under the tag we were asked about, not the anti-particle tag the # fallback above may have rewritten it to. self._pdir_cache[event_tag] = (pdir, orig_order, prefix, pos, tag) return pdir,orig_order, prefix, pos, tag + # no run_card in the banner: the partons may be anywhere, keep projecting + _input_on_mc_mass_shell = True + + def _project_massless(self, momenta, pdgs, n_initial, final_state=False): + """lhe_parser.project_massless_partons, skipped where there is + nothing to project: only aMC@NLO puts massless partons on a Monte-Carlo + mass shell, so neither an LO production event nor a decay (one initial + leg), which MadSpin generated itself, needs it. Run anyway it rescales + legs whose mass is a rounding error -- the projection triggers on any + m^2 != 0 -- for ~40 us per density matrix.""" + if n_initial == 1 or not self._input_on_mc_mass_shell: + return momenta + return lhe_parser.project_massless_partons(momenta, pdgs, self.model, + n_initial=n_initial, + final_state=final_state) + # Two model_init are used, one for the production and one the decay (to support LO decay + NLO production or different models for each side) model_init_prod = True model_init_decay = True @@ -12744,14 +13116,15 @@ def calculate_matrix_element(self, event): orig_order = self.all_me[tag]['order'] pdir = self.all_me[tag]['pdir'] if pdir in self.all_f2py: - all_p = event.get_all_momenta(orig_order, merged_map=self._revert_merged or None) - if self.options['identical_particle_in_prod_and_decay'] == "crash" and\ - len(all_p)> 1: + all_p = self._all_momenta(event, orig_order) + identical = self.options['identical_particle_in_prod_and_decay'] + if identical == "crash" and len(all_p)> 1: raise Exception("Ambiguous particle in production and decay. crash as requested by 'identical_particle_in_prod_and_decay'") # When the ME legs use merged-particle IDs (apply_flavor_grouping # is on for MadSpin), smatrixhel still needs the concrete raw PDGs # from the event for the current permutation; otherwise it cannot - # pick the right flavor branch and returns 0. See the analogous + # pick the right flavor branch and answers NaN (an ERROR line, no + # matrix element for those PDG codes). See the analogous # logic in ReweightInterface.calculate_matrix_element. pdg_template = list(orig_order[0]) + list(orig_order[1]) merged_particles = self.model.get('merged_particles') or {} @@ -12764,24 +13137,22 @@ def calculate_matrix_element(self, event): # undo the Monte-Carlo mass shell aMC@NLO writes its partons # on, initial and final state -- on a copy, the event keeps its # own momenta - p = lhe_parser.project_massless_partons( - p, pdg_for_call, self.model, n_initial=len(orig_order[0])) - p_inv = rwgt_interface.ReweightInterface.invert_momenta(p) + p = self._project_massless(p, pdg_for_call, len(orig_order[0])) if event[0].color1 == 599 and event.aqcd==0: - new_value = self.all_f2py[pdir](pdg_for_call, p_inv, 0.113, 0) + new_value = self.all_f2py[pdir](pdg_for_call, p, 0.113, 0) else: - new_value = self.all_f2py[pdir](pdg_for_call, p_inv, event.aqcd, event.scale, -1) + new_value = self.all_f2py[pdir](pdg_for_call, p, event.aqcd, event.scale) #if the process is Loop-Induced, smatrixhel returns the tuple (value, returncode), we need to keep only the value if isinstance(new_value, tuple): new_value = new_value[0] - if self.options['identical_particle_in_prod_and_decay'] == "average": + if identical == "average": out += new_value else: if abs(out)< abs(new_value): out = new_value - if self.options['identical_particle_in_prod_and_decay'] == 'first': + if identical == 'first': return out - if self.options['identical_particle_in_prod_and_decay'] == "average": + if identical == "average": return out/len(all_p) else: return out @@ -12850,15 +13221,29 @@ def calculate_matrix_element(self, event): # pdg is passed per call (not captured here) so that callers can # substitute concrete event PDGs when the ME legs use merged IDs. if self.options['spinmode'] in ['onshell_v1', 'madspin_v1']: - self.all_f2py[pdir] = lambda pdg_list, *args : mymod.smatrixhel(pdg_list, 0, *args) + module = mymod + elif self.all_me[tag]['type'] == 'production': + module = mymod[0] + elif self.all_me[tag]['type'] == 'decay': + module = mymod[1] else: - if self.all_me[tag]['type'] == 'production': - self.all_f2py[pdir] = lambda pdg_list, *args : mymod[0].smatrixhel(pdg_list, 0, *args) - elif self.all_me[tag]['type'] == 'decay': - self.all_f2py[pdir] = lambda pdg_list, *args : mymod[1].smatrixhel(pdg_list, 0, *args) - else: - raise ValueError("The key 'type' of self.all_me can only take as values 'production' or 'decay'.") + raise ValueError("The key 'type' of self.all_me can only take as values 'production' or 'decay'.") + self.all_f2py[pdir] = self._matrix_element_call(module) return self.calculate_matrix_element(event) + + @staticmethod + def _matrix_element_call(module): + """(pdgs, momenta, alpha_s, scale) -> the helicity-summed |M|^2 of + module, the momenta being (E, px, py, pz) per leg in the leg order of the + process.""" + if isinstance(module, madmatrix_density.MadDensityLibrary): + return lambda pdg_list, p, alphas, scale: \ + module.matrix_element(pdg_list, p, alphas) + # f2py: momenta as the Fortran P(0:3, NEXTERNAL), and the helicity sum is + # NHEL = -1 + invert = rwgt_interface.ReweightInterface.invert_momenta + return lambda pdg_list, p, alphas, scale: \ + module.smatrixhel(pdg_list, 0, invert(p), alphas, scale, -1) def generate_all_matrix_element(self): diff --git a/Template/Common/Cards/madspin_card_default.dat b/Template/Common/Cards/madspin_card_default.dat index 62683dcbef..f9e4f71378 100644 --- a/Template/Common/Cards/madspin_card_default.dat +++ b/Template/Common/Cards/madspin_card_default.dat @@ -16,6 +16,9 @@ # set seed 1 # set Nevents_for_max_weight 75 # number of events for the estimate of the max. weight # set BW_cut 15 # cut on how far the particle can be off-shell +# set offshell_min_recoil 0.5 # madspin/full: smallest momentum fraction a massless +# # recoil (a jet, a photon) may keep when the production +# # is reshuffled onto the drawn virtualities (0 = off) # set spinmode XXXXX # Use one of the madspin special modes # allowed spinmode: # - PA : Pole approximation -- onshell matrix-element + reshuffling. diff --git a/Template/Common/Source/PDF/gammaUPC/makefile b/Template/Common/Source/PDF/gammaUPC/makefile index 2248c92c40..8ca21c53cf 100644 --- a/Template/Common/Source/PDF/gammaUPC/makefile +++ b/Template/Common/Source/PDF/gammaUPC/makefile @@ -12,22 +12,27 @@ photonflux=tbessk.o tbessj.o elliptic_integral.o nielsen_generalized_polylog.o p all: $(LIBDIR)$(LIBRARY) +# A module and its .mod file come from one compile of its +# source: only the .o is a target, so a parallel make never +# runs that compile twice (the two would race on the .mod). +# Modules a file USEs are prerequisites through their .o. + $(LIBDIR)$(LIBRARY): $(photonflux) $(call CREATELIB, $@, $^) $(EXE): test.o $(photonflux) $(FC) $(FFLAGS) -o $(EXE) test.o $(photonflux) -test.o: test.f90 ElasticPhotonPhotonFlux.mod OpticalGlauber_Geometry.mod +test.o: test.f90 ElasticPhotonPhotonFlux.o OpticalGlauber_Geometry.o $(FC) $(FFLAGS) -c $< -ElasticPhotonPhotonFlux.o ElasticPhotonPhotonFlux.mod: ElasticPhotonPhotonFlux.f90 OpticalGlauber_Geometry.mod nintlib.mod interpolation.mod fmin_module.mod photoabsorption.mod +ElasticPhotonPhotonFlux.o: ElasticPhotonPhotonFlux.f90 OpticalGlauber_Geometry.o nintlib.o interpolation.o fmin_module.o photoabsorption.o $(FC) $(FFLAGS) -c $< -photoabsorption.o photoabsorption.mod: photoabsorption.f90 +photoabsorption.o: photoabsorption.f90 $(FC) $(FFLAGS) -c $< -photonpdfsquare.o : photonpdfsquare.f ElasticPhotonPhotonFlux.mod +photonpdfsquare.o : photonpdfsquare.f ElasticPhotonPhotonFlux.o $(FC) $(FFLAGS) -c $< tbessk.o: tbessk.f90 @@ -39,19 +44,19 @@ tbessj.o: tbessj.f90 elliptic_integral.o: elliptic_integral.f90 $(FC) $(FFLAGS) -c $< -nielsen_generalized_polylog.o nielsen_generalized_polylog.mod: nielsen_generalized_polylog.f90 +nielsen_generalized_polylog.o: nielsen_generalized_polylog.f90 $(FC) $(FFLAGS) -c $< -interpolation.o interpolation.mod: interpolation.f90 +interpolation.o: interpolation.f90 $(FC) $(FFLAGS) -c $< -fmin_module.o fmin_module.mod: fmin_module.f90 +fmin_module.o: fmin_module.f90 $(FC) $(FFLAGS) -c $< -OpticalGlauber_Geometry.o OpticalGlauber_Geometry.mod: OpticalGlauber_Geometry.f90 nintlib.mod interpolation.mod nielsen_generalized_polylog.mod +OpticalGlauber_Geometry.o: OpticalGlauber_Geometry.f90 nintlib.o interpolation.o nielsen_generalized_polylog.o $(FC) $(FFLAGS) -c $< -nintlib.o nintlib.mod: nintlib.f90 +nintlib.o: nintlib.f90 $(FC) $(FFLAGS) -c $< clean_all: diff --git a/Template/LO/SubProcesses/unwgt.f b/Template/LO/SubProcesses/unwgt.f index eb7565e1d8..6377b77065 100644 --- a/Template/LO/SubProcesses/unwgt.f +++ b/Template/LO/SubProcesses/unwgt.f @@ -143,44 +143,41 @@ subroutine clear_events C************************************************************************** C HELPING ROUTINE FOR PERFORMING THE Z BOOST OF THE EVENTS C************************************************************************** - double precision function get_betaz(pin,pout) -C compute the boost for the requested transformation + double precision function get_rapz(pin,pout) +C rapidity of the z boost taking pin to pout (same mass). +C The light-cone ratio stays accurate at small parton x, +C where beta -> 1 lost digits to the beam mass. implicit none double precision pin(0:3), pout(0:3) - double precision denom, control + double precision num, den - denom = pin(0)*pout(0) - pin(3)*pout(3) - if (denom.ne.0d0) then - control = pin(0)*pout(0) + pin(3)*pout(3) - if (denom/control.gt.1d-12)then - get_betaz = (pin(3) * pout(0) - pout(3) * pin(0)) / denom -c pass in massless case - else if (abs(pin(0)+pin(3)).gt.abs(pin(0))) then - get_betaz = (pin(0)**2 - pout(0)**2)/(pin(0)**2 + pout(0)**2) - else - get_betaz = (pout(0)**2 - pin(0)**2)/(pin(0)**2 + pout(0)**2) - endif - else if (pin(0).eq.pin(3)) then - get_betaz = (pin(0)**2 - pout(0)**2)/(pin(0)**2 + pout(0)**2) - else if (pin(0).eq.abs(pin(3))) then - get_betaz = (pout(0)**2 - pin(0)**2)/(pin(0)**2 + pout(0)**2) + if (pin(3)+pout(3).ge.0d0) then + num = pin(0) + pin(3) + den = pout(0) + pout(3) + else + num = pout(0) - pout(3) + den = pin(0) - pin(3) + endif + if (num.gt.0d0.and.den.gt.0d0) then + get_rapz = log(num/den) else - get_betaz = 0d0 + get_rapz = 0d0 endif return end - subroutine zboost_with_beta(pin, beta, pout) -c apply the boost + subroutine zboost_with_rap(pin, rap, pout) +c apply the boost on the light-cone components E+pz, E-pz implicit none double precision pin(0:3), pout(0:3) - double precision beta, gamma + double precision rap, pplus, pminus - gamma = 1d0/DSQRT(1-beta**2) - pout(0) = gamma * pin(0) - gamma * beta * pin(3) + pplus = (pin(0) + pin(3)) * exp(-rap) + pminus = (pin(0) - pin(3)) * exp(rap) + pout(0) = 0.5d0 * (pplus + pminus) pout(1) = pin(1) pout(2) = pin(2) - pout(3) = - gamma * beta * pin(0) + gamma * pin(3) + pout(3) = 0.5d0 * (pplus - pminus) return end @@ -553,7 +550,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) integer iseed double precision beam_mass double precision pboost(0:3) - double precision beta, get_betaz + double precision rap, get_rapz double precision ebi(0:3), ebo(0:3) double precision ptcltmp(nexternal), pdum(0:3) integer beam_number @@ -701,6 +698,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) pboost(1)=0d0 pboost(2)=0d0 pboost(3)=0d0 + rap=0d0 if (nincoming.eq.2) then if (xbk(1) .gt. 0d0 .and. xbk(1) .le. 1d0 .and. $ xbk(2) .gt. 0d0 .and. xbk(2) .le. 1d0) then @@ -718,7 +716,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) ebo(1) = 0 ebo(2) = 0 ebo(3) = DSQRT(ebo(0)**2-beam_mass**2) - beta = get_betaz(ebi, ebo) + rap = get_rapz(ebi, ebo) if (xbk(1).eq.1d0) then pb(0,isym(1,jsym)) = ebo(0) pb(1,isym(1,jsym)) = ebo(1) @@ -728,7 +726,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) endif do j=1,nexternal if (j.eq.1.and.xbk(1).eq.1d0) cycle - call zboost_with_beta(p(0,j),beta,pb(0,isym(j,jsym))) + call zboost_with_rap(p(0,j),rap,pb(0,isym(j,jsym))) pb(4,isym(j,jsym))=pmass(j) enddo @@ -746,7 +744,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) ebo(1) = 0 ebo(2) = 0 ebo(3) = -1d0*DSQRT(ebo(0)**2-beam_mass**2) - beta = get_betaz(ebi, ebo) + rap = get_rapz(ebi, ebo) if (xbk(2).eq.1d0) then pb(0,isym(2,jsym)) = ebo(0) pb(1,isym(2,jsym)) = ebo(1) @@ -756,7 +754,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) endif do j=1,nexternal if (j.eq.2.and.xbk(2).eq.1d0) cycle - call zboost_with_beta(p(0,j),beta,pb(0,isym(j,jsym))) + call zboost_with_rap(p(0,j),rap,pb(0,isym(j,jsym))) pb(4,isym(j,jsym))=pmass(j) enddo ! wrong boost if both parton are massive! @@ -766,7 +764,7 @@ SUBROUTINE write_leshouche(p,wgt,numproc,do_write_events, ihel, icol, ivec) $ xbk(1),xbk(2) endif do j=1,nexternal - call zboost_with_beta(p(0,j),beta,pb(0,isym(j,jsym))) + call zboost_with_rap(p(0,j),rap,pb(0,isym(j,jsym))) pb(4,isym(j,jsym))=pmass(j) enddo diff --git a/Template/loop_material/StandAlone/SubProcesses/makefile b/Template/loop_material/StandAlone/SubProcesses/makefile index a2a335aa9a..6f48b552ed 100644 --- a/Template/loop_material/StandAlone/SubProcesses/makefile +++ b/Template/loop_material/StandAlone/SubProcesses/makefile @@ -176,6 +176,11 @@ ifeq ($(origin MENUM),undefined) MENUM=2 endif +# name of the f2py python module. A caller importing a rebuild of this +# directory in a python process that already imported the previous build has +# to give it (and ROOTNAME) a new value: a module cannot be imported twice. +F2PYMOD= all_matrix$(MENUM)py + liball$(ROOTNAME)_$(MENUM)me.$(dylibext): all_matrix.o libMadLoop.$(dylibext) $(WRAPPER_OBJS) $(LIBS) $(OLP) $(CXX) $(DYNLIBFLAG) $(LIBALLME_DYNFLAG) $(STDLIB_FLAG) -o liball$(ROOTNAME)_$(MENUM)me.$(dylibext) all_matrix.o */f2py_wrapper.o ../Source/DHELAS/*.o ../Source/MODEL/*.o $(STAT_LIB) $(RPATH_LIBS) $(LINK_LOOP_LIBS) $(ALL_DOTO) $(STDLIB) $(LINK_LOOP_LIBS) @@ -201,7 +206,7 @@ allmatrix$(MENUM)py.so: $(OLP)_static all_matrix.f $(LIBS) $(WRAPPER) all_matrix$(MENUM)py.so: liball$(ROOTNAME)_$(MENUM)me.$(dylibext) f2py_wrapper.f makefile - LDFLAGS="-Wl,-rpath,$(HERE) -L$(HERE) $(RPATH_LIBS) $(LINK_LOOP_LIBS) $(LD_F2PY)" $(F2PY) -c f2py_wrapper.f -L$(HERE) -lall$(ROOTNAME)_$(MENUM)me -m all_matrix$(MENUM)py + LDFLAGS="-Wl,-rpath,$(HERE) -L$(HERE) $(RPATH_LIBS) $(LINK_LOOP_LIBS) $(LD_F2PY)" $(F2PY) -c f2py_wrapper.f -L$(HERE) -lall$(ROOTNAME)_$(MENUM)me -m $(F2PYMOD) touch all_matrix$(MENUM)py.so touch __init__.py diff --git a/aloha/template_files/madmatrix/helas.h b/aloha/template_files/madmatrix/helas.h index a243ce0d96..bf2e0bf0d8 100644 --- a/aloha/template_files/madmatrix/helas.h +++ b/aloha/template_files/madmatrix/helas.h @@ -94,6 +94,22 @@ //-------------------------------------------------------------------------- + // Compute the output wavefunction vc[6] from the input momenta[npar*4*nevt], + // in the axial gauge of the momentum of particle iref (see the definition) + template + __host__ __device__ INLINE void + vxxxxxr( const fptype_momenta momenta[], // input: momenta + const fptype_amp vmass, // input: vector boson mass + const int nhel, // input: -1, 0 (only if vmass!=0) or +1 (helicity of vector boson) + const int nsv, // input: +1 (final) or -1 (initial) + const int flv, // input: flavor index + ALOHAOBJ & vc, // output: wavefunctions + const int ipar, // input: particle# out of npar + const int iref // input: particle# of the gauge reference momentum + ) ALWAYS_INLINE; + + //-------------------------------------------------------------------------- + // Compute the output wavefunction sc[3] from the input momenta[npar*4*nevt] template __host__ __device__ INLINE void @@ -616,6 +632,66 @@ //-------------------------------------------------------------------------- + // Compute the output wavefunction vc[6] from the input momenta[npar*4*nevt], + // in the axial gauge of the momentum r of particle iref, i.e. with r.eps = 0. + // This is vxxxxx followed by the residual gauge transformation + // eps'^mu = eps^mu - (r.eps)/(r.p) p^mu + // which, for a massless p, keeps eps'.p = 0, eps'.eps' = 0 and eps'.eps'* = -1 + // and so returns the same helicity state up to a phase that cancels in |M|^2. + // With r the (lightlike) momentum of another external particle, eps.r = 0 and, + // for legs sharing r, eps_i.eps_j = 0 for same-helicity pairs: whole currents + // and amplitudes vanish for given helicities (arXiv:2312.07447). + // r is ignored for a massive vector and wherever r.p = 0, in particular when + // iref is ipar itself (see vxxxxxr in aloha_functions.f) + template + __host__ __device__ void + vxxxxxr( const fptype_momenta momenta[], // input: momenta + const fptype_amp vmass, // input: vector boson mass + const int nhel, // input: -1, 0 (only if vmass!=0) or +1 (helicity of vector boson) + const int nsv, // input: +1 (final) or -1 (initial) + const int flv, // input: flavour + ALOHAOBJ & vc, // output: wavefunctions + const int ipar, // input: particle# out of npar + const int iref ) // input: particle# of the gauge reference momentum + { + vxxxxx( momenta, vmass, nhel, nsv, flv, vc, ipar ); + if( vmass != 0. || std::abs( nhel ) != 1 || iref == ipar ) return; + mgDebug( 0, __FUNCTION__ ); + const fptype_amp_sv r0 = fpamp_of_mom( M_ACCESS::kernelAccessIp4IparConst( momenta, 0, iref ) ); + const fptype_amp_sv r1 = fpamp_of_mom( M_ACCESS::kernelAccessIp4IparConst( momenta, 1, iref ) ); + const fptype_amp_sv r2 = fpamp_of_mom( M_ACCESS::kernelAccessIp4IparConst( momenta, 2, iref ) ); + const fptype_amp_sv r3 = fpamp_of_mom( M_ACCESS::kernelAccessIp4IparConst( momenta, 3, iref ) ); + // vc.pvec is nsv * p: the sign cancels between r.p and the shift along p + const fptype_amp_sv p0 = fpamp_of_mom( vc.pvec[0] ); + const fptype_amp_sv p1 = fpamp_of_mom( vc.pvec[1] ); + const fptype_amp_sv p2 = fpamp_of_mom( vc.pvec[2] ); + const fptype_amp_sv p3 = fpamp_of_mom( vc.pvec[3] ); + cxtype_amp_sv* w = W_ACCESS::kernelAccess( vc.w ); + const fptype_amp_sv rdotp = r0 * p0 - r1 * p1 - r2 * p2 - r3 * p3; + const cxtype_amp_sv rdote = w[0] * r0 - w[1] * r1 - w[2] * r2 - w[3] * r3; +#ifndef MGONGPU_CPPSIMD + if( rdotp == 0. ) + { + mgDebug( 1, __FUNCTION__ ); + return; + } + const cxtype_amp_sv cfac = rdote / rdotp; +#else + // as in vxxxxx: no division by zero in the lanes where r.p = 0 (#701), which keep eps + volatile fptype_amp_v rpDENOM = fpternary( rdotp != 0, rdotp, 1. ); + const fptype_amp_v rpINV = fptype_amp( 1 ) / rpDENOM; + const cxtype_amp_v cfac = cxternary( rdotp != 0, rdote * rpINV, cxzero_sv() ); +#endif + w[0] = w[0] - cfac * p0; + w[1] = w[1] - cfac * p1; + w[2] = w[2] - cfac * p2; + w[3] = w[3] - cfac * p3; + mgDebug( 1, __FUNCTION__ ); + return; + } + + //-------------------------------------------------------------------------- + // Compute the output wavefunction sc[3] from the input momenta[npar*4*nevt] template __host__ __device__ void diff --git a/docs/standalone_flavor_python.md b/docs/standalone_flavor_python.md index 36a77d2536..efb4ce18dc 100644 --- a/docs/standalone_flavor_python.md +++ b/docs/standalone_flavor_python.md @@ -70,8 +70,10 @@ unchanged. Calling the per-process `matrix2py` module directly, it is up to you to present a tabulated ordering. Going through the multi-process wrapper (`SubProcesses/all_matrix.f`, which is what MadSpin and reweighting use) nothing is needed: `FLAVOR_ORDER_REPAIR` finds a tabulated ordering and moves -the momenta with it. The one case it leaves alone is `smatrixhel` asked for a -single helicity row, since that row names the legs by position. +the momenta with it. A `smatrixhel` call for a single helicity code is moved +too: the code is re-keyed with the legs (`FLAVOR_ORDER_HEL`, read in the +process's own helicity table, `py_get_nhel_idx`), so every leg keeps +the helicity the caller gave it, crossed subprocesses included. --- diff --git a/madgraph/core/diagram_generation.py b/madgraph/core/diagram_generation.py index 44f702e4ee..dc997fbd51 100755 --- a/madgraph/core/diagram_generation.py +++ b/madgraph/core/diagram_generation.py @@ -2525,6 +2525,14 @@ class MultiProcess(base_objects.PhysicsObject): list of amplitudes (after generation) """ + # Position of each subprocess in the order a merge_crossing=False + # generation lists them, stamped on its Process as `generation_rank` when + # it is generated or recorded as a crossing. Expanding recorded crossings + # (MadGraphCmd._expand_recorded_crossings) puts them back at that position, + # so the expanded output -- its P directories, and the iproc.dat that + # offsets each one's random seed -- matches a --use_crossing=False one. + _generation_rank = itertools.count() + def default_setup(self): """Default values for all properties""" @@ -2955,6 +2963,8 @@ def get_fs_flavor(id, fsleg): process, permutations[crossed_index], permutation) + amplitude.get('process').generation_rank = \ + next(MultiProcess._generation_rank) amplitudes.append(amplitude) success_procs.append(sorted_legs) permutations.append(permutation) @@ -2968,6 +2978,8 @@ def get_fs_flavor(id, fsleg): # base's crossing-aware SMATRIX (its partonic # contribution is not lost, unlike merge_crossing=True). index, (base_perm, crossed_perm) = record + process.generation_rank = \ + next(MultiProcess._generation_rank) amplitudes[index].get('crossed_processes')\ .append((process, base_perm, crossed_perm)) logger.info("Crossed process %s recorded on %s " @@ -2991,6 +3003,8 @@ def get_fs_flavor(id, fsleg): else: # Succeeded in generating diagrams if amplitude.get('diagrams'): + amplitude.get('process').generation_rank = \ + next(MultiProcess._generation_rank) amplitudes.append(amplitude) success_procs.append(sorted_legs) permutations.append(permutation) @@ -3235,7 +3249,11 @@ def crossing_flavor_pairing(base_legs, crossed_legs, model): None sends it back to a matrix element of its own. When no leg is restricted the pairing is the id order (the - `permutation` of both processes), exactly as before.""" + `permutation` of both processes), exactly as before. + + An off-shell leg ('*') is evaluated at the invariant mass of its + momentum (the external call of its slot), so it pairs only with an + off-shell leg, and an on-shell leg only with an on-shell one.""" merged = model.get('merged_particles') or {} def flavors(leg): @@ -3247,19 +3265,23 @@ def flavors(leg): return None if flavor >= frozenset(group) else flavor def contains(base, crossed): + if base[1] != crossed[1]: + return False + base, crossed = base[0], crossed[0] return base is None or (crossed is not None and crossed <= base) def slots(legs): ids = base_objects.LegList(legs).get_outgoing_id_list(model) - return [(pid, i + 1, flavors(leg)) + return [(pid, i + 1, (flavors(leg), bool(leg.get('offshell')))) for i, (pid, leg) in enumerate(zip(ids, legs))] base, crossed = slots(base_legs), slots(crossed_legs) if sorted(p for p, _, _ in base) != sorted(p for p, _, _ in crossed): return None - if all(f is None for _, _, f in base + crossed): - return ([i for _, i in sorted((p, i) for p, i, _ in base)], + id_order = ([i for _, i in sorted((p, i) for p, i, _ in base)], [i for _, i in sorted((p, i) for p, i, _ in crossed)]) + if all(f == (None, False) for _, _, f in base + crossed): + return id_order base_perm, crossed_perm = [], [] for pid in sorted(set(p for p, _, _ in base)): base_group = [(i, f) for p, i, f in base if p == pid] diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 1c287f0df5..d598683d7c 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -3158,6 +3158,16 @@ def do_rivet(self, line, postprocess=False): #2 Prepare Rivet setup environments rivet_path = self.options['rivet_path'] yoda_path = self.options['yoda_path'] + # Rivet links against rivet, yoda and a full fastjet installation + # (fjcore, the fallback without fastjet-config, is not enough) + for key, what in [('rivet_path', 'rivet'), ('yoda_path', 'yoda'), + ('fastjet', 'fastjet-config')]: + value = self.options[key] + if not value or value in ['None', 'NA'] or \ + (key == 'fastjet' and not misc.which(value)): + raise self.InvalidCmd('Rivet requires %s, but option %s is "%s":' + ' install it (install rivet) or set %s in ' + 'mg5_configuration.txt.' % (what, key, value, key)) fastjet_path = subprocess.Popen([self.options['fastjet'], '--prefix'], stdout = subprocess.PIPE).stdout.read().decode(errors='ignore').strip() diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 725f0b7db3..e7e2d39e5e 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -778,11 +778,14 @@ def help_output(self): logger.info(" --jamp_orbit=[True|False]: [madevent|standalone_fortran|mg7] look for the shared color-factor sub-expressions by whole orbits of the color basis symmetry.") logger.info(" --t_strategy: [madevent] allows to change ordering strategy for t-channel.") logger.info(" --hel_recycling=False: [madevent] forbids helicity recycling optimization") - logger.info(" --axial_gauge=True: [madevent|standalone_fortran] build the polarisation of massless vectors in the axial gauge of another external (lightlike) leg, so that whole diagrams vanish (default:False).") + logger.info(" --hel_recycling=True: [standalone_fortran|standalone] build each wavefunction once per helicity copy instead of once per helicity row, and skip the amplitudes that vanish at a helicity (off by default; C++: CPU backends). A warm-up at generation time measures the good helicities and the vanishing amplitudes.") + logger.info(" --hel_recycling_chunk=: [standalone_fortran|standalone] statements per function when the recycled block is cut up (default 1500 Fortran, 200 C++; 0 keeps it in one piece).") + logger.info(" --axial_gauge=True: [madevent|standalone_fortran|standalone|mg7] build the polarisation of massless vectors in the axial gauge of another external (lightlike) leg, so that whole diagrams vanish (default:False).") logger.info(" --mask=False: [madevent|standalone_fortran] disable flavor-mask optimization for grouped/merged flavors (default:True).") logger.info(" --prefix=int|proc: [standalone_fortran] prefix matrix-element routine names (int: M_, proc: process name); generates f2py python-linkable routines.") + logger.info(" --f2py_per_process=False: [standalone_fortran with --prefix] build the combined all_matrix2py module with only its dispatching entry points (smatrixhel, smatrixhel_idx, py_get_density, ...), without the per-process py_* ones: much faster to build for many subprocesses (used by MadSpin).") logger.info(" --use_crossing=False: [standalone_fortran|standalone|mg7|madevent] write this output WITHOUT the crossing machinery (on by default, as on the generate line): the crossed subprocesses folded onto their base at generation are written back as their own directories. With it, mg7 evaluates each crossed subprocess with the library of its base (cpu, simd and GPU).") - logger.info(" --crossing_table=all: [standalone_fortran|standalone] with the crossing machinery, let the extended flavor index also reach every applicable crossing (each choice of the legs that start in the initial state), not only the crossed subprocesses the generation folded -- e.g. for a single generated process, which folds none.") + logger.info(" --crossing_table=all: [standalone_fortran|standalone] with the crossing machinery, let the extended flavor index also reach every applicable crossing (each choice of the legs that start in the initial state), not only the crossed subprocesses the generation folded -- e.g. for a single generated process, which folds none. Only the extended-index entry (smatrixhel_idx) reaches these extra crossings: the Fortran PDG-keyed smatrixhel/density still answer only the generated and folded subprocesses (NaN otherwise).") logger.info(" Examples:",'$MG:color:GREEN') logger.info(" output",'$MG:color:GREEN') logger.info(" output standalone_fortran MYRUN -f",'$MG:color:GREEN') @@ -3339,7 +3342,7 @@ def complete_open(self, text, line, begidx, endidx): @cmd.debug() def complete_output(self, text, line, begidx, endidx, possible_options = ['f', 'noclean', 'nojpeg'], - possible_options_full = ['-f', '-noclean', '-nojpeg', '--noeps=True','--hel_recycling=False', + possible_options_full = ['-f', '-noclean', '-nojpeg', '--noeps=True','--hel_recycling=False', '--hel_recycling=True', '--hel_recycling_chunk=', '--jamp_optim=', '--jamp_orbit=', '--t_strategy=', '--vector_size=4', '--nb_warp=1', '--mask=False', '--prefix=', '--use_crossing=True', '--use_crossing=False', '--crossing_table=all', '--axial_gauge=True']): @@ -3368,7 +3371,7 @@ def complete_output(self, text, line, begidx, endidx, except Exception as error: print(error) if 'standalone_fortran' in args: - possible_options_full = list(possible_options_full) + ['--prefix=int', '--prefix=proc', '--density='] + possible_options_full = list(possible_options_full) + ['--prefix=int', '--prefix=proc', '--density=', '--f2py_per_process=False'] # Directory continuation if args[-1].endswith(os.path.sep): @@ -12996,28 +12999,47 @@ def _fastproc(amp): originals.append((amp, [record for record in crossed if not any(record is k for k in kept)], kept)) + # Each subprocess goes back where the merge_crossing=False generation + # lists it (generation_rank, see MultiProcess), crossings between + # the bases instead of after all of them. Sorted by number of + # diagrams afterwards, as the caller does, the madevent P directories + # then come in the --use_crossing=False order, and so get the same + # iproc.dat, hence random seeds. Without every rank, the bases keep + # their order and the crossings follow. + entries = [(amp.get('process'), amp, None, kept) + for amp, _crossed, kept in originals] + \ + [(record[0], amp, record, None) + for amp, crossed, _kept in originals for record in crossed] + ranks = [getattr(entry[0], 'generation_rank', None) + for entry in entries] + if None not in ranks: + entries = [entry for _rank, _i, entry in + sorted(zip(ranks, range(len(entries)), entries))] + expanded = diagram_generation.AmplitudeList() seen = {} # fast_proc -> amplitude, for mirror fold - for amp, _crossed, kept in originals: - amp = copy.copy(amp) - if 'crossed_processes' in amp: - amp.set('crossed_processes', kept) - expanded.append(amp) - seen[_fastproc(amp)] = amp - for amp, crossed, _kept in originals: - for (proc, base_perm, cross_perm) in crossed: - xamp = diagram_generation.MultiProcess.\ - cross_amplitude(amp, proc, base_perm, cross_perm) - xamp.set('crossed_processes', []) - fp = _fastproc(xamp) - mirror = (fp[1], fp[0]) + fp[2:] - if collect_mirror and mirror in seen and \ - proc.get_ninitial() == 2: - seen[mirror].set('has_mirror_process', True) - continue - xamp.set('has_mirror_process', False) - expanded.append(xamp) - seen[fp] = xamp + for proc, amp, record, kept in entries: + if record is None: + # a base, with the crossings it still folds + amp = copy.copy(amp) + if 'crossed_processes' in amp: + amp.set('crossed_processes', kept) + expanded.append(amp) + seen[_fastproc(amp)] = amp + continue + _proc, base_perm, cross_perm = record + xamp = diagram_generation.MultiProcess.\ + cross_amplitude(amp, proc, base_perm, cross_perm) + xamp.set('crossed_processes', []) + fp = _fastproc(xamp) + mirror = (fp[1], fp[0]) + fp[2:] + if collect_mirror and mirror in seen and \ + proc.get_ninitial() == 2: + seen[mirror].set('has_mirror_process', True) + continue + xamp.set('has_mirror_process', False) + expanded.append(xamp) + seen[fp] = xamp return expanded @staticmethod @@ -13332,6 +13354,11 @@ def generate_matrix_elements(self, group_processes=True): if self._crossing_needs_expansion(non_dc_amps): non_dc_amps = \ self._expand_recorded_crossings(non_dc_amps) + # sorted as self._curr_amps above, which decides the + # P directory order (and iproc.dat) + non_dc_amps.sort( + key=lambda x: x.get_number_of_diagrams(), + reverse=True) if not self._output_folds_crossings(decay_chains=True): # Decay chains: the crossing dedup (folding the crossed @@ -13701,11 +13728,20 @@ def finalize(self, nojpeg, online = False, flaglist=[]): wanted_lorentz = self._curr_matrix_elements.get_used_lorentz() wanted_couplings = self._curr_matrix_elements.get_used_couplings() - # Standalone --hel_recycling reuses the madevent recycling machinery, - # which needs the P1N (amplitude-split) variant of every used routine. + # the P1N (amplitude split) routines: madevent's helicity recycling, + # Fortran standalone --hel_recycling (same machinery) and the C++ + # one of a madmatrix output asked for --hel_recycling sa_hel_recycling = str(getattr(self._curr_exporter, 'cmd_options', {}).get( 'hel_recycling', False)).lower() in ('true', '1', 'yes') - if (self._export_format == 'madevent' or sa_hel_recycling) and \ + exporter_opts = (getattr(self._curr_exporter, 'opt', None) or {}).get( + 'output_options') or {} + cpp_recycling = getattr(getattr(self._curr_exporter, + 'oneprocessclass', None), + 'runs_hel_recycling_warmup', False) and \ + str(exporter_opts.get('hel_recycling', False)).lower() in \ + ('true', '1', 'yes') + if (self._export_format == 'madevent' or sa_hel_recycling or + cpp_recycling) and \ not 'no_helrecycling' in flaglist and \ not isinstance(self._curr_amps[0], loop_diagram_generation.LoopAmplitude): for (name, flag, out) in wanted_lorentz[:]: diff --git a/madgraph/interface/reweight_interface.py b/madgraph/interface/reweight_interface.py index c1e4cb9c2e..b1ac9b57ce 100755 --- a/madgraph/interface/reweight_interface.py +++ b/madgraph/interface/reweight_interface.py @@ -57,6 +57,14 @@ dir_to_f2py_free_mod = {} nb_f2py_module = 0 # each time the process/model is changed this number is modified to # forced the python module to re-create an executable +# names (output directory name + suffix, e.g. rw_me, rw_me_r2) under which a +# reweighting f2py module and its shared library were imported in this python +# process. Nothing loaded can be unloaded: the dynamic loader resolves a +# library by its install name/SONAME to the one already loaded, whichever +# directory it comes from (e.g. the rw_me of a previous reweighting in the same +# session, or of another sample). A new build must therefore use a name that is +# not in here (see get_f2py_suffix), or it gets the previous matrix elements. +loaded_f2py_names = set() #lhapdf = None @@ -118,6 +126,9 @@ def __init__(self, event_path=None, allow_madspin=False, mother=None, *completek self.inc_sudakov = False self.event_path = event_path self.path2prefix = {} # store the f2pyprefix associated to a library + # output directory name (rw_me, rw_mevirt, ...) -> suffix of the names + # its f2py modules and library were built with ('' = default names) + self.f2py_suffix = {} # id_to_path-style tag -> folded crossed subprocesses reachable through # a base matrix element (see build_cross_resolve); empty when crossing # folded nothing. @@ -1792,12 +1803,16 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): new_value = module.smatrixhel(pdg, pid, p, event.aqcd, scale2, nhel) else: # folded crossing: name the matrix element by its slot - # and the process by the extended flavor index, the PDG - # dispatch cannot reach it. NPDG is f2py-derived from p. + # and the process by the extended flavor index the + # event was resolved to (build_cross_resolve), rather + # than by its PDG codes. NPDG is f2py-derived from p. new_value = module.smatrixhel_idx(procindex, flav_idx, p, event.aqcd, scale2, nhel) #misc.sprint(new_value) - if new_value == 0: + # NaN: no matrix element of the module serves these PDG + # codes (its ERROR line goes to the silenced stdout) + if new_value == 0 or (isinstance(new_value, float) + and math.isnan(new_value)): raise Exception("Invalid matrix element") # for loop we have also the stability status code if isinstance(new_value, tuple): @@ -2118,10 +2133,13 @@ def tree_crossing_flag(self, processes): element's own leg order, which makes the id_to_path key order-sensitive; a folded crossing has no directory and hence no such order to promise, so a crossed event would miss the lookup. - - the density mode evaluates GET_DENSITY, not SMATRIX, and only its - FLAVOR-array entry point is wired up here; the FLAVOR array cannot - express a crossing (GET_DENSITY_IDX would be needed, as MadSpin's - density path does it). + - the density mode evaluates py_get_density, not SMATRIX, and finds + the matrix element and its leg order through get_pdg_order, which + lists no folded crossing. py_get_density itself falls back to a + folded crossing (f77_crossed_density), but only for legs in that + crossing's own order (GET_PDG_FOR_FLAVOR) up to exchanges of + same-code legs not in POS inside the initial or the final state, + which this mode does not build. - 'use_eventid' hands the event's process id to SMATRIXHEL, while the folded entry point SMATRIXHEL_IDX takes none: a crossed event would silently be given the first matching matrix element instead. @@ -2672,11 +2690,26 @@ def compile(self): if not os.path.isdir(pjoin(path_me,onedir)): continue pdir = pjoin(path_me, onedir, 'SubProcesses') - self.compile_SubProcess_dir(pdir) + self.f2py_suffix[onedir] = self.get_f2py_suffix(pdir) + self.compile_SubProcess_dir(pdir, self.f2py_suffix[onedir]) - - def compile_SubProcess_dir(self, Sdir): - """compile a full Subprocess directory""" + @staticmethod + def get_f2py_suffix(Sdir): + """suffix for the names of the f2py modules/library built in Sdir: + '' (the default names) unless that name was already imported in this + process, from this or any other directory (see loaded_f2py_names). + """ + name = os.path.basename(os.path.dirname(os.path.normpath(Sdir))) + suffix = '' + i = 1 + while name + suffix in loaded_f2py_names: + i += 1 + suffix = '_r%i' % i + return suffix + + def compile_SubProcess_dir(self, Sdir, suffix=''): + """compile a full Subprocess directory. A non-empty suffix is appended + to the names of the f2py modules and of their shared library.""" if self.mother: nb_core = self.mother.options['nb_core'] if self.mother.options['run_mode'] !=0 else 1 @@ -2687,20 +2720,25 @@ def compile_SubProcess_dir(self, Sdir): # the loader hands the second f2py module the first library. # The leading '_' keeps it different from the liball_me.so # name that load_module tries to preload with RTLD_GLOBAL. - procname = 'PROCNAME=_%s' % os.path.basename(os.path.dirname(os.path.normpath(Sdir))) - os.environ['MENUM'] = '2' - try: - misc.compile([procname, 'all_matrix2py.so'], cwd=Sdir, nb_core=nb_core) - except Exception as e: - misc.compile([procname, 'all_matrix2py.so'], cwd=Sdir, nb_core=1) - - if not (self.second_model or self.second_process or self.dedicated_path): - os.environ['MENUM'] = '3' + # A rebuild of a name already loaded in this process (suffix) also + # renames the f2py module, and for the loop output (rw_mevirt, whose + # makefile names its library after ROOTNAME) the library too. + dirname = os.path.basename(os.path.dirname(os.path.normpath(Sdir))) + names = ['PROCNAME=_%s%s' % (dirname, suffix)] + if suffix: + names.append('ROOTNAME=%s%s' % (dirname, suffix)) + for menum in ['2', '3']: + if menum == '3' and (self.second_model or self.second_process or self.dedicated_path): + break + os.environ['MENUM'] = menum + args = names + ['all_matrix%spy.so' % menum] + if suffix: + args.insert(-1, 'F2PYMOD=all_matrix%spy%s' % (menum, suffix)) try: - misc.compile([procname, 'all_matrix3py.so'], cwd=Sdir, nb_core=nb_core) + misc.compile(args, cwd=Sdir, nb_core=nb_core) except Exception as e: - misc.compile([procname, 'all_matrix3py.so'], cwd=Sdir, nb_core=1) - + misc.compile(args, cwd=Sdir, nb_core=1) + def load_module(self, metag=1): """load the various module and load the associate information""" @@ -2740,23 +2778,19 @@ def load_module(self, metag=1): except Exception as err: logger.debug('ctypes trick fail for module') break - mod_name = '%s.SubProcesses.all_matrix%spy' % (onedir, tag) - #mymod = __import__('%s.SubProcesses.allmatrix%spy' % (onedir, tag), globals(), locals(), [],-1) - if mod_name in list(sys.modules.keys()): - del sys.modules[mod_name] - tmp_mod_name = mod_name - while '.' in tmp_mod_name: - tmp_mod_name = tmp_mod_name.rsplit('.',1)[0] - del sys.modules[tmp_mod_name] - import importlib - mymod = importlib.import_module(mod_name,) - mymod = importlib.reload(mymod) - #mymod = __import__(mod_name, globals(), locals(), []) - else: - import importlib - mymod = importlib.import_module(mod_name,) - #mymod = __import__(mod_name, globals(), locals(), []) - + suffix = self.f2py_suffix.get(onedir, '') + mod_name = '%s.SubProcesses.all_matrix%spy%s' % (onedir, tag, suffix) + # forget the module and its packages: the packages of a + # previous reweighting may point to another rwgt_dir + tmp_mod_name = mod_name + while tmp_mod_name: + sys.modules.pop(tmp_mod_name, None) + tmp_mod_name = tmp_mod_name.rpartition('.')[0] + import importlib + importlib.invalidate_caches() + mymod = importlib.import_module(mod_name) + loaded_f2py_names.add(onedir + suffix) + if fprefix != '': fprefix = 'f%i_' % fprefix for attr in dir(mymod): @@ -3236,6 +3270,7 @@ def save_to_pickle(self): to_save['allow_missing_finalstate'] = self.options['allow_missing_finalstate'] to_save['identical_particle_in_prod_and_decay'] = self.options['identical_particle_in_prod_and_decay'] to_save['nb_library'] = self.nb_library + to_save['f2py_suffix'] = self.f2py_suffix name = pjoin(self.rwgt_dir, 'rw_me', 'rwgt.pkl') save_load_object.save_to_file(name, to_save) @@ -3285,6 +3320,7 @@ def load_from_pickle(self, keep_name=False): self.second_model = obj['second_model'] self.has_nlo = obj['has_nlo'] self.nb_library = obj['nb_library'] + self.f2py_suffix = obj.get('f2py_suffix', {}) if not self.rwgt_mode: self.rwgt_mode = obj['rwgt_mode'] logger.info("mode set to %s" % self.rwgt_mode) @@ -4077,6 +4113,13 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): # the argument event.scale**2 is a dummy argument for LO processes, else it is taken as the value given in the LHE file production_matrix = get_density(-1, pinv, pos_corrected, #self.number_changing_helicities, self.allowed_helicities, event.aqcd, event.scale**2) + # NaN (z != z): no matrix element of the module evaluates these + # legs in this order (f77_no_process in all_matrix.f). + if any(z != z for z in production_matrix): + raise Exception("Invalid density matrix: NaN returned for " + "event %s (pdg %s): no matrix element evaluates these " + "legs in this order." % (getattr(event, 'ievent', -1), + list(pdg))) if self.symmetrise_initial_state: pinv_bis = self.invert_momenta(all_p_bis[i]) diff --git a/madgraph/iolibs/crossing_table.py b/madgraph/iolibs/crossing_table.py index 467aba8c97..bbd61e9714 100644 --- a/madgraph/iolibs/crossing_table.py +++ b/madgraph/iolibs/crossing_table.py @@ -283,6 +283,24 @@ def spincol(self, spincol_part): out.append(factor) return out + def beam_helicities(self, hel_part): + """Helicity-state count of each initial leg of the dependent process + served by each row, flattened K*2 + beam (beam two is 1 for a 1->N + process, 0 for an invalid row): `hel_part[slot]` is the count of base + leg `slot`. A crossing that moves a massive vector or a scalar into + the initial state changes the helicity average, which a single + helicity of the crossed process has to undo.""" + out = [] + for K, row in enumerate(self.rows): + for k in range(2): + if K in self.invalid: + out.append(0) + elif k < self.ninitial: + out.append(hel_part[row.D[k]]) + else: + out.append(1) + return out + def flat(self, attr, offset=0): """The `attr` ('D', 'B', 'SD' or 'SB') of every row, flattened K*NEXTERNAL + slot, with `offset` added (1 for fortran slot numbers, diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index bd670e4567..dbe76ef5fc 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -504,6 +504,8 @@ def __init__(self, dir_path = "", opt=None): if isinstance(self.cmd_options, dict) and 'axial_gauge' in self.cmd_options: self.opt['axial_gauge'] = banner_mod.ConfigFile.format_variable( self.cmd_options['axial_gauge'], bool, 'axial_gauge') + # once per output: says so if the FD gauge drops the option + helas_call_writers.axial_gauge_requested(self.opt, warn=True) self._configure_flavor_mask_from_cmd_options() self._check_crossing_support() @@ -2914,12 +2916,19 @@ def _helstate_data(self, matrix_element): A helicity configuration is encoded as a single mixed-radix integer (the 'canonical code') over the per-leg helicity states, with the last external leg as the least-significant digit -- matching the - itertools.product ordering used by get_helicity_matrix(). For a - non-polarized process this makes the code of the i-th row exactly i, so - HELALLOW is simply [1..NCOMB] and nothing is relabelled; a polarization - restriction ({0}/{L}/...) keeps the *full* per-leg multiplicity as the - radix (so helicity 0 / longitudinal stays a first-class state) and - leaves HELALLOW as the selected, non-contiguous subset of codes. + itertools.product ordering used by get_helicity_matrix(). The per-leg + states are the very lists get_helicity_matrix() runs over, a + polarization restriction ({0}/{T}/{L}...) included, so the code of the + i-th row is exactly i for EVERY process and HELALLOW is [1..NCOMB]. + + That identity is load-bearing. Every producer and consumer of a single + helicity label speaks rows: madevent stores the row it sampled and its + GET_NHEL decodes the event's helicities (and, for the EVA PDF, the beam + helicity and multiplicity) from these tables; the reweighting asks + SMATRIXHEL for a row; madmatrix reports a row. A radix over the FULL + per-leg states made the code differ from the row as soon as one leg was + polarized, and the row was then decoded as a code: p p > w+{0} j wrote + W+ helicity -1 in most of its events. Returns a dict with keys: maxhel - max per-leg helicity multiplicity (STATES 1st dim) @@ -2928,13 +2937,11 @@ def _helstate_data(self, matrix_element): hel_allow_data - DATA for HELALLOW(NCOMB) (allowed codes) flip_data - DATA for FLIP(NCOMB) (C-parity partner row) """ - model = matrix_element.get('processes')[0].get('model') - pdict = model.get('particle_dict') ext = matrix_element.get_external_wavefunctions() - # Full per-leg helicity states, allow_reverse=True so the value order - # matches get_helicity_matrix() for non-polarized legs (code==row). - states = [pdict[wf.get('pdg_code')].get_helicity_states(True) - for wf in ext] + # The per-leg lists get_helicity_matrix() enumerates: the polarization + # when the leg has one, else the particle's states in allow_reverse=True + # order -- so that code == row (see the docstring). + states = [list(s) for s in matrix_element.get_helicity_per_particle()] if matrix_element.get_nonia() > 0: # A bound state is one helicity digit, its 2J+1 states, in place of # its two constituents -- the rows get_helicity_matrix enumerates. @@ -2967,8 +2974,8 @@ def _helstate_data(self, matrix_element): # reads as "this row is not part of a C-symmetric pair, keep the # de-duplication off". Two ways to land there, both deliberate: a row # that negates to itself (all helicities 0), and a row whose negation is - # not in the allowed set -- the polarized case, where a leg keeps its - # full radix but only some states are selected. + # not a configuration of this process -- a polarized leg whose list + # does not hold the negated state ({L} has no +1). code_to_row = {code: i + 1 for i, code in enumerate(allowed)} flip = [] for i, row in enumerate(matrix_element.get_helicity_matrix()): @@ -3486,7 +3493,12 @@ def crossing_table_all_requested(self): matrix element every applicable crossing (each choice of the base legs that start in the initial state), not only the recorded ones -- for a standalone user evaluating an arbitrary crossing through the extended - flavor index, and for the crossing tests.""" + flavor index, and for the crossing tests. The PDG-keyed entries + (f77_smatrixhel, f77_density) are not widened: they still serve only + the generated subprocesses and the recorded crossings + (crossed_smatrixhel_prefixes), so that a PDG lookup never answers a + process the user did not generate; the extra rows are reached by + their extended index only (f77_smatrixhel_idx, GET_DENSITY_IDX).""" options = getattr(self, 'cmd_options', None) or {} return str(options.get('crossing_table', '')).lower() == 'all' @@ -3522,6 +3534,11 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, replace_dict.update({ 'smatrixhel_userhel': ' USERHEL=HEL', 'helreset_cross': '', + # the initial helicity average a single helicity code or a + # polarised beam undoes (the split-orders template shares + # these two holes) + 'helavg_user': 'HELAVGFACTOR', + 'helavg_beam': 'BEAMS_HELAVGFACTOR(J)', # C-parity scan of the plain flavors only 'csym_doc_ntry': 'C NTRY_CSYM counts only the uncrossed (cross 0) calls: CSYM\n' @@ -3597,6 +3614,11 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, (key, value % {'proc_prefix': prefix, 'ncross': ncross, 'den_factor_line': replace_dict['den_factor_line']}) for key, value in self.CROSSING_SNIPPETS.items())) + # A crossed process has its own initial helicity average (a Z + # moved into the initial state has three states, a gluon two): the + # (shared with split orders) holes read it per crossing. + replace_dict['helavg_user'] = '%sGET_HELAVG_CROSS(CROSSUSE,0)' % prefix + replace_dict['helavg_beam'] = '%sGET_HELAVG_CROSS(CROSSUSE,J)' % prefix # the per-index try and C-parity scan counters are reset with the # shared filter and verdict replace_dict['helreset_cross'] = \ @@ -3651,6 +3673,10 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, ' ELSE\n' ' IDEN_STAR = %(prefix)sGET_SPINCOL_CROSS(NHI_CROSS)\n' ' & * %(prefix)sGET_IDENT_CROSS(NHI_CROSS, FLAVOR)\n' + 'C SMATRIXHEL takes the crossed process\'s own code\n' + 'C (CROSS_HELCODE): report that process\'s own table.\n' + ' CALL %(prefix)sCROSS_NHEL_TABLE(FLAV_IDX_IN, NCOMB,\n' + ' & NHEL_STAR)\n' ' ENDIF' % {'prefix': prefix}) crossing_template = pjoin(_file_path, 'iolibs', 'template_files', 'matrix_standalone_crossing_v4.inc') @@ -3730,6 +3756,7 @@ def fill_crossing_replace_dict_so(self, matrix_element, replace_dict, ' INTEGER IDENUSE, CROSSUSE\n' ' INTEGER %(p)sGET_SPINCOL_CROSS\n' ' INTEGER %(p)sGET_IDENT_CROSS\n' + ' INTEGER %(p)sGET_HELAVG_CROSS\n' ' REAL*8 PUSE(0:3,NEXTERNAL)\n' ' INTEGER NHELUSE(NEXTERNAL,NCOMB)\n' ' INTEGER ICUSE(NEXTERNAL)\n' @@ -3827,6 +3854,18 @@ def fill_crossing_replace_dict_so(self, matrix_element, replace_dict, # template's names so that a split-orders file written WITHOUT crossing # gets neither -- GET_PDG_FOR_FLAVOR only exists to decode a crossing. replace_dict['so_crossing_routines'] = replace_dict['crossing_routines'] + # The density stack of this template takes the FLAVOR array, which + # cannot carry a crossing: the f2py PDG dispatch reaches a folded + # crossed subprocess through these crossing-aware twins + # (GET_DENSITY_IDX, GET_ALL_INTER_SUMSO_IDX) instead. Only this + # template can take them (they need its split-order sizes). + get_template = getattr(self, 'get_matrix_template', None) + if get_template and get_template(matrix_element) == \ + 'matrix_standalone_splitOrders_v4.inc': + so_density = open(pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_standalone_splitOrders_crossing_v4.inc')).read() + replace_dict['so_crossing_routines'] += \ + '\n\n' + so_density % replace_dict replace_dict['so_pdg_function'] = replace_dict['flavor_pdg_function'] # One filter shared by every crossing, a try counter per extended # index (see CROSSING_SNIPPETS smatrix_cross_decl). @@ -3873,6 +3912,7 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, 'me_goodhel_set': ' GOODHEL(I,IFLAV,%s)=.TRUE.' % pid, 'me_matrix_args': 'P ,NHEL(1,I),IFLAV,I,AMP2, JAMP2, IVEC', + 'me_amp2_cut': '', 'smatrix_me_iden_line': ' ANS=ANS/DBLE(IDEN)*BROKEN_SYM%s(FLAVOR_FOR_SYM)' % pid, 'crossing_routines_me': '', @@ -4015,6 +4055,41 @@ def _data2d(name, icol, values, per_line=10): else: xg_decl, xg_decode = '', '' confsub_j = 'CONFSUB(%s, I)' % pid + # ---- multi-channel weights of a matrix element that serves crossings. + # GOODHEL is the union of the good rows of every caller, so for the + # caller at hand some rows are rounding zeros of diagrams that cancel + # (p p > w+ j: two rows of g u > w+ d vanish that g d~ > w+ u~ needs). + # Their diagrams are not small, and summed into AMP2 they would skew the + # single-diagram channel weights away from those of the uncrossed + # output, which never evaluates such rows. Each row's AMP2 is therefore + # kept apart (AMP2H) and only the rows above the good-helicity cut of + # this point join AMP2, the test GOODHEL itself is trained on. With MC + # over helicities no row sum exists (TS stays 0): the picked row counts. + if len(table) > 1: + cut_decl = '\n DOUBLE PRECISION AMP2H(MAXAMPS,NCOMB), TSSUM' + cut_init = ('\n IF (MULTI_CHANNEL.AND.SDE_STRAT.EQ.1)' + ' AMP2H(1:NDIAGS,:) = 0D0') + cut_amp2 = 'AMP2H(1,I)' + cut_pass = '\n'.join([ + 'C A row dead for this caller cancels between non-small', + 'C diagrams: keep it out of the channel weights.', + ' IF (MULTI_CHANNEL.AND.SDE_STRAT.EQ.1) THEN', + ' TSSUM = 0D0', + ' DO I=1,NCOMB', + ' TSSUM = TSSUM + DABS(TS(I))', + ' ENDDO', + ' DO I=1,NCOMB', + ' IF (TSSUM.EQ.0D0.OR.' + 'DABS(TS(I)).GT.TSSUM*LIMHEL/NCOMB) THEN', + ' DO J=1,NDIAGS', + ' AMP2(J) = AMP2(J) + AMP2H(J,I)', + ' ENDDO', + ' ENDIF', + ' ENDDO', + ' ENDIF', + '']) + else: + cut_decl, cut_init, cut_amp2, cut_pass = '', '', 'AMP2', '' replace_dict.update({ 'me_confsub_j': confsub_j, 'smatrix_me_cross_decl': ( @@ -4040,9 +4115,10 @@ def _data2d(name, icol, values, per_line=10): # crossed index runs its own first 20 scans of it (NCSCANX). ' INTEGER NCSCANX(NXFLAV)\n' ' DATA NCSCANX/NXFLAV*0/' - '%(xg_decl)s' + '%(xg_decl)s%(cut_decl)s' ) % {'nflav': nflav, 'cp': cp, 'xg_decl': xg_decl, - 'nx': max(1, nflav * (len(table) - 1))}, + 'nx': max(1, nflav * (len(table) - 1)), + 'cut_decl': cut_decl}, # Decode the crossing and build the crossed P/NHEL/IC once, before the # helicity loop. An unusable crossing (spin*color = 0) has a zero ME. 'smatrix_me_cross_decode': ( @@ -4064,8 +4140,8 @@ def _data2d(name, icol, values, per_line=10): ' XGX = IFLAV - NFLAV\n' ' NTRYX(XGX) = NTRYX(XGX) + 1\n' ' ENDIF' - '%(xg_decode)s' - ) % {'cp': cp, 'xg_decode': xg_decode}, + '%(xg_decode)s%(cut_init)s' + ) % {'cp': cp, 'xg_decode': xg_decode, 'cut_init': cut_init}, 'me_flav_key': 'FLAV_USE', # One filter for the base and every crossing of it; the try # counter is the crossed index's own (MERGE picks this call's). @@ -4075,7 +4151,8 @@ def _data2d(name, icol, values, per_line=10): 'me_goodhel_set': ' GOODHEL(I,FLAV_USE,%s)=.TRUE.' % pid, 'me_matrix_args': - 'PUSE ,NHELUSE(1,I),IC,FLAV_USE,I,AMP2, JAMP2, IVEC', + 'PUSE ,NHELUSE(1,I),IC,FLAV_USE,I,%s, JAMP2, IVEC' % cut_amp2, + 'me_amp2_cut': cut_pass, # Uncrossed keeps IDEN/BROKEN_SYM; crossed rebuilds the denominator # as initial spin*color (per crossing) times the identical-final # factor of the actual flavors (per flavor). @@ -4236,6 +4313,7 @@ def _data2d(name, icol, values, per_line=10): INTEGER IDENUSE, CROSSUSE INTEGER %(proc_prefix)sGET_SPINCOL_CROSS INTEGER %(proc_prefix)sGET_IDENT_CROSS + INTEGER %(proc_prefix)sGET_HELAVG_CROSS C Crossed copies of the arguments, built ONCE per SMATRIX C call (see the BEGIN CODE section). They are only touched C when a crossing is actually requested, so the uncrossed @@ -4363,6 +4441,13 @@ def _data2d(name, icol, values, per_line=10): CALL %(proc_prefix)sAPPLY_CROSSING(FLAV_IDX, P, NHEL, IC, PUSE, & NHELUSE, ICUSE, DUMFLAV) CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX, PERM, SGN, DUMFLAV) +C NHEL follows the caller's legs, like P and POS. Base slot +C I is caller leg PERM(I), so it takes NHEL(PERM(I)), with +C no sign: NHEL is the physical helicity of the crossed +C process's own leg, the view APPLY_CROSSING_TABLE takes. + DO I = 1, NEXTERNAL + NHELUSE(I) = NHEL(PERM(I)) + ENDDO DO IPART = 1, N_CHANGING DO I = 1, NEXTERNAL IF (PERM(I).EQ.POS(IPART)) CPOS(IPART) = I @@ -4444,6 +4529,8 @@ def compute_crossing_tables(self, matrix_element): invariant). A crossing-table row's initial-state average is the product over the legs it puts in the initial slots (crossing_table.CrossingTable.spincol); + 'hel_part' : per base leg, its number of helicity states alone + (crossing_table.CrossingTable.beam_helicities); 'ids_base' : per base leg, its PDG (merged labels as they are); 'antipid_base' : the charge conjugate of that PDG; 'countable' : 1 for a single external leg, 0 for a decay-block @@ -4493,10 +4580,12 @@ def particle(pdg): ninitial = len([leg for leg in legs if not leg.get('state')]) spincol_part = [] + hel_part = [] for slot in range(nexternal): pol = polarizations[slot] nspin = len(pol) if pol else \ len(particle(leg_ids[slot]).get_helicity_states()) + hel_part.append(nspin) spincol_part.append(nspin * abs(particle(leg_ids[slot]).get('color'))) ids_base = list(leg_ids) antipid_base = [particle(pid).get_anti_pdg_code() for pid in leg_ids] @@ -4550,7 +4639,7 @@ def particle(pdg): '%s*%s vs %s' % (spincol0, identical, matrix_element.get_denominator_factor()) - return {'spincol_part': spincol_part, + return {'spincol_part': spincol_part, 'hel_part': hel_part, 'ids_base': ids_base, 'antipid_base': antipid_base, 'countable': countable, 'ident_resonance': ident_resonance, 'nexternal': nexternal, 'ninitial': ninitial} @@ -4661,10 +4750,16 @@ def crossing_table_replace_dict(self, matrix_element, table, spincol=None): """The matrix_standalone_crossing_v4.inc holes carrying `table`.""" fmt = ProcessExporterFortran.format_integer_data_lines + tables = ProcessExporterFortran.compute_crossing_tables( + self, matrix_element) if spincol is None: - spincol_part = ProcessExporterFortran.compute_crossing_tables( - self, matrix_element)['spincol_part'] - spincol = table.spincol(spincol_part) + spincol = table.spincol(tables['spincol_part']) + beamhel = table.beam_helicities(tables['hel_part']) + # Row 0 is the identity: it must give back the uncrossed + # HELAVGFACTOR, which the crossed accessor replaces. + assert beamhel[0] * beamhel[1] == matrix_element.get_hel_avg_factor(), \ + 'Crossing helicity average disagrees with get_hel_avg_factor: ' \ + '%s vs %s' % (beamhel[:2], matrix_element.get_hel_avg_factor()) return { 'ncross': len(table), 'xperm_data': fmt('XPERM', table.flat('B', 1)), @@ -4674,6 +4769,7 @@ def crossing_table_replace_dict(self, matrix_element, table, 'xvalid_data': fmt('XVALID', [1 if table.valid(K) else 0 for K in range(len(table))]), 'xspincol_data': fmt('XSPINCOL', spincol), + 'xbeamhel_data': fmt('XBEAMHEL', beamhel), } def _diagram_topology_signature(self, me): @@ -7122,9 +7218,10 @@ class ProcessExporterFortranSA(ProcessExporterFortran): jamp_optim = True jamp_orbit = True # The only exporter implementing the extended FLAV_IDX decoding. The - # per-matrix-element cases it still cannot cross (msP/msF, matchbox, - # split orders) are handled by the use_crossing_ic gate in - # write_matrix_element_v4, which falls back to the uncrossed code. + # per-matrix-element cases it still cannot cross (msP/msF, matchbox) + # are handled by the use_crossing_ic gate in write_matrix_element_v4, + # which falls back to the uncrossed code. Split orders do cross, through + # the split-orders template's own holes (fill_crossing_replace_dict_so). supports_crossing = True default_vector_size = 0 # standalone only squares the amplitude, so it can use the DDM basis. It @@ -7158,6 +7255,10 @@ def __init__(self, *args,**opts): # filled per subprocess directory and written out by # write_crossing_records; see recorded_crossing_codes. self.crossing_records = {} + # proc_prefix of the matrix elements written from the split-orders + # template: their GET_ALL_INTER returns one INTER per squared split + # order, so the f2py dispatch calls GET_ALL_INTER_SUMSO instead. + self.split_order_prefixes = set() ProcessExporterFortran.__init__(self, *args, **opts) def get_proc_prefix(self, matrix_element, default=''): @@ -7179,19 +7280,25 @@ def get_matrix_template(self, matrix_element): carries the full standalone API, and the msP/msF, split-orders and matchbox variants each carry a subset (see matrix_template_provides). - For the split-orders variant the dividing line is the crossing, and it - is meant to be readable as such: it carries the parts of the default - template that mean something without one (the canonical helicity - encoder/decoder feeding PROCESS_NHEL, the C-parity de-duplication, the - flavor-aware denominator accessor GET_NHEL_IDX) and none of the parts - that exist only to decode an extended FLAV_IDX (the _IDX/_CROSSED - density stack, GET_PDG_FOR_FLAVOR, the crossing routines themselves -- - note fill_crossing_replace_dict fills holes this template does not - have, and use_crossing_ic excludes split orders outright). The one + For the split-orders variant the dividing line is the crossing. It + always carries the parts of the default template that mean something + without one (the canonical helicity encoder/decoder feeding + PROCESS_NHEL, the C-parity de-duplication, the flavor-aware + denominator accessor GET_NHEL_IDX). Everything that exists only to + decode an extended FLAV_IDX comes through holes of its own, which + fill_crossing_replace_dict_so leaves empty without crossing -- so an + uncrossed FKS/MadLoop born gets none of it -- and fills with crossing: + so_cross_decode/so_cross_apply decode and apply the crossing in + SMATRIX, so_pdg_function opts into GET_PDG_FOR_FLAVOR, and + so_crossing_routines opts into the default template's crossing + routines plus the split-order density twins of + matrix_standalone_splitOrders_crossing_v4.inc (GET_DENSITY_IDX, + GET_ALL_INTER_SUMSO_IDX, GET_ALL_INTER_SO_CROSSED, GET_INTER_RESCALE). + Its own FLAVOR-array density stack stays uncrossed. use_crossing_ic + threads IC through this template like the default one. The one further difference is the external helicity label: the row number here, the canonical code there, because MadLoop passes a row. - test_splitorders_template_carries_the_non_crossing_standalone_api pins - all of it. + test_splitorders_template_carries_the_standalone_api pins all of it. --hel_recycling is deliberately not special-cased: it rewrites SMATRIX/MATRIX but appends every other routine verbatim from @@ -7512,6 +7619,7 @@ def write_f2py_splitter(self): alliden = [self.prefix_info[key][3] for key in allids] min_nexternal = min([len(ids[0]) for ids in allids]) max_nexternal = max([len(ids[0]) for ids in allids]) + max_ncomb = max(allncomb) info = [] for (key, pid), value in self.prefix_info.items(): @@ -7565,6 +7673,8 @@ def write_f2py_splitter(self): use_repair = nb > 0 and all(len(v) > 5 for v in self.prefix_info.values()) momenta = 'pfix' if use_repair else 'p' + helicities = 'hfix' if use_repair else 'lnhel' + positions = 'POSFIX' if use_repair else 'POS' text = [] smtext = [] smatrixhel_prefixes = set() @@ -7578,13 +7688,17 @@ def write_f2py_splitter(self): current_pid = [ids[1] for ids in allids if len(ids[0])==n_ext] if not current_id: continue - if min_nexternal != max_nexternal: - if n_ext == min_nexternal: - line = ' if (npdg.eq.%i)then' % n_ext - else: - line = ' else if (npdg.eq.%i)then' % n_ext - text.append(line) - smtext.append(line) + # The multiplicity guard is written even for a single + # multiplicity: LPDGS only copies the first MIN(NPDG, maxpart) + # codes, so without it a PDGS array of the wrong length matched + # on its leading codes, while the crossed fallback + # (F77_CROSSED_SMATRIXHEL/DENSITY) refuses it with NPDG.EQ.N. + if n_ext == min_nexternal: + line = ' if (npdg.eq.%i)then' % n_ext + else: + line = ' else if (npdg.eq.%i)then' % n_ext + text.append(line) + smtext.append(line) for ii,pdgs in enumerate(current_id): pid = current_pid[ii] @@ -7599,42 +7713,85 @@ def write_f2py_splitter(self): prefix = info_me[0] smatrixhel_prefixes.add(prefix) if use_repair: - # POS names the legs whose helicity is an open index of the - # density: those must stay where they are, so they are - # passed as the frozen legs. smatrixhel has no such leg. repair = (' call flavor_order_repair(%sget_flavor_index,' - ' %sget_flavor, %i, %i, %i, %%s, flavor, lpdgs,' - ' p, pfix)' % (prefix, prefix, n_ext, - info_me[4], info_me[5])) + ' %sget_flavor, %i, %i, %i, .true., flavor,' + ' lpdgs, p, pfix, legof)' % ( + prefix, prefix, n_ext, info_me[4], + info_me[5])) + text.append(repair) # GET_ALL_INTER fixes the helicity of every other leg from - # NHEL, by leg position: it gets the same no-repair rule - # as a specific smatrixhel row (below), hence the switch. - text.append(repair % 'n_changing, pos, %(dorepair)s') - # a request for one specific helicity row names it by leg - # position, so re-ordering the legs would answer for a - # different helicity assignment: leave those calls alone. - smtext.append(repair % '0, ms_nofrozen, nhel.lt.1') - text.append(' call %s%%(fct_name)s' % prefix) - smtext.append(' call %ssmatrixhel(%s, nhel, %sget_flavor_index(flavor), ans)' - % (prefix, momenta, prefix)) - text.append(' endif') + # NHEL, by leg position: the helicities move with their + # legs (HFIX), so the repair stays a pure relabelling. + text.append('%(helperm)s') + # POS names the legs whose helicity is an open index of + # the density: each follows its leg too (POSFIX), in the + # same order, so rho keeps the caller's rows and columns. + # Frozen in place, they left an ordering that needs one + # of them moved (u~ d~ > u~ d~, POS = 1 2, on a table + # holding d~ u~ > d~ u~) at a silent 0. + text.append(self._f2py_pos_follow_legs) + # an ordering the table still does not hold is a miss: + # the crossed fallback, then NaN (FOUND, below) + text.append(' if (%sget_flavor_index(flavor).eq.0) then' + % prefix) + text.append(' found = .false.') + text.append(' else') + # a request for one helicity code names the helicity of + # each leg in the caller's order: re-key it through the + # same permutation (FLAVOR_ORDER_HEL), so that each leg + # keeps its helicity (left unrepaired, it answered 0). + smtext.append(repair) + smtext.append(' nhelfix = nhel') + smtext.append(' if (nhel.ge.1) then') + smtext.append(' call %sget_nhel_idx(%sget_flavor_index(' + 'flavor), frden, frtab)' % (prefix, prefix)) + smtext.append(' call flavor_order_hel(nhel, %i, %i, frtab,' + ' legof, nhelfix)' % (n_ext, info_me[2])) + smtext.append(' endif') + # a split-order GET_ALL_INTER fills INTER(NSQAMPSO,*): the + # f2py buffer holds one split-order sum, see + # GET_ALL_INTER_SUMSO (GET_DENSITY sums them itself) + if prefix in getattr(self, 'split_order_prefixes', ()): + text.append(' call %s%%(fct_name_so)s' % prefix) + else: + text.append(' call %s%%(fct_name)s' % prefix) + if use_repair: + text.append(' endif') + smtext.append(' call %ssmatrixhel(%s, %s, %sget_flavor_index(' + 'flavor), ans)' % (prefix, momenta, 'nhelfix' + if use_repair else 'nhel', + prefix)) + # No PDG branch matched: try the folded crossings, and otherwise + # report the miss (FOUND, see the template): every entry point + # answers NaN with an ERROR line for a process it does not serve. + # The density and get_all_inter also try them after a branch + # whose ordering did not resolve: a folded crossing may serve a + # flavor combination the matched matrix element does not hold. + text.append(' else') + smtext.append(' else') + text.append(' found = .false.') if n_ext in crossed_next: - smtext.append(' else') smtext.append(' call %(f2py_prefix)sf77_crossed_smatrixhel(' - 'opdgs, procid, npdg, p, nhel, ans)') + 'pdgs, lpdgs, procid, npdg, p, nhel, ans, found)') + else: + smtext.append(' found = .false.') + text.append(' endif') smtext.append(' endif') - #close the function - if min_nexternal != max_nexternal: - text.append('endif') - smtext.append('endif') + if n_ext in crossed_next: + text.append(' if (.not.found) call %(fct_cross)s') + #close the function: any other NPDG is not served + for lines in (text, smtext): + lines.append(' else') + lines.append(' found = .false.') + lines.append('endif') # INTEGER declarations for the per-process GET_FLAVOR_INDEX functions # used inline by the smatrixhel dispatch (their name does not start with # i-n, so they default to REAL without an explicit declaration). # With the ordering repair on, the same two per-process routines are # also *passed* to FLAVOR_ORDER_REPAIR, hence the EXTERNAL statements, - # and every wrapper needs the repaired-momenta scratch array (PFIX) and - # the empty frozen-leg list smatrixhel hands over. + # and every wrapper needs the repaired-momenta scratch array (PFIX) + # and the leg map (LEGOF). flavor_index_decl = '\n'.join(' integer %sget_flavor_index' % prefix for prefix in sorted(smatrixhel_prefixes)) if use_repair: @@ -7642,20 +7799,31 @@ def write_f2py_splitter(self): ' external %sget_flavor_index\n external %sget_flavor' % (prefix, prefix) for prefix in sorted(smatrixhel_prefixes)) flavor_index_decl += ('\n double precision pfix(0:3,%i)' - '\n integer ms_nofrozen(1)' % max_nexternal) + '\n integer legof(%i)' + % (max_nexternal, max_nexternal)) + # the density's and get_all_inter's open legs, moved with + # their legs (_f2py_pos_follow_legs) + density_decl = ' integer posfix(n_changing), j' + # f77_smatrixhel's helicity table (GET_NHEL_IDX, up to MAXPART x + # the largest NCOMB) and code for FLAVOR_ORDER_HEL + smatrixhel_decl = (' integer frtab(%i), frden, nhelfix' + % (max_nexternal * max_ncomb)) flavor_repair_function = open(pjoin(MG5DIR, 'madgraph', 'iolibs', 'template_files', 'f2py_flavor_order_repair.inc')).read() flavor_repair_function %= {'maxpart': max_nexternal} else: flavor_repair_function = '' + smatrixhel_decl = '' + density_decl = '' # smatrixhel_idx: the same dispatch keyed on the 1-based matrix-element # slot (the get_pdg_order / get_prefix index) instead of on the PDG # codes, taking the extended FLAV_IDX as given. A FOLDED crossed - # subprocess has no PDG entry of its own -- that is the whole point of - # folding -- so the PDG dispatch cannot reach it; a caller that resolved - # the crossing itself (through GET_PDG_FOR_FLAVOR) holds a slot and an - # extended index instead. It shares f77_smatrixhel's alphas/scale2 + # subprocess has no PDG branch of its own -- that is the whole point of + # folding -- so the PDG dispatch finds it only by scanning the crossings + # (f77_crossed_smatrixhel); a caller that resolved the crossing itself + # (through GET_PDG_FOR_FLAVOR) holds a slot and an extended index + # instead and comes in here. It shares f77_smatrixhel's alphas/scale2 # handling so that a crossed and an uncrossed evaluation of the same # event use the exact same running couplings. idxtext = [] @@ -7741,28 +7909,40 @@ def write_f2py_splitter(self): #misc.sprint(all_iden) + # the crossed fallback reads the caller's PDGS: it is never rewritten + # (the merged codes live in LPDGS), so no copy is needed. The density + # and get_all_inter dispatches fall back the same way (their `else` + # branch above), get_all_inter with the caller's LNHEL copy. if crossed_next: - crossed_decl = ' integer opdgs(%i)' % max_nexternal - crossed_copy = ' do i = 1, npdg\n opdgs(i) = pdgs(i)\n enddo' - crossed_routine = self.crossed_smatrixhel_routine(f2py_prefix, - max_nexternal) + crossed_routine = self.crossed_smatrixhel_routine( + f2py_prefix, max_nexternal, + max_ncomb if use_repair else None) \ + + '\n' + self.crossed_order_routine(f2py_prefix, max_nexternal) + crossed_density = self.crossed_density_routine(f2py_prefix, + max_nexternal) + crossed_inter = self.crossed_get_all_inter_routine(f2py_prefix, + max_nexternal) else: - crossed_decl = crossed_copy = crossed_routine = '' + crossed_routine = crossed_density = crossed_inter = '' formatting = {'python_information':'\n'.join(info), 'smatrixhel': '\n'.join(smtext) % {'f2py_prefix': f2py_prefix}, - 'crossed_pdgs_decl': crossed_decl, - 'crossed_pdgs_copy': crossed_copy, 'crossed_smatrixhel': crossed_routine, + 'crossed_density': crossed_density, + 'crossed_get_all_inter': crossed_inter, 'smatrixhel_idx': '\n'.join(idxtext), 'flavor_index_decl': flavor_index_decl, + 'smatrixhel_decl': smatrixhel_decl, + 'density_decl': density_decl, 'maxpart': max_nexternal, 'nb_me': len(allids), 'pdgs': ','.join(str(pdg[i]) if iGET_PDG_FOR_FLAVOR / GET_FLAVOR_LAYOUT / GET_NHEL_IDX / # GET_DENSITY_IDX, etc.) in the COMBINED all_matrix module. They live in # each subprocess' self-contained f2py_matrix_wrapper.f and call the - # M_* routines already linked into liball...me; the combined wrapper is - # otherwise base-only, so a crossing-aware python caller (MadSpin's - # density path) could not reach a folded crossed subprocess through it. + # M_* routines already linked into liball...me; the combined + # wrapper's PDG entries reach a folded crossed subprocess only by its + # PDG codes (f77_crossed_*), so a python caller holding an extended + # index needs these. # Concatenate rather than add the files to the f2py command line: f2py's # multi-file build leaves the extra wrappers' symbols undefined at # dlopen on some platforms, whereas a single scanned source links them. + # --f2py_per_process=False leaves them out: f2py's code generation + # grows faster than linearly with the routines of one module (about 14 + # per matrix element), so a 300-subprocess MadSpin output spent over an + # hour in f2py for entry points MadSpin never calls -- it only uses the + # dispatching ones above, which reach a folded crossing by its PDG + # codes (f77_crossed_*). wrappers = sorted(glob.glob(pjoin(self.dir_path, 'SubProcesses', '*', 'f2py_matrix_wrapper.f'))) - if wrappers: + per_process = (self.opt['output_options'] or {}).get( + 'f2py_per_process', True) + if wrappers and str(per_process).lower() not in ('false', '0', 'no'): with open(f2py_wrapper_path, 'a') as fsock: for wpath in wrappers: fsock.write('\nC crossing-aware f2py wrappers from %s\n' @@ -7812,22 +8003,24 @@ def write_f2py_splitter(self): self.write_crossing_records() def crossed_smatrixhel_prefixes(self): - """{proc_prefix: (nexternal, nflav, recorded rows K, process ids)} of - the matrix elements a crossed subprocess was folded into.""" + """{proc_prefix: (nexternal, nflav, recorded rows K, process ids, + ninitial)} of the matrix elements a crossed subprocess was folded + into.""" out = {} for (pdgs, pid), info in self.prefix_info.items(): rows = self.crossing_records.get(info[0], ((), True, {}))[0] if not rows or len(info) < 6: continue entry = out.setdefault(info[0], (len(pdgs), info[5], list(rows), - set())) + set(), info[4])) entry[3].add(pid) return out def crossed_smatrixhel_nexternal(self): return set(v[0] for v in self.crossed_smatrixhel_prefixes().values()) - def crossed_smatrixhel_routine(self, f2py_prefix, maxpart): + def crossed_smatrixhel_routine(self, f2py_prefix, maxpart, + max_ncomb=None): """F77_CROSSED_SMATRIXHEL: the PDG entry of a FOLDED crossed subprocess. A crossed subprocess folded onto its base (merge_crossing='record') has @@ -7837,19 +8030,161 @@ def crossed_smatrixhel_routine(self, f2py_prefix, maxpart): FLAV_IDX = K*NFLAV + FLAV, K one of the crossing-table rows the generation recorded (crossing_records); GET_PDG_FOR_FLAVOR names the process of each such index in its own slot order, so the one asked - for is found by comparing the signed PDG codes. Legs must come in an - order the crossing table serves (GET_PDG_FOR_FLAVOR's), as for the - base entries without the flavor-order repair; otherwise ANS = 0.""" - lines = [' subroutine %sf77_crossed_smatrixhel(pdgs, procid, npdg,' - ' p, nhel, ans)' % f2py_prefix, - ' implicit none', - ' integer npdg, procid, nhel', - ' integer pdgs(npdg)', - ' double precision p(0:3,npdg), ans', - ' integer xpdgs(%i), ik, f, j, idx' % maxpart, - ' logical same'] + for is found by comparing the signed PDG codes. Legs in another order + inside the initial or the final state are moved to GET_PDG_FOR_FLAVOR's + order (F77_CROSSED_ORDER), as the base entries do with + FLAVOR_ORDER_REPAIR, and a helicity code NHEL .GE. 1 is re-keyed with + them (FLAVOR_ORDER_HEL, through the crossed process's own table + GET_NHEL_IDX), so that the folded and the expanded output give the + same per-helicity value. That needs the repair routines, emitted with + merged particles only (MAX_NCOMB, the largest NCOMB, is None without + them: then no two legs of one merged code can differ and nothing + moves anyway). A call no crossing serves leaves FOUND false (the + dispatch then answers NaN, F77_NO_PROCESS_ANS). + The beam swap of a folded crossing is found as well (see + _crossed_dispatch_routine), a PDG order the expanded output lacks.""" + return self._crossed_dispatch_routine( + [' subroutine %sf77_crossed_smatrixhel(pdgs, lpdgs, procid,' + ' npdg, p, nhel, ans, found)' % f2py_prefix, + ' implicit none', + ' integer npdg, procid, nhel', + ' integer pdgs(npdg), lpdgs(npdg)', + ' double precision p(0:3,npdg), ans', + ' logical found'] + ( + [' integer xtab(%i), xden, nhelfix' % (maxpart * max_ncomb)] + if max_ncomb else []), + ' ans = 0d0\n found = .false.', + lambda prefix: ([ + ' nhelfix = nhel', + ' if (nhel.ge.1) then', + ' call %sget_nhel_idx(idx, xden, xtab)' % prefix, + ' call flavor_order_hel(nhel, npdg, %i, xtab,' + ' legof, nhelfix)' % self._prefix_ncomb(prefix), + ' endif', + ' call %ssmatrixhel(pfix, nhelfix, idx, ans)' + % prefix] if max_ncomb else [ + ' call %ssmatrixhel(pfix, nhel, idx, ans)' + % prefix]) + [' found = .true.'], + maxpart, f2py_prefix, '.true.' if max_ncomb else 'nhel.lt.1') + + def _prefix_ncomb(self, prefix): + """NCOMB of the matrix element written with proc_prefix `prefix`.""" + return max(info[2] for info in self.prefix_info.values() + if info[0] == prefix) + + # The open legs POS of a density follow their legs through a repaired + # order (LEGOF(J), the caller's leg in slot J): POSFIX(K) is the slot of + # the caller's leg POS(K), in the same order K, so the open index K of rho + # still names that leg and the result is a pure relabelling. + _f2py_pos_follow_legs = '\n'.join([ + ' do i = 1, n_changing', + ' posfix(i) = pos(i)', + ' do j = 1, npdg', + ' if (legof(j).eq.pos(i)) posfix(i) = j', + ' enddo', + ' enddo']) + + def crossed_density_routine(self, f2py_prefix, maxpart): + """F77_CROSSED_DENSITY: f77_density's twin of F77_CROSSED_SMATRIXHEL. + + The folded crossed subprocess named by the caller's PDGS is found the + same way, and its density matrix is GET_DENSITY_IDX of the base at that + extended index, P and POS in the caller's leg order. The legs may be put + in GET_PDG_FOR_FLAVOR's order, as FLAVOR_ORDER_REPAIR does for the base + entries, the open legs POS following theirs (_f2py_pos_follow_legs). + FOUND tells the dispatch whether any process matched.""" + return self._crossed_dispatch_routine( + [' subroutine %sf77_crossed_density(pdgs, lpdgs, procid, npdg,' + ' p, pos, n_changing, allow_hel, n_comb, alphas, scale2, inter,' + ' found)' % f2py_prefix, + ' implicit none', + ' integer npdg, procid, n_changing, n_comb', + ' integer pdgs(npdg), lpdgs(npdg), pos(n_changing)', + ' integer allow_hel(n_changing*n_comb)', + ' double precision p(0:3,npdg), alphas, scale2', + ' double complex inter(n_comb*(n_comb+1)/2)', + ' integer posfix(n_changing), i, j', + ' logical found'], + ' found = .false.', + lambda prefix: [self._f2py_pos_follow_legs, + ' call %sget_density_idx(pfix, posfix, n_changing,' + ' allow_hel, n_comb, idx, alphas, scale2, inter)' % prefix, + ' found = .true.'], + maxpart, f2py_prefix, '.true.') + + def crossed_get_all_inter_routine(self, f2py_prefix, maxpart): + """F77_CROSSED_GET_ALL_INTER: f77_get_all_inter's twin of + F77_CROSSED_SMATRIXHEL, calling GET_ALL_INTER_IDX of the base (its + split-order sum GET_ALL_INTER_SUMSO_IDX for a split-order matrix + element) at the extended index. P, POS and NHEL all follow the + caller's legs; NHEL is the caller's copy (LNHEL) and is not written + to. The legs may be put in GET_PDG_FOR_FLAVOR's order, as for the + density; NHEL fixes their helicities by position, so it is moved with + them (HFIX), as POS is (POSFIX), and the result is a pure + relabelling. FOUND tells the dispatch whether any process matched.""" + so = getattr(self, 'split_order_prefixes', ()) + return self._crossed_dispatch_routine( + [' subroutine %sf77_crossed_get_all_inter(pdgs, lpdgs, procid,' + ' npdg, p, nhel, pos, n_changing, allow_hel, n_comb, inter, found)' + % f2py_prefix, + ' implicit none', + ' integer npdg, procid, n_changing, n_comb', + ' integer pdgs(npdg), lpdgs(npdg), nhel(npdg), pos(n_changing)', + ' integer allow_hel(n_changing*n_comb)', + ' double precision p(0:3,npdg)', + ' double complex inter(n_comb*(n_comb+1)/2)', + ' integer hfix(%i), posfix(n_changing), i, j' % maxpart, + ' logical found'], + ' found = .false.', + lambda prefix: [ + ' do i = 1, npdg', + ' hfix(i) = nhel(legof(i))', + ' enddo', self._f2py_pos_follow_legs, + ' call %s%s(pfix, hfix, posfix, n_changing,' + ' allow_hel, n_comb, idx, inter)' % ( + prefix, 'get_all_inter_sumso_idx' if prefix in so else + 'get_all_inter_idx'), + ' found = .true.'], + maxpart, f2py_prefix, '.true.') + + def _crossed_dispatch_routine(self, head, init, call, maxpart, + f2py_prefix, repair): + """The scan shared by the F77_CROSSED_* routines: for each matrix + element a crossed subprocess was folded into (crossed_smatrixhel_prefixes) + and each recorded crossing row K and flavor, compare GET_PDG_FOR_FLAVOR + of the extended index with the caller's PDGS and run call(prefix) (the + lines evaluating it at IDX, with the momenta PFIX) on the first match. + `head` is the subroutine line and its declarations, `init` the + statement run first (the no-match answer). + + GET_PDG_FOR_FLAVOR gives one leg order per crossed process, and a + caller may hand the same process with legs exchanged inside the + initial or the final state (`u~ d~ > u~ d~` for a row serving + `u~ d~ > d~ u~`). LPDGS, the caller's merged codes, keeps that to + legs of one merged code, as for the uncrossed entries. `repair` (a + Fortran condition) allows that, as FLAVOR_ORDER_REPAIR does for the + uncrossed entries: when no row matches exactly and the condition + holds, a second pass accepts a row whose legs only differ by such an + exchange (F77_CROSSED_ORDER); `call` moves the per-leg data along + (LEGOF). None keeps the exact match only. + + The recorded rows also hold the beam-swapped partner of a folded + crossing (its mirror), so this dispatch answers e.g. `u~ u > e+ e- g` + of p p > e+ e- j too, which the --use_crossing=False output -- one + PDG branch per generated leg order, mirrors merged -- leaves at NaN. + That value is exact: the crossed |M|^2 with the two beams (codes, + momenta and helicities) exchanged. Only beam swaps recorded as a + crossing row are reached: with grouped subprocesses the base's own + (a mirror of the base, not a row) stays NaN in both outputs. The rows + are needed by madevent and the python consumers + (recorded_crossing_codes), so this superset is kept, not filtered.""" + lines = head + [' integer xpdgs(%i), legof(%i), ik, f, idx,' + ' ipass, npass' % (maxpart, maxpart), + ' double precision pfix(0:3,%i)' % maxpart, + ' logical same'] + cond = repair or '.false.' data, body = [], [] - for prefix, (nexternal, nflav, rows, pids) in \ + for prefix, (nexternal, nflav, rows, pids, ninitial) in \ sorted(self.crossed_smatrixhel_prefixes().items()): lines.append(' integer %sxrows(%i)' % (prefix, len(rows))) data.append(' data %sxrows /%s/' % ( @@ -7862,19 +8197,81 @@ def crossed_smatrixhel_routine(self, f2py_prefix, maxpart): ' do f = 1, %i' % nflav, ' idx = %sxrows(ik)*%i + f' % (prefix, nflav), ' call %sget_pdg_for_flavor(idx, xpdgs)' % prefix, - ' same = .true.', - ' do j = 1, npdg', - ' if (xpdgs(j).ne.pdgs(j)) same = .false.', - ' enddo', - ' if (same) then', - ' call %ssmatrixhel(p, nhel, idx, ans)' % prefix, + ' call %sf77_crossed_order(npdg, %i, xpdgs, pdgs,' + ' lpdgs, ipass.eq.2, p, pfix, legof, same)' % ( + f2py_prefix, ninitial), + ' if (same) then'] + call(prefix) + [ ' return', ' endif', ' enddo', ' enddo', ' endif'] - return '\n'.join(lines + data + [' ans = 0d0'] + body + - [' return', ' end']) + return '\n'.join(lines + data + [init, + ' npass = 1', + ' if (%s) npass = 2' % cond, + ' do ipass = 1, npass'] + body + + [' enddo', ' return', ' end']) + + def crossed_order_routine(self, f2py_prefix, maxpart): + """F77_CROSSED_ORDER: does the caller's leg order PDGS read XPDGS, + the order a crossing row serves? With MOVE, legs of the same merged + code (LPDGS) may be exchanged inside the initial or inside the final + state; PFIX gets the momenta in XPDGS's order, and LEGOF(J) is the + caller's leg now in slot J (a density's open legs follow it).""" + return ''' subroutine %(fp)sf77_crossed_order(next, nini, xpdgs, pdgs, lpdgs, move, p, pfix, legof, ok) +C Exchanging two legs of one merged code inside the +C initial or the final state only renames external +C lines, so |M|^2 and the density matrix are unchanged +C (the open legs of a density move with their legs). + implicit none + integer next, nini + integer xpdgs(next), pdgs(next), lpdgs(next) + integer legof(next) + logical move, ok + double precision p(0:3,next), pfix(0:3,next) + integer perm(%(maxpart)i), i, j, k, lo, hi + logical taken(%(maxpart)i) + do j = 1, next + perm(j) = 0 + taken(j) = .false. + enddo +C fixed points first, so only the legs that must move do + do j = 1, next + if (pdgs(j).eq.xpdgs(j)) then + perm(j) = j + taken(j) = .true. + endif + enddo + ok = .false. + do j = 1, next + if (perm(j).ne.0) cycle + if (.not.move) return + if (j.le.nini) then + lo = 1 + hi = nini + else + lo = nini + 1 + hi = next + endif + do i = lo, hi + if (taken(i)) cycle + if (pdgs(i).ne.xpdgs(j).or.lpdgs(i).ne.lpdgs(j)) cycle + perm(j) = i + taken(i) = .true. + exit + enddo + if (perm(j).eq.0) return + enddo + ok = .true. + do j = 1, next + legof(j) = perm(j) + do k = 0, 3 + pfix(k, j) = p(k, perm(j)) + enddo + enddo + return + end +''' % {'fp': f2py_prefix, 'maxpart': maxpart} def write_crossing_records(self): """List the folded crossed subprocesses for the python consumers of the @@ -8088,6 +8485,9 @@ def color_dim_from_particle(p): # merely-applicable ones; write_f2py_splitter exports them. self.crossing_records[proc_prefix] = \ self.recorded_crossing_codes(matrix_element) + if self.get_matrix_template(matrix_element) == \ + 'matrix_standalone_splitOrders_v4.inc': + self.split_order_prefixes.add(proc_prefix) template = open(pjoin(self.mgme_dir, 'madgraph', 'iolibs', 'template_files', 'makefile_sa_f_sp'),'r') text = template.read() @@ -8755,8 +9155,9 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, self.fill_crossing_replace_dict_so(matrix_element, replace_dict, use_crossing) - # f2py entry points taking an extended FLAV_IDX (the only way a python - # caller can request a crossing, and reach GET_DENSITY_IDX / + # f2py entry points taking an extended FLAV_IDX (the way a python + # caller holding the index requests a crossing, and reaches + # GET_DENSITY_IDX / # GET_ALL_INTER_IDX / GET_NHEL_IDX / GET_PDG_FOR_FLAVOR). Those routines # only exist in matrix_standalone_v4.inc, so the wrappers are emitted # only there; the other standalone templates get an empty hole (the @@ -8845,6 +9246,7 @@ def fill_crossing_replace_dict_hr(self, replace_dict, use_crossing): ' ANS=ANS/DBLE(IDEN)*%sBROKEN_SYM(FLAVOR)' % prefix, 'hr_helcheck': ' IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) RETURN', + 'hr_helavg_beam': 'BEAMS_HELAVGFACTOR(L)', 'hr_helsel_decl': '', 'hr_warmup_ncross': '1', 'hr_warmup_cross_decl': '', @@ -8863,6 +9265,7 @@ def fill_crossing_replace_dict_hr(self, replace_dict, use_crossing): ' INTEGER IDENUSE, CROSSUSE\n' ' INTEGER %(p)sGET_SPINCOL_CROSS\n' ' INTEGER %(p)sGET_IDENT_CROSS\n' + ' INTEGER %(p)sGET_HELAVG_CROSS\n' ' REAL*8 PUSE(0:3,NEXTERNAL)\n' ' INTEGER ICUSE(NEXTERNAL)\n' 'C APPLY_CROSSING permutes a helicity row together with the\n' @@ -8903,9 +9306,11 @@ def fill_crossing_replace_dict_hr(self, replace_dict, use_crossing): ' ANS=ANS/DBLE(IDENUSE*%(p)sGET_IDENT_CROSS(CROSSUSE,\n' ' & FLAVOR))\n' ' ENDIF' % {'p': prefix}, + 'hr_helavg_beam': '%sGET_HELAVG_CROSS(CROSSUSE,L)' % prefix, 'hr_helsel_decl': '\n INTEGER CROSSUSE, FLAV_USE, IDENUSE, BHEL, DUMFLAV\n' ' INTEGER %(p)sGET_SPINCOL_CROSS, %(p)sGET_IDENT_CROSS\n' + ' INTEGER %(p)sGET_HELAVG_CROSS\n' ' REAL*8 PUSE(0:3,NEXTERNAL)\n' ' INTEGER ICUSE(NEXTERNAL), NHELDUM(NEXTERNAL)\n' ' INTEGER NHELOUT(NEXTERNAL)' % {'p': prefix}, @@ -8934,7 +9339,8 @@ def fill_crossing_replace_dict_hr(self, replace_dict, use_crossing): ' ENDDO\n' ' ANS = ANS / DBLE(IDENUSE*%(p)sGET_IDENT_CROSS(CROSSUSE,\n' ' & FLAVOR))\n' - ' ANS = ANS * HELAVGFACTOR\n' + 'C The crossed process has its own initial helicity average.\n' + ' ANS = ANS * %(p)sGET_HELAVG_CROSS(CROSSUSE, 0)\n' ' RETURN\n' ' ENDIF' % {'p': prefix}, 'hr_warmup_ncross': str(replace_dict.get( @@ -9232,14 +9638,20 @@ def _write_hel_recycling_matrix(self, writer, replace_dict, matrix_element): shared_anchor = (' SUBROUTINE %(proc_prefix)sGET_NHEL(' 'IDEN_STAR,NHEL_STAR)' % replace_dict) standard = open(pjoin(tmpl_dir, self.matrix_template)).read() - if shared_anchor.replace('%(proc_prefix)s', rd['proc_prefix']) \ - not in standard % rd: + # The appended GET_AMP has a rank-1 AMP and nothing else zeroes it: + # its flavor setup must carry the AMP zero-initialisation that the + # recycled templates (which zero their own AMP) were built without, + # or a diagram masked off for this flavor leaves stack data in JAMP. + rd_standard = dict(rd) + rd_standard['flavor_mask_setup'] = \ + self._get_flavor_mask_blocks(matrix_element, + append_amp_init=True)[1] + rendered = standard % rd_standard + if shared_anchor not in rendered: raise MadGraph5Error( 'hel_recycling: cannot find the shared-routine anchor in %s' % self.matrix_template) - rendered = standard % rd - tail = rendered[rendered.index( - shared_anchor.replace('%(proc_prefix)s', rd['proc_prefix'])):] + tail = rendered[rendered.index(shared_anchor):] # template_matrix.f: %()s keys filled now; ${...} slots left to hel_recycle. # FortranWriter is still used (to split long color DATA lines), but it @@ -9255,6 +9667,8 @@ def _write_hel_recycling_matrix(self, writer, replace_dict, matrix_element): # Written raw (not via FortranWriter) -- it is hand-authored fixed-form # with numbered/shared DO labels and IMPLICIT typing that the MG line # formatter would mangle; it is compiled with -ffixed-line-length-132. + rd['hr_warmup_hmv_data'] = self._hel_recycling_hmv_data( + self.frame_dependent_helicity_legs(matrix_element)) open(warmup_path, 'w').write(open(warmup_tmpl).read() % rd) gauge = 'U' @@ -9288,6 +9702,48 @@ def _write_hel_recycling_matrix(self, writer, replace_dict, matrix_element): 'ncomb': ncomb, 'gauge': gauge, 'chunk': chunk, 'ngraphs': matrix_element.get_number_of_amplitudes()}) + @staticmethod + def frame_dependent_helicity_legs(matrix_element): + """Per column of the helicity table (get_helicity_matrix order): True + for a leg whose helicity states mix under a Lorentz boost, so that a + row zero in one frame need not be zero in another -- a massive leg with + spin, and a massless spin-2 one (its +-1 states are not helicities of + the massless particle). Only the sum over such a leg's helicities, the + other legs' helicities fixed, is frame invariant. A massless spin + 1/2, 1 or 3/2 leg keeps its helicity in every frame.""" + model = matrix_element.get('processes')[0].get('model') + legs = [] + for wf in matrix_element.get_external_wavefunctions(): + part = model.get('particle_dict')[wf.get('pdg_code')] + nstates = len(wf.get('polarization')) if wf.get('polarization') \ + else len(part.get_helicity_states()) + legs.append(nstates > 1 and ( + str(part.get('mass')).lower() != 'zero' + or part.get('spin') not in (2, 3, 4))) + # An onium takes the helicity column of its first constituent (a + # massive bound state of spin J) and drops the second one, as in + # get_helicity_matrix. + if matrix_element.get_nonia() > 0: + wfs = matrix_element.get_external_wavefunctions() + constituent = [] + for first, second in matrix_element.get_onia_pairs(): + legs[first - 1] = wfs[first - 1].get('onium').get('J') > 0 + constituent.append(second) + for i in sorted(constituent, reverse=True): + del legs[i - 1] + return legs + + @staticmethod + def _hel_recycling_hmv_data(legs, per_line=8): + """The warm-up's 'DATA HMV /.../' statement (fixed form, wrapped).""" + vals = ['.TRUE.' if leg else '.FALSE.' for leg in legs] + lines = [' DATA HMV /'] + for i in range(0, len(vals), per_line): + last = i + per_line >= len(vals) + lines.append(' & %s%s' % (','.join(vals[i:i + per_line]), + '/' if last else ',')) + return '\n'.join(lines) + @staticmethod def _parse_hel_warmup(stdout): """Parse the hel_warmup stdout into (good_hels, bad_amps, @@ -9375,7 +9831,12 @@ def _run_hel_recycling_warmups(self, fortran_compiler=None): good_hels, bad_amps, bad_amps_perhel, csym_pairs = \ self._parse_hel_warmup(stdout) if not good_hels: - continue # nothing measured -> keep the compute-all version + # A Fortran STOP (e.g. RAMBO below threshold) exits with + # status 0, so an empty measurement is the only sign of it. + logger.warning('hel_recycling warm-up found no good helicity ' + 'in %s; keeping the compute-all matrix.f.\n%s', dirpath, + stdout[-1500:]) + continue bad_amps_perhel, csym_reuse, csym_dead = self._hel_recycling_csym( csym_pairs, good_hels, bad_amps_perhel, info['ngraphs']) @@ -10752,7 +11213,9 @@ def __init__(self, dir_path = "", opt=None): 'axial_gauge' in opt['output_options']: self.opt['axial_gauge'] = banner_mod.ConfigFile.format_variable( opt['output_options']['axial_gauge'], bool, 'axial_gauge') - if self.opt['axial_gauge']: + # False if the FD gauge drops it (ProcessExporterFortran.__init__ + # already said so) + if helas_call_writers.axial_gauge_requested(self.opt): # AMP2 -- the single diagram enhancement weight of # sde_strategy 1 -- is gauge dependent: the axial gauge moves # amplitude between diagrams, so |AMP_i|^2 stops following the @@ -11504,11 +11967,20 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # matrix_madevent_v4.inc does not), and a process whose definition pins a # specific s-channel has its crossings generated separately, so it stays # on the plain path. When off the fills reproduce the historical code. + # So does an ME that serves no crossing: the routing (committed before + # any matrix file is written) left its table at the identity row and + # no router or cross-group dependent calls it with a crossed FLAV_IDX, + # so the decode would only ever see CROSSUSE = 0. me_use_crossing = ( self.opt.get('use_crossing', True) and self.matrix_file == 'matrix_madevent_group_v4.inc' and not any(self.breaks_crossing_symmetry(proc) - for proc in matrix_element.get('processes'))) + for proc in matrix_element.get('processes')) + and (len(self.madevent_crossing_table(matrix_element)) > 1 + or id(matrix_element) in getattr( + self, '_crossgroup_base_mes', set()) + or id(matrix_element) in getattr( + self, '_router_base_mes', set()))) self.fill_crossing_replace_dict_me(matrix_element, replace_dict, me_use_crossing, proc_id, xgrow_map=xgrow_map) @@ -11981,13 +12453,11 @@ def _crossgroup_configmap(self, dep_me, base_me, cross): def _helicity_state_table(self, matrix_element, name): """(maxhel, lines): DATA for NAME(MAXHEL, NEXTERNAL), the helicity - values of each leg in the canonical-code digit order (the - allow_reverse=True order GET_NHEL decodes with, see _helstate_data), - and N(NEXTERNAL) their counts.""" - pdict = matrix_element.get('processes')[0].get('model').get( - 'particle_dict') - states = [pdict[wf.get('pdg_code')].get_helicity_states(True) - for wf in matrix_element.get_external_wavefunctions()] + values of each leg in the canonical-code digit order (the lists + GET_NHEL decodes with, see _helstate_data: allow_reverse=True, a + polarization restriction included -- though a polarized process never + crosses), and N(NEXTERNAL) their counts.""" + states = [list(s) for s in matrix_element.get_helicity_per_particle()] maxhel = max(len(s) for s in states) if states else 1 flat = [s[i] if i < len(s) else 0 for s in states for i in range(maxhel)] @@ -12007,7 +12477,11 @@ def _crossed_helicity_relabel(self, base_proc_id, flav_expr, hel_var): and the NSF flip keeps the fed value physical -- looked up in this subprocess's own state order. Copying the digit instead flips every leg whose state order is reversed by the crossing (a fermion or W - changing between particle and antiparticle). Needs XBST/NXBST (base states), XDST/NXDST + changing between particle and antiparticle). The base drew `hel_var` + from its own CDF in base-row order (C-parity representative = lower + base row), so a folded run has the helicity distribution of + --use_crossing=False but, for the same seed, not necessarily the same + helicity on each event. Needs XBST/NXBST (base states), XDST/NXDST (this subprocess's) and the XPINV/XSGNI/XDUMF/XBDIG/XHR/XHK/XHV/XHD/XHJ temporaries declared.""" return '\n'.join([ @@ -12094,6 +12568,27 @@ def positions(flav_tuple): self._get_broken_symmetry_data(process, ninitial)) return '\n'.join(lines) + '\n' + broken_sym + def _crossgroup_flavor_data(self, matrix_element, name): + """Declaration + DATA lines of the FLAVOR table (group-position coded, + exactly as this subprocess's own GET_FLAVOR would return it) of a + cross-group dependent, under the Fortran array name *name*. + + The dependent links the base's matrix object, whose GET_FLAVOR + returns the BASE leg order; every caller in the dependent's directory + (PDF in auto_dsig.f, the GET_FLAVOR dispatch of auto_dsig.f feeding + setclscales/cluster) must read this table instead. + """ + all_flv = matrix_element.get_external_flavors_with_iden() + model = self.model or matrix_element.get('processes')[0].get('model') + pdg_to_group_pos, max_group_size = self._build_flavor_group_lookup(model) + # Column-major flat DATA (leg fastest, then flavor) -- avoids an implied- + # do index variable, which need not be declared in every program unit. + positions = [str(self._map_flavor_to_group_pos( + f, pdg_to_group_pos, max_group_size)) + for flav in all_flv for f in flav[0]] + return [' INTEGER %s(NEXTERNAL,%d)' % (name, len(all_flv)), + ' DATA %s /%s/' % (name, ','.join(positions))] + def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): """Fill the cross-group (Track B) holes of auto_dsig_v4.inc for a dependent subprocess that has no matrix element of its own and routes to @@ -12121,18 +12616,10 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): base_me = crossgroup['base_me'] nflav_base = len(base_me.get_external_flavors_with_iden()) all_flv = matrix_element.get_external_flavors_with_iden() - model = self.model or matrix_element.get('processes')[0].get('model') - pdg_to_group_pos, max_group_size = self._build_flavor_group_lookup(model) - - # Column-major flat DATA (leg fastest, then flavor) -- avoids an implied- - # do index variable, which need not be declared in every program unit. - positions = [str(self._map_flavor_to_group_pos( - f, pdg_to_group_pos, max_group_size)) - for flav in all_flv for f in flav[0]] - decl = [' INTEGER DSIG_XGFLAV(NEXTERNAL,%d)' % len(all_flv), - ' DATA DSIG_XGFLAV /%s/' % ','.join(positions), - ' INTEGER DSIG_XGROUTE(%d)' % len(all_flv), - ' DATA DSIG_XGROUTE /%s/' % ','.join(str(x) for x in flav_idx)] + decl = self._crossgroup_flavor_data(matrix_element, 'DSIG_XGFLAV') + decl += [' INTEGER DSIG_XGROUTE(%d)' % len(all_flv), + ' DATA DSIG_XGROUTE /%s/' + % ','.join(str(x) for x in flav_idx)] # Per-flavor colour map (base flow -> dependent flow). colmap = [self._router_colmap(matrix_element, base_me, @@ -14838,15 +15325,32 @@ def write_super_auto_dsig_file(self, writer, subproc_group, # Generate get_flavor dispatch for the grouped case # Each subprocess has its own GET_FLAVOR subroutine (from matrix element template) # The wrapper get_flavor(iflav, iproc, flavor) dispatches based on iproc + # A cross-group dependent (Track B) links the base's matrix object, so + # its GET_FLAVOR is the base's (base leg order): the clustering scale + # (setclscales/cluster) then pairs the wrong legs and every config with + # a gluon joining two final-state gluons fails. Read the dependent's own + # table instead (the DSIG_XGFLAV its auto_dsig.f uses for the PDF). get_flavor_decl = [] get_flavor_call = [] + crossgroup = getattr(self, '_crossgroup', None) or {} for iproc in range(len(matrix_elements)): - get_flavor_decl.append(" external get_flavor%i" % (iproc + 1)) + routed = group_number is not None and \ + (group_number, iproc) in crossgroup + if routed: + get_flavor_decl.extend(self._crossgroup_flavor_data( + matrix_elements[iproc], 'XGFLAV%i' % (iproc + 1))) + else: + get_flavor_decl.append(" external get_flavor%i" % (iproc + 1)) if iproc == 0: get_flavor_call.append(' if(iproc.eq.1)then') else: get_flavor_call.append(' elseif(iproc.eq.%d)then' % (iproc + 1)) - get_flavor_call.append(" call get_flavor%i(iflav, flavor)" % (iproc + 1)) + if routed: + get_flavor_call.append(" flavor(:) = xgflav%i(:, iflav)" + % (iproc + 1)) + else: + get_flavor_call.append(" call get_flavor%i(iflav, flavor)" + % (iproc + 1)) get_flavor_call.append(' endif') replace_dict['call_to_local_get_flavor'] = "\n".join(get_flavor_call) diff --git a/madgraph/iolibs/files.py b/madgraph/iolibs/files.py index 62d6254057..d5f81b3e0b 100755 --- a/madgraph/iolibs/files.py +++ b/madgraph/iolibs/files.py @@ -246,6 +246,19 @@ def ln(file_pos, starting_dir='.', name='', log=True, cwd=None, abspath=False): if not abspath: target = os.path.relpath(file_pos, starting_dir) + # The kernel resolves '..' on the physical directory, so a relative + # path counted on a logical one that crosses a symlinked directory + # (macOS /var -> /private/var) lands elsewhere: count it on the + # physical directories then, or fall back to the absolute path. + if os.path.realpath(os.path.join(starting_dir, target)) != \ + os.path.realpath(file_pos): + phys_pos = os.path.join( + os.path.realpath(os.path.dirname(os.path.abspath(file_pos))), + os.path.basename(os.path.abspath(file_pos))) + target = os.path.relpath(phys_pos, os.path.realpath(starting_dir)) + if os.path.realpath(os.path.join(starting_dir, target)) != \ + os.path.realpath(file_pos): + target = os.path.abspath(file_pos) else: target = file_pos diff --git a/madgraph/iolibs/helas_call_writers.py b/madgraph/iolibs/helas_call_writers.py index aa3d7853d5..31a58d0ebe 100755 --- a/madgraph/iolibs/helas_call_writers.py +++ b/madgraph/iolibs/helas_call_writers.py @@ -38,11 +38,13 @@ class HelasWriterError(Exception): #=============================================================================== # Axial-gauge reference vectors #=============================================================================== -def axial_gauge_requested(output_options): +def axial_gauge_requested(output_options, warn=False): """Whether --axial_gauge=True was given on the output line. The FD gauge has its own five-component vxxxxx and no vxxxxxr, so the - option is dropped there (with a warning).""" + option is dropped there. This is asked once per matrix element; the + exporters ask it once more with warn=True when they are built, so that + every output command that drops the option says so, once.""" value = (output_options or {}).get('axial_gauge', False) if isinstance(value, str): @@ -50,13 +52,11 @@ def axial_gauge_requested(output_options): if not value: return False if aloha.unitary_gauge == 3: - if not axial_gauge_requested.warned: + if warn: logger.warning('axial_gauge is not available in the FD gauge: ' 'the option is ignored.') - axial_gauge_requested.warned = True return False return True -axial_gauge_requested.warned = False def get_axial_gauge_refs(matrix_element): @@ -86,6 +86,19 @@ def get_axial_gauge_refs(matrix_element): by r.p, which for r on the beam is E * pT * exp(-y) of the gauged leg and so is kept away from zero by the usual pT and rapidity cuts (a final-state r relies on the separation cuts instead). + + Crossing: the choice is made on the base process and a crossed call keeps + it as a base SLOT (fortran P(0,ref), C++ xmom/xperm[ref]), so the crossed + amplitude is the analytic continuation of the base one in the same gauge + and the gauge zeros are the same for every crossing served (what lets the + recycled zero-amplitude scan drop them under crossing). The price: a slot + that was initial in the base can be final in a crossing, so the + numerical-safety argument above holds for the base only. When a crossed + vector and its reference end up on the same beam (g c > g c served by + g g > c c~, the outgoing gluon at angle theta from the incoming one) r.p + is ~theta**2 and the relative precision is ~1e-16/theta**2 instead of + machine precision: below 1e-11 for theta > 1e-2, i.e. for any physical + jet cut. --use_crossing=False picks the reference per process. """ externals = {} diff --git a/madgraph/iolibs/template_files/f2py_flavor_order_repair.inc b/madgraph/iolibs/template_files/f2py_flavor_order_repair.inc index 5847ca9948..a666b0d7d5 100644 --- a/madgraph/iolibs/template_files/f2py_flavor_order_repair.inc +++ b/madgraph/iolibs/template_files/f2py_flavor_order_repair.inc @@ -1,5 +1,5 @@ SUBROUTINE FLAVOR_ORDER_REPAIR(GETIDX, GETFLV, NEXT, NINI, NFLAV, - & NFROZEN, FROZEN, DOREPAIR, FLAVOR, PDGS, P, PFIX) + & DOREPAIR, FLAVOR, PDGS, P, PFIX, LEGOF) C Re-order the external legs so that GETIDX resolves their flavor. C C The generated FLAV_TABLE tabulates ONE leg ordering per flavor @@ -25,15 +25,17 @@ C GETFLV : the ME's GET_FLAVOR (index -> the tabulated FLAVOR column) C NEXT : number of external legs of this ME C NINI : number of initial legs (legs never move across that split) C NFLAV : number of columns of the ME's flavor table -C NFROZEN, FROZEN : legs that must NOT move. For the density these are -C POS, the legs whose helicity is an open index of rho: -C permuting one of those would permute the rows/columns of -C rho, which is a genuine ambiguity and not ours to settle. -C DOREPAIR: .FALSE. leaves the ordering untouched. SMATRIXHEL passes -C NHEL .LT. 1 here: a caller asking for one specific helicity -C row names it by leg position, and re-ordering the legs would -C answer for a different helicity assignment. Rather than -C guess, such a call keeps the 0 it returned before. +C DOREPAIR: .FALSE. leaves the ordering untouched. Per-leg +C helicities must move with their legs: GET_ALL_INTER's +C NHEL array through LEGOF, a SMATRIXHEL helicity code +C through FLAVOR_ORDER_HEL (below), and the open legs +C POS of a density too (POS(K) -> the slot J with +C LEGOF(J) = POS(K), in the same order K): the open +C index K of rho then still names the caller's leg +C POS(K), so rho comes out unpermuted. Freezing +C those legs instead left a resolvable ordering +C (u~ d~ > u~ d~ with POS = 1 2 on a table holding +C d~ u~ > d~ u~) at a silent 0. C FLAVOR : per-leg group positions, repaired in place C PDGS : per-leg merged pdg codes (read only; two legs may only be C exchanged when they carry the same merged code) @@ -41,22 +43,27 @@ C P : input momenta C PFIX : momenta to hand to the matrix element. Always written -- C a copy of P when nothing had to move -- so that the caller C has a single array to pass on and P is never modified. +C LEGOF : the input leg now in slot J is LEGOF(J) +C (the identity when nothing moved), for +C a caller moving more per-leg data along +C (GET_ALL_INTER's NHEL). IMPLICIT NONE INTEGER MAXLEG PARAMETER (MAXLEG=%(maxpart)i) INTEGER GETIDX EXTERNAL GETIDX EXTERNAL GETFLV - INTEGER NEXT, NINI, NFLAV, NFROZEN + INTEGER NEXT, NINI, NFLAV LOGICAL DOREPAIR - INTEGER FROZEN(*), FLAVOR(*), PDGS(*) + INTEGER FLAVOR(*), PDGS(*), LEGOF(*) DOUBLE PRECISION P(0:3,*), PFIX(0:3,*) INTEGER FR_I, FR_J, FR_K, FR_LO, FR_HI, FR_IFLAV INTEGER CAND(MAXLEG), PERM(MAXLEG), TAKEN(MAXLEG), FTMP(MAXLEG) - LOGICAL FR_OK, FR_KEEP(MAXLEG) + LOGICAL FR_OK DO FR_J = 1, NEXT + LEGOF(FR_J) = FR_J DO FR_K = 0, 3 PFIX(FR_K, FR_J) = P(FR_K, FR_J) ENDDO @@ -67,15 +74,6 @@ C has a single array to pass on and P is never modified. C the ordering we were given is tabulated: nothing to repair IF (GETIDX(FLAVOR) .NE. 0) RETURN - DO FR_J = 1, NEXT - FR_KEEP(FR_J) = .FALSE. - ENDDO - DO FR_I = 1, NFROZEN - IF (FROZEN(FR_I) .GE. 1 .AND. FROZEN(FR_I) .LE. NEXT) THEN - FR_KEEP(FROZEN(FR_I)) = .TRUE. - ENDIF - ENDDO - DO FR_IFLAV = 1, NFLAV CALL GETFLV(FR_IFLAV, CAND) DO FR_J = 1, NEXT @@ -96,11 +94,6 @@ C finds one. FR_OK = .TRUE. DO FR_J = 1, NEXT IF (PERM(FR_J) .NE. 0) CYCLE -C a frozen leg that is not already a fixed point cannot be helped - IF (FR_KEEP(FR_J)) THEN - FR_OK = .FALSE. - EXIT - ENDIF IF (FR_J .LE. NINI) THEN FR_LO = 1 FR_HI = NINI @@ -109,7 +102,7 @@ C a frozen leg that is not already a fixed point cannot be helped FR_HI = NEXT ENDIF DO FR_I = FR_LO, FR_HI - IF (TAKEN(FR_I) .EQ. 0 .AND. .NOT. FR_KEEP(FR_I) + IF (TAKEN(FR_I) .EQ. 0 & .AND. FLAVOR(FR_I) .EQ. CAND(FR_J) & .AND. PDGS(FR_I) .EQ. PDGS(FR_J)) THEN PERM(FR_J) = FR_I @@ -125,6 +118,7 @@ C a frozen leg that is not already a fixed point cannot be helped IF (.NOT. FR_OK) CYCLE C slot FR_J takes the leg that currently sits in PERM(FR_J) DO FR_J = 1, NEXT + LEGOF(FR_J) = PERM(FR_J) FTMP(FR_J) = FLAVOR(PERM(FR_J)) DO FR_K = 0, 3 PFIX(FR_K, FR_J) = P(FR_K, PERM(FR_J)) @@ -135,7 +129,59 @@ C slot FR_J takes the leg that currently sits in PERM(FR_J) ENDDO RETURN ENDDO -C no tabulated ordering matches: leave everything as it was, the matrix -C element returns 0 exactly as it did before. +C No tabulated ordering matches: FLAVOR stays unresolved +C (GETIDX = 0), which the density and get_all_inter +C dispatch treat as a miss (crossed fallback, then NaN). + RETURN + END + + + SUBROUTINE FLAVOR_ORDER_HEL(NHEL, NEXT, NCOMB, TAB, LEGOF, + & NHELFIX) +C Re-key a SMATRIXHEL helicity code through the leg +C permutation of FLAVOR_ORDER_REPAIR (or of the folded +C crossing dispatch), so that every helicity stays with +C its leg. +C +C The caller names code NHEL in ITS leg order: leg I has +C helicity TAB(I,NHEL), TAB being the table of the +C process asked for (GET_NHEL_IDX), whose column C is the +C code C. Slot J now holds the caller's leg LEGOF(J), so +C the code to hand on is the column C' with +C TAB(J,C') = TAB(LEGOF(J),NHEL) for every J. Only legs +C of one merged code are exchanged, so both carry the +C same helicity states and that column exists. +C +C NHELFIX is NHEL for the helicity sum (NHEL .LT. 1) or +C when nothing moved, and 0 (no helicity, |M|^2 = 0) when +C NHEL names no column or no column matches. + IMPLICIT NONE + INTEGER NHEL, NEXT, NCOMB, NHELFIX + INTEGER TAB(NEXT,*), LEGOF(*) + INTEGER FH_J, FH_C + LOGICAL FH_SAME + + NHELFIX = NHEL + IF (NHEL .LT. 1) RETURN + FH_SAME = .TRUE. + DO FH_J = 1, NEXT + IF (LEGOF(FH_J) .NE. FH_J) FH_SAME = .FALSE. + ENDDO + IF (FH_SAME) RETURN + NHELFIX = 0 + IF (NHEL .GT. NCOMB) RETURN + DO FH_C = 1, NCOMB + FH_SAME = .TRUE. + DO FH_J = 1, NEXT + IF (TAB(FH_J,FH_C) .NE. TAB(LEGOF(FH_J),NHEL)) THEN + FH_SAME = .FALSE. + EXIT + ENDIF + ENDDO + IF (FH_SAME) THEN + NHELFIX = FH_C + RETURN + ENDIF + ENDDO RETURN END diff --git a/madgraph/iolibs/template_files/f2py_splitter.py b/madgraph/iolibs/template_files/f2py_splitter.py index f730d6c5c4..40133bb29a 100644 --- a/madgraph/iolibs/template_files/f2py_splitter.py +++ b/madgraph/iolibs/template_files/f2py_splitter.py @@ -18,11 +18,12 @@ double precision p(*) double precision ANS, ALPHAS, PI,SCALE2 integer flavor(%(maxpart)i),lpdgs(%(maxpart)i),I + logical found %(flavor_index_decl)s -%(crossed_pdgs_decl)s +%(smatrixhel_decl)s include 'coupl.inc' - - + + if (scale2.eq.0)then PI = 3.141592653589793D0 G = 2* DSQRT(ALPHAS*PI) @@ -31,10 +32,13 @@ CALL UPDATE_AS_PARAM2(scale2, ALPHAS) endif -%(crossed_pdgs_copy)s %(flavormapping)s +C FOUND: as in f77_density (f77_crossed_smatrixhel for a +C folded crossing); a miss is NaN, not a vanishing |M|^2. + found = .true. %(smatrixhel)s + if (.not.found) call %(f2py_prefix)sf77_no_process_ans('smatrixhel', pdgs, npdg, procid, ans) return end @@ -49,10 +53,10 @@ C by its slot in get_pdg_order/get_prefix (PROCINDEX, C 1-based) and taking the extended flavor index (FLAV_IDX = C K*NFLAV + flav) as given rather than resolving it from the -C PDG codes. This is the only way in to a FOLDED crossed -C subprocess: it has no PDG entry of its own, so the -C dispatch above cannot name it, and the FLAVOR array cannot -C express a crossing (see +C PDG codes, for a caller that resolved a FOLDED crossed +C subprocess itself (f77_smatrixhel finds it from the PDG +C codes through f77_crossed_smatrixhel; the FLAVOR array +C cannot express a crossing, see C matrix_standalone_f2py_flav_idx.inc). The alphas/scale2 C setup is deliberately the same as in f77_smatrixhel. CF2PY double precision, intent(in), dimension(0:3,npdg) :: p @@ -106,20 +110,29 @@ INTEGER ALLOW_HEL(n_changing*n_comb) DOUBLE COMPLEX INTER(n_comb*(n_comb+1)/2) integer flavor(%(maxpart)i),lpdgs(%(maxpart)i),I + logical found %(flavor_index_decl)s +%(density_decl)s C Update is done insider the direct density call functions %(flavormapping)s +C FOUND: some matrix element (uncrossed, or a folded +C crossing through f77_crossed_density) evaluates PDGS. + found = .true. %(density_splitter)s + if (.not.found) call %(f2py_prefix)sf77_no_process('py_get_density', pdgs, npdg, procid, inter, n_comb*(n_comb+1)/2) return end +%(crossed_density)s + subroutine %(f2py_prefix)sf77_get_all_inter(pdgs, procid, npdg, P, NHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, INTER) IMPLICIT NONE C P momenta -C NHEL(NPDG) helicity of the legs not in POS (POS entries ignored) +C NHEL(NPDG) helicity of the legs not in POS (entries +C at POS ignored) C POS(N_CHNGING): position of the changing helicity C n_changing: number of changing helicity C ALLOW_HEL(NCOMB, N_CHANGING): combination of helicity to @@ -129,7 +142,7 @@ integer pdgs(*) integer npdg, procid, n_changing - integer nhel(*), lnhel(%(maxpart)i) + integer nhel(*), lnhel(%(maxpart)i), hfix(%(maxpart)i) integer n_comb double precision p(*) double precision ANS, PI,SCALE2 @@ -137,25 +150,68 @@ INTEGER ALLOW_HEL(*) DOUBLE COMPLEX INTER(*) integer flavor(%(maxpart)i),lpdgs(%(maxpart)i),I + logical found %(flavor_index_decl)s +%(density_decl)s C Update is done insider the direct density call functions C Update is done insider the direct density call functions %(flavormapping)s -C The per-process GET_ALL_INTER writes the POS entries of its NHEL: -C hand it a copy, f2py passes a numpy array straight through. +C GET_ALL_INTER may overwrite NHEL at POS (the split-order +C one does): hand it a copy, f2py passes the numpy array. +C A repaired leg order takes the helicities along (HFIX). lnhel(:) = 0 do i = 1, min(npdg, %(maxpart)i) lnhel(i) = nhel(i) enddo +C FOUND: as in f77_density (f77_crossed_get_all_inter +C for a folded crossing, NHEL in the caller's legs). + found = .true. %(inter_splitter)s + if (.not.found) call %(f2py_prefix)sf77_no_process('py_get_all_inter', pdgs, npdg, procid, inter, n_comb*(n_comb+1)/2) return end +%(crossed_get_all_inter)s + + SUBROUTINE %(f2py_prefix)sf77_no_process(FCT, PDGS, NPDG, PROCID, INTER, NINTER) +C No matrix element of this module, uncrossed or folded +C crossing, evaluates PDGS (for PROCID): say so and +C answer NaN, never a silent zero a caller could not tell +C from a vanishing density matrix. Legs must come in an +C order a matrix element tabulates (get_pdg_order, or for +C a folded crossing its GET_PDG_FOR_FLAVOR order). + IMPLICIT NONE + CHARACTER*(*) FCT + INTEGER NPDG, PROCID, NINTER, I + INTEGER PDGS(NPDG) + DOUBLE COMPLEX INTER(NINTER) + DOUBLE PRECISION XNAN + CALL %(f2py_prefix)sF77_NO_PROCESS_ANS(FCT, PDGS, NPDG, PROCID, XNAN) + DO I = 1, NINTER + INTER(I) = DCMPLX(XNAN, XNAN) + ENDDO + RETURN + END + + SUBROUTINE %(f2py_prefix)sF77_NO_PROCESS_ANS(FCT, PDGS, NPDG, PROCID, ANS) +C F77_NO_PROCESS for a single answer (smatrixhel): the +C ERROR line, and ANS = NaN. + USE, INTRINSIC :: IEEE_ARITHMETIC + IMPLICIT NONE + CHARACTER*(*) FCT + INTEGER NPDG, PROCID, I + INTEGER PDGS(NPDG) + DOUBLE PRECISION ANS + WRITE(*,*) 'ERROR: ', FCT, ': no matrix element for PDGS', (PDGS(I), I=1,NPDG), ' PROCID', PROCID, ': answer set to NaN' + ANS = IEEE_VALUE(ANS, IEEE_QUIET_NAN) + RETURN + END + SUBROUTINE %(f2py_prefix)sf77_INITIALISE(PATH) C ROUTINE FOR F2PY to read the benchmark point. IMPLICIT NONE diff --git a/madgraph/iolibs/template_files/f2py_wrapper_all.inc b/madgraph/iolibs/template_files/f2py_wrapper_all.inc index 9bf96ebe6e..00b1827ec2 100644 --- a/madgraph/iolibs/template_files/f2py_wrapper_all.inc +++ b/madgraph/iolibs/template_files/f2py_wrapper_all.inc @@ -29,9 +29,9 @@ CF2PY double precision, intent(in) :: SCALE2 C Crossing-aware twin of SMATRIXHEL. PROCINDEX is the C 1-based get_pdg_order slot of the matrix element and C FLAV_IDX the extended flavor index (cross*NFLAV+flav) of -C the process to evaluate. A folded crossed subprocess is -C reachable only this way: it has no PDG entry of its own, -C and the FLAVOR array cannot carry a crossing. +C the process to evaluate, for a caller that resolved the +C crossing itself. SMATRIXHEL reaches a folded crossed +C subprocess by its PDG codes too (F77_CROSSED_SMATRIXHEL). CF2PY double precision, intent(in), dimension(0:3,npdg) :: p CF2PY integer, intent(in) :: procindex @@ -78,13 +78,13 @@ CF2PY integer, intent(hide), depend(PDGS) :: NPDG = len(PDGS) SUBROUTINE %(f2py_prefix)sPY_GET_DENSITY_BATCH(PDGS, PROCID, P, POS, ALLOW_HEL, ALPHAS, SCALE2, NBATCH, NEXT, INTER, N_CHANGING, N_COMB, NPDG) C PY_GET_DENSITY for NBATCH phase-space points in one call. C -C The density itself is cheap; crossing into fortran is not. MadSpin's -C max-weight scan evaluates hundreds of decay configurations against the -C same production event, so paying the f2py entry once per point dominated -C the cost. Everything is per point except POS/ALLOW_HEL, which describe -C the helicity structure and are shared: PDGS is per point because a decay -C pool mixes flavours, and ALPHAS/SCALE2 because nothing guarantees the -C points share a scale. +C The density is cheap; the f2py entry is not. The MadSpin +C max-weight scan tests hundreds of decay configurations +C against one production event, so one entry per point +C dominated the cost. All inputs are per point except +C POS/ALLOW_HEL (shared helicity structure): PDGS since a +C decay pool mixes flavours, ALPHAS/SCALE2 since points +C need not share a scale. IMPLICIT NONE CF2PY double precision, intent(in), dimension(0:3,next,nbatch) :: p CF2PY integer, intent(in), dimension(npdg,nbatch) :: pdgs @@ -119,14 +119,21 @@ CF2PY integer, intent(hide), depend(PDGS) :: NPDG = len(PDGS) end SUBROUTINE %(f2py_prefix)sPY_GET_ALL_INTER(PDGS, PROCID, P, NHEL, POS, ALLOW_HEL, INTER, N_CHANGING, N_COMB, NPDG) -C PY_GET_DENSITY for ONE helicity of the legs not in POS: the -C interference terms between the N_COMB helicity combinations -C ALLOW_HEL of the legs POS, with every other leg at its NHEL -C helicity (the entries of NHEL at POS are ignored). Summed over -C those other helicities it gives PY_GET_DENSITY (unpolarised -C beams). -C No ALPHAS here: the couplings are the ones last set (INITIALISE, -C SMATRIXHEL or PY_GET_DENSITY). +C PY_GET_DENSITY for ONE helicity of the legs not in POS: +C the interference terms between the N_COMB helicity +C combinations ALLOW_HEL of the legs POS, every other leg +C at its NHEL helicity (NHEL at POS is ignored). Summed +C over the rows of the helicity table (NHEL) it +C gives PY_GET_DENSITY for unpolarised beams. +C No ALPHAS here: the couplings are the ones last set +C (INITIALISE, SMATRIXHEL or PY_GET_DENSITY). +C P, POS and NHEL are all in the caller's leg order. As +C for PY_GET_DENSITY, same-code legs not in POS may come +C in any order inside the initial or the final state +C (each keeps its NHEL), a folded crossed subprocess is +C found by its PDG codes too (F77_CROSSED_GET_ALL_INTER), +C and PDGS that no process matches give NaN (with an +C ERROR line), not zeros. IMPLICIT NONE CF2PY double precision, intent(in), dimension(0:3,npdg) :: p CF2PY integer, intent(in), dimension(npdg) :: pdgs diff --git a/madgraph/iolibs/template_files/hel_warmup_v4.inc b/madgraph/iolibs/template_files/hel_warmup_v4.inc index 1a7b69fae4..3493fb4696 100644 --- a/madgraph/iolibs/template_files/hel_warmup_v4.inc +++ b/madgraph/iolibs/template_files/hel_warmup_v4.inc @@ -13,6 +13,20 @@ C gen_ximprove.py parse ('Matrix Element/Good Helicity:', C 'Amplitude/ZEROAMP:', 'HEL/ZEROAMP:'). The generation step C re-runs hel_recycle with this information to drop the dead C work from the final matrix.f. +C +C The recycled matrix.f is then called in WHATEVER frame +C the user works in, so a zero may only be dropped if it +C holds in every frame. Zeros of the partonic CM frame do +C not: a massive leg's helicity states mix under a boost, +C and a single diagram is not Lorentz invariant (HELAS +C builds a massless vector's polarization in the current +C frame, a boost adds a gauge term), so e.g. the s-channel +C diagram of g u > z u, zero by angular momentum along the +C beam axis in the CM frame for half of the rows, is not +C zero once boosted. Every other point is therefore +C evaluated in a boosted frame (generic boosts, see +C BOOSTV), and the good rows are closed over the +C helicities of massive legs (HMV). C************************************************************************** use model_object IMPLICIT NONE @@ -35,12 +49,35 @@ C Crossing codes to scan. 1 (identity only) when the process C was generated without crossing symmetry. INTEGER NCROSS PARAMETER (NCROSS=%(hr_warmup_ncross)s) +C A row is good above LIMHEL/NCOMB of the helicity sum when +C every leg is massless (RELHEL). At these fixed energies a +C row suppressed by powers of m/E looks negligible while it +C is sizeable near threshold, so with a mass only an exact 0 +C is dropped, as in the C++ scan (hel_massive). REAL*8 LIMHEL PARAMETER (LIMHEL=1D-12) REAL*8 ZERO PARAMETER (ZERO=0D0) +C The boosts applied to the even-numbered points: velocities +C of 0.59-0.69 along directions with no special relation to +C the beam axis or to each other. + INTEGER NBOOST + PARAMETER (NBOOST=4) + REAL*8 BOOSTV(3,NBOOST) + DATA BOOSTV / 0.31D0,-0.42D0, 0.27D0, + & -0.53D0, 0.18D0,-0.36D0, + & 0.12D0, 0.61D0,-0.22D0, + & -0.24D0,-0.29D0, 0.58D0 / +C HMV(I): leg I has helicity states that mix under a boost +C (a massive leg with spin, or a massless spin-2 one, see +C frame_dependent_helicity_legs in export_v4.py). Summed +C over those, at fixed helicities of the other legs, the +C rows are frame invariant: a row is kept as soon as a row +C differing from it only in such legs is. + LOGICAL HMV(NEXTERNAL), HMSAME +%(hr_warmup_hmv_data)s C LOCAL - INTEGER I, IHEL, ITRY, IFLV, CROSS, NPSUSE, FLAV_EXT + INTEGER I, J, IHEL, ITRY, IFLV, CROSS, NPSUSE, FLAV_EXT REAL*8 P(0:3,NEXTERNAL), PMASS(NEXTERNAL), TOTALMASS REAL*8 PUSE(0:3,NEXTERNAL) INTEGER JC(NEXTERNAL), ICUSE(NEXTERNAL) @@ -50,8 +87,11 @@ C PMASSX: the masses of the process a crossing crosses into, C slot by slot (its own leg order): its points are generated C in its own frame REAL*8 PMASSX(NEXTERNAL) - REAL*8 SQRTS, T, ANS, TSARR(NCOMB) - LOGICAL GOODHEL(NCOMB) + REAL*8 SQRTS, T, ANS, TSARR(NCOMB), RELHEL +C GOODHEL: the rows found above RELHEL at a sampled point; +C GOODREL: above LIMHEL (C-parity pairs compared there); +C GOODOUT: GOODHEL closed over HMV, the rows that are baked. + LOGICAL GOODHEL(NCOMB), GOODREL(NCOMB), GOODOUT(NCOMB) C C-parity de-duplication. FLIP(I) is the row whose helicity C configuration is the full negation of row I (an C involution); CSYM stays true only while EVERY pair gave @@ -87,6 +127,7 @@ C----- CALL %(proc_prefix)sRESET_ZEROAMP() DO I=1,NCOMB GOODHEL(I) = .FALSE. + GOODREL(I) = .FALSE. ENDDO C FLIP is generated data (the C-parity partner of every @@ -116,6 +157,14 @@ C union over all of them. PMASSX(I) = PMASS(I) ENDDO %(hr_warmup_cross_mass)s +C A decay lighter than its products (a crossing +C into b~ > t~ e+ ve) has no physical point, so no +C row; RAMBO would STOP the whole scan on it. + IF (NINCOMING.EQ.1) THEN + IF (PMASSX(1).LE.SUM(PMASSX(2:NEXTERNAL))) CYCLE + ENDIF + RELHEL = LIMHEL + IF (ANY(PMASSX.NE.0D0)) RELHEL = 0D0 DO ITRY=1, NPSUSE IF (NINCOMING.EQ.1) THEN SQRTS = PMASSX(1) @@ -124,6 +173,14 @@ C union over all of them. IF (SQRTS.LE.2D0*TOTALMASS) SQRTS = 2.1D0*TOTALMASS + 100D0*ITRY ENDIF CALL GET_MOMENTA(SQRTS, PMASSX, P) +C Odd points stay in their partonic CM (rest) frame, even +C ones are boosted: a zero found at a boosted point too is +C one of every frame. A crossing is boosted in its own +C frame, before APPLY_CROSSING (a boost commutes with the +C crossing, which only moves the momenta between slots). + IF (MOD(ITRY,2).EQ.0) THEN + CALL HR_BOOST(P, BOOSTV(1,MOD(ITRY/2-1,NBOOST)+1)) + ENDIF DO I=1, NEXTERNAL JC(I) = +1 ENDDO @@ -145,14 +202,16 @@ C recycled calls. ANS = ANS + T ENDDO DO IHEL=1, NCOMB - IF (DABS(TSARR(IHEL)) .GT. ANS*LIMHEL/NCOMB) GOODHEL(IHEL)=.TRUE. + IF (DABS(TSARR(IHEL)) .GT. ANS*RELHEL/NCOMB) GOODHEL(IHEL)=.TRUE. + IF (DABS(TSARR(IHEL)) .GT. ANS*LIMHEL/NCOMB) GOODREL(IHEL)=.TRUE. ENDDO C One mismatch anywhere permanently invalidates the C-parity -C reuse. A pair whose BOTH members sit below the -C good-helicity threshold is pure round-off (those rows are -C dropped from the recycled table anyway), so comparing them -C relatively would reject the reuse on noise -- e.g. 1.5D-30 -C against 3.1D-31 for a dead row of g g > t t~. +C reuse; the boosted points are compared too, so a pairing +C that only holds in the CM frame is refused. A pair whose +C BOTH members sit below the good-helicity threshold is pure +C round-off, so comparing them relatively would reject the +C reuse on noise -- e.g. 1.5D-30 against 3.1D-31 for a dead +C row of g g > t t~ (such a pair is never reused below). IF (CSYM) THEN DO IHEL=1, NCOMB IF (FLIP(IHEL).GT.IHEL) THEN @@ -169,19 +228,43 @@ C against 3.1D-31 for a dead row of g g > t t~. ENDDO ENDDO +C Close the good rows over the helicities of massive legs: +C in g u > z u, rows 9, 11, 14 and 16 vanish in the CM +C frame only, and boosted they carry up to 3 per cent of +C the total. The boosted points find such rows already; +C the closure does not depend on how large they were there. DO IHEL=1, NCOMB - IF (GOODHEL(IHEL)) THEN + GOODOUT(IHEL) = GOODHEL(IHEL) + ENDDO + IF (ANY(HMV)) THEN + DO IHEL=1, NCOMB + IF (.NOT.GOODHEL(IHEL)) CYCLE + DO J=1, NCOMB + HMSAME = .TRUE. + DO I=1, NEXTERNAL + IF (.NOT.HMV(I) .AND. NHEL(I,J).NE.NHEL(I,IHEL)) + & HMSAME = .FALSE. + ENDDO + IF (HMSAME) GOODOUT(J) = .TRUE. + ENDDO + ENDDO + ENDIF + + DO IHEL=1, NCOMB + IF (GOODOUT(IHEL)) THEN WRITE(*,*) 'Matrix Element/Good Helicity: 1 ', IHEL ENDIF ENDDO C Surviving C-parity pairs (representative first). The C exporter drops the partner's amplitudes from the recycled C matrix.f and copies the representative's |M|^2 into it -C instead. +C instead. Only pairs above LIMHEL at a sampled point, so +C that each pair was compared at least once: a row kept only +C as non-zero or by the closure stays computed. IF (CSYM) THEN DO IHEL=1, NCOMB - IF (FLIP(IHEL).GT.IHEL .AND. GOODHEL(IHEL) - & .AND. GOODHEL(FLIP(IHEL))) THEN + IF (FLIP(IHEL).GT.IHEL .AND. GOODREL(IHEL) + & .AND. GOODREL(FLIP(IHEL))) THEN WRITE(*,*) 'CSYM PAIR: 1 ', IHEL, FLIP(IHEL) ENDIF ENDDO @@ -230,6 +313,26 @@ C instead. END + SUBROUTINE HR_BOOST(P, B) +C Boost every momentum of P by the velocity B (|B| < 1). + IMPLICIT NONE + INCLUDE "nexternal.inc" + REAL*8 P(0:3,NEXTERNAL), B(3) + REAL*8 B2, GAM, GG, BP + INTEGER I, K + B2 = B(1)**2 + B(2)**2 + B(3)**2 + GAM = 1D0/DSQRT(1D0-B2) + GG = (GAM-1D0)/B2 + DO I=1, NEXTERNAL + BP = B(1)*P(1,I) + B(2)*P(2,I) + B(3)*P(3,I) + DO K=1, 3 + P(K,I) = P(K,I) + (GG*BP + GAM*P(0,I))*B(K) + ENDDO + P(0,I) = GAM*(P(0,I) + BP) + ENDDO + END + + DOUBLE PRECISION FUNCTION DOT(P1,P2) C 4-Vector Dot product IMPLICIT NONE diff --git a/madgraph/iolibs/template_files/madmatrix/ProcessTables.h b/madgraph/iolibs/template_files/madmatrix/ProcessTables.h index dc2f4b87cd..7e56886906 100644 --- a/madgraph/iolibs/template_files/madmatrix/ProcessTables.h +++ b/madgraph/iolibs/template_files/madmatrix/ProcessTables.h @@ -47,6 +47,17 @@ namespace madmatrix __device__ constexpr int broken_sym_block_starts[broken_sym_nentries] = { %(broken_sym_block_starts)s }; __device__ constexpr int broken_sym_block_lengths[broken_sym_nentries] = { %(broken_sym_block_lengths)s }; + // Good-helicity scan (sigmaKin_getGoodHel, host only). The scan points sit at + // a fixed sqrt(s), where a row suppressed by powers of m/E (a helicity flip + // on a b quark at 1500 GeV) looks negligible, though near threshold it is + // several per cent of |M|^2: with a massive external leg (hel_massive) only an + // exact 0 is dropped. hel_frame_leg[k] is 1 when the helicity states of leg + // k mix under a boost (a massive leg with spin, a massless spin-2 one): a + // row is good as soon as a row differing from it only at such legs is, so + // that the list holds in every frame (the fortran warm-up's HMV). + constexpr bool hel_massive = %(hel_massive)s; + constexpr int hel_frame_leg[ProcessData::npar] = { %(hel_frame_leg)s }; + %(crossing_tables)s // Row `cross` of the crossing table in the BASE-slot view: perm[b] is the // input slot whose momentum lands in base slot b and ic[b] its NSF sign diff --git a/madgraph/iolibs/template_files/madmatrix/backend/common/Density.cc b/madgraph/iolibs/template_files/madmatrix/backend/common/Density.cc new file mode 100644 index 0000000000..1ae9bd1a11 --- /dev/null +++ b/madgraph/iolibs/template_files/madmatrix/backend/common/Density.cc @@ -0,0 +1,505 @@ +// Copyright (C) 2020-2026 CERN and UCLouvain. +// Licensed under the GNU Lesser General Public License (version 3 or later). +// Integrated with the MadGraph7 project in Feb 2026. +// +// Spin-density matrices for MadSpin (see Density.h for the interface and its +// conventions). Shared by the C++ backends: the jamps come from the backend's +// calculate_jamps, one event page at a time, and the color contraction between +// two helicity combinations is done here, per event, in double precision. + +#include "Density.h" + +#include "mgOnGpuConfig.h" + +#include "ColorData.h" // colorMatrix/colorDenom, sqSoIndex/chosenSqso +#include "MemoryAccessMomenta.h" +#include "Parameters.h" // ndcoup +#include "ProcessData.h" +#include "ProcessTables.h" // the crossing table (use_crossing, ncross, cross_pinv) +#include "SigmaKin.h" // calculate_jamps, computeDependentCouplings, getHelicities + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace madmatrix +{ + namespace + { + using namespace ProcessData; + using cplx = std::complex; + using ProcessTables::ncross; + using ProcessTables::use_crossing; + + // Events evaluated by one call of calculate_jamps (two SIMD pages in mixed precision) +#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + constexpr int nParity = 2; +#else + constexpr int nParity = 1; +#endif + constexpr int npage = nParity * neppV; + static_assert( npage % MemoryAccessMomentaBase::neppM == 0, "an event page must hold whole momenta pages" ); + + // A helicity row is evaluated on every event until nscan events have been + // seen, and afterwards only if one of its jamps was ever non-zero: the + // Fortran SMATRIX filter (NTRY < 20). + constexpr int nscan = 20; + + struct HelicityFilter + { + int ntry = 0; + std::vector good = std::vector( ncomb, 0 ); + std::vector base; // the rows of the spectator sum (see density_kernel) + bool hasBase = false; + }; + + // One filter per flavor and helicity structure (the open legs and their + // combinations decide which rows contribute, and are part of the key) + std::map, HelicityFilter> g_filters; + + // A growable buffer with the alignment the SIMD kernels need + template + struct AlignedBuffer + { + T* data = nullptr; + size_t capacity = 0; + T* get( const size_t n ) + { + if( n > capacity ) + { + release(); + data = new( std::align_val_t( mgOnGpu::cppAlign ) ) T[n](); + capacity = n; + } + return data; + } + void release() + { + if( data ) ::operator delete[]( data, std::align_val_t( mgOnGpu::cppAlign ) ); + data = nullptr; + capacity = 0; + } + ~AlignedBuffer() { release(); } + }; + + // Everything a call needs, kept from one call to the next: MadSpin evaluates + // one point per call, and allocating all of it again every time costs more + // than the jamps of a small process. Used under g_mutex. + struct Scratch + { + AlignedBuffer momenta; + AlignedBuffer gs; + AlignedBuffer couplings; + AlignedBuffer flavors; + AlignedBuffer numerators; + AlignedBuffer denominators; + std::vector jamps; + std::vector rows; + std::vector lj, mj, inter; + std::vector events; + std::vector key, pos0; + }; + Scratch g_scratch; + std::mutex g_mutex; + + double g_beampol[2] = { 0., 0. }; + + // The color matrix normalised by its denominators, in double precision, + // full (not triangular): the two jamp vectors contracted are different. + struct NormalizedColorMatrix + { + constexpr NormalizedColorMatrix() + : value() + { + for( int a = 0; a < ncolor; a++ ) + for( int b = 0; b < ncolor; b++ ) + value[a][b] = double( ColorMatrixData::colorMatrix[a][b] ) / double( ColorMatrixData::colorDenom[a] ); + } + double value[ncolor][ncolor]; + }; + constexpr NormalizedColorMatrix cf{}; + + // One SIMD lane of a jamp component (the value itself without SIMD) + template + inline double lane( const V& v, const int i ) + { + if constexpr( std::is_arithmetic_v ) + { + (void)i; + return v; + } + else + return v[i]; + } + + // The crossing an extended flavor id carries (ProcessTables::use_crossing): + // the row cross of the crossing table in the input-slot view, input slot k + // (the caller's leg k) being fed to base slot pinv[k], with the side flip + // sgn[k]. The identity for cross 0 (and without crossing). + // + // The caller gives the momenta, the open legs and their helicities in its + // own leg order and the helicities as physical ones, as the Fortran + // GET_DENSITY_IDX takes them (its APPLY_CROSSING_TABLE view). calculate_jamps + // gathers the momenta itself, from the flavor id, but evaluates the base + // helicity row of the base slot s with the opposite helicity where the leg + // changes side (the (-nsf, -h) call of a crossed external, see + // _crossing_external_block): the physical helicity h of input slot k is + // therefore row[pinv[k]] = sgn[k] * h. The spectator sum runs over every row, + // which a side flip only reorders. + struct CrossView + { + int cross = 0; + int pinv[npar]; + int sgn[npar]; + explicit CrossView( const int xcross ) + : cross( xcross ) + { + if constexpr( use_crossing ) + { + ProcessTables::cross_pinv( xcross, pinv, sgn ); + } + else + { + for( int k = 0; k < npar; k++ ) + { + pinv[k] = k; + sgn[k] = 1; + } + } + } + // the physical helicity of input slot k in the base helicity row hel + inline int physical( const short* hel, const int k ) const { return sgn[k] * hel[pinv[k]]; } + }; + + // Beam-polarisation weight of helicity row hel, as the Fortran density: the + // helicities of the incoming legs of the process the flavor id names (a + // crossing may have moved them into other base slots) + inline double beam_polarisation_factor( const short* hel, const CrossView& xv ) + { + double factor = 1.; + if( npari != 2 ) return factor; // a 1 -> N process: leg 1 is a resonance, not a beam + for( int jj = 0; jj < npari; jj++ ) + { + const double pol = g_beampol[jj]; + if( std::abs( pol ) <= 1. ) continue; + factor *= ( xv.physical( hel, jj ) == ( pol > 0 ? 1 : -1 ) ) ? std::abs( pol ) : 2. - std::abs( pol ); + } + return factor; + } + + // What the density and |M|^2 of an extended flavor id are multiplied by, as + // the Fortran GET_INTER_RESCALE/SMATRIX and sigmaKin: 1/iden times the + // identical-particle factor of the actual flavors (broken_symmetry_factor) + // for the base process, and the crossed denominator (the initial-state + // spin*color average of the crossed process times its identical-final-state + // factor) for a crossing; 0 for a row out of the table. + inline double normalisation( const unsigned int flavorId ) + { + const int xcross = (int)( flavorId / (unsigned int)nmaxflavor ); + const int flav = (int)( flavorId % (unsigned int)nmaxflavor ); + if( xcross == 0 ) return broken_symmetry_factor( flav ) / double( helcolDenominators[0] ); + if( xcross >= ncross ) return 0.; + const double denominator = double( ProcessTables::xspincol_tab[xcross] ) * double( ident_cross( xcross, flav ) ); + return denominator > 0. ? 1. / denominator : 0.; + } + + // Adds the density of nevt events (whole pages of one flavor each) to inter[nevt][ninter] + void + density_kernel( const fptype_momenta* allmomenta, + const fptype* allcouplings, + const unsigned int* allflavors, + const int nevt, + const int nChanging, + const int* pos0, // 0-based + const int nComb, + const int* allowHel, + const CrossView& xv, + HelicityFilter& filter, + Scratch& s, + cplx* inter ) + { + const int ninter = nComb * ( nComb + 1 ) / 2; + const short* hels = getHelicities(); + if( !filter.hasBase ) + { + // The spectator sum runs over the rows whose open legs carry allowHel[0] + for( int ihel = 0; ihel < ncomb; ihel++ ) + { + bool match = true; + for( int n = 0; n < nChanging && match; n++ ) + match = hels[ihel * npar + pos0[n]] == allowHel[n]; + if( match ) filter.base.push_back( ihel ); + } + filter.hasBase = true; + } + s.rows.resize( (size_t)nComb * npar ); + s.jamps.resize( (size_t)nComb * nParity * njampso ); + s.lj.resize( (size_t)nComb * njampso ); + s.mj.resize( (size_t)nComb * njampso ); + fptype_amp* numerators = s.numerators.get( (size_t)nevt * ndiagrams ); + fptype_amp* denominators = s.denominators.get( nevt ); + for( int ievt00 = 0; ievt00 < nevt; ievt00 += npage ) + { + const bool scanning = filter.ntry < nscan; + for( const int ihel : filter.base ) + { + if( !scanning && !filter.good[ihel] ) continue; + const double polfact = beam_polarisation_factor( hels + ihel * npar, xv ); + for( int icomb = 0; icomb < nComb; icomb++ ) + { + short* row = &s.rows[(size_t)icomb * npar]; + for( int ipar = 0; ipar < npar; ipar++ ) row[ipar] = hels[ihel * npar + ipar]; + for( int n = 0; n < nChanging; n++ ) row[pos0[n]] = allowHel[icomb * nChanging + n]; + cxtype_amp_sv* jamp_sv = &s.jamps[(size_t)icomb * nParity * njampso]; + for( int i = 0; i < nParity * njampso; i++ ) jamp_sv[i] = cxzero_sv(); + calculate_jamps( row, allmomenta, allcouplings, allflavors, jamp_sv, false, numerators, denominators, nullptr, ievt00 ); + } + bool nonzero = false; + for( int iParity = 0; iParity < nParity; iParity++ ) + { + for( int ieppV = 0; ieppV < neppV; ieppV++ ) + { + for( int icomb = 0; icomb < nComb; icomb++ ) + { + const cxtype_amp_sv* jamp_sv = &s.jamps[( (size_t)icomb * nParity + iParity ) * njampso]; + for( int ijamp = 0; ijamp < njampso; ijamp++ ) + { + const cplx z( lane( cxreal( jamp_sv[ijamp] ), ieppV ), lane( cximag( jamp_sv[ijamp] ), ieppV ) ); + nonzero = nonzero || z != 0.; + s.lj[(size_t)icomb * njampso + ijamp] = z; + } + // C JAMP_I, for every amplitude order + for( int iao = 0; iao < nampso; iao++ ) + { + const cplx* j = &s.lj[(size_t)icomb * njampso + iao * ncolor]; + cplx* m = &s.mj[(size_t)icomb * njampso + iao * ncolor]; + for( int a = 0; a < ncolor; a++ ) + { + cplx sum = 0.; + for( int b = 0; b < ncolor; b++ ) sum += cf.value[a][b] * j[b]; + m[a] = sum; + } + } + } + // rho(I,J) = JAMP_J^dagger C JAMP_I, summed over the ordered pairs of + // amplitude orders the process keeps (see color_sum_cpu_splitorders) + cplx* out = inter + (size_t)( ievt00 + iParity * neppV + ieppV ) * ninter; + int sol = 0; + for( int I = 0; I < nComb; I++ ) + { + for( int J = I; J < nComb; J++ ) + { + cplx z = 0.; + for( int iao = 0; iao < nampso; iao++ ) + { + for( int jao = 0; jao < nampso; jao++ ) + { + if( !ColorMatrixData::chosenSqso[ColorMatrixData::sqSoIndex[iao][jao]] ) continue; + const cplx* jj = &s.lj[(size_t)J * njampso + jao * ncolor]; + const cplx* mi = &s.mj[(size_t)I * njampso + iao * ncolor]; + for( int a = 0; a < ncolor; a++ ) z += std::conj( jj[a] ) * mi[a]; + } + } + out[sol++] += polfact * z; + } + } + } + } + if( nonzero ) filter.good[ihel] = 1; + } + filter.ntry += npage; + } + } + + // One momentum given in the rest frame of b, the momentum of that frame in + // the lab, taken to the lab: HELAS boostx, with the arithmetic of MadSpin's + // lhe_parser.Event.boost (MadSpin decays are generated at rest). + inline void boost_from_rest_frame( double p[4], const double b[4] ) + { + const double pnorm = b[1] * b[1] + b[2] * b[2] + b[3] * b[3]; + if( pnorm == 0. ) return; // b at rest: nothing to do + const double mass = std::sqrt( std::max( b[0] * b[0] - pnorm, 0. ) ); + const double invMass = 1. / mass; + const double k = ( b[0] - mass ) / pnorm; + const double s3 = p[1] * b[1] + p[2] * b[2] + p[3] * b[3]; + const double lf = ( p[0] + k * s3 ) * invMass; + p[0] = ( p[0] * b[0] + s3 ) * invMass; + p[1] += b[1] * lf; + p[2] += b[2] * lf; + p[3] += b[3] * lf; + } + + // The density (or, with no open leg, the helicity sum) of count events, each + // normalised as the Fortran density and SMATRIX (normalisation, above): + // out[count][ninter]. flavor holds extended ids (cross * nmaxflavor + flavor) + // with crossing, pos and allowHel follow the legs of the process the id + // names (see CrossView). Groups the events by flavor id, since all the events + // of a page must share it, padding each group to whole pages with copies of + // its first event (zero momenta would give nan, see umami.cc). boosts, if not + // null, holds per event the lab momentum of the frame its momenta are given + // in (boost_from_rest_frame). + int + evaluate( const size_t count, + const double* momenta, + const double* boosts, + const double* alpha_s, + const unsigned int* flavor, + const int nChanging, + const int* pos, + const int nComb, + const int* allowHel, + cplx* out ) + { + if( nComb < 1 || nChanging < 0 || ( nChanging == 0 && nComb != 1 ) ) return 1; + for( int n = 0; n < nChanging; n++ ) + if( pos[n] < 1 || pos[n] > npar ) return 2; + const unsigned int nflavorId = (unsigned int)( ncross * nmaxflavor ); + for( size_t i = 0; i < count; i++ ) + if( flavor[i] >= nflavorId ) return 3; + std::lock_guard lock( g_mutex ); + Scratch& s = g_scratch; + const int ninter = nComb * ( nComb + 1 ) / 2; + constexpr size_t neppM = MemoryAccessMomentaBase::neppM; + std::vector ids( flavor, flavor + count ); + std::sort( ids.begin(), ids.end() ); + ids.erase( std::unique( ids.begin(), ids.end() ), ids.end() ); + std::vector allowHelBase( (size_t)nComb * nChanging ); + for( const unsigned int flavorId : ids ) + { + s.events.clear(); + for( size_t i = 0; i < count; i++ ) + if( flavor[i] == flavorId ) s.events.push_back( i ); + const size_t nreal = s.events.size(); + const double norm = normalisation( flavorId ); + if( norm == 0. ) + { + // a crossing out of the table (or one that cannot be applied): no + // process, every entry 0, as the Fortran GET_DENSITY_IDX + for( size_t k = 0; k < nreal; k++ ) + for( int sol = 0; sol < ninter; sol++ ) + out[s.events[k] * ninter + sol] = 0.; + continue; + } + // the open legs and their helicities in the base slots and the base + // row convention (CrossView) + const CrossView xv( (int)( flavorId / (unsigned int)nmaxflavor ) ); + s.pos0.resize( nChanging ); + for( int n = 0; n < nChanging; n++ ) s.pos0[n] = xv.pinv[pos[n] - 1]; + for( int icomb = 0; icomb < nComb; icomb++ ) + for( int n = 0; n < nChanging; n++ ) + allowHelBase[(size_t)icomb * nChanging + n] = xv.sgn[pos[n] - 1] * allowHel[(size_t)icomb * nChanging + n]; + const size_t nevt = ( nreal + npage - 1 ) / npage * npage; + fptype_momenta* allmomenta = s.momenta.get( nevt * npar * 4 ); + fptype* gs = s.gs.get( nevt ); + fptype* couplings = s.couplings.get( nevt * Parameters_dependentCouplings::ndcoup * 2 ); + unsigned int* flavors = s.flavors.get( nevt ); + for( size_t k = 0; k < nevt; k++ ) + { + const size_t i = s.events[k < nreal ? k : 0]; + const size_t ipage = k / neppM, ivec = k % neppM; + for( int ipar = 0; ipar < npar; ipar++ ) + { + double p[4] = { momenta[( i * npar + ipar ) * 4], momenta[( i * npar + ipar ) * 4 + 1], + momenta[( i * npar + ipar ) * 4 + 2], momenta[( i * npar + ipar ) * 4 + 3] }; + if( boosts ) boost_from_rest_frame( p, boosts + i * 4 ); + for( int imom = 0; imom < 4; imom++ ) + allmomenta[ipage * npar * 4 * neppM + ipar * 4 * neppM + imom * neppM + ivec] = p[imom]; + } + gs[k] = std::sqrt( 4. * M_PI * alpha_s[i] ); + // the extended id: calculate_jamps gathers the momenta of a crossing + flavors[k] = flavorId; + } + computeDependentCouplings( gs, couplings, nevt ); + s.key.assign( { (int)flavorId, nChanging } ); + s.key.insert( s.key.end(), s.pos0.begin(), s.pos0.end() ); + s.key.push_back( nComb ); + s.key.insert( s.key.end(), allowHelBase.begin(), allowHelBase.end() ); + s.inter.assign( nevt * ninter, 0. ); + density_kernel( allmomenta, couplings, flavors, (int)nevt, nChanging, s.pos0.data(), nComb, allowHelBase.data(), xv, g_filters[s.key], s, s.inter.data() ); + for( size_t k = 0; k < nreal; k++ ) + for( int sol = 0; sol < ninter; sol++ ) + out[s.events[k] * ninter + sol] = s.inter[k * ninter + sol] * norm; + } + return 0; + } + } +} + +extern "C" +{ + int madmatrix_density( size_t count, + const double* momenta, + const double* alpha_s, + const unsigned int* flavor, + int n_changing, + const int* pos, + int n_comb, + const int* allow_hel, + double* inter ) + { + if( n_changing < 1 ) return 1; + return madmatrix::evaluate( count, momenta, nullptr, alpha_s, flavor, n_changing, pos, n_comb, allow_hel, + reinterpret_cast*>( inter ) ); + } + + int madmatrix_density_boosted( size_t count, + const double* momenta, + const double* boosts, + const double* alpha_s, + const unsigned int* flavor, + int n_changing, + const int* pos, + int n_comb, + const int* allow_hel, + double* inter ) + { + if( n_changing < 1 || !boosts ) return 1; + return madmatrix::evaluate( count, momenta, boosts, alpha_s, flavor, n_changing, pos, n_comb, allow_hel, + reinterpret_cast*>( inter ) ); + } + + int madmatrix_matrix_element( size_t count, + const double* momenta, + const double* alpha_s, + const unsigned int* flavor, + double* me ) + { + std::vector> sum( count ); + const int status = madmatrix::evaluate( count, momenta, nullptr, alpha_s, flavor, 0, nullptr, 1, nullptr, sum.data() ); + if( status != 0 ) return status; + for( size_t i = 0; i < count; i++ ) + me[i] = sum[i].real(); + return 0; + } + + void madmatrix_set_beampol( double pol1, double pol2 ) + { + madmatrix::g_beampol[0] = pol1; + madmatrix::g_beampol[1] = pol2; + } + + void madmatrix_process_info( int* info ) + { + info[0] = ProcessData::npar; + info[1] = ProcessData::npari; + info[2] = ProcessData::ncomb; + info[3] = ProcessData::nmaxflavor; + info[4] = ProcessData::ncolor; + info[5] = ProcessData::helcolDenominators[0]; + info[6] = madmatrix::ncross; + } + + double madmatrix_iden( unsigned int flavor_id ) + { + const int xcross = (int)( flavor_id / (unsigned int)ProcessData::nmaxflavor ); + if( xcross == 0 ) return ProcessData::helcolDenominators[0]; + if( xcross >= madmatrix::ncross ) return 0.; + return double( madmatrix::ProcessTables::xspincol_tab[xcross] ) * + double( madmatrix::ident_cross( xcross, (int)( flavor_id % (unsigned int)ProcessData::nmaxflavor ) ) ); + } +} diff --git a/madgraph/iolibs/template_files/madmatrix/backend/common/Density.h b/madgraph/iolibs/template_files/madmatrix/backend/common/Density.h new file mode 100644 index 0000000000..ffb23eea1d --- /dev/null +++ b/madgraph/iolibs/template_files/madmatrix/backend/common/Density.h @@ -0,0 +1,102 @@ +// Copyright (C) 2020-2026 CERN and UCLouvain. +// Licensed under the GNU Lesser General Public License (version 3 or later). +// Integrated with the MadGraph7 project in Feb 2026. +// +// Spin-density matrices of the decaying particles of a process, the quantity +// MadSpin contracts production and decay with. C++ backends (scalar, simd) only. +// +// The library of each subprocess exports these next to the UMAMI functions, and +// umami_initialize (param card, couplings, helicity table) must have been called +// once before any of them. Momenta are event major: momenta[count][npar][4], +// (E, px, py, pz) per particle, in the leg order of the process. alpha_s and +// flavor are per event, flavor being the index of the flavor combination +// (ProcessData::flavorPDGs) of that event. +// +// With crossing (an output folding crossed processes, --use_crossing) flavor is +// the extended id cross * nmaxflavor + flavor, cross a row of the crossing table +// (ProcessTables.h; 0 the process itself), and an event of a crossed process +// gives its momenta, pos and allow_hel in the leg order of that process and its +// helicities as physical ones: the Fortran GET_DENSITY_IDX convention, the +// crossed process's own helicities. +// +// The conventions are those of the Fortran standalone GET_DENSITY, so that +// MadSpin reads the two the same way: +// - pos[n_changing] are the (1-based) legs whose helicity is left open, and +// allow_hel[n_comb][n_changing] the n_comb helicity combinations they take; +// - the other legs are summed over the rows of the helicity table whose open +// legs carry the first combination, allow_hel[0]; +// - inter[count][n_comb*(n_comb+1)/2] (complex, as re/im pairs) holds +// rho(I,J) = sum_spectators JAMP_J^dagger C JAMP_I / iden for I <= J, I +// outer, iden being the spin/color average and identical-particle factor +// of the process (helcolDenominators, corrected for the actual flavors as +// SMATRIX does with BROKEN_SYM; for a crossing the crossed process's own, +// madmatrix_iden), and the beam polarisation set by madmatrix_set_beampol +// folded into the spectator sum. +// A helicity row whose amplitudes all vanish over the first evaluations of a +// given (flavor, open legs) is then skipped, exactly as the Fortran SMATRIX +// filters its helicities. + +#ifndef MADMATRIX_DENSITY_H +#define MADMATRIX_DENSITY_H 1 + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + // Returns 0, or non-zero (nothing written) for an invalid request. + int madmatrix_density( size_t count, + const double* momenta, + const double* alpha_s, + const unsigned int* flavor, + int n_changing, + const int* pos, + int n_comb, + const int* allow_hel, + double* inter ); + + // madmatrix_density for momenta given in the rest frame of a boost: + // boosts[count][4] is, per event, the lab momentum of the frame its momenta are + // in (a MadSpin decay is generated at rest, and boosted along its parent). + int madmatrix_density_boosted( size_t count, + const double* momenta, + const double* boosts, + const double* alpha_s, + const unsigned int* flavor, + int n_changing, + const int* pos, + int n_comb, + const int* allow_hel, + double* inter ); + + // |M|^2 summed over helicities and colors, averaged as the Fortran SMATRIX: + // divided by iden, times the identical-particle factor of the flavor and with + // the beam polarisation. Returns 0, or non-zero for an invalid request. + int madmatrix_matrix_element( size_t count, + const double* momenta, + const double* alpha_s, + const unsigned int* flavor, + double* me ); + + // Beam polarisation of the two incoming legs, constant over a run. Same + // convention as madevent and the Fortran density: 1 (or anything with + // |pol| <= 1) is unpolarised, |pol| grows to 2 for a fully polarised beam and + // its sign gives the favoured helicity. Ignored for a 1 -> N process. + void madmatrix_set_beampol( double pol1, double pol2 ); + + // Process constants: npar, npari, ncomb, nmaxflavor, ncolor, iden, ncross (in + // this order; ncross the rows of the crossing table, 1 without crossing). + void madmatrix_process_info( int* info ); + + // The spin/color average and identical-particle factor of the process the + // (extended) flavor id names: iden for the process itself, the crossed + // denominator for a crossing, 0 for a row out of the table. + double madmatrix_iden( unsigned int flavor_id ); + +#ifdef __cplusplus +} +#endif + +#endif // MADMATRIX_DENSITY_H diff --git a/madgraph/iolibs/template_files/madmatrix/backend/cpu/JampsPreamble.h b/madgraph/iolibs/template_files/madmatrix/backend/cpu/JampsPreamble.h new file mode 100644 index 0000000000..f7607d9601 --- /dev/null +++ b/madgraph/iolibs/template_files/madmatrix/backend/cpu/JampsPreamble.h @@ -0,0 +1,61 @@ +// Copyright (C) 2020-2026 CERN and UCLouvain. +// Licensed under the GNU Lesser General Public License (version 3 or later). +//========================================================================== +// The per-page setup the generated diagram calls read: the couplings of the +// page (dependent and fixed), its momenta, the flavor coupling views and the +// scalar iflavor (with crossing, also the page's extended id iflavor_ext). A +// fragment, not a header: #include'd inside the iParity loop of calculate_jamps +// and, for --hel_recycling, of calculate_all_jamps, which evaluate the same calls. +//========================================================================== +constexpr size_t nxcoup = ndcoup + nIPC; // both dependent and independent couplings +// nxcoup can be 0 (no alpha_s-dependent coupling, and only flavor couplings, e.g. +// u u~ > u u~ QED^2==4 with flavor grouping): a zero-sized array is only a compiler +// extension (and refused in GPU device code), so size them at least 1, as cIPD/cIPC +const fptype* allCOUPs[nxcoup > 0 ? nxcoup : 1]; +for( size_t idcoup = 0; idcoup < ndcoup; idcoup++ ) + allCOUPs[idcoup] = CD_ACCESS::idcoupAccessBufferConst( allcouplings, idcoup ); // dependent couplings, vary event-by-event +for( size_t iicoup = 0; iicoup < nIPC; iicoup++ ) + allCOUPs[ndcoup + iicoup] = CI_ACCESS::iicoupAccessBufferConst( cIPC, iicoup ); // independent couplings, fixed for all events +// C++ kernels take input/output buffers with momenta/MEs for one specific event (the first in the current event page) +const fptype_momenta* momenta = M_ACCESS::ieventAccessRecordConst( allmomenta, ievt0 ); +const fptype* COUPs[nxcoup > 0 ? nxcoup : 1]; +for( size_t idcoup = 0; idcoup < ndcoup; idcoup++ ) + COUPs[idcoup] = CD_ACCESS::ieventAccessRecordConst( allCOUPs[idcoup], ievt0 ); // dependent couplings, vary event-by-event +for( size_t iicoup = 0; iicoup < nIPC; iicoup++ ) + COUPs[ndcoup + iicoup] = allCOUPs[ndcoup + iicoup]; // independent couplings, fixed for all events +// Create an array of views over the Flavor Couplings +FLV_COUPLING_ARRAY flvCOUPs{ cIPF_partner1, cIPF_partner2, cIPF_value }; + +// Dependent (event-by-event, running-alphas) flavor couplings (Step 3): the per-flavor +// values are NOT baked in (they run per event). Gather the current values of the +// underlying dependent couplings for this event page into an AOSOA buffer dpf_value +// (one nx2*neppC SIMD record per (coupling,flavor) slot, matching CD_ACCESS), then build +// an ordinary value-based view over it. The flavor index is constant across a SIMD lane +// (guaranteed by the phase-space integrator), so each lane gets its own running value +// while sharing the same flavor selection. This is the direct analogue of Fortran's +// FLV_xx%VAL(k)%P => GC_yyy(J). The vertex routines are instantiated with CDPF_ACCESS +// (= CD_ACCESS here) so get_coupling_def reads dpf_value with the right per-flavor stride. +constexpr int ndpfbuf = ( nDPF > 0 ? nDPF * nMF * CDPF_ACCESS::flv_stride : 1 ); +// cppAlign is only defined for SIMD +alignas( mgOnGpu::cppAlign ) fptype dpf_value[ndpfbuf]{}; +for( int idpf = 0; idpf < nDPF; idpf++ ) + for( int imf = 0; imf < nMF; imf++ ) + { + const int idc = cDPF_idcoup[idpf * nMF + imf]; + if( idc >= 0 ) + CDPF_ACCESS::kernelAccess( dpf_value + ( idpf * nMF + imf ) * CDPF_ACCESS::flv_stride ) = + CD_ACCESS::kernelAccessConst( COUPs[idc] ); + } +FLV_COUPLING_ARRAY flvCOUPs_dep{ cDPF_partner1, cDPF_partner2, dpf_value }; + +// Scalar iflavor for the current event page (constant across the SIMD vector). +// With crossing the per-event id is cross*nmaxflavor + flavor: only the +// flavor is constant across the page, and it is what indexes cFlavors and +// the flavor masks; the crossing is applied per lane by EvaluateDiagrams.inc. +// Without crossing an id past cFlavors names no flavor (a NaN |M|^2 in +// sigmaKin): reduced as well, it is the flavor umami grouped its page by, so +// the valid lanes sharing the page keep theirs and nothing reads out of bounds. +const unsigned int* iflavor_rec = F_ACCESS::ieventAccessRecordConst( iflavorVec, ievt0 ); +const uint_sv iflavor_sv = F_ACCESS::kernelAccessConst( iflavor_rec ); +const unsigned int iflavor_ext = reinterpret_cast( &iflavor_sv )[0]; +const unsigned int iflavor = iflavor_ext % (unsigned int)nmaxflavor; diff --git a/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.cc b/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.cc index 9e3fe4acbf..c63a6a1906 100644 --- a/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.cc +++ b/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.cc @@ -30,9 +30,12 @@ #include "ColorData.h" // for shouldUseBlas/mgOnGpu::nchannels/channel2iconfig/icolamp/nconfigSDE #include "color_sum.h" // for color_sum_cpu/color_sum_cpu_blas +#include #include +#include #include // for std::abort (crossing guard in EvaluateDiagrams.inc) #include +#include #include #include #include @@ -61,7 +64,8 @@ namespace madmatrix // by CPPProcess's constructor/initProc via the setters below. static short cHel[ncomb][npar]; static short cFlavors[nmaxflavor][npar]; - static int cNGoodHel; + static int cNGoodHel; // the rows the helicity loop runs + static int cNGoodHelRows; // the good rows, before the C-parity reduction static int cGoodHel[ncomb]; static fptype cIPD[nIPD > 0 ? nIPD : 1]; static fptype cIPC[nIPC > 0 ? nIPC * 2 : 1]; @@ -105,6 +109,11 @@ namespace madmatrix static bool cCsymBad; // latched when ANY pair mismatched at a scan point (any crossing) static bool cCsymOk; // the de-duplication is on + // The crossing-table rows with no phase space at their own masses + // (sigmaKin_setNoPhaseSpace): never evaluated on shell, so they need no + // good helicity, and the scan skips them as the fortran warm-up does. + static bool cNoPhaseSpace[ncross]; + // Initial-state spin*color average of the process crossing-table row // `cross` crosses into (the product of the per-leg spin*color of the base // legs it puts in the initial state, tabulated by the exporter). 0 for a @@ -194,6 +203,76 @@ namespace madmatrix return code + 1; } + // Whether the total of a running |M|^2 (or jamp2) sum can drive an event-by-event + // choice: a zero, infinite or NaN total chooses no helicity/colour (0, reported + // as -1). The test reads the exponent bits of a volatile copy because the build + // uses -ffast-math: finite-math-only folds std::isfinite and NaN comparisons + // away, after which a NaN cumulative ratio passes `rnd < ratio` and the event + // reports an arbitrary helicity. + template + inline bool + selectable_total( const T total ) + { + static_assert( sizeof( T ) == sizeof( uint64_t ) || sizeof( T ) == sizeof( uint32_t ) ); + volatile T vtotal = total; + const T t = vtotal; + if constexpr( sizeof( T ) == sizeof( uint64_t ) ) + { + uint64_t bits; + memcpy( &bits, &t, sizeof( bits ) ); + if( ( bits & 0x7ff0000000000000ULL ) == 0x7ff0000000000000ULL ) return false; + } + else + { + uint32_t bits; + memcpy( &bits, &t, sizeof( bits ) ); + if( ( bits & 0x7f800000U ) == 0x7f800000U ) return false; + } + return t != 0; + } + + // A quiet NaN made from its bits through a volatile: under -ffast-math + // (finite-math-only) a NaN constant counts as poison, and the compiler may + // drop a store of std::numeric_limits::quiet_NaN() altogether. + template + inline T + quiet_nan_bits() + { + static_assert( sizeof( T ) == sizeof( uint64_t ) || sizeof( T ) == sizeof( uint32_t ) ); + T t; + if constexpr( sizeof( T ) == sizeof( uint64_t ) ) + { + volatile uint64_t vbits = 0x7ff8000000000000ULL; + const uint64_t bits = vbits; + memcpy( &t, &bits, sizeof( t ) ); + } + else + { + volatile uint32_t vbits = 0x7fc00000U; + const uint32_t bits = vbits; + memcpy( &t, &bits, sizeof( t ) ); + } + return t; + } + + // Whether an event's flavor id names a flavor of this process: an index of + // cFlavors or, with crossing, an extended id whose crossing-table row exists. + // An id past the table is a process that does not exist: |M|^2 = 0, as in the + // Fortran and standalone_cpp crossing code, and, as for any zero |M|^2, no + // helicity, colour or diagram choice (its numerators and jamp2 are those of + // another crossing). Without crossing, any other id is evaluated with the + // reduced flavor of its page (see JampsPreamble.h), so that nothing is read + // out of bounds, and then gets a NaN |M|^2 and no choice either, as on GPU + // (normalise_output). + inline bool + valid_flavor_id( const unsigned int flavor_id ) + { + if constexpr( use_crossing ) + return spincol_cross( (int)( flavor_id / (unsigned int)nmaxflavor ) ) != 0; + else + return flavor_id < (unsigned int)nmaxflavor; + } + // Pick the helicity row to report for the ighel-th good helicity of a lane. // The row stands for a C-parity PAIR counted twice when the de-duplication // is on, so either member must come out at equal rate or the event-level @@ -214,7 +293,11 @@ namespace madmatrix return ( ( rnd - lo ) < (fptype)0.5 * w ) ? ihel : cFlip[ihel]; } - // The reported (Fortran-indexed, [1,ncomb]) helicity of one lane + // The reported (Fortran-indexed, [1,ncomb]) helicity of one lane. + // The CDF runs over the one good-helicity list in base-row order for every + // crossing, so a crossed lane gets the same helicity distribution as the + // --use_crossing=False output but, for a given rndhel, not necessarily the + // same helicity (that output orders the rows by its own legs). inline int selected_helicity( int ighel, unsigned int flavor_id, fptype rnd, fptype lo, fptype hi ) { @@ -269,6 +352,11 @@ namespace madmatrix cBWCUTOFF = bwcutoff; } + const short* getHelicities() + { + return &cHel[0][0]; + } + //-------------------------------------------------------------------------- // SCALAR channelId for the whole SIMD neppV2 event page (C++), i.e. one or two neppV event page(s) @@ -333,11 +421,98 @@ namespace madmatrix //-------------------------------------------------------------------------- + //-------------------------------------------------------------------------- + + // The mass an external leg marked off-shell ('*': MadSpin's decaying particles) + // is evaluated with: the invariant mass of its momentum, as in the Fortran + // standalone output (SQRT(P(0,i)**2-...)), so that the wavefunction is the one + // of the momentum the leg actually has. The pole mass comes first only for the + // code generator, which maps it to its parameter. + template + inline fptype_amp + offshell_mass( const fptype_amp poleMass, const fptype_momenta* momenta, const int ipar ) + { + (void)poleMass; + const double e = M_ACCESS::kernelAccessIp4IparConst( momenta, 0, ipar ); + const double px = M_ACCESS::kernelAccessIp4IparConst( momenta, 1, ipar ); + const double py = M_ACCESS::kernelAccessIp4IparConst( momenta, 2, ipar ); + const double pz = M_ACCESS::kernelAccessIp4IparConst( momenta, 3, ipar ); + return static_cast( std::sqrt( std::max( e * e - px * px - py * py - pz * pz, 0. ) ) ); + } + + //-------------------------------------------------------------------------- + // Accumulate a multichannel numerator contribution in place. In C++ each // event page is processed serially within the helicity loop, so a plain // sum suffices (CUDA needs atomicAdd instead: see backend/gpu/SigmaKin.cc). #define NUM_ATOMIC_ADD( DST, VAL ) ( DST ) += ( VAL ) + // --hel_recycling warm-up probe (madmatrix/output.py). The recycled build drops + // the amplitudes that vanish at a given helicity, and it has to be told which + // those are. A build with -DMG_HR_PROBE has every amplitude of the + // generated diagrams record |amp|^2 (MG_HR_PROBE_AMP), and an amplitude is alive + // at a helicity once it is above zeroamp_tol of the largest amplitude of that + // helicity at the same point: a cancellation zero is a rounding residual, never + // an exact 0 (the same rule as the fortran hel_zeroamp detection). The answer is + // the union over the points and extended flavor ids (every crossing) that + // getGoodHel samples, so an amplitude is dropped at a base row only if it + // vanishes for every crossing served; sigmaKin_setGoodHel prints it under + // MG_DUMP_GOODHEL. Scalar double builds only. + // With a massive external leg (hel_massive) only an exact 0 is dropped, as for + // the rows (see limhel): the points sit at one sqrt(s), where an amplitude + // suppressed by powers of m/E against the others of its row can look like a + // residual although near threshold it is not. + // Under --axial_gauge (MG_HR_EXACT_ZERO set by the exporter) only an exact 0 is + // dropped too. The zeros of the reference-vector choice are exact only for + // exactly lightlike momenta: near the beam axis the 1/theta growth of the axial + // polarisation turns their rounding residual into a needed part of the gauge + // cancellation (dropped, d d~ > g g is 1.5e-5 off at 1e-6 rad), so they + // stay, as in the fortran standalone warm-up (AMP.ne.0). +#ifdef MG_HR_PROBE +#if defined MGONGPU_CPPSIMD or !defined MGONGPU_FPTYPE_DOUBLE +#error "MG_HR_PROBE needs BACKEND=scalar FPTYPE=d" +#endif + static std::vector hrProbeAbs2; // |amp|^2 at the current point, by amplitude number + static std::vector> hrProbeSeen; // [ihel][namp]: evaluated at this helicity + static std::vector> hrProbeAlive; // [ihel][namp]: not a cancellation zero + static std::vector hrProbeOffShell; // crossings scanned on points off shell for them + inline void + hr_probe_record( const int namp, const cxtype_amp_sv& amp ) + { + if( (int)hrProbeAbs2.size() <= namp ) hrProbeAbs2.resize( namp + 1, -1. ); + hrProbeAbs2[namp] = cxabs2( amp ); + } + inline void + hr_probe_close( const int ihel ) + { + static const double zeroamp_tol = hel_massive || getenv( "MG_HR_EXACT_ZERO" ) != nullptr ? 0. : 1e-12; + if( hrProbeSeen.empty() ) + { + hrProbeSeen.resize( ncomb ); + hrProbeAlive.resize( ncomb ); + } + double ampmax = 0; + for( double a : hrProbeAbs2 ) ampmax = std::max( ampmax, a ); + std::vector& seen = hrProbeSeen[ihel]; + std::vector& alive = hrProbeAlive[ihel]; + if( seen.size() < hrProbeAbs2.size() ) + { + seen.resize( hrProbeAbs2.size(), 0 ); + alive.resize( hrProbeAbs2.size(), 0 ); + } + for( size_t namp = 0; namp < hrProbeAbs2.size(); namp++ ) + { + if( hrProbeAbs2[namp] < 0 ) continue; + seen[namp] = 1; + if( hrProbeAbs2[namp] > zeroamp_tol * zeroamp_tol * ampmax ) alive[namp] = 1; + hrProbeAbs2[namp] = -1.; + } + } +#define MG_HR_PROBE_AMP( NAMP ) hr_probe_record( NAMP, amp_sv[0] ) +#else +#define MG_HR_PROBE_AMP( NAMP ) +#endif + // Evaluate QCD partial amplitudes jamps for this given helicity from Feynman diagrams. // Also compute running sums over helicities adding jamp2, numerator, denominator // (NB: this function no longer handles matrix elements as the color sum has now been @@ -346,7 +521,7 @@ namespace madmatrix // channelId because it is GUARANTEED that all events in a SIMD vector have the same // channelId #898. void - calculate_jamps( int ihel, + calculate_jamps( const short* hel, // input: helicity of each leg, hel[npar] (a row of cHel, or not: see Density.cc) const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] const unsigned int* iflavorVec, // input: indices of the flavor combinations @@ -367,6 +542,16 @@ namespace madmatrix using NUM_ACCESS = HostAccessNumerators; // non-trivial access: buffer includes all events mgDebug( 0, __FUNCTION__ ); + // EvaluateDiagrams.inc reads the helicities as cHel[ihel][ipar]. Shadow the + // helicity table with the one row this call evaluates, so that the row need + // not be one of cHel at all: the density matrix builds combinations that a + // polarised process leaves out of its table (sigmaKin_density, Density.cc). +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wshadow" + const short( *cHel )[npar] = reinterpret_cast( hel ); +#pragma GCC diagnostic pop + constexpr int ihel = 0; + // Local TEMPORARY variables for a subset of Feynman diagrams in the given C++ event // page (ipagV) [NB these variables are reused several times (and re-initialised each // time) within the same event or event page]. Create memory for both momenta and @@ -405,47 +590,8 @@ namespace madmatrix { const int ievt0 = ievt00 + iParity * neppV; - constexpr size_t nxcoup = ndcoup + nIPC; // both dependent and independent couplings - // nxcoup can be 0 (no alpha_s-dependent coupling, and only flavor couplings, e.g. - // u u~ > u u~ QED^2==4 with flavor grouping): a zero-sized array is only a compiler - // extension (and refused in GPU device code), so size them at least 1, as cIPD/cIPC - const fptype* allCOUPs[nxcoup > 0 ? nxcoup : 1]; - for( size_t idcoup = 0; idcoup < ndcoup; idcoup++ ) - allCOUPs[idcoup] = CD_ACCESS::idcoupAccessBufferConst( allcouplings, idcoup ); // dependent couplings, vary event-by-event - for( size_t iicoup = 0; iicoup < nIPC; iicoup++ ) - allCOUPs[ndcoup + iicoup] = CI_ACCESS::iicoupAccessBufferConst( cIPC, iicoup ); // independent couplings, fixed for all events - // C++ kernels take input/output buffers with momenta/MEs for one specific event (the first in the current event page) - const fptype_momenta* momenta = M_ACCESS::ieventAccessRecordConst( allmomenta, ievt0 ); - const fptype* COUPs[nxcoup > 0 ? nxcoup : 1]; - for( size_t idcoup = 0; idcoup < ndcoup; idcoup++ ) - COUPs[idcoup] = CD_ACCESS::ieventAccessRecordConst( allCOUPs[idcoup], ievt0 ); // dependent couplings, vary event-by-event - for( size_t iicoup = 0; iicoup < nIPC; iicoup++ ) - COUPs[ndcoup + iicoup] = allCOUPs[ndcoup + iicoup]; // independent couplings, fixed for all events +#include "JampsPreamble.h" // a fragment of the iParity loop body, not a header fptype_amp* numerators = NUM_ACCESS::ieventAccessRecord( allNumerators, ievt0 * ndiagrams ); - // Create an array of views over the Flavor Couplings - FLV_COUPLING_ARRAY flvCOUPs{ cIPF_partner1, cIPF_partner2, cIPF_value }; - - // Dependent (event-by-event, running-alphas) flavor couplings (Step 3): the per-flavor - // values are NOT baked in (they run per event). Gather the current values of the - // underlying dependent couplings for this event page into an AOSOA buffer dpf_value - // (one nx2*neppC SIMD record per (coupling,flavor) slot, matching CD_ACCESS), then build - // an ordinary value-based view over it. The flavor index is constant across a SIMD lane - // (guaranteed by the phase-space integrator), so each lane gets its own running value - // while sharing the same flavor selection. This is the direct analogue of Fortran's - // FLV_xx%VAL(k)%P => GC_yyy(J). The vertex routines are instantiated with CDPF_ACCESS - // (= CD_ACCESS here) so get_coupling_def reads dpf_value with the right per-flavor stride. - constexpr int ndpfbuf = ( nDPF > 0 ? nDPF * nMF * CDPF_ACCESS::flv_stride : 1 ); - // cppAlign is only defined for SIMD - alignas( mgOnGpu::cppAlign ) fptype dpf_value[ndpfbuf]{}; - for( int idpf = 0; idpf < nDPF; idpf++ ) - for( int imf = 0; imf < nMF; imf++ ) - { - const int idc = cDPF_idcoup[idpf * nMF + imf]; - if( idc >= 0 ) - CDPF_ACCESS::kernelAccess( dpf_value + ( idpf * nMF + imf ) * CDPF_ACCESS::flv_stride ) = - CD_ACCESS::kernelAccessConst( COUPs[idc] ); - } - FLV_COUPLING_ARRAY flvCOUPs_dep{ cDPF_partner1, cDPF_partner2, dpf_value }; // Reset color flows (reset jamp_sv) at the beginning of a new event or event page for( int i = 0; i < njampso; i++ ) { jamp_sv[i] = cxzero_sv(); } @@ -453,14 +599,6 @@ namespace madmatrix // Numerators for the current event page (C++); denominators are no longer // accumulated here: they are derived as the sum of numerators later. fptype_amp_sv* numerators_sv = NUM_ACCESS::kernelAccessP( numerators ); - // Scalar iflavor for the current event page (constant across the SIMD vector). - // With crossing the per-event id is cross*nmaxflavor + flavor: only the - // flavor is constant across the page, and it is what indexes cFlavors and - // the flavor masks; the crossing is applied per lane by EvaluateDiagrams.inc. - const unsigned int* iflavor_rec = F_ACCESS::ieventAccessRecordConst( iflavorVec, ievt0 ); - const uint_sv iflavor_sv = F_ACCESS::kernelAccessConst( iflavor_rec ); - const unsigned int iflavor_ext = reinterpret_cast( &iflavor_sv )[0]; - const unsigned int iflavor = use_crossing ? iflavor_ext % (unsigned int)nmaxflavor : iflavor_ext; #include "EvaluateDiagrams.inc" #include "ColorFlows.inc" // defines jampflow_sv[ncolor_flow], which is not jamp_sv on the DDM basis @@ -483,9 +621,217 @@ namespace madmatrix } #undef NUM_ATOMIC_ADD +#undef MG_HR_PROBE_AMP + + //-------------------------------------------------------------------------- + + // --hel_recycling (CPU only; on GPU blockIdx.y already parallelises over the + // helicities). A wavefunction only depends on the helicities of the external + // legs it descends from, so the helicity rows that agree on those legs can + // share it. calculate_jamps rebuilds everything once per row; the recycled + // build, generated by madevent's hel_recycle into HelRecyclingCalls.inc, makes + // each wavefunction once per COPY, skips the amplitudes the warm-up probe found + // vanishing at a row, and folds the others into per-row tables that load_jamps + // then hands back to the helicity loop one row at a time. Without the generated + // files (no --hel_recycling, or a warm-up that could not run) this is not + // compiled and the helicity loop calls calculate_jamps as before. +#if __has_include( "HelRecycling.inc" ) and !defined MG_HR_PROBE +#define MGONGPU_HEL_RECYCLING 1 +#include "HelRecycling.inc" // nHrRows, cHrRow[ncomb], hrNwf (and the recycled chunks, if any) + + // The rows that were built are the rows that contributed AT THE PARAMETERS THE + // WARM-UP RAN AT. getGoodHel still runs at run time, and may ask for a row that + // is not in the table: a different param_card, a C-parity verdict that differs + // from the warm-up's (only representatives are built), MGONGPU_NOCSYM. That row + // is computed the ordinary way (load_jamps), exact but not recycled; say so once. + inline void + hr_unbaked_row( int ihel ) + { + static bool warned = false; + if( warned ) return; + warned = true; + printf( "INFO: helicity row %d is not in the --hel_recycling table: it is\n" + "INFO: computed without recycling.\n", + ihel ); + } + + // Build every helicity row of the table at once, for one event page (two in + // mixed precision): jampAll_sv[nHrRows][nParity][njampso] and, if + // storeChannelWeights, numAll_sv[nHrRows][nParity][ndiagrams]. + void + calculate_all_jamps( const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] + const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] + const unsigned int* iflavorVec, // input: indices of the flavor combinations + cxtype_amp_sv* jampAll_sv, // output: jamps of every row + fptype_amp_sv* numAll_sv, // output: |amp|^2 by diagram of every row + bool storeChannelWeights, + const int ievt00 ) // input: first event number in current C++ event page + { + using M_ACCESS = HostAccessMomenta; + using W_ACCESS = HostAccessWavefunctions; + using A_ACCESS = HostAccessAmplitudes; + using CD_ACCESS = HostAccessCouplings; + using CI_ACCESS = HostAccessCouplingsFixed; + using CDPF_ACCESS = CD_ACCESS; // the running flavor couplings gathered into dpf_value (as in calculate_jamps) + using F_ACCESS = HostAccessIflavorVec; + mgDebug( 0, __FUNCTION__ ); + // one wavefunction slot per COPY, which is more than the nwf of one row and + // far fewer than one row's worth per row + fptype_momenta_sv pvec_sv[hrNwf][np4]; + cxtype_amp_sv w_sv[hrNwf][nw6]; + cxtype_amp_sv amp_sv[1]; // every amplitude goes through this one scratch, then into the tables + ALOHAOBJ aloha_obj[hrNwf]; + for( int iwf = 0; iwf < hrNwf; iwf++ ) aloha_obj[iwf] = ALOHAOBJ{ pvec_sv[iwf], w_sv[iwf] }; + fptype_amp* amp_fp = reinterpret_cast( amp_sv ); + // the vertex with one leg left off, shared by every row that differs only in + // that leg (the P1N amplitude split) + fptype_momenta_sv p1n_pvec_sv[np4]; + cxtype_amp_sv p1n_w_sv[nw6]; + ALOHAOBJ _p1n = ALOHAOBJ{ p1n_pvec_sv, p1n_w_sv }; + (void)_p1n; + (void)amp_fp; + for( int i = 0; i < nHrRows * nParity * njampso; i++ ) jampAll_sv[i] = cxzero_sv(); + if( storeChannelWeights ) + for( int i = 0; i < nHrRows * nParity * ndiagrams; i++ ) numAll_sv[i] = fptype_amp_sv{ 0 }; + for( int iParity = 0; iParity < nParity; ++iParity ) + { + const int ievt0 = ievt00 + iParity * neppV; +#include "JampsPreamble.h" // a fragment of the iParity loop body, not a header +#include "HelRecyclingCalls.inc" + } + mgDebug( 1, __FUNCTION__ ); + } + + // Hand the helicity loop the row it asked for, out of the tables above, with the + // bookkeeping calculate_jamps does for its own row: the multichannel numerators + // and the color flows for the choice of color. A row the table lacks is + // computed by calculate_jamps instead (see hr_unbaked_row). + void + load_jamps( int ihel, + const cxtype_amp_sv* jampAll_sv, // input: jamps of every row + const fptype_amp_sv* numAll_sv, // input: |amp|^2 by diagram of every row + const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] (for a row not in the table) + const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] (idem) + const unsigned int* iflavorVec, // input: indices of the flavor combinations (idem) + cxtype_amp_sv* allJamp_sv, // output: jamp_sv[nParity*njampso] for this helicity + bool storeChannelWeights, + fptype_amp* allNumerators, // input/output: multichannel numerators[nevt], add helicity ihel + fptype_amp* allDenominators, // input/output: multichannel denominators[nevt] (idem) + fptype_amp_sv* jamp2_sv, // output: jamp2[nParity][ncolor_flow][neppV] for color choice (nullptr if disabled) + const int ievt00 ) // input: first event number in current C++ event page + { + using NUM_ACCESS = HostAccessNumerators; + const int row = cHrRow[ihel]; + if( row < 0 ) + { + hr_unbaked_row( ihel ); + calculate_jamps( cHel[ihel], allmomenta, allcouplings, iflavorVec, allJamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); + return; + } + for( int iParity = 0; iParity < nParity; ++iParity ) + { + const int ievt0 = ievt00 + iParity * neppV; + const size_t slot = (size_t)row * nParity + iParity; + cxtype_amp_sv jamp_sv[njampso]; + for( int icol = 0; icol < njampso; icol++ ) + jamp_sv[icol] = jampAll_sv[slot * njampso + icol]; + if( storeChannelWeights ) + { + fptype_amp* numerators = NUM_ACCESS::ieventAccessRecord( allNumerators, ievt0 * ndiagrams ); + fptype_amp_sv* numerators_sv = NUM_ACCESS::kernelAccessP( numerators ); + for( int idiag = 0; idiag < ndiagrams; idiag++ ) + numerators_sv[idiag] += numAll_sv[slot * ndiagrams + idiag]; + } +#include "ColorFlows.inc" // defines jampflow_sv[ncolor_flow], which is not jamp_sv on the DDM basis + if( jamp2_sv ) // disable color choice if nullptr + { + for( int icol = 0; icol < ncolor_flow; icol++ ) + jamp2_sv[ncolor_flow * iParity + icol] += cxabs2( jampflow_sv[icol] ); // may underflow #831 + } + for( int icol = 0; icol < njampso; icol++ ) + allJamp_sv[iParity * njampso + icol] = jamp_sv[icol]; + } + } +#endif // HelRecycling.inc + + //-------------------------------------------------------------------------- + + // The good-helicity filter of madevent (matrix_madevent_group_v4.inc): a + // helicity contributes if, at one of the sampled events, its |M|^2 is more than + // limhel/ncomb of the sum over all helicities there (limhel is the hidden + // run_card parameter of madevent, 1e-8 by default). Comparing to an exact 0 kept + // every helicity whose amplitudes only cancel to rounding -- the all-plus and + // one-minus helicities of a purely gluonic process, 12 of the 32 of g g > g g g -- + // so that every evaluation then computed them for nothing. + // + // The relative test is only made for a process of massless external legs. + // The scan points sit at one sqrt(s) (rambo::helicity_scan_points), where a + // row suppressed by powers of m/E looks negligible: in heft g g > b b~ two + // rows are 2e-11 of the sum at 1500 GeV but 4% each near threshold, and the + // relative test lost 11% of the cross section. With a massive external leg + // (ProcessTables::hel_massive) a helicity is good when it is not exactly 0. + // That also covers a crossing scanned on points off shell for it (xonshell_tab + // 0, only with a massive leg), where a row it needs can come out at rounding + // level: a longitudinal Z fed a lightlike momentum is proportional to it. The + // list is then closed over the legs whose helicity states mix under a boost + // (close_good_hel), so that it holds in every frame the events come in. +#ifndef MG_HR_PROBE + constexpr double limhel = 1e-8; +#else + // The --hel_recycling warm-up bakes the rows it finds into the recycled table. + // A row the run time asks for and the table lacks is still computed, without + // recycling (load_jamps), but at a cost on every event. So the probe keeps + // every row above a much lower threshold -- still far above a rounding + // residual (~1e-30 of the sum) -- on more events (goodHelScanEvents). + constexpr double limhel = 1e-14; +#endif //-------------------------------------------------------------------------- + // Close the good rows over the legs whose helicity states mix under a boost + // (ProcessTables::hel_frame_leg: massive legs with spin, massless spin-2 + // ones). Only the sum over such a leg's states, the other helicities fixed, + // is frame invariant: a row of a massive b that vanishes at the CM scan + // points is non-zero once boosted. Every row differing from a good one only + // at those legs is good too, as the fortran warm-up's HMV closure does. + inline void + close_good_hel( bool* isGoodHel, const short ( *hel )[npar] ) + { + bool closed[ncomb]; + for( int ihel = 0; ihel < ncomb; ihel++ ) closed[ihel] = isGoodHel[ihel]; + for( int ihel = 0; ihel < ncomb; ihel++ ) + { + if( !isGoodHel[ihel] ) continue; + for( int jhel = 0; jhel < ncomb; jhel++ ) + { + bool same = true; + for( int k = 0; k < npar; k++ ) + if( !hel_frame_leg[k] && hel[jhel][k] != hel[ihel][k] ) same = false; + if( same ) closed[jhel] = true; + } + } + for( int ihel = 0; ihel < ncomb; ihel++ ) isGoodHel[ihel] = closed[ihel]; + } + + //-------------------------------------------------------------------------- + + // The first event of the points crossing `cross` is scanned on: its own block + // of the batch when it has one -- events cross*goodHelScanEvents on, every one + // of them of this crossing, as scan_points (umami.cc) makes them at the + // crossing's own masses -- or -1, the first events then, which are the base + // process's (an API caller's first batch, a crossing RAMBO found no point + // for; one with no phase space at all is not scanned, cNoPhaseSpace). + inline int + scan_block( const int cross, const unsigned int* iflavorVec, const int nevt ) + { + if( cross == 0 ) return 0; + const int ievt0 = cross * goodHelScanEvents; + if( iflavorVec == nullptr || nevt < ievt0 + goodHelScanEvents ) return -1; + for( int ievt = ievt0; ievt < ievt0 + goodHelScanEvents; ievt++ ) + if( iflavorVec[ievt] / (unsigned int)nmaxflavor != (unsigned int)cross ) return -1; + return ievt0; + } + void sigmaKin_getGoodHel( const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] @@ -497,7 +843,7 @@ namespace madmatrix const int nevt ) // input: #events (for cuda: nevt == ndim == gpublocks*gputhreads) { // Allocate arrays at build time to contain at least 16 events (or at least neppV events if neppV>16, e.g. in future VPUs) - constexpr int maxtry0 = std::max( 16, neppV ); // 16, but at least neppV (otherwise the npagV loop does not even start) + constexpr int maxtry0 = goodHelScanEvents; // 16, but at least neppV // Loop over only nevt events if nevt is < 16 (note that nevt is always >= neppV) assert( nevt >= neppV ); const int maxtry = std::min( maxtry0, nevt ); // 16, but at most nevt (avoid invalid memory access if nevt helMEs( (size_t)ncomb * maxtry0, 0. ); // C-parity partner of every helicity row, and the per-hel |M|^2 of the // current scan page for the C-parity test fptype me_scan[ncomb][neppV]; @@ -538,11 +888,12 @@ namespace madmatrix // Crossing: sample every extended flavor id, i.e. every row of the // crossing table -- the crossings this ME records (every applicable one // only with --crossing_table=all) -- times every flavor, and OR them into - // the one list: each crossing is evaluated in its own frame, where a - // massive leg can need a row the base does not. Each row costs a full ncomb-helicity - // calculate_jamps scan, which is why the table carries no merely - // applicable crossing by default (scanning those was a 46x one-off - // startup cost on g g > t t~ g g g: 48 applicable, 0 recorded). + // the one list: each crossing is evaluated in its own frame, at its own + // masses, where a massive leg can need a row the base does not. Each row + // costs a full ncomb-helicity calculate_jamps scan, which is why the + // table carries no merely applicable crossing by default (scanning those + // was a 46x one-off startup cost on g g > t t~ g g g: 48 applicable, 0 + // recorded). constexpr int nscan = use_crossing ? ProcessTables::ncross * nmaxflavor : nmaxflavor; for( int iflav = 0; iflav < nscan; ++iflav ) { @@ -550,8 +901,21 @@ namespace madmatrix if constexpr( use_crossing ) { if( spincol_cross( xcross ) == 0 ) continue; + // a crossed decay lighter than its products has no physical point + if( cNoPhaseSpace[xcross] ) continue; } for( int i = 0; i < maxtry0; ++i ) hgFlavorVec[i] = (unsigned int)iflav; + // the points of this crossing: whole pages of the batch, so its momenta and + // couplings start at a page boundary + static_assert( goodHelScanEvents % MemoryAccessMomentaBase::neppM == 0 ); + const int iscan = scan_block( xcross, iflavorVec, nevt ); + const size_t iscan0 = iscan > 0 ? (size_t)iscan : 0; + const fptype_momenta* scanMomenta = allmomenta + iscan0 * npar * np4; + const fptype* scanCouplings = allcouplings + iscan0 * ndcoup * 2; +#ifdef MG_HR_PROBE + // the recycled build would drop the amplitudes measured off shell + if( iscan < 0 && !xonshell_tab[xcross] ) hrProbeOffShell.push_back( xcross ); +#endif for( int ipagV2 = 0; ipagV2 < npagV2; ++ipagV2 ) { #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT /* clang-format off */ @@ -577,18 +941,21 @@ namespace madmatrix #else cxtype_amp_sv jamp_sv[njampso] = {}; // all zeros #endif - calculate_jamps( ihel, allmomenta, allcouplings, hgFlavorVec, jamp_sv, false, allNumerators, allDenominators, jamp2_sv, ievt00 ); + calculate_jamps( cHel[ihel], scanMomenta, scanCouplings, hgFlavorVec, jamp_sv, false, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#ifdef MG_HR_PROBE + // file this evaluation's amplitudes under its helicity row: here, as + // calculate_jamps only sees the row it was handed, not its number + hr_probe_close( ihel ); +#endif color_sum_cpu( allMEs, jamp_sv, ievt00 ); for( int ie = 0; ie < neppV; ++ie ) me_scan[ihel][ie] = allMEs[ievt00 + ie]; for( int ieppV = 0; ieppV < neppV; ++ieppV ) { const int ievt = ievt00 + ieppV; - if( allMEs[ievt] != 0 ) // NEW IMPLEMENTATION OF GETGOODHEL (#630): COMPARE EACH HELICITY CONTRIBUTION TO 0 - isGoodHel[ihel] = true; + helMEs[(size_t)ihel * maxtry0 + ievt] = allMEs[ievt]; #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT const int ievt2 = ievt00 + ieppV + neppV; - if( allMEs[ievt2] != 0 ) // NEW IMPLEMENTATION OF GETGOODHEL (#630): COMPARE EACH HELICITY CONTRIBUTION TO 0 - isGoodHel[ihel] = true; + helMEs[(size_t)ihel * maxtry0 + ievt2] = allMEs[ievt2]; #endif } } @@ -629,7 +996,19 @@ namespace madmatrix } cCsymScanned = true; // a full ncomb-row comparison has been made } + // a helicity is good for this extended flavor id if it is above limhel/ncomb + // of the sum over helicities at one of the events -- not exactly 0 with a + // massive leg (see limhel) -- OR-ed over the ids: one list for every crossing + const double relhel = hel_massive ? 0. : limhel; + for( int ievt = 0; ievt < maxtry; ++ievt ) + { + fptype sumhel = 0; + for( int ihel = 0; ihel < ncomb; ihel++ ) sumhel += std::abs( helMEs[(size_t)ihel * maxtry0 + ievt] ); + for( int ihel = 0; ihel < ncomb; ihel++ ) + if( std::abs( helMEs[(size_t)ihel * maxtry0 + ievt] ) > sumhel * relhel / ncomb ) isGoodHel[ihel] = true; + } } // end loop over flavor combinations (per-flavor good-helicity union) + close_good_hel( isGoodHel, cHel ); } //-------------------------------------------------------------------------- @@ -648,6 +1027,7 @@ namespace madmatrix } } cNGoodHel = nGoodHel; + cNGoodHelRows = nGoodHel; for( int ihel = 0; ihel < ncomb; ihel++ ) cGoodHel[ihel] = goodHel[ihel]; { // All-or-nothing C-parity verdict. cCsymScanned is the load-bearing term: @@ -675,11 +1055,49 @@ namespace madmatrix nGoodHel = n; } } +#ifdef MG_HR_PROBE + // --hel_recycling warm-up: the good helicities and, for each, the amplitudes + // the probe never saw alive, in the protocol the madevent warm-up prints. + // The rows the helicity loop reads: after the C-parity reduction above, + // only the representatives (cGoodHel), as madevent bakes them; a row the + // run time asks for and the table lacks is computed without recycling. + if( getenv( "MG_DUMP_GOODHEL" ) != nullptr ) + { + for( int cross : hrProbeOffShell ) + printf( "HEL/OFFSHELL: %d\n", cross ); + for( int ighel = 0; ighel < cNGoodHel; ighel++ ) + printf( "Matrix Element/Good Helicity: %d %d\n", 1, cGoodHel[ighel] + 1 ); + for( int ighel = 0; ighel < cNGoodHel; ighel++ ) + { + const int ihel = cGoodHel[ighel]; + if( hrProbeSeen.empty() ) break; + for( size_t namp = 0; namp < hrProbeSeen[ihel].size(); namp++ ) + if( hrProbeSeen[ihel][namp] and !hrProbeAlive[ihel][namp] ) + printf( "HEL/ZEROAMP: %d %d %d\n", 1, ihel + 1, (int)namp ); + } + } +#endif return nGoodHel; } //-------------------------------------------------------------------------- + int + sigmaKin_getNGoodHel() + { + return cNGoodHelRows; // as on GPUs, which have no C-parity reduction + } + + //-------------------------------------------------------------------------- + + void + sigmaKin_setNoPhaseSpace( const int cross ) + { + if( cross > 0 && cross < ncross ) cNoPhaseSpace[cross] = true; + } + + //-------------------------------------------------------------------------- + // Decay-aware identical-particle (broken-)symmetry factor, shared with the // Fortran / standalone_cpp exporters (_get_broken_symmetry_data). Two // entries contribute to the over-counting factor only when they have the @@ -738,7 +1156,8 @@ namespace madmatrix // ighelSum[ighel] is the running sum of T_j over the good helicities j <= ighel, as the // helicity loop leaves it. Returns the |M|^2 of the event (before the helicity/color // average and the channel weight) and sets selhel; a vanishing |M|^2 returns 0 and - // leaves selhel alone, as the positive case does. + // leaves selhel alone, as the positive case does, and so does a NaN or infinite + // one, returned as is for the abnormal-ME check. static inline fptype select_helicity_signed( const fptype* ighelSum, // input: running sums over the good helicities const int ngood, // input: number of good helicities in ighelSum @@ -749,7 +1168,7 @@ namespace madmatrix fptype absTotal = 0; for( int ighel = 0; ighel < ngood; ighel++ ) absTotal += std::abs( ighelSum[ighel] - ( ighel > 0 ? ighelSum[ighel - 1] : 0 ) ); - if( absTotal == 0 ) return 0; + if( !selectable_total( absTotal ) ) return absTotal; fptype absSum = 0, absLo = 0, absHi = 0; int ighelLast = -1; // last non-vanishing helicity, in case rndhel is not below 1 for( int ighel = 0; ighel < ngood; ighel++ ) @@ -841,7 +1260,7 @@ namespace madmatrix const int nGoodLoop = cNGoodHel; #ifdef _OPENMP // OMP multithreading #575 (NB: tested only with gcc11 so far) -#define _OMPLIST0 allcouplings, allMEs, allmomenta, allrndcol, allrndhel, allselcol, allselhel, cGoodHel, nGoodLoop, npagV2, sampleSignedHelicity +#define _OMPLIST0 allcouplings, allMEs, allmomenta, allrndcol, allrndhel, allselcol, allselhel, cGoodHel, cHel, nGoodLoop, npagV2, sampleSignedHelicity #define _OMPLIST1 , allDenominators, allNumerators, allChannelIds, allDiagramIdsOut, allrnddiagram, iflavorVec, mgOnGpu::icolamp, mgOnGpu::channel2iconfig #pragma omp parallel for default( none ) shared( _OMPLIST0 _OMPLIST1 ) #undef _OMPLIST0 @@ -875,6 +1294,15 @@ namespace madmatrix reinterpret_cast( &csymExtra2 )[ie] = csym_lane_on( iflavorVec[ievt00 + neppV + ie] ) ? (fptype)1. : (fptype)0.; #endif +#ifdef MGONGPU_HEL_RECYCLING + // --hel_recycling: build every helicity row of this event page at once, then + // read the rows back inside the helicity loop (see calculate_all_jamps) + static thread_local std::vector hrJamp_sv( (size_t)nHrRows * nParity * njampso ); + static thread_local std::vector hrNum_sv( (size_t)nHrRows * nParity * ndiagrams ); + calculate_all_jamps( allmomenta, allcouplings, iflavorVec, hrJamp_sv.data(), hrNum_sv.data(), + allChannelIds != nullptr || allrnddiagram != nullptr, ievt00 ); +#endif + // The color matrix does not depend on the helicity, so for a large-enough color matrix // (ColorMatrixData::shouldUseBlas) it pays to keep the jamps of every good helicity and // hand them all to BLAS in one call after the loop instead of the per-helicity color sum. @@ -891,7 +1319,11 @@ namespace madmatrix for( int i = 0; i < nParity * ncolor; i++ ) jamp_sv[i] = cxzero_sv(); // calculate_jamps accumulates into jamp_sv // **NB! in "mixed" precision, using SIMD, calculate_jamps computes MEs for TWO neppV pages with a single channelId! #924 bool storeChannelWeights = allChannelIds != nullptr || allrnddiagram != nullptr; - calculate_jamps( ihel, allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#ifdef MGONGPU_HEL_RECYCLING + load_jamps( ihel, hrJamp_sv.data(), hrNum_sv.data(), allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#else + calculate_jamps( cHel[ihel], allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#endif } // The C-parity weight is NOT optional here: with the de-duplication on, // every helicity this loop just computed stands for two. The scalar loop @@ -921,7 +1353,11 @@ namespace madmatrix cxtype_amp_sv jamp_sv[nParity * njampso] = {}; // fixed nasty bug (omitting 'nParity' caused memory corruptions after calling calculate_jamps) // **NB! in "mixed" precision, using SIMD, calculate_jamps computes MEs for TWO neppV pages with a single channelId! #924 bool storeChannelWeights = allChannelIds != nullptr || allrnddiagram != nullptr; - calculate_jamps( ihel, allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#ifdef MGONGPU_HEL_RECYCLING + load_jamps( ihel, hrJamp_sv.data(), hrNum_sv.data(), allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#else + calculate_jamps( cHel[ihel], allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); +#endif color_sum_cpu( allMEs, jamp_sv, ievt00 ); MEs_ighel[ighel] = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 ) ); #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT @@ -943,10 +1379,16 @@ namespace madmatrix } } - // Event-by-event random choice of helicity #403 + // Event-by-event random choice of helicity #403. No helicity (0) unless one is + // chosen below: a vanishing or NaN |M|^2 chooses none, and the buffer may hold + // the previous call's choice. for( int ieppV = 0; ieppV < neppV; ++ieppV ) { const int ievt = ievt00 + ieppV; + allselhel[ievt] = 0; +#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + allselhel[ievt00 + ieppV + neppV] = 0; +#endif if constexpr( nampso > 1 ) { if( sampleSignedHelicity ) @@ -958,17 +1400,24 @@ namespace madmatrix #else ighelSum[ighel] = MEs_ighel[ighel]; #endif - *E_ACCESS::ieventAccessRecord( allMEs, ievt ) = select_helicity_signed( ighelSum, nGoodLoop, iflavorVec[ievt], allrndhel[ievt], allselhel[ievt] ); + if( valid_flavor_id( iflavorVec[ievt] ) ) + *E_ACCESS::ieventAccessRecord( allMEs, ievt ) = select_helicity_signed( ighelSum, nGoodLoop, iflavorVec[ievt], allrndhel[ievt], allselhel[ievt] ); #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT const int ievt2 = ievt00 + ieppV + neppV; for( int ighel = 0; ighel < nGoodLoop; ighel++ ) ighelSum[ighel] = MEs_ighel2[ighel][ieppV]; - *E_ACCESS::ieventAccessRecord( allMEs, ievt2 ) = select_helicity_signed( ighelSum, nGoodLoop, iflavorVec[ievt2], allrndhel[ievt2], allselhel[ievt2] ); + if( valid_flavor_id( iflavorVec[ievt2] ) ) + *E_ACCESS::ieventAccessRecord( allMEs, ievt2 ) = select_helicity_signed( ighelSum, nGoodLoop, iflavorVec[ievt2], allrndhel[ievt2], allselhel[ievt2] ); #endif continue; } } - for( int ighel = 0; ighel < nGoodLoop; ighel++ ) +#if defined MGONGPU_CPPSIMD + const bool okcdf = nGoodLoop > 0 && valid_flavor_id( iflavorVec[ievt] ) && selectable_total( MEs_ighel[nGoodLoop - 1][ieppV] ); +#else + const bool okcdf = nGoodLoop > 0 && valid_flavor_id( iflavorVec[ievt] ) && selectable_total( MEs_ighel[nGoodLoop - 1] ); +#endif + for( int ighel = 0; okcdf && ighel < nGoodLoop; ighel++ ) { #if defined MGONGPU_CPPSIMD const bool okhel = allrndhel[ievt] < ( MEs_ighel[ighel][ieppV] / MEs_ighel[nGoodLoop - 1][ieppV] ); @@ -996,7 +1445,8 @@ namespace madmatrix } #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT const int ievt2 = ievt00 + ieppV + neppV; - for( int ighel = 0; ighel < nGoodLoop; ighel++ ) + const bool okcdf2 = nGoodLoop > 0 && valid_flavor_id( iflavorVec[ievt2] ) && selectable_total( MEs_ighel2[nGoodLoop - 1][ieppV] ); + for( int ighel = 0; okcdf2 && ighel < nGoodLoop; ighel++ ) { if( allrndhel[ievt2] < ( MEs_ighel2[ighel][ieppV] / MEs_ighel2[nGoodLoop - 1][ieppV] ) ) { @@ -1039,8 +1489,11 @@ namespace madmatrix normalization += allNumerators[ievt / neppV * neppV * ndiagrams + ichan * neppV + ieppV % neppV]; } + // No diagram weight (a zero or NaN sum): keep the last channel, which has an + // iconfig (#919), as the GPU does; -ffast-math could pass rnd < 0/0 instead channelIdVec[ieppV] = mgOnGpu::nchannels; - for( unsigned int ichan = 0; ichan < mgOnGpu::nchannels; ichan++ ) + const bool okcdf = valid_flavor_id( iflavorVec[ievt] ) && selectable_total( normalization ); + for( unsigned int ichan = 0; okcdf && ichan < mgOnGpu::nchannels; ichan++ ) { if( mgOnGpu::channel2iconfig[ichan] == -1 ) continue; numerator_sum += allNumerators[ievt / neppV * neppV * ndiagrams + @@ -1095,7 +1548,9 @@ namespace madmatrix #endif } const int ievt = ievt00 + ieppV; - for( int icolC = 0; icolC < ncolor_flow; icolC++ ) + allselcol[ievt] = 0; // no color unless one is chosen below (vanishing or NaN jamp2 sum) + const bool okcdf = valid_flavor_id( iflavorVec[ievt] ) && selectable_total( targetamp[ncolor_flow - 1] ); + for( int icolC = 0; okcdf && icolC < ncolor_flow; icolC++ ) { if( allrndcol[ievt] < ( targetamp[icolC] / targetamp[ncolor_flow - 1] ) ) { @@ -1128,8 +1583,8 @@ namespace madmatrix const int ievt0 = ipagV * neppV; fptype* MEs = E_ACCESS::ieventAccessRecord( allMEs, ievt0 ); fptype_sv& MEs_sv = E_ACCESS::kernelAccess( MEs ); - if constexpr( !use_crossing ) - MEs_sv = MEs_sv * static_cast( broken_symmetry_factor( iflavorVec[ievt0] ) ) / static_cast( helcolDenominators[0] ); + if constexpr( !use_crossing ) // the flavor the page was evaluated with (JampsPreamble.h) + MEs_sv = MEs_sv * static_cast( broken_symmetry_factor( iflavorVec[ievt0] % nmaxflavor ) ) / static_cast( helcolDenominators[0] ); else { // Per-event crossing-aware denominator: the crossing may differ per @@ -1175,6 +1630,19 @@ namespace madmatrix MEs_sv *= numerators_sv[channelId - 1] / safe_den; } } + // Without crossing, an id that names no flavor: NaN, as on GPU, in the + // |M|^2 and the denominator, so its channel weights (amp2) too (with + // crossing, an id past the table got the zero |M|^2 above) + if constexpr( !use_crossing ) + { + for( int ieppV = 0; ieppV < neppV; ++ieppV ) + { + if( valid_flavor_id( iflavorVec[ievt0 + ieppV] ) ) continue; + reinterpret_cast( &MEs_sv )[ieppV] = quiet_nan_bits(); + if( storeChannelWeights ) + allDenominators[ievt0 + ieppV] = quiet_nan_bits(); // ARRAY[nevt] + } + } } mgDebugFinalise(); } diff --git a/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.h b/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.h index a00286cc54..09d7aadd89 100644 --- a/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.h +++ b/madgraph/iolibs/template_files/madmatrix/backend/cpu/SigmaKin.h @@ -21,6 +21,17 @@ namespace madmatrix fptype* allcouplings, // output: couplings[nevt*ndcoup*2] const int nevt ); // input: #events (for cuda: nevt == ndim == gpublocks*gputhreads) + // The events sigmaKin_getGoodHel looks at, which umami_initialize makes for it + // (choose_good_helicities): one constant, so that the two never disagree. + // 16, but at least neppV (otherwise the npagV loop does not even start). + // The --hel_recycling warm-up probe (scalar, MG_HR_PROBE) bakes what it finds + // with no run-time fallback for a dropped amplitude, so it samples 64. +#ifndef MG_HR_PROBE + constexpr int goodHelScanEvents = ( neppV > 16 ? neppV : 16 ); +#else + constexpr int goodHelScanEvents = 64; +#endif + void sigmaKin_getGoodHel( const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] @@ -34,6 +45,15 @@ namespace madmatrix int // output: nGoodHel (the number of good helicity combinations out of ncomb) sigmaKin_setGoodHel( const bool* isGoodHel ); // input: isGoodHel[ncomb] - host array + int // output: the good helicity rows set by sigmaKin_setGoodHel, before + // the C-parity reduction (0 before): the same count as on GPUs + sigmaKin_getNGoodHel(); + + // A crossing-table row with no phase space at its own masses (a crossed + // decay lighter than its products, see scan_points in umami.cc): set before + // sigmaKin_getGoodHel, which then leaves it out of the good-helicity scan. + void sigmaKin_setNoPhaseSpace( const int cross ); + void sigmaKin( const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] @@ -65,6 +85,28 @@ namespace madmatrix void setBsmIndepParam( const double* values, int n ); // Window (in widths) of the $-excluded propagators, the run card bw_cutoff void setBwCutoff( const double bwcutoff ); + + // What the spin-density matrix (Density.cc) needs on top of sigmaKin: the + // jamps of one event page for any helicity combination hel[npar], the + // helicity table cHel[ncomb][npar] (row major) the combinations are built + // from, and the identical-particle factor of a flavor combination. + void + calculate_jamps( const short* hel, // input: helicity of each leg, hel[npar] + const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] + const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] + const unsigned int* iflavorVec, // input: indices of the flavor combinations + cxtype_amp_sv* allJamp_sv, // output: jamp_sv[njampso] (float/double) or jamp_sv[2*njampso] (mixed) + bool storeChannelWeights, + fptype_amp* allNumerators, // input/output: multichannel numerators[nevt] + fptype_amp* allDenominators, // input/output: multichannel denominators[nevt] + fptype_amp_sv* jamp2_sv, // output: jamp2 for the color choice (nullptr if disabled) + const int ievt00 ); // input: first event number in the event page + const short* getHelicities(); + int broken_symmetry_factor( const int iflavor ); + // With crossing (ProcessTables::use_crossing): the identical-final-state + // factor of the process crossing-table row cross crosses into, for the + // flavor iflavor (the crossed denominator, see sigmaKin) + int ident_cross( int cross, int iflavor ); } #endif // SIGMAKIN_H diff --git a/madgraph/iolibs/template_files/madmatrix/backend/cpu/mgOnGpuCxtypes.h b/madgraph/iolibs/template_files/madmatrix/backend/cpu/mgOnGpuCxtypes.h index cb354ff07b..24e69b3d89 100644 --- a/madgraph/iolibs/template_files/madmatrix/backend/cpu/mgOnGpuCxtypes.h +++ b/madgraph/iolibs/template_files/madmatrix/backend/cpu/mgOnGpuCxtypes.h @@ -69,6 +69,18 @@ namespace mgOnGpu /* clang-format off */ { return cxsmpl( c.real(), -c.imag() ); } + + // Principal square root, as std::sqrt( std::complex ) (branch cut along the negative real axis). + // The complex-mass scheme needs it in Parameters.cc, e.g. CMASS_mdl_MT = sqrt( MT^2 - i*MT*WT ), + // and its complex couplings then feed further sqrt's (e.g. mdl_cw = sqrt( 1 - mdl_sw2 )). + // Host only: model parameters are computed once on the host, never event by event on a device. + template + inline __host__ cxsmpl + sqrt( const cxsmpl& c ) + { + const std::complex s = std::sqrt( std::complex( c.real(), c.imag() ) ); + return cxsmpl( s.real(), s.imag() ); + } } /* clang-format on */ // Expose the cxsmpl class outside the namespace @@ -511,6 +523,17 @@ namespace madmatrix template inline CX cxzero_sv() { return CX{}; } + // Denominator p^2 - M^2 + i M W of a massive propagator, with the width + // kept only where p^2 > 0 (timelike): a spacelike (t-channel) propagator + // has no pole to regulate. Used by the ALOHA routines when the width of a + // t-channel propagator is dropped (aloha.t_channel_width False, the + // default), lane by lane, as the Fortran routines do event by event. + inline __host__ __device__ cxtype_denom_sv + propagator_denom( const fptype_denom_sv& p2, const fptype_denom M, const fptype_denom W ) + { + return cxtype_denom_sv( p2 - M * M, ( p2 > 0 ) ? M * W : fptype_denom( 0 ) ); + } + // Functions and operators for cxtype_sv (and multi-precision variant) template inline __host__ __device__ auto diff --git a/madgraph/iolibs/template_files/madmatrix/backend/cpu/umami.cc b/madgraph/iolibs/template_files/madmatrix/backend/cpu/umami.cc index 2773b83b46..4ce149e015 100644 --- a/madgraph/iolibs/template_files/madmatrix/backend/cpu/umami.cc +++ b/madgraph/iolibs/template_files/madmatrix/backend/cpu/umami.cc @@ -7,11 +7,14 @@ #include "umami.h" #include "ProcessData.h" +#include "ProcessTables.h" // the crossing rows (choose_good_helicities) #include "CPPProcess.h" // needed to construct/initProc the process object (umami_initialize) #include "SigmaKin.h" // sigmaKin_getGoodHel/setGoodHel #include "MemoryAccessMomenta.h" #include "MemoryBuffers.h" +#include "rambo.h" // RAMBO points to choose the good helicities on (umami_initialize) +#include #include #include #include @@ -53,6 +56,17 @@ namespace } + // Set by choose_good_helicities where RAMBO has no phase space (a decay below + // threshold): the momenta of such a process are off shell, and a first call + // cannot choose its good helicities. In its rest frame a scalar to two + // off-shell vectors only has the rows of equal helicities, the others are + // rounding residuals, but they are not 0 once the decay is boosted: h > w+ w- + // at mh < 2 mW kept 3 of its 9 rows after a first call at rest, and |M|^2 in a + // boosted frame was then 8.5 times too small. Every row is kept instead, so + // that the result does not depend on the first call, at the cost of the + // vanishing rows of a low multiplicity. + bool g_keep_every_helicity = false; + void* initialize_impl( const fptype_momenta* momenta, const fptype* couplings, @@ -63,6 +77,12 @@ namespace std::size_t count ) { bool is_good_hel[ProcessData::ncomb]; + if( g_keep_every_helicity ) + { + for( int ihel = 0; ihel < ProcessData::ncomb; ++ihel ) is_good_hel[ihel] = true; + sigmaKin_setGoodHel( is_good_hel ); + return nullptr; + } sigmaKin_getGoodHel( momenta, couplings, flavor_indices, matrix_elements, numerators, denominators, is_good_hel, @@ -116,6 +136,87 @@ namespace std::vector g_externalMasses; + // The good-helicity scan points: goodHelScanEvents RAMBO points per row of the + // crossing table, block K (events K*goodHelScanEvents on) at the masses of + // crossing K -- its input slot k carries the mass of the base slot it is fed + // to -- and with its extended id K*nmaxflavor, which is how sigmaKin_getGoodHel + // finds it. The base points serve a crossing that moves no mass. A crossing + // that does needs its own: a massive leg in a massless slot is off shell on the + // base points, where a row it needs can vanish to rounding. A crossing with no + // phase space at its masses (t > b e+ ve crossed into b~ > t~ e+ ve) is never + // evaluated on shell and needs no row: getGoodHel skips it + // (sigmaKin_setNoPhaseSpace), as the fortran warm-up does, and its block holds + // the base points under the base id. One RAMBO does not converge on gets them + // too, which getGoodHel scans the conservative way. Returns the number of + // events, 0 when the base process itself has no phase space. + std::size_t scan_points( std::vector& momenta, std::vector& flavor_indices ) + { + constexpr std::size_t nevt = goodHelScanEvents; + constexpr int npar = ProcessData::npar; + constexpr int nblock = ProcessTables::use_crossing ? ProcessTables::ncross : 1; + const std::vector base = rambo::helicity_scan_points( ProcessData::npari, g_externalMasses, nevt ); + if( base.empty() ) return 0; + const std::size_t count = nblock * nevt; + momenta.assign( count * npar * 4, 0. ); + flavor_indices.assign( count, 0 ); + for( int cross = 0; cross < nblock; ++cross ) + { + std::vector own; + if( cross > 0 && !ProcessTables::xonshell_tab[cross] ) + { + std::vector masses( npar ); + for( int k = 0; k < npar; ++k ) masses[k] = g_externalMasses[ProcessTables::xmass_slot_tab[cross * npar + k]]; + if( rambo::has_phase_space( ProcessData::npari, masses ) ) + own = rambo::helicity_scan_points( ProcessData::npari, masses, nevt ); + else + sigmaKin_setNoPhaseSpace( cross ); + } + const bool use_base = own.empty(); + const std::vector& points = use_base ? base : own; + const bool own_id = cross == 0 || ProcessTables::xonshell_tab[cross] || !use_base; + for( std::size_t ievt = 0; ievt < nevt; ++ievt ) + { + const std::size_t jevt = cross * nevt + ievt; + flavor_indices[jevt] = own_id ? cross * ProcessData::nmaxflavor : 0; + for( int i = 0; i < npar * 4; ++i ) momenta[count * i + jevt] = points[nevt * i + ievt]; + } + } + return count; + } + + // The helicity filter (sigmaKin_getGoodHel) runs once per library and keeps a + // helicity if, at one of the scan points, its |M|^2 is above limhel/ncomb of the + // sum over helicities (the relative test of madevent, see limhel in SigmaKin.cc), + // or not exactly 0 with a massive external leg, closed over the helicities of + // the legs whose states mix under a boost. + // Run at the first umami_matrix_element call, it depended on what that call held: + // a first batch of special points, where some helicities happen to vanish (the + // forward direction of u u~ > e+ e-, say), dropped them for the rest of the run, + // and every later |M|^2 was wrong. It runs instead here, at initialization, on + // RAMBO points, half of them boosted (rambo::helicity_scan_points). The filter + // evaluates every flavor and every crossing of the table, into the one + // good-helicity list all of them share, each crossing on points of its own masses + // (scan_points). A 2 > 1 is scanned with its beams along random axes. Where there + // is no phase space (a decay below threshold), every helicity is kept + // (g_keep_every_helicity). + void choose_good_helicities( UmamiHandle handle ) + { + std::vector momenta; + std::vector flavor_indices; + const std::size_t count = scan_points( momenta, flavor_indices ); + if( count == 0 ) + { + g_keep_every_helicity = true; + return; + } + std::vector matrix_elements( count ); + const UmamiInputKey input_keys[] = { UMAMI_IN_MOMENTA, UMAMI_IN_FLAVOR_INDEX }; + const void* inputs[] = { momenta.data(), flavor_indices.data() }; + const UmamiOutputKey output_keys[] = { UMAMI_OUT_MATRIX_ELEMENT }; + void* outputs[] = { matrix_elements.data() }; + umami_matrix_element( handle, count, count, 0, 2, input_keys, inputs, 1, output_keys, outputs ); + } + } extern "C" @@ -192,6 +293,9 @@ extern "C" auto instance = new InterfaceInstance(); *handle = instance; + // once per library, like the filter (a thread-safe static initialization) + static const bool good_helicities = ( choose_good_helicities( instance ), true ); + (void)good_helicities; return UMAMI_SUCCESS; } @@ -230,6 +334,19 @@ extern "C" return UMAMI_ERROR_NOT_IMPLEMENTED; } + // Numbering of the outputs of a crossed call (crossing on, flavor index + // K*nmaxflavor + flavor: row K of the crossing table in ProcessTables.h). + // - UMAMI_OUT_HELICITY_INDEX is the crossed process's own helicity code, + // the one its --use_crossing=False output reports (selected_hel_code). + // - UMAMI_OUT_COLOR_INDEX, UMAMI_OUT_DIAGRAM_INDEX and UMAMI_OUT_DIAGRAM_AMP2 + // number the colour flows and diagrams of the BASE subprocess (this P + // directory's). To read a flow on the crossed legs, input slot k is base + // leg xpinv_tab[K*npar+k], with colour and anticolour exchanged when + // xsgni_tab is -1 (the leg changed side). This is not the numbering of the + // --use_crossing=False output: two rows can serve the same flavours with + // different leg assignments, so one base flow is a different flow of the + // crossed process under each. `output mg7` decodes through the base this + // way (export_mg7.py, get_crossed_subprocess_info). UmamiStatus umami_matrix_element( UmamiHandle handle, size_t count, @@ -315,8 +432,16 @@ extern "C" } constexpr std::size_t vector_size = MemoryAccessMomentaBase::neppM; - // need to round to round to double page size for some reason + // The kernels run on one SIMD page of events at a time (neppM == neppV events, + // a single event for the scalar backend), and on two pages at a time in mixed + // precision (double amplitudes, float colour algebra): the batch is rounded up + // to that, and no further. Every extra event is computed for nothing: a call + // with one event used to evaluate two of them on the scalar backend. +#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT constexpr std::size_t page_size2 = 2 * vector_size; +#else + constexpr std::size_t page_size2 = vector_size; +#endif std::vector permutation; std::size_t rounded_count; @@ -349,17 +474,11 @@ extern "C" if ( vcount == 0 ) { vindex = voffset * page_size2; - // Pre-fill the whole page with a valid padding id (crossing 0 of this - // reduced flavor) so that unused tail lanes never index the crossing - // tables out of range; real events overwrite their own slot below. - for ( std::size_t i = 0; i < page_size2; ++i) { - flavor_indices[voffset * page_size2 + i] = rflav; - } + // no pre-fill: a padding lane copies the first event of its page, + // extended id included (see the lane loop below) voffset += 1; } - const std::size_t slot = vindex + vcount; - permutation[i_event] = slot; - flavor_indices[slot] = flav; // per-event full extended id (flavor + crossing) + permutation[i_event] = vindex + vcount; vcount = (vcount + 1) % page_size2; } rounded_count = voffset * page_size2; @@ -379,29 +498,33 @@ extern "C" HostBufferBase denominators( rounded_count ); HostBufferBase helicity_index( rounded_count ); HostBufferBase color_index( rounded_count ); - if ( sort_flavors ) { - for( std::size_t i_event = 0; i_event < count; ++i_event ) - { - std::size_t i_sorted = permutation[i_event]; - transpose_momenta( &momenta_in[offset], momenta.data(), i_event, i_sorted, stride ); - helicity_random[i_sorted] = random_helicity_in ? random_helicity_in[i_event + offset] : 0.5; - color_random[i_sorted] = random_color_in ? random_color_in[i_event + offset] : 0.5; - diagram_random[i_sorted] = random_diagram_in ? random_diagram_in[i_event + offset] : 0.5; - g_s[i_sorted] = alpha_s_in ? sqrt( 4 * M_PI * alpha_s_in[i_event + offset] ) : 1.2177157847767195; - } - } else { - for( std::size_t i_event = 0; i_event < count; ++i_event ) - { - transpose_momenta( &momenta_in[offset], momenta.data(), i_event, i_event, stride ); - helicity_random[i_event] = random_helicity_in ? random_helicity_in[i_event + offset] : 0.5; - color_random[i_event] = random_color_in ? random_color_in[i_event + offset] : 0.5; - diagram_random[i_event] = random_diagram_in ? random_diagram_in[i_event + offset] : 0.5; - g_s[i_event] = alpha_s_in ? sqrt( 4 * M_PI * alpha_s_in[i_event + offset] ) : 1.2177157847767195; - flavor_indices[i_event] = flavor_indices_in ? flavor_indices_in[i_event + offset] : 0; - } - for ( std::size_t i_event = count; i_event < rounded_count; ++i_event ) { - flavor_indices[i_event] = 0; - } + // The input event of each lane. The padding lanes, which complete the last page + // of a flavor, are copies of the first event of their page, as on GPUs. Left at + // zero (momenta and g_s), they evaluated to nan, and the good-helicity filter + // then run at the first call (which kept a helicity whose |M|^2 was not 0 at one + // of its first events) took that for a non-zero value whenever a padding lane + // was among them, as in a first call with a single event: every helicity was + // then kept for the rest of the run, the vanishing ones of a q q~ initial state + // included. + std::vector source( rounded_count, count ); + for( std::size_t i_event = 0; i_event < count; ++i_event ) + { + source[sort_flavors ? permutation[i_event] : i_event] = i_event; + } + for( std::size_t lane = 0; lane < rounded_count; ++lane ) + { + // a page is opened by an event, so its first lane is never padding + if( source[lane] == count ) source[lane] = source[lane - lane % page_size2]; + } + for( std::size_t lane = 0; lane < rounded_count; ++lane ) + { + const std::size_t i_event = source[lane]; + transpose_momenta( &momenta_in[offset], momenta.data(), i_event, lane, stride ); + helicity_random[lane] = random_helicity_in ? random_helicity_in[i_event + offset] : 0.5; + color_random[lane] = random_color_in ? random_color_in[i_event + offset] : 0.5; + diagram_random[lane] = random_diagram_in ? random_diagram_in[i_event + offset] : 0.5; + g_s[lane] = alpha_s_in ? sqrt( 4 * M_PI * alpha_s_in[i_event + offset] ) : 1.2177157847767195; + flavor_indices[lane] = flavor_indices_in ? flavor_indices_in[i_event + offset] : 0; } computeDependentCouplings( g_s.data(), couplings.data(), rounded_count ); @@ -517,4 +640,15 @@ extern "C" delete instance; return UMAMI_SUCCESS; } + + // A madmatrix extension, not part of UMAMI: the number of rows in the one + // good-helicity list shared by every flavor and crossing. It counts both rows of + // a C-parity pair even when the helicity loop runs only one of them, so every + // backend gives the same number. The filter runs at the first umami_initialize + // (at the first umami_matrix_element call where RAMBO has no phase space; 0 + // before it), shared by every instance. + int madmatrix_good_helicity_count() + { + return sigmaKin_getNGoodHel(); + } } diff --git a/madgraph/iolibs/template_files/madmatrix/backend/gpu/SigmaKin.cc b/madgraph/iolibs/template_files/madmatrix/backend/gpu/SigmaKin.cc index a9b72c7487..48193f70fc 100644 --- a/madgraph/iolibs/template_files/madmatrix/backend/gpu/SigmaKin.cc +++ b/madgraph/iolibs/template_files/madmatrix/backend/gpu/SigmaKin.cc @@ -30,6 +30,8 @@ #include "ColorData.h" // for mgOnGpu::nchannels/channel2iconfig/icolamp/nconfigSDE #include +#include +#include namespace madmatrix { @@ -52,11 +54,15 @@ namespace madmatrix // Helicity/flavor tables and SM parameter/coupling storage, populated once // by CPPProcess's constructor/initProc via the setters below. __device__ __constant__ short cHel[ncomb][npar]; + static short hstHel[ncomb][npar]; // host copy, for the good-helicity closure __device__ __constant__ int dcNGoodHel; __device__ __constant__ int dcGoodHel[ncomb]; __device__ __constant__ short cFlavors[nmaxflavor][npar]; static int cNGoodHel; static int cGoodHel[ncomb]; + // The crossing-table rows with no phase space at their own masses + // (sigmaKin_setNoPhaseSpace), which the good-helicity scan skips, as cpu + static bool cNoPhaseSpace[ncross]; __device__ __constant__ fptype cIPD[nIPD > 0 ? nIPD : 1]; // Crossing symmetry (ProcessTables::use_crossing, from --use_crossing), as on @@ -81,6 +87,8 @@ namespace madmatrix void setHelicitiesAndFlavors( const short* tHel, const short* tFlavors ) { gpuMemcpyToSymbol( cHel, tHel, ncomb * npar * sizeof( short ) ); + for( int ihel = 0; ihel < ncomb; ihel++ ) + for( int ipar = 0; ipar < npar; ipar++ ) hstHel[ihel][ipar] = tHel[ihel * npar + ipar]; gpuMemcpyToSymbol( cFlavors, tFlavors, nmaxflavor * npar * sizeof( short ) ); } @@ -146,6 +154,20 @@ namespace madmatrix return ( cross >= 0 && cross < ncross ) ? xspincol_tab[cross] : 0; } + // Whether an event's flavor id names a flavor of this process, as on cpu + // (backend/cpu valid_flavor_id): an index of cFlavors or, with crossing, an + // extended id whose crossing-table row exists. Any other id gets no helicity, + // colour or diagram choice, and normalise_output gives it a NaN |M|^2 without + // crossing, 0 with (a row past the table, as in the Fortran crossing code). + __device__ inline bool + valid_flavor_id( const unsigned int flavor_id ) + { + if constexpr( use_crossing ) + return spincol_cross( (int)( flavor_id / (unsigned int)nmaxflavor ) ) != 0; + else + return flavor_id < (unsigned int)nmaxflavor; + } + // Identical-final-state factor of the crossed process: the device copy of // backend/cpu ident_cross (see there). __device__ int @@ -240,10 +262,28 @@ namespace madmatrix //-------------------------------------------------------------------------- + // The mass an external leg marked off-shell ('*': MadSpin's decaying particles) + // is evaluated with. The Fortran output and the scalar C++ backend take the + // invariant mass of its momentum; the GPU kernels keep the pole mass, the same + // as long as the momenta are on shell, which is all mg7 generates (off-shell + // momenta need the scalar backend, see madmatrix.density). + template + __device__ inline fptype_amp + offshell_mass( const fptype_amp poleMass, const fptype_momenta*, const int ) + { + return poleMass; + } + + //-------------------------------------------------------------------------- + // Accumulate a multichannel numerator contribution in place. All good-helicity // blocks/streams for a given event race on the same numerator slot (the helicity // dimension has been removed to save memory), so an atomicAdd is mandatory. #define NUM_ATOMIC_ADD( DST, VAL ) atomicAdd( &( DST ), VAL ) +// The --hel_recycling warm-up probe hook that a recycled output's diagram calls +// carry after every amplitude: the probe and the recycled build are CPU only +// (see backend/{cpu,simd}/SigmaKin.cc), so it is nothing here. +#define MG_HR_PROBE_AMP( NAMP ) // Evaluate QCD partial amplitudes jamps for this given helicity from Feynman diagrams. // Also compute running sums over helicities adding jamp2, numerator, denominator @@ -409,9 +449,50 @@ namespace madmatrix } #undef NUM_ATOMIC_ADD +#undef MG_HR_PROBE_AMP //-------------------------------------------------------------------------- + // Close the good rows over the legs whose helicity states mix under a boost + // (ProcessTables::hel_frame_leg), as backend/cpu does: every row differing + // from a good one only at those legs is good too (host helicity table). + inline void + close_good_hel( bool* isGoodHel, const short ( *hel )[npar] ) + { + bool closed[ncomb]; + for( int ihel = 0; ihel < ncomb; ihel++ ) closed[ihel] = isGoodHel[ihel]; + for( int ihel = 0; ihel < ncomb; ihel++ ) + { + if( !isGoodHel[ihel] ) continue; + for( int jhel = 0; jhel < ncomb; jhel++ ) + { + bool same = true; + for( int k = 0; k < npar; k++ ) + if( !hel_frame_leg[k] && hel[jhel][k] != hel[ihel][k] ) same = false; + if( same ) closed[jhel] = true; + } + } + for( int ihel = 0; ihel < ncomb; ihel++ ) isGoodHel[ihel] = closed[ihel]; + } + + //-------------------------------------------------------------------------- + + // The first event of the points crossing `cross` is scanned on: its own block + // of the batch, or -1 for the first events (the host copy of backend/cpu + // scan_block, which reads the flavor ids of the block from the device). + inline int + scan_block( const int cross, const unsigned int* devFlavorVec, const int nevt ) + { + if( cross == 0 ) return 0; + const int ievt0 = cross * goodHelScanEvents; + if( devFlavorVec == nullptr || nevt < ievt0 + goodHelScanEvents ) return -1; + unsigned int ids[goodHelScanEvents]; + gpuMemcpy( ids, devFlavorVec + ievt0, goodHelScanEvents * sizeof( unsigned int ), gpuMemcpyDeviceToHost ); + for( int ievt = 0; ievt < goodHelScanEvents; ievt++ ) + if( ids[ievt] / (unsigned int)nmaxflavor != (unsigned int)cross ) return -1; + return ievt0; + } + void /* clang-format off */ sigmaKin_getGoodHel( const fptype_momenta* allmomenta, // input: momenta[nevt*npar*4] const fptype* allcouplings, // input: couplings[nevt*ndcoup*2] @@ -423,15 +504,27 @@ namespace madmatrix bool* isGoodHel, // output: isGoodHel[ncomb] - host array const int nevt ) // input: #events (for cuda: nevt == ndim == gpublocks*gputhreads) { /* clang-format on */ - const int maxtry0 = 16; + constexpr int maxtry0 = goodHelScanEvents; // 16 fptype hstMEs[maxtry0]; const int maxtry = std::min( maxtry0, nevt ); // 16, but at most nevt (avoid invalid memory access if nevt