File indexing completed on 2026-05-15 07:41:49
0001
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
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
0085
0086
0087
0088
0089 eta = 2.0
0090 theta = 2.0 * math.atan(math.exp(-eta))
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
0107 geant4.setupPhysics("QGSP_BERT")
0108 ph = DDG4.PhysicsList(kernel, "Geant4PhysicsList/OpticalPhys")
0109 ph.addPhysicsConstructor(str("G4OpticalPhysics"))
0110 kernel.physicsList().adopt(ph)
0111
0112
0113 if mode == "cpu":
0114
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
0124 geant4.setupTracker("DRICH")
0125
0126 elif mode == "gpu":
0127
0128 os.environ.setdefault("OPTICKS_EVENT_MODE", "Minimal")
0129 os.environ.setdefault("OPTICKS_INTEGRATION_MODE", "1")
0130
0131
0132 seq, act = geant4.setupTracker("DRICH")
0133 filt = DDG4.Filter(kernel, "EnergyDepositMinimumCut/Block")
0134 filt.Cut = 1e12
0135 seq.adopt(filt)
0136
0137
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
0155 kernel.NumEvents = num_events
0156 kernel.configure()
0157
0158
0159 if mode in ("baseline", "gpu"):
0160 G4OpticalParameters.Instance().SetCerenkovStackPhotons(False)
0161 G4OpticalParameters.Instance().SetScintStackPhotons(False)
0162
0163 kernel.initialize()
0164
0165
0166 if mode == "cpu":
0167 G4OpticalParameters.Instance().SetBoundaryInvokeSD(True)
0168
0169
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
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
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
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
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)