Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-05-15 07:41:49

0001 #!/usr/bin/env python3
0002 """
0003 Benchmark: GPU speedup for optical photon simulation on the EPIC dRICH detector.
0004 
0005 Runs three configurations with the full dRICH geometry (100 pi+ at 10 GeV):
0006 
0007   cpu      -- Optical photons generated AND tracked on CPU by Geant4
0008   baseline -- Optical photons generated but NOT tracked (SetStackPhotons=false)
0009   gpu      -- Optical photons simulated on GPU via eic-opticks
0010 
0011 Speedup = CPU_optical_time / GPU_simulate_time
0012   where CPU_optical_time = T(cpu) - T(baseline)
0013   and   GPU_simulate_time = wall time of G4CXOpticks::simulate()
0014 
0015 Prerequisites:
0016   - Spack environment activated with DD4hep, Geant4, eic-opticks
0017   - `spack load epic` (sets DETECTOR_PATH to EPIC geometry)
0018   - DD4HEP_LIBRARY_PATH includes /opt/local/lib (eic-opticks plugins)
0019 
0020 Usage:
0021   python3 benchmark_drich.py                                # all modes, 10 events
0022   python3 benchmark_drich.py --events 100 --photon-threshold 5000000
0023   python3 benchmark_drich.py --mode gpu --events 10
0024 """
0025 import argparse
0026 import json
0027 import math
0028 import os
0029 import re
0030 import subprocess
0031 import sys
0032 import time
0033 
0034 
0035 _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
0036 
0037 
0038 def find_compact_file(geometry="1sector"):
0039     """Locate the dRICH compact XML.
0040 
0041     geometry: '1sector' (default) uses the bundled single-sector XML,
0042               'full' uses EPIC's epic_drich_only.xml (all 6 sectors).
0043     """
0044     if geometry == "1sector":
0045         compact = os.path.join(_SCRIPT_DIR, "geometry", "epic_drich_1sector.xml")
0046         os.environ["DRICH_1SECTOR_XML"] = os.path.join(
0047             _SCRIPT_DIR, "geometry", "drich_1sector.xml")
0048     else:
0049         detector_path = os.environ.get("DETECTOR_PATH", "")
0050         if not detector_path:
0051             print("ERROR: DETECTOR_PATH not set. Run: spack load epic",
0052                   file=sys.stderr)
0053             sys.exit(1)
0054         compact = os.path.join(detector_path, "epic_drich_only.xml")
0055 
0056     if not os.path.exists(compact):
0057         print(f"ERROR: {compact} not found", file=sys.stderr)
0058         sys.exit(1)
0059 
0060     return compact
0061 
0062 
0063 def run_single_mode(mode, num_events, photon_threshold=0, multiplicity=100,
0064                     geometry="1sector"):
0065     """Run one benchmark mode inside the current process."""
0066     import cppyy
0067     import DDG4
0068     from g4units import GeV
0069 
0070     cppyy.include("G4OpticalParameters.hh")
0071     from cppyy.gbl import G4OpticalParameters
0072 
0073     compact = find_compact_file(geometry)
0074 
0075     # ---- kernel & geometry ------------------------------------------------
0076     kernel = DDG4.Kernel()
0077     print(f"Loading geometry: {compact}")
0078     kernel.loadGeometry(str("file:" + compact))
0079 
0080     geant4 = DDG4.Geant4(kernel)
0081     geant4.printDetectors()
0082     geant4.setupUI(typ="tcsh", vis=False, ui=False)
0083 
0084     # ---- particle gun: pi+ at 10 GeV toward dRICH sector 0 ---------------
0085     # dRICH acceptance: eta 1.6-3.5 => theta 3.5-22.8 deg
0086     # ideal theta: eta=2.0 => ~15.4 deg (full rings in one sector)
0087     # 1-sector geometry: sector 0 sensors are at phi ~ -167 deg
0088     # Full geometry: sector 0 at same phi; other sectors fill the ring
0089     eta = 2.0
0090     theta = 2.0 * math.atan(math.exp(-eta))  # ~15.4 deg
0091     phi = math.radians(-167.0) if geometry == "1sector" else 0.0
0092     geant4.setupGun(
0093         "Gun",
0094         particle="pi+",
0095         energy=10 * GeV,
0096         position=(0, 0, 0),
0097         isotrop=False,
0098         direction=(
0099             math.sin(theta) * math.cos(phi),
0100             math.sin(theta) * math.sin(phi),
0101             math.cos(theta),
0102         ),
0103         multiplicity=multiplicity,
0104     )
0105 
0106     # ---- physics ----------------------------------------------------------
0107     geant4.setupPhysics("QGSP_BERT")
0108     ph = DDG4.PhysicsList(kernel, "Geant4PhysicsList/OpticalPhys")
0109     ph.addPhysicsConstructor(str("G4OpticalPhysics"))
0110     kernel.physicsList().adopt(ph)
0111 
0112     # ---- mode-specific setup ----------------------------------------------
0113     if mode == "cpu":
0114         # Track optical photons on CPU with proper optical tracker
0115         seq, act = geant4.setupDetector("DRICH", "Geant4OpticalTrackerAction")
0116         filt = DDG4.Filter(
0117             kernel, "ParticleSelectFilter/OpticalPhotonSelector"
0118         )
0119         filt.particle = "opticalphoton"
0120         seq.adopt(filt)
0121 
0122     elif mode == "baseline":
0123         # Need a tracker so DD4hep is happy, but no photons will arrive
0124         geant4.setupTracker("DRICH")
0125 
0126     elif mode == "gpu":
0127         # Opticks env vars for hit gathering
0128         os.environ.setdefault("OPTICKS_EVENT_MODE", "Minimal")
0129         os.environ.setdefault("OPTICKS_INTEGRATION_MODE", "1")
0130 
0131         # Block CPU G4Step hits; only GPU-injected hits pass
0132         seq, act = geant4.setupTracker("DRICH")
0133         filt = DDG4.Filter(kernel, "EnergyDepositMinimumCut/Block")
0134         filt.Cut = 1e12
0135         seq.adopt(filt)
0136 
0137         # eic-opticks DDG4 plugins
0138         stepping = DDG4.SteppingAction(
0139             kernel, "OpticsSteppingAction/OpticsStep1"
0140         )
0141         stepping.Verbose = 0
0142         kernel.steppingAction().adopt(stepping)
0143 
0144         run_action = DDG4.RunAction(kernel, "OpticsRun/OpticsRun1")
0145         run_action.SaveGeometry = False
0146         kernel.runAction().adopt(run_action)
0147 
0148         evt_action = DDG4.EventAction(kernel, "OpticsEvent/OpticsEvt1")
0149         evt_action.Verbose = 1
0150         if photon_threshold > 0:
0151             evt_action.PhotonThreshold = photon_threshold
0152         kernel.eventAction().adopt(evt_action)
0153 
0154     # ---- configure & initialize -------------------------------------------
0155     kernel.NumEvents = num_events
0156     kernel.configure()
0157 
0158     # Disable photon stacking BEFORE initialize so G4Cerenkov reads it
0159     if mode in ("baseline", "gpu"):
0160         G4OpticalParameters.Instance().SetCerenkovStackPhotons(False)
0161         G4OpticalParameters.Instance().SetScintStackPhotons(False)
0162 
0163     kernel.initialize()
0164 
0165     # BoundaryInvokeSD AFTER initialize (runtime parameter)
0166     if mode == "cpu":
0167         G4OpticalParameters.Instance().SetBoundaryInvokeSD(True)
0168 
0169     # ---- run & time -------------------------------------------------------
0170     t0 = time.perf_counter()
0171     kernel.run()
0172     t1 = time.perf_counter()
0173     wall_s = t1 - t0
0174 
0175     kernel.terminate()
0176 
0177     result = {
0178         "mode": mode,
0179         "events": num_events,
0180         "wall_s": round(wall_s, 4),
0181         "per_event_ms": round(wall_s / num_events * 1000, 2),
0182         "photon_threshold": photon_threshold,
0183         "multiplicity": multiplicity,
0184         "geometry": geometry,
0185     }
0186     print(f"BENCHMARK_RESULT:{json.dumps(result)}", flush=True)
0187     return result
0188 
0189 
0190 # ---------------------------------------------------------------------------
0191 # "all" mode — runs each config as a subprocess, then computes speedup
0192 # ---------------------------------------------------------------------------
0193 
0194 def _run_subprocess(mode, num_events, photon_threshold=0, multiplicity=100,
0195                     geometry="1sector"):
0196     """Run a single mode in a child process, return (result_dict, raw_output)."""
0197     script = os.path.abspath(__file__)
0198     cmd = [
0199         sys.executable, script,
0200         "--mode", mode,
0201         "--events", str(num_events),
0202         "--photon-threshold", str(photon_threshold),
0203         "--multiplicity", str(multiplicity),
0204         "--geometry", geometry,
0205     ]
0206     proc = subprocess.run(
0207         cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
0208     )
0209     output = proc.stdout
0210 
0211     if proc.returncode != 0:
0212         print(f"ERROR: subprocess exited with code {proc.returncode}",
0213               file=sys.stderr)
0214         return None, output
0215 
0216     result = None
0217     for line in output.splitlines():
0218         if line.startswith("BENCHMARK_RESULT:"):
0219             result = json.loads(line[len("BENCHMARK_RESULT:"):])
0220             break
0221 
0222     return result, output
0223 
0224 
0225 def _parse_gpu_times(output):
0226     """Extract per-event GPU simulate() times from C++ info output."""
0227     times = []
0228     for line in output.splitlines():
0229         m = re.search(r"OPTICKS_GPU_TIME event=(\d+) ms=([\d.]+)", line)
0230         if m:
0231             times.append((int(m.group(1)), float(m.group(2))))
0232     return times
0233 
0234 
0235 def _parse_gpu_photons(output):
0236     """Extract total photon count from GPU output."""
0237     total = 0
0238     for line in output.splitlines():
0239         m = re.search(r"OPTICKS_GPU_TIME.*photons=(\d+)", line)
0240         if m:
0241             total += int(m.group(1))
0242     return total
0243 
0244 
0245 def run_all(num_events, photon_threshold=0, multiplicity=100, geometry="1sector"):
0246     """Run all three modes and print speedup analysis."""
0247     results = {}
0248     gpu_event_times = []
0249     gpu_total_photons = 0
0250 
0251     for mode in ("baseline", "cpu", "gpu"):
0252         pt = photon_threshold if mode == "gpu" else 0
0253         label = {
0254             "baseline": "baseline (no photon tracking)",
0255             "cpu": "cpu (photons tracked on CPU)",
0256             "gpu": "gpu (photons on GPU via eic-opticks)",
0257         }[mode]
0258         extra = f", threshold={pt}" if pt > 0 else ""
0259         print(f"\n{'='*60}")
0260         print(f"  {label}  --  {num_events} events{extra}")
0261         phi_str = "phi=-167 (sector 0)" if geometry == "1sector" else "phi=0"
0262         print(f"  {multiplicity} pi+ at 10 GeV, eta=2.0 (theta~15.4deg), {phi_str}")
0263         print(f"{'='*60}")
0264 
0265         result, output = _run_subprocess(mode, num_events, pt, multiplicity,
0266                                          geometry)
0267 
0268         if result is None:
0269             print(f"ERROR: mode={mode} failed. Output:\n{output[-3000:]}")
0270             sys.exit(1)
0271 
0272         results[mode] = result
0273 
0274         # Show selected output lines
0275         for line in output.splitlines():
0276             if line.startswith("BENCHMARK_RESULT:"):
0277                 continue
0278             if any(kw in line for kw in (
0279                 "OPTICKS_GPU_TIME", "gensteps", "hits from GPU",
0280                 "Detected photons",
0281             )):
0282                 print(f"  {line.strip()}")
0283 
0284         print(f"  Wall time: {result['wall_s']:.3f} s  "
0285               f"({result['per_event_ms']:.1f} ms/event)")
0286 
0287         if mode == "gpu":
0288             gpu_event_times = _parse_gpu_times(output)
0289             gpu_total_photons = _parse_gpu_photons(output)
0290 
0291     # ------------------------------------------------------------------
0292     # Summary
0293     # ------------------------------------------------------------------
0294     T_b = results["baseline"]["wall_s"]
0295     T_c = results["cpu"]["wall_s"]
0296     T_g = results["gpu"]["wall_s"]
0297     cpu_optical = T_c - T_b
0298     gpu_overhead = T_g - T_b
0299 
0300     photons_per_event = gpu_total_photons / num_events if num_events > 0 else 0
0301 
0302     print(f"\n{'='*60}")
0303     print(f"  RESULTS  ({num_events} events, {multiplicity} pi+ @ 10 GeV in dRICH)")
0304     print(f"{'='*60}")
0305     print(f"\n  {'Mode':<12} {'Total (s)':<12} {'Per event (ms)'}")
0306     print(f"  {'-'*40}")
0307     for m in ("baseline", "cpu", "gpu"):
0308         r = results[m]
0309         print(f"  {m:<12} {r['wall_s']:<12.3f} {r['per_event_ms']:.1f}")
0310 
0311     print(f"\n  CPU optical photon tracking: {cpu_optical:.3f} s"
0312           f"  ({cpu_optical / num_events * 1000:.1f} ms/event)")
0313     print(f"  GPU total overhead:          {gpu_overhead:.3f} s"
0314           f"  ({gpu_overhead / num_events * 1000:.1f} ms/event)")
0315 
0316     if gpu_total_photons > 0:
0317         print(f"\n  Total Cerenkov photons: {gpu_total_photons:,}"
0318               f"  (~{photons_per_event:,.0f}/event)")
0319 
0320     if gpu_event_times:
0321         all_ms = [t for _, t in gpu_event_times]
0322         total_ms = sum(all_ms)
0323         print(f"\n  GPU simulate() calls: {len(all_ms)}")
0324         # Show first 5 and last 2 if many events
0325         show = gpu_event_times
0326         if len(show) > 10:
0327             show = gpu_event_times[:5] + [("...", None)] + gpu_event_times[-2:]
0328         for item in show:
0329             if item[1] is None:
0330                 print(f"    ...")
0331             else:
0332                 evt_id, ms = item
0333                 tag = " (includes OptiX warmup)" if evt_id == 0 else ""
0334                 print(f"    event {evt_id:>3d}:  {ms:>8.1f} ms{tag}")
0335         print(f"    {'total':>9s}:  {total_ms:>8.1f} ms")
0336 
0337         if len(all_ms) > 1:
0338             warm_ms = all_ms[1:]
0339             avg_warm = sum(warm_ms) / len(warm_ms)
0340             print(f"    avg (excl. first): {avg_warm:>5.1f} ms")
0341 
0342         if total_ms > 0:
0343             speedup = cpu_optical / (total_ms / 1000)
0344             print(f"\n  >>> SPEEDUP (CPU optical / GPU simulate):  {speedup:.1f}x <<<")
0345 
0346         if len(all_ms) > 1 and avg_warm > 0:
0347             speedup_warm = (cpu_optical / num_events * 1000) / avg_warm
0348             print(f"  >>> SPEEDUP (per-event, excl. warmup):     {speedup_warm:.1f}x <<<")
0349 
0350     if gpu_overhead > 0:
0351         speedup_total = cpu_optical / gpu_overhead
0352         print(f"  >>> SPEEDUP (CPU optical / GPU overhead):   {speedup_total:.1f}x <<<")
0353 
0354     print(f"{'='*60}\n")
0355 
0356 
0357 # ---------------------------------------------------------------------------
0358 if __name__ == "__main__":
0359     parser = argparse.ArgumentParser(
0360         description="Benchmark GPU vs CPU optical photon speedup on EPIC dRICH"
0361     )
0362     parser.add_argument(
0363         "--mode",
0364         choices=["cpu", "baseline", "gpu", "all"],
0365         default="all",
0366         help="cpu=photons on CPU, baseline=no photon tracking, "
0367              "gpu=photons on GPU, all=run all three",
0368     )
0369     parser.add_argument(
0370         "--events", type=int, default=10,
0371         help="number of events (default: 10)",
0372     )
0373     parser.add_argument(
0374         "--photon-threshold", type=int, default=0,
0375         help="GPU batch mode: accumulate photons across events and simulate "
0376              "when this count is reached (default: 0 = per-event)",
0377     )
0378     parser.add_argument(
0379         "--multiplicity", type=int, default=100,
0380         help="number of pi+ per event (default: 100)",
0381     )
0382     parser.add_argument(
0383         "--geometry", choices=["1sector", "full"], default="1sector",
0384         help="1sector=single dRICH sector (default), full=all 6 sectors",
0385     )
0386     args = parser.parse_args()
0387 
0388     if args.mode == "all":
0389         run_all(args.events, args.photon_threshold, args.multiplicity,
0390                 args.geometry)
0391     else:
0392         run_single_mode(args.mode, args.events, args.photon_threshold,
0393                         args.multiplicity, args.geometry)