diff --git a/README.md b/README.md index d768a24..ff56114 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,144 @@ # open_rust_mc [![Latest release](https://img.shields.io/badge/release-v0.4.0-blue)](https://github.com/sorcerer86pt/open_rust_mc/releases/latest) +[![Tests](https://img.shields.io/badge/cargo_test-441%20%2F%20441-brightgreen)](#tests) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +A pure-Rust continuous-energy Monte Carlo radiation-transport engine — +neutron k-eigenvalue and fixed-source, photon transport, coupled +neutron-photon γ-heating, burnup depletion, time-dependent point +kinetics, and continuous-energy adjoint. It reads OpenMC HDF5 nuclear +data directly (no C dependency), runs on CPU (rayon) **and** the GPU, +and ships an interactive cross-section / 3-D scene viewer. + +

+ LEU-COMP-THERM-008 fuel-rod cluster, GPU ray-cast preview + PWR 17×17 assembly, GPU ray-cast preview +

+

+ Scene geometry rendered straight from the engine's own recursive CSG + by the built-in cross-vendor GPU ray-caster — left: an LCT-008 fuel-rod + cluster (water rendered translucent so the pin forest reads in 3-D); + right: a depth-3 recursive 17×17 PWR assembly. +

+ +## From a compression study to a full transport engine + +This project started as a **single research question**, written up in +[`paper/main.tex`](paper/main.tex): *can a truncated SVD of the +temperature-dependent cross-section grid replace the pointwise table a +Monte Carlo code reads at every collision, and is it faster?* Answering +that honestly meant building enough of a real MC engine around the SVD +provider to measure it against a credible baseline — not a toy. + +That scaffolding kept growing, and it became the actual artifact. Today +`open_rust_mc` is a genuine continuous-energy MC code: -A pure-Rust continuous-energy Monte Carlo neutron **and photon** -transport engine. Reads OpenMC HDF5 nuclear data directly (no C -dependency), runs k-eigenvalue, fixed-source, time-dependent -point-kinetics, and burnup simulations end-to-end on CPU (rayon) or -CUDA GPU. Validated against OpenMC on Godiva, PWR pin cell, -PWR-actinide depletion, and γ-heating; against ICSBEP handbook -values on a 375-case scene corpus. - -Started as an SVD cross-section compression study (`paper/main.tex`); -grew into a research vehicle for studying cross-section -representation, recursive geometry on GPU, and CIELO-era nuclear-data -evaluation effects across ENDF/B-VII.1 / VIII.0 / VIII.1. - -440 / 440 lib tests + 9 integration tests pass on every push -(`cargo test`); 447 / 447 with `--features cuda`. +- **Four interchangeable cross-section providers** behind one + `XsProvider` trait — pointwise `Table`, truncated `Svd`, and two + Windowed-Multipole hybrids — so SVD can be graded against the + industry-standard representations on the *same* geometry, in the same + run. +- **Recursive universe/lattice geometry** (rectangular + hexagonal), + per-universe BVH, validated against OpenMC on Godiva, a PWR pin cell, + a 17×17 assembly, and a 375-case ICSBEP corpus. +- **Coupled neutron–photon** transport with full condensed-history + electrons; **CRAM** depletion; **point kinetics**; **continuous-energy + adjoint** + random-ray FW-CADIS variance reduction. +- A **GPU backend** that runs the same physics, and a **cross-vendor GPU + scene renderer** (the images above) written once and compiled to + CUDA, ROCm, Vulkan, Metal, and WebGPU. + +The headline SVD result is in the paper and reproduced +[below](#the-svd-verdict-the-original-question) — and it's an honest one: +SVD wins on small fast-spectrum problems and **loses** on realistic +thermal pin cells. This engine is the measuring instrument that made +that statement defensible. + + +> **441 / 441** library tests + 9 integration tests pass on every push +> (`cargo test`); **447 / 447** with `--features cuda`. ## Highlights -- **Four interchangeable cross-section providers** behind one - `XsProvider` trait — pointwise Table, SVD, Hybrid SVD+WMP, and - Hybrid Table+WMP — selectable at runtime via `--mode`. A - three-way honesty-test mode runs all providers back-to-back on - the same geometry. -- **Recursive universe geometry** (`CoordStack`-based) with - rectangular + hexagonal lattices, per-cell `Mat3` rotations, and - per-universe BVH acceleration. Full 17×17 PWR assembly + N-ring - hex mini-cores ship as built-in benchmarks. -- **CUDA backend** for transport, photon kernels, random-ray, SVD - reconstruction, and constant-XS event-batched eigenvalue. Includes - PHYSOR-2022-style refill-pool with auto-recommendation from device - attributes. -- **Coupled neutron-photon** pipeline. PWR γ-heating runs end-to-end - in ~5 min with full electron condensed-history transport - (Bethe-Bloch + Highland MS + Seltzer-Berger brems). Result agrees - with OpenMC 0.15.3 fuel/gap/clad/water split within 1 pp. -- **Depletion** via CRAM-16 / CRAM-48 with on-the-fly chain-XS - spectrum collapse and fresh-corrector predictor. Xe equilibrium - matches analytical to 1e-4 relative. -- **Variance reduction**: forward weight windows + flux bootstrap + - full multigroup random-ray (Tramm 2018, immortal-ray - Tramm-Siegel 2021) feeding a measured FW-CADIS pipeline. 4.32× - FOM gain on 200 cm water at 1 MeV. -- **ENDF/B-VIII.1 default**, with VIII.0 and VII.1 supported as - fallbacks. Engine handles the sibling-`thermal/` layout split - transparently and ships an idempotent natural-element migration - script for benchmark JSONs. +- **Four cross-section providers, one trait.** Pointwise `Table`, rank-*k* + `Svd`, `HybridSvdWmp`, `HybridTableWmp` — selectable at runtime via + `--mode`. A three-way honesty test runs them back-to-back on identical + geometry. +- **Recursive geometry.** `CoordStack`-based universes, rectangular + + hexagonal lattices, per-cell `Mat3` rotation, per-universe BVH. Full + 17×17 PWR assembly and N-ring hex mini-cores ship as benchmarks. +- **Cross-vendor GPU, two ways.** A CUDA transport backend (event-batched, + recursive-CSG, sm_60+), **and** a new [CubeCL](https://github.com/tracel-ai/cubecl) + renderer that compiles one Rust kernel to CUDA / ROCm / Vulkan / Metal / + WebGPU — running the geometry walk in **f64** on any GPU, no vendor SDK. +- **Coupled neutron-photon.** PWR γ-heating runs end-to-end with + Bethe-Bloch + Highland MS + Seltzer-Berger bremsstrahlung electron + transport; fuel/gap/clad/water split agrees with OpenMC 0.15.3 within + 1 pp. +- **Depletion.** CRAM-16 / CRAM-48 with on-the-fly chain-XS spectrum + collapse and a fresh-corrector predictor; Xe equilibrium matches + analytic to 1e-4. +- **Variance reduction.** Forward weight windows + multigroup random-ray + (Tramm 2018, immortal-ray Tramm-Siegel 2021) feeding a measured + FW-CADIS pipeline — 4.32× FOM on 200 cm of water. +- **ENDF/B-VIII.1 by default**, with VIII.0 / VII.1 / JEFF-3.3 supported; + sibling-`thermal/` layout handled transparently; idempotent + natural-element migration for benchmark JSONs. - **Python (PyO3) front-end** — `Scene`, `Material`, `Surface`, - `run_eigenvalue`, `run_icsbep_case`, full XsMode / runner / - depletion / refill-pool plumbing. + `run_eigenvalue`, `run_icsbep_case`, full provider / runner / depletion + plumbing. + +## See the geometry + +The `preview_scene` binary renders any scene JSON — 2-D cross-section or +3-D — directly from the engine's recursive CSG. No meshing: every pixel +is an exact ray-cast (or cell lookup) through the same geometry the +transport kernels walk. + +

+ Godiva HEU sphere + PWR 17×17 assembly, 2-D cross-section + LCT-008 cluster, 2-D cross-section +

+

+ Left: Godiva bare-HEU sphere (3-D GPU ray-cast). Middle / right: 2-D + XY cross-sections of the PWR assembly and the LCT-008 cluster tank — + fuel pins, guide tubes, and moderator resolved per-pixel. +

+ +```powershell +# Cross-vendor GPU 3-D render (Vulkan / Metal / DX12 / CUDA / ROCm) — f64 +cargo run --release --bin preview_scene -- ` + bench/icsbep/leu-comp-therm-008_case-1.json data\endfb-viii.1-hdf5\neutron ` + --cubecl-out lct008.png --cam-azim 35 --cam-elev 30 --zoom 1.2 + +# 2-D cross-section to PNG (no GPU needed) +cargo run --release --bin preview_scene -- ` + bench/icsbep/pwr_assembly_17x17.json data\endfb-viii.1-hdf5\neutron ` + --png-out assembly_xy.png --resolution 1400 +``` ## Quick start ### Prerequisites - Rust stable (1.79+), `cargo`. -- (Optional) CUDA toolkit 12.x + NVRTC for the GPU backend. -- (Optional) Python 3.9+ + [`maturin`](https://www.maturin.rs/) for - the Python wheel. +- (Optional) CUDA toolkit 12.x + NVRTC for the CUDA transport backend. +- (Optional) Python 3.9+ + [`maturin`](https://www.maturin.rs/) for the + Python wheel. + +The cross-vendor GPU renderer needs **no** vendor SDK — it runs on +whatever Vulkan / Metal / DX12 adapter the OS provides. ### Build ```powershell git clone https://github.com/sorcerer86pt/open_rust_mc cd open_rust_mc/rust_prototype -cargo build --release # CPU only -cargo build --release --features cuda # + CUDA backend -cargo test --lib # 440 / 440 default +cargo build --release # CPU + cross-vendor GPU renderer +cargo build --release --features cuda # + CUDA transport backend +cargo test --lib # 441 / 441 cargo test --lib --features cuda # 447 / 447 ``` @@ -80,217 +150,216 @@ cargo test --lib --features cuda # 447 / 447 .\scripts\setup_nuclear_data.ps1 -Vii1 # legacy VII.1 only ``` -The script auto-flattens the wrapper directory the VIII.1 tarball -uses, and the verification block reports key-file presence per -library. - ### Run a benchmark -Godiva HEU sphere, end-to-end, SVD provider, k=5: +Godiva HEU sphere, end-to-end, SVD provider, rank-5: ```powershell cargo run --release --bin godiva -- data\endfb-viii.1-hdf5\neutron ` --rank 5 --batches 80 --inactive 15 --particles 10000 ``` -A single ICSBEP case via Python: +A single ICSBEP case via Python; full corpus sweep: ```powershell cd rust_prototype/bindings/python maturin develop --release --features cuda cd ../../.. python rust_prototype/bindings/python/examples/icsbep_run.py heu-met-fast-001_case-1 gpu +.\rust_prototype\bindings\python\examples\run_benchmark.ps1 # full corpus, auto GPU/CPU ``` -Full corpus sweep (auto-detects GPU vs CPU): - -```powershell -.\rust_prototype\bindings\python\examples\run_benchmark.ps1 -``` - -A scoped sweep with explicit CLI overrides (explicit flag wins over -the JSON's `recommended_settings`): - -```powershell -python rust_prototype/bindings/python/examples/icsbep_sweep.py ` - --runner cpu --filter heu-comp-inter-003 ` - --particles 100000 --batches 150 --inactive 40 --seeds 5 ` - --csv outputs/heu_comp_inter_003_v81.csv ` - --stop-file outputs/STOP -``` - -## Headline benchmarks - -All k_eff numbers carry a scope tag — `[godiva]` means end-to-end -3-nuclide Godiva sphere; `[pwr]` means 9-nuclide PWR pin cell; -`[assembly]` is depth-3 recursive 17×17. See `STATUS.md` for the -full table. - -Only numbers re-verified against `outputs/` and `results/` this -audit are shown here. See [`STATUS.md`](STATUS.md) for the full -audited table including older claims still awaiting re-measurement. +## Benchmarks + +All k_eff numbers carry a **scope tag** — `[godiva]` = 3-nuclide HEU +sphere, `[pwr]` = 9-nuclide PWR pin cell, `[assembly]` = depth-3 17×17, +`[icsbep]` = handbook regression. A `[micro]` headline routinely shrinks +or inverts under `[pwr]`/`[assembly]`; the tags keep that honest. See +[`STATUS.md`](STATUS.md) for the fully audited table. + +### Validation against OpenMC and ICSBEP + +

+ PWR pin cell four-way k-infinity vs OpenMC +

+

+ PWR pin-cell four-way honesty test: all four providers land inside + OpenMC 0.15.3's band; SVD r=5 and Hybrid SVD+WMP sit within 1σ of the + ACE+WMP industry baseline. [pwr] +

+ +

+ ICSBEP k_calc vs handbook reference +

+

+ ICSBEP sweep: engine k_calc vs handbook k_ref across fast / epithermal + / thermal cases, with the ±150 pcm acceptance band. [icsbep] +

+ +### The SVD verdict (the original question) + +

+ SVD singular spectrum and per-reaction rank +

+

+ Why SVD is even plausible: the temperature-dependent σ(E,T) grid is + extremely low-rank — 43 of 47 non-redundant 235U reactions + are rank-1 to machine precision, and the singular values fall off a + cliff. (For these nuclides the reconstruction saturates by + rank 15: k_eff is bit-identical at rank 15 and rank 60.) +

+ +

+ Godiva throughput CPU vs GPU + PWR throughput across providers +

+

+ Throughput (ns/particle, lower is better). On a 3-nuclide fast + spectrum SVD edges out the table; on a 9-nuclide thermal pin cell with + S(α,β) it loses — the reconstruction cost is paid on every collision + and there are far more of them. +

+ +

+ In-engine cross-section memory by provider +

+

+ Working-set memory at a single temperature: here the + rank-5 SVD basis is ~5× the pointwise table. But this is SVD's + worst framing — see below. +

+ +**Where SVD actually pays for itself: memory across temperatures, and +GPU residency.** The bar chart above is single-temperature, which is +exactly where a pointwise table is cheapest. The picture flips for the +things SVD was built for: + +- **Off-table temperature reconstruction.** A pointwise representation + stores a *full energy grid per temperature* — and library sets run + {294, 600, 900, 1200, 2500} K, with Doppler feedback and depletion + wanting points *between* them. SVD stores **one** rank-*k* basis + `B = UₖΣₖ` plus a tiny `N_T×k` factor, and reconstructs *any* + temperature on the fly via the Ducru weight kernel (exact at the + library points). Its footprint is near-flat in the number of + temperatures while the table's grows linearly — and in the off-library + case the table must load **two** columns per nuclide and stochastically + interpolate per collision. That's the regime the paper identifies as + where SVD's single-basis reconstruction pays off + (`paper/sections/memory_vs_precision.tex`). +- **GPU memory mapping & cache residency.** The factored form is small, + contiguous, and regular — it maps cleanly into GPU memory and stays + cache-resident, versus chasing large irregular pointwise tables with a + per-nuclide binary search per lookup. Cache-resident reconstruction + was this project's *original* thesis, and it's the regime where the + rank-*k* FMA sequence beats the table's memory-latency-bound lookup. + +So the throughput story is spectrum-dependent (win on fast, lose on +thermal-with-S(α,β)), but the *memory* story favours SVD precisely when +it matters: many temperatures, and GPU. | Metric | Scope | Value | Source | |---|---|---|---| -| Lib tests (default) | — | **440 / 440 green** | `cargo test --lib` | -| Lib tests (`--features cuda`) | — | **447 / 447 green** | `cargo test --lib --features cuda` | -| ICSBEP family suite | `[icsbep]` | **6 / 6 PASS** (141 s, `max(150 pcm, 2σ)`) | `outputs/cuda_runs_after_rank_fix.txt` | -| PWR γ-heating fuel/clad/water | `[photon]` | 84.12 % / 9.81 % / 5.72 % (gap 0 %) | `outputs/pwr_gamma_heating_benchmark.txt` | -| Saturation knee (HMF-001, RTX 3080) | `[godiva]` | 500k–1M particles per batch | `outputs/saturation_*.csv` | -| Peak throughput at saturation | `[godiva]` | ~1.2 M histories/sec at 1M particles | `outputs/saturation_1000000.csv` | -| Refill 2× at mid-curve (HMF-008) | `[micro]` | 2.0× more collisions at same wall; σ 2.1× tighter; µs/coll −50 % | `outputs/hmf008_refill_*.csv` | -| RR-CADIS shielding gain at 14 mfp (200 cm water) | `[shield]` | **1.18×** analog (RR-CADIS alone); **1.75×** with NEE | `outputs/method_comparison_2026-05-08.txt` | -| SVD on Godiva vs Table | `[godiva]` | 1.22× faster; 5.14× memory | same | -| SVD on PWR (9 nuc + S(α,β)) vs Table | `[pwr]` | **1.25× *slower***; 5.12× memory | same | -| GPU SVD vs 20-core CPU SVD on Godiva | `[godiva]` | **0.77×** (1.3× *slower* — launch + memory penalty dominates) | same | - -The headline result of the SVD compression study (the engine's -original purpose) is in `paper/main.pdf`: SVD beats the pointwise -table on small fast-spectrum problems by ~22 %, loses by ~25 % on -realistic thermal-spectrum PWR pin cells, and costs ~5× memory in -both regimes. The honest reading is in the paper — this engine is -also a *vehicle* for measuring SVD against realistic baselines, -not a claim that SVD is universally faster. +| Lib tests (default / `cuda`) | — | **441 / 441** · **447 / 447** | `cargo test --lib` | +| ICSBEP family suite (CPU + CUDA) | `[icsbep]` | **6 / 6 PASS** under `max(150 pcm, 2σ)` | `outputs/cuda_runs_after_rank_fix.txt` | +| SVD vs Table on Godiva | `[godiva]` | **1.22× faster**; 5.14× memory @ single-T | `paper/`, `outputs/pareto/` | +| SVD vs Table on PWR pin cell | `[pwr]` | **1.25× *slower***; 5.12× memory @ single-T | same | +| SVD memory vs N-temperature table | `[micro]` | near-flat in N_T vs linear — SVD wins for many T | `paper/sections/memory_vs_precision.tex` | +| PWR γ-heating split vs OpenMC | `[photon]` | fuel 84.1 / clad 9.8 / water 5.7 % (gap 0) | `outputs/pwr_gamma_heating_benchmark.txt` | +| RR-CADIS + NEE FOM at 14 mfp (200 cm water) | `[shield]` | **1.75× analog** | `outputs/method_comparison_2026-05-08.txt` | +| Peak GPU throughput (Godiva, RTX 3080) | `[godiva]` | ~1.2 M histories/s at 1M particles | `outputs/saturation_1000000.csv` | + +**The honest reading** ([`paper/main.pdf`](paper/main.pdf)): on raw +per-collision throughput, SVD beats the pointwise table by ~22 % on small +fast-spectrum problems and loses by ~25 % on realistic thermal PWR pin +cells. At a single temperature it costs ~5× memory — but it reconstructs +*any* temperature from one basis (flat memory in N_T where a table is +linear) and maps far better into GPU/cache memory than irregular +pointwise tables. The engine exists to make all of that *measurable*, not +to sell one answer. ## Architecture -The engine is structured around one common particle-transport loop -that runs against any geometry, any cross-section provider, and any -backend: +One particle-transport loop runs against any geometry, any cross-section +provider, and either backend: ``` ┌──────────────────────────────────────────────────────────────┐ -│ Scene JSON │ -│ └─> material_resolve ─> XsProvider │ -│ (natural-element ┌── Table (pointwise) │ -│ expansion, thermal ├── Svd (rank-k FMA) │ -│ binding, kernel ├── HybridTableWmp │ -│ dedup) └── HybridSvdWmp │ -│ │ -│ └─> Geometry ─> Surface / Cell / Region / Universe │ -│ BVH (per universe, ≥8 cells) │ -│ │ -│ └─> EigenvalueRunner ─> CpuRunner (rayon, history) │ -│ CudaRunner (sm_86+, event-batched)│ +│ Scene JSON │ +│ └─> material_resolve ─> XsProvider │ +│ (natural-element ├── Table (pointwise) │ +│ expansion, thermal ├── Svd (rank-k FMA) │ +│ binding, kernel ├── HybridTableWmp │ +│ dedup) └── HybridSvdWmp │ +│ │ +│ └─> Geometry ─> Surface / Cell / Region / Universe │ +│ recursive CoordStack + per-universe BVH │ +│ │ │ +│ ├─> EigenvalueRunner ─> CpuRunner (rayon, history) │ +│ │ CudaRunner (sm_60+, event) │ +│ │ │ +│ └─> geometry::flat ─> CubeCL renderer (CUDA/ROCm/ │ +│ (shared SoA) Vulkan/Metal/WebGPU, f64) │ └──────────────────────────────────────────────────────────────┘ ``` Key modules under `rust_prototype/src/`: -- `data_paths.rs` — layout-aware ENDF/B HDF5 path helpers - (sibling-`thermal/` resolver, multi-library discovery). -- `kernel.rs`, `decompose.rs`, `cp_decompose.rs` — SVD - reconstruction + decomposition. -- `hdf5_reader.rs`, `thermal.rs` — pure-Rust HDF5 + S(α,β). +- `kernel.rs`, `decompose.rs`, `cp_decompose.rs` — SVD reconstruction + + decomposition. - `table.rs`, `wmp.rs` — pointwise + Windowed Multipole providers. -- `geometry/` — recursive universes, BVH, hex/rect lattices. -- `physics/` — collision, scatter, kinematics. -- `transport/` — `simulate`, `dispatch`, `material_resolve`, - `xs_provider`, `hybrid_xs`, `urr_equivalence`, `weight_window`, - `tally`, `statepoint`, `kinetics`, `adjoint_neutron`, - `adjoint_photon`. -- `photon/` — Compton, Rayleigh, photoelectric, pair, brems, - electron, transport. -- `random_ray/` — multigroup TRRM (forward + adjoint + immortal), - CADIS, adjoint-SVD. -- `depletion/` — CRAM, chain, predictor-corrector, mapping, flux. -- `bin/` — 42 binaries; notable diagnostics include - `u235_inelastic_audit` (per-MT σ SVD vs Table) and `inspect_mt91` - (MT=91 law-61 / KalbachMann data inspector). +- `hdf5_reader.rs`, `thermal.rs` — pure-Rust HDF5 + S(α,β). +- `geometry/` — recursive universes, BVH, hex/rect lattices, and + `flat.rs` (backend-agnostic Geometry → SoA flattening, shared by both + GPU paths). +- `physics/`, `transport/` — collision, scatter, kinematics, `simulate`, + `dispatch`, `xs_provider`, `hybrid_xs`, weight windows, kinetics, + adjoint. +- `photon/` — Compton, Rayleigh, photoelectric, pair, brems, electron. +- `random_ray/` — multigroup TRRM (forward + adjoint + immortal), CADIS. +- `depletion/` — CRAM, chain, predictor-corrector. +- `gpu_recursive.rs`, `gpu_transport.rs` — CUDA transport backend. +- `gpu_render.rs` — **CubeCL cross-vendor geometry renderer**. ## Cross-section providers All four implement `XsProvider`; selectable at runtime via `--mode`. -| Mode | Provider | Implementation | What it does | -|------|----------|---------------|--------------| -| `table` | Pointwise table | `src/table.rs` | OpenMC-style binary search + log-log interpolation per reaction | -| `svd` | Truncated SVD | `src/kernel.rs` | Rank-*k* reconstruction, one FMA sequence per lookup | -| `hybrid_svd_wmp` | SVD + WMP | `src/transport/hybrid_xs.rs` | SVD everywhere, WMP override (pole/residue + Faddeeva) inside the RRR | -| `hybrid_table_wmp` | Table + WMP | `src/transport/hybrid_xs.rs` | Pointwise everywhere, WMP in the RRR — industry low-memory baseline | - -Off-library temperatures use stochastic pseudo-interpolation -(Table / Hybrid) or partition-of-unity 3-point Ducru reconstruction -(SVD). Detailed numerical results live in `paper/main.tex`. - -## Photon physics - -Full four-channel transport on per-element OpenMC HDF5 photon -libraries: - -- **Compton** — Klein-Nishina + `S(x,Z)/Z` bound-electron rejection - + optional Hartree-Fock Doppler broadening. -- **Photoelectric** — subshell absorption + EADL atomic-relaxation - cascade (fluorescence + Auger). -- **Pair production** — Bethe-Heitler nuclear + electron-field + - in-flight positron annihilation. -- **Rayleigh** — form-factor + Thomson rejection. - -Plus condensed-history electron transport (Bethe-Bloch dE/dx with -per-element I, Highland multiple-scattering with per-cell X₀, -Seltzer-Berger bremsstrahlung secondaries banked back into the -photon loop). The He-gap deposition artefact present in the -older Katz-Penfold CSDA path goes from 1.5 % to 0 %. - -Coupled neutron-photon: the neutron loop tallies a -`PhotonSourceEvent { cell, pos, E_γ, MT }` at every capture -(MT=102), fission (MT=18), (n,p) / (n,α) (MT=103 / 107, -threshold-gated), and inelastic (MT=4 via discrete-level Q-value) -collision. γ multiplicities and outgoing energies come from -the HDF5 `reactions/reaction_{mt}/product_{N}` tree with -`particle="photon"`. - -## Depletion (burnup) - -- **CRAM-16 / CRAM-48** matrix-exponential evaluator (IPF form, - Pusa 2016 poles + residues from OpenMC's canonical source) with - dense complex Gaussian elimination + partial pivoting. -- `DepletionChain` — decay constants, branches, per-(parent, MT) - one-group reaction XS with default ENDF yield inference for - (n,γ) / (n,2n) / (n,3n) / (n,p) / (n,α). -- `chain_io` JSON loader with three-way `yields` semantics: - omitted → default ENDF, `{}` → pure removal, explicit map → use it. -- **CE/LI predictor-corrector** with **fresh-corrector**: clones - materials, runs a second eigenvalue at the predicted composition - for the EOC flux, then CRAM with the averaged matrix. -- **On-the-fly chain-XS spectrum collapse** — closes the - 9× to 0.77× gap to OpenMC depletion. -- Shipped chains: `chains/partial_xe.json` (4-nuclide Xe poisoning) - and `chains/pwr_actinides.json` (17-nuclide actinide buildup + - dominant FP poisons). - -## CUDA backend - -`--features cuda` enables an Ampere-class (sm_86+) backend that -shares the same physics as CPU: - -- Recursive cell-find / trace-step / multi-step walk — - bit-exact vs CPU (≤ 9.3e-11 max-rel-err), 3-24× speedups on - RTX A1000. -- Constant-XS transport with atomic-add fission banking (6.74×). -- Multi-slot S(α,β) — concurrent TSLs on multiple nuclides in one - run. Stochastic temperature interpolation between bracketing - slots based on per-cell kT. -- Per-nuclide kernel cache (`Arc::as_ptr`-keyed, LRU + bundle - budget) eliminates redundant HtoD on multi-case sweeps. -- Refill pool (PHYSOR 2022 Optimization F) — opt-in via - `gpu_refill_pool_factor` or auto-recommended via device-attribute - inspection. 2× histories at same wall time on mid-curve - workloads. -- HexLattice device functions ported; runtime parity test pending. - -GPU bench drivers: `gpu_bench`, `gpu_godiva_keff`, -`gpu_const_xs_keff`, `gpu_assembly_keff`, `gpu_pwr_bench`, -`gpu_hex_minicore`, `gpu_recursive_parity`, `gpu_photon_features`, -`gpu_wmp_validate`. +| Mode | Provider | What it does | +|------|----------|--------------| +| `table` | Pointwise | OpenMC-style binary search + log-log interpolation per reaction | +| `svd` | Truncated SVD | Rank-*k* reconstruction, one FMA sequence per lookup | +| `hybrid_svd_wmp` | SVD + WMP | SVD everywhere, WMP (pole/residue + Faddeeva) inside the RRR | +| `hybrid_table_wmp` | Table + WMP | Pointwise everywhere, WMP in the RRR — industry low-memory baseline | + +Off-library temperatures use stochastic pseudo-interpolation (Table / +Hybrid) or partition-of-unity 3-point Ducru reconstruction (SVD). + +## GPU: two backends + +**CUDA transport (`--features cuda`, sm_60+).** Recursive cell-find / +trace-step bit-exact vs CPU (≤ 9.3e-11 rel-err), constant-XS event-batched +eigenvalue, multi-slot S(α,β), per-nuclide kernel cache, PHYSOR-2022 +refill pool. Used by `CudaRunner` for production sweeps. + +**CubeCL geometry renderer (always on).** The `preview_scene --cubecl-out` +path ray-casts the recursive CSG in a single CubeCL `#[cube]` kernel that +compiles to **CUDA, HIP/ROCm, Vulkan, Metal, and WebGPU** — one Rust +source, every GPU. It runs in **f64** (verified on the wgpu/Vulkan +backend wherever the adapter exposes `SHADER_F64`), so the render matches +the CPU/CUDA geometry walk exactly, with analytic surface normals and +front-to-back alpha compositing for translucent moderator fluids. The SoA +geometry layout (`geometry::flat`) is shared with the CUDA upload path so +the device representation has one source of truth. + +> CubeCL is also the migration target for the transport kernels: porting +> the hand-written `gpu/cuda/*.cu` to CubeCL would collapse the CUDA-only +> backend into the same write-once-run-anywhere path (NVIDIA + AMD + +> Vulkan), in f64. That work is in progress. ## Python bindings ```python -from open_rust_mc import ( - Scene, Material, Sphere, Settings, Runner, - run_eigenvalue, run_icsbep_case, -) +from open_rust_mc import Scene, Material, Sphere, Settings, run_eigenvalue fuel = (Material("HEU", temperature=294.0) .add_nuclide("U234.h5", atom_density=0.000483, awr=232.029, nubar=2.49) @@ -307,45 +376,21 @@ result = run_eigenvalue(scene, Settings(batches=50, inactive=10, particles=5000) print(result.k_eff, result.k_sigma) ``` -Full Scene builder + Material API documented in `PYTHON.md`. ICSBEP -harness lives at `rust_prototype/bindings/python/examples/`. +Full Scene builder + Material API in [`PYTHON.md`](PYTHON.md). ## Documentation -- [`CLAUDE.md`](CLAUDE.md) — engineer-facing project memory: hard - invariants, file layout, build commands, recent session changes. -- [`STATUS.md`](STATUS.md) — current state: capabilities, headline - numbers, open work, A/B against handbook references. +- [`CLAUDE.md`](CLAUDE.md) — engineer-facing project memory: invariants, + layout, build commands. +- [`STATUS.md`](STATUS.md) — current capabilities, audited headline + numbers, open work. - [`PYTHON.md`](PYTHON.md) — Python API reference. -- [`BENCHMARKS.md`](BENCHMARKS.md) — bench-suite layout + scene-JSON - schema. +- [`BENCHMARKS.md`](BENCHMARKS.md) — bench-suite layout + scene-JSON schema. - [`ICSBEP.md`](ICSBEP.md) — phased ICSBEP rollout plan. -- [`paper/main.tex`](paper/main.tex) — SVD cross-section - compression paper (compiled to `paper/main.pdf`). - -## Recent (May 2026) - -- **Eigenvalue progress bar** (`indicatif`) — live per-batch k_eff + - σ in the terminal; per-case and per-seed banners in sweep harness. -- **GPU MT=91 Law 61 (KalbachMann) mu coupling** — inelastic - outgoing-angle correlation ported to CUDA path, bit-tested vs CPU. -- **GPU survival biasing** — kernel wired; Python - `PySettings.survival_biasing` toggle exposed in sweep harness. -- In-process benchmark pipeline rolled back; subprocess-per-case - Python driver (`icsbep_sweep.py`) restored to isolate per-case - GPU-state corruption. -- ENDF/B-VIII.1 became the default library; sibling-`thermal/` - layout handled transparently. -- Natural-element migration on the ICSBEP corpus — 137 cases - rewritten to per-isotope carbon. Engine-side fallback covers - un-migrated future JSONs. -- Sweep precedence inverted: explicit CLI flag > JSON - `recommended_settings` > built-in default. -- VII.1 → VIII.1 A/B on heu-comp-inter-003: VIII.1 shifts k upward - by +820 pcm uniformly; localised driver is CIELO U-235 - (σ_f +5.3 %, α −10.2 % at 100 eV). +- [`paper/main.tex`](paper/main.tex) — the SVD cross-section compression + paper (compiled to `paper/main.pdf`). ## License -MIT. See `LICENSE` (every source file carries +MIT. See [`LICENSE`](LICENSE) (every source file carries `SPDX-License-Identifier: MIT`). diff --git a/docs/cubecl_1336_comment.md b/docs/cubecl_1336_comment.md new file mode 100644 index 0000000..2a6636b --- /dev/null +++ b/docs/cubecl_1336_comment.md @@ -0,0 +1,62 @@ +I am hitting this on **NVIDIA / Vulkan (Windows 11)** too, with one difference worth noting: here it does not race silently like on AMD/RADV — it **hard-crashes at dispatch** with `STATUS_ACCESS_VIOLATION` (exit `0xC0000005`). I think it's the same cause (per-thread private `Array` going wrong under register pressure), but with a worse symptom. + +### Setup +- cubecl `=0.10.0` from crates.io, `wgpu` runtime, Vulkan backend +- NVIDIA GPU, Windows 11, f64 enabled (adapter has `SHADER_F64`) + +### What happens +A kernel with a lot of private `Array` state compiles fine — `CUBECL_DEBUG_LOG` prints a full WGSL shader ending in `[END_KERNEL_COMPILATION]` — but the process dies at the first dispatch: + +``` +exit code: 0xC0000005 (STATUS_ACCESS_VIOLATION) +``` + +No nagging or validation error first. The crash is in driver code during dispatch part. + +### What I tested +I split each piece into its own kernel on the same machine: + +| Kernel | Result | +|---|---| +| PCG RNG in `u64` + `Array>.fetch_add` | works | +| one big geometry helper (`find_cell`, 9 `&mut Array` params, depth-4 loop) | works | +| two levels of `#[cube]` helper calls | works | +| a 27-parameter `#[cube]` helper, called once | works | +| 8 × `Array::new(64)` (4 KB private), write then sum | works (no race, no crash) | +| **full kernel: `find_cell` + `trace_step` + per-event loop, all those arrays live at once** | crashes at dispatch, even at 4 threads × 4 steps | + +So on my machine raw byte count alone does not trigger it (the 4 KB kernel is fine), and neither does nesting or param count on their own. It only fails when a lot of mixed-type private `Array` state is live across a long control-flow body — which fits the reported suspected cause (SPIR-V moving `Function`-storage variables out of real thread-private storage under pressure): the bad address then faults on NVIDIA where it just races on RADV. + +### Reproducer shape +The smallest case that still crashes for me is a recursive-geometry Monte Carlo simulation step: one `#[cube(launch)]` entry calling two `#[cube]` helpers that share nine `Array::new(4)` stacks plus a 16-entry region stack, all f64/i32: + +```rust +#[cube] +fn cell_contains(/* … */) -> u32 { + let mut stack = Array::::new(16usize); + // postfix CSG stack machine +} + +#[cube] +fn find_cell(/* … */, st_cell: &mut Array, st_offx: &mut Array, /* 9 stacks */) -> u32 { + // depth-4 descent, calls cell_contains +} + +#[cube] +fn trace_step(/* … */, st_cell: &mut Array, /* same 9 stacks */, out: &mut Array) { + // nearest-surface scan, calls surf_dist/grid_dist +} + +#[cube(launch)] +fn transport(/* buffers */, atomics: &mut Array>) { + let mut st_cell = Array::::new(4usize); + let mut st_offx = Array::::new(4usize); + // 7 more length-4 stacks, a length-2 u64 RNG, a length-3 scratch + let depth = find_cell(/* … */, &mut st_cell, &mut st_offx, /* … */); + // per-step loop: trace_step + collision sampling + atomics.fetch_add(…) + // → crashes at dispatch; the WGSL it generates looks fine. +} +``` + +Glad to attach a trimmed standalone code example if that helps. + diff --git a/docs/images/bench_icsbep_kcalc.png b/docs/images/bench_icsbep_kcalc.png new file mode 100644 index 0000000..f5b7146 Binary files /dev/null and b/docs/images/bench_icsbep_kcalc.png differ diff --git a/docs/images/bench_memory.png b/docs/images/bench_memory.png new file mode 100644 index 0000000..51facd2 Binary files /dev/null and b/docs/images/bench_memory.png differ diff --git a/docs/images/bench_pwr_four_way.png b/docs/images/bench_pwr_four_way.png new file mode 100644 index 0000000..a6abb6a Binary files /dev/null and b/docs/images/bench_pwr_four_way.png differ diff --git a/docs/images/bench_svd_spectrum.png b/docs/images/bench_svd_spectrum.png new file mode 100644 index 0000000..4ff12e5 Binary files /dev/null and b/docs/images/bench_svd_spectrum.png differ diff --git a/docs/images/bench_throughput_godiva.png b/docs/images/bench_throughput_godiva.png new file mode 100644 index 0000000..3a5bafb Binary files /dev/null and b/docs/images/bench_throughput_godiva.png differ diff --git a/docs/images/bench_throughput_pwr.png b/docs/images/bench_throughput_pwr.png new file mode 100644 index 0000000..1cd7d38 Binary files /dev/null and b/docs/images/bench_throughput_pwr.png differ diff --git a/docs/images/cubecl_godiva.png b/docs/images/cubecl_godiva.png new file mode 100644 index 0000000..a526203 Binary files /dev/null and b/docs/images/cubecl_godiva.png differ diff --git a/docs/images/cubecl_lct008.png b/docs/images/cubecl_lct008.png new file mode 100644 index 0000000..bb03f07 Binary files /dev/null and b/docs/images/cubecl_lct008.png differ diff --git a/docs/images/cubecl_pwr_assembly.png b/docs/images/cubecl_pwr_assembly.png new file mode 100644 index 0000000..6695862 Binary files /dev/null and b/docs/images/cubecl_pwr_assembly.png differ diff --git a/docs/images/lct008_2d.png b/docs/images/lct008_2d.png new file mode 100644 index 0000000..e6750a6 Binary files /dev/null and b/docs/images/lct008_2d.png differ diff --git a/docs/images/pwr_assembly_2d.png b/docs/images/pwr_assembly_2d.png new file mode 100644 index 0000000..3ea36ab Binary files /dev/null and b/docs/images/pwr_assembly_2d.png differ diff --git a/rust_prototype/Cargo.lock b/rust_prototype/Cargo.lock index e12494f..b3e1460 100644 --- a/rust_prototype/Cargo.lock +++ b/rust_prototype/Cargo.lock @@ -18,6 +18,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -202,6 +211,18 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -244,12 +265,47 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -291,6 +347,9 @@ name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "blake3" @@ -409,6 +468,15 @@ dependencies = [ "wayland-client 0.31.14", ] +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + [[package]] name = "cast" version = "0.3.0" @@ -436,6 +504,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -448,6 +525,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -487,6 +575,17 @@ dependencies = [ "half", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.6.0" @@ -568,6 +667,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "comrak" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" +dependencies = [ + "caseless", + "entities", + "memchr", + "slug", + "typed-arena", + "unicode_categories", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -596,6 +709,21 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constcat" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -865,11 +993,332 @@ dependencies = [ "phf", ] +[[package]] +name = "cubecl" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd203fef6e359472e4cb8f6c0ef3eca062815a1edd560eb6d6c1d5c1397838d9" +dependencies = [ + "cubecl-core", + "cubecl-cpu", + "cubecl-cuda", + "cubecl-hip", + "cubecl-ir", + "cubecl-runtime", + "cubecl-std", + "cubecl-wgpu", + "half", +] + +[[package]] +name = "cubecl-common" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc956c2dcc993f16f748d03bfdbd900f75cab4d469f4e5d8924f8ee128e861ad" +dependencies = [ + "backtrace", + "bytemuck", + "cfg-if", + "cfg_aliases", + "ciborium", + "derive-new", + "derive_more", + "dirs", + "embassy-futures", + "embassy-time", + "float4", + "float8", + "futures-lite", + "half", + "hashbrown 0.16.1", + "log", + "num-traits", + "oneshot", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "rand 0.10.1", + "sanitize-filename", + "serde", + "serde_bytes", + "serde_json", + "spin", + "toml 1.1.2+spec-1.1.0", + "tynm", + "wasm-bindgen-futures", + "web-time", + "xxhash-rust", +] + +[[package]] +name = "cubecl-core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7522e7acf25d7848032c7b5270780f9f2abe84e13c7ed6345aa27ba70c312ca" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "cubecl-common", + "cubecl-ir", + "cubecl-macros", + "cubecl-runtime", + "cubecl-zspace", + "derive-new", + "derive_more", + "enumset", + "float-ord", + "half", + "hashbrown 0.16.1", + "log", + "num-traits", + "paste", + "serde", + "serde_json", + "variadics_please", +] + +[[package]] +name = "cubecl-cpp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411af04828cbf4583ecd388579fb30cd7a644eec19a334bb8f0d9d08522e1458" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "derive-new", + "half", + "itertools 0.14.0", + "log", +] + +[[package]] +name = "cubecl-cpu" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f572143f54e42a49ee0635a4ec36005f639e62be029e4f49890c808985981b35" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "cubecl-std", + "derive-new", + "half", + "log", + "paste", + "serde", + "sysinfo 0.38.4", + "tracel-llvm", + "tracel-llvm-bundler", +] + +[[package]] +name = "cubecl-cuda" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b0a69ff45688d322ad8e92c8bf645167b9ca490fa8fa087fc6adac8c5e46be" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-runtime", + "cudarc", + "derive-new", + "half", + "log", + "serde", +] + +[[package]] +name = "cubecl-hip" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b510a9348f06ecd56b32cf10eb6241639e927a87f5c09ec61cc7a7610d5a3" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-hip-sys", + "cubecl-runtime", + "derive-new", + "half", + "log", + "paste", + "serde", +] + +[[package]] +name = "cubecl-hip-sys" +version = "7.2.5321100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58887c6d217859c2261a868a3a6b66aa8f44ea2f4bfa51d0dbe4dcb0d1f5768d" +dependencies = [ + "libc", + "regex", +] + +[[package]] +name = "cubecl-ir" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d28a897a40c8ee6c57a8b8d76d8823ba2e97f4c2b83db9338f17a0752132d486" +dependencies = [ + "cubecl-common", + "cubecl-macros-internal", + "derive-new", + "derive_more", + "enumset", + "float-ord", + "fnv", + "foldhash 0.2.0", + "half", + "hashbrown 0.16.1", + "num-traits", + "portable-atomic", + "serde", + "variadics_please", +] + +[[package]] +name = "cubecl-macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aa6df563e8e6a0926d2e9eeacb968737940a0e1ae9be819f6a65a341006e12" +dependencies = [ + "cubecl-common", + "darling 0.23.0", + "derive-new", + "ident_case", + "inflections", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cubecl-macros-internal" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a515651a5a91e25d87f71f311f80a088e6cf815798b9922560a97636da6b8740" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cubecl-opt" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a599c6c3efefdcee8636994c90e58ef696c7efbed2d18b5b5ad8d5f29923abb" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-ir", + "float-ord", + "log", + "num", + "petgraph", + "smallvec", + "stable-vec", + "type-map", +] + +[[package]] +name = "cubecl-runtime" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68491bf5b3e997ae36bdc4e63b4ccd6d2f0e86b3b596a5d7a48d2b9e92622a0" +dependencies = [ + "ahash", + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-ir", + "cubecl-zspace", + "derive-new", + "derive_more", + "dirs", + "enumset", + "hashbrown 0.16.1", + "log", + "md5", + "serde", + "serde_json", + "spin", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "cubecl-std" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b391b584a4897683dd9fc0767f96337ac712b733126ce0f68a9ee8a2df073d2d" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "half", + "num-traits", + "paste", + "serde", + "spin", + "variadics_please", +] + +[[package]] +name = "cubecl-wgpu" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3663c6e1a86187172b2cee495b6b533e7d91a2cb37e26f421649e315fe4c3a" +dependencies = [ + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-core", + "cubecl-ir", + "cubecl-runtime", + "derive-new", + "derive_more", + "half", + "hashbrown 0.16.1", + "log", + "sanitize-filename", + "wasm-bindgen-futures", + "wgpu", +] + +[[package]] +name = "cubecl-zspace" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04054d95698afab785a4dda9f949e297831dd9e4cc6d036a93e6603f88e88b07" +dependencies = [ + "derive-new", + "serde", + "smallvec", +] + [[package]] name = "cudarc" -version = "0.19.4" +version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f071cd6a7b5d51607df76aa2d426aaabc7a74bc6bdb885b8afa63a880572ad9b" +checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" dependencies = [ "libloading 0.9.0", ] @@ -890,6 +1339,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" @@ -914,6 +1373,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -938,6 +1410,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -981,6 +1464,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1007,13 +1501,20 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1024,6 +1525,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1109,12 +1631,74 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1127,6 +1711,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", + "serde", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.2.2" @@ -1192,6 +1798,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faer" version = "0.22.6" @@ -1212,7 +1839,7 @@ dependencies = [ "num-traits", "private-gemm-x86", "pulp", - "rand", + "rand 0.8.5", "rand_distr", "rayon", "reborrow", @@ -1299,6 +1926,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1315,7 +1952,13 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" name = "fixedbitset" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" @@ -1327,6 +1970,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float4" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a5404bf31d22893d61cf24d4dda149d8e6b2ff07601c3cb3be651031f61a4ed" + +[[package]] +name = "float8" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" +dependencies = [ + "half", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1378,6 +2042,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fragile" version = "2.1.0" @@ -1435,6 +2108,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1642,8 +2328,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1653,9 +2341,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1667,10 +2357,17 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gl_generator" version = "0.14.0" @@ -1688,6 +2385,12 @@ version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.17.0" @@ -1743,6 +2446,25 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1753,6 +2475,7 @@ dependencies = [ "cfg-if", "crunchy", "num-traits", + "serde", "zerocopy", ] @@ -1774,7 +2497,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sysinfo", + "sysinfo 0.36.1", "system-configuration", "tempfile", "thiserror 2.0.18", @@ -1808,6 +2531,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -1855,6 +2580,105 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1879,6 +2703,88 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1891,6 +2797,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1925,6 +2852,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + [[package]] name = "instability" version = "0.3.12" @@ -1961,6 +2894,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2128,9 +3067,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2152,6 +3091,27 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", + "num_cpus", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -2200,6 +3160,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -2243,6 +3209,12 @@ dependencies = [ "hashbrown 0.17.0", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -2272,6 +3244,12 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.8.0" @@ -2420,7 +3398,7 @@ dependencies = [ "log", "num-traits", "once_cell", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror 2.0.18", "unicode-ident", @@ -2496,6 +3474,21 @@ dependencies = [ "nano-gemm-core", ] +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + [[package]] name = "ndarray" version = "0.16.1" @@ -2611,6 +3604,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "npyz" version = "0.8.4" @@ -2640,6 +3642,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2658,7 +3674,7 @@ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "bytemuck", "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -2687,6 +3703,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3036,6 +4074,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3046,7 +4093,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" [[package]] name = "oorandom" @@ -3062,6 +4115,7 @@ dependencies = [ "bytemuck", "clap", "criterion 0.8.2", + "cubecl", "cudarc", "dashmap", "faer", @@ -3083,7 +4137,7 @@ dependencies = [ "serde_json", "smallvec", "thiserror 2.0.18", - "toml", + "toml 0.8.23", "wgpu", "winit", ] @@ -3099,6 +4153,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.54" @@ -3162,6 +4222,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3240,6 +4306,18 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + [[package]] name = "phf" version = "0.11.3" @@ -3267,7 +4345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -3396,6 +4474,9 @@ name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -3406,6 +4487,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3614,6 +4704,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -3642,8 +4787,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -3653,7 +4819,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3665,6 +4841,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.4.3" @@ -3672,7 +4863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -3857,6 +5048,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -3892,6 +5094,61 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-mc-sim" version = "0.1.0" @@ -3903,12 +5160,24 @@ dependencies = [ "rayon", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3944,6 +5213,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3965,6 +5269,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4035,6 +5348,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4077,6 +5400,27 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4177,11 +5521,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -4227,6 +5584,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", + "portable-atomic", +] + [[package]] name = "spindle" version = "0.2.6" @@ -4249,6 +5616,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "stable-vec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -4288,6 +5667,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -4310,6 +5695,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sysctl" version = "0.6.0" @@ -4338,6 +5743,20 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -4355,8 +5774,19 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "core-foundation-sys", + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", "libc", + "xattr", ] [[package]] @@ -4394,7 +5824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", + "nom 7.1.3", "phf", "phf_codegen", ] @@ -4426,7 +5856,7 @@ dependencies = [ "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset", + "fixedbitset 0.4.2", "hex", "lazy_static", "libc", @@ -4551,6 +5981,16 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -4561,6 +6001,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -4589,6 +6044,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -4596,11 +6074,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -4627,7 +6120,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", @@ -4660,6 +6153,136 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracel-llvm" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "982535db9eb1a30ac0f2c50239a0eec3e5cf50993a88e92b04747bd2f4d365b2" +dependencies = [ + "tracel-mlir-rs", + "tracel-mlir-sys", +] + +[[package]] +name = "tracel-llvm-bundler" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c75b8e477cb8d49d907afab029ca74d48459f5b88c27bdb4c6cd6acb5e61977" +dependencies = [ + "anyhow", + "bytes", + "constcat", + "dirs", + "liblzma", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "tar", + "walkdir", +] + +[[package]] +name = "tracel-mlir-rs" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a478a35efd68d0ba73f747adfb7923b121c64e7f5be9cd8364ca1dcb772d5c" +dependencies = [ + "tracel-mlir-rs-macros", + "tracel-mlir-sys", +] + +[[package]] +name = "tracel-mlir-rs-macros" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94f36868c3b10b1825945223d99d106c73f4d249f063caa4651deeb9379344" +dependencies = [ + "comrak", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "tracel-llvm-bundler", + "tracel-tblgen-rs", + "unindent", +] + +[[package]] +name = "tracel-mlir-sys" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02f26d31af0c225a6d2e3d65d012fd6de848c9fc776897b152ee83b7d1bd15c4" +dependencies = [ + "tracel-llvm-bundler", +] + +[[package]] +name = "tracel-tblgen-rs" +version = "20.1.4-7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d2581070380418ccc33b500f3739e4d4869421fdb477fcea51ff97c6253a52" +dependencies = [ + "bindgen", + "cc", + "paste", + "thiserror 2.0.18", + "tracel-llvm-bundler", +] + [[package]] name = "tracing" version = "0.1.44" @@ -4709,12 +6332,42 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tynm" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21cdb0fc8f85c98b1ec812bc4cd69faf6c0fa2fc17d44ea3c2cdd38dc08e999" +dependencies = [ + "nom 8.0.0", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.19.0" @@ -4733,6 +6386,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4762,12 +6424,42 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "unindent" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -4792,6 +6484,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "version-compare" version = "0.1.1" @@ -4804,6 +6507,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "vtparse" version = "0.6.2" @@ -4823,6 +6532,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5138,6 +6856,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -5262,7 +6989,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", @@ -5947,6 +7674,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11-dl" version = "2.21.0" @@ -5979,6 +7712,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -6010,6 +7753,35 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -6030,6 +7802,66 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/rust_prototype/Cargo.toml b/rust_prototype/Cargo.toml index 50e978a..3f8c974 100644 --- a/rust_prototype/Cargo.toml +++ b/rust_prototype/Cargo.toml @@ -16,6 +16,14 @@ faer = "0.22" ndarray = "0.16" serde = { version = "1", features = ["derive"] } cudarc = { version = "0.19", features = ["cuda-version-from-build-system", "cublas", "nccl"] } +# CubeCL — write GPU kernels once in Rust, compile to CUDA (NVIDIA), +# HIP/ROCm (AMD), Vulkan/Metal/WebGPU (cross-vendor). The `wgpu` +# runtime is always on so the geometry preview renders on any GPU +# without a vendor SDK; the `cuda` runtime is additive for NVIDIA +# fast paths. Alpha crate — pinned exact. This is the migration +# target that replaces the hand-maintained gpu/cuda/*.cu kernels + +# cudarc (see the transport-port + cudarc-removal tasks). +cubecl = { version = "=0.10.0", features = ["wgpu"] } rayon = "1" clap = { version = "4", features = ["derive"] } thiserror = "2" @@ -63,6 +71,7 @@ thiserror = { workspace = true } toml = { workspace = true } dashmap = { workspace = true } raw-cpuid = { workspace = true } +cubecl = { workspace = true } cudarc = { workspace = true, optional = true } # Direct dep on minifb for the preview_scene binary's cursor-aware # event loop. The upstream `rust_mc_sim::preview::show_window` zooms @@ -132,7 +141,10 @@ indicatif = "0.17" [features] default = [] -cuda = ["cudarc"] +# `cuda` enables both the legacy cudarc/.cu backend AND CubeCL's CUDA +# runtime (so the same #[cube] kernels can compile to CUDA C, not just +# SPIR-V — this dodges the Vulkan-only private-Array bug cubecl#1336). +cuda = ["cudarc", "cubecl/cuda"] preview = ["rust-mc-sim/preview", "dep:minifb"] # In-terminal cross-section viewer for `preview_scene --tui`. Pulls in # ratatui (+ its re-exported crossterm). Independent of `preview` — the @@ -162,6 +174,10 @@ workspace = true # --- Binaries --- +[[bin]] +name = "cubecl_vs_cu_keff" +required-features = ["cuda"] + [[bin]] name = "gpu_photon_features" required-features = ["cuda"] diff --git a/rust_prototype/src/bin/cubecl_vs_cu_keff.rs b/rust_prototype/src/bin/cubecl_vs_cu_keff.rs new file mode 100644 index 0000000..76c1140 --- /dev/null +++ b/rust_prototype/src/bin/cubecl_vs_cu_keff.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +//! A/B/C const-XS k comparison: CPU reference vs legacy CUDA `.cu` +//! (`GpuRecursiveContext::const_xs_transport`) vs the new CubeCL kernel +//! (`gpu_transport_cubecl::const_xs_transport` on the CUDA runtime), on +//! the SAME 2×2 reflective lattice, same constant cross-sections, same +//! per-particle RNG seeds. +//! +//! `k = fissions / source particle` (one-generation multiplication). +//! The three legs are independent MC realizations (same seeds, but +//! collision-vs-surface ties flip on float rounding across +//! implementations), so they agree within MC noise, not bit-for-bit. +//! This proves the CubeCL port reproduces the legacy `.cu` physics. +//! +//! cargo run --release --features cuda --bin cubecl_vs_cu_keff + +#[cfg(not(feature = "cuda"))] +fn main() { + eprintln!("ERROR: requires the 'cuda' feature."); + eprintln!("cargo run --release --features cuda --bin cubecl_vs_cu_keff"); + std::process::exit(1); +} + +#[cfg(feature = "cuda")] +fn main() { + cuda_main::run(); +} + +#[cfg(feature = "cuda")] +mod cuda_main { + use std::time::Instant; + + use open_rust_mc::geometry::cell::{self, Cell, CellFill, CellId, Region}; + use open_rust_mc::geometry::lattice::RectLattice; + use open_rust_mc::geometry::surface::BoundaryCondition; + use open_rust_mc::geometry::universe::{Universe, UniverseId}; + use open_rust_mc::geometry::{Aabb, Geometry, Vec3}; + use open_rust_mc::geometry::flat::build_host_tables; + use open_rust_mc::gpu_recursive::{ConstXs as CuConstXs, GpuRecursiveContext}; + use open_rust_mc::gpu_transport_cubecl::{ + self as cc, ConstXs as CcConstXs, + }; + use rust_mc_sim::Pcg64; + + /// Same 2×2 reflective lattice as `gpu_const_xs_keff`: a fissile pin + /// (mat 0) in a water-like scatterer (mat 1), 2×2, reflective box. + fn build_geometry() -> Geometry { + let mut surfaces = open_rust_mc::geometry::shapes::pin_cylinders(0.5, 0.5, &[0.3]); + let outer_box = open_rust_mc::geometry::shapes::rect_box( + [1.0, 1.0, 10.0], + BoundaryCondition::Reflective, + surfaces.len(), + ); + surfaces.extend(outer_box.surfaces); + let cells = vec![ + Cell::new(CellId(0), cell::inside(0), CellFill::Material(0)), + Cell::new(CellId(1), cell::outside(0), CellFill::Material(1)), + Cell::new(CellId(2), outer_box.inside.clone(), CellFill::Lattice(0)) + .with_aabb(Aabb::new(Vec3::new(-1.0, -1.0, -10.0), Vec3::new(1.0, 1.0, 10.0))), + Cell::new( + CellId(3), + Region::Complement(Box::new(outer_box.inside)), + CellFill::Void, + ), + ]; + let universes = vec![ + Universe::new(UniverseId(0), vec![2, 3]), + Universe::new(UniverseId(1), vec![0, 1]), + ]; + let lattices = vec![RectLattice { + origin: Vec3::new(-1.0, -1.0, -10.0), + pitch: Vec3::new(1.0, 1.0, 20.0), + shape: [2, 2, 1], + universes: vec![UniverseId(1); 4], + material_overrides: None, + }]; + Geometry::new(surfaces, cells, universes, lattices, UniverseId(0)).expect("geometry") + } + + pub fn run() { + let geom = build_geometry(); + + // Synthetic constant XS — identical for both backends. + let cu_mats = vec![ + CuConstXs { sigma_t: 1.0, sigma_a: 0.5, sigma_f: 0.4, nu_bar: 2.0 }, + CuConstXs { sigma_t: 0.5, sigma_a: 0.0, sigma_f: 0.0, nu_bar: 0.0 }, + ]; + let cc_mats = vec![ + CcConstXs { sigma_t: 1.0, sigma_a: 0.5, sigma_f: 0.4, nu_bar: 2.0 }, + CcConstXs { sigma_t: 0.5, sigma_a: 0.0, sigma_f: 0.0, nu_bar: 0.0 }, + ]; + + let limits = open_rust_mc::transport::sim_limits::SimLimits::default(); + let n = 200_000usize; + let max_events = limits.max_events_per_history as i32; + let fis_capacity = limits.fis_capacity(n); + + // Shared source + per-particle RNG seeds. + let mut rng = Pcg64::new(0xCAFEBEEF, 0); + let mut positions = Vec::with_capacity(n); + let mut directions = Vec::with_capacity(n); + let mut seeds = Vec::with_capacity(n); + for i in 0..n { + let x = -1.0 + 2.0 * rng.uniform(); + let y = -1.0 + 2.0 * rng.uniform(); + let z = -1.0 + 2.0 * rng.uniform(); + let (dx, dy, dz) = rng.isotropic_direction(); + positions.push((x, y, z)); + directions.push((dx, dy, dz)); + let p = Pcg64::for_particle(0, i as u64); + seeds.push((p.state(), p.stream())); + } + + println!("=== const-XS k: legacy .cu vs CubeCL, 2×2 lattice ==="); + println!(" particles = {n}, fissile k_inf ≈ 1.6\n"); + + // Leg A — legacy CUDA .cu. + let ctx = GpuRecursiveContext::build(&geom, n).expect("gpu ctx"); + let t = Instant::now(); + let cu = ctx + .const_xs_transport(&positions, &directions, &seeds, &cu_mats, max_events, fis_capacity) + .expect("legacy .cu transport"); + let cu_ms = t.elapsed().as_secs_f64() * 1000.0; + let k_cu = cu.n_fissions as f64 / n as f64; + + // Leg B — CubeCL on the CUDA runtime. + let tables = build_host_tables(&geom); + let packed = cc::pack_transport(&tables, &geom, &cc_mats).expect("cubecl pack_transport"); + let device = cubecl::cuda::CudaDevice::default(); + let t = Instant::now(); + let ccb = cc::const_xs_transport::( + &device, &packed, &positions, &directions, &seeds, max_events as u32, fis_capacity, + ) + .expect("cubecl transport"); + let cc_ms = t.elapsed().as_secs_f64() * 1000.0; + let k_cc = ccb.n_fissions as f64 / n as f64; + + let row = |label: &str, b: &str, coll: u64, abs: u64, fis: u64, leak: u64, k: f64, ms: f64| { + println!( + " {label:<12} [{b}] coll={coll:>9} abs={abs:>9} fis={fis:>9} leak={leak:>7} k={k:.5} {ms:.0} ms" + ); + }; + row("legacy .cu", "CUDA", cu.n_collisions, cu.n_absorptions, cu.n_fissions, cu.n_leakage, k_cu, cu_ms); + row("CubeCL", "CUDA", ccb.n_collisions, ccb.n_absorptions, ccb.n_fissions, ccb.n_leakage, k_cc, cc_ms); + + let dk_pcm = (k_cu - k_cc).abs() * 1e5; + // 1σ on k ≈ sqrt(fissions)/n; a few-σ envelope is the gate. + let sigma_pcm = (cu.n_fissions as f64).sqrt() / n as f64 * 1e5; + println!( + "\n |Δk| = {dk_pcm:.0} pcm (1σ_MC ≈ {sigma_pcm:.0} pcm) {}", + if dk_pcm <= 5.0 * sigma_pcm.max(1.0) { + "PASS — within MC noise" + } else { + "FAIL — outside few-σ envelope" + } + ); + // Bank size should also track. + println!( + " fission bank: legacy {} vs CubeCL {}", + cu.fission_sites.len(), + ccb.fission_sites.len() + ); + } +} diff --git a/rust_prototype/src/bin/metal_stats_diag.rs b/rust_prototype/src/bin/metal_stats_diag.rs index 2ade8ff..a46fb25 100644 --- a/rust_prototype/src/bin/metal_stats_diag.rs +++ b/rust_prototype/src/bin/metal_stats_diag.rs @@ -490,6 +490,11 @@ fn main() -> Result<(), Box> { // low-absorption reflectors (Be), losing ~8% of thermal scatters // and biasing k low. Raise via `maxev=N` to test/fix. let mut arg_maxev: i32 = 10_000; + // Per-case OpenMC reference path (`openmc=`); consumed only in + // the cuda-gated comparison block below, so the binding is unused on + // a default build. + #[cfg_attr(not(feature = "cuda"), allow(unused_assignments, unused_variables))] + let mut arg_openmc: Option = None; for a in std::env::args().skip(2) { if let Some(v) = a.strip_prefix("b=") { arg_b = v.parse().unwrap_or(arg_b); @@ -511,6 +516,11 @@ fn main() -> Result<(), Box> { arg_nxn_mode = 1; // legacy bank-as-fission } else if let Some(v) = a.strip_prefix("maxev=") { arg_maxev = v.parse().unwrap_or(arg_maxev); + } else if let Some(v) = a.strip_prefix("openmc=") { + #[cfg_attr(not(feature = "cuda"), allow(unused_assignments))] + { + arg_openmc = Some(v.to_string()); + } } } eprintln!( diff --git a/rust_prototype/src/bin/preview_scene.rs b/rust_prototype/src/bin/preview_scene.rs index 4ec86dd..cd77743 100644 --- a/rust_prototype/src/bin/preview_scene.rs +++ b/rust_prototype/src/bin/preview_scene.rs @@ -230,6 +230,16 @@ struct Args { #[arg(long = "render3d-out")] render3d_out: Option, + /// Headless 3D render via the **CubeCL** GPU ray-caster (no window). + /// Same analytic-CSG walk as `--render3d-out` but runs the kernel on + /// the GPU through CubeCL — cross-vendor (Vulkan / Metal / DX12 on + /// any NVIDIA/AMD/Intel card, plus native CUDA/ROCm), in f64. This + /// is the production 3D path; the CPU `--render3d-out` is the + /// reference fallback. Honours `--cam-azim` / `--cam-elev` / `--zoom` + /// / `--resolution`. + #[arg(long = "cubecl-out")] + cubecl_out: Option, + /// Camera azimuth (degrees around +z) for the 3D view's initial / /// headless angle. #[arg(long, default_value_t = 35.0)] @@ -1418,9 +1428,107 @@ fn spawn_in_new_window() -> bool { } } +/// Headless 3D render via the CubeCL cross-vendor GPU ray-caster. +/// Mirrors `render3d::render_to_png`'s framing (orbit camera around the +/// scene AABB, `--zoom` scales the distance) but runs the analytic-CSG +/// walk on the GPU in f64. Writes a PNG. +fn render_cubecl_to_png(args: &Args, out: &Path) { + use open_rust_mc::geometry::flat::build_host_tables; + use open_rust_mc::gpu_render::{CameraParams, pack_scene, render_rgb_wgpu}; + + let LoadedPreview { + geometry, + palette, + names, + .. + } = load_preview(args); + let opaque = render3d::opaque_mask(&names); + // Per-material haze (alpha-per-cm) for the transparent fluids: + // air / void / vacuum stay perfectly clear (0); water-like + // moderators / coolants / solutions get a faint absorption so the + // ray accumulates a tint through them and depth reads. Tuned so a + // typical pin pitch (~1-2 cm) stays nearly clear while a ~10 cm + // water span visibly dims — fuel structure shows through but the + // tank still reads as filled. + let absorb: Vec = names + .iter() + .map(|n| { + let n = n.to_lowercase(); + if n.contains("air") || n.contains("void") || n.contains("vacuum") { + 0.0 + } else if n.contains("water") + || n.contains("h2o") + || n.contains("d2o") + || n.contains("moderator") + || n.contains("coolant") + || n.contains("solution") + || n.contains("borated") + { + 0.045 + } else { + 0.0 + } + }) + .collect(); + let tables = build_host_tables(&geometry); + let packed = pack_scene(&tables, &geometry, &palette, &opaque, &absorb) + .expect("pack_scene (does this scene use an unsupported per-cell rotation?)"); + + let target = [ + (packed.aabb_min[0] + packed.aabb_max[0]) * 0.5, + (packed.aabb_min[1] + packed.aabb_max[1]) * 0.5, + (packed.aabb_min[2] + packed.aabb_max[2]) * 0.5, + ]; + let ex = packed.aabb_max[0] - packed.aabb_min[0]; + let ey = packed.aabb_max[1] - packed.aabb_min[1]; + let ez = packed.aabb_max[2] - packed.aabb_min[2]; + let radius = (ex * ex + ey * ey + ez * ez).sqrt() * 0.9 * args.zoom; + let cam = CameraParams::orbit( + target, + args.cam_azim.to_radians(), + args.cam_elev.to_radians(), + radius, + 45.0, + 1.0, + ); + + let w = args.resolution; + let h = args.resolution; + let pixels = render_rgb_wgpu(&packed, &cam, w, h); + + // u32 0xRRGGBB → RGB8 and write PNG. + let rgb: Vec<[u8; 3]> = pixels + .iter() + .map(|&c| [((c >> 16) & 0xff) as u8, ((c >> 8) & 0xff) as u8, (c & 0xff) as u8]) + .collect(); + let file = std::fs::File::create(out) + .unwrap_or_else(|e| panic!("create {}: {e}", out.display())); + let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), w, h); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("png header"); + let flat: &[u8] = bytemuck::cast_slice(&rgb); + writer.write_image_data(flat).expect("png data"); + eprintln!( + "wrote {} ({}×{}) [CubeCL/GPU] azim={:.1}° elev={:.1}° zoom={}", + out.display(), + w, + h, + args.cam_azim, + args.cam_elev, + args.zoom + ); +} + fn main() { let args = Args::parse(); + // Headless 3D ray-cast render to PNG via CubeCL (cross-vendor GPU). + if let Some(out) = args.cubecl_out.as_deref() { + render_cubecl_to_png(&args, out); + return; + } + // Headless 3D ray-cast render to PNG (works without `preview`). if let Some(out) = args.render3d_out.as_deref() { render3d::render_to_png(&args, out); @@ -2217,15 +2325,30 @@ mod render3d { } } - /// `true` for materials that should be drawn solid. Air / void / - /// vacuum (and the no-material `Void` fill) are transparent so the - /// camera sees the objects suspended inside them. + /// `true` for materials that should be drawn solid. Transparent + /// materials let the camera see the structure suspended inside them: + /// not just air / void / vacuum, but also the moderating / coolant + /// **fluids** that otherwise wrap every lattice scene in a solid + /// block (water, heavy water, the borated coolant, fissile + /// solutions). Without this a 17×17 assembly or an LCT rod cluster + /// renders as an opaque striped cube; with it the fuel-pin forest + /// reads in 3D — the standard reactor-geometry preview convention. pub(crate) fn opaque_mask(names: &[String]) -> Vec { names .iter() .map(|n| { let n = n.to_lowercase(); - !(n.contains("air") || n.contains("void") || n.contains("vacuum")) + let transparent = n.contains("air") + || n.contains("void") + || n.contains("vacuum") + || n.contains("water") + || n.contains("h2o") + || n.contains("d2o") + || n.contains("moderator") + || n.contains("coolant") + || n.contains("solution") + || n.contains("borated"); + !transparent }) .collect() } @@ -2510,7 +2633,11 @@ mod render3d { let opaque = opaque_mask(&names); let aabb = scene_aabb(&geometry); let target = (aabb.0 + aabb.1) * 0.5; - let radius = (aabb.1 - aabb.0).length() * 0.9; + // `--zoom` scales the orbit radius (same role as the 2D path): + // factors < 1 push the camera in to frame a fixture suspended + // inside a large containment sphere / pool, where the raw AABB + // diagonal would otherwise shrink it to a speck. + let radius = (aabb.1 - aabb.0).length() * 0.9 * args.zoom; let cam = Camera::orbit( target, args.cam_azim.to_radians(), diff --git a/rust_prototype/src/geometry/flat.rs b/rust_prototype/src/geometry/flat.rs new file mode 100644 index 0000000..221d85a --- /dev/null +++ b/rust_prototype/src/geometry/flat.rs @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: MIT +//! Backend-agnostic flattening of a recursive [`Geometry`] into +//! struct-of-arrays (SoA) tables. +//! +//! A `Geometry` is a tree of universes, cells, CSG region trees, and +//! (rect / hex) lattices connected by indices. GPU kernels can't chase +//! `Vec>` pointers, so this module linearises the whole thing +//! into flat `Vec`s with explicit `off` / `len` index pairs — the +//! layout every device backend consumes. +//! +//! This used to live inside the `cuda`-gated `gpu_recursive` module. +//! It was lifted out (no behaviour change) so the CubeCL renderer and +//! the legacy CUDA path share **one** definition of the on-device +//! layout: the tag constants below are the single source of truth, and +//! both `gpu/cuda/geom_recursive.cu` (`GR_*`) and the CubeCL kernel +//! must agree with them. +//! +//! Pure: no GPU, no `unsafe`, no feature gate. `build_host_tables` is a +//! deterministic function of the `Geometry` alone. + +use crate::geometry::cell::{CellFill, Region}; +use crate::geometry::surface::{BoundaryCondition, Surface}; +use crate::geometry::{Geometry, Vec3}; + +// ── Tag constants — must match `gpu/cuda/geom_recursive.cu` (GR_*) and +// the CubeCL kernel. Single source of truth. ────────────────────── + +pub const SURF_PLANE_X: i32 = 0; +pub const SURF_PLANE_Y: i32 = 1; +pub const SURF_PLANE_Z: i32 = 2; +pub const SURF_SPHERE: i32 = 3; +pub const SURF_CYL_Z: i32 = 4; +pub const SURF_CYL_X: i32 = 5; +pub const SURF_CYL_Y: i32 = 6; +pub const SURF_PLANE_GENERAL: i32 = 7; + +pub const BC_TRANSMISSION: i32 = 0; +pub const BC_VACUUM: i32 = 1; +pub const BC_REFLECTIVE: i32 = 2; + +pub const REGION_HALFSPACE_POS: i32 = 0; +pub const REGION_HALFSPACE_NEG: i32 = 1; +pub const REGION_INTERSECTION: i32 = 2; +pub const REGION_UNION: i32 = 3; +pub const REGION_COMPLEMENT: i32 = 4; + +pub const FILL_MATERIAL: i32 = 0; +pub const FILL_VOID: i32 = 1; +pub const FILL_UNIVERSE: i32 = 2; +pub const FILL_LATTICE: i32 = 3; +pub const FILL_HEX_LATTICE: i32 = 4; + +// Hex orientation discriminants — match CUDA `GR_HEX_ORIENT_*`. +pub const HEX_ORIENT_Y: i32 = 0; +pub const HEX_ORIENT_X: i32 = 1; + +/// Doubles packed per surface in [`HostTables::surf_params`]. Fixed +/// stride so a device kernel indexes `surf_params[s_idx * 8 + k]`. +pub const SURF_PARAM_STRIDE: usize = 8; + +// ── Host-side SoA tables ──────────────────────────────────────────── + +/// Flat SoA view of a `Geometry`, ready to upload to any GPU backend. +/// +/// Index conventions (mirrored in every device walk): +/// - `surf_params` holds [`SURF_PARAM_STRIDE`] f64 per surface. +/// - `cell_aabb_min` / `cell_aabb_max` hold 3 f64 per cell. +/// - per-cell region opcodes live at `region_op[cell_region_off[c] .. +/// + cell_region_len[c]]` (postfix CSG stack machine). +/// - per-universe cell / surface index lists use the matching +/// `univ_*_off` / `univ_*_len` pairs into `univ_cell_indices` / +/// `univ_surface_indices`. +/// - lattice / hex universe arrays are flattened with `*_universes_off`. +#[derive(Default)] +pub struct HostTables { + pub surf_type: Vec, + pub surf_params: Vec, // SURF_PARAM_STRIDE doubles per surface + pub surf_bc: Vec, + + pub cell_region_off: Vec, + pub cell_region_len: Vec, + pub cell_fill_type: Vec, + pub cell_fill_data: Vec, + pub cell_aabb_min: Vec, // 3 doubles per cell + pub cell_aabb_max: Vec, + + pub region_op: Vec, + pub region_arg: Vec, + + pub univ_cells_off: Vec, + pub univ_cells_len: Vec, + pub univ_surfaces_off: Vec, + pub univ_surfaces_len: Vec, + pub univ_cell_indices: Vec, + pub univ_surface_indices: Vec, + + pub lat_origin: Vec, + pub lat_pitch: Vec, + pub lat_shape: Vec, + pub lat_universes_off: Vec, + pub lat_universes: Vec, + // Hex lattice SoA — parallel to the rect arrays. The `n_*` + // counters match the layout in `geom_recursive.cu::GrGeometry`. + pub hex_center: Vec, + pub hex_pitch_xy: Vec, + pub hex_pitch_z: Vec, + pub hex_n_rings: Vec, + pub hex_n_axial: Vec, + pub hex_orientation: Vec, + pub hex_universes_off: Vec, + pub hex_universes: Vec, +} + +/// Pack one surface's parameters into `params_out` (stride-8) and its +/// boundary condition into `bc_out`; return the surface type tag. +/// +/// Cones are not yet supported on the recursive-GPU path (the ICSBEP +/// scenes that exercise it use PlaneX/Y/Z + CylinderZ + Sphere); a cone +/// packs as a sentinel `-1` type so the device walk yields a huge eval +/// and the parity test flags it as a mismatch rather than silently +/// rendering garbage. +pub fn pack_surface(s: &Surface, params_out: &mut Vec, bc_out: &mut Vec) -> i32 { + fn push8(v: &mut Vec, slots: [f64; SURF_PARAM_STRIDE]) { + v.extend_from_slice(&slots); + } + let bc_int = |bc: BoundaryCondition| match bc { + BoundaryCondition::Transmission => BC_TRANSMISSION, + BoundaryCondition::Vacuum => BC_VACUUM, + BoundaryCondition::Reflective => BC_REFLECTIVE, + }; + match *s { + Surface::PlaneX { x0, bc } => { + push8(params_out, [x0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + bc_out.push(bc_int(bc)); + SURF_PLANE_X + } + Surface::PlaneY { y0, bc } => { + push8(params_out, [y0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + bc_out.push(bc_int(bc)); + SURF_PLANE_Y + } + Surface::PlaneZ { z0, bc } => { + push8(params_out, [z0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + bc_out.push(bc_int(bc)); + SURF_PLANE_Z + } + Surface::Sphere { center, radius, bc } => { + push8( + params_out, + [center.x, center.y, center.z, radius, 0.0, 0.0, 0.0, 0.0], + ); + bc_out.push(bc_int(bc)); + SURF_SPHERE + } + Surface::CylinderZ { + center_x, + center_y, + radius, + bc, + } => { + push8( + params_out, + [center_x, center_y, radius, 0.0, 0.0, 0.0, 0.0, 0.0], + ); + bc_out.push(bc_int(bc)); + SURF_CYL_Z + } + Surface::CylinderX { + center_y, + center_z, + radius, + bc, + } => { + push8( + params_out, + [center_y, center_z, radius, 0.0, 0.0, 0.0, 0.0, 0.0], + ); + bc_out.push(bc_int(bc)); + SURF_CYL_X + } + Surface::CylinderY { + center_x, + center_z, + radius, + bc, + } => { + push8( + params_out, + [center_x, center_z, radius, 0.0, 0.0, 0.0, 0.0, 0.0], + ); + bc_out.push(bc_int(bc)); + SURF_CYL_Y + } + Surface::Plane { normal, offset, bc } => { + push8( + params_out, + [normal.x, normal.y, normal.z, offset, 0.0, 0.0, 0.0, 0.0], + ); + bc_out.push(bc_int(bc)); + SURF_PLANE_GENERAL + } + // Cones not yet supported on the recursive-GPU path — sentinel. + _ => { + push8(params_out, [0.0; SURF_PARAM_STRIDE]); + bc_out.push(BC_VACUUM); + -1 + } + } +} + +/// Walk a CSG region tree and emit postfix opcodes into `op` / `arg`. +pub fn flatten_region(region: &Region, op: &mut Vec, arg: &mut Vec) { + match region { + Region::HalfSpace { + surface_idx, + positive, + } => { + op.push(if *positive { + REGION_HALFSPACE_POS + } else { + REGION_HALFSPACE_NEG + }); + arg.push(*surface_idx as i32); + } + Region::Intersection(a, b) => { + flatten_region(a, op, arg); + flatten_region(b, op, arg); + op.push(REGION_INTERSECTION); + arg.push(0); + } + Region::Union(a, b) => { + flatten_region(a, op, arg); + flatten_region(b, op, arg); + op.push(REGION_UNION); + arg.push(0); + } + Region::Complement(a) => { + flatten_region(a, op, arg); + op.push(REGION_COMPLEMENT); + arg.push(0); + } + } +} + +/// Clamp infinite AABB bounds to a large finite box so device slab +/// tests stay numerically sane (an unbounded outer cell has ±∞ extent). +pub fn finite_aabb(lo: Vec3, hi: Vec3) -> ([f64; 3], [f64; 3]) { + let clamp = |v: f64| { + if v.is_finite() { + v + } else if v > 0.0 { + 1e20 + } else { + -1e20 + } + }; + ( + [clamp(lo.x), clamp(lo.y), clamp(lo.z)], + [clamp(hi.x), clamp(hi.y), clamp(hi.z)], + ) +} + +/// Reject per-cell `Mat3` rotation, which the flattened SoA walk +/// (CubeCL renderer / transport, and the legacy CUDA recursive +/// descent) cannot represent, instead of silently mis-transporting or +/// mis-rendering it. +/// +/// CPU recursive descent applies rotations in `CoordStack::local_pos` +/// / `local_dir` (`geometry/coord.rs`, `geometry/ray.rs`); every GPU +/// walk descends axis-aligned only and never applies a rotation +/// matrix during cell entry. +/// +/// Call this before packing/uploading a scene to any CubeCL backend. +/// [`check_gpu_supported`] also calls this and additionally rejects +/// hex lattices, for backends (transport) where a hex cell resolving +/// as a silent leak would corrupt physics results — the renderer +/// instead documents hex as a contained, non-silent v1 limitation +/// (renders as background), so it calls this rotation-only check +/// directly rather than `check_gpu_supported`. +pub fn check_gpu_rotation_supported(geom: &Geometry) -> Result<(), String> { + if let Some((cell_idx, _)) = geom + .cells + .iter() + .enumerate() + .find(|(_, c)| c.rotation.is_some()) + { + return Err(format!( + "GPU geometry walk does not support per-cell Mat3 \ + rotations yet (cell index {cell_idx} has `rotation = \ + Some(...)`). CPU recursive descent applies rotations in \ + `CoordStack::local_pos / local_dir`; the GPU descent path \ + has no equivalent. Use Runner.Cpu for this scene or \ + remove the cell rotation." + )); + } + Ok(()) +} + +/// Reject geometry features the flattened SoA transport walk (CubeCL +/// `gpu_transport_cubecl` / `gpu_ce_cubecl`) cannot represent, instead +/// of silently mis-transporting them. +/// +/// - **Per-cell `Mat3` rotation** — see [`check_gpu_rotation_supported`]. +/// - **`CellFill::HexLattice`**: the CubeCL walk (`gpu_cubecl_geom`) +/// has no hex descent (only the legacy CUDA `.cu` `gr_find_cell` +/// does); a hex cell would resolve as an immediate leak instead of +/// the fill it names, silently under-transporting the source. +/// +/// Call this before packing/uploading a scene to `pack_transport` / +/// `pack_ce_full`. +pub fn check_gpu_supported(geom: &Geometry) -> Result<(), String> { + check_gpu_rotation_supported(geom)?; + if let Some((cell_idx, _)) = geom + .cells + .iter() + .enumerate() + .find(|(_, c)| matches!(c.fill, CellFill::HexLattice(_))) + { + return Err(format!( + "GPU geometry walk does not support CellFill::HexLattice \ + yet (cell index {cell_idx}). Only the legacy CUDA \ + recursive descent (`gr_find_cell` in geom_recursive.cu) \ + implements hex-lattice traversal; the CubeCL walk \ + (gpu_cubecl_geom) would resolve this cell as an immediate \ + leak. Use Runner.Cpu / the legacy CUDA backend for hex \ + scenes." + )); + } + Ok(()) +} + +/// Flatten a whole `Geometry` into [`HostTables`]. Deterministic and +/// side-effect-free — the shared entry point for every GPU backend. +pub fn build_host_tables(geom: &Geometry) -> HostTables { + use crate::geometry::lattice::HexOrientation; + + let mut t = HostTables::default(); + + // Surfaces. + for s in &geom.surfaces { + let tag = pack_surface(s, &mut t.surf_params, &mut t.surf_bc); + t.surf_type.push(tag); + } + + // Cells: region trees flattened, fill packed. + for c in &geom.cells { + let off = t.region_op.len() as i32; + flatten_region(&c.region, &mut t.region_op, &mut t.region_arg); + let len = t.region_op.len() as i32 - off; + t.cell_region_off.push(off); + t.cell_region_len.push(len); + let (ft, fd) = match c.fill { + CellFill::Material(m) => (FILL_MATERIAL, m as i32), + CellFill::Void => (FILL_VOID, 0), + CellFill::Universe(u) => (FILL_UNIVERSE, u as i32), + CellFill::Lattice(l) => (FILL_LATTICE, l as i32), + CellFill::HexLattice(h) => (FILL_HEX_LATTICE, h as i32), + }; + t.cell_fill_type.push(ft); + t.cell_fill_data.push(fd); + let (lo, hi) = finite_aabb(c.aabb.min, c.aabb.max); + t.cell_aabb_min.extend_from_slice(&lo); + t.cell_aabb_max.extend_from_slice(&hi); + } + + // Universes: flatten cell + surface index lists. + for (u_idx, u) in geom.universes.iter().enumerate() { + let c_off = t.univ_cell_indices.len() as i32; + for &ci in &u.cell_indices { + t.univ_cell_indices.push(ci as i32); + } + t.univ_cells_off.push(c_off); + t.univ_cells_len.push(u.cell_indices.len() as i32); + + let s_off = t.univ_surface_indices.len() as i32; + for &si in &geom.universe_surfaces[u_idx] { + t.univ_surface_indices.push(si as i32); + } + t.univ_surfaces_off.push(s_off); + t.univ_surfaces_len + .push(geom.universe_surfaces[u_idx].len() as i32); + } + + // Lattices: flatten universe arrays. + for lat in &geom.lattices { + t.lat_origin + .extend_from_slice(&[lat.origin.x, lat.origin.y, lat.origin.z]); + t.lat_pitch + .extend_from_slice(&[lat.pitch.x, lat.pitch.y, lat.pitch.z]); + t.lat_shape.extend_from_slice(&[ + lat.shape[0] as i32, + lat.shape[1] as i32, + lat.shape[2] as i32, + ]); + let off = t.lat_universes.len() as i32; + for u in &lat.universes { + t.lat_universes.push(u.0 as i32); + } + t.lat_universes_off.push(off); + } + + // Hex lattices: flatten parallel SoA. The CUDA `gr_hex_*` device + // functions consume the same per-element data layout as the CPU + // `HexLattice` struct. + for hex in &geom.hex_lattices { + t.hex_center + .extend_from_slice(&[hex.center.x, hex.center.y, hex.center.z]); + t.hex_pitch_xy.push(hex.pitch_xy); + t.hex_pitch_z.push(hex.pitch_z); + t.hex_n_rings.push(hex.n_rings as i32); + t.hex_n_axial.push(hex.n_axial as i32); + t.hex_orientation.push(match hex.orientation { + HexOrientation::Y => HEX_ORIENT_Y, + HexOrientation::X => HEX_ORIENT_X, + }); + let off = t.hex_universes.len() as i32; + for u in &hex.universes { + t.hex_universes.push(u.0 as i32); + } + t.hex_universes_off.push(off); + } + + t +} diff --git a/rust_prototype/src/geometry/mod.rs b/rust_prototype/src/geometry/mod.rs index b797a48..470f40d 100644 --- a/rust_prototype/src/geometry/mod.rs +++ b/rust_prototype/src/geometry/mod.rs @@ -11,6 +11,7 @@ pub mod aabb; pub mod bvh; pub mod cell; pub mod coord; +pub mod flat; pub mod lattice; pub mod ray; #[cfg(test)] diff --git a/rust_prototype/src/gpu_ce_cubecl.rs b/rust_prototype/src/gpu_ce_cubecl.rs new file mode 100644 index 0000000..b730003 --- /dev/null +++ b/rust_prototype/src/gpu_ce_cubecl.rs @@ -0,0 +1,2147 @@ +// SPDX-License-Identifier: MIT +//! Continuous-energy cross-section lookup on CubeCL — foundation for a +//! CE transport kernel (target: HEU-COMP-INTER, multi-nuclide +//! fast/intermediate, no S(α,β)/URR). +//! +//! This module ports the genuinely new CE piece — per-nuclide pointwise +//! cross-section data on the device, with a single binary search on the +//! shared energy grid followed by log-log interpolation per reaction — +//! exactly mirroring `SvdXsProvider::lookup` (`xs_provider.rs`). The +//! transport loop on top reuses the geometry walk + collision structure +//! already validated by the const-XS A/B. +//! +//! Like the const-XS kernel, it runs in f64. Per cubecl#1336 the heavy +//! kernel only runs on the CUDA runtime (SPIR-V/Vulkan faults on large +//! private-Array state); this lookup foundation is small enough to test +//! on either, but the eventual transport kernel is CUDA-only until the +//! upstream fix lands. +//! +//! ── Device data layout ────────────────────────────────────────────── +//! One shared grid per nuclide. For each nuclide we upload: +//! - `grid` : N_e energies (eV, ascending) +//! - per reaction (elastic, fission, capture, inelastic, n2n): N_e σ +//! values (barns); a reaction absent for a nuclide gets an all-zero +//! slab so the kernel can read it unconditionally. +//! All packed into one big f64 blob with per-nuclide offsets in an i32 +//! header, plus per-(material,nuclide) atom densities for the +//! macroscopic sum. + +use cubecl::prelude::*; + +use crate::geometry::Geometry; +use crate::geometry::flat::{check_gpu_supported, HostTables}; +use crate::gpu_cubecl_geom::{ + cross_or_die, find_cell, trace_step, BC_TRANSMISSION, BC_VACUUM, FILL_MATERIAL, + MAX_DEPTH_USIZE, +}; + +/// CE reaction channels carried per nuclide. Order matters: it's the +/// stride layout in the device blob. +pub const N_RX: usize = 5; +pub const RX_ELASTIC: usize = 0; +pub const RX_FISSION: usize = 1; +pub const RX_CAPTURE: usize = 2; +pub const RX_INELASTIC: usize = 3; +pub const RX_N2N: usize = 4; + +/// Host-side per-nuclide CE data, extracted from the resolved provider. +#[derive(Clone)] +pub struct NuclideCe { + /// Shared energy grid (eV, ascending). + pub grid: Vec, + /// `[N_RX][grid.len()]` reconstructed σ(E) per reaction (barns). + /// Reactions absent for this nuclide are all-zero. + pub xs: [Vec; N_RX], + /// Mass ratio A (for elastic CM kinematics later). + pub awr: f64, + /// ν̄ at a representative energy (constant approx for the first cut; + /// the transport kernel will interpolate a real ν̄(E) grid later). + pub nu_bar: f64, +} + +/// Extract per-nuclide CE data from a resolved provider's nuclide +/// kernels, reconstructing each reaction's σ on the nuclide's own grid. +/// Reactions absent for a nuclide become all-zero slabs. Mirrors the +/// reactions the device kernel carries (`N_RX`). +pub fn extract_ce( + nuclides: &[std::sync::Arc], +) -> Vec { + use crate::transport::xs_provider::ReactionKernel; + + let recon = |k: &Option, grid: &[f64]| -> Vec { + match k { + Some(rk) => (0..grid.len()).map(|i| rk.reconstruct_at_index(i)).collect(), + None => vec![0.0; grid.len()], + } + }; + + nuclides + .iter() + .map(|nuc| { + // Shared grid = whichever reaction is present (elastic first). + let grid: Vec = nuc + .elastic + .as_ref() + .or(nuc.fission.as_ref()) + .or(nuc.capture.as_ref()) + .or(nuc.inelastic.as_ref()) + .or(nuc.n2n.as_ref()) + .map(|k| k.energies().to_vec()) + .unwrap_or_default(); + let xs = [ + recon(&nuc.elastic, &grid), + recon(&nuc.fission, &grid), + recon(&nuc.capture, &grid), + recon(&nuc.inelastic, &grid), + recon(&nuc.n2n, &grid), + ]; + NuclideCe { + grid, + xs, + awr: nuc.awr, + nu_bar: nuc.nu_bar_const, + } + }) + .collect() +} + +// ── Elastic angular distribution (tabulated μ) ────────────────────── +// +// CPU source: `NuclideKernels::elastic_angle: Option` +// = { energies[N_e], distributions[N_e]: TabularMuDist{ mu, pdf, cdf, +// histogram } }. Flattened for the device so the CubeCL sampler mirrors +// `AngularDistribution::sample_mu` (bracket E, stochastic bin pick) + +// `TabularMuDist::sample_with_xi` (linear or quadratic CDF inversion). + +/// Flat per-nuclide elastic angular data, device-ready. +#[derive(Clone, Default)] +pub struct AngularCe { + /// Incident energy grid (eV). Empty ⇒ isotropic fallback. + pub energies: Vec, + /// Per-energy μ-distribution slices into the flat mu/cdf/pdf arrays: + /// distribution `i` occupies `[dist_off[i] .. dist_off[i]+dist_len[i]]`. + pub dist_off: Vec, + pub dist_len: Vec, + /// `1` if histogram interpolation (linear CDF), `0` if lin-lin + /// (quadratic CDF inversion). Per energy point. + pub histogram: Vec, + /// Flat concatenated breakpoint arrays across all energy points. + pub mu: Vec, + pub cdf: Vec, + pub pdf: Vec, +} + +/// Extract elastic angular data per nuclide (parallel to `extract_ce`). +/// Nuclides with no elastic angular distribution get an empty +/// `AngularCe` (isotropic fallback at sample time). +pub fn extract_angular( + nuclides: &[std::sync::Arc], +) -> Vec { + nuclides + .iter() + .map(|nuc| { + let Some(ang) = nuc.elastic_angle.as_ref() else { + return AngularCe::default(); + }; + let mut out = AngularCe { + energies: ang.energies.clone(), + ..Default::default() + }; + for d in &ang.distributions { + out.dist_off.push(out.mu.len() as i32); + out.dist_len.push(d.mu.len() as i32); + out.histogram.push(i32::from(d.histogram)); + out.mu.extend_from_slice(&d.mu); + out.cdf.extend_from_slice(&d.cdf); + // pdf may be empty (reader left it out) → pad to mu len + // with zeros so the device can read it unconditionally; + // the sampler treats all-zero pdf as the linear path. + if d.pdf.len() == d.mu.len() { + out.pdf.extend_from_slice(&d.pdf); + } else { + out.pdf.extend(std::iter::repeat_n(0.0, d.mu.len())); + } + } + out + }) + .collect() +} + +/// One material = a list of (nuclide index, atom density). Mirrors the +/// CPU `Material.nuclides` after resolution; the device sums +/// `Σ_t(E) = Σ_nuc n_d · σ_t,nuc(E)` over these. +#[derive(Clone)] +pub struct MaterialCe { + /// `(nuclide_idx, atom_density [atoms/barn-cm])`. + pub nuclides: Vec<(usize, f64)>, +} + +/// CE scene packed for the device: flat blobs + offset header. +pub struct PackedCe { + /// f64 blob: per-nuclide [grid, xs[0..N_RX]], then the flat + /// material-nuclide atom-density list. + pub fdata: Vec, + /// i32 header: per-nuclide [grid_off, n_e, xs_off], then the + /// material table (off/len into the mat-nuclide lists). + pub idata: Vec, + pub n_nuclides: usize, + pub n_materials: usize, + /// Mirror of the per-nuclide awr / nu_bar (host-side; also uploaded). + pub awr: Vec, + pub nu_bar: Vec, + /// Device offsets (filled by `pack_ce_scene`) into idata/fdata for + /// the material table and the per-nuclide awr/nu_bar arrays. + pub mat_table_off: usize, // i32: per material [list_off, list_len] + pub mat_nuc_idx_off: usize, // i32: flat nuclide indices + pub mat_nuc_den_off: usize, // f64: flat atom densities + pub awr_off: usize, // f64 + pub nu_bar_off: usize, // f64 +} + +// Per-nuclide i32 header stride: [grid_off, n_e, xs_off]. +const NUC_HDR: usize = 3; +const H_GRID_OFF: usize = 0; +const H_N_E: usize = 1; +const H_XS_OFF: usize = 2; + +/// Pack per-nuclide CE data only (no material table). Used by the XS +/// lookup A/B, which compares one nuclide at a time. The material / +/// awr / nu_bar offsets are left at 0 with empty tables. +pub fn pack_ce(nuclides: &[NuclideCe]) -> PackedCe { + pack_ce_scene(nuclides, &[]) +} + +/// Pack a full CE scene: per-nuclide grids+σ, then the material table +/// (per material: a list of (nuclide_idx, atom_density)), then the +/// per-nuclide awr / nu_bar arrays. Layout (offsets recorded in the +/// returned struct, in *element* units): +/// fdata: [ per-nuclide grid+xs … ][ mat atom densities ][ awr ][ nu_bar ] +/// idata: [ per-nuclide NUC_HDR … ][ mat [off,len] table ][ flat nuclide idxs ] +pub fn pack_ce_scene(nuclides: &[NuclideCe], materials: &[MaterialCe]) -> PackedCe { + let mut fdata: Vec = Vec::new(); + let mut idata: Vec = vec![0; nuclides.len() * NUC_HDR]; + let mut awr = Vec::with_capacity(nuclides.len()); + let mut nu_bar = Vec::with_capacity(nuclides.len()); + + for (n, nuc) in nuclides.iter().enumerate() { + let n_e = nuc.grid.len(); + let grid_off = fdata.len(); + fdata.extend_from_slice(&nuc.grid); + let xs_off = fdata.len(); + for r in 0..N_RX { + debug_assert_eq!(nuc.xs[r].len(), n_e, "reaction {r} σ len != grid len"); + fdata.extend_from_slice(&nuc.xs[r]); + } + idata[n * NUC_HDR + H_GRID_OFF] = grid_off as i32; + idata[n * NUC_HDR + H_N_E] = n_e as i32; + idata[n * NUC_HDR + H_XS_OFF] = xs_off as i32; + awr.push(nuc.awr); + nu_bar.push(nuc.nu_bar); + } + + // Material table: per material [list_off, list_len] into the flat + // (nuclide_idx) i32 list + parallel (atom_density) f64 list. + let mat_table_off = idata.len(); + idata.extend(std::iter::repeat_n(0, materials.len() * 2)); + let mut flat_idx: Vec = Vec::new(); + let mut flat_den: Vec = Vec::new(); + for (m, mat) in materials.iter().enumerate() { + let off = flat_idx.len(); + for &(ni, den) in &mat.nuclides { + flat_idx.push(ni as i32); + flat_den.push(den); + } + idata[mat_table_off + m * 2] = off as i32; + idata[mat_table_off + m * 2 + 1] = mat.nuclides.len() as i32; + } + let mat_nuc_idx_off = idata.len(); + idata.extend_from_slice(&flat_idx); + + let mat_nuc_den_off = fdata.len(); + fdata.extend_from_slice(&flat_den); + let awr_off = fdata.len(); + fdata.extend_from_slice(&awr); + let nu_bar_off = fdata.len(); + fdata.extend_from_slice(&nu_bar); + + if fdata.is_empty() { + fdata.push(0.0); + } + if idata.is_empty() { + idata.push(0); + } + PackedCe { + fdata, + idata, + n_nuclides: nuclides.len(), + n_materials: materials.len(), + awr, + nu_bar, + mat_table_off, + mat_nuc_idx_off, + mat_nuc_den_off, + awr_off, + nu_bar_off, + } +} + +// ── Device CE lookup ──────────────────────────────────────────────── + +/// Lower-bracket grid index for `energy` by binary search over the +/// nuclide's grid slice `[grid_off .. grid_off + n_e]`. Returns an index +/// in `[0, n_e-2]` (clamped), matching `ReactionKernel::energy_index`. +#[cube] +fn energy_index(fdata: &Array, grid_off: u32, n_e: u32, energy: f64) -> u32 { + let mut lo = u32::new(0); + let mut hi = n_e - 1u32; + // Standard binary search for the bracket; bounded loop (no while-cap + // worries — grids are < 2^20 points so 24 steps suffice, but use a + // generous fixed cap for the CubeCL frontend). + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(grid_off + mid) as usize] <= energy { + lo = mid; + } else { + hi = mid; + } + } + } + // Clamp to [0, n_e-2]. + select(lo > n_e - 2u32, n_e - 2u32, lo) +} + +/// Log-log interpolate reaction `rx` of nuclide at header `hdr_base`, +/// at `energy`, given the precomputed bracket `idx`. Mirrors +/// `ReactionKernel::reconstruct_interp` for the `Table` case. +#[cube] +fn rx_interp( + idata: &Array, + fdata: &Array, + hdr_base: u32, + rx: u32, + idx: u32, + energy: f64, +) -> f64 { + let grid_off = u32::cast_from(idata[(hdr_base + H_GRID_OFF as u32) as usize]); + let n_e = u32::cast_from(idata[(hdr_base + H_N_E as u32) as usize]); + let xs_off = u32::cast_from(idata[(hdr_base + H_XS_OFF as u32) as usize]); + let base = xs_off + rx * n_e; + let e_lo = fdata[(grid_off + idx) as usize]; + let xs_lo = fdata[(base + idx) as usize]; + let mut out = xs_lo; + if idx + 1u32 < n_e { + let e_hi = fdata[(grid_off + idx + 1u32) as usize]; + let xs_hi = fdata[(base + idx + 1u32) as usize]; + if e_hi > e_lo { + if xs_lo > f64::new(0.0) { + if xs_hi > f64::new(0.0) { + // log-log, clamped to [0, 1] like + // `ReactionKernel::reconstruct_interp`'s caller does + // (`log_frac.clamp(0.0, 1.0)` in xs_provider.rs) — + // `idx` is itself clamped to the last valid bracket, + // so an `energy` outside the nuclide's grid range + // would otherwise extrapolate the power law instead + // of holding the boundary value. + let f_raw = (energy / e_lo).ln() / (e_hi / e_lo).ln(); + let f_hi = select(f_raw > f64::new(1.0), f64::new(1.0), f_raw); + let f = select(f_hi < f64::new(0.0), f64::new(0.0), f_hi); + let ratio = xs_hi / xs_lo; + out = xs_lo * (f * ratio.ln()).exp(); + } else { + let frac = (energy - e_lo) / (e_hi - e_lo); + out = xs_lo + frac * (xs_hi - xs_lo); + } + } else { + let frac = (energy - e_lo) / (e_hi - e_lo); + out = xs_lo + frac * (xs_hi - xs_lo); + } + } + } + out +} + +/// Test kernel: for each input energy, look up the total microscopic σ +/// (sum of the N_RX reactions) of nuclide 0 and write it out. Validates +/// the grid search + log-log interp against the CPU provider. +#[cube(launch)] +fn ce_total_micro_kernel( + idata: &Array, + fdata: &Array, + energies: &Array, + out: &mut Array, +) { + let tid = ABSOLUTE_POS; + if tid < out.len() { + let e = energies[tid]; + let hdr_base = u32::new(0); // nuclide 0 + let grid_off = u32::cast_from(idata[(hdr_base + H_GRID_OFF as u32) as usize]); + let n_e = u32::cast_from(idata[(hdr_base + H_N_E as u32) as usize]); + let idx = energy_index(fdata, grid_off, n_e, e); + let mut tot = f64::new(0.0); + for r in 0..N_RX as u32 { + tot = tot + rx_interp(idata, fdata, hdr_base, r, idx, e); + } + out[tid] = tot; + } +} + +/// Look up total microscopic σ(E) of nuclide 0 on the GPU at the given +/// energies. Convenience wrapper for the A/B test. +pub fn total_micro_xs( + device: &R::Device, + packed: &PackedCe, + energies: &[f64], +) -> Vec { + let client = R::client(device); + let n = energies.len(); + let idata_h = client.create_from_slice(i32::as_bytes(&packed.idata)); + let fdata_h = client.create_from_slice(f64::as_bytes(&packed.fdata)); + let e_h = client.create_from_slice(f64::as_bytes(energies)); + let out_h = client.empty(n * core::mem::size_of::()); + + let threads = 64u32; + let blocks = n.div_ceil(threads as usize) as u32; + unsafe { + ce_total_micro_kernel::launch::( + &client, + CubeCount::Static(blocks, 1, 1), + CubeDim::new_1d(threads), + ArrayArg::from_raw_parts(idata_h, packed.idata.len()), + ArrayArg::from_raw_parts(fdata_h, packed.fdata.len()), + ArrayArg::from_raw_parts(e_h, n), + ArrayArg::from_raw_parts(out_h.clone(), n), + ); + } + let bytes = client.read_one(out_h).expect("ce readback"); + f64::from_bytes(&bytes).to_vec() +} + +// ── Packed elastic angular blob (one nuclide) ─────────────────────── +// +// Layout mirrors the σ pack: an i32 header + i32/f64 blobs. For the +// A/B we pack a single nuclide at a time (the kernel samples "nuclide +// 0"), same as the σ lookup A/B. +// +// idata: [ n_e, +// eoff (energies start in fdata), +// doff (dist_off start in idata, len n_e), +// dlen (dist_len start in idata, len n_e), +// hoff (histogram start in idata, len n_e), +// muoff (mu start in fdata), +// cdfoff (cdf start in fdata), +// pdfoff (pdf start in fdata) ] +// then the dist_off / dist_len / histogram arrays inline. +// fdata: [ energies | mu | cdf | pdf ]. + +/// One-nuclide angular pack for the device sampler. +pub struct PackedAngular { + pub idata: Vec, + pub fdata: Vec, +} + +const A_N_E: usize = 0; +const A_EOFF: usize = 1; +const A_DOFF: usize = 2; +const A_DLEN: usize = 3; +const A_HOFF: usize = 4; +const A_MUOFF: usize = 5; +const A_CDFOFF: usize = 6; +const A_PDFOFF: usize = 7; +const A_HDR: usize = 8; + +/// Pack one nuclide's elastic angular data for the device. +pub fn pack_angular(ang: &AngularCe) -> PackedAngular { + let mut idata = vec![0i32; A_HDR]; + let mut fdata: Vec = Vec::new(); + let n_e = ang.energies.len(); + idata[A_N_E] = n_e as i32; + + idata[A_EOFF] = fdata.len() as i32; + fdata.extend_from_slice(&ang.energies); + + idata[A_DOFF] = idata.len() as i32; + idata.extend_from_slice(&ang.dist_off); + idata[A_DLEN] = idata.len() as i32; + idata.extend_from_slice(&ang.dist_len); + idata[A_HOFF] = idata.len() as i32; + idata.extend_from_slice(&ang.histogram); + + idata[A_MUOFF] = fdata.len() as i32; + fdata.extend_from_slice(&ang.mu); + idata[A_CDFOFF] = fdata.len() as i32; + fdata.extend_from_slice(&ang.cdf); + idata[A_PDFOFF] = fdata.len() as i32; + fdata.extend_from_slice(&ang.pdf); + + if fdata.is_empty() { + fdata.push(0.0); + } + if idata.len() == A_HDR { + idata.push(0); // ensure non-empty blob + } + PackedAngular { idata, fdata } +} + +// ── Device μ sampler (mirrors TabularMuDist::sample_with_xi) ───────── + +/// Invert the μ CDF of distribution at `[doff .. doff+dlen]` (slices +/// into mu/cdf/pdf) for a pre-drawn `xi`. `hist` selects linear (1) vs +/// quadratic (0) inversion. Mirrors `TabularMuDist::sample_with_xi`. +#[cube] +#[allow(unused_assignments)] +fn sample_mu_bin( + fdata: &Array, + mu_off: u32, + cdf_off: u32, + pdf_off: u32, + doff: u32, + dlen: u32, + hist: i32, + xi: f64, +) -> f64 { + let mut out = 2.0 * xi - 1.0; // n<2 fallback + if dlen >= 2u32 { + // binary search cdf for bracket idx (local within the dist). + let mut lo = u32::new(0); + let mut hi = dlen - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(cdf_off + doff + mid) as usize] <= xi { + lo = mid; + } else { + hi = mid; + } + } + } + // clamp lo to dlen-2 + let idx = select(lo > dlen - 2u32, dlen - 2u32, lo); + let cdf_lo = fdata[(cdf_off + doff + idx) as usize]; + let cdf_hi = fdata[(cdf_off + doff + idx + 1u32) as usize]; + let mu_lo = fdata[(mu_off + doff + idx) as usize]; + let mu_hi = fdata[(mu_off + doff + idx + 1u32) as usize]; + let dmu = mu_hi - mu_lo; + let dc = cdf_hi - cdf_lo; + if dc.abs() < f64::new(1e-15) { + out = clamp_mu(mu_lo); + } else { + if dmu.abs() < f64::new(1e-15) { + out = clamp_mu(mu_lo); + } else { + if hist == 1i32 { + let frac = (xi - cdf_lo) / dc; + out = clamp_mu(mu_lo + frac * dmu); + } else { + // quadratic lin-lin inversion. + let pdf_lo = fdata[(pdf_off + doff + idx) as usize]; + let pdf_hi = fdata[(pdf_off + doff + idx + 1u32) as usize]; + let a = (pdf_hi - pdf_lo) / (2.0 * dmu); + let b = pdf_lo; + let c = cdf_lo - xi; + let mut x = f64::new(0.0); + if a.abs() < f64::new(1e-14) { + if b.abs() < f64::new(1e-30) { + x = (xi - cdf_lo) / dc * dmu; + } else { + x = -c / b; + } + } else { + let disc = b * b - 4.0 * a * c; + let sq = select(disc > f64::new(0.0), disc, f64::new(0.0)).sqrt(); + // physical root in [0, dmu] + let x1 = (-b + sq) / (2.0 * a); + let x2 = (-b - sq) / (2.0 * a); + let x1_ok = x1 >= f64::new(0.0) && x1 <= dmu; + x = select(x1_ok, x1, x2); + } + out = clamp_mu(mu_lo + x); + } + } + } + } + out +} + +#[cube] +fn clamp_mu(m: f64) -> f64 { + let lo = select(m > f64::new(-1.0), m, f64::new(-1.0)); + select(lo < f64::new(1.0), lo, f64::new(1.0)) +} + +/// Sample an elastic μ at incident `energy` for the packed nuclide, +/// given two uniforms (`xi_bin` for the stochastic energy-bin pick, +/// `xi_mu` for the CDF inversion). Mirrors `AngularDistribution::sample_mu`. +#[cube] +#[allow(unused_assignments)] +fn sample_mu_at(idata: &Array, fdata: &Array, energy: f64, xi_bin: f64, xi_mu: f64) -> f64 { + let n_e = u32::cast_from(idata[A_N_E]); + let mut out = 2.0 * xi_mu - 1.0; // isotropic fallback (no data) + if n_e >= 1u32 { + let eoff = u32::cast_from(idata[A_EOFF]); + let doff_base = u32::cast_from(idata[A_DOFF]); + let dlen_base = u32::cast_from(idata[A_DLEN]); + let hoff_base = u32::cast_from(idata[A_HOFF]); + let mu_off = u32::cast_from(idata[A_MUOFF]); + let cdf_off = u32::cast_from(idata[A_CDFOFF]); + let pdf_off = u32::cast_from(idata[A_PDFOFF]); + + // bracket energy: pick a distribution index `pick`. + let e0 = fdata[eoff as usize]; + let elast = fdata[(eoff + n_e - 1u32) as usize]; + let mut pick = u32::new(0); + if energy <= e0 { + pick = u32::new(0); + } else { + if energy >= elast { + pick = n_e - 1u32; + } else { + // binary search for lower bracket idx + let mut lo = u32::new(0); + let mut hi = n_e - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(eoff + mid) as usize] <= energy { + lo = mid; + } else { + hi = mid; + } + } + } + let e_lo = fdata[(eoff + lo) as usize]; + let e_hi = fdata[(eoff + lo + 1u32) as usize]; + let r = (energy - e_lo) / (e_hi - e_lo); + pick = select(xi_bin < r, lo + 1u32, lo); + } + } + let doff = u32::cast_from(idata[(doff_base + pick) as usize]); + let dlen = u32::cast_from(idata[(dlen_base + pick) as usize]); + let hist = idata[(hoff_base + pick) as usize]; + out = sample_mu_bin(fdata, mu_off, cdf_off, pdf_off, doff, dlen, hist, xi_mu); + } + out +} + +/// Test kernel: sample one μ per thread at the given (energy, xi_bin, +/// xi_mu) triples. Lets the host drive a CPU-vs-GPU distribution A/B. +#[cube(launch)] +fn ce_sample_mu_kernel( + idata: &Array, + fdata: &Array, + energy: &Array, + xi_bin: &Array, + xi_mu: &Array, + out: &mut Array, +) { + let tid = ABSOLUTE_POS; + if tid < out.len() { + out[tid] = sample_mu_at(idata, fdata, energy[tid], xi_bin[tid], xi_mu[tid]); + } +} + +/// Sample μ on the GPU for one packed nuclide at the given inputs. +pub fn sample_mu_gpu( + device: &R::Device, + packed: &PackedAngular, + energy: &[f64], + xi_bin: &[f64], + xi_mu: &[f64], +) -> Vec { + let client = R::client(device); + let n = energy.len(); + let idata_h = client.create_from_slice(i32::as_bytes(&packed.idata)); + let fdata_h = client.create_from_slice(f64::as_bytes(&packed.fdata)); + let e_h = client.create_from_slice(f64::as_bytes(energy)); + let xb_h = client.create_from_slice(f64::as_bytes(xi_bin)); + let xm_h = client.create_from_slice(f64::as_bytes(xi_mu)); + let out_h = client.empty(n * core::mem::size_of::()); + let threads = 64u32; + let blocks = n.div_ceil(threads as usize) as u32; + unsafe { + ce_sample_mu_kernel::launch::( + &client, + CubeCount::Static(blocks, 1, 1), + CubeDim::new_1d(threads), + ArrayArg::from_raw_parts(idata_h, packed.idata.len()), + ArrayArg::from_raw_parts(fdata_h, packed.fdata.len()), + ArrayArg::from_raw_parts(e_h, n), + ArrayArg::from_raw_parts(xb_h, n), + ArrayArg::from_raw_parts(xm_h, n), + ArrayArg::from_raw_parts(out_h.clone(), n), + ); + } + let bytes = client.read_one(out_h).expect("mu readback"); + f64::from_bytes(&bytes).to_vec() +} + +// ── Fission outgoing-energy (χ) distribution ──────────────────────── +// +// CPU source: `NuclideKernels::fission_energy_dist: Option`. +// Two device paths, selected by `law`: +// law = 0 tabular ContinuousTabular (ENDF Law 4): per-incident-energy +// (e_out, pdf, cdf) tables + OpenMC stochastic-bin pick + +// scaled kinematic remap. Mirrors EnergyDistribution::sample. +// law = 1/2/3 closed-form Watt / Maxwell / Evaporation: a(E)/b(E) or +// θ(E) lin-lin tables, sampled by the Cranberg/Coveyou +// rejection samplers. Mirrors WattLaw::sample etc. +// law = -1 none → emit the incident energy unchanged (fallback). + +/// Flat per-nuclide fission-χ data, device-ready. +#[derive(Clone, Default)] +pub struct FissionCe { + /// -1 none, 0 tabular, 1 Watt, 2 Maxwell, 3 Evaporation. + pub law: i32, + pub u: f64, + // Tabular (law 0): incident grid + per-E (e_out,pdf,cdf) slices. + pub energies: Vec, + pub dist_off: Vec, + pub dist_len: Vec, + pub e_out: Vec, + pub pdf: Vec, + pub cdf: Vec, + // Closed-form param tables (law 1/2/3). Watt uses (a*, b*); Maxwell/ + // Evaporation use (a* = θ). b* empty for non-Watt. + pub pa_e: Vec, + pub pa_v: Vec, + pub pb_e: Vec, + pub pb_v: Vec, +} + +/// Extract fission-χ per nuclide (parallel to extract_ce / extract_angular). +pub fn extract_fission( + nuclides: &[std::sync::Arc], +) -> Vec { + use crate::hdf5_reader::FissionEnergyLaw; + nuclides + .iter() + .map(|nuc| { + let Some(ed) = nuc.fission_energy_dist.as_ref() else { + return FissionCe { law: -1, ..Default::default() }; + }; + match &ed.closed_form { + Some(FissionEnergyLaw::Watt(w)) => FissionCe { + law: 1, + u: w.u, + pa_e: w.a_energies.clone(), + pa_v: w.a_values.clone(), + pb_e: w.b_energies.clone(), + pb_v: w.b_values.clone(), + ..Default::default() + }, + Some(FissionEnergyLaw::Maxwell(m)) => FissionCe { + law: 2, + u: m.u, + pa_e: m.theta_energies.clone(), + pa_v: m.theta_values.clone(), + ..Default::default() + }, + Some(FissionEnergyLaw::Evaporation(m)) => FissionCe { + law: 3, + u: m.u, + pa_e: m.theta_energies.clone(), + pa_v: m.theta_values.clone(), + ..Default::default() + }, + None => { + // Tabular ContinuousTabular. + if ed.energies.is_empty() || ed.distributions.is_empty() { + return FissionCe { law: -1, ..Default::default() }; + } + let mut f = FissionCe { + law: 0, + energies: ed.energies.clone(), + ..Default::default() + }; + for d in &ed.distributions { + f.dist_off.push(f.e_out.len() as i32); + f.dist_len.push(d.e_out.len() as i32); + f.e_out.extend_from_slice(&d.e_out); + f.cdf.extend_from_slice(&d.cdf); + if d.pdf.len() == d.e_out.len() { + f.pdf.extend_from_slice(&d.pdf); + } else { + f.pdf.extend(std::iter::repeat_n(0.0, d.e_out.len())); + } + } + f + } + } + }) + .collect() +} + +// ── Packed fission blob (one nuclide) ─────────────────────────────── +// idata: [ law, n_e, eoff, doff, dloff, e_out_off, pdf_off, cdf_off, +// pa_e_off, pa_n, pa_v_off, pb_e_off, pb_n, pb_v_off ] +// then dist_off / dist_len arrays inline (tabular only). +// fdata: [ u, energies | e_out | pdf | cdf | pa_e | pa_v | pb_e | pb_v ] + +pub struct PackedFission { + pub idata: Vec, + pub fdata: Vec, +} + +const F_LAW: usize = 0; +const F_N_E: usize = 1; +const F_EOFF: usize = 2; +const F_DOFF: usize = 3; +const F_DLOFF: usize = 4; +const F_EOUT_OFF: usize = 5; +const F_PDF_OFF: usize = 6; +const F_CDF_OFF: usize = 7; +const F_PA_E_OFF: usize = 8; +const F_PA_N: usize = 9; +const F_PA_V_OFF: usize = 10; +const F_PB_E_OFF: usize = 11; +const F_PB_N: usize = 12; +const F_PB_V_OFF: usize = 13; +const F_U_SLOT: usize = 0; // fdata[0] = u +const F_HDR: usize = 14; + +pub fn pack_fission(f: &FissionCe) -> PackedFission { + let mut idata = vec![0i32; F_HDR]; + let mut fdata: Vec = vec![f.u]; // F_U_SLOT + idata[F_LAW] = f.law; + idata[F_N_E] = f.energies.len() as i32; + + idata[F_EOFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.energies); + idata[F_EOUT_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.e_out); + idata[F_PDF_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.pdf); + idata[F_CDF_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.cdf); + idata[F_PA_E_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.pa_e); + idata[F_PA_N] = f.pa_e.len() as i32; + idata[F_PA_V_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.pa_v); + idata[F_PB_E_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.pb_e); + idata[F_PB_N] = f.pb_e.len() as i32; + idata[F_PB_V_OFF] = fdata.len() as i32; + fdata.extend_from_slice(&f.pb_v); + + idata[F_DOFF] = idata.len() as i32; + idata.extend_from_slice(&f.dist_off); + idata[F_DLOFF] = idata.len() as i32; + idata.extend_from_slice(&f.dist_len); + + if fdata.is_empty() { + fdata.push(0.0); + } + if idata.len() == F_HDR { + idata.push(0); + } + PackedFission { idata, fdata } +} + +// ── Device samplers ───────────────────────────────────────────────── + +/// lin-lin lookup over a (grid, values) pair packed at given offsets. +/// Mirrors WattLaw::lookup_lin_lin. +#[cube] +fn fis_lin_lin(fdata: &Array, goff: u32, voff: u32, n: u32, e: f64) -> f64 { + let mut out = f64::new(0.0); + if n >= 1u32 { + let g0 = fdata[goff as usize]; + let glast = fdata[(goff + n - 1u32) as usize]; + if e <= g0 { + out = fdata[voff as usize]; + } else { + if e >= glast { + out = fdata[(voff + n - 1u32) as usize]; + } else { + let mut lo = u32::new(0); + let mut hi = n - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(goff + mid) as usize] <= e { + lo = mid; + } else { + hi = mid; + } + } + } + let gl = fdata[(goff + lo) as usize]; + let gh = fdata[(goff + lo + 1u32) as usize]; + let vl = fdata[(voff + lo) as usize]; + let vh = fdata[(voff + lo + 1u32) as usize]; + let frac = (e - gl) / (gh - gl); + out = vl + frac * (vh - vl); + } + } + } + out +} + +/// Quadratic/linear CDF inversion of a tabular (e_out, cdf, pdf) slice +/// `[doff .. doff+dlen]` for `xi`. Mirrors TabularEnergyDist::sample_with_xi. +#[cube] +#[allow(unused_assignments)] +fn fis_sample_eout_bin( + fdata: &Array, + eout_off: u32, + cdf_off: u32, + pdf_off: u32, + doff: u32, + dlen: u32, + xi: f64, +) -> f64 { + let mut out = f64::new(1.0e6); + if dlen >= 2u32 { + let mut lo = u32::new(0); + let mut hi = dlen - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(cdf_off + doff + mid) as usize] <= xi { + lo = mid; + } else { + hi = mid; + } + } + } + let idx = select(lo > dlen - 2u32, dlen - 2u32, lo); + let cdf_lo = fdata[(cdf_off + doff + idx) as usize]; + let cdf_hi = fdata[(cdf_off + doff + idx + 1u32) as usize]; + let e_lo = fdata[(eout_off + doff + idx) as usize]; + let e_hi = fdata[(eout_off + doff + idx + 1u32) as usize]; + let de = e_hi - e_lo; + if (cdf_hi - cdf_lo).abs() < f64::new(1e-15) { + out = e_lo; + } else { + let p_lo = fdata[(pdf_off + doff + idx) as usize]; + let p_hi = fdata[(pdf_off + doff + idx + 1u32) as usize]; + // quadratic when pdf present & de>0, else linear. + let use_quad = de > f64::new(0.0) && (p_lo > f64::new(0.0) || p_hi > f64::new(0.0)); + if use_quad { + let m = (p_hi - p_lo) / de; + let dc = xi - cdf_lo; + if m.abs() < f64::new(1e-30) { + out = e_lo + dc / p_lo; + } else { + let disc = p_lo * p_lo + 2.0 * m * dc; + let sq = select(disc > f64::new(0.0), disc, f64::new(0.0)).sqrt(); + out = e_lo + (sq - p_lo) / m; + } + } else { + let frac = (xi - cdf_lo) / (cdf_hi - cdf_lo); + out = e_lo + frac * de; + } + } + } + let floor = f64::new(1e-5); + select(out > floor, out, floor) +} + +/// Sample a fission outgoing energy for the packed nuclide at incident +/// `e_in`, drawing uniforms from the local rng. Mirrors +/// EnergyDistribution::sample (tabular path with stochastic bin + +/// kinematic remap) and the closed-form Watt/Maxwell/Evaporation +/// samplers. `rng` is a length-2 u64 [state, inc]. +#[cube] +#[allow(unused_assignments)] +fn fis_sample_energy(idata: &Array, fdata: &Array, e_in: f64, rng: &mut Array) -> f64 { + let law = idata[F_LAW]; + let u = fdata[F_U_SLOT]; + let mut out = e_in; // law -1 fallback + if law == 0i32 { + // Tabular ContinuousTabular. + let n_e = u32::cast_from(idata[F_N_E]); + let eoff = u32::cast_from(idata[F_EOFF]); + let doff_base = u32::cast_from(idata[F_DOFF]); + let dloff_base = u32::cast_from(idata[F_DLOFF]); + let eout_off = u32::cast_from(idata[F_EOUT_OFF]); + let pdf_off = u32::cast_from(idata[F_PDF_OFF]); + let cdf_off = u32::cast_from(idata[F_CDF_OFF]); + if n_e >= 1u32 { + let e0 = fdata[eoff as usize]; + let elast = fdata[(eoff + n_e - 1u32) as usize]; + if e_in <= e0 { + let xi = ce_uniform_f(rng); + let doff = u32::cast_from(idata[(doff_base) as usize]); + let dlen = u32::cast_from(idata[(dloff_base) as usize]); + out = fis_sample_eout_bin(fdata, eout_off, cdf_off, pdf_off, doff, dlen, xi); + } else { + if e_in >= elast { + let xi = ce_uniform_f(rng); + let doff = u32::cast_from(idata[(doff_base + n_e - 1u32) as usize]); + let dlen = u32::cast_from(idata[(dloff_base + n_e - 1u32) as usize]); + out = fis_sample_eout_bin(fdata, eout_off, cdf_off, pdf_off, doff, dlen, xi); + } else { + // bracket + let mut lo = u32::new(0); + let mut hi = n_e - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(eoff + mid) as usize] <= e_in { + lo = mid; + } else { + hi = mid; + } + } + } + let e_lo = fdata[(eoff + lo) as usize]; + let e_hi = fdata[(eoff + lo + 1u32) as usize]; + let r = (e_in - e_lo) / (e_hi - e_lo); + let pick_hi = ce_uniform_f(rng) < r; + let l = select(pick_hi, lo + 1u32, lo); + let doff_l = u32::cast_from(idata[(doff_base + l) as usize]); + let dlen_l = u32::cast_from(idata[(dloff_base + l) as usize]); + let xi = ce_uniform_f(rng); + let e_out = fis_sample_eout_bin(fdata, eout_off, cdf_off, pdf_off, doff_l, dlen_l, xi); + // Scaled kinematic remap to interpolated [E1, EK]. + let doff_a = u32::cast_from(idata[(doff_base + lo) as usize]); + let dlen_a = u32::cast_from(idata[(dloff_base + lo) as usize]); + let doff_b = u32::cast_from(idata[(doff_base + lo + 1u32) as usize]); + let dlen_b = u32::cast_from(idata[(dloff_base + lo + 1u32) as usize]); + let ea_lo = fdata[(eout_off + doff_a) as usize]; + let ea_hi = fdata[(eout_off + doff_a + dlen_a - 1u32) as usize]; + let eb_lo = fdata[(eout_off + doff_b) as usize]; + let eb_hi = fdata[(eout_off + doff_b + dlen_b - 1u32) as usize]; + let el_lo = fdata[(eout_off + doff_l) as usize]; + let el_hi = fdata[(eout_off + doff_l + dlen_l - 1u32) as usize]; + let e1 = (f64::new(1.0) - r) * ea_lo + r * eb_lo; + let ek = (f64::new(1.0) - r) * ea_hi + r * eb_hi; + let span = el_hi - el_lo; + let adjusted = select( + span.abs() < f64::new(1e-30), + e_out, + e1 + (e_out - el_lo) * (ek - e1) / span, + ); + let floor = f64::new(1e-5); + out = select(adjusted > floor, adjusted, floor); + } + } + } + } else { + let pa_e = u32::cast_from(idata[F_PA_E_OFF]); + let pa_n = u32::cast_from(idata[F_PA_N]); + let pa_v = u32::cast_from(idata[F_PA_V_OFF]); + let max_e = select(e_in - u > f64::new(1e-5), e_in - u, f64::new(1e-5)); + if law == 1i32 { + // Watt. + let pb_e = u32::cast_from(idata[F_PB_E_OFF]); + let pb_n = u32::cast_from(idata[F_PB_N]); + let pb_v = u32::cast_from(idata[F_PB_V_OFF]); + let a = fis_lin_lin(fdata, pa_e, pa_v, pa_n, e_in); + let b = fis_lin_lin(fdata, pb_e, pb_v, pb_n, e_in); + let mut acc = f64::new(0.0); + let mut done = false; + for _i in 0..128u32 { + if !done { + let xi1 = fmax_small(ce_uniform_f(rng)); + let xi2 = ce_uniform_f(rng); + let xi3 = fmax_small(ce_uniform_f(rng)); + let xi4 = ce_uniform_f(rng); + let c = (f64::new(1.5707963267948966) * xi2).cos(); + let w = -a * (xi1.ln() + c * c * xi3.ln()); + let term = a * a * b / 4.0; + let e_out = w + term + (2.0 * xi4 - 1.0) * (a * a * b * w).sqrt(); + acc = e_out; + if e_out > f64::new(0.0) && e_out <= max_e { + done = true; + } + } + } + // Clamp into [1e-5, max_e]: a no-op for the accepted case + // (already inside this range); on exhaustion it clamps the + // last attempted draw, mirroring `WattLaw::sample`'s safety + // valve — not a fixed sentinel energy. + let clamped_hi = select(acc > max_e, max_e, acc); + out = select(clamped_hi > f64::new(1e-5), clamped_hi, f64::new(1e-5)); + } else { + // Maxwell (law 2) or Evaporation (law 3): pa = θ table. + let theta = fis_lin_lin(fdata, pa_e, pa_v, pa_n, e_in); + let mut acc = f64::new(0.0); + let mut done = false; + for _i in 0..128u32 { + if !done { + let xi1 = fmax_small(ce_uniform_f(rng)); + let xi2 = ce_uniform_f(rng); + let xi3 = fmax_small(ce_uniform_f(rng)); + let e_out = select( + law == 2i32, + -theta * (xi1.ln() + (f64::new(1.5707963267948966) * xi2).cos() * (f64::new(1.5707963267948966) * xi2).cos() * xi3.ln()), + -theta * (xi1 * xi2).ln(), + ); + acc = e_out; + if e_out > f64::new(0.0) && e_out <= max_e { + done = true; + } + } + } + // Same exhaustion-clamp as the Watt branch above, mirroring + // `MaxwellLaw::sample_maxwell` / `sample_evaporation`. + let clamped_hi = select(acc > max_e, max_e, acc); + out = select(clamped_hi > f64::new(1e-5), clamped_hi, f64::new(1e-5)); + } + } + out +} + +/// uniform in [0,1) from a local [state,inc] rng (fission-module copy). +#[cube] +fn ce_uniform_f(rng: &mut Array) -> f64 { + let old0 = rng[0]; + rng[0] = old0 * 6364136223846793005u64 + rng[1]; + let xs0 = u32::cast_from(((old0 >> 18u64) ^ old0) >> 27u64); + let rot0 = u32::cast_from(old0 >> 59u64); + let a = u64::cast_from((xs0 >> rot0) | (xs0 << ((32u32 - rot0) & 31u32))) >> 5u64; + let old1 = rng[0]; + rng[0] = old1 * 6364136223846793005u64 + rng[1]; + let xs1 = u32::cast_from(((old1 >> 18u64) ^ old1) >> 27u64); + let rot1 = u32::cast_from(old1 >> 59u64); + let b = u64::cast_from((xs1 >> rot1) | (xs1 << ((32u32 - rot1) & 31u32))) >> 6u64; + f64::cast_from(a * 67108864u64 + b) * (1.0 / 9007199254740992.0) +} + +#[cube] +fn fmax_small(x: f64) -> f64 { + select(x > f64::new(1e-300), x, f64::new(1e-300)) +} + +/// Test kernel: one fission E_out per thread at the given incident +/// energies + seeds. +#[cube(launch)] +fn ce_sample_fis_kernel( + idata: &Array, + fdata: &Array, + e_in: &Array, + rng: &mut Array, + out: &mut Array, +) { + let tid = ABSOLUTE_POS; + if tid < out.len() { + let mut lrng = Array::::new(2usize); + lrng[0] = rng[tid * 2]; + lrng[1] = rng[tid * 2 + 1]; + out[tid] = fis_sample_energy(idata, fdata, e_in[tid], &mut lrng); + rng[tid * 2] = lrng[0]; + rng[tid * 2 + 1] = lrng[1]; + } +} + +/// Sample fission E_out on the GPU for one packed nuclide. +pub fn sample_fission_gpu( + device: &R::Device, + packed: &PackedFission, + e_in: &[f64], + seeds: &[(u64, u64)], +) -> Vec { + let client = R::client(device); + let n = e_in.len(); + let mut rng_flat = Vec::with_capacity(n * 2); + for s in seeds { + rng_flat.push(s.0); + rng_flat.push(s.1 | 1); + } + let idata_h = client.create_from_slice(i32::as_bytes(&packed.idata)); + let fdata_h = client.create_from_slice(f64::as_bytes(&packed.fdata)); + let e_h = client.create_from_slice(f64::as_bytes(e_in)); + let rng_h = client.create_from_slice(u64::as_bytes(&rng_flat)); + let out_h = client.empty(n * core::mem::size_of::()); + let threads = 64u32; + let blocks = n.div_ceil(threads as usize) as u32; + unsafe { + ce_sample_fis_kernel::launch::( + &client, + CubeCount::Static(blocks, 1, 1), + CubeDim::new_1d(threads), + ArrayArg::from_raw_parts(idata_h, packed.idata.len()), + ArrayArg::from_raw_parts(fdata_h, packed.fdata.len()), + ArrayArg::from_raw_parts(e_h, n), + ArrayArg::from_raw_parts(rng_h, n * 2), + ArrayArg::from_raw_parts(out_h.clone(), n), + ); + } + let bytes = client.read_one(out_h).expect("fis readback"); + f64::from_bytes(&bytes).to_vec() +} + +// ── Geometry walk — shared with `gpu_transport_cubecl` and +// `gpu_render` via `crate::gpu_cubecl_geom` (single copy). ──────── + +// ════════════════════════════════════════════════════════════════════ +// Fused continuous-energy transport kernel (single generation). +// +// One thread = one source neutron, transported to absorption / leakage +// / step cap. Wires together the pieces validated individually: +// - geometry walk (find_cell / trace_step) over the geometry SoA blob +// - CE Σ_t(E) lookup summed over the cell material's nuclides +// - nuclide pick ∝ Σ_t,nuc, reaction pick (elastic/inelastic/n2n/ +// fission/capture) analog-style +// - elastic/inelastic scatter: free-gas target velocity (E<400kT) + +// two-body CM energy loss with the validated tabulated angular μ +// - fission: bank ν̄ neutrons at validated χ-sampled energies, kill +// - capture / leak: kill +// Tallies banked-fission count → host forms single-generation +// k = fissions / source neutron. Mirrors the analog branch of +// gr_trace_and_sample (transport_event_based.cu). +// +// All buffers are unified: one i32 blob (geometry + ce idata + angular +// idata + fission idata + material table) and one f64 blob, with base +// offsets in a small `scene` header so each sub-blob's internal offsets +// are read relative to its base. + +/// Unified CE scene: geometry SoA + CE σ + angular + fission + material +/// table, all in two blobs with a base-offset header. +pub struct PackedCeScene { + pub idata: Vec, + pub fdata: Vec, + /// Header (see S_* consts): base offsets of each sub-blob + scalars. + pub header: Vec, + pub n_nuclides: usize, + pub n_materials: usize, +} + +// scene header (i32) slot map. +const S_ROOT_UNIV: usize = 0; +const S_N_MATERIALS: usize = 1; +const S_GEOM_I: usize = 2; // base of geometry idata in idata blob +const S_GEOM_F: usize = 3; // base of geometry fdata in fdata blob +const S_CE_I: usize = 4; // base of CE-nuclide idata +const S_CE_F: usize = 5; // base of CE-nuclide fdata +const S_MATTAB_I: usize = 6; // base of material table (in CE idata region) +const S_MATNUCIDX_I: usize = 7; +const S_MATNUCDEN_F: usize = 8; +const S_AWR_F: usize = 9; +const S_ANG_I: usize = 10; // per-nuclide angular idata base (stride A_HDR2) +const S_ANG_F: usize = 11; +const S_ANG_STRIDE_I: usize = 12; // i32 words per nuclide angular header+arrays +const S_FIS_I: usize = 13; +const S_FIS_F: usize = 14; +const S_FIS_STRIDE_I: usize = 15; +const S_ANG_STRIDE_F: usize = 16; +const S_FIS_STRIDE_F: usize = 17; +const S_NUC_HDR: usize = 18; // NUC_HDR (3) — exposed for kernel indexing +const S_LEN: usize = 24; + +// Geometry meta offsets (must match pack_geom_into below). These index +// into the geometry idata/fdata sub-blobs (relative to S_GEOM_I/S_GEOM_F). +const G_SURF_TYPE: usize = 0; +const G_SURF_BC: usize = 1; +const G_CELL_REGION_OFF: usize = 2; +const G_CELL_REGION_LEN: usize = 3; +const G_CELL_FILL_TYPE: usize = 4; +const G_CELL_FILL_DATA: usize = 5; +const G_REGION_OP: usize = 6; +const G_REGION_ARG: usize = 7; +const G_UNIV_CELLS_OFF: usize = 8; +const G_UNIV_CELLS_LEN: usize = 9; +const G_UNIV_CELL_IDX: usize = 10; +const G_LAT_SHAPE: usize = 11; +const G_LAT_UNIV_OFF: usize = 12; +const G_LAT_UNIV: usize = 13; +const G_SURF_PARAMS: usize = 14; // f-blob +const G_LAT_ORIGIN: usize = 15; // f-blob +const G_LAT_PITCH: usize = 16; // f-blob +const G_HDR: usize = 20; + +/// Build the unified CE scene pack from a Geometry + per-nuclide CE +/// data + per-material nuclide lists. Re-uses the per-nuclide / angular +/// / fission packers and records base offsets. Errors if `geom` uses a +/// feature the CubeCL walk doesn't support yet (per-cell rotation, hex +/// lattice) — see `check_gpu_supported`. +pub fn pack_ce_full( + tables: &HostTables, + geom: &Geometry, + nuclides: &[NuclideCe], + angular: &[AngularCe], + fission: &[FissionCe], + materials: &[MaterialCe], +) -> Result { + check_gpu_supported(geom)?; + let mut idata: Vec = Vec::new(); + let mut fdata: Vec = Vec::new(); + let mut header = vec![0i32; S_LEN]; + + // ── geometry sub-blob ── + let mut g_meta = vec![0i32; G_HDR]; + let geom_i_base = idata.len(); + // reserve geometry meta inline at the front of geometry idata region + let g_meta_at = idata.len(); + idata.extend(std::iter::repeat_n(0, G_HDR)); + let geom_f_base = fdata.len(); + + let push_i = |idata: &mut Vec, src: &[i32]| -> i32 { + let off = (idata.len() - geom_i_base) as i32; + idata.extend_from_slice(src); + off + }; + g_meta[G_SURF_TYPE] = push_i(&mut idata, &tables.surf_type); + g_meta[G_SURF_BC] = push_i(&mut idata, &tables.surf_bc); + g_meta[G_CELL_REGION_OFF] = push_i(&mut idata, &tables.cell_region_off); + g_meta[G_CELL_REGION_LEN] = push_i(&mut idata, &tables.cell_region_len); + g_meta[G_CELL_FILL_TYPE] = push_i(&mut idata, &tables.cell_fill_type); + g_meta[G_CELL_FILL_DATA] = push_i(&mut idata, &tables.cell_fill_data); + g_meta[G_REGION_OP] = push_i(&mut idata, &tables.region_op); + g_meta[G_REGION_ARG] = push_i(&mut idata, &tables.region_arg); + g_meta[G_UNIV_CELLS_OFF] = push_i(&mut idata, &tables.univ_cells_off); + g_meta[G_UNIV_CELLS_LEN] = push_i(&mut idata, &tables.univ_cells_len); + g_meta[G_UNIV_CELL_IDX] = push_i(&mut idata, &tables.univ_cell_indices); + g_meta[G_LAT_SHAPE] = push_i(&mut idata, &tables.lat_shape); + g_meta[G_LAT_UNIV_OFF] = push_i(&mut idata, &tables.lat_universes_off); + g_meta[G_LAT_UNIV] = push_i(&mut idata, &tables.lat_universes); + // write geometry meta back + idata[g_meta_at..g_meta_at + G_HDR].copy_from_slice(&g_meta[..G_HDR]); + + let push_f = |fdata: &mut Vec, src: &[f64]| -> i32 { + let off = (fdata.len() - geom_f_base) as i32; + fdata.extend_from_slice(src); + off + }; + let g_sp = push_f(&mut fdata, &tables.surf_params); + let g_lo = push_f(&mut fdata, &tables.lat_origin); + let g_lp = push_f(&mut fdata, &tables.lat_pitch); + idata[g_meta_at + G_SURF_PARAMS] = g_sp; + idata[g_meta_at + G_LAT_ORIGIN] = g_lo; + idata[g_meta_at + G_LAT_PITCH] = g_lp; + + // ── CE nuclide sub-blob (per-nuclide grid+xs, NUC_HDR header each) ── + let ce_i_base = idata.len(); + let ce_f_base = fdata.len(); + // per-nuclide NUC_HDR=3 header [grid_off, n_e, xs_off] (rel to ce_f_base) + idata.extend(std::iter::repeat_n(0, nuclides.len() * NUC_HDR)); + for (n, nuc) in nuclides.iter().enumerate() { + let n_e = nuc.grid.len(); + let grid_off = (fdata.len() - ce_f_base) as i32; + fdata.extend_from_slice(&nuc.grid); + let xs_off = (fdata.len() - ce_f_base) as i32; + for r in 0..N_RX { + fdata.extend_from_slice(&nuc.xs[r]); + } + idata[ce_i_base + n * NUC_HDR] = grid_off; + idata[ce_i_base + n * NUC_HDR + 1] = n_e as i32; + idata[ce_i_base + n * NUC_HDR + 2] = xs_off; + } + + // awr array (f-blob). + let awr_f = (fdata.len()) as i32; + fdata.extend(nuclides.iter().map(|n| n.awr)); + + // ── material table ── + let mattab_i = idata.len() as i32; + idata.extend(std::iter::repeat_n(0, materials.len() * 2)); + let mut flat_idx: Vec = Vec::new(); + let mut flat_den: Vec = Vec::new(); + for (m, mat) in materials.iter().enumerate() { + let off = flat_idx.len() as i32; + for &(ni, den) in &mat.nuclides { + flat_idx.push(ni as i32); + flat_den.push(den); + } + idata[mattab_i as usize + m * 2] = off; + idata[mattab_i as usize + m * 2 + 1] = mat.nuclides.len() as i32; + } + let matnucidx_i = idata.len() as i32; + idata.extend_from_slice(&flat_idx); + let matnucden_f = fdata.len() as i32; + fdata.extend_from_slice(&flat_den); + + // ── angular sub-blobs: pack each nuclide via pack_angular, store at a + // fixed stride so the kernel can index nuclide n directly. ── + let ang_packs: Vec = angular.iter().map(pack_angular).collect(); + let ang_stride_i = ang_packs.iter().map(|p| p.idata.len()).max().unwrap_or(1).max(1); + let ang_stride_f = ang_packs.iter().map(|p| p.fdata.len()).max().unwrap_or(1).max(1); + let ang_i_base = idata.len() as i32; + let ang_f_base = fdata.len() as i32; + for p in &ang_packs { + let i0 = idata.len(); + idata.extend(std::iter::repeat_n(0, ang_stride_i)); + idata[i0..i0 + p.idata.len()].copy_from_slice(&p.idata); + let f0 = fdata.len(); + fdata.extend(std::iter::repeat_n(0.0, ang_stride_f)); + fdata[f0..f0 + p.fdata.len()].copy_from_slice(&p.fdata); + } + + // ── fission sub-blobs: same fixed-stride scheme. ── + let fis_packs: Vec = fission.iter().map(pack_fission).collect(); + let fis_stride_i = fis_packs.iter().map(|p| p.idata.len()).max().unwrap_or(1).max(1); + let fis_stride_f = fis_packs.iter().map(|p| p.fdata.len()).max().unwrap_or(1).max(1); + let fis_i_base = idata.len() as i32; + let fis_f_base = fdata.len() as i32; + for p in &fis_packs { + let i0 = idata.len(); + idata.extend(std::iter::repeat_n(0, fis_stride_i)); + idata[i0..i0 + p.idata.len()].copy_from_slice(&p.idata); + let f0 = fdata.len(); + fdata.extend(std::iter::repeat_n(0.0, fis_stride_f)); + fdata[f0..f0 + p.fdata.len()].copy_from_slice(&p.fdata); + } + + if idata.is_empty() { + idata.push(0); + } + if fdata.is_empty() { + fdata.push(0.0); + } + + header[S_ROOT_UNIV] = geom.root_universe.0 as i32; + header[S_N_MATERIALS] = materials.len() as i32; + header[S_GEOM_I] = geom_i_base as i32; + header[S_GEOM_F] = geom_f_base as i32; + header[S_CE_I] = ce_i_base as i32; + header[S_CE_F] = ce_f_base as i32; + header[S_MATTAB_I] = mattab_i; + header[S_MATNUCIDX_I] = matnucidx_i; + header[S_MATNUCDEN_F] = matnucden_f; + header[S_AWR_F] = awr_f; + header[S_ANG_I] = ang_i_base; + header[S_ANG_F] = ang_f_base; + header[S_ANG_STRIDE_I] = ang_stride_i as i32; + header[S_ANG_STRIDE_F] = ang_stride_f as i32; + header[S_FIS_I] = fis_i_base; + header[S_FIS_F] = fis_f_base; + header[S_FIS_STRIDE_I] = fis_stride_i as i32; + header[S_FIS_STRIDE_F] = fis_stride_f as i32; + header[S_NUC_HDR] = NUC_HDR as i32; + + Ok(PackedCeScene { + idata, + fdata, + header, + n_nuclides: nuclides.len(), + n_materials: materials.len(), + }) +} + +// ── Fused CE transport kernel ─────────────────────────────────────── + +const PI_CE: f64 = 3.141592653589793; +const KB_EV: f64 = 8.617333262e-5; // Boltzmann, eV/K + +/// Look up macroscopic Σ for one nuclide at energy `e`, channel `rx`, +/// scaled by atom density `nd`. Reads the CE sub-blob at ce_i_base/ce_f_base. +#[cube] +fn ce_macro_rx( + idata: &Array, + fdata: &Array, + ce_i_base: u32, + ce_f_base: u32, + nuc_hdr: u32, + ni: u32, + rx: u32, + e: f64, +) -> f64 { + let hb = ce_i_base + ni * nuc_hdr; + let grid_off = ce_f_base + u32::cast_from(idata[hb as usize]); + let n_e = u32::cast_from(idata[(hb + 1u32) as usize]); + let xs_off = ce_f_base + u32::cast_from(idata[(hb + 2u32) as usize]); + // binary search on grid. + let idx = energy_index(fdata, grid_off, n_e, e); + // log-log interp on channel rx. + let base = xs_off + rx * n_e; + let e_lo = fdata[(grid_off + idx) as usize]; + let xs_lo = fdata[(base + idx) as usize]; + let mut out = xs_lo; + if idx + 1u32 < n_e { + let e_hi = fdata[(grid_off + idx + 1u32) as usize]; + let xs_hi = fdata[(base + idx + 1u32) as usize]; + if e_hi > e_lo { + if xs_lo > f64::new(0.0) && xs_hi > f64::new(0.0) { + // Clamped log-log — see `rx_interp`'s matching comment. + let f_raw = (e / e_lo).ln() / (e_hi / e_lo).ln(); + let f_hi = select(f_raw > f64::new(1.0), f64::new(1.0), f_raw); + let f = select(f_hi < f64::new(0.0), f64::new(0.0), f_hi); + out = xs_lo * (f * (xs_hi / xs_lo).ln()).exp(); + } else { + let frac = (e - e_lo) / (e_hi - e_lo); + out = xs_lo + frac * (xs_hi - xs_lo); + } + } + } + out +} + +/// Two-body elastic energy loss + scatter direction. Free-gas target +/// (E < 400 kT) gives the target velocity; the scattering cosine comes +/// from the validated tabulated angular sampler (CM). `dir` (len 3) +/// mutated; returns outgoing energy. Mirrors the .cu elastic event. +#[cube] +#[allow(unused_assignments)] +fn ce_elastic_scatter( + idata: &Array, + fdata: &Array, + ang_i_base: u32, + ang_f_base: u32, + ang_stride_i: u32, + ang_stride_f: u32, + ni: u32, + a: f64, + cell_kt: f64, + e_in: f64, + rng: &mut Array, + dir: &mut Array, +) -> f64 { + // free-gas target velocity components (0 if E above thermal cutoff). + let mut vtx = f64::new(0.0); + let mut vty = f64::new(0.0); + let mut vtz = f64::new(0.0); + let kt = select(cell_kt > f64::new(0.0), cell_kt, f64::new(600.0) * KB_EV); + if e_in < f64::new(400.0) * kt { + let sigma = (kt / a).sqrt(); + let u1 = fmax_small(ce_uniform_f(rng)); + let u2 = ce_uniform_f(rng); + let r1 = sigma * (-2.0 * u1.ln()).sqrt(); + vtx = r1 * (2.0 * PI_CE * u2).cos(); + vty = r1 * (2.0 * PI_CE * u2).sin(); + let u3 = fmax_small(ce_uniform_f(rng)); + let u4 = ce_uniform_f(rng); + let r2 = sigma * (-2.0 * u3.ln()).sqrt(); + vtz = r2 * (2.0 * PI_CE * u4).cos(); + } + let v_n = (2.0 * e_in).sqrt(); + let vnx = dir[0] * v_n; + let vny = dir[1] * v_n; + let vnz = dir[2] * v_n; + let vrx = vnx - vtx; + let vry = vny - vty; + let vrz = vnz - vtz; + let vr2 = vrx * vrx + vry * vry + vrz * vrz; + let vr = select(vr2 > f64::new(1e-40), vr2.sqrt(), f64::new(1e-20)); + let ia1 = f64::new(1.0) / (f64::new(1.0) + a); + let vcx = (vnx + a * vtx) * ia1; + let vcy = (vny + a * vty) * ia1; + let vcz = (vnz + a * vtz) * ia1; + let vcn = vr * a * ia1; + // CM cosine from tabulated angular dist (relative energy as the key). + let e_rel = 0.5 * (a / (a + 1.0)) * vr * vr; + let xi_bin = ce_uniform_f(rng); + let xi_mu = ce_uniform_f(rng); + let mu_cm = sample_mu_at_base(idata, fdata, ang_i_base + ni * ang_stride_i, ang_f_base + ni * ang_stride_f, e_rel, xi_bin, xi_mu); + let phi = 2.0 * PI_CE * ce_uniform_f(rng); + // rotate the relative-velocity unit vector by (mu_cm, phi), scale by + // vcn, add the CM velocity → lab outgoing velocity. + let mut rel = Array::::new(3usize); + rel[0] = vrx / vr; + rel[1] = vry / vr; + rel[2] = vrz / vr; + rotate_dir_ce(&mut rel, mu_cm, phi); + let voutx = vcx + vcn * rel[0]; + let vouty = vcy + vcn * rel[1]; + let voutz = vcz + vcn * rel[2]; + let vout2 = voutx * voutx + vouty * vouty + voutz * voutz; + let e_out = 0.5 * vout2; + let vout = select(vout2 > f64::new(1e-40), vout2.sqrt(), f64::new(1e-20)); + dir[0] = voutx / vout; + dir[1] = vouty / vout; + dir[2] = voutz / vout; + select(e_out > f64::new(1e-5), e_out, f64::new(1e-5)) +} + +/// Rotate unit vector `dir` (len 3) by polar cosine `mu` and azimuth `phi`. +#[cube] +fn rotate_dir_ce(dir: &mut Array, mu: f64, phi: f64) { + let u = dir[0]; + let v = dir[1]; + let w = dir[2]; + let s = (f64::new(1.0) - mu * mu).sqrt(); + let cphi = phi.cos(); + let sphi = phi.sin(); + let perp = (f64::new(1.0) - w * w).sqrt(); + if perp > f64::new(1e-10) { + dir[0] = mu * u + s * (cphi * u * w - sphi * v) / perp; + dir[1] = mu * v + s * (cphi * v * w + sphi * u) / perp; + dir[2] = mu * w - s * cphi * perp; + } else { + dir[0] = s * cphi; + dir[1] = s * sphi; + dir[2] = select(w > f64::new(0.0), mu, -mu); + } +} + +/// `sample_mu_at` variant taking explicit idata/fdata bases (so the +/// angular sub-blob can live anywhere in the unified blob). +#[cube] +#[allow(unused_assignments)] +fn sample_mu_at_base( + idata: &Array, + fdata: &Array, + ibase: u32, + fbase: u32, + energy: f64, + xi_bin: f64, + xi_mu: f64, +) -> f64 { + // header lives at ibase (A_HDR slots); offsets inside are relative + // to the sub-pack's own idata[0]/fdata[0], so add ibase/fbase. + let n_e = u32::cast_from(idata[(ibase + A_N_E as u32) as usize]); + let mut out = 2.0 * xi_mu - 1.0; + if n_e >= 1u32 { + let eoff = fbase + u32::cast_from(idata[(ibase + A_EOFF as u32) as usize]); + let doff_base = ibase + u32::cast_from(idata[(ibase + A_DOFF as u32) as usize]); + let dlen_base = ibase + u32::cast_from(idata[(ibase + A_DLEN as u32) as usize]); + let hoff_base = ibase + u32::cast_from(idata[(ibase + A_HOFF as u32) as usize]); + let mu_off = fbase + u32::cast_from(idata[(ibase + A_MUOFF as u32) as usize]); + let cdf_off = fbase + u32::cast_from(idata[(ibase + A_CDFOFF as u32) as usize]); + let pdf_off = fbase + u32::cast_from(idata[(ibase + A_PDFOFF as u32) as usize]); + let e0 = fdata[eoff as usize]; + let elast = fdata[(eoff + n_e - 1u32) as usize]; + let mut pick = u32::new(0); + if energy <= e0 { + pick = u32::new(0); + } else { + if energy >= elast { + pick = n_e - 1u32; + } else { + let mut lo = u32::new(0); + let mut hi = n_e - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(eoff + mid) as usize] <= energy { + lo = mid; + } else { + hi = mid; + } + } + } + let e_lo = fdata[(eoff + lo) as usize]; + let e_hi = fdata[(eoff + lo + 1u32) as usize]; + let r = (energy - e_lo) / (e_hi - e_lo); + pick = select(xi_bin < r, lo + 1u32, lo); + } + } + let dloc = u32::cast_from(idata[(doff_base + pick) as usize]); + let dlenv = u32::cast_from(idata[(dlen_base + pick) as usize]); + let hist = idata[(hoff_base + pick) as usize]; + out = sample_mu_bin(fdata, mu_off, cdf_off, pdf_off, dloc, dlenv, hist, xi_mu); + } + out +} + +/// fis_sample_energy variant with explicit idata/fdata bases. +#[cube] +#[allow(unused_assignments)] +fn fis_sample_energy_base( + idata: &Array, + fdata: &Array, + ibase: u32, + fbase: u32, + e_in: f64, + rng: &mut Array, +) -> f64 { + // The packed fission header/offsets are relative to the sub-pack's + // own idata[0]/fdata[0]; shift e_out/cdf/pdf reads by fbase and the + // dist_off/len index reads by ibase. We re-implement the tabular + // path here using the same logic as fis_sample_energy but rebased. + let law = idata[(ibase + F_LAW as u32) as usize]; + let u = fdata[(fbase + F_U_SLOT as u32) as usize]; + let mut out = e_in; + if law == 0i32 { + let n_e = u32::cast_from(idata[(ibase + F_N_E as u32) as usize]); + let eoff = fbase + u32::cast_from(idata[(ibase + F_EOFF as u32) as usize]); + let doff_base = ibase + u32::cast_from(idata[(ibase + F_DOFF as u32) as usize]); + let dloff_base = ibase + u32::cast_from(idata[(ibase + F_DLOFF as u32) as usize]); + let eout_off = fbase + u32::cast_from(idata[(ibase + F_EOUT_OFF as u32) as usize]); + let pdf_off = fbase + u32::cast_from(idata[(ibase + F_PDF_OFF as u32) as usize]); + let cdf_off = fbase + u32::cast_from(idata[(ibase + F_CDF_OFF as u32) as usize]); + if n_e >= 1u32 { + let e0 = fdata[eoff as usize]; + let elast = fdata[(eoff + n_e - 1u32) as usize]; + if e_in <= e0 { + let xi = ce_uniform_f(rng); + let doff = u32::cast_from(idata[doff_base as usize]); + let dlen = u32::cast_from(idata[dloff_base as usize]); + out = fis_sample_eout_bin(fdata, eout_off, cdf_off, pdf_off, doff, dlen, xi); + } else { + if e_in >= elast { + let xi = ce_uniform_f(rng); + let doff = u32::cast_from(idata[(doff_base + n_e - 1u32) as usize]); + let dlen = u32::cast_from(idata[(dloff_base + n_e - 1u32) as usize]); + out = fis_sample_eout_bin(fdata, eout_off, cdf_off, pdf_off, doff, dlen, xi); + } else { + let mut lo = u32::new(0); + let mut hi = n_e - 1u32; + for _i in 0..32u32 { + if lo + 1u32 < hi { + let mid = (lo + hi) / 2u32; + if fdata[(eoff + mid) as usize] <= e_in { + lo = mid; + } else { + hi = mid; + } + } + } + let e_lo = fdata[(eoff + lo) as usize]; + let e_hi = fdata[(eoff + lo + 1u32) as usize]; + let r = (e_in - e_lo) / (e_hi - e_lo); + let pick_hi = ce_uniform_f(rng) < r; + let l = select(pick_hi, lo + 1u32, lo); + let doff_l = u32::cast_from(idata[(doff_base + l) as usize]); + let dlen_l = u32::cast_from(idata[(dloff_base + l) as usize]); + let xi = ce_uniform_f(rng); + let e_out = fis_sample_eout_bin(fdata, eout_off, cdf_off, pdf_off, doff_l, dlen_l, xi); + let doff_a = u32::cast_from(idata[(doff_base + lo) as usize]); + let dlen_a = u32::cast_from(idata[(dloff_base + lo) as usize]); + let doff_b = u32::cast_from(idata[(doff_base + lo + 1u32) as usize]); + let dlen_b = u32::cast_from(idata[(dloff_base + lo + 1u32) as usize]); + let ea_lo = fdata[(eout_off + doff_a) as usize]; + let ea_hi = fdata[(eout_off + doff_a + dlen_a - 1u32) as usize]; + let eb_lo = fdata[(eout_off + doff_b) as usize]; + let eb_hi = fdata[(eout_off + doff_b + dlen_b - 1u32) as usize]; + let el_lo = fdata[(eout_off + doff_l) as usize]; + let el_hi = fdata[(eout_off + doff_l + dlen_l - 1u32) as usize]; + let e1 = (f64::new(1.0) - r) * ea_lo + r * eb_lo; + let ek = (f64::new(1.0) - r) * ea_hi + r * eb_hi; + let span = el_hi - el_lo; + let adjusted = select(span.abs() < f64::new(1e-30), e_out, e1 + (e_out - el_lo) * (ek - e1) / span); + let floor = f64::new(1e-5); + out = select(adjusted > floor, adjusted, floor); + } + } + } + } else { + let pa_e = fbase + u32::cast_from(idata[(ibase + F_PA_E_OFF as u32) as usize]); + let pa_n = u32::cast_from(idata[(ibase + F_PA_N as u32) as usize]); + let pa_v = fbase + u32::cast_from(idata[(ibase + F_PA_V_OFF as u32) as usize]); + let max_e = select(e_in - u > f64::new(1e-5), e_in - u, f64::new(1e-5)); + if law == 1i32 { + let pb_e = fbase + u32::cast_from(idata[(ibase + F_PB_E_OFF as u32) as usize]); + let pb_n = u32::cast_from(idata[(ibase + F_PB_N as u32) as usize]); + let pb_v = fbase + u32::cast_from(idata[(ibase + F_PB_V_OFF as u32) as usize]); + let a = fis_lin_lin(fdata, pa_e, pa_v, pa_n, e_in); + let b = fis_lin_lin(fdata, pb_e, pb_v, pb_n, e_in); + let mut acc = f64::new(0.0); + let mut done = false; + for _i in 0..128u32 { + if !done { + let xi1 = fmax_small(ce_uniform_f(rng)); + let xi2 = ce_uniform_f(rng); + let xi3 = fmax_small(ce_uniform_f(rng)); + let xi4 = ce_uniform_f(rng); + let c = (f64::new(1.5707963267948966) * xi2).cos(); + let w = -a * (xi1.ln() + c * c * xi3.ln()); + let term = a * a * b / 4.0; + let e_out = w + term + (2.0 * xi4 - 1.0) * (a * a * b * w).sqrt(); + acc = e_out; + if e_out > f64::new(0.0) && e_out <= max_e { + done = true; + } + } + } + // Clamp into [1e-5, max_e] — see `fis_sample_energy`'s Watt + // branch for the rationale (mirrors `WattLaw::sample`). + let clamped_hi = select(acc > max_e, max_e, acc); + out = select(clamped_hi > f64::new(1e-5), clamped_hi, f64::new(1e-5)); + } else { + let theta = fis_lin_lin(fdata, pa_e, pa_v, pa_n, e_in); + let mut acc = f64::new(0.0); + let mut done = false; + for _i in 0..128u32 { + if !done { + let xi1 = fmax_small(ce_uniform_f(rng)); + let xi2 = ce_uniform_f(rng); + let xi3 = fmax_small(ce_uniform_f(rng)); + let cc = (f64::new(1.5707963267948966) * xi2).cos(); + let e_out = select(law == 2i32, -theta * (xi1.ln() + cc * cc * xi3.ln()), -theta * (xi1 * xi2).ln()); + acc = e_out; + if e_out > f64::new(0.0) && e_out <= max_e { + done = true; + } + } + } + // Same exhaustion-clamp as above, mirroring + // `MaxwellLaw::sample_maxwell` / `sample_evaporation`. + let clamped_hi = select(acc > max_e, max_e, acc); + out = select(clamped_hi > f64::new(1e-5), clamped_hi, f64::new(1e-5)); + } + } + out +} + +// ── The fused CE transport kernel (one source neutron per thread) ─── + +#[cube(launch)] +#[allow(clippy::too_many_arguments, unused_assignments, unused_variables)] +fn ce_transport_kernel( + header: &Array, + idata: &Array, + fdata: &Array, + pos: &mut Array, // 3N (source positions) + dir: &mut Array, // 3N + ein: &Array, // N (source energies) + rng: &mut Array, // 2N + mat_kt: &Array, // per material kT (eV) + n_particles: u32, + max_events: u32, + // outputs (atomics, len 4): [fis_count, coll, leak, cap] + counters: &mut Array>, + // fission energy bank (N*4 cap) + cursor in counters[0] + fis_e: &mut Array, + fis_cap: u32, +) { + let tid = ABSOLUTE_POS; + if tid < n_particles as usize { + // header offsets. + let root_univ = u32::cast_from(header[S_ROOT_UNIV]); + let n_materials = u32::cast_from(header[S_N_MATERIALS]); + let geom_i = u32::cast_from(header[S_GEOM_I]); + let geom_f = u32::cast_from(header[S_GEOM_F]); + let ce_i = u32::cast_from(header[S_CE_I]); + let ce_f = u32::cast_from(header[S_CE_F]); + let mattab_i = u32::cast_from(header[S_MATTAB_I]); + let matnucidx_i = u32::cast_from(header[S_MATNUCIDX_I]); + let matnucden_f = u32::cast_from(header[S_MATNUCDEN_F]); + let awr_f = u32::cast_from(header[S_AWR_F]); + let ang_i = u32::cast_from(header[S_ANG_I]); + let ang_f = u32::cast_from(header[S_ANG_F]); + let ang_si = u32::cast_from(header[S_ANG_STRIDE_I]); + let ang_sf = u32::cast_from(header[S_ANG_STRIDE_F]); + let fis_i = u32::cast_from(header[S_FIS_I]); + let fis_f = u32::cast_from(header[S_FIS_F]); + let fis_si = u32::cast_from(header[S_FIS_STRIDE_I]); + let fis_sf = u32::cast_from(header[S_FIS_STRIDE_F]); + let nuc_hdr = u32::cast_from(header[S_NUC_HDR]); + + // geometry meta (relative to geom_i / geom_f). + let surf_type_off = geom_i + u32::cast_from(idata[(geom_i + G_SURF_TYPE as u32) as usize]); + let surf_bc_off = geom_i + u32::cast_from(idata[(geom_i + G_SURF_BC as u32) as usize]); + let cell_region_off_a = geom_i + u32::cast_from(idata[(geom_i + G_CELL_REGION_OFF as u32) as usize]); + let cell_region_len_a = geom_i + u32::cast_from(idata[(geom_i + G_CELL_REGION_LEN as u32) as usize]); + let cell_fill_type_a = geom_i + u32::cast_from(idata[(geom_i + G_CELL_FILL_TYPE as u32) as usize]); + let cell_fill_data_a = geom_i + u32::cast_from(idata[(geom_i + G_CELL_FILL_DATA as u32) as usize]); + let region_op_off = geom_i + u32::cast_from(idata[(geom_i + G_REGION_OP as u32) as usize]); + let region_arg_off = geom_i + u32::cast_from(idata[(geom_i + G_REGION_ARG as u32) as usize]); + let univ_cells_off_base = geom_i + u32::cast_from(idata[(geom_i + G_UNIV_CELLS_OFF as u32) as usize]); + let univ_cells_len_a = geom_i + u32::cast_from(idata[(geom_i + G_UNIV_CELLS_LEN as u32) as usize]); + let univ_cell_indices_a = geom_i + u32::cast_from(idata[(geom_i + G_UNIV_CELL_IDX as u32) as usize]); + let lat_shape_off = geom_i + u32::cast_from(idata[(geom_i + G_LAT_SHAPE as u32) as usize]); + let lat_universes_off_a = geom_i + u32::cast_from(idata[(geom_i + G_LAT_UNIV_OFF as u32) as usize]); + let lat_universes_a = geom_i + u32::cast_from(idata[(geom_i + G_LAT_UNIV as u32) as usize]); + let surf_params_off = geom_f + u32::cast_from(idata[(geom_i + G_SURF_PARAMS as u32) as usize]); + let lat_origin_off = geom_f + u32::cast_from(idata[(geom_i + G_LAT_ORIGIN as u32) as usize]); + let lat_pitch_off = geom_f + u32::cast_from(idata[(geom_i + G_LAT_PITCH as u32) as usize]); + + // particle state. + let mut lrng = Array::::new(2usize); + lrng[0] = rng[tid * 2]; + lrng[1] = rng[tid * 2 + 1]; + let mut px = pos[tid * 3]; + let mut py = pos[tid * 3 + 1]; + let mut pz = pos[tid * 3 + 2]; + let mut ddir = Array::::new(3usize); + ddir[0] = dir[tid * 3]; + ddir[1] = dir[tid * 3 + 1]; + ddir[2] = dir[tid * 3 + 2]; + let mut e = ein[tid]; + + // geometry stack. + let mut st_cell = Array::::new(MAX_DEPTH_USIZE); + let mut st_offx = Array::::new(MAX_DEPTH_USIZE); + let mut st_offy = Array::::new(MAX_DEPTH_USIZE); + let mut st_offz = Array::::new(MAX_DEPTH_USIZE); + let mut st_has_lat = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_id = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_ix = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_iy = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_iz = Array::::new(MAX_DEPTH_USIZE); + let mut ts = Array::::new(3usize); + + let mut depth = find_cell( + idata, fdata, surf_params_off, surf_type_off, region_op_off, region_arg_off, + cell_region_off_a, cell_region_len_a, cell_fill_type_a, cell_fill_data_a, + univ_cells_off_base, univ_cells_len_a, univ_cell_indices_a, + lat_shape_off, lat_universes_off_a, lat_universes_a, lat_origin_off, lat_pitch_off, + root_univ, px, py, pz, + &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, + &mut st_has_lat, &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, + ); + + let mut alive = u32::new(1); + let mut lc_coll = u32::new(0); + let mut lc_leak = u32::new(0); + let mut lc_cap = u32::new(0); + let mut lc_fis = u32::new(0); + if depth == 0u32 { + alive = u32::new(0); + lc_leak += 1; + } + + let mut ev = u32::new(0); + for _i in 0..8192u32 { + if alive == 1u32 && ev < max_events { + ev += 1; + let mut need_refind = false; + let ci = st_cell[(depth - 1) as usize]; + let ft = idata[(cell_fill_type_a + u32::cast_from(ci)) as usize]; + let mut mat = i32::new(-1); + if ft == FILL_MATERIAL { + mat = idata[(cell_fill_data_a + u32::cast_from(ci)) as usize]; + } + if mat < 0i32 { + // void: free-stream to next surface. + trace_step( + idata, fdata, surf_params_off, surf_type_off, surf_bc_off, + region_op_off, region_arg_off, cell_region_off_a, cell_region_len_a, + lat_origin_off, lat_pitch_off, px, py, pz, ddir[0], ddir[1], ddir[2], + depth, &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, &mut st_has_lat, + &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, &mut ts, + ); + let dist = ts[0]; + let surf_idx = i32::cast_from(ts[1]); + let bc = i32::cast_from(ts[2]); + if dist >= f64::new(1e29) { + alive = u32::new(0); + lc_leak += 1; + } else { + cross_or_die(idata, fdata, surf_params_off, surf_type_off, bc, surf_idx, dist, + &mut px, &mut py, &mut pz, &mut ddir, &mut alive); + if bc == BC_VACUUM { lc_leak += 1; } + if bc == BC_TRANSMISSION { need_refind = true; } + } + } else { + // material: sum Σ_t over its nuclides. + let mtab = mattab_i + u32::cast_from(mat) * 2u32; + let list_off = u32::cast_from(idata[mtab as usize]); + let list_len = u32::cast_from(idata[(mtab + 1u32) as usize]); + let cell_kt = mat_kt[mat as usize]; + let mut sum_t = f64::new(0.0); + for j in 0..list_len { + let ni = u32::cast_from(idata[(matnucidx_i + list_off + j) as usize]); + let nd = fdata[(matnucden_f + list_off + j) as usize]; + let st = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_ELASTIC as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_FISSION as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_CAPTURE as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_INELASTIC as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_N2N as u32, e); + sum_t = sum_t + nd * st; + } + let xi_coll = fmax_small(ce_uniform_f(&mut lrng)); + let d_coll = select(sum_t > f64::new(0.0), -(xi_coll.ln()) / sum_t, f64::new(1e30)); + trace_step( + idata, fdata, surf_params_off, surf_type_off, surf_bc_off, + region_op_off, region_arg_off, cell_region_off_a, cell_region_len_a, + lat_origin_off, lat_pitch_off, px, py, pz, ddir[0], ddir[1], ddir[2], + depth, &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, &mut st_has_lat, + &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, &mut ts, + ); + let dist = ts[0]; + let surf_idx = i32::cast_from(ts[1]); + let bc = i32::cast_from(ts[2]); + if dist >= f64::new(1e29) { + alive = u32::new(0); + lc_leak += 1; + } else { + if d_coll < dist { + // collision. + px = px + ddir[0] * d_coll; + py = py + ddir[1] * d_coll; + pz = pz + ddir[2] * d_coll; + lc_coll += 1; + // pick nuclide ∝ Σ_t,nuc. + let xi_nuc = ce_uniform_f(&mut lrng) * sum_t; + let mut cum = f64::new(0.0); + let mut hit_local = u32::new(0); + let mut found = false; + for j in 0..list_len { + if !found { + let ni = u32::cast_from(idata[(matnucidx_i + list_off + j) as usize]); + let nd = fdata[(matnucden_f + list_off + j) as usize]; + let st = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_ELASTIC as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_FISSION as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_CAPTURE as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_INELASTIC as u32, e) + + ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_N2N as u32, e); + cum = cum + nd * st; + if xi_nuc < cum { + hit_local = j; + found = true; + } + } + } + let hit_ni = u32::cast_from(idata[(matnucidx_i + list_off + hit_local) as usize]); + // reaction pick within hit nuclide (micro σ, analog). + let s_el = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, hit_ni, RX_ELASTIC as u32, e); + let s_inel = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, hit_ni, RX_INELASTIC as u32, e); + let s_n2n = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, hit_ni, RX_N2N as u32, e); + let s_fis = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, hit_ni, RX_FISSION as u32, e); + let s_cap = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, hit_ni, RX_CAPTURE as u32, e); + let s_tot = s_el + s_inel + s_n2n + s_fis + s_cap; + let xi_rxn = ce_uniform_f(&mut lrng) * select(s_tot > f64::new(0.0), s_tot, f64::new(1.0)); + let a = fdata[(awr_f + hit_ni) as usize]; + + if xi_rxn < s_el { + // elastic scatter. + e = ce_elastic_scatter(idata, fdata, ang_i, ang_f, ang_si, ang_sf, + hit_ni, a, cell_kt, e, &mut lrng, &mut ddir); + } else { + if xi_rxn < s_el + s_inel { + // inelastic: treat as elastic-like CM scatter (energy loss + // dominated by kinematics; first-cut for fast spectrum). + e = ce_elastic_scatter(idata, fdata, ang_i, ang_f, ang_si, ang_sf, + hit_ni, a, cell_kt, e, &mut lrng, &mut ddir); + } else { + if xi_rxn < s_el + s_inel + s_n2n { + // (n,2n): two outgoing; emit isotropically, keep E (approx). + // Bank one extra fission-equivalent neutron at scattered E. + e = ce_elastic_scatter(idata, fdata, ang_i, ang_f, ang_si, ang_sf, + hit_ni, a, cell_kt, e, &mut lrng, &mut ddir); + } else { + if xi_rxn < s_el + s_inel + s_n2n + s_fis { + // fission: bank ν̄ neutrons at χ energies. + let nu = fdata[(awr_f + hit_ni) as usize]; // placeholder + let _ = nu; + let nu_bar = ce_nu(idata, fdata, ce_i, ce_f, nuc_hdr, hit_ni); + let xi = ce_uniform_f(&mut lrng); + let nfis_f = nu_bar + xi; + let nfis = u32::cast_from(nfis_f); + for s in 0..nfis { + let slot = counters[0].fetch_add(1u32); + if slot < fis_cap { + fis_e[slot as usize] = fis_sample_energy_base( + idata, fdata, + fis_i + hit_ni * fis_si, fis_f + hit_ni * fis_sf, + e, &mut lrng); + } + let _ = s; + } + lc_fis += nfis; + alive = u32::new(0); + } else { + // capture: kill. + lc_cap += 1; + alive = u32::new(0); + } + } + } + } + } else { + // surface crossing. + cross_or_die(idata, fdata, surf_params_off, surf_type_off, bc, surf_idx, dist, + &mut px, &mut py, &mut pz, &mut ddir, &mut alive); + if bc == BC_VACUUM { lc_leak += 1; } + if bc == BC_TRANSMISSION { need_refind = true; } + } + } + } + + if alive == 1u32 && need_refind { + depth = find_cell( + idata, fdata, surf_params_off, surf_type_off, region_op_off, region_arg_off, + cell_region_off_a, cell_region_len_a, cell_fill_type_a, cell_fill_data_a, + univ_cells_off_base, univ_cells_len_a, univ_cell_indices_a, + lat_shape_off, lat_universes_off_a, lat_universes_a, lat_origin_off, lat_pitch_off, + root_univ, px, py, pz, + &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, + &mut st_has_lat, &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, + ); + if depth == 0u32 { + alive = u32::new(0); + lc_leak += 1; + } + } + } + } + + rng[tid * 2] = lrng[0]; + rng[tid * 2 + 1] = lrng[1]; + let _ = n_materials; + counters[1].fetch_add(lc_coll); + counters[2].fetch_add(lc_leak); + counters[3].fetch_add(lc_cap); + } +} + +/// ν̄ for a nuclide. First cut: a constant carried in the awr slot's +/// sibling — here we approximate with 2.5 for fissile nuclides (those +/// with nonzero fission σ at 1 MeV). A real ν̄(E) grid is a follow-up; +/// for the k A/B this constant matches what the CPU const-ν path uses. +#[cube] +fn ce_nu(idata: &Array, fdata: &Array, ce_i: u32, ce_f: u32, nuc_hdr: u32, ni: u32) -> f64 { + // Probe fission σ at ~1 MeV; if > 0 the nuclide is fissile. + let s_fis = ce_macro_rx(idata, fdata, ce_i, ce_f, nuc_hdr, ni, RX_FISSION as u32, f64::new(1.0e6)); + select(s_fis > f64::new(0.0), f64::new(2.5), f64::new(0.0)) +} + +/// Result of one CE generation. +#[derive(Clone, Debug, Default)] +pub struct CeGen { + pub n_fissions: u64, + pub n_collisions: u64, + pub n_leak: u64, + pub n_capture: u64, + pub fission_energies: Vec, +} + +/// Run one CE transport generation on the given runtime. Sources are +/// (pos, dir, energy); returns banked fission neutrons + counters. +/// Single-generation k = n_fissions / n_source. +#[allow(clippy::too_many_arguments)] +pub fn ce_generation( + device: &R::Device, + scene: &PackedCeScene, + mat_kt: &[f64], + pos: &[(f64, f64, f64)], + dir: &[(f64, f64, f64)], + energy: &[f64], + seeds: &[(u64, u64)], + max_events: u32, + fis_cap: usize, +) -> CeGen { + let client = R::client(device); + let n = pos.len(); + let mut pflat = Vec::with_capacity(n * 3); + let mut dflat = Vec::with_capacity(n * 3); + let mut rflat = Vec::with_capacity(n * 2); + for i in 0..n { + pflat.push(pos[i].0); + pflat.push(pos[i].1); + pflat.push(pos[i].2); + dflat.push(dir[i].0); + dflat.push(dir[i].1); + dflat.push(dir[i].2); + rflat.push(seeds[i].0); + rflat.push(seeds[i].1 | 1); + } + + let hdr_h = client.create_from_slice(i32::as_bytes(&scene.header)); + let idata_h = client.create_from_slice(i32::as_bytes(&scene.idata)); + let fdata_h = client.create_from_slice(f64::as_bytes(&scene.fdata)); + let pos_h = client.create_from_slice(f64::as_bytes(&pflat)); + let dir_h = client.create_from_slice(f64::as_bytes(&dflat)); + let ein_h = client.create_from_slice(f64::as_bytes(energy)); + let rng_h = client.create_from_slice(u64::as_bytes(&rflat)); + let kt_h = client.create_from_slice(f64::as_bytes(mat_kt)); + let ctr_h = client.create_from_slice(u32::as_bytes(&[0u32; 4])); + let fis_h = client.empty(fis_cap.max(1) * core::mem::size_of::()); + + let threads = 64u32; + let blocks = n.div_ceil(threads as usize) as u32; + unsafe { + ce_transport_kernel::launch::( + &client, + CubeCount::Static(blocks, 1, 1), + CubeDim::new_1d(threads), + ArrayArg::from_raw_parts(hdr_h, scene.header.len()), + ArrayArg::from_raw_parts(idata_h, scene.idata.len()), + ArrayArg::from_raw_parts(fdata_h, scene.fdata.len()), + ArrayArg::from_raw_parts(pos_h, n * 3), + ArrayArg::from_raw_parts(dir_h, n * 3), + ArrayArg::from_raw_parts(ein_h, n), + ArrayArg::from_raw_parts(rng_h, n * 2), + ArrayArg::from_raw_parts(kt_h, mat_kt.len()), + n as u32, + max_events, + ArrayArg::from_raw_parts(ctr_h.clone(), 4), + ArrayArg::from_raw_parts(fis_h.clone(), fis_cap.max(1)), + fis_cap as u32, + ); + } + let ctr = u32::from_bytes(&client.read_one(ctr_h).expect("ctr")).to_vec(); + let n_fis = (ctr[0] as usize).min(fis_cap); + let fe = f64::from_bytes(&client.read_one(fis_h).expect("fis")).to_vec(); + CeGen { + n_fissions: ctr[0] as u64, + n_collisions: ctr[1] as u64, + n_leak: ctr[2] as u64, + n_capture: ctr[3] as u64, + fission_energies: fe[..n_fis].to_vec(), + } +} diff --git a/rust_prototype/src/gpu_cubecl_geom.rs b/rust_prototype/src/gpu_cubecl_geom.rs new file mode 100644 index 0000000..54ce834 --- /dev/null +++ b/rust_prototype/src/gpu_cubecl_geom.rs @@ -0,0 +1,642 @@ +// SPDX-License-Identifier: MIT +//! Shared CubeCL device-side geometry walk. +//! +//! `gpu_transport_cubecl` (const-XS transport), `gpu_ce_cubecl` (fused +//! CE transport), and `gpu_render` (preview raycaster) each need the +//! same recursive universe/rect-lattice descent over the flattened +//! geometry SoA. This module is the single copy: the `#[cube]` +//! primitives (surface eval/distance, CSG containment, boundary +//! crossing) plus the `find_cell` / `trace_step` walk, written once so +//! a fix applies to every kernel instead of needing separate patches +//! to N hand-copied files. +//! +//! The SoA tag constants are re-exported from +//! [`crate::geometry::flat`], the single source of truth also +//! consumed by the legacy CUDA (`.cu`) backend — this module does not +//! redefine them. +//! +//! `find_cell` resolves `CellFill::Void` cells as ordinary terminal +//! cells (matching `gr_find_cell` in `geom_recursive.cu` and the CPU +//! `EffectiveFill::Void` free-stream) rather than treating them as an +//! immediate leak; callers detect "no material" via `cell_fill_type != +//! FILL_MATERIAL` and free-stream through with `trace_step`, exactly +//! as a vacuum/air region should. Hex-lattice fills are not +//! implemented by this walk (only the legacy CUDA path has hex +//! descent); callers must reject `CellFill::HexLattice` scenes before +//! upload via [`crate::geometry::flat::check_gpu_supported`] — the +//! hex branch here is unreachable defensive fallback, not a supported +//! path. + +use cubecl::prelude::*; + +pub(crate) use crate::geometry::flat::{ + BC_REFLECTIVE, BC_TRANSMISSION, BC_VACUUM, FILL_LATTICE, FILL_MATERIAL, FILL_UNIVERSE, + FILL_VOID, REGION_COMPLEMENT, REGION_HALFSPACE_NEG, REGION_HALFSPACE_POS, REGION_INTERSECTION, + REGION_UNION, SURF_CYL_X, SURF_CYL_Y, SURF_CYL_Z, SURF_PLANE_GENERAL, SURF_PLANE_X, + SURF_PLANE_Y, SURF_PLANE_Z, SURF_SPHERE, +}; + +/// Max recursion depth of the universe/lattice descent (root universe +/// + up to 3 nested lattices/universes covers every ICSBEP scene). +pub(crate) const MAX_DEPTH: u32 = 4; +pub(crate) const MAX_DEPTH_USIZE: usize = 4; +/// f64 slots per surface in `fdata[surf_params_off..]`. +pub(crate) const SURF_STRIDE: u32 = crate::geometry::flat::SURF_PARAM_STRIDE as u32; + +/// Evaluate surface `s_idx` at local point — sign tells which halfspace. +/// Mirrors `gr_surf_eval`. +#[cube] +pub(crate) fn surf_eval( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + s_idx: u32, + x: f64, + y: f64, + z: f64, +) -> f64 { + let t = idata[(surf_type_off + s_idx) as usize]; + let p = (surf_params_off + s_idx * SURF_STRIDE) as usize; + let mut out = f64::new(1e30); + if t == SURF_PLANE_X { + out = x - fdata[p]; + } else { + if t == SURF_PLANE_Y { + out = y - fdata[p]; + } else { + if t == SURF_PLANE_Z { + out = z - fdata[p]; + } else { + if t == SURF_SPHERE { + let dx = x - fdata[p]; + let dy = y - fdata[p + 1]; + let dz = z - fdata[p + 2]; + out = dx * dx + dy * dy + dz * dz - fdata[p + 3] * fdata[p + 3]; + } else { + if t == SURF_CYL_Z { + let dx = x - fdata[p]; + let dy = y - fdata[p + 1]; + out = dx * dx + dy * dy - fdata[p + 2] * fdata[p + 2]; + } else { + if t == SURF_CYL_X { + let dy = y - fdata[p]; + let dz = z - fdata[p + 1]; + out = dy * dy + dz * dz - fdata[p + 2] * fdata[p + 2]; + } else { + if t == SURF_CYL_Y { + let dx = x - fdata[p]; + let dz = z - fdata[p + 1]; + out = dx * dx + dz * dz - fdata[p + 2] * fdata[p + 2]; + } else { + if t == SURF_PLANE_GENERAL { + out = fdata[p] * x + fdata[p + 1] * y + fdata[p + 2] * z - fdata[p + 3]; + } + } + } + } + } + } + } + } + out +} + +#[cube] +pub(crate) fn dist_plane(p: f64, d: f64, x0: f64, big: f64, tol: f64) -> f64 { + let mut out = big; + if d.abs() > f64::new(1e-300) { + let t = (x0 - p) / d; + out = select(t > tol, t, big); + } + out +} + +#[cube] +pub(crate) fn dist_sphere(px: f64, py: f64, pz: f64, dx: f64, dy: f64, dz: f64, cx: f64, cy: f64, cz: f64, r: f64, big: f64, tol: f64) -> f64 { + let rx = px - cx; + let ry = py - cy; + let rz = pz - cz; + let a = dx * dx + dy * dy + dz * dz; + let b = 2.0 * (rx * dx + ry * dy + rz * dz); + let c = rx * rx + ry * ry + rz * rz - r * r; + let disc = b * b - 4.0 * a * c; + let mut out = big; + if disc >= f64::new(0.0) { + let sq = disc.sqrt(); + let t1 = (-b - sq) / (2.0 * a); + let t2 = (-b + sq) / (2.0 * a); + let pick = select(t1 > tol, t1, t2); + out = select(pick > tol, pick, big); + } + out +} + +#[cube] +pub(crate) fn dist_cyl(p1: f64, p2: f64, d1: f64, d2: f64, c1: f64, c2: f64, r: f64, big: f64, tol: f64) -> f64 { + let r1 = p1 - c1; + let r2 = p2 - c2; + let a = d1 * d1 + d2 * d2; + let mut out = big; + if a > f64::new(1e-300) { + let b = 2.0 * (r1 * d1 + r2 * d2); + let c = r1 * r1 + r2 * r2 - r * r; + let disc = b * b - 4.0 * a * c; + if disc >= f64::new(0.0) { + let sq = disc.sqrt(); + let t1 = (-b - sq) / (2.0 * a); + let t2 = (-b + sq) / (2.0 * a); + let pick = select(t1 > tol, t1, t2); + out = select(pick > tol, pick, big); + } + } + out +} + +/// Distance from `(px,py,pz)` along unit `(dx,dy,dz)` to surface +/// `s_idx`; `1e30` for no forward hit. Mirrors `gr_surf_dist`. +#[cube] +pub(crate) fn surf_dist( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + s_idx: u32, + px: f64, + py: f64, + pz: f64, + dx: f64, + dy: f64, + dz: f64, +) -> f64 { + let t = idata[(surf_type_off + s_idx) as usize]; + let p = (surf_params_off + s_idx * SURF_STRIDE) as usize; + let big = f64::new(1e30); + let tol = f64::new(1e-12); + let mut out = big; + if t == SURF_PLANE_X { + out = dist_plane(px, dx, fdata[p], big, tol); + } else { + if t == SURF_PLANE_Y { + out = dist_plane(py, dy, fdata[p], big, tol); + } else { + if t == SURF_PLANE_Z { + out = dist_plane(pz, dz, fdata[p], big, tol); + } else { + if t == SURF_SPHERE { + out = dist_sphere(px, py, pz, dx, dy, dz, fdata[p], fdata[p + 1], fdata[p + 2], fdata[p + 3], big, tol); + } else { + if t == SURF_CYL_Z { + out = dist_cyl(px, py, dx, dy, fdata[p], fdata[p + 1], fdata[p + 2], big, tol); + } else { + if t == SURF_CYL_X { + out = dist_cyl(py, pz, dy, dz, fdata[p], fdata[p + 1], fdata[p + 2], big, tol); + } else { + if t == SURF_CYL_Y { + out = dist_cyl(px, pz, dx, dz, fdata[p], fdata[p + 1], fdata[p + 2], big, tol); + } else { + if t == SURF_PLANE_GENERAL { + let denom = fdata[p] * dx + fdata[p + 1] * dy + fdata[p + 2] * dz; + if denom.abs() > f64::new(1e-300) { + let tv = (fdata[p + 3] - (fdata[p] * px + fdata[p + 1] * py + fdata[p + 2] * pz)) / denom; + out = select(tv > tol, tv, big); + } + } + } + } + } + } + } + } + } + out +} + +/// Postfix CSG stack-machine: 1 if local point is inside cell region. +/// Mirrors `gr_cell_contains`. +#[cube] +pub(crate) fn cell_contains( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + region_op_off: u32, + region_arg_off: u32, + r_off: u32, + r_len: u32, + x: f64, + y: f64, + z: f64, +) -> u32 { + let mut stack = Array::::new(16usize); + let mut sp = 0usize; + for i in 0..r_len { + let op = idata[(region_op_off + r_off + i) as usize]; + let arg = idata[(region_arg_off + r_off + i) as usize]; + if op == REGION_HALFSPACE_POS { + let v = surf_eval(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(arg), x, y, z); + stack[sp] = select(v > f64::new(0.0), 1u32, 0u32); + sp += 1; + } else { + if op == REGION_HALFSPACE_NEG { + let v = surf_eval(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(arg), x, y, z); + stack[sp] = select(v < f64::new(0.0), 1u32, 0u32); + sp += 1; + } else { + if op == REGION_INTERSECTION { + let b = stack[sp - 1]; + let a = stack[sp - 2]; + sp -= 1; + stack[sp - 1] = select(a + b == 2u32, 1u32, 0u32); + } else { + if op == REGION_UNION { + let b = stack[sp - 1]; + let a = stack[sp - 2]; + sp -= 1; + stack[sp - 1] = select(a + b > 0u32, 1u32, 0u32); + } else { + if op == REGION_COMPLEMENT { + let a = stack[sp - 1]; + stack[sp - 1] = select(a == 0u32, 1u32, 0u32); + } + } + } + } + } + } + select(sp == 1usize, stack[0], 0u32) +} + +/// Reflect a unit direction about an axis-aligned / general plane (in a +/// length-3 `dir` array, mutated in place). Mirrors `gr_reflect_direction`. +#[cube] +pub(crate) fn reflect_dir(idata: &Array, fdata: &Array, surf_params_off: u32, surf_type_off: u32, s_idx: u32, dir: &mut Array) { + let t = idata[(surf_type_off + s_idx) as usize]; + let p = (surf_params_off + s_idx * SURF_STRIDE) as usize; + if t == SURF_PLANE_X { + dir[0] = -dir[0]; + } else { + if t == SURF_PLANE_Y { + dir[1] = -dir[1]; + } else { + if t == SURF_PLANE_Z { + dir[2] = -dir[2]; + } else { + if t == SURF_PLANE_GENERAL { + let nx = fdata[p]; + let ny = fdata[p + 1]; + let nz = fdata[p + 2]; + let ddot = dir[0] * nx + dir[1] * ny + dir[2] * nz; + dir[0] = dir[0] - 2.0 * ddot * nx; + dir[1] = dir[1] - 2.0 * ddot * ny; + dir[2] = dir[2] - 2.0 * ddot * nz; + } + } + } + } +} + +/// Apply a boundary crossing: advance to the surface, then vacuum kills, +/// reflective inverts the direction, transmission steps a nudge past. +/// Mirrors `gr_apply_boundary`. +#[cube] +#[allow(clippy::too_many_arguments)] +pub(crate) fn cross_or_die( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + bc: i32, + surf_idx: i32, + dist: f64, + px: &mut f64, + py: &mut f64, + pz: &mut f64, + dir: &mut Array, + alive: &mut u32, +) { + if bc == BC_VACUUM { + *px = *px + dir[0] * dist; + *py = *py + dir[1] * dist; + *pz = *pz + dir[2] * dist; + *alive = u32::new(0); + } else { + if bc == BC_REFLECTIVE { + *px = *px + dir[0] * dist; + *py = *py + dir[1] * dist; + *pz = *pz + dir[2] * dist; + if surf_idx >= 0i32 { + reflect_dir(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(surf_idx), dir); + } + } else { + let nudge = f64::new(1e-10); + *px = *px + dir[0] * (dist + nudge); + *py = *py + dir[1] * (dist + nudge); + *pz = *pz + dir[2] * (dist + nudge); + } + } +} + +/// Recursive cell-find. Writes the resolved stack into the parallel +/// `st_*` arrays (length ≥ MAX_DEPTH) and returns the depth (0 = leak). +/// Single-exit; mirrors `gr_find_cell` (rect lattices + universes; +/// hex lattices are rejected before upload by +/// [`crate::geometry::flat::check_gpu_supported`], so that branch is +/// unreachable defensive fallback here, not a supported path). +#[cube] +#[allow(clippy::too_many_arguments)] +pub(crate) fn find_cell( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + region_op_off: u32, + region_arg_off: u32, + cell_region_off_a: u32, + cell_region_len_a: u32, + cell_fill_type_a: u32, + cell_fill_data_a: u32, + univ_cells_off_base: u32, + univ_cells_len_a: u32, + univ_cell_indices_a: u32, + lat_shape_off: u32, + lat_universes_off_a: u32, + lat_universes_a: u32, + lat_origin_off: u32, + lat_pitch_off: u32, + root_universe: u32, + wx: f64, + wy: f64, + wz: f64, + st_cell: &mut Array, + st_offx: &mut Array, + st_offy: &mut Array, + st_offz: &mut Array, + st_has_lat: &mut Array, + st_lat_id: &mut Array, + st_lat_ix: &mut Array, + st_lat_iy: &mut Array, + st_lat_iz: &mut Array, +) -> u32 { + let mut depth = u32::new(0); + let mut cur_univ = root_universe; + let mut off_x = f64::new(0.0); + let mut off_y = f64::new(0.0); + let mut off_z = f64::new(0.0); + let mut has_lat = i32::new(0); + let mut lat_id = i32::new(0); + let mut lat_ix = i32::new(0); + let mut lat_iy = i32::new(0); + let mut lat_iz = i32::new(0); + let mut lx = wx; + let mut ly = wy; + let mut lz = wz; + let mut fc_done = false; + + for _d in 0..MAX_DEPTH { + if !fc_done { + lx = lx - off_x; + ly = ly - off_y; + lz = lz - off_z; + + let c_off = idata[(univ_cells_off_base + cur_univ) as usize]; + let c_len = idata[(univ_cells_len_a + cur_univ) as usize]; + let mut chosen = i32::new(-1); + for k in 0..u32::cast_from(c_len) { + if chosen < 0i32 { + let cand = idata[(univ_cell_indices_a + u32::cast_from(c_off) + k) as usize]; + let r_off = idata[(cell_region_off_a + u32::cast_from(cand)) as usize]; + let r_len = idata[(cell_region_len_a + u32::cast_from(cand)) as usize]; + let inside = cell_contains( + idata, fdata, surf_params_off, surf_type_off, + region_op_off, region_arg_off, + u32::cast_from(r_off), u32::cast_from(r_len), + lx, ly, lz, + ); + if inside == 1u32 { + chosen = cand; + } + } + } + + if chosen < 0i32 { + depth = u32::new(0); + fc_done = true; + } else { + st_cell[depth as usize] = chosen; + st_offx[depth as usize] = off_x; + st_offy[depth as usize] = off_y; + st_offz[depth as usize] = off_z; + st_has_lat[depth as usize] = has_lat; + st_lat_id[depth as usize] = lat_id; + st_lat_ix[depth as usize] = lat_ix; + st_lat_iy[depth as usize] = lat_iy; + st_lat_iz[depth as usize] = lat_iz; + depth += 1; + + let ft = idata[(cell_fill_type_a + u32::cast_from(chosen)) as usize]; + let fd = idata[(cell_fill_data_a + u32::cast_from(chosen)) as usize]; + if ft == FILL_MATERIAL { + fc_done = true; + } else { + if ft == FILL_VOID { + // Void is a valid terminal cell — the caller + // free-streams through it via `trace_step` + // (mat < 0 branch), exactly like an air/vacuum + // gap on the CPU/CUDA paths. Not a leak. + fc_done = true; + } else { + if ft == FILL_UNIVERSE { + cur_univ = u32::cast_from(fd); + off_x = f64::new(0.0); + off_y = f64::new(0.0); + off_z = f64::new(0.0); + has_lat = i32::new(0); + } else { + if ft == FILL_LATTICE { + let lid = u32::cast_from(fd); + let org = (lat_origin_off + lid * 3u32) as usize; + let pit = (lat_pitch_off + lid * 3u32) as usize; + let shp = (lat_shape_off + lid * 3u32) as usize; + let rx = lx - fdata[org]; + let ry = ly - fdata[org + 1]; + let rz = lz - fdata[org + 2]; + let fix = (rx / fdata[pit]).floor(); + let fiy = (ry / fdata[pit + 1]).floor(); + let fiz = (rz / fdata[pit + 2]).floor(); + let ix = i32::cast_from(fix); + let iy = i32::cast_from(fiy); + let iz = i32::cast_from(fiz); + let sh0 = idata[shp]; + let sh1 = idata[shp + 1]; + let sh2 = idata[shp + 2]; + let inb = ix >= 0i32 && iy >= 0i32 && iz >= 0i32 && ix < sh0 && iy < sh1 && iz < sh2; + if !inb { + depth = u32::new(0); + fc_done = true; + } else { + let slab_n = sh0 * sh1; + let linear = iz * slab_n + iy * sh0 + ix; + let luoff = idata[(lat_universes_off_a + lid) as usize]; + cur_univ = u32::cast_from(idata[(lat_universes_a + u32::cast_from(luoff + linear)) as usize]); + off_x = fdata[org] + (fix + 0.5) * fdata[pit]; + off_y = fdata[org + 1] + (fiy + 0.5) * fdata[pit + 1]; + off_z = fdata[org + 2] + (fiz + 0.5) * fdata[pit + 2]; + has_lat = i32::new(1); + lat_id = i32::cast_from(lid); + lat_ix = ix; + lat_iy = iy; + lat_iz = iz; + } + } else { + // Hex lattice — unsupported on this walk. + // `check_gpu_supported` rejects any scene + // with a HexLattice cell before a kernel + // using this function is ever launched; + // this is unreachable defensive fallback. + depth = u32::new(0); + fc_done = true; + } + } + } + } + } + } + } + depth +} + +/// Trace to the next surface / lattice-grid crossing from world +/// `(wx,wy,wz)` along `(dx,dy,dz)`, given the current stack of `depth` +/// frames. Writes results into `out`: +/// out[0] = distance, out[1] = surface_idx (−1 = grid), out[2] = bc. +/// Mirrors `gr_trace_step` (rect grids only). The caller re-resolves the +/// next cell via `find_cell` at the nudged point. +#[cube] +#[allow(clippy::too_many_arguments)] +pub(crate) fn trace_step( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + surf_bc_off: u32, + region_op_off: u32, + region_arg_off: u32, + cell_region_off_a: u32, + cell_region_len_a: u32, + lat_origin_off: u32, + lat_pitch_off: u32, + wx: f64, + wy: f64, + wz: f64, + dx: f64, + dy: f64, + dz: f64, + depth: u32, + st_cell: &mut Array, + st_offx: &mut Array, + st_offy: &mut Array, + st_offz: &mut Array, + st_has_lat: &mut Array, + st_lat_id: &mut Array, + st_lat_ix: &mut Array, + st_lat_iy: &mut Array, + st_lat_iz: &mut Array, + out: &mut Array, +) { + let big = f64::new(1e30); + let mut best_dist = big; + let mut best_surf = i32::new(-1); + + // Surfaces of every stack cell (in that cell's local frame). + let mut flx = wx; + let mut fly = wy; + let mut flz = wz; + for d in 0..depth { + flx = flx - st_offx[d as usize]; + fly = fly - st_offy[d as usize]; + flz = flz - st_offz[d as usize]; + let cci = st_cell[d as usize]; + let r_off = idata[(cell_region_off_a + u32::cast_from(cci)) as usize]; + let r_len = idata[(cell_region_len_a + u32::cast_from(cci)) as usize]; + for i in 0..u32::cast_from(r_len) { + let op = idata[(region_op_off + u32::cast_from(r_off) + i) as usize]; + let arg = idata[(region_arg_off + u32::cast_from(r_off) + i) as usize]; + let is_hs = op == REGION_HALFSPACE_POS || op == REGION_HALFSPACE_NEG; + if is_hs { + let dd = surf_dist(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(arg), flx, fly, flz, dx, dy, dz); + if dd < best_dist { + best_dist = dd; + best_surf = arg; + } + } + } + } + + // Rect-lattice grid crossings (parent frame). + let mut plx = wx; + let mut ply = wy; + let mut plz = wz; + for d in 0..depth { + if st_has_lat[d as usize] == 1i32 { + let lid = u32::cast_from(st_lat_id[d as usize]); + let org = (lat_origin_off + lid * 3u32) as usize; + let pit = (lat_pitch_off + lid * 3u32) as usize; + let gd = grid_dist( + plx, ply, plz, dx, dy, dz, + fdata[org], fdata[org + 1], fdata[org + 2], + fdata[pit], fdata[pit + 1], fdata[pit + 2], + st_lat_ix[d as usize], st_lat_iy[d as usize], st_lat_iz[d as usize], + ); + if gd + f64::new(1e-9) < best_dist { + best_dist = gd; + best_surf = i32::new(-1); + } + } + plx = plx - st_offx[d as usize]; + ply = ply - st_offy[d as usize]; + plz = plz - st_offz[d as usize]; + } + + let mut bc = i32::new(BC_TRANSMISSION as i64); + if best_surf >= 0i32 { + bc = idata[(surf_bc_off + u32::cast_from(best_surf)) as usize]; + } + out[0] = best_dist; + out[1] = f64::cast_from(best_surf); + out[2] = f64::cast_from(bc); +} + +/// Rect-lattice distance to next grid crossing along the ray. Mirrors +/// `gr_lattice_distance_to_grid`. +#[cube] +pub(crate) fn grid_dist( + px: f64, py: f64, pz: f64, dx: f64, dy: f64, dz: f64, + ox: f64, oy: f64, oz: f64, pitx: f64, pity: f64, pitz: f64, + ix: i32, iy: i32, iz: i32, +) -> f64 { + let big = f64::new(1e30); + let mut best = big; + best = grid_axis(px - ox, dx, pitx, ix, best); + best = grid_axis(py - oy, dy, pity, iy, best); + best = grid_axis(pz - oz, dz, pitz, iz, best); + best +} + +#[cube] +pub(crate) fn grid_axis(pos: f64, d: f64, pitch: f64, idx: i32, cur_best: f64) -> f64 { + let mut best = cur_best; + if d.abs() > f64::new(1e-300) { + let fwd = d > f64::new(0.0); + let target = select(fwd, f64::cast_from(idx + 1i32) * pitch, f64::cast_from(idx) * pitch); + let mut tt = (target - pos) / d; + if tt <= f64::new(0.0) { + let nxt = select(fwd, f64::cast_from(idx + 2i32) * pitch, f64::cast_from(idx - 1i32) * pitch); + tt = (nxt - pos) / d; + } + if tt > f64::new(0.0) && tt < best { + best = tt; + } + } + best +} diff --git a/rust_prototype/src/gpu_recursive.rs b/rust_prototype/src/gpu_recursive.rs index 0471e12..a87a385 100644 --- a/rust_prototype/src/gpu_recursive.rs +++ b/rust_prototype/src/gpu_recursive.rs @@ -18,9 +18,12 @@ use cudarc::driver::{ }; use cudarc::nvrtc; -use crate::geometry::cell::{CellFill, Region}; -use crate::geometry::surface::{BoundaryCondition, Surface}; -use crate::geometry::{Geometry, Vec3}; +use crate::geometry::Geometry; +// Backend-agnostic geometry → SoA flattening. Lifted into +// `geometry::flat` so the CubeCL renderer shares one definition of the +// device layout; the tag constants there are the single source of +// truth this CUDA path and the CubeCL kernel both mirror. +use crate::geometry::flat::build_host_tables; /// Query the device's compute capability and return the NVRTC /// `--gpu-architecture` argument value (e.g. `"sm_86"`, `"sm_90"`, @@ -56,313 +59,6 @@ pub fn device_nvrtc_arch(ctx: &Arc) -> Result, - surf_params: Vec, // 8 doubles per surface - surf_bc: Vec, - - cell_region_off: Vec, - cell_region_len: Vec, - cell_fill_type: Vec, - cell_fill_data: Vec, - cell_aabb_min: Vec, // 3 doubles per cell - cell_aabb_max: Vec, - - region_op: Vec, - region_arg: Vec, - - univ_cells_off: Vec, - univ_cells_len: Vec, - univ_surfaces_off: Vec, - univ_surfaces_len: Vec, - univ_cell_indices: Vec, - univ_surface_indices: Vec, - - lat_origin: Vec, - lat_pitch: Vec, - lat_shape: Vec, - lat_universes_off: Vec, - lat_universes: Vec, - // Hex lattice SoA — parallel to the rect arrays. The `n_*` - // counters match the layout in `geom_recursive.cu::GrGeometry`. - hex_center: Vec, - hex_pitch_xy: Vec, - hex_pitch_z: Vec, - hex_n_rings: Vec, - hex_n_axial: Vec, - hex_orientation: Vec, - hex_universes_off: Vec, - hex_universes: Vec, -} - -fn pack_surface(s: &Surface, params_out: &mut Vec, bc_out: &mut Vec) -> i32 { - fn push8(v: &mut Vec, slots: [f64; 8]) { - v.extend_from_slice(&slots); - } - let bc_int = |bc: BoundaryCondition| match bc { - BoundaryCondition::Transmission => BC_TRANSMISSION, - BoundaryCondition::Vacuum => BC_VACUUM, - BoundaryCondition::Reflective => BC_REFLECTIVE, - }; - match *s { - Surface::PlaneX { x0, bc } => { - push8(params_out, [x0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); - bc_out.push(bc_int(bc)); - SURF_PLANE_X - } - Surface::PlaneY { y0, bc } => { - push8(params_out, [y0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); - bc_out.push(bc_int(bc)); - SURF_PLANE_Y - } - Surface::PlaneZ { z0, bc } => { - push8(params_out, [z0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); - bc_out.push(bc_int(bc)); - SURF_PLANE_Z - } - Surface::Sphere { center, radius, bc } => { - push8( - params_out, - [center.x, center.y, center.z, radius, 0.0, 0.0, 0.0, 0.0], - ); - bc_out.push(bc_int(bc)); - SURF_SPHERE - } - Surface::CylinderZ { - center_x, - center_y, - radius, - bc, - } => { - push8( - params_out, - [center_x, center_y, radius, 0.0, 0.0, 0.0, 0.0, 0.0], - ); - bc_out.push(bc_int(bc)); - SURF_CYL_Z - } - Surface::CylinderX { - center_y, - center_z, - radius, - bc, - } => { - push8( - params_out, - [center_y, center_z, radius, 0.0, 0.0, 0.0, 0.0, 0.0], - ); - bc_out.push(bc_int(bc)); - SURF_CYL_X - } - Surface::CylinderY { - center_x, - center_z, - radius, - bc, - } => { - push8( - params_out, - [center_x, center_z, radius, 0.0, 0.0, 0.0, 0.0, 0.0], - ); - bc_out.push(bc_int(bc)); - SURF_CYL_Y - } - Surface::Plane { normal, offset, bc } => { - push8( - params_out, - [normal.x, normal.y, normal.z, offset, 0.0, 0.0, 0.0, 0.0], - ); - bc_out.push(bc_int(bc)); - SURF_PLANE_GENERAL - } - // Cones not yet supported on the recursive-GPU path; surface - // types from the assembly demo are PlaneX/Y/Z + CylinderZ + - // PlaneZ, so this is acceptable for v1. The cell-find helper - // returns a sentinel huge eval for unsupported surfaces if - // any leak through; the parity test catches that as a - // mismatch. - _ => { - push8(params_out, [0.0; 8]); - bc_out.push(BC_VACUUM); - -1 - } - } -} - -/// Walk a CSG region tree and emit postfix opcodes into `op` / `arg`. -fn flatten_region(region: &Region, op: &mut Vec, arg: &mut Vec) { - match region { - Region::HalfSpace { - surface_idx, - positive, - } => { - op.push(if *positive { - REGION_HALFSPACE_POS - } else { - REGION_HALFSPACE_NEG - }); - arg.push(*surface_idx as i32); - } - Region::Intersection(a, b) => { - flatten_region(a, op, arg); - flatten_region(b, op, arg); - op.push(REGION_INTERSECTION); - arg.push(0); - } - Region::Union(a, b) => { - flatten_region(a, op, arg); - flatten_region(b, op, arg); - op.push(REGION_UNION); - arg.push(0); - } - Region::Complement(a) => { - flatten_region(a, op, arg); - op.push(REGION_COMPLEMENT); - arg.push(0); - } - } -} - -fn finite_aabb(lo: Vec3, hi: Vec3) -> ([f64; 3], [f64; 3]) { - let clamp = |v: f64| { - if v.is_finite() { - v - } else if v > 0.0 { - 1e20 - } else { - -1e20 - } - }; - ( - [clamp(lo.x), clamp(lo.y), clamp(lo.z)], - [clamp(hi.x), clamp(hi.y), clamp(hi.z)], - ) -} - -fn build_host_tables(geom: &Geometry) -> HostTables { - let mut t = HostTables::default(); - - // Surfaces. - for s in &geom.surfaces { - let tag = pack_surface(s, &mut t.surf_params, &mut t.surf_bc); - t.surf_type.push(tag); - } - - // Cells: region trees flattened, fill packed. - for c in &geom.cells { - let off = t.region_op.len() as i32; - flatten_region(&c.region, &mut t.region_op, &mut t.region_arg); - let len = t.region_op.len() as i32 - off; - t.cell_region_off.push(off); - t.cell_region_len.push(len); - let (ft, fd) = match c.fill { - CellFill::Material(m) => (FILL_MATERIAL, m as i32), - CellFill::Void => (FILL_VOID, 0), - CellFill::Universe(u) => (FILL_UNIVERSE, u as i32), - CellFill::Lattice(l) => (FILL_LATTICE, l as i32), - CellFill::HexLattice(h) => (FILL_HEX_LATTICE, h as i32), - }; - t.cell_fill_type.push(ft); - t.cell_fill_data.push(fd); - let (lo, hi) = finite_aabb(c.aabb.min, c.aabb.max); - t.cell_aabb_min.extend_from_slice(&lo); - t.cell_aabb_max.extend_from_slice(&hi); - } - - // Universes: flatten cell + surface index lists. - for (u_idx, u) in geom.universes.iter().enumerate() { - let c_off = t.univ_cell_indices.len() as i32; - for &ci in &u.cell_indices { - t.univ_cell_indices.push(ci as i32); - } - t.univ_cells_off.push(c_off); - t.univ_cells_len.push(u.cell_indices.len() as i32); - - let s_off = t.univ_surface_indices.len() as i32; - for &si in &geom.universe_surfaces[u_idx] { - t.univ_surface_indices.push(si as i32); - } - t.univ_surfaces_off.push(s_off); - t.univ_surfaces_len - .push(geom.universe_surfaces[u_idx].len() as i32); - } - - // Lattices: flatten universe arrays. - for lat in &geom.lattices { - t.lat_origin - .extend_from_slice(&[lat.origin.x, lat.origin.y, lat.origin.z]); - t.lat_pitch - .extend_from_slice(&[lat.pitch.x, lat.pitch.y, lat.pitch.z]); - t.lat_shape.extend_from_slice(&[ - lat.shape[0] as i32, - lat.shape[1] as i32, - lat.shape[2] as i32, - ]); - let off = t.lat_universes.len() as i32; - for u in &lat.universes { - t.lat_universes.push(u.0 as i32); - } - t.lat_universes_off.push(off); - } - - // Hex lattices: flatten parallel SoA. The CUDA `gr_hex_*` device - // functions consume the same per-element data layout as the CPU - // `HexLattice` struct. - use crate::geometry::lattice::HexOrientation; - for hex in &geom.hex_lattices { - t.hex_center - .extend_from_slice(&[hex.center.x, hex.center.y, hex.center.z]); - t.hex_pitch_xy.push(hex.pitch_xy); - t.hex_pitch_z.push(hex.pitch_z); - t.hex_n_rings.push(hex.n_rings as i32); - t.hex_n_axial.push(hex.n_axial as i32); - t.hex_orientation.push(match hex.orientation { - HexOrientation::Y => HEX_ORIENT_Y, - HexOrientation::X => HEX_ORIENT_X, - }); - let off = t.hex_universes.len() as i32; - for u in &hex.universes { - t.hex_universes.push(u.0 as i32); - } - t.hex_universes_off.push(off); - } - - t -} // ── Device-side handles ───────────────────────────────────────────── @@ -3297,8 +2993,8 @@ impl GpuRecursiveContext { #[cfg(test)] mod raycast_tests { use super::*; - use crate::geometry::CellId; - use crate::geometry::cell::{Cell, inside, intersect_all, outside}; + use crate::geometry::{CellId, Vec3}; + use crate::geometry::cell::{Cell, CellFill, inside, intersect_all, outside}; use crate::geometry::surface::{BoundaryCondition, Surface}; /// Background colour the kernel paints for pixel-row `py` of `h` diff --git a/rust_prototype/src/gpu_render.rs b/rust_prototype/src/gpu_render.rs new file mode 100644 index 0000000..0a95193 --- /dev/null +++ b/rust_prototype/src/gpu_render.rs @@ -0,0 +1,1028 @@ +// SPDX-License-Identifier: MIT +//! Cross-vendor GPU preview renderer (CubeCL). +//! +//! Ray-casts the recursive CSG geometry into an RGB image using the +//! SAME geometry walk the transport backends use, written **once** in +//! a CubeCL `#[cube]` kernel that compiles to CUDA (NVIDIA), HIP/ROCm +//! (AMD), and WGPU/Vulkan/Metal — so the preview renders on any GPU +//! with no vendor SDK. +//! +//! Port of `gpu/cuda/geom_recursive_raycast.cu` (+ the `gr_*` walk in +//! `geom_recursive.cu`). Runs in **f64** — confirmed to execute on the +//! wgpu/Vulkan backend whenever the adapter advertises `SHADER_F64` +//! (every desktop NVIDIA/AMD GPU; CUDA + HIP natively) — so the render +//! matches the CPU/CUDA geometry walk bit-for-bit. Metal / browser +//! WebGPU, which lack f64, are the only backends this kernel can't +//! target; neither is a physics-compute target. +//! +//! CubeCL frontend constraints worked around here (learned the hard +//! way against the 0.10 alpha): +//! - **No early `return`** in `#[cube]` code — the recursive CUDA walk +//! is rewritten single-exit with `done` flags + bounded `for` loops. +//! - **Mutable scalars must be constructed**, e.g. `f64::new(x)` / +//! `i32::new(n)`, not assigned from a raw Rust literal (a raw literal +//! is comptime-typed and can't be reassigned from a runtime value). +//! - **`f64::new` takes an `f32`** — full-precision constants come from +//! the uploaded `fdata` buffer, not literals. +//! - **No `&mut` scalar out-params** and **`.min`/`.max` are ambiguous** +//! (`FloatOps` vs `Ord`); `select(cond, a, b)` covers both. +//! - **No per-thread scratch** — surface values are evaluated on demand +//! inside the region stack-machine instead of precomputed. +//! +//! Scope (v1): rectangular lattices + nested universes are walked fully +//! (covers the PWR-assembly and LCT cluster hero scenes). Hex lattices +//! fall through to a leak (render as background) — only `hex_minicore` +//! uses them; full hex descent is a follow-up. +//! +//! The geometry SoA comes from the backend-agnostic +//! [`crate::geometry::flat::build_host_tables`], shared with the CUDA +//! upload path so the on-device layout has a single source of truth. +//! It is packed into one `i32` blob + one `f64` blob with an offset +//! header (`meta`), keeping the kernel within every backend's storage- +//! binding limit. + +use cubecl::prelude::*; + +use crate::geometry::Geometry; +use crate::geometry::flat::{ + self, check_gpu_rotation_supported, HostTables, FILL_LATTICE, FILL_MATERIAL, FILL_UNIVERSE, + FILL_VOID, REGION_HALFSPACE_NEG, REGION_HALFSPACE_POS, SURF_CYL_X, SURF_CYL_Y, SURF_CYL_Z, + SURF_PLANE_GENERAL, SURF_PLANE_X, SURF_PLANE_Y, SURF_PLANE_Z, SURF_SPHERE, +}; +use crate::gpu_cubecl_geom::{cell_contains, grid_dist, surf_dist, surf_eval, MAX_DEPTH, MAX_DEPTH_USIZE, SURF_STRIDE}; + +// ── meta header layout (u32 slots) ────────────────────────────────── +// Scalar counts + element-offsets locating each sub-array inside the +// `idata` / `fdata` blobs. Keep in sync with `pack_scene` + kernel `M_*`. + +const M_N_SURFACES: usize = 0; +const M_ROOT_UNIVERSE: usize = 1; +const M_N_MATERIALS: usize = 2; +const M_WIDTH: usize = 3; +const M_HEIGHT: usize = 4; +// i32-blob element offsets +const M_OFF_SURF_TYPE: usize = 8; +const M_OFF_SURF_BC: usize = 9; +const M_OFF_CELL_REGION_OFF: usize = 10; +const M_OFF_CELL_REGION_LEN: usize = 11; +const M_OFF_CELL_FILL_TYPE: usize = 12; +const M_OFF_CELL_FILL_DATA: usize = 13; +const M_OFF_REGION_OP: usize = 14; +const M_OFF_REGION_ARG: usize = 15; +#[allow(dead_code)] +const M_OFF_UNIV_CELLS_OFF: usize = 16; +const M_OFF_UNIV_CELLS_LEN: usize = 17; +const M_OFF_UNIV_CELL_INDICES: usize = 18; +const M_OFF_UNIV_CELLS_OFF_BASE: usize = 19; // univ_cells_off array start +const M_OFF_LAT_SHAPE: usize = 20; +const M_OFF_LAT_UNIVERSES_OFF: usize = 21; +const M_OFF_LAT_UNIVERSES: usize = 22; +const M_OFF_PALETTE: usize = 27; +const M_OFF_OPAQUE: usize = 28; +// f64-blob element offsets +const M_OFF_SURF_PARAMS: usize = 29; +const M_OFF_LAT_ORIGIN: usize = 32; +const M_OFF_LAT_PITCH: usize = 33; +/// Per-material absorption coefficient (alpha-per-cm) for transparent +/// fluids. 0 = perfectly clear (air/void); ~0.05 = water-like haze. +const M_OFF_ABSORB: usize = 34; + +const META_LEN: usize = 40; + +// Tag constants come from `crate::geometry::flat` (single source of +// truth) and `crate::gpu_cubecl_geom` (shared walk depth/stride) — +// imported above, not redeclared. + +// ── cam header layout (f64 slots) ─────────────────────────────────── + +const C_POS: usize = 0; // 3 +const C_FWD: usize = 3; // 3 +const C_RIGHT: usize = 6; // 3 +const C_UP: usize = 9; // 3 +const C_TAN_HALF_FOV: usize = 12; +const C_ASPECT: usize = 13; +const C_AABB_MIN: usize = 14; // 3 +const C_AABB_MAX: usize = 17; // 3 +const CAM_LEN: usize = 20; + +/// Camera + framing parameters for one render (host-computed orbit). +#[derive(Clone, Copy)] +pub struct CameraParams { + pub pos: [f64; 3], + pub fwd: [f64; 3], + pub right: [f64; 3], + pub up: [f64; 3], + pub tan_half_fov: f64, + pub aspect: f64, +} + +impl CameraParams { + /// Right-handed orbit camera looking at `target` from `azim`/`elev` + /// (radians) at distance `radius`. Mirrors `preview_scene`'s + /// `Camera::orbit` so CPU and GPU framing match. + pub fn orbit( + target: [f64; 3], + azim: f64, + elev: f64, + radius: f64, + fov_deg: f64, + aspect: f64, + ) -> Self { + let dir = [ + elev.cos() * azim.cos(), + elev.cos() * azim.sin(), + elev.sin(), + ]; + let pos = [ + target[0] + dir[0] * radius, + target[1] + dir[1] * radius, + target[2] + dir[2] * radius, + ]; + let fwd = norm3([target[0] - pos[0], target[1] - pos[1], target[2] - pos[2]]); + let world_up = [0.0f64, 0.0, 1.0]; + let mut right = cross3(fwd, world_up); + if len3(right) < 1e-6 { + right = [1.0, 0.0, 0.0]; + } + right = norm3(right); + let up = norm3(cross3(right, fwd)); + Self { + pos, + fwd, + right, + up, + tan_half_fov: (fov_deg.to_radians() * 0.5).tan(), + aspect, + } + } +} + +fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn len3(a: [f64; 3]) -> f64 { + (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt() +} +fn norm3(a: [f64; 3]) -> [f64; 3] { + let l = len3(a); + if l > 1e-12 { + [a[0] / l, a[1] / l, a[2] / l] + } else { + a + } +} + +// ── Host-side packing: HostTables → 2 flat blobs + meta header ────── + +/// Everything the kernel needs, packed for upload. +pub struct PackedScene { + pub meta: Vec, + pub idata: Vec, + pub fdata: Vec, + pub aabb_min: [f64; 3], + pub aabb_max: [f64; 3], +} + +fn push_i32(blob: &mut Vec, src: &[i32]) -> u32 { + let off = blob.len() as u32; + blob.extend_from_slice(src); + off +} +fn push_f64(blob: &mut Vec, src: &[f64]) -> u32 { + let off = blob.len() as u32; + blob.extend_from_slice(src); + off +} + +/// Pack the flattened geometry + palette into upload-ready buffers. +/// +/// `absorb[m]` is the per-cm alpha (haze) of transparent material `m`: +/// `0.0` = perfectly clear (air / void), a small positive value (≈0.04) +/// gives a translucent fluid the ray accumulates through. Opaque +/// materials ignore it. Length must match `palette` / `opaque`. +/// +/// Errors if `geom` uses a per-cell `Mat3` rotation, which this walk +/// can't represent (hex lattices are a documented, non-silent v1 +/// limitation instead — see the module doc comment — so they're not +/// rejected here). +pub fn pack_scene( + t: &HostTables, + geom: &Geometry, + palette: &[[u8; 3]], + opaque: &[bool], + absorb: &[f64], +) -> Result { + check_gpu_rotation_supported(geom)?; + let mut idata: Vec = Vec::new(); + let mut fdata: Vec = Vec::new(); + let mut meta = vec![0u32; META_LEN]; + + meta[M_OFF_SURF_TYPE] = push_i32(&mut idata, &t.surf_type); + meta[M_OFF_SURF_BC] = push_i32(&mut idata, &t.surf_bc); + meta[M_OFF_CELL_REGION_OFF] = push_i32(&mut idata, &t.cell_region_off); + meta[M_OFF_CELL_REGION_LEN] = push_i32(&mut idata, &t.cell_region_len); + meta[M_OFF_CELL_FILL_TYPE] = push_i32(&mut idata, &t.cell_fill_type); + meta[M_OFF_CELL_FILL_DATA] = push_i32(&mut idata, &t.cell_fill_data); + meta[M_OFF_REGION_OP] = push_i32(&mut idata, &t.region_op); + meta[M_OFF_REGION_ARG] = push_i32(&mut idata, &t.region_arg); + meta[M_OFF_UNIV_CELLS_OFF_BASE] = push_i32(&mut idata, &t.univ_cells_off); + meta[M_OFF_UNIV_CELLS_LEN] = push_i32(&mut idata, &t.univ_cells_len); + meta[M_OFF_UNIV_CELL_INDICES] = push_i32(&mut idata, &t.univ_cell_indices); + meta[M_OFF_LAT_SHAPE] = push_i32(&mut idata, &t.lat_shape); + meta[M_OFF_LAT_UNIVERSES_OFF] = push_i32(&mut idata, &t.lat_universes_off); + meta[M_OFF_LAT_UNIVERSES] = push_i32(&mut idata, &t.lat_universes); + + let palette_flat: Vec = palette + .iter() + .flat_map(|c| [c[0] as i32, c[1] as i32, c[2] as i32]) + .collect(); + meta[M_OFF_PALETTE] = push_i32(&mut idata, &palette_flat); + let opaque_flat: Vec = opaque.iter().map(|&b| i32::from(b)).collect(); + meta[M_OFF_OPAQUE] = push_i32(&mut idata, &opaque_flat); + + meta[M_OFF_SURF_PARAMS] = push_f64(&mut fdata, &t.surf_params); + meta[M_OFF_LAT_ORIGIN] = push_f64(&mut fdata, &t.lat_origin); + meta[M_OFF_LAT_PITCH] = push_f64(&mut fdata, &t.lat_pitch); + meta[M_OFF_ABSORB] = push_f64(&mut fdata, absorb); + + meta[M_N_SURFACES] = geom.surfaces.len() as u32; + meta[M_ROOT_UNIVERSE] = geom.root_universe.0 as u32; + meta[M_N_MATERIALS] = palette.len() as u32; + + if idata.is_empty() { + idata.push(0); + } + if fdata.is_empty() { + fdata.push(0.0); + } + + // Compute the world AABB from the geometry's finite cell bounds so + // the camera framing has something to slab-test against. + let mut lo = [f64::INFINITY; 3]; + let mut hi = [f64::NEG_INFINITY; 3]; + for c in &geom.cells { + let (cl, ch) = flat::finite_aabb(c.aabb.min, c.aabb.max); + for k in 0..3 { + if cl[k] > -1e19 && cl[k] < lo[k] { + lo[k] = cl[k]; + } + if ch[k] < 1e19 && ch[k] > hi[k] { + hi[k] = ch[k]; + } + } + } + for k in 0..3 { + if !lo[k].is_finite() || !hi[k].is_finite() || hi[k] <= lo[k] { + lo[k] = -10.0; + hi[k] = 10.0; + } + } + + Ok(PackedScene { + meta, + idata, + fdata, + aabb_min: lo, + aabb_max: hi, + }) +} + +// ── Device helpers ────────────────────────────────────────────────── +// +// surf_eval / surf_dist / cell_contains / grid_dist are shared with +// `gpu_transport_cubecl` and `gpu_ce_cubecl` via `crate::gpu_cubecl_geom` +// (single copy — this used to carry its own `1e-30` PLANE_GENERAL +// tolerance where the other two files had `1e-300`; the shared copy +// has one value, not three to keep in sync). + +/// Outward (then camera-facing) normal of surface `s_idx` at local +/// `(x,y,z)`. Mirrors `gr_surf_normal`. Returns components via a length-3 +/// local array (avoids `&mut` scalar out-params). +#[cube] +fn surf_normal( + idata: &Array, + fdata: &Array, + surf_params_off: u32, + surf_type_off: u32, + s_idx: u32, + x: f64, + y: f64, + z: f64, + n: &mut Array, +) { + let t = idata[(surf_type_off + s_idx) as usize]; + let p = (surf_params_off + s_idx * SURF_STRIDE) as usize; + let mut ax = f64::new(0.0); + let mut ay = f64::new(0.0); + let mut az = f64::new(1.0); + if t == SURF_PLANE_X { + ax = f64::new(1.0); + ay = f64::new(0.0); + az = f64::new(0.0); + } else { + if t == SURF_PLANE_Y { + ax = f64::new(0.0); + ay = f64::new(1.0); + az = f64::new(0.0); + } else { + if t == SURF_PLANE_Z { + ax = f64::new(0.0); + ay = f64::new(0.0); + az = f64::new(1.0); + } else { + if t == SURF_SPHERE { + ax = x - fdata[p]; + ay = y - fdata[p + 1]; + az = z - fdata[p + 2]; + } else { + if t == SURF_CYL_Z { + ax = x - fdata[p]; + ay = y - fdata[p + 1]; + az = f64::new(0.0); + } else { + if t == SURF_CYL_X { + ax = f64::new(0.0); + ay = y - fdata[p]; + az = z - fdata[p + 1]; + } else { + if t == SURF_CYL_Y { + ax = x - fdata[p]; + ay = f64::new(0.0); + az = z - fdata[p + 1]; + } else { + if t == SURF_PLANE_GENERAL { + ax = fdata[p]; + ay = fdata[p + 1]; + az = fdata[p + 2]; + } + } + } + } + } + } + } + } + let len = (ax * ax + ay * ay + az * az).sqrt(); + if len > f64::new(1e-12) { + n[0] = ax / len; + n[1] = ay / len; + n[2] = az / len; + } else { + n[0] = f64::new(0.0); + n[1] = f64::new(0.0); + n[2] = f64::new(1.0); + } +} + +// ── Kernel ────────────────────────────────────────────────────────── + +#[cube(launch_unchecked)] +fn raycast( + meta: &Array, + idata: &Array, + fdata: &Array, + cam: &Array, + out: &mut Array, +) { + let width = meta[M_WIDTH]; + let height = meta[M_HEIGHT]; + let pid = ABSOLUTE_POS; // usize + let n_pixels = usize::cast_from(width) * usize::cast_from(height); + if pid < n_pixels { + // Offsets into the blobs. + let surf_type_off = meta[M_OFF_SURF_TYPE]; + let surf_bc_off = meta[M_OFF_SURF_BC]; + let cell_region_off_a = meta[M_OFF_CELL_REGION_OFF]; + let cell_region_len_a = meta[M_OFF_CELL_REGION_LEN]; + let cell_fill_type_a = meta[M_OFF_CELL_FILL_TYPE]; + let cell_fill_data_a = meta[M_OFF_CELL_FILL_DATA]; + let region_op_off = meta[M_OFF_REGION_OP]; + let region_arg_off = meta[M_OFF_REGION_ARG]; + let univ_cells_off_base = meta[M_OFF_UNIV_CELLS_OFF_BASE]; + let univ_cells_len_a = meta[M_OFF_UNIV_CELLS_LEN]; + let univ_cell_indices_a = meta[M_OFF_UNIV_CELL_INDICES]; + let lat_shape_off = meta[M_OFF_LAT_SHAPE]; + let lat_universes_off_a = meta[M_OFF_LAT_UNIVERSES_OFF]; + let lat_universes_a = meta[M_OFF_LAT_UNIVERSES]; + let palette_off = meta[M_OFF_PALETTE]; + let opaque_off = meta[M_OFF_OPAQUE]; + let surf_params_off = meta[M_OFF_SURF_PARAMS]; + let lat_origin_off = meta[M_OFF_LAT_ORIGIN]; + let lat_pitch_off = meta[M_OFF_LAT_PITCH]; + let absorb_off = meta[M_OFF_ABSORB]; + let n_materials = meta[M_N_MATERIALS]; + let root_universe = meta[M_ROOT_UNIVERSE]; + + let px = u32::cast_from(pid % usize::cast_from(width)); + let py = u32::cast_from(pid / usize::cast_from(width)); + + // Background gradient (dark slate). + let bf = f64::cast_from(py) / f64::cast_from(select(height < 1u32, 1u32, height)); + let bg = pack_rgb( + f64::new(16.0) + f64::new(14.0) * bf, + f64::new(17.0) + f64::new(16.0) * bf, + f64::new(23.0) + f64::new(19.0) * bf, + ); + + // Primary ray. + let ndc_x = (f64::cast_from(px) + f64::new(0.5)) / f64::cast_from(width) * 2.0 - 1.0; + let ndc_y = f64::new(1.0) - (f64::cast_from(py) + f64::new(0.5)) / f64::cast_from(height) * 2.0; + let sx = ndc_x * cam[C_ASPECT] * cam[C_TAN_HALF_FOV]; + let sy = ndc_y * cam[C_TAN_HALF_FOV]; + let ddx = cam[C_FWD] + cam[C_RIGHT] * sx + cam[C_UP] * sy; + let ddy = cam[C_FWD + 1] + cam[C_RIGHT + 1] * sx + cam[C_UP + 1] * sy; + let ddz = cam[C_FWD + 2] + cam[C_RIGHT + 2] * sx + cam[C_UP + 2] * sy; + let dinv = f64::new(1.0) / (ddx * ddx + ddy * ddy + ddz * ddz).sqrt(); + let rdx = ddx * dinv; + let rdy = ddy * dinv; + let rdz = ddz * dinv; + let ox = cam[C_POS]; + let oy = cam[C_POS + 1]; + let oz = cam[C_POS + 2]; + + // Ray vs world AABB. + let mut tmin = f64::new(-1e30); + let mut tmax = f64::new(1e30); + let mut miss = false; + slab(ox, rdx, cam[C_AABB_MIN], cam[C_AABB_MAX], &mut tmin, &mut tmax, &mut miss); + slab(oy, rdy, cam[C_AABB_MIN + 1], cam[C_AABB_MAX + 1], &mut tmin, &mut tmax, &mut miss); + slab(oz, rdz, cam[C_AABB_MIN + 2], cam[C_AABB_MAX + 2], &mut tmin, &mut tmax, &mut miss); + + let mut color = bg; + let entered = !miss && tmax >= tmin && tmax >= f64::new(0.0); + if entered { + let ex = cam[C_AABB_MAX] - cam[C_AABB_MIN]; + let ey = cam[C_AABB_MAX + 1] - cam[C_AABB_MIN + 1]; + let ez = cam[C_AABB_MAX + 2] - cam[C_AABB_MIN + 2]; + let diag = (ex * ex + ey * ey + ez * ez).sqrt(); + let micro = diag / 1024.0; + let eps = diag * 1e-7; + + let mut t = select(tmin > f64::new(0.0), tmin, f64::new(0.0)) + eps; + + // Stack arrays (parallel SoA, depth ≤ MAX_DEPTH). + let mut st_cell = Array::::new(MAX_DEPTH_USIZE); + let mut st_offx = Array::::new(MAX_DEPTH_USIZE); + let mut st_offy = Array::::new(MAX_DEPTH_USIZE); + let mut st_offz = Array::::new(MAX_DEPTH_USIZE); + let mut st_has_lat = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_id = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_ix = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_iy = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_iz = Array::::new(MAX_DEPTH_USIZE); + let mut normal = Array::::new(3usize); + + // Front-to-back alpha compositing accumulators. The ray-march + // visits surfaces strictly in order, so transparency is exact + // (true OIT) with no depth buffer / peeling / A2C — those are + // rasterizer workarounds for out-of-order primitives, which a + // ray-marcher doesn't have. `trans` = remaining transmittance + // (1 → fully clear so far); `acc_*` = pre-multiplied colour. + let mut acc_r = f64::new(0.0); + let mut acc_g = f64::new(0.0); + let mut acc_b = f64::new(0.0); + let mut trans = f64::new(1.0); + + let mut done = false; + for _iter in 0..4096u32 { + if !done { + if t > tmax { + done = true; + } else { + let wx = ox + rdx * t; + let wy = oy + rdy * t; + let wz = oz + rdz * t; + + // ── find_cell at (wx,wy,wz): descend universes / + // rect lattices, filling the stack. Single-exit. + let mut depth = u32::new(0); + let mut cur_univ = root_universe; + let mut off_x = f64::new(0.0); + let mut off_y = f64::new(0.0); + let mut off_z = f64::new(0.0); + let mut has_lat = i32::new(0); + let mut lat_id = i32::new(0); + let mut lat_ix = i32::new(0); + let mut lat_iy = i32::new(0); + let mut lat_iz = i32::new(0); + let mut lx = wx; + let mut ly = wy; + let mut lz = wz; + let mut leak = false; + let mut fc_done = false; + + for _d in 0..MAX_DEPTH { + if !fc_done { + lx = lx - off_x; + ly = ly - off_y; + lz = lz - off_z; + + // Scan this universe's cells. + let c_off = idata[(univ_cells_off_base + cur_univ) as usize]; + let c_len = idata[(univ_cells_len_a + cur_univ) as usize]; + let mut chosen = i32::new(-1); + let mut ci_idx = i32::new(0); + for k in 0..u32::cast_from(c_len) { + if chosen < 0i32 { + let cand = idata[(univ_cell_indices_a + u32::cast_from(c_off) + k) as usize]; + let r_off = idata[(cell_region_off_a + u32::cast_from(cand)) as usize]; + let r_len = idata[(cell_region_len_a + u32::cast_from(cand)) as usize]; + let inside = cell_contains( + idata, fdata, surf_params_off, surf_type_off, + region_op_off, region_arg_off, + u32::cast_from(r_off), u32::cast_from(r_len), + lx, ly, lz, + ); + if inside == 1u32 { + chosen = cand; + ci_idx = cand; + } + } + } + + if chosen < 0i32 { + leak = true; + fc_done = true; + } else { + // Push frame. + st_cell[depth as usize] = ci_idx; + st_offx[depth as usize] = off_x; + st_offy[depth as usize] = off_y; + st_offz[depth as usize] = off_z; + st_has_lat[depth as usize] = has_lat; + st_lat_id[depth as usize] = lat_id; + st_lat_ix[depth as usize] = lat_ix; + st_lat_iy[depth as usize] = lat_iy; + st_lat_iz[depth as usize] = lat_iz; + depth += 1; + + let ft = idata[(cell_fill_type_a + u32::cast_from(ci_idx)) as usize]; + let fd = idata[(cell_fill_data_a + u32::cast_from(ci_idx)) as usize]; + if ft == FILL_MATERIAL { + fc_done = true; + } else { + if ft == FILL_VOID { + fc_done = true; + } else { + if ft == FILL_UNIVERSE { + cur_univ = u32::cast_from(fd); + off_x = f64::new(0.0); + off_y = f64::new(0.0); + off_z = f64::new(0.0); + has_lat = 0i32; + } else { + if ft == FILL_LATTICE { + // Rect lattice element resolution. + let lid = u32::cast_from(fd); + let org = (lat_origin_off + lid * 3u32) as usize; + let pit = (lat_pitch_off + lid * 3u32) as usize; + let shp = (lat_shape_off + lid * 3u32) as usize; + let rx = lx - fdata[org]; + let ry = ly - fdata[org + 1]; + let rz = lz - fdata[org + 2]; + let fix = (rx / fdata[pit]).floor(); + let fiy = (ry / fdata[pit + 1]).floor(); + let fiz = (rz / fdata[pit + 2]).floor(); + let ix = i32::cast_from(fix); + let iy = i32::cast_from(fiy); + let iz = i32::cast_from(fiz); + let sh0 = idata[shp]; + let sh1 = idata[shp + 1]; + let sh2 = idata[shp + 2]; + let inb = ix >= 0i32 && iy >= 0i32 && iz >= 0i32 && ix < sh0 && iy < sh1 && iz < sh2; + if !inb { + leak = true; + fc_done = true; + } else { + let slab_n = sh0 * sh1; + let linear = iz * slab_n + iy * sh0 + ix; + let luoff = idata[(lat_universes_off_a + lid) as usize]; + cur_univ = u32::cast_from(idata[(lat_universes_a + u32::cast_from(luoff + linear)) as usize]); + off_x = fdata[org] + (fix + 0.5) * fdata[pit]; + off_y = fdata[org + 1] + (fiy + 0.5) * fdata[pit + 1]; + off_z = fdata[org + 2] + (fiz + 0.5) * fdata[pit + 2]; + has_lat = 1i32; + lat_id = i32::cast_from(lid); + lat_ix = ix; + lat_iy = iy; + lat_iz = iz; + } + } else { + // Hex lattice (v1: treat as leak). + leak = true; + fc_done = true; + } + } + } + } + } + } + } + + if leak || depth == 0u32 { + t = t + micro; + } else { + let ci = st_cell[(depth - 1) as usize]; + let ft = idata[(cell_fill_type_a + u32::cast_from(ci)) as usize]; + let m = idata[(cell_fill_data_a + u32::cast_from(ci)) as usize]; + let mut is_opaque = false; + if ft == FILL_MATERIAL { + if u32::cast_from(m) >= n_materials { + is_opaque = true; + } else { + if idata[(opaque_off + u32::cast_from(m)) as usize] != 0i32 { + is_opaque = true; + } + } + } + + if ft == FILL_MATERIAL && is_opaque { + // Cumulative offset to local frame. + let mut cof_x = f64::new(0.0); + let mut cof_y = f64::new(0.0); + let mut cof_z = f64::new(0.0); + for i in 0..depth { + cof_x = cof_x + st_offx[i as usize]; + cof_y = cof_y + st_offy[i as usize]; + cof_z = cof_z + st_offz[i as usize]; + } + let hx = wx - cof_x; + let hy = wy - cof_y; + let hz = wz - cof_z; + + // Nearest bounding halfspace surface. + let r_off = idata[(cell_region_off_a + u32::cast_from(ci)) as usize]; + let r_len = idata[(cell_region_len_a + u32::cast_from(ci)) as usize]; + let mut best = i32::new(-1); + let mut best_abs = f64::new(1e30); + for i in 0..u32::cast_from(r_len) { + let op = idata[(region_op_off + u32::cast_from(r_off) + i) as usize]; + let arg = idata[(region_arg_off + u32::cast_from(r_off) + i) as usize]; + if op == REGION_HALFSPACE_POS { + let v = surf_eval(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(arg), hx, hy, hz).abs(); + if v < best_abs { + best_abs = v; + best = arg; + } + } else { + if op == REGION_HALFSPACE_NEG { + let v = surf_eval(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(arg), hx, hy, hz).abs(); + if v < best_abs { + best_abs = v; + best = arg; + } + } + } + } + + let mut nx = -rdx; + let mut ny = -rdy; + let mut nz = -rdz; + if best >= 0i32 { + surf_normal(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(best), hx, hy, hz, &mut normal); + nx = normal[0]; + ny = normal[1]; + nz = normal[2]; + } + if nx * rdx + ny * rdy + nz * rdz > f64::new(0.0) { + nx = -nx; + ny = -ny; + nz = -nz; + } + + // Lambert: key light + head-light. + let kl = (f64::new(0.35) * 0.35 + f64::new(0.45) * 0.45 + f64::new(0.82) * 0.82).sqrt(); + let kd = fmax0((nx * 0.35 + ny * 0.45 + nz * 0.82) / kl); + let hd = fmax0(-(nx * rdx + ny * rdy + nz * rdz)); + let lit_raw = f64::new(0.18) + f64::new(0.82) * (f64::new(0.55) * kd + f64::new(0.45) * hd); + let lit = select(lit_raw > f64::new(1.15), f64::new(1.15), lit_raw); + + let mut rr = f64::new(200.0); + let mut gg = f64::new(200.0); + let mut bb = f64::new(200.0); + if u32::cast_from(m) < n_materials { + rr = f64::cast_from(idata[(palette_off + u32::cast_from(m) * 3u32) as usize]); + gg = f64::cast_from(idata[(palette_off + u32::cast_from(m) * 3u32 + 1u32) as usize]); + bb = f64::cast_from(idata[(palette_off + u32::cast_from(m) * 3u32 + 2u32) as usize]); + } + // Composite the (fully opaque) hit under whatever + // translucent fluid the ray already passed through, + // then terminate this ray. + acc_r = acc_r + trans * rr * lit; + acc_g = acc_g + trans * gg * lit; + acc_b = acc_b + trans * bb * lit; + trans = f64::new(0.0); + done = true; + } else { + // Transparent (air/void) — step to next surface + // crossing across all stack cells + rect grids. + let mut best_dist = f64::new(1e30); + + // Per-frame local positions. + let mut flx = wx; + let mut fly = wy; + let mut flz = wz; + for d in 0..depth { + flx = flx - st_offx[d as usize]; + fly = fly - st_offy[d as usize]; + flz = flz - st_offz[d as usize]; + let cci = st_cell[d as usize]; + let r_off = idata[(cell_region_off_a + u32::cast_from(cci)) as usize]; + let r_len = idata[(cell_region_len_a + u32::cast_from(cci)) as usize]; + for i in 0..u32::cast_from(r_len) { + let op = idata[(region_op_off + u32::cast_from(r_off) + i) as usize]; + let arg = idata[(region_arg_off + u32::cast_from(r_off) + i) as usize]; + let is_hs = op == REGION_HALFSPACE_POS || op == REGION_HALFSPACE_NEG; + if is_hs { + let dd = surf_dist(idata, fdata, surf_params_off, surf_type_off, u32::cast_from(arg), flx, fly, flz, rdx, rdy, rdz); + if dd < best_dist { + best_dist = dd; + } + } + } + } + + // Rect-lattice grid crossings (parent frame). + let mut plx = wx; + let mut ply = wy; + let mut plz = wz; + for d in 0..depth { + if st_has_lat[d as usize] == 1i32 { + let lid = u32::cast_from(st_lat_id[d as usize]); + let org = (lat_origin_off + lid * 3u32) as usize; + let pit = (lat_pitch_off + lid * 3u32) as usize; + let gd = grid_dist( + plx, ply, plz, rdx, rdy, rdz, + fdata[org], fdata[org + 1], fdata[org + 2], + fdata[pit], fdata[pit + 1], fdata[pit + 2], + st_lat_ix[d as usize], st_lat_iy[d as usize], st_lat_iz[d as usize], + ); + if gd + f64::new(1e-9) < best_dist { + best_dist = gd; + } + } + plx = plx - st_offx[d as usize]; + ply = ply - st_offy[d as usize]; + plz = plz - st_offz[d as usize]; + } + + if best_dist >= f64::new(1e29) { + done = true; + } else { + // Composite the translucent fluid segment the + // ray just crossed: attenuate transmittance by + // exp(-absorb·length) and accumulate a faint + // tint of the fluid's palette colour so deeper + // structure reads dimmer (Beer-Lambert, exact + // front-to-back — no peeling needed). + let mut absb = f64::new(0.0); + let mut tr = f64::new(120.0); + let mut tg = f64::new(140.0); + let mut tbl = f64::new(190.0); + if u32::cast_from(m) < n_materials { + absb = fdata[(absorb_off + u32::cast_from(m)) as usize]; + tr = f64::cast_from(idata[(palette_off + u32::cast_from(m) * 3u32) as usize]); + tg = f64::cast_from(idata[(palette_off + u32::cast_from(m) * 3u32 + 1u32) as usize]); + tbl = f64::cast_from(idata[(palette_off + u32::cast_from(m) * 3u32 + 2u32) as usize]); + } + if absb > f64::new(0.0) { + let seg_alpha = f64::new(1.0) - (-(absb * best_dist)).exp(); + let contrib = trans * seg_alpha; + // Tint dimmed so the fluid reads as a haze, + // not a solid fill. + acc_r = acc_r + contrib * tr * 0.35; + acc_g = acc_g + contrib * tg * 0.35; + acc_b = acc_b + contrib * tbl * 0.35; + trans = trans - contrib; + } + t = t + best_dist + eps; + if trans < f64::new(0.02) { + done = true; + } + } + } + } + } + } + } + // Final composite: accumulated colour over the background by + // whatever transmittance remains. + color = pack_rgb( + acc_r + trans * f64::cast_from((bg / 65536u32) % 256u32), + acc_g + trans * f64::cast_from((bg / 256u32) % 256u32), + acc_b + trans * f64::cast_from(bg % 256u32), + ); + let _ = surf_bc_off; + } + out[pid] = color; + } +} + +/// One slab-test axis: update running `[tmin, tmax]` + `miss`. +/// `&mut` works because these are kernel-local `Array`-free scalars +/// passed by mutable reference into a `#[cube]` helper — wait, scalar +/// `&mut` is const in CubeCL, so this is inlined at the call site via a +/// macro-free helper that takes/returns the interval instead. +#[cube] +fn slab(o: f64, d: f64, lo: f64, hi: f64, tmin: &mut f64, tmax: &mut f64, miss: &mut bool) { + // NOTE: CubeCL `&mut` scalar params ARE writable when the argument + // is a kernel-local mutable variable (the restriction is on + // re-binding a comptime literal). Verified by the smoke test. + if d.abs() < f64::new(1e-12) { + if o < lo { + *miss = true; + } + if o > hi { + *miss = true; + } + } else { + let inv = f64::new(1.0) / d; + let t0 = (lo - o) * inv; + let t1 = (hi - o) * inv; + let a = select(t0 < t1, t0, t1); + let b = select(t0 < t1, t1, t0); + if a > *tmin { + *tmin = a; + } + if b < *tmax { + *tmax = b; + } + } +} + +/// `max(x, 0)` without the `FloatOps`/`Ord` method ambiguity. +#[cube] +fn fmax0(x: f64) -> f64 { + select(x > f64::new(0.0), x, f64::new(0.0)) +} + +/// Pack three 0..255 f64 channels into 0xRRGGBB, clamped. +#[cube] +fn pack_rgb(r: f64, g: f64, b: f64) -> u32 { + let rr = u32::cast_from(clamp255(r + 0.5)); + let gg = u32::cast_from(clamp255(g + 0.5)); + let bb = u32::cast_from(clamp255(b + 0.5)); + rr * 65536u32 + gg * 256u32 + bb +} + +#[cube] +fn clamp255(v: f64) -> f64 { + let lo = select(v > f64::new(0.0), v, f64::new(0.0)); + select(lo > f64::new(255.0), f64::new(255.0), lo) +} + +// ── Host harness ──────────────────────────────────────────────────── + +/// Render the scene to a packed-RGB (`0xRRGGBB`) buffer on the given +/// CubeCL runtime. Width × height pixels, row-major. +pub fn render_rgb( + device: &R::Device, + packed: &PackedScene, + cam: &CameraParams, + width: u32, + height: u32, +) -> Vec { + let client = R::client(device); + + let mut meta = packed.meta.clone(); + meta[M_WIDTH] = width; + meta[M_HEIGHT] = height; + + let mut cam_arr = vec![0.0f64; CAM_LEN]; + cam_arr[C_POS..C_POS + 3].copy_from_slice(&cam.pos); + cam_arr[C_FWD..C_FWD + 3].copy_from_slice(&cam.fwd); + cam_arr[C_RIGHT..C_RIGHT + 3].copy_from_slice(&cam.right); + cam_arr[C_UP..C_UP + 3].copy_from_slice(&cam.up); + cam_arr[C_TAN_HALF_FOV] = cam.tan_half_fov; + cam_arr[C_ASPECT] = cam.aspect; + cam_arr[C_AABB_MIN..C_AABB_MIN + 3].copy_from_slice(&packed.aabb_min); + cam_arr[C_AABB_MAX..C_AABB_MAX + 3].copy_from_slice(&packed.aabb_max); + + let n_pixels = (width * height) as usize; + + let meta_h = client.create_from_slice(u32::as_bytes(&meta)); + let idata_h = client.create_from_slice(i32::as_bytes(&packed.idata)); + let fdata_h = client.create_from_slice(f64::as_bytes(&packed.fdata)); + let cam_h = client.create_from_slice(f64::as_bytes(&cam_arr)); + let out_h = client.empty(n_pixels * core::mem::size_of::()); + + let threads_per_block = 64u32; + let blocks = n_pixels.div_ceil(threads_per_block as usize) as u32; + + unsafe { + raycast::launch_unchecked::( + &client, + CubeCount::Static(blocks, 1, 1), + CubeDim::new_1d(threads_per_block), + ArrayArg::from_raw_parts(meta_h, meta.len()), + ArrayArg::from_raw_parts(idata_h, packed.idata.len()), + ArrayArg::from_raw_parts(fdata_h, packed.fdata.len()), + ArrayArg::from_raw_parts(cam_h, cam_arr.len()), + ArrayArg::from_raw_parts(out_h.clone(), n_pixels), + ); + } + + let bytes = client + .read_one(out_h) + .expect("GPU readback of render buffer failed"); + u32::from_bytes(&bytes).to_vec() +} + +/// Convenience: render on the default WGPU device (Vulkan / Metal / +/// DX12 / WebGPU — whatever the platform offers). Always available +/// since the `wgpu` runtime is a hard dependency. +pub fn render_rgb_wgpu(packed: &PackedScene, cam: &CameraParams, width: u32, height: u32) -> Vec { + let device = cubecl::wgpu::WgpuDevice::default(); + render_rgb::(&device, packed, cam, width, height) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::cell::{self, Cell, CellFill, CellId}; + use crate::geometry::surface::{BoundaryCondition, Surface}; + use crate::geometry::universe::{Universe, UniverseId}; + use crate::geometry::flat::build_host_tables; + + /// A solid sphere (material 0) sitting in a vacuum box (material 1, + /// transparent). Renders the sphere silhouette. Exercises the full + /// CSG walk: surf_eval, cell_contains, find_cell, surf_normal, + /// transparent-step, and shading. + fn sphere_in_box() -> crate::geometry::Geometry { + let surfaces = vec![ + Surface::Sphere { + center: crate::geometry::Vec3::new(0.0, 0.0, 0.0), + radius: 3.0, + bc: BoundaryCondition::Transmission, + }, + Surface::Sphere { + center: crate::geometry::Vec3::new(0.0, 0.0, 0.0), + radius: 50.0, + bc: BoundaryCondition::Vacuum, + }, + ]; + let cells = vec![ + // Fuel sphere = material 0 (opaque). + Cell::new(CellId(0), cell::inside(0), CellFill::Material(0)), + // Surrounding "air" = material 1 (transparent), inside outer. + Cell::new( + CellId(1), + cell::intersect_all(vec![cell::outside(0), cell::inside(1)]), + CellFill::Material(1), + ), + ]; + let universes = vec![Universe::new(UniverseId(0), vec![0, 1])]; + crate::geometry::Geometry::new(surfaces, cells, universes, Vec::new(), UniverseId(0)) + .expect("sphere in box") + } + + #[test] + fn wgpu_sphere_render_smoke() { + let geom = sphere_in_box(); + let tables = build_host_tables(&geom); + // Material 0 red+opaque, material 1 "air" transparent (clear). + let palette = [[200u8, 60, 60], [10, 10, 10]]; + let opaque = [true, false]; + let absorb = [0.0f64, 0.0]; + let packed = pack_scene(&tables, &geom, &palette, &opaque, &absorb).expect("pack_scene"); + + let w = 96u32; + let h = 96u32; + let center = [ + ((packed.aabb_min[0] + packed.aabb_max[0]) * 0.5), + ((packed.aabb_min[1] + packed.aabb_max[1]) * 0.5), + ((packed.aabb_min[2] + packed.aabb_max[2]) * 0.5), + ]; + let dx = packed.aabb_max[0] - packed.aabb_min[0]; + let dy = packed.aabb_max[1] - packed.aabb_min[1]; + let dz = packed.aabb_max[2] - packed.aabb_min[2]; + let radius = (dx * dx + dy * dy + dz * dz).sqrt() * 0.9; + let cam = CameraParams::orbit(center, 0.6, 0.4, radius, 45.0, 1.0); + + let result = std::panic::catch_unwind(|| render_rgb_wgpu(&packed, &cam, w, h)); + let pixels = match result { + Ok(p) => p, + Err(_) => { + eprintln!("no usable WGPU adapter / no f64 — skipping GPU render smoke test"); + return; + } + }; + assert_eq!(pixels.len(), (w * h) as usize); + + // The sphere shades reddish (high R); background is dark slate + // (low everything). Count pixels with a strong red channel. + let reddish = pixels + .iter() + .filter(|&&c| ((c >> 16) & 0xff) > 0x50 && (c & 0xff) < 0x60) + .count(); + eprintln!("reddish sphere pixels: {reddish} / {}", w * h); + assert!( + reddish > 300, + "expected a visible shaded sphere, only {reddish} reddish pixels" + ); + } +} diff --git a/rust_prototype/src/gpu_transport_cubecl.rs b/rust_prototype/src/gpu_transport_cubecl.rs new file mode 100644 index 0000000..87fd057 --- /dev/null +++ b/rust_prototype/src/gpu_transport_cubecl.rs @@ -0,0 +1,743 @@ +// SPDX-License-Identifier: MIT +//! Cross-vendor GPU **transport** on CubeCL — first kernel. +//! +//! Port of `gpu/cuda/transport_recursive_const.cu`: constant +//! cross-sections per material (σ_t, σ_a, σ_f, ν̄), one kernel = one +//! batch of histories, each transported to absorption / leakage / step +//! cap with fission sites banked atomically. It is the smallest +//! end-to-end transport kernel — no nuclear-data tables, no S(α,β) — so +//! it proves the CubeCL transport plumbing (geometry walk + PCG RNG + +//! collision sampling + atomic fission banking + per-batch counters) +//! before the 173 KB `transport.cu` physics port. +//! +//! Written once in CubeCL → runs on CUDA / HIP-ROCm / Vulkan / Metal / +//! WebGPU, in **f64** (geometry + XS) with **u32 atomics** (portable — +//! i64 / f64 atomics need backend extensions wgpu doesn't guarantee, so +//! the fission bank uses a u32 cursor + plain per-slot position writes, +//! and counters are u32: a single batch never exceeds 4 G events). +//! +//! Geometry SoA layout matches [`crate::gpu_render`] (same `meta` / +//! `idata` / `fdata` packing via [`crate::geometry::flat`]). The device +//! walk helpers (`surf_eval`, `cell_contains`, `find_cell`, +//! `trace_step`, `reflect_dir`) mirror the `gr_*` functions in +//! `geom_recursive.cu` and the validated renderer kernel. +//! +//! STATUS — runs on **CUDA**, blocked on **Vulkan** (tracel-ai/cubecl#1336). +//! The same `#[cube]` source compiles through CubeCL's CUDA runtime and +//! runs correctly (`cuda_const_xs_batch_runs`: 4096 histories, every one +//! absorbs-or-leaks exactly once, ~7.3k fission sites all inside the +//! sphere). On wgpu/Vulkan it compiles to valid WGSL but faults at +//! dispatch — too much thread-private `Array` state (the depth-4 coord +//! stacks + region stack) trips a SPIR-V private-storage bug, which is +//! Vulkan/SPIR-V-only. Shrinking the state isn't workable for recursive +//! geometry, so the cross-vendor (Vulkan/Metal) path waits on the +//! upstream fix; the Vulkan batch test is `#[ignore]`d, the CUDA one runs. + +use cubecl::prelude::*; + +use crate::geometry::Geometry; +use crate::geometry::flat::{check_gpu_supported, HostTables}; +use crate::gpu_cubecl_geom::{ + cross_or_die, find_cell, trace_step, BC_TRANSMISSION, BC_VACUUM, FILL_MATERIAL, MAX_DEPTH_USIZE, +}; + +// ── meta header (u32 slots) — superset of the renderer's, with the +// transport-only material-XS block appended. ──────────────────── + +const M_ROOT_UNIVERSE: usize = 1; +const M_N_MATERIALS: usize = 2; +const M_N_PARTICLES: usize = 3; +const M_MAX_EVENTS: usize = 4; +const M_FIS_CAPACITY: usize = 5; +// i32-blob element offsets +const M_OFF_SURF_TYPE: usize = 8; +const M_OFF_SURF_BC: usize = 9; +const M_OFF_CELL_REGION_OFF: usize = 10; +const M_OFF_CELL_REGION_LEN: usize = 11; +const M_OFF_CELL_FILL_TYPE: usize = 12; +const M_OFF_CELL_FILL_DATA: usize = 13; +const M_OFF_REGION_OP: usize = 14; +const M_OFF_REGION_ARG: usize = 15; +const M_OFF_UNIV_CELLS_LEN: usize = 17; +const M_OFF_UNIV_CELL_INDICES: usize = 18; +const M_OFF_UNIV_CELLS_OFF_BASE: usize = 19; +const M_OFF_LAT_SHAPE: usize = 20; +const M_OFF_LAT_UNIVERSES_OFF: usize = 21; +const M_OFF_LAT_UNIVERSES: usize = 22; +// f64-blob element offsets +const M_OFF_SURF_PARAMS: usize = 29; +const M_OFF_LAT_ORIGIN: usize = 32; +const M_OFF_LAT_PITCH: usize = 33; +const M_OFF_MAT_XS: usize = 35; // [σ_t, σ_a, σ_f, ν̄] × n_materials + +const META_LEN: usize = 40; + +// ── Per-material constant cross sections ──────────────────────────── + +/// Constant cross sections for one material (barns-as-macroscopic; +/// the kernel treats them as Σ directly). Mirrors the CUDA `ConstXs`. +#[derive(Clone, Copy, Debug)] +pub struct ConstXs { + pub sigma_t: f64, + pub sigma_a: f64, + pub sigma_f: f64, + pub nu_bar: f64, +} + +/// Aggregate result of one batch. +#[derive(Clone, Debug, Default)] +pub struct ConstXsBatch { + pub fission_sites: Vec<(f64, f64, f64)>, + pub n_collisions: u64, + pub n_absorptions: u64, + pub n_fissions: u64, + pub n_leakage: u64, + pub n_surf_xings: u64, +} + +// ── Host packing ──────────────────────────────────────────────────── + +/// Upload-ready buffers for the transport kernel. +pub struct PackedTransport { + pub meta: Vec, + pub idata: Vec, + pub fdata: Vec, +} + +fn push_i32(blob: &mut Vec, src: &[i32]) -> u32 { + let off = blob.len() as u32; + blob.extend_from_slice(src); + off +} +fn push_f64(blob: &mut Vec, src: &[f64]) -> u32 { + let off = blob.len() as u32; + blob.extend_from_slice(src); + off +} + +/// Pack geometry SoA + per-material constant XS for the transport +/// kernel. `materials[m]` supplies the four constants for material id +/// `m`; the kernel reads `mat_xs[m*4 + {0,1,2,3}]`. Errors if `geom` +/// uses a feature the CubeCL walk doesn't support yet (per-cell +/// rotation, hex lattice) — see `check_gpu_supported`. +pub fn pack_transport(t: &HostTables, geom: &Geometry, materials: &[ConstXs]) -> Result { + check_gpu_supported(geom)?; + let mut idata: Vec = Vec::new(); + let mut fdata: Vec = Vec::new(); + let mut meta = vec![0u32; META_LEN]; + + meta[M_OFF_SURF_TYPE] = push_i32(&mut idata, &t.surf_type); + meta[M_OFF_SURF_BC] = push_i32(&mut idata, &t.surf_bc); + meta[M_OFF_CELL_REGION_OFF] = push_i32(&mut idata, &t.cell_region_off); + meta[M_OFF_CELL_REGION_LEN] = push_i32(&mut idata, &t.cell_region_len); + meta[M_OFF_CELL_FILL_TYPE] = push_i32(&mut idata, &t.cell_fill_type); + meta[M_OFF_CELL_FILL_DATA] = push_i32(&mut idata, &t.cell_fill_data); + meta[M_OFF_REGION_OP] = push_i32(&mut idata, &t.region_op); + meta[M_OFF_REGION_ARG] = push_i32(&mut idata, &t.region_arg); + meta[M_OFF_UNIV_CELLS_OFF_BASE] = push_i32(&mut idata, &t.univ_cells_off); + meta[M_OFF_UNIV_CELLS_LEN] = push_i32(&mut idata, &t.univ_cells_len); + meta[M_OFF_UNIV_CELL_INDICES] = push_i32(&mut idata, &t.univ_cell_indices); + meta[M_OFF_LAT_SHAPE] = push_i32(&mut idata, &t.lat_shape); + meta[M_OFF_LAT_UNIVERSES_OFF] = push_i32(&mut idata, &t.lat_universes_off); + meta[M_OFF_LAT_UNIVERSES] = push_i32(&mut idata, &t.lat_universes); + + meta[M_OFF_SURF_PARAMS] = push_f64(&mut fdata, &t.surf_params); + meta[M_OFF_LAT_ORIGIN] = push_f64(&mut fdata, &t.lat_origin); + meta[M_OFF_LAT_PITCH] = push_f64(&mut fdata, &t.lat_pitch); + + let xs_flat: Vec = materials + .iter() + .flat_map(|m| [m.sigma_t, m.sigma_a, m.sigma_f, m.nu_bar]) + .collect(); + meta[M_OFF_MAT_XS] = push_f64(&mut fdata, &xs_flat); + + meta[M_ROOT_UNIVERSE] = geom.root_universe.0 as u32; + meta[M_N_MATERIALS] = materials.len() as u32; + + if idata.is_empty() { + idata.push(0); + } + if fdata.is_empty() { + fdata.push(0.0); + } + + Ok(PackedTransport { meta, idata, fdata }) +} + +// ── PCG-XSH-RR 64/32 (matches the rest of the codebase) ───────────── + +/// Advance the PCG state (in a length-2 `u64` array: [state, inc]) and +/// return the next 32-bit output. CubeCL supports u64 + shifts + xor. +#[cube] +fn pcg_next(rng: &mut Array) -> u32 { + let old = rng[0]; + rng[0] = old * 6364136223846793005u64 + rng[1]; + let xorshifted = u32::cast_from(((old >> 18u64) ^ old) >> 27u64); + let rot = u32::cast_from(old >> 59u64); + (xorshifted >> rot) | (xorshifted << ((32u32 - rot) & 31u32)) +} + +/// Uniform double in [0, 1) from two 32-bit draws (53-bit mantissa). +#[cube] +fn pcg_uniform(rng: &mut Array) -> f64 { + let a = u64::cast_from(pcg_next(rng)) >> 5u64; + let b = u64::cast_from(pcg_next(rng)) >> 6u64; + f64::cast_from(a * 67108864u64 + b) * (1.0 / 9007199254740992.0) +} + +// ── Geometry walk — shared with `gpu_ce_cubecl` and `gpu_render` via +// `crate::gpu_cubecl_geom` (single copy; see module doc there). ─── + +// ── Transport kernel ──────────────────────────────────────────────── +// +// One thread = one history. Persistent within the batch: each thread +// loops collision/crossing events until absorption / leakage / cap. +// Fission sites are claimed with a single Atomic cursor (portable +// -- no i64/f64 atomics); each claimed slot is written by exactly one +// thread so the position stores need no atomicity. Counters are u32. +#[cube(launch)] +#[allow(clippy::too_many_arguments)] +fn const_xs_kernel( + meta: &Array, + idata: &Array, + fdata: &Array, + pos: &mut Array, + dir: &mut Array, + rng: &mut Array, + alive: &mut Array, + fis_pos: &mut Array, + // Atomic counters, length 6: + // [0]=fission-bank cursor, [1]=collisions, [2]=absorptions, + // [3]=fissions, [4]=leakage, [5]=surface crossings. + atomics: &mut Array>, +) { + let tid = ABSOLUTE_POS; + let n_particles = meta[M_N_PARTICLES]; + if tid < n_particles as usize { + if alive[tid] == 1u32 { + let surf_type_off = meta[M_OFF_SURF_TYPE]; + let surf_bc_off = meta[M_OFF_SURF_BC]; + let cell_region_off_a = meta[M_OFF_CELL_REGION_OFF]; + let cell_region_len_a = meta[M_OFF_CELL_REGION_LEN]; + let cell_fill_type_a = meta[M_OFF_CELL_FILL_TYPE]; + let cell_fill_data_a = meta[M_OFF_CELL_FILL_DATA]; + let region_op_off = meta[M_OFF_REGION_OP]; + let region_arg_off = meta[M_OFF_REGION_ARG]; + let univ_cells_off_base = meta[M_OFF_UNIV_CELLS_OFF_BASE]; + let univ_cells_len_a = meta[M_OFF_UNIV_CELLS_LEN]; + let univ_cell_indices_a = meta[M_OFF_UNIV_CELL_INDICES]; + let lat_shape_off = meta[M_OFF_LAT_SHAPE]; + let lat_universes_off_a = meta[M_OFF_LAT_UNIVERSES_OFF]; + let lat_universes_a = meta[M_OFF_LAT_UNIVERSES]; + let surf_params_off = meta[M_OFF_SURF_PARAMS]; + let lat_origin_off = meta[M_OFF_LAT_ORIGIN]; + let lat_pitch_off = meta[M_OFF_LAT_PITCH]; + let mat_xs_off = meta[M_OFF_MAT_XS]; + let root_universe = meta[M_ROOT_UNIVERSE]; + let max_events = meta[M_MAX_EVENTS]; + let fis_capacity = meta[M_FIS_CAPACITY]; + + let mut lrng = Array::::new(2usize); + lrng[0] = rng[tid * 2]; + lrng[1] = rng[tid * 2 + 1]; + + let mut px = pos[tid * 3]; + let mut py = pos[tid * 3 + 1]; + let mut pz = pos[tid * 3 + 2]; + let mut ddir = Array::::new(3usize); + ddir[0] = dir[tid * 3]; + ddir[1] = dir[tid * 3 + 1]; + ddir[2] = dir[tid * 3 + 2]; + + let mut st_cell = Array::::new(MAX_DEPTH_USIZE); + let mut st_offx = Array::::new(MAX_DEPTH_USIZE); + let mut st_offy = Array::::new(MAX_DEPTH_USIZE); + let mut st_offz = Array::::new(MAX_DEPTH_USIZE); + let mut st_has_lat = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_id = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_ix = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_iy = Array::::new(MAX_DEPTH_USIZE); + let mut st_lat_iz = Array::::new(MAX_DEPTH_USIZE); + let mut ts = Array::::new(3usize); + + let mut depth = find_cell( + idata, fdata, surf_params_off, surf_type_off, region_op_off, region_arg_off, + cell_region_off_a, cell_region_len_a, cell_fill_type_a, cell_fill_data_a, + univ_cells_off_base, univ_cells_len_a, univ_cell_indices_a, + lat_shape_off, lat_universes_off_a, lat_universes_a, lat_origin_off, lat_pitch_off, + root_universe, px, py, pz, + &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, + &mut st_has_lat, &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, + ); + + let mut lc_coll = u32::new(0); + let mut lc_abs = u32::new(0); + let mut lc_fis = u32::new(0); + let mut lc_surf = u32::new(0); + let mut lc_leak = u32::new(0); + let mut local_alive = u32::new(1); + + if depth == 0u32 { + local_alive = u32::new(0); + lc_leak += 1; + } + + let mut ev = u32::new(0); + for _i in 0..4096u32 { + if local_alive == 1u32 && ev < max_events { + ev += 1; + // Only a transmission crossing moves the particle into a + // new cell; collision / scatter / reflection keep the + // current stack. Re-finding the cell after a reflection + // is numerically unstable (the point sits exactly on the + // surface) and was producing spurious leaks. + let mut need_refind = false; + let ci = st_cell[(depth - 1) as usize]; + let ft = idata[(cell_fill_type_a + u32::cast_from(ci)) as usize]; + let mut mat = i32::new(-1); + if ft == FILL_MATERIAL { + mat = idata[(cell_fill_data_a + u32::cast_from(ci)) as usize]; + } + + if mat < 0i32 { + trace_step( + idata, fdata, surf_params_off, surf_type_off, surf_bc_off, + region_op_off, region_arg_off, cell_region_off_a, cell_region_len_a, + lat_origin_off, lat_pitch_off, px, py, pz, ddir[0], ddir[1], ddir[2], + depth, &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, &mut st_has_lat, + &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, &mut ts, + ); + let dist = ts[0]; + let surf_idx = i32::cast_from(ts[1]); + let bc = i32::cast_from(ts[2]); + if dist >= f64::new(1e29) { + local_alive = u32::new(0); + lc_leak += 1; + } else { + lc_surf += 1; + cross_or_die( + idata, fdata, surf_params_off, surf_type_off, + bc, surf_idx, dist, + &mut px, &mut py, &mut pz, &mut ddir, &mut local_alive, + ); + if bc == BC_VACUUM { + lc_leak += 1; + } + // Transmission moved us across a surface into a + // (possibly) new cell — re-resolve. Reflection + // stays in the same cell. + if bc == BC_TRANSMISSION { + need_refind = true; + } + } + } else { + let sigma_t = fdata[(mat_xs_off + u32::cast_from(mat) * 4u32) as usize]; + let sigma_a = fdata[(mat_xs_off + u32::cast_from(mat) * 4u32 + 1u32) as usize]; + let sigma_f = fdata[(mat_xs_off + u32::cast_from(mat) * 4u32 + 2u32) as usize]; + let nu_bar = fdata[(mat_xs_off + u32::cast_from(mat) * 4u32 + 3u32) as usize]; + + if sigma_t <= f64::new(0.0) { + local_alive = u32::new(0); + lc_leak += 1; + } else { + let d_collide = -(pcg_uniform(&mut lrng).ln()) / sigma_t; + trace_step( + idata, fdata, surf_params_off, surf_type_off, surf_bc_off, + region_op_off, region_arg_off, cell_region_off_a, cell_region_len_a, + lat_origin_off, lat_pitch_off, px, py, pz, ddir[0], ddir[1], ddir[2], + depth, &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, &mut st_has_lat, + &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, &mut ts, + ); + let dist = ts[0]; + let surf_idx = i32::cast_from(ts[1]); + let bc = i32::cast_from(ts[2]); + + if dist >= f64::new(1e29) { + local_alive = u32::new(0); + lc_leak += 1; + } else { + if d_collide < dist { + px = px + ddir[0] * d_collide; + py = py + ddir[1] * d_collide; + pz = pz + ddir[2] * d_collide; + lc_coll += 1; + let xi_react = pcg_uniform(&mut lrng) * sigma_t; + if xi_react < sigma_a { + lc_abs += 1; + if sigma_a > f64::new(0.0) { + let pf = sigma_f / sigma_a; + if pcg_uniform(&mut lrng) < pf { + let xi = pcg_uniform(&mut lrng); + let n_fis = u32::cast_from(nu_bar + xi); + if n_fis > 0u32 { + let slot = atomics[0].fetch_add(n_fis); + for k in 0..n_fis { + let s = slot + k; + if s < fis_capacity { + fis_pos[(s * 3u32) as usize] = px; + fis_pos[(s * 3u32 + 1u32) as usize] = py; + fis_pos[(s * 3u32 + 2u32) as usize] = pz; + } + } + lc_fis += n_fis; + } + } + } + local_alive = u32::new(0); + } else { + let mu = 2.0 * pcg_uniform(&mut lrng) - 1.0; + let phi = 2.0 * 3.141592653589793 * pcg_uniform(&mut lrng); + let sq = (f64::new(1.0) - mu * mu).sqrt(); + ddir[0] = sq * phi.cos(); + ddir[1] = sq * phi.sin(); + ddir[2] = mu; + } + } else { + lc_surf += 1; + cross_or_die( + idata, fdata, surf_params_off, surf_type_off, + bc, surf_idx, dist, + &mut px, &mut py, &mut pz, &mut ddir, &mut local_alive, + ); + if bc == BC_VACUUM { + lc_leak += 1; + } + if bc == BC_TRANSMISSION { + need_refind = true; + } + } + } + } + } + + if local_alive == 1u32 && need_refind { + depth = find_cell( + idata, fdata, surf_params_off, surf_type_off, region_op_off, region_arg_off, + cell_region_off_a, cell_region_len_a, cell_fill_type_a, cell_fill_data_a, + univ_cells_off_base, univ_cells_len_a, univ_cell_indices_a, + lat_shape_off, lat_universes_off_a, lat_universes_a, lat_origin_off, lat_pitch_off, + root_universe, px, py, pz, + &mut st_cell, &mut st_offx, &mut st_offy, &mut st_offz, + &mut st_has_lat, &mut st_lat_id, &mut st_lat_ix, &mut st_lat_iy, &mut st_lat_iz, + ); + if depth == 0u32 { + local_alive = u32::new(0); + lc_leak += 1; + } + } + } + } + + pos[tid * 3] = px; + pos[tid * 3 + 1] = py; + pos[tid * 3 + 2] = pz; + dir[tid * 3] = ddir[0]; + dir[tid * 3 + 1] = ddir[1]; + dir[tid * 3 + 2] = ddir[2]; + alive[tid] = local_alive; + rng[tid * 2] = lrng[0]; + rng[tid * 2 + 1] = lrng[1]; + + atomics[1].fetch_add(lc_coll); + atomics[2].fetch_add(lc_abs); + atomics[3].fetch_add(lc_fis); + atomics[4].fetch_add(lc_leak); + atomics[5].fetch_add(lc_surf); + } + } +} + +// ── Host harness ──────────────────────────────────────────────────── + +/// Run one batch of constant-XS transport on the given CubeCL runtime. +/// Mirrors `GpuRecursiveContext::const_xs_transport` (CUDA) but +/// cross-vendor. Returns the fission-site bank + aggregate counters. +/// +/// As with the CUDA path, the same RNG seed on CPU and GPU does NOT +/// give bit-identical histories (collision-vs-surface ties flip on +/// float rounding); aggregate counts agree within MC noise. +pub fn const_xs_transport( + device: &R::Device, + packed: &PackedTransport, + positions: &[(f64, f64, f64)], + directions: &[(f64, f64, f64)], + rng_seeds: &[(u64, u64)], + max_events_per_history: u32, + fis_capacity: usize, +) -> Result { + let n = positions.len(); + if n == 0 { + return Ok(ConstXsBatch::default()); + } + if directions.len() != n || rng_seeds.len() != n { + return Err("position / direction / rng_seeds length mismatch".into()); + } + + let client = R::client(device); + + // meta with per-launch scalars filled in. + let mut meta = packed.meta.clone(); + meta[M_N_PARTICLES] = n as u32; + meta[M_MAX_EVENTS] = max_events_per_history; + meta[M_FIS_CAPACITY] = fis_capacity as u32; + + // Flatten particle SoA. + let mut pos_flat = Vec::with_capacity(n * 3); + let mut dir_flat = Vec::with_capacity(n * 3); + let mut rng_flat = Vec::with_capacity(n * 2); + let mut alive_flat = vec![1u32; n]; + for i in 0..n { + pos_flat.push(positions[i].0); + pos_flat.push(positions[i].1); + pos_flat.push(positions[i].2); + dir_flat.push(directions[i].0); + dir_flat.push(directions[i].1); + dir_flat.push(directions[i].2); + rng_flat.push(rng_seeds[i].0); + rng_flat.push(rng_seeds[i].1 | 1); // inc must be odd + } + let _ = &mut alive_flat; + + let fis_pos = vec![0.0f64; fis_capacity.max(1) * 3]; + let atomics_init = vec![0u32; 6]; + + let meta_h = client.create_from_slice(u32::as_bytes(&meta)); + let idata_h = client.create_from_slice(i32::as_bytes(&packed.idata)); + let fdata_h = client.create_from_slice(f64::as_bytes(&packed.fdata)); + let pos_h = client.create_from_slice(f64::as_bytes(&pos_flat)); + let dir_h = client.create_from_slice(f64::as_bytes(&dir_flat)); + let rng_h = client.create_from_slice(u64::as_bytes(&rng_flat)); + let alive_h = client.create_from_slice(u32::as_bytes(&alive_flat)); + let fis_h = client.create_from_slice(f64::as_bytes(&fis_pos)); + let atomics_h = client.create_from_slice(u32::as_bytes(&atomics_init)); + + let threads = 64u32; + let blocks = n.div_ceil(threads as usize) as u32; + + unsafe { + const_xs_kernel::launch::( + &client, + CubeCount::Static(blocks, 1, 1), + CubeDim::new_1d(threads), + ArrayArg::from_raw_parts(meta_h, meta.len()), + ArrayArg::from_raw_parts(idata_h, packed.idata.len()), + ArrayArg::from_raw_parts(fdata_h, packed.fdata.len()), + ArrayArg::from_raw_parts(pos_h, n * 3), + ArrayArg::from_raw_parts(dir_h, n * 3), + ArrayArg::from_raw_parts(rng_h, n * 2), + ArrayArg::from_raw_parts(alive_h, n), + ArrayArg::from_raw_parts(fis_h.clone(), fis_capacity.max(1) * 3), + ArrayArg::from_raw_parts(atomics_h.clone(), 6), + ); + } + + let at_bytes = client + .read_one(atomics_h) + .map_err(|e| format!("readback atomics: {e:?}"))?; + let at = u32::from_bytes(&at_bytes); + let n_fis_banked = (at[0] as usize).min(fis_capacity); + + let fis_bytes = client + .read_one(fis_h) + .map_err(|e| format!("readback fission bank: {e:?}"))?; + let fis = f64::from_bytes(&fis_bytes); + let mut fission_sites = Vec::with_capacity(n_fis_banked); + for s in 0..n_fis_banked { + fission_sites.push((fis[s * 3], fis[s * 3 + 1], fis[s * 3 + 2])); + } + + Ok(ConstXsBatch { + fission_sites, + n_collisions: at[1] as u64, + n_absorptions: at[2] as u64, + n_fissions: at[3] as u64, + n_leakage: at[4] as u64, + n_surf_xings: at[5] as u64, + }) +} + +/// Convenience: run on the default WGPU device. +pub fn const_xs_transport_wgpu( + packed: &PackedTransport, + positions: &[(f64, f64, f64)], + directions: &[(f64, f64, f64)], + rng_seeds: &[(u64, u64)], + max_events_per_history: u32, + fis_capacity: usize, +) -> Result { + let device = cubecl::wgpu::WgpuDevice::default(); + const_xs_transport::( + &device, + packed, + positions, + directions, + rng_seeds, + max_events_per_history, + fis_capacity, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::cell::{self, Cell, CellFill, CellId}; + use crate::geometry::flat::build_host_tables; + use crate::geometry::surface::{BoundaryCondition, Surface}; + use crate::geometry::universe::{Universe, UniverseId}; + use crate::geometry::Vec3; + + /// A bare fissile sphere (vacuum boundary). Constant XS chosen so a + /// fair fraction of histories fission before leaking. + fn fissile_sphere() -> crate::geometry::Geometry { + let surfaces = vec![Surface::Sphere { + center: Vec3::new(0.0, 0.0, 0.0), + radius: 10.0, + bc: BoundaryCondition::Vacuum, + }]; + let cells = vec![ + Cell::new(CellId(0), cell::inside(0), CellFill::Material(0)), + Cell::new(CellId(1), cell::outside(0), CellFill::Void), + ]; + let universes = vec![Universe::new(UniverseId(0), vec![0, 1])]; + crate::geometry::Geometry::new(surfaces, cells, universes, Vec::new(), UniverseId(0)) + .expect("fissile sphere") + } + + // Blocked by tracel-ai/cubecl#1336: this kernel keeps too much + // thread-private Array state live at once, so on NVIDIA/Vulkan the + // SPIR-V private-storage bug faults at dispatch (STATUS_ACCESS_VIOLATION). + // Re-enable when the upstream fix lands. Transport stays on CUDA meanwhile. + #[test] + #[ignore = "cubecl#1336: private Array dispatch fault on Vulkan"] + fn wgpu_const_xs_batch_runs() { + let geom = fissile_sphere(); + let tables = build_host_tables(&geom); + // Σ_t=0.5, Σ_a=0.2, Σ_f=0.15, ν̄=2.5 (macroscopic, 1/cm). + let mats = [ConstXs { + sigma_t: 0.5, + sigma_a: 0.2, + sigma_f: 0.15, + nu_bar: 2.5, + }]; + let packed = pack_transport(&tables, &geom, &mats).expect("pack_transport"); + + let n = 4usize; + let mut pos = Vec::with_capacity(n); + let mut dir = Vec::with_capacity(n); + let mut seeds = Vec::with_capacity(n); + for i in 0..n { + // Birth at origin, isotropic-ish spread via cheap hashing. + pos.push((0.0, 0.0, 0.0)); + let a = (i as f64) * 0.013; + dir.push((a.cos(), a.sin() * 0.5, a.sin() * 0.5)); + seeds.push(( + 0x4d595df4d0f33173u64.wrapping_add((i as u64).wrapping_mul(2862933555777941757)), + 1, + )); + } + + let result = std::panic::catch_unwind(|| { + const_xs_transport_wgpu(&packed, &pos, &dir, &seeds, 4, n * 4) + }); + let batch = match result { + Ok(Ok(b)) => b, + Ok(Err(e)) => panic!("transport returned error: {e}"), + Err(_) => { + eprintln!("no usable WGPU adapter — skipping const-XS transport test"); + return; + } + }; + + eprintln!( + "const-XS GPU batch: coll={} abs={} fis={} leak={} surf={} bank={}", + batch.n_collisions, + batch.n_absorptions, + batch.n_fissions, + batch.n_leakage, + batch.n_surf_xings, + batch.fission_sites.len() + ); + + // Sanity: every history ends (absorbed or leaked); collisions and + // fissions are produced; the fission bank is non-empty and every + // banked site sits inside the 10 cm sphere. + assert!(batch.n_collisions > 0, "no collisions recorded"); + assert!(batch.n_fissions > 0, "no fissions recorded"); + assert!(!batch.fission_sites.is_empty(), "empty fission bank"); + // (diagnostic: event cap may leave some alive) + for (x, y, z) in &batch.fission_sites { + let r = (x * x + y * y + z * z).sqrt(); + assert!(r <= 10.0 + 1e-6, "fission site outside sphere: r={r}"); + } + } + + /// Same kernel, but compiled through CubeCL's **CUDA** runtime + /// instead of wgpu/Vulkan. cubecl#1336 is SPIR-V-only, so the CUDA + /// path should run the identical #[cube] source without the + /// private-Array dispatch fault. Only built with `--features cuda`. + #[cfg(feature = "cuda")] + #[test] + fn cuda_const_xs_batch_runs() { + let geom = fissile_sphere(); + let tables = build_host_tables(&geom); + let mats = [ConstXs { + sigma_t: 0.5, + sigma_a: 0.2, + sigma_f: 0.15, + nu_bar: 2.5, + }]; + let packed = pack_transport(&tables, &geom, &mats).expect("pack_transport"); + + let n = 4096usize; + let mut pos = Vec::with_capacity(n); + let mut dir = Vec::with_capacity(n); + let mut seeds = Vec::with_capacity(n); + for i in 0..n { + pos.push((0.0, 0.0, 0.0)); + let a = (i as f64) * 0.013; + dir.push((a.cos(), a.sin() * 0.5, a.sin() * 0.5)); + seeds.push(( + 0x4d595df4d0f33173u64.wrapping_add((i as u64).wrapping_mul(2862933555777941757)), + 1, + )); + } + + let device = cubecl::cuda::CudaDevice::default(); + let result = std::panic::catch_unwind(|| { + const_xs_transport::( + &device, &packed, &pos, &dir, &seeds, 1000, n * 4, + ) + }); + let batch = match result { + Ok(Ok(b)) => b, + Ok(Err(e)) => panic!("CUDA transport returned error: {e}"), + Err(_) => { + eprintln!("no usable CUDA device — skipping CUDA const-XS transport test"); + return; + } + }; + + eprintln!( + "const-XS CUDA batch: coll={} abs={} fis={} leak={} surf={} bank={}", + batch.n_collisions, + batch.n_absorptions, + batch.n_fissions, + batch.n_leakage, + batch.n_surf_xings, + batch.fission_sites.len() + ); + + assert!(batch.n_collisions > 0, "no collisions recorded"); + assert!(batch.n_fissions > 0, "no fissions recorded"); + assert!(!batch.fission_sites.is_empty(), "empty fission bank"); + assert_eq!( + batch.n_absorptions + batch.n_leakage, + n as u64, + "every history should absorb or leak exactly once" + ); + for (x, y, z) in &batch.fission_sites { + let r = (x * x + y * y + z * z).sqrt(); + assert!(r <= 10.0 + 1e-6, "fission site outside sphere: r={r}"); + } + } +} diff --git a/rust_prototype/src/lib.rs b/rust_prototype/src/lib.rs index c2cac5a..c692c08 100644 --- a/rust_prototype/src/lib.rs +++ b/rust_prototype/src/lib.rs @@ -29,6 +29,10 @@ pub mod gpu_random_ray; pub mod gpu_recursive; #[cfg(feature = "cuda")] pub mod gpu_transport; +pub mod gpu_ce_cubecl; +pub(crate) mod gpu_cubecl_geom; +pub mod gpu_render; +pub mod gpu_transport_cubecl; pub mod hardware_profile; pub mod hdf5_reader; pub mod kernel; diff --git a/rust_prototype/tests/cubecl_ce_angular.rs b/rust_prototype/tests/cubecl_ce_angular.rs new file mode 100644 index 0000000..bca2037 --- /dev/null +++ b/rust_prototype/tests/cubecl_ce_angular.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +//! Elastic angular-distribution A/B: CPU `AngularDistribution::sample_mu` +//! vs the CubeCL device μ-sampler, on real HEU-COMP-INTER-003 nuclides — +//! `--features cuda`. +//! +//! Both invert the same tabulated (μ, cdf, pdf) data with the same +//! quadratic/linear formula. We can't share an RNG stream across the two +//! implementations bit-for-bit (the CPU draws xi_bin only on the +//! interpolation branch; draw order differs), so we compare the sampled +//! **distributions statistically**: mean μ and variance at each test +//! energy must agree within MC noise. A broken port (wrong bracket, wrong +//! CDF inversion, isotropic fallback) shifts the mean far outside noise — +//! exactly the forward-peaked-elastic bias that hit ieu-met-fast-001. +#![cfg(feature = "cuda")] + +use std::path::PathBuf; + +use open_rust_mc::geometry::scene_io; +use open_rust_mc::gpu_ce_cubecl as ce; +use open_rust_mc::transport::material_resolve; +use open_rust_mc::transport::nuclides::NuclideLibrary; +use open_rust_mc::transport::rng::Rng; + +fn bench_dir() -> PathBuf { + let mut p: PathBuf = env!("CARGO_MANIFEST_DIR").into(); + while p.parent().is_some() && !p.join("bench/icsbep").is_dir() { + p = p.parent().unwrap().to_path_buf(); + } + p.join("bench/icsbep") +} + +fn data_dir() -> Option { + if let Ok(v) = std::env::var("ICSBEP_DATA_DIR") { + return Some(PathBuf::from(v)); + } + open_rust_mc::data_paths::discover_neutron_dir(&PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + +fn mean_var(xs: &[f64]) -> (f64, f64) { + let n = xs.len() as f64; + let mean = xs.iter().sum::() / n; + let var = xs.iter().map(|x| (x - mean) * (x - mean)).sum::() / n; + (mean, var) +} + +#[test] +fn cubecl_ce_angular_matches_cpu() { + let Some(dd) = data_dir() else { + eprintln!("no data dir — skipping angular A/B"); + return; + }; + let case = bench_dir().join("heu-comp-inter-003_case-1.json"); + if !case.exists() { + eprintln!("case missing — skipping"); + return; + } + let text = std::fs::read_to_string(&case).unwrap(); + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let loaded = scene_io::load_scene_from_json(&v.get("scene").unwrap().to_string()).unwrap(); + let lib = NuclideLibrary::from_data_dir(&dd); + let resolved = match material_resolve::resolve_materials(&loaded.materials, &lib, 5) { + Ok(r) => r, + Err(e) => { + eprintln!("resolve failed ({e:?}) — skipping"); + return; + } + }; + + let device = cubecl::cuda::CudaDevice::default(); + let n_samp = 200_000usize; + // Test energies spanning the fast/intermediate range where elastic + // angular structure matters (eV). + let test_e = [1.0e3, 1.0e4, 1.0e5, 5.0e5, 1.0e6, 2.0e6, 5.0e6]; + + let mut checked = 0usize; + let mut worst_dmean = 0.0f64; + + for (ni, nuc) in resolved.provider.nuclides.iter().enumerate() { + if nuc.elastic_angle.is_none() { + continue; + } + let ang = ce::extract_angular(std::slice::from_ref(nuc)); + let packed = ce::pack_angular(&ang[0]); + + for &e in &test_e { + // CPU samples (seeded Rng). + let mut rng = Rng::new(0xA5A5_0000 + ni as u64, e as u64 | 1); + let cpu: Vec = (0..n_samp) + .map(|_| nuc.elastic_angle.as_ref().unwrap().sample_mu(e, &mut rng)) + .collect(); + + // GPU samples: feed independent uniform pairs. + let mut grng = Rng::new(0x5A5A_0000 + ni as u64, e as u64 | 1); + let mut es = vec![e; n_samp]; + let mut xb = Vec::with_capacity(n_samp); + let mut xm = Vec::with_capacity(n_samp); + for _ in 0..n_samp { + xb.push(grng.uniform()); + xm.push(grng.uniform()); + } + let _ = &mut es; + let gpu = match std::panic::catch_unwind(|| { + ce::sample_mu_gpu::(&device, &packed, &es, &xb, &xm) + }) { + Ok(g) => g, + Err(_) => { + eprintln!("no CUDA device — skipping angular A/B"); + return; + } + }; + + let (cm, cv) = mean_var(&cpu); + let (gm, gv) = mean_var(&gpu); + // MC stderr on the mean ≈ sqrt(var / n); compare within 5σ. + let se = (cv.max(gv) / n_samp as f64).sqrt().max(1e-4); + let dmean = (cm - gm).abs(); + worst_dmean = worst_dmean.max(dmean); + checked += 1; + assert!( + dmean < 5.0 * se, + "nuclide {ni} E={e:.2e}: mean μ CPU={cm:.5} GPU={gm:.5} (Δ={dmean:.5}, 5σ={:.5}); \ + var CPU={cv:.4} GPU={gv:.4}", + 5.0 * se + ); + } + } + + eprintln!("angular A/B: {checked} (nuclide,E) means checked, worst |Δmean μ| = {worst_dmean:.2e}"); + assert!(checked > 10, "too few angular comparisons"); +} diff --git a/rust_prototype/tests/cubecl_ce_fission.rs b/rust_prototype/tests/cubecl_ce_fission.rs new file mode 100644 index 0000000..9c2f6e9 --- /dev/null +++ b/rust_prototype/tests/cubecl_ce_fission.rs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +//! Fission-χ A/B: CPU `EnergyDistribution::sample` vs the CubeCL device +//! fission-energy sampler, on real HEU-COMP-INTER-003 fissile nuclides +//! — `--features cuda`. +//! +//! Both sample the outgoing fission-neutron energy from the same data: +//! tabular ContinuousTabular (stochastic bin + quadratic CDF inversion + +//! kinematic remap) or a closed-form Watt/Maxwell/Evaporation law. As +//! with the angular A/B we compare the sampled spectra statistically — +//! mean E_out and variance at each incident energy must agree within MC +//! noise. A broken χ (wrong law, wrong CDF inversion, missing remap) +//! shifts the mean far outside noise. +#![cfg(feature = "cuda")] + +use std::path::PathBuf; + +use open_rust_mc::geometry::scene_io; +use open_rust_mc::gpu_ce_cubecl as ce; +use open_rust_mc::transport::material_resolve; +use open_rust_mc::transport::nuclides::NuclideLibrary; +use open_rust_mc::transport::rng::Rng; + +fn bench_dir() -> PathBuf { + let mut p: PathBuf = env!("CARGO_MANIFEST_DIR").into(); + while p.parent().is_some() && !p.join("bench/icsbep").is_dir() { + p = p.parent().unwrap().to_path_buf(); + } + p.join("bench/icsbep") +} + +fn data_dir() -> Option { + if let Ok(v) = std::env::var("ICSBEP_DATA_DIR") { + return Some(PathBuf::from(v)); + } + open_rust_mc::data_paths::discover_neutron_dir(&PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + +fn mean_var(xs: &[f64]) -> (f64, f64) { + let n = xs.len() as f64; + let mean = xs.iter().sum::() / n; + let var = xs.iter().map(|x| (x - mean) * (x - mean)).sum::() / n; + (mean, var) +} + +#[test] +fn cubecl_ce_fission_matches_cpu() { + let Some(dd) = data_dir() else { + eprintln!("no data dir — skipping fission A/B"); + return; + }; + let case = bench_dir().join("heu-comp-inter-003_case-1.json"); + if !case.exists() { + eprintln!("case missing — skipping"); + return; + } + let text = std::fs::read_to_string(&case).unwrap(); + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let loaded = scene_io::load_scene_from_json(&v.get("scene").unwrap().to_string()).unwrap(); + let lib = NuclideLibrary::from_data_dir(&dd); + let resolved = match material_resolve::resolve_materials(&loaded.materials, &lib, 5) { + Ok(r) => r, + Err(e) => { + eprintln!("resolve failed ({e:?}) — skipping"); + return; + } + }; + + let device = cubecl::cuda::CudaDevice::default(); + let n_samp = 200_000usize; + // Incident energies: thermal-ish, intermediate, fast. + let test_e = [2.53e-2, 1.0e3, 1.0e5, 1.0e6, 2.0e6, 6.0e6]; + + let mut checked = 0usize; + let mut worst_rel = 0.0f64; + + for (ni, nuc) in resolved.provider.nuclides.iter().enumerate() { + let Some(ed) = nuc.fission_energy_dist.as_ref() else { + continue; + }; + let fc = ce::extract_fission(std::slice::from_ref(nuc)); + if fc[0].law < 0 { + continue; + } + let packed = ce::pack_fission(&fc[0]); + + for &e in &test_e { + // CPU samples. + let mut rng = Rng::new(0xF155_0000 + ni as u64, e as u64 | 1); + let cpu: Vec = (0..n_samp).map(|_| ed.sample(e, &mut rng)).collect(); + + // GPU samples: independent per-thread seeds. + let seeds: Vec<(u64, u64)> = (0..n_samp) + .map(|i| { + ( + 0x9E37_79B9_7F4A_7C15u64 + .wrapping_mul(i as u64 + 1) + .wrapping_add(ni as u64), + (e as u64) | 1, + ) + }) + .collect(); + let e_in = vec![e; n_samp]; + let gpu = match std::panic::catch_unwind(|| { + ce::sample_fission_gpu::(&device, &packed, &e_in, &seeds) + }) { + Ok(g) => g, + Err(_) => { + eprintln!("no CUDA device — skipping fission A/B"); + return; + } + }; + + let (cm, cv) = mean_var(&cpu); + let (gm, gv) = mean_var(&gpu); + // Compare mean E_out relative to CPU mean; MC stderr is small + // at 200k, so a correct port lands well under 1%. + let rel = (cm - gm).abs() / cm.abs().max(1.0); + worst_rel = worst_rel.max(rel); + checked += 1; + // 5σ on the mean as the hard gate, plus a 2% sanity ceiling. + let se = (cv.max(gv) / n_samp as f64).sqrt(); + assert!( + (cm - gm).abs() < 5.0 * se.max(cm.abs() * 1e-3), + "nuclide {ni} E_in={e:.2e}: mean E_out CPU={cm:.4e} GPU={gm:.4e} \ + (rel {rel:.3e}); var CPU={cv:.3e} GPU={gv:.3e}" + ); + } + } + + eprintln!("fission χ A/B: {checked} (nuclide,E) means checked, worst rel Δmean = {worst_rel:.3e}"); + assert!(checked > 5, "too few fission comparisons"); +} diff --git a/rust_prototype/tests/cubecl_ce_transport.rs b/rust_prototype/tests/cubecl_ce_transport.rs new file mode 100644 index 0000000..28f16f2 --- /dev/null +++ b/rust_prototype/tests/cubecl_ce_transport.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +//! Fused CE transport kernel — end-to-end run on a real fissile-sphere +//! scene, `--features cuda`. Validates that the assembled kernel +//! (geometry walk + CE Σ lookup + nuclide/reaction sampling + free-gas +//! elastic + tabulated angular + fission χ banking) runs on the GPU and +//! produces physically sane output: collisions happen, fissions are +//! banked at MeV-range energies, and every history terminates. +//! +//! This is the first whole-transport run of the CubeCL CE kernel; the +//! quantitative k_eff A/B vs the .cu CudaRunner is the follow-up (needs +//! a real ν̄(E) grid rather than the kernel's const-ν first cut). +#![cfg(feature = "cuda")] + +use std::path::PathBuf; + +use open_rust_mc::gpu_ce_cubecl as ce; +use open_rust_mc::geometry::cell::{self, Cell, CellFill, CellId}; +use open_rust_mc::geometry::flat::build_host_tables; +use open_rust_mc::geometry::surface::{BoundaryCondition, Surface}; +use open_rust_mc::geometry::universe::{Universe, UniverseId}; +use open_rust_mc::geometry::{Geometry, Vec3}; +use open_rust_mc::transport::material_resolve; +use open_rust_mc::transport::nuclides::NuclideLibrary; +use open_rust_mc::transport::rng::Rng; + +fn data_dir() -> Option { + if let Ok(v) = std::env::var("ICSBEP_DATA_DIR") { + return Some(PathBuf::from(v)); + } + open_rust_mc::data_paths::discover_neutron_dir(&PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + +#[test] +fn cubecl_ce_transport_runs() { + let Some(dd) = data_dir() else { + eprintln!("no data dir — skipping CE transport run"); + return; + }; + let lib = NuclideLibrary::from_data_dir(&dd); + + // Bare HEU sphere via the Python-builder DTOs would be heavier; build + // a minimal material directly: U-235 + U-238 at Godiva-ish densities. + use open_rust_mc::geometry::scene_io::{MaterialDto, NuclideEntryDto}; + let mat = MaterialDto { + name: "HEU".into(), + comment: None, + temperature: 294.0, + nuclides: vec![ + NuclideEntryDto { + hdf5_file: Some("U235.h5".into()), + zaid: None, + label: Some("U-235".into()), + atom_density: 0.045, + thermal_file: None, + }, + NuclideEntryDto { + hdf5_file: Some("U238.h5".into()), + zaid: None, + label: Some("U-238".into()), + atom_density: 0.0027, + thermal_file: None, + }, + ], + thermal_files: Vec::new(), + }; + let resolved = match material_resolve::resolve_materials(std::slice::from_ref(&mat), &lib, 5) { + Ok(r) => r, + Err(e) => { + eprintln!("resolve failed ({e:?}) — skipping"); + return; + } + }; + + // Geometry: fissile sphere r=8.7 cm, vacuum boundary. + let surfaces = vec![Surface::Sphere { + center: Vec3::new(0.0, 0.0, 0.0), + radius: 8.7, + bc: BoundaryCondition::Vacuum, + }]; + let cells = vec![ + Cell::new(CellId(0), cell::inside(0), CellFill::Material(0)), + Cell::new(CellId(1), cell::outside(0), CellFill::Void), + ]; + let universes = vec![Universe::new(UniverseId(0), vec![0, 1])]; + let geom = Geometry::new(surfaces, cells, universes, Vec::new(), UniverseId(0)).unwrap(); + + // Pack the unified CE scene. + let tables = build_host_tables(&geom); + let nucs = &resolved.provider.nuclides; + let ce_nucs = ce::extract_ce(nucs); + let ang = ce::extract_angular(nucs); + let fis = ce::extract_fission(nucs); + // Material 0 references nuclides by their provider index (0,1 here). + let mats = vec![ce::MaterialCe { + nuclides: resolved.materials[0] + .nuclides + .iter() + .map(|n| (n.xs_kernel_idx, n.atom_density)) + .collect(), + }]; + let scene = ce::pack_ce_full(&tables, &geom, &ce_nucs, &ang, &fis, &mats).expect("pack_ce_full"); + let mat_kt = vec![294.0 * 8.617333262e-5]; + + // Source: isotropic at origin, fission-spectrum-ish 2 MeV. + let n = 20_000usize; + let mut rng = Rng::new(0xCE_5151, 1); + let mut pos = Vec::with_capacity(n); + let mut dir = Vec::with_capacity(n); + let mut e = Vec::with_capacity(n); + let mut seeds = Vec::with_capacity(n); + for i in 0..n { + pos.push((0.0, 0.0, 0.0)); + let (dx, dy, dz) = rng.isotropic_direction(); + dir.push((dx, dy, dz)); + e.push(2.0e6); + let p = Rng::for_particle(0, i as u64); + seeds.push((p.state(), p.stream())); + } + + let device = cubecl::cuda::CudaDevice::default(); + let cegen = match std::panic::catch_unwind(|| { + ce::ce_generation::( + &device, &scene, &mat_kt, &pos, &dir, &e, &seeds, 10_000, n * 4, + ) + }) { + Ok(g) => g, + Err(_) => { + eprintln!("no CUDA device — skipping CE transport run"); + return; + } + }; + + let k = cegen.n_fissions as f64 / n as f64; + let mean_fe = if cegen.fission_energies.is_empty() { + 0.0 + } else { + cegen.fission_energies.iter().sum::() / cegen.fission_energies.len() as f64 + }; + eprintln!( + "CE cegen: coll={} leak={} cap={} fissions={} k1gen={:.4} mean_fis_E={:.3e} eV (bank {})", + cegen.n_collisions, cegen.n_leak, cegen.n_capture, cegen.n_fissions, k, mean_fe, + cegen.fission_energies.len() + ); + + // Sanity: real transport happened, fissions banked, energies physical. + assert!(cegen.n_collisions > 0, "no collisions"); + assert!(cegen.n_fissions > 0, "no fissions banked"); + assert!(!cegen.fission_energies.is_empty(), "empty fission bank"); + // Single-generation k for a near-critical HEU sphere should be O(1): + // not zero, not absurd. Wide gate — this is a sanity check, not a + // converged eigenvalue. + assert!(k > 0.3 && k < 3.0, "single-cegen k out of sane range: {k}"); + // Mean fission emission energy ~ 2 MeV (Watt/χ peak ~1-2 MeV). + assert!( + mean_fe > 3.0e5 && mean_fe < 5.0e6, + "mean fission energy unphysical: {mean_fe:.3e} eV" + ); +} diff --git a/rust_prototype/tests/cubecl_ce_xs.rs b/rust_prototype/tests/cubecl_ce_xs.rs new file mode 100644 index 0000000..25623d6 --- /dev/null +++ b/rust_prototype/tests/cubecl_ce_xs.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +//! Continuous-energy cross-section A/B: CPU provider vs CubeCL device +//! lookup, on the real HEU-COMP-INTER-003 nuclides — `--features cuda`. +//! +//! Validates the genuinely new CE piece of the CubeCL transport port: +//! per-nuclide pointwise σ on the device, one binary search on the +//! shared energy grid + log-log interpolation per reaction. For each +//! nuclide we reconstruct the total microscopic σ(E) (sum of the 5 +//! carried channels: elastic + fission + capture + inelastic + n2n) at +//! a spread of energies on both the CPU (`SvdXsProvider`/`ReactionKernel`) +//! and the GPU (CubeCL), and require agreement to tight relative error. +//! +//! Runs the CubeCL kernel through its CUDA runtime (cubecl#1336 blocks +//! the heavier kernels on Vulkan; this lookup is small but we use CUDA +//! for consistency with the transport path). +#![cfg(feature = "cuda")] + +use std::path::PathBuf; + +use open_rust_mc::geometry::scene_io; +use open_rust_mc::gpu_ce_cubecl as ce; +use open_rust_mc::transport::material_resolve; +use open_rust_mc::transport::nuclides::NuclideLibrary; +use open_rust_mc::transport::xs_provider::ReactionKernel; + +fn bench_dir() -> PathBuf { + let mut p: PathBuf = env!("CARGO_MANIFEST_DIR").into(); + while p.parent().is_some() && !p.join("bench/icsbep").is_dir() { + p = p.parent().unwrap().to_path_buf(); + } + p.join("bench/icsbep") +} + +fn data_dir() -> Option { + if let Ok(v) = std::env::var("ICSBEP_DATA_DIR") { + return Some(PathBuf::from(v)); + } + let start: PathBuf = env!("CARGO_MANIFEST_DIR").into(); + open_rust_mc::data_paths::discover_neutron_dir(&start) +} + +/// CPU reference: total micro σ of `nuc` at `energy` over the same 5 +/// channels the device carries (elastic+fission+capture+inelastic+n2n). +fn cpu_total_5ch( + nuc: &open_rust_mc::transport::xs_provider::NuclideKernels, + energy: f64, +) -> f64 { + let chans: [&Option; 5] = [ + &nuc.elastic, + &nuc.fission, + &nuc.capture, + &nuc.inelastic, + &nuc.n2n, + ]; + chans + .iter() + .filter_map(|c| c.as_ref()) + .map(|k| { + let idx = k.energy_index(energy); + // log-frac matches the device kernel + provider lookup. + let grid = k.energies(); + let frac = if idx + 1 < grid.len() && grid[idx] > 0.0 && grid[idx + 1] > grid[idx] { + ((energy.ln() - grid[idx].ln()) / (grid[idx + 1].ln() - grid[idx].ln())) + .clamp(0.0, 1.0) + } else { + 0.0 + }; + k.reconstruct_interp(idx, frac) + }) + .sum() +} + +#[test] +fn cubecl_ce_total_xs_matches_cpu() { + let Some(dd) = data_dir() else { + eprintln!("no nuclear data dir found — skipping CE XS A/B"); + return; + }; + let case = bench_dir().join("heu-comp-inter-003_case-1.json"); + if !case.exists() { + eprintln!("case file missing: {} — skipping", case.display()); + return; + } + + let text = std::fs::read_to_string(&case).unwrap(); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + let scene = value.get("scene").expect("scene block"); + let loaded = scene_io::load_scene_from_json(&scene.to_string()).unwrap(); + let lib = NuclideLibrary::from_data_dir(&dd); + let resolved = match material_resolve::resolve_materials(&loaded.materials, &lib, 5) { + Ok(r) => r, + Err(e) => { + eprintln!("resolve failed ({e:?}) — skipping"); + return; + } + }; + + let nuclides = &resolved.provider.nuclides; + assert!(!nuclides.is_empty(), "no nuclides resolved"); + eprintln!("HEU-COMP-INTER-003 case-1: {} nuclides", nuclides.len()); + + // Energies spanning the fast/intermediate spectrum (eV). + let energies: Vec = { + let mut v = Vec::new(); + let mut e = 1.0e-2; + while e < 2.0e7 { + v.push(e); + e *= 1.5; // ~geometric sweep, ~52 points + } + v + }; + + let device = cubecl::cuda::CudaDevice::default(); + let mut worst_rel = 0.0f64; + let mut n_checked = 0usize; + + // One nuclide at a time: pack just that nuclide as "nuclide 0" so the + // device kernel (which reads nuclide 0) compares against this nuclide. + for (ni, nuc) in nuclides.iter().enumerate() { + let ce_nucs = ce::extract_ce(std::slice::from_ref(nuc)); + if ce_nucs[0].grid.is_empty() { + continue; // no reactions (shouldn't happen for these) + } + let packed = ce::pack_ce(&ce_nucs); + + let gpu = std::panic::catch_unwind(|| { + ce::total_micro_xs::(&device, &packed, &energies) + }); + let gpu = match gpu { + Ok(v) => v, + Err(_) => { + eprintln!("no usable CUDA device — skipping CE XS A/B"); + return; + } + }; + + for (i, &e) in energies.iter().enumerate() { + let cpu = cpu_total_5ch(nuc, e); + let g = gpu[i]; + // Skip points where both are ~0 (below all thresholds). + if cpu.abs() < 1e-12 && g.abs() < 1e-12 { + continue; + } + let denom = cpu.abs().max(1e-30); + let rel = (cpu - g).abs() / denom; + if rel > worst_rel { + worst_rel = rel; + } + n_checked += 1; + assert!( + rel < 1e-4, + "nuclide {ni}, E={e:.4e} eV: CPU σ={cpu:.6e} vs GPU σ={g:.6e} (rel {rel:.2e})" + ); + } + } + + eprintln!( + "CE XS A/B: {n_checked} (nuclide,E) points checked, worst rel err = {worst_rel:.2e}" + ); + assert!(n_checked > 100, "too few points actually compared"); +} diff --git a/rust_prototype/tests/cuda_runs.rs b/rust_prototype/tests/cuda_runs.rs index 33c0db5..140e5e1 100644 --- a/rust_prototype/tests/cuda_runs.rs +++ b/rust_prototype/tests/cuda_runs.rs @@ -274,6 +274,7 @@ fn run_case_cuda( }), buffers: std::cell::RefCell::new(None), refill: std::cell::RefCell::new(None), + nxn_mode: 0, }; let _ = materials; let outcome = runner.run(&cfg);