diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
new file mode 100644
index 0000000..aa52953
--- /dev/null
+++ b/.github/actions/setup/action.yml
@@ -0,0 +1,28 @@
+name: Set up roseNNa
+description: Python with a cached pip, and the project's dependencies.
+
+runs:
+ using: composite
+ steps:
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ # Every job installed the same ~500 MB of wheels from scratch:
+ # "Install dependencies" was 64-128s in each of the nine, the single
+ # largest repeated cost in the matrix.
+ cache: pip
+ cache-dependency-path: requirements.txt
+
+ - name: Install dependencies
+ shell: bash
+ run: |
+ set -euo pipefail
+ # CPU torch on Linux: the default wheel is 529 MB against 188 MB, and
+ # nothing in CI runs torch on a GPU -- it only exports the golden
+ # models. requirements.txt then finds torch already satisfied.
+ # macOS wheels are CPU-only anyway and are not on that index.
+ if [ "${{ runner.os }}" = "Linux" ]; then
+ pip install --index-url https://download.pytorch.org/whl/cpu torch
+ fi
+ pip install -r requirements.txt
+ pip install -e python
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index b597df6..18f5f84 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -28,21 +28,245 @@ jobs:
- name: Check gfortran version
run: gfortran --version
- - name: Set up Python
- uses: actions/setup-python@v5
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
+
+ - name: Python package tests
+ run: cd python && python3 -m pytest tests -v -n auto
+
+ # A dead-model skip (the golden models are regenerated unseeded each run
+ # and can come out all-zero) is legitimate; a skip for a missing compiler
+ # is not. pipefail keeps pytest's own exit status through the tee.
+ - name: Device-path tests ran on the host (not skipped for a missing compiler)
+ run: |
+ set -o pipefail
+ cd python && python3 -m pytest tests/test_device_c.py -v -rs 2>&1 | tee device.log
+ ! grep -E -q "SKIPPED.*(no C compiler|-fopenmp|no gfortran)" device.log
+
+ # Compile-only check of the generated CUDA sources. There is no GPU here and
+ # no driver is installed: nvcc builds the kernel and the .c-as-C++ library for
+ # sm_80 and the test asserts the archive exists. Running it is the GPU gate's
+ # job. This job is the first time the generated CUDA code meets a compiler.
+ # ubuntu-22.04 on purpose: NVIDIA's ubuntu2404 apt repository starts at CUDA
+ # 12.5, and 12.4 is the toolkit version this job pins.
+ nvcc_compile:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Install the CUDA 12.4 compiler and runtime headers (no driver)
+ run: |
+ set -euo pipefail
+ wget -q https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb
+ sudo dpkg -i cuda-keyring_1.1-1_all.deb
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends cuda-nvcc-12-4 cuda-cudart-dev-12-4
+ echo "/usr/local/cuda-12.4/bin" >> "$GITHUB_PATH"
+
+ - name: Check nvcc
+ run: nvcc --version && gcc --version
+
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
+
+ # No -n here: this step uses -s (it prints the compiler's own output),
+ # and pytest-xdist cannot capture that.
+ - name: CUDA backend compiles under nvcc (not skipped)
+ run: |
+ set -o pipefail
+ cd python && python3 -m pytest tests/test_kernel.py -v -rs -s -k cuda 2>&1 | tee cuda.log
+ ! grep -q "SKIPPED" cuda.log
+ grep -q "PASSED" cuda.log
+
+ # The HIP twin of nvcc_compile: hipcc and the HIP headers from AMD's apt
+ # repository, no GPU and no driver. It compiles the kernel and the .c-as-HIP
+ # library for gfx90a and links a driver against the archive, which is where
+ # hipcc-specific issues surfaced on the MI210 (an archive named after the .cu
+ # is compiled as HIP source; __HIP__ is not a HIP-compilation signal).
+ # Running is the GPU gate's job.
+ hipcc_compile:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Install hipcc and the HIP headers (no driver)
+ run: |
+ set -euo pipefail
+ sudo mkdir -p --mode=0755 /etc/apt/keyrings
+ wget -qO- https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg > /dev/null
+ echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/6.4.1 noble main" \
+ | sudo tee /etc/apt/sources.list.d/rocm.list
+ printf 'Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n' | sudo tee /etc/apt/preferences.d/rocm-pin-600
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends hipcc hip-dev rocm-device-libs
+ echo "/opt/rocm/bin" >> "$GITHUB_PATH"
+
+ - name: Check hipcc
+ run: hipcc --version
+
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
+
+ - name: HIP backend compiles under hipcc (not skipped)
+ run: |
+ set -o pipefail
+ cd python && python3 -m pytest tests/test_kernel.py -v -rs -s -k hip 2>&1 | tee hip.log
+ ! grep -q "SKIPPED" hip.log
+ grep -q "PASSED" hip.log
+
+ # ASan/UBSan over the generated code for every golden model, both backends.
+ # A compiler warning cannot see the bug class this is for: the generated code
+ # is loop nests over fixed-size locals whose bounds come from the plan, so it
+ # goes wrong by computing an index from the wrong extent -- which writes past
+ # a stack array and returns plausible numbers. That shipped once already (an
+ # activation bounded by the previous op's output length), and it matched
+ # onnxruntime on every model that did not branch.
+ sanitizers:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Set up gfortran
+ uses: fortran-lang/setup-fortran@v1
with:
- python-version: '3.11'
+ compiler: gcc
+ version: 13
- - name: Install dependencies
- run: pip install -r requirements.txt
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
- - name: Python package tests
+ # -rs and the SKIPPED check together: a sanitizer job that skips every
+ # case because a compiler is missing would otherwise report success.
+ - name: Generated code is clean under ASan and UBSan
+ run: |
+ set -o pipefail
+ cd python && python3 -m pytest tests/test_sanitizers.py -v -rs -n auto 2>&1 | tee san.log
+ ! grep -q "SKIPPED" san.log
+
+ # The C backend under a second compiler. clang is preinstalled on the runner,
+ # so this costs nothing and reads the generated code with a different set of
+ # warnings from gcc's.
+ clang_c:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
+
+ - name: Check clang
+ run: clang --version
+
+ - name: Every golden model compiles warning-free under clang
+ env:
+ ROSENNA_CC: clang
+ run: |
+ set -o pipefail
+ cd python && python3 -m pytest tests/test_regressions.py -v -rs -n auto -k warning 2>&1 | tee clang.log
+ ! grep -q "SKIPPED" clang.log
+
+ # A second Fortran front end. Fortran is the backend with the least compiler
+ # diversity -- gfortran in CI, nvfortran only in the GPU gate -- and it is
+ # where the conformance risk sits: nvfortran is what found that
+ # `has_device_addr` is unimplemented there. flang and ifx each build every
+ # golden model and run it against onnxruntime, so this is a behaviour check
+ # and not only a compile.
+ #
+ # Both were verified on all 21 models before this job was written; what is
+ # unverified here is the install step, not the test.
+ flang_fortran:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Install flang
+ run: |
+ set -euo pipefail
+ wget -qO llvm.sh https://apt.llvm.org/llvm.sh
+ chmod +x llvm.sh
+ sudo ./llvm.sh 20
+ sudo apt-get install -y flang-20
+ flang-20 --version
+
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
+
+ - name: Every golden model builds with flang and matches onnxruntime
+ env:
+ ROSENNA_FC: flang-20
run: |
- pip install -e python
- cd python && python3 -m pytest tests -v
+ set -o pipefail
+ cd python && python3 -m pytest tests/test_golden_suite.py -v -rs -n auto 2>&1 | tee flang.log
+ ! grep -q "SKIPPED" flang.log
+
+ ifx_fortran:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Install ifx
+ run: |
+ set -euo pipefail
+ wget -qO- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB \
+ | gpg --dearmor | sudo tee /usr/share/keyrings/oneapi-archive-keyring.gpg > /dev/null
+ echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" \
+ | sudo tee /etc/apt/sources.list.d/oneAPI.list
+ sudo apt-get update
+ sudo apt-get install -y intel-oneapi-compiler-fortran
+
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
+
+ # setvars.sh is what puts ifx and its runtime on PATH/LD_LIBRARY_PATH;
+ # it has to be sourced in the same step that runs the tests.
+ - name: Every golden model builds with ifx and matches onnxruntime
+ run: |
+ set -o pipefail
+ source /opt/intel/oneapi/setvars.sh >/dev/null
+ cd python && ROSENNA_FC=ifx python3 -m pytest tests/test_golden_suite.py -v -rs -n auto 2>&1 | tee ifx.log
+ ! grep -q "SKIPPED" ifx.log
+
+ # Coverage of the generator, with a floor. The number is not the point: the
+ # floor is, because it turns "this change quietly stopped testing something"
+ # into a failure. validate.py sat at 75% until every uncovered line turned out
+ # to be a `raise` -- a refusal nobody had ever run, in the file whose whole
+ # job is refusing what it cannot compile correctly.
+ #
+ # gate.py is omitted (see pyproject.toml): it drives real compilers and a real
+ # GPU, so measuring it here would report the runner, not the tests.
+ coverage:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone roseNNa
+ uses: actions/checkout@v4
+
+ - name: Set up gfortran
+ uses: fortran-lang/setup-fortran@v1
+ with:
+ compiler: gcc
+ version: 13
+
+ - name: Set up Python and dependencies
+ uses: ./.github/actions/setup
- - name: Run test cases
+ # The badge in the README states the floor, which is a guarantee CI
+ # enforces rather than a snapshot that rots. The exact number goes in
+ # this run's summary, where it costs no service and no write access.
+ - name: Coverage is above the floor
run: |
- mkdir -p fLibrary/objFiles
- chmod +x test/run.sh
- cd test && ./run.sh
+ cd python && python3 -m pytest tests --cov -q -n auto
+ echo "## Coverage: $(python3 -m coverage report --format=total)% (floor: 97%)" \
+ >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index 6726778..5953b8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,33 +1,46 @@
-*
-!*/
-!goldenFiles/*/
-!openNP.fpp
-!userTesting.fpp
-!modelCreator.fpp
-!variables.fpp
-!*.f90
-!*.py
-!Makefile
-!*.sh
-!*.yml
-!*.c
-!*.toml
-!goldenFiles/mnist/mnist.onnx
-!instructions/*
-reading.f90
-userTesting.f90
-linearV3copy.f90
-test.txt
-goldenFiles/gemm_huge/
-goldenFiles/vgg16/
-goldenFiles/turbulentShear/
-graphs/
-graph_scripts/
-fLibrary/*.txt
+# This file was a whitelist: `*` followed by `!*.py`, `!*.c` and so on, plus a
+# one-off exception every time something new needed to ship (`!requirements.txt`,
+# `!goldenFiles/mnist/mnist.onnx`). That inverts the failure mode. A file nobody
+# remembered to whitelist is not merely untracked, it is invisible: absent from
+# `git status`, skipped by `git add .`, and gone at the next clean checkout. The
+# root `README.md` and `LICENSE` were both matched by it and survived only
+# because they were already in the index.
+#
+# So: ignore what is generated, and let everything else be seen.
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+.pytest_cache/
+.coverage
+
+# Compiled objects, modules and archives
+*.o
+*.mod
+*.smod
+*.a
+*.so
+
+# Weights written beside a model, and the scratch file the golden generators
+# drop next to their working directory.
+*.rwt
+*.fpp
+
+# The golden models are exported by their own .py at test time and dump a .txt
+# of expected outputs; mnist is the exception, a fixture whose .py reads it
+# rather than generating it (deleting it once cost an afternoon).
+goldenFiles/*/*.onnx
goldenFiles/*/*.txt
-randomStuff/
-fLibrary/modelCreator.f90
-fLibrary/variables.fpp
-test/modelCreator.f90
-test/variables.fpp
-!requirements.txt
+!goldenFiles/mnist/mnist.onnx
+
+# Example builds: generated sources go to gen/, and each surrogate's Makefile
+# links a pair of drivers named _c and _f. The surrogates' own
+# .onnx files ship (they are the example); microfd_closure's is exported by
+# closure.py on demand.
+examples/**/gen/
+examples/surrogates/*/*_c
+examples/surrogates/*/*_f
+examples/cns_closure/cns_c
+*.lock
diff --git a/README.md b/README.md
index 0844ffc..ef9ff53 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,9 @@
+
+
+
@@ -14,194 +17,106 @@
RoseNNa is a fast, portable, and minimally-intrusive library for neural network inference.
-It can run inference on neural networks in [ONNX](https://onnx.ai/) format, which is universal and can be used with PyTorch, TensorFlow, Keras, and more.
+It reads a neural network in [ONNX](https://onnx.ai/) format -- the format PyTorch, TensorFlow and Keras all export -- and **generates** a small, self-contained Fortran module and C library that computes it.
__RoseNNa's intended use case is embedding neural networks in Fortran- and C-based HPC codebases.__
-One compiles RoseNNa and links it to an existing PDE (e.g., CFD) solver written in C or Fortran.
-You can then evaluate your neural network from the PDE solver at Fortran/C speeds.
+You link the generated code into an existing PDE (e.g. CFD) solver and call it per point, on the CPU or inside your own GPU offload loop.
-RoseNNa currently supports RNNs, CNNs, and MLPs.
-The library is optimized Fortran and outperforms PyTorch (by a factor between 2 and 5x) for the relatively small neural networks used in physics applications, like computational fluid dynamics.
-RoseNNa is described in detail in A. Bati, S. H. Bryngelson (2024) Comp. Phys. Comm., 296, 109052..
+RoseNNa supports MLPs, CNNs and RNNs.
+Because the generated code has literal loop bounds, no runtime shape logic, no allocation and no mutable global state, it inlines into a solver's own compute kernel -- including a device kernel.
+RoseNNa is described in A. Bati, S. H. Bryngelson (2024) Comp. Phys. Comm., 296, 109052., which describes the earlier runtime-parsing library; the generator replaced it (see [History](#history)).
## Hello RoseNNa
-``` fortran
-program hello_roseNNa
-
- use rosenna
- implicit none
-
- real, dimension(1,1,28,28) :: input ! model inputs
- real, dimension(1,5) :: output ! model outputs
-
- call initialize() ! reads weights
- call use_model(input, output) ! run inference
-
-end program
+```sh
+pip install -e python
+rosenna generate model.onnx --lang both --out build/
```
-This example program links to the roseNNa library, parses the model inputs, and runs inference on the loaded library.
-Only a few lines are required to use the library: `use rosenna`, `call initialize()`, and `call use_model(args)`.
+That writes `model_model.F90` and `model.c`/`model.h` (plus build recipes) into `build/`. Then, in Fortran:
-With no arguments, `initialize` reads `onnxModel.txt` and `onnxWeights.bin` from the working directory.
-If `onnxWeights.bin` does not exist, it reads a legacy `onnxWeights.txt` instead and prints a notice to standard error; it never does this when a weights path is passed explicitly.
-To read the files from elsewhere, pass the paths.
-`initialize` is a `bind(c)` procedure, so a Fortran caller must terminate each path with `c_null_char`:
``` fortran
-use iso_c_binding
-call initialize("path/onnxModel.txt"//c_null_char, "path/onnxWeights.bin"//c_null_char)
-```
-
-## Dependencies
+program hello_roseNNa
+ use model_model
+ implicit none
+ real(real64) :: input(784), output(10)
+ integer :: status
-We have minimal dependencies.
-For example, on MacOS you can get away with just
-```
-brew install wget make cmake coreutils gcc
-pip install torch onnx numpy fypp onnxruntime pandas
+ call model_init("model.rwt", status) ! only for a file-loaded model
+ call model_infer(input, output) ! run inference
+end program
```
-## Basic Example
-Here is a quick example of how **roseNNa** works. With just a few steps, you can see how to convert a basic feed-forward neural network originally built with PyTorch into usable, accurate code in Fortran.
-First, `cd` into the `fLibrary/` directory.
+or in C:
-Then, create PyTorch model and convert to ONNX:
-``` bash
-python ../goldenFiles/gemm_small/gemm_small.py
-```
-
-Read and interpret the corresponding output files from the last step via
-``` bash
-python modelParserONNX.py -f ../goldenFiles/gemm_small/gemm_small.onnx
-```
-and compile the library
-``` bash
-make library
-```
+```c
+#include "model.h"
-Compile the "source files" (`capiTester.f90`) and link to the library file created:
-``` bash
-gfortran -c ../examples/capiTester.f90 -IobjFiles/
-gfortran -o flibrary capiTester.o libcorelib.a
-./flibrary
-```
-and finally check if the output from PyTorch model matches roseNNa's output
-``` bash
-python ../test/testChecker.py gemm_small
+int main(void) {
+ double input[784], output[10];
+ if (model_init("model.rwt") != 0) return 1; /* file-loaded models only */
+ model_infer(input, output);
+}
```
-## Compiling roseNNa
-
-1. **Save the neural network model that needs to be converted**
-
- Make sure to refer to the specific library's documentation about how to save the model.
-
-2. **Convert the saved model to an ONNX format**
-
- Details on converting a saved model to ONNX format can be found on their [website](https://onnx.ai/supported-tools.html#buildModel).
-
-
- **Converting an LSTM?**
-
- ONNX's constant folding renames an LSTM's weight initializers and stores the
- four gates in ONNX's `iofc` order, while roseNNa's `lstm_cell` consumes
- PyTorch's `ifgo` order. The parser now remaps the gates internally and looks
- every weight up by name, so a single `do_constant_folding=True` export is all
- that is needed. Earlier versions required a second, unoptimized
- (`do_constant_folding=False`) export passed via `-w`; that flag is now
- accepted but ignored.
+A model under a million parameters embeds its weights into the generated source by default, and then has no `init` to call at all.
+`model_infer` is `pure` in Fortran, takes `restrict` pointers in C, does no I/O and allocates nothing, so it is safe to call from inside an OpenMP-target, OpenACC, CUDA or HIP loop.
-```python
-torch.onnx.export(model, # model being run
- (inp, hidden), # model input (or a tuple for multiple inputs)
- filePath+"lstm_gemm.onnx", # where to save the model (can be a file or file-like object)
- export_params=True, # store the trained parameter weights inside the model file
- opset_version=12, # the ONNX version to export the model to
- do_constant_folding=True, # whether to execute constant folding for optimization
- input_names = ['input', 'hidden_state','cell_state'], # the model's input names
- output_names = ['output'], # the model's output names
- )
-```
+## Supported ONNX operators and limits
-3. **Preprocess the model**
+roseNNa generates code for: `Gemm`, `MatMul`, `Conv` (1-D and 2-D, including grouped and depthwise), `MaxPool`, `AveragePool`, `LSTM`, `GRU`, `Add`, `Concat`, `Pad`,
+`Reshape`, `Transpose`, `Squeeze`, `Unsqueeze`, `Flatten`, `Identity`, `Relu`, `Sigmoid`, `Tanh`, `Softmax`.
+`Pad` takes the opset-18 `axes` operand as well as the older whole-rank `pads`.
+An inference `BatchNormalization` is folded into the `Conv` or `Gemm` that feeds it, so it costs nothing at runtime.
-`fLibrary/` holds the library files that recreate and run inference on the model. Run `python modelParserONNX.py -f path/to/model.onnx` to reconstruct the model.
+Everything statically knowable is resolved at generation time: shapes, buffer sizes, padding (including `auto_pad`), and every node whose inputs are all constants -- so a `Reshape` of a weight, or an int64 shape tensor, never reaches the emitted code.
-4. **Compiling the library**
+A model using something the generator cannot lower is **refused by name at generation time**, never silently mis-computed. `rosenna info model.onnx` reports what it found. The limits:
-Then, in the same `/fLibrary` directory, run `make library`. This compiles the library into `libcorelib.a`, which is required to link other `*.o` files with the library. This library file is now ready to be integrated into any Fortran/C workflow.
+- one archive serves both call paths: `.c` is built by your host compiler with its offload flags, `_kernel.cu` by `nvcc`/`hipcc`. Whoever's device code is in the archive does the final link -- built with offload flags it holds the host compiler's own fatbin, so link with that compiler (`nvc -cuda`); built without them, `nvcc` can link it directly
+- spatial ops are 1-D (rank-3 NCW) or 2-D (rank-4 NCHW); `ceil_mode` must be 0
+- `Conv` `group` must divide both channel counts, and the weight's channel axis must be `C_in / group`
+- `Softmax` normalises the last axis only
+- `Pad` supports `constant`, `edge` and `reflect` with constant pads (crops included); `reflect` is limited to one reflection, so a pad must be narrower than its axis
+- a `BatchNormalization` that cannot be folded (training mode, non-constant parameters, or an intermediate read elsewhere) is refused
+- `Gemm` `alpha` and `beta` must be 1, `transA` must be 0, and weights must be constant
+- `LSTM` and `GRU` must be forward-direction with the default activations, no `clip`, `sequence_lens` (nor `input_forget`/peepholes for `LSTM`). `GRU` implements BOTH values of `linear_before_reset`: the ONNX default is 0 and PyTorch exports 1, and they compute different things
+- several inputs and several outputs are fine; they arrive concatenated in `x` and leave concatenated in `y` (see below)
+- every weight must be a constant initializer, not computed at runtime
-## Supported ONNX operators and limits
+## Verify it
-roseNNa supports the following ONNX operators: `Gemm`, `MatMul`, `Conv`, `MaxPool`, `AveragePool`, `LSTM`, `Add`,
-`Reshape`, `Transpose`, `Squeeze`, `Relu`, `Sigmoid`, `Tanh`.
-
-The parser rejects a model with `NotImplementedError` rather than silently producing a wrong answer when it
-encounters an attribute it cannot honour. The limits it enforces:
-
-- `kernel_shape` is required for `MaxPool` and `AveragePool` (inferred from the weights for `Conv`)
-- `dilations` must be 1
-- `ceil_mode` must be 0
-- kernels must be square
-- pads must be symmetric per axis
-- `Conv` `group` must be 1 (no grouped or depthwise convolution)
-- `AveragePool` with nonzero pads requires `count_include_pad=1`
-- `AveragePool` `auto_pad` must be `NOTSET` or `VALID`
-- a `Pad` node must have all-zero pads
-- `Gemm` `alpha` and `beta` must be 1, and `transA` must be 0
-
-## Fortran use
-
-One can compile a Fortran example (like the `Hello RoseNNa` example above) by specifying the location of the module files and linking the library to other program files.
-In practice, this looks like
-``` shell
-gfortran -c *.f90 -Ipath/to/objFiles
-gfortran -o flibrary *.o path/to/libcorelib.a
-./flibrary
+```sh
+rosenna verify model.onnx --cases 32
```
-**Memory layout.** `use_model` expects inputs in Fortran (column-major) order. A C caller with a row-major array must transpose it first; a Fortran caller building an array from a row-major literal should use `RESHAPE(..., order=[2,1])`, as `examples/capiTester.f90` does.
+compiles both backends and compares them against onnxruntime on random inputs. Every model in `goldenFiles/` is checked this way, on both backends, by `python/tests/test_golden_suite.py`.
-## C use
+## Several inputs
-One can readily call roseNNa from C.
-Compile roseNNa, then use the following C program as an example:
-```c
-#include
+A model with more than one graph input -- an LSTM's initial hidden and cell state, say -- takes them **concatenated in declaration order** in the single `x` buffer, and a model with more than one graph output -- that LSTM's `Y`, `Y_h` and `Y_c` -- writes them concatenated the same way in `y`. That keeps one entry point, one input buffer, one output buffer, and so one device contract, for every model; `rosenna info` prints where each tensor sits. A solver that keeps a recurrent model's state per cell feeds `y`'s state slices straight back into `x` next step, on the device.
-void use_model(double * i0, double * o0);
-void initialize(const char * model_file, const char * weights_file);
+## GPU use
-int main(void) {
+The generated code is callable from a device loop, and `rosenna gpu-gate` validates that end to end on real hardware. See [python/README.md](python/README.md) for the full story: the batched entry point, the CUDA/HIP kernel, the build recipes, and the measured per-point cost.
- /* roseNNa expects column-major (Fortran) ordering. */
- double a[2] = {1, 1};
- double b[3];
+## Examples: surrogates inside PDE solvers
- initialize("onnxModel.txt", "onnxWeights.bin");
- use_model(a, b);
+[examples/surrogates/](examples/surrogates/) has four self-contained solvers, each in C and Fortran, with a network called inside the time-step loop -- a per-cell closure (coarse-grid Burgers), a batched learned time-stepper (reaction-diffusion), a recurrent per-cell model with resident state (bubbly acoustics), and a whole-field initial guess (Poisson). They are organised by where the network sits and what code structure that forces; `make TOOLCHAIN=amd|nvidia|gnu` in any of them generates, builds and runs.
- for (int i = 0; i < 3; i++) {
- printf("%f ", b[i]);
- }
- printf("\n");
- return 0;
-}
-```
-and compile it as
-```shell
-gcc -c *.c
-gfortran -o capi *.o path/to/libcorelib.a
-./capi
-```
+## Further documentation
-A weights path ending in `.txt` (in any letter case, trailing blanks ignored) is read as the legacy text format;
-any other path is read as little-endian float64 binary, which must match the model exactly, or `initialize`
-stops with an error. The `onnxWeights.txt` fallback described under Hello RoseNNa is read as text.
+- [python/README.md](python/README.md) -- install, generate, build, and call from C or Fortran
+- [doc/methodology.md](doc/methodology.md) -- the roseNNa pipeline
+- [doc/adding-an-operator.md](doc/adding-an-operator.md) -- extending roseNNa to new operators
-## Further documentation
+## History
-Please see [this document](https://github.com/comp-physics/roseNNa/blob/master/doc/opensource.md) on how to extend roseNNa to new network models and [this document](https://github.com/comp-physics/roseNNa/blob/master/doc/methodology.md) on the details of the roseNNa pipeline.
+roseNNa began as `fLibrary/`: a Fortran library that parsed a model description at
+startup and walked it at runtime. The generator in `python/` replaced it once it
+covered every operator the library did and every model in `goldenFiles/`, which it
+now verifies against onnxruntime on both backends rather than against recorded
+output. The library, its `modelParserONNX.py`, and the shell suite that drove it
+were removed at that point; they remain in the git history.
## Citation
diff --git a/doc/adding-an-operator.md b/doc/adding-an-operator.md
new file mode 100644
index 0000000..533bf36
--- /dev/null
+++ b/doc/adding-an-operator.md
@@ -0,0 +1,119 @@
+# Adding an operator
+
+roseNNa does not implement every ONNX operator. Adding one means teaching four
+places about it, in this order. The order matters: each step is refused loudly
+by the one before it until you get there, so you are never debugging generated
+code that should not have been generated.
+
+Work through it with `rosenna verify` after every step. A new op is done when
+the model it unblocks matches onnxruntime on **both** backends.
+
+## 0. Decide whether it is really an operator
+
+Before writing a loop nest, check whether the op belongs in one of the two
+categories that cost nothing:
+
+- **Constant-only.** If every input is an initializer, add it to `FOLDABLE` in
+ `fold.py` and give `_evaluate` a numpy one-liner. It is then computed at
+ generation time and never reaches the emitters. Most `Reshape`s of weights,
+ and every `Constant`, land here.
+- **Relabelling.** If it only renames axes — it moves no bytes in a flat
+ row-major buffer — add it to `RELABEL`. `plan.py` turns it into a buffer
+ alias: no code, no copy, no extra buffer. `Reshape`, `Squeeze`, `Unsqueeze`,
+ `Flatten` and `Identity` are all in this class, and so is any `Transpose`
+ whose permutation only moves size-1 axes (`_flat_preserving` decides).
+
+Only what survives both of those needs real generated code.
+
+## 1. `validate.py` — refuse what you will not implement
+
+Add the op to `SUPPORTED`, then write a `_validate_` that rejects every
+attribute your loop nest does **not** honour, naming the node.
+
+This is the most important step and the easiest to under-do. Every rule here
+exists because the alternative is not a crash but a model that runs and returns
+plausible, wrong numbers. If your Conv ignores `dilations`, refuse a non-unit
+`dilations` — do not quietly compute something else.
+
+```python
+def _validate_mything(graph: Graph, node) -> None:
+ where = f"node '{node.name}'"
+ if int(node.attrs.get("some_mode", 0)) != 0:
+ raise UnsupportedModel(f"{where}: some_mode=1 is not supported")
+```
+
+## 2. `plan.py` — lower it to literal extents
+
+Two parts: a frozen spec dataclass carrying whatever the loop nest needs, and a
+branch in `build_plan` that fills it.
+
+Resolve everything shape-dependent **here**, not in the emitters. `auto_pad` is
+the worked example: it depends on the input extent, the input extent is
+literal, so `_begin_pads` turns it into two integers and the emitted code never
+learns that `auto_pad` exists. The emitters should only ever interpolate
+numbers.
+
+```python
+@dataclass(frozen=True)
+class MyThing:
+ n: int
+ extent: int
+```
+
+Add the field to `Op` (default `None`), and append your op in `build_plan`.
+`n_in`/`n_out` are the flat element counts — `_length(graph.values[name])`.
+
+If your op has extra operands or results beyond the single in/out every other
+op uses, put them in `extra_in` / `outs`; `_assign_buffers` already tracks
+liveness across both. If it needs scratch that lives across its own internal
+loop, allocate it there too, the way `lstm` does for its carried state.
+
+## 3. The emitters — one loop nest each
+
+`emit_c.py` and `emit_fortran.py` render the same plan, and the golden suite
+asserts they agree. Write them together and keep them line-for-line parallel;
+it is the only practical way to keep them in step.
+
+Buffers are flat and row-major in both languages. In Fortran the counters stay
+0-based and only the subscript gains the `+ 1`, so the two emitters compute
+visibly the same index:
+
+```python
+idx = f"((n * {c} + ic) * {h} + ih) * {w} + iw" # C
+idx = f"((n * {c} + ic) * {h} + ih) * {w} + iw + 1" # Fortran
+```
+
+Three rules the existing ops follow:
+
+- **Add a bias after the accumulation, never as the seed.** `acc = 0`, sum,
+ then `acc += b[i]`. Seeding from a declare-target array makes nvc refuse to
+ compile a `distribute parallel for` body at all. See
+ [`doc/nvhpc_teams_mapping/`](nvhpc_teams_mapping/).
+- **Propagate NaN.** `max(v, 0)` returns 0 for a NaN, and `v > best` drops one.
+ Write `merge(0, v, v < 0)` and `!(v <= best)`. This library is linked into
+ solvers where a NaN out of a diverged run is the signal.
+- **Declare Fortran locals.** Fortran has no statement-scoped declarations, so
+ any new counter or accumulator has to be added to the `loop_vars` list in
+ `emit_fortran.py`, and only when an op actually uses it — an unused variable
+ is a warning in any tree built with `-Werror`.
+
+Weight layout differs between the backends: `emit_c` indexes a weight flat,
+while `emit_fortran` declares it with the ONNX shape **reversed** and fills it
+from the same C-order value list, so `w(kw, kh, ic, oc)` in Fortran addresses
+exactly what `w[((oc*IC+ic)*KH+kh)*KW+kw]` reaches in C. A weight whose index
+arithmetic is genuinely flat (a broadcast `Add` constant, an LSTM's `W`) is
+registered with a flat shape instead.
+
+## 4. Tests
+
+- Add a golden model under `goldenFiles//.py` if the op needs one,
+ and add its name to `GOLDEN` in `python/tests/test_golden_suite.py` — the
+ suite asserts that list is exactly the set on disk, so it cannot drift.
+- Add a rejection test for each attribute `validate.py` refuses.
+- Build a regression test from an inline `onnx.helper` graph for anything the
+ golden models do not exercise. `python/tests/test_regressions.py` has the
+ pattern; `_both_backends` compiles and runs both and compares to onnxruntime.
+
+Run `cd python && python3 -m pytest tests`. If you have an NVIDIA GPU, run
+`rosenna gpu-gate` too — the per-point path is compiled by a different compiler
+than the tests use, and it has caught real codegen problems.
diff --git a/doc/methodology.md b/doc/methodology.md
index 903d3cf..29beb27 100644
--- a/doc/methodology.md
+++ b/doc/methodology.md
@@ -1,13 +1,105 @@
# Pipeline
-First, all the core files are compiled (`activation_funcs.f90`, `derived_types.f90`, `layers.f90`, `reader.f90`). `activation_funcs.f90` stores activation functions, `derived_types.f90` stores derived types for certain layer types, `layers.f90` stores the math behind certain layers (**currently we support GEMM, LSTM, Convolutional, and MaxPool layers**), and `reader.f90` loads in the weights that are stored in the system itself.
-## Initialization and Preprocessing
-Then, in each of the test case files in [`goldenFiles`](https://github.com/comp-physics/roseNNa/tree/master/goldenFiles), the **.py** file is run to create the model, randomly initialized with weights. It creates an intermediary file called inputs.fpp, which stores the exact inputs given to the model, which is later fed to the fortran built model. It also creates a "golden file" which represents the correct shape and output of the model. Lastly, the model that was run is stored in **.onnx** format.
+roseNNa turns an ONNX model into Fortran and C source at *generation* time. The
+generated code contains no parser, no shape logic, no allocation and no mutable
+global state: every loop bound is a literal, so it inlines into a solver's own
+compute kernel, including a device kernel.
-[`modelParserONNX.py`](https://github.com/comp-physics/roseNNa/blob/master/fLibrary/modelParserONNX.py) is run to parse the onnx model and gathers information about the model and creates `onnxModel.txt` (layer names and weights dimensions) and `onnxWeights.bin` (the corresponding weights for each layer). It also creates a `variables.fpp` file that stores some key information about the model that fypp will process during model creation.
+The pipeline is a chain of graph-to-graph passes in `python/rosenna/`, each of
+which either resolves something or refuses the model by name.
-## Running and Testing
-Lastly, we have two **.fpp** files. [`modelCreator.fpp`](https://github.com/comp-physics/roseNNa/blob/master/fLibrary/modelCreator.fpp) is the module that builds the subroutine that stores the correct model architecture. It parses through `variables.fpp` and reconstructs the model with the subroutines in **layers.f90**. [`userTesting.fpp`](https://github.com/comp-physics/roseNNa/blob/master/test/userTesting.fpp) is used to create **userTesting.f90**, a sample file that calls "**initialize**" (which enables fortran to read in the weights and model structure from `onnxModel.txt` and `onnxWeights.bin`). Then it passes in the inputs from the intermediary file inputs.fpp, and runs the model. [`userTesting.fpp`](https://github.com/comp-physics/roseNNa/blob/master/test/userTesting.fpp) then stores the shape and output in a text file.
+## 1. Load — `frontend.py`
+`onnx.shape_inference` first, so every value has a literal shape. The result is
+a `Graph` of `Node`s, `Tensor` values, and initializer arrays. A symbolic
+dimension is refused here: roseNNa fixes every shape at generation.
-[`testChecker.py`](https://github.com/comp-physics/roseNNa/blob/master/test/testChecker.py) compares the outputted text file to the test's "golden file". If the shapes match and the outputs are within reasonable range, the test case passes. Otherwise, the error is outputted as either a failure due to shape or mismatching values or to an external text file `output.txt` indicating there was a runtime failure somewhere (probably due to the model encoding, decoding, or running).
+## 2. Fold — `fold.py`
+
+Two passes run before anything else looks at the graph.
+
+`fold_constants` evaluates every node whose inputs are all constants and turns
+the result into an initializer. A real export is full of these: the shape
+tensor of a `Reshape`, a `Constant` holding an LSTM's initial state, a weight
+transposed once on the way in. Running them now is also what removes the int64
+tensors the generated code could never carry.
+
+`strip_shape_inputs` then drops the metadata operands of relabelling ops, and
+any initializer nothing reads any more.
+
+## 3. Validate — `validate.py`
+
+Refuses, by node name, anything the emitters cannot lower: an unsupported op, a
+rank the loop nests do not implement, a `Conv` with `group > 1`, an `LSTM` with
+custom activations. Every rule here exists because the alternative is a model
+that runs and returns confident nonsense — which is the failure mode this file
+exists to prevent.
+
+## 4. Plan — `plan.py`
+
+Lowers the graph to an explicit `Plan`: a list of `Op`s, a set of flat rank-1
+buffers, and a weight layout.
+
+- **Shapes become arithmetic.** Buffers stay rank 1 and row-major whatever the
+ value's logical rank; a `Spatial` spec carries the literal extents a Conv or
+ pool loop nest needs, and `auto_pad` is resolved to begin-pads here, because
+ it depends on the input shape and the input shape is known.
+- **Relabelling is free.** `Reshape`, `Squeeze`, `Unsqueeze`, `Flatten`,
+ `Identity`, and any `Transpose` that only moves size-1 axes move no bytes, so
+ they become buffer aliases: no code, no copy. Liveness is tracked on the root
+ of an alias chain, so a buffer is only reused after the last read of anything
+ sharing it.
+- **Buffers are recycled.** Input and output get dedicated buffers; every
+ intermediate rotates through a free list.
+- **Several inputs, one buffer; several outputs, one buffer.** A model with
+ more than one graph input takes them concatenated in `x` in declaration
+ order, and each secondary input is copied out of its slice; a model with
+ more than one graph output writes them concatenated in `y`, each secondary
+ output copied into its slice after the last op. That is what keeps
+ `infer(x, y)` — and with it `infer_batch`, the native kernel and the device
+ contract — unchanged.
+
+The plan carries a sha256 of itself, which the weights file records and the
+generated reader checks.
+
+## 5. Emit — `emit_c.py`, `emit_fortran.py`, `emit_kernel.py`
+
+Both emitters render the *same* plan, so the two backends agree to 1e-12 and
+emit identical buffer structure. `emit_kernel.py` writes the native CUDA/HIP
+batched kernel, which calls the same header-inline body.
+
+One detail is not cosmetic: a dense layer adds its bias **after** the dot
+product rather than seeding the accumulator with it. Seeding an accumulator
+from a declare-target array is what makes nvc refuse to generate a
+`distribute parallel for` body at all — it emits a kernel that traps — and the
+reordering unlocks a ~30x faster per-point offload loop. See
+[`doc/nvhpc_teams_mapping/`](nvhpc_teams_mapping/).
+
+## 6. Verify — `verify.py`
+
+`rosenna verify` generates, compiles and runs both backends and compares them
+against onnxruntime on random inputs drawn from a fixed seed.
+
+Two things it deliberately does. It resamples until the reference is *alive*: a
+model whose own weights compute all zeros would otherwise "pass" by reproducing
+a dead network. And it allows a cancellation term in the tolerance — the
+classical `n * eps * sum|terms|` bound for a summation — because onnxruntime
+blocks and vectorises its convolutions and GEMMs, so two correct
+implementations legitimately differ by more than `rtol * |expected|` when the
+sum cancels.
+
+`python/tests/test_golden_suite.py` runs every model in `goldenFiles/` through
+this, on both backends. That replaced the old shell suite, which compared
+against recorded output — a recorded file pins whatever the library did the day
+it was recorded, so a wrong-but-stable implementation records its own error as
+the expectation.
+
+## 7. Gate — `gate.py`
+
+`rosenna gpu-gate` is the check that exercises the device path on real
+hardware: it builds the model embedded and file-loaded, in both languages, and
+runs three harnesses — a per-point C host, a per-point Fortran host, and a host
+that hands device-resident data to `infer_batch` — each compared against
+onnxruntime and timed. An `nsys` capture scoped to the timed call asserts zero
+`cudaMemcpy` inside it. Every command and its output goes into
+`gate-report.md`.
diff --git a/doc/nvhpc_teams_mapping/README.md b/doc/nvhpc_teams_mapping/README.md
new file mode 100644
index 0000000..309de69
--- /dev/null
+++ b/doc/nvhpc_teams_mapping/README.md
@@ -0,0 +1,231 @@
+# Two performance cliffs on the NVIDIA path, and what was behind them
+
+Measured on an NVIDIA A100 80GB (driver 590.48.01) with NVIDIA HPC SDK
+25.11 (nvc/nvfortran 25.11-0, nvcc 13.0.88), via `rosenna gpu-gate
+--backend cuda`, over a million distinct points with the data mapped
+outside the timed window in every harness.
+
+| ns per point | before | after |
+|---|---|---|
+| `infer` from a C per-point offload loop | 49.5 | **1.7** |
+| `infer` from a Fortran per-point offload loop | 47.3 | **1.8** |
+| `infer_batch`, native CUDA kernel, file-loaded | 1.54 | 1.49 |
+| `infer_batch`, native CUDA kernel, embedded | 3.3 | **1.46** |
+
+Every route through the library now lands within noise of every other,
+which is what the same arithmetic over the same data should cost. Three
+changes got there, and the first two are entangled:
+
+1. The generated dense layer adds the bias *after* the dot product instead
+ of seeding the accumulator with it -- without which nvc will not compile
+ the loop below at all.
+2. The host loop uses `target teams distribute parallel for` rather than
+ `target teams loop`, which is what puts all 32 lanes of a warp to work.
+3. Embedded weights go to `__constant__` only below 2 KB, not below 48 KB.
+
+The rest of this file is the evidence for each.
+
+## Why `teams loop` cost 30x
+
+It is not code quality, inlining or LTO. `ptxas -v` on the two kernels:
+
+| | registers/thread | stack frame | spill st/ld |
+|---|---|---|---|
+| nvc `nvkernel_main_F1L41_4` | 140 | 0 B | 0 / 0 |
+| nvcc `gemm_big_kernel` | 96 | 320 B | 0 / 0 |
+
+nvc's per-thread code is the better of the two -- it keeps the body in
+registers where nvcc spends a 320-byte frame -- and neither spills.
+`-Minline`, `-Minline=maxsize:2000,levels:5` and `-Mnoinline` change
+nothing.
+
+It is the loop-to-hardware mapping. `ncu` launch geometry:
+
+```
+nvc nvkernel_main_F1L41_4 (1000000, 1, 1) x (32, 1, 1)
+nvcc gemm_big_kernel ( 7813, 1, 1) x (128, 1, 1)
+```
+
+nvc maps one loop iteration to one **team** -- one point per thread block,
+32 threads per block, and no inner `parallel` for the other 31 lanes to do.
+**31 of every 32 lanes idle**, which is the factor observed (49.5 / 1.54 =
+32.1).
+
+The PTX says why the block cannot be used. nvc outlines `gemm_big_infer`
+and places its two 40-double locals in *dynamic shared memory*, at fixed
+offsets, with no per-thread indexing:
+
+```ptx
+.extern .shared .align 8 .b8 S52_1[];
+...
+st.shared.f64 [S52_1], %fd50;
+st.shared.f64 [S52_1+8], %fd57;
+```
+
+`ncu` confirms 896 bytes of dynamic shared per block -- one point's worth.
+That storage is team-shared, correct only while a single thread per team
+runs the body, which is exactly what `teams loop` arranges. Self-consistent,
+and it costs a factor of 32.
+
+### Confirmed with no compiler in the way
+
+`mapping_emulation.cu` runs the *same* generated `infer` body as a
+hand-written CUDA kernel, two ways -- no OpenMP anywhere:
+
+```
+128 thr/block, all lanes active : 1.49 ns/point
+32 thr/block, lane 0 only (nvc) : 67.80 ns/point
+```
+
+The mapping alone reproduces the gap.
+
+## Why `distribute parallel for` did not simply work
+
+It is the idiom that puts every lane to work, and under nvc it used to
+abort:
+
+```
+Fatal error: expression 'HX_CU_CALL_CHECK(__hx_cuStreamSynchronize(stream))'
+(value 1) is not equal to expression 'HX_SUCCESS' (value 0)
+```
+
+`compute-sanitizer` reported 208,769 `Trace/breakpoint trap`s inside the
+kernel. Not a race and not a resource limit: nvc declines to generate the
+loop, and the whole kernel is a 62-line PTX stub that computes the trip
+count and traps if any thread has work:
+
+```ptx
+ setp.lt.s64 %p5, %rd12, 1;
+ @%p5 bra $L__BB1_6; // no iterations: return
+$L__BB1_6:
+ ret;
+$L__BB1_5:
+ trap; // any iterations: trap
+```
+
+It survived every obvious remedy: `declare simd`; a manually blocked
+`teams distribute` over tiles with an inner `parallel for`; scratch passed
+in as parameters; scratch declared inside the loop body; the body fully
+inlined with no device routine at all; `thread_limit` 32, 64, 128 and 256;
+`-Minline` and `-Mnoinline`. Embedded and file-loaded alike.
+
+### The actual trigger
+
+Bisecting a standalone reproducer down to the line, what nvc cannot
+generate is **an accumulator initialised directly from a declare-target
+array element** inside a `distribute parallel for` region:
+
+```c
+double s = bb[0]; for (int i = 0; i < 40; ++i) s += t[i]; /* traps */
+double s = 0.0; for (int i = 0; i < 40; ++i) s += t[i]; s += bb[0]; /* fine */
+double s = 0.0; for (int i = 0; i < 8; ++i) s += bb[i]; /* fine */
+```
+
+Which is exactly the shape a dense layer is written in, once per layer:
+
+```c
+for (int i = 0; i < 20; ++i) {
+ double acc = gemm_big_b0[i]; /* <- the trigger */
+ for (int j = 0; j < 2; ++j) acc += x[j] * gemm_big_w0[i * 2 + j];
+ t0[i] = acc;
+}
+```
+
+`emit_c.py` and `emit_fortran.py` now emit the bias afterwards instead:
+
+```c
+for (int i = 0; i < 20; ++i) {
+ double acc = 0.0;
+ for (int j = 0; j < 2; ++j) acc += x[j] * gemm_big_w0[i * 2 + j];
+ acc += gemm_big_b0[i];
+ t0[i] = acc;
+}
+```
+
+Both emitters changed together, so the C and Fortran backends still agree
+to 1e-12, and both still match onnxruntime. It does reassociate the sum by
+one term, so results can differ from the old code in the last ulp.
+
+`teams_mapping_repro.c` is a 45-line self-contained reproducer -- no
+roseNNa headers, no library:
+
+```
+nvc -O2 -mp=gpu -gpu=cc80 teams_mapping_repro.c -lm -o repro && ./repro
+ teams loop -> OK
+ teams distribute parallel for -> Aborted (core dumped)
+ teams distribute parallel for, bias after -> OK
+```
+
+The bailout is a compiler defect, not something roseNNa can fix, and it has
+not been reported to NVIDIA. The reproducer above is kept so that whoever
+next wonders why a dense layer adds its bias where it does has the evidence
+in one file -- and so it can be re-checked against a later HPC SDK, since
+the reordering is a workaround that a fixed compiler would make unnecessary.
+
+## What this means for a solver
+
+Use `target teams distribute parallel for` (C) or `target teams distribute
+parallel do` (Fortran) around your per-point `infer` call, as
+`examples/cns_closure/cns.c` and the examples in `python/README.md` now do.
+`target teams loop` still compiles and still gives correct answers -- it
+just runs about 30x slower, because it leaves 31 of every 32 lanes idle.
+
+Both paths transfer nothing in the loop and both match onnxruntime, so the
+choice between per-point and `infer_batch` is now about which shape fits
+your solver, not about speed.
+
+## The second cliff: where embedded weights live
+
+With the per-point path fixed, embedded `infer_batch` still cost 4.7
+ns/point against file-loaded's 1.7. Same arithmetic, same kernel, different
+weight storage: embedded weights went to `__constant__`, file-loaded ones to
+ordinary device memory behind a `__constant__` pointer table.
+
+`ncu` on the two kernels:
+
+| | duration | `imc_miss` stall | `long_scoreboard` stall |
+|---|---|---|---|
+| embedded (`__constant__`) | 4.26 ms | **71.4%** | 0.7% |
+| file-loaded (device memory) | 1.92 ms | 0.09% | 28.6% |
+
+`imc_miss` is the immediate-constant-cache miss. Constant memory is fast
+only while the working set fits a per-SM cache of a couple of KB; gemm_big
+embeds 23 KB of weights, so nearly every read misses. The file-loaded path
+reads the same values through L1/L2, and its 28.6% `long_scoreboard` is
+ordinary, well-hidden memory latency.
+
+Forcing each dense golden model to the other qualifier, one thread per
+point, a million points, identical outputs throughout:
+
+| model | weight bytes | `__constant__` | `__device__ const` |
+|---|---|---|---|
+| gemm_small | 120 | 0.027 ns/pt | 0.028 ns/pt |
+| gemm_nobias | 160 | 0.027 | 0.028 |
+| droplet | 344 | 0.041 | 0.041 |
+| batchnet | 15,904 | 6.731 | **2.384** |
+| gemm_big | 23,208 | 3.574 | **1.423** |
+
+`CONSTANT_MEMORY_LIMIT` was 48 KB -- chosen against the 64 KB per-module
+bank, which is a correctness bound, not a performance one. It is now 2 KB:
+the three models that measure the same keep `__constant__`, and the two that
+pay 2.5-2.8x move to `__device__ const`.
+
+### The caveat on that threshold
+
+A byte count is not really the right control. Synthetic models with a
+*wide* input (16 values per point rather than 2) thrash the constant cache
+just as hard -- 67% `imc_miss` at 18 KB of weights -- and yet still come out
+~1.3x faster in `__constant__` than in device memory, because streaming a
+wide `x` puts enough pressure on L1 to change the balance:
+
+| weight bytes | `__constant__` | `__device__ const` |
+|---|---|---|
+| 2,312 | 0.130 | 0.118 |
+| 4,616 | 0.338 | 0.431 |
+| 18,440 | 1.623 | 2.025 |
+| 36,872 | 3.921 | 5.417 |
+
+2 KB is calibrated for the shape this library targets: a per-point closure
+with a handful of inputs, where the weights dominate the cache. If a
+wide-input model ever turns up, this should become a generate-time flag
+rather than a different constant.
diff --git a/doc/nvhpc_teams_mapping/mapping_emulation.cu b/doc/nvhpc_teams_mapping/mapping_emulation.cu
new file mode 100644
index 0000000..789fd96
--- /dev/null
+++ b/doc/nvhpc_teams_mapping/mapping_emulation.cu
@@ -0,0 +1,44 @@
+/* Emulate nvc's teams-loop mapping with a hand-written CUDA kernel:
+ one BLOCK per point, 32 threads, only lane 0 doing the work. If the
+ 32x gap is the idle-lane mapping, this reproduces the nvc timing. */
+#include
+#include
+#include "gemm_big.h"
+extern "C" int gemm_big_device_bind(void);
+
+static __global__ void k_one_thread_per_point(int n, const double *__restrict__ x, double *__restrict__ y) {
+ const int p = (int)(blockIdx.x * blockDim.x + threadIdx.x);
+ if (p >= n) return;
+ gemm_big_infer(x + (size_t)p * 2, y + (size_t)p * 1);
+}
+static __global__ void k_one_block_per_point(int n, const double *__restrict__ x, double *__restrict__ y) {
+ if (threadIdx.x != 0) return; /* nvc: 31 of 32 lanes idle */
+ const int p = (int)blockIdx.x;
+ if (p >= n) return;
+ gemm_big_infer(x + (size_t)p * 2, y + (size_t)p * 1);
+}
+int main(void) {
+ if (gemm_big_init("gemm_big.rwt") != 0) { printf("init failed\n"); return 1; }
+ if (gemm_big_device_bind_here() != 0) { printf("bind failed\n"); return 1; }
+ const long n = 1000000L;
+ double *hx = (double*)malloc(sizeof(double)*n*2);
+ for (long i = 0; i < n*2; ++i) hx[i] = 0.5;
+ double *dx, *dy;
+ cudaMalloc(&dx, sizeof(double)*n*2); cudaMalloc(&dy, sizeof(double)*n);
+ cudaMemcpy(dx, hx, sizeof(double)*n*2, cudaMemcpyHostToDevice);
+ cudaEvent_t a, b; cudaEventCreate(&a); cudaEventCreate(&b);
+ float ms;
+ for (int rep = 0; rep < 2; ++rep) {
+ k_one_thread_per_point<<<(n+127)/128, 128>>>(n, dx, dy);
+ cudaDeviceSynchronize();
+ cudaEventRecord(a);
+ k_one_thread_per_point<<<(n+127)/128, 128>>>(n, dx, dy);
+ cudaEventRecord(b); cudaEventSynchronize(b); cudaEventElapsedTime(&ms, a, b); { cudaError_t e = cudaGetLastError(); if (e != cudaSuccess) { printf("CUDA ERR: %s\n", cudaGetErrorString(e)); return 2; } }
+ if (rep) printf("128 thr/block, all lanes active : %7.2f ns/point\n", ms*1e6/n);
+ cudaEventRecord(a);
+ k_one_block_per_point<<>>(n, dx, dy);
+ cudaEventRecord(b); cudaEventSynchronize(b); cudaEventElapsedTime(&ms, a, b); { cudaError_t e = cudaGetLastError(); if (e != cudaSuccess) { printf("CUDA ERR: %s\n", cudaGetErrorString(e)); return 2; } }
+ if (rep) printf("32 thr/block, lane 0 only (nvc) : %7.2f ns/point\n", ms*1e6/n);
+ }
+ return 0;
+}
diff --git a/doc/nvhpc_teams_mapping/teams_mapping_repro.c b/doc/nvhpc_teams_mapping/teams_mapping_repro.c
new file mode 100644
index 0000000..e9f4f57
--- /dev/null
+++ b/doc/nvhpc_teams_mapping/teams_mapping_repro.c
@@ -0,0 +1,46 @@
+/* Self-contained: same shape as roseNNa's generated infer -- 5 dense layers
+ 2->20->30->30->40->1 over two 40-double locals, called from a target
+ teams region over device-resident heap data. No roseNNa headers. */
+#include
+#include
+#include
+#pragma omp declare target
+extern double w0[40], b0[20], w1[600], b1[30], w2[900], b2[30], w3[1200], b3[40], w4[40], b4[1];
+#pragma omp end declare target
+double w0[40], b0[20], w1[600], b1[30], w2[900], b2[30], w3[1200], b3[40], w4[40], b4[1];
+
+#pragma omp declare target
+static inline void infer(const double *restrict x, double *restrict y) {
+ double t0[40], t1[40];
+ for (int i = 0; i < 20; ++i) { double a = b0[i]; for (int j = 0; j < 2; ++j) a += x[j] * w0[i*2+j]; t0[i] = a; }
+ for (int i = 0; i < 20; ++i) t1[i] = t0[i] < 0.0 ? 0.0 : t0[i];
+ for (int i = 0; i < 30; ++i) { double a = b1[i]; for (int j = 0; j < 20; ++j) a += t1[j] * w1[i*20+j]; t0[i] = a; }
+ for (int i = 0; i < 30; ++i) t1[i] = 1.0 / (1.0 + exp(-t0[i]));
+ for (int i = 0; i < 30; ++i) { double a = b2[i]; for (int j = 0; j < 30; ++j) a += t1[j] * w2[i*30+j]; t0[i] = a; }
+ for (int i = 0; i < 30; ++i) t1[i] = t0[i] < 0.0 ? 0.0 : t0[i];
+ for (int i = 0; i < 40; ++i) { double a = b3[i]; for (int j = 0; j < 30; ++j) a += t1[j] * w3[i*30+j]; t0[i] = a; }
+ for (int i = 0; i < 40; ++i) t1[i] = tanh(t0[i]);
+ double a = b4[0]; for (int j = 0; j < 40; ++j) a += t1[j] * w4[j];
+ y[0] = 1.0 / (1.0 + exp(-a));
+}
+#pragma omp end declare target
+
+int main(void) {
+ long n = 1000000;
+ double *x = malloc(sizeof(double)*n*2), *y = malloc(sizeof(double)*n);
+ for (long i = 0; i < n*2; ++i) x[i] = 0.5;
+ for (int i = 0; i < 40; ++i) w0[i] = 0.01;
+ for (int i = 0; i < 600; ++i) w1[i] = 0.01;
+ for (int i = 0; i < 900; ++i) w2[i] = 0.01;
+ for (int i = 0; i < 1200; ++i) w3[i] = 0.01;
+ for (int i = 0; i < 40; ++i) w4[i] = 0.01;
+#pragma omp target update to(w0, b0, w1, b1, w2, b2, w3, b3, w4, b4)
+#pragma omp target enter data map(to: x[0:n*2]) map(alloc: y[0:n])
+/* Swap this line for the distribute form to reproduce the trap:
+ #pragma omp target teams distribute parallel for */
+#pragma omp target teams loop
+ for (long p = 0; p < n; ++p) infer(x + p*2, y + p);
+#pragma omp target exit data map(from: y[0:n]) map(delete: x[0:n])
+ printf("OK y[0]=%.6f\n", y[0]);
+ return 0;
+}
diff --git a/doc/opensource.md b/doc/opensource.md
deleted file mode 100644
index aaf1418..0000000
--- a/doc/opensource.md
+++ /dev/null
@@ -1,209 +0,0 @@
-# Open Source Development
-This project is ongoing and does not contain functionality of every layer available in ONNX. In order to embed new layers into roseNNa, certain steps must be followed:
-
-## Parsing in modelParserONNX.py
-This file reads in the ONNX interpretation of the model. At a higher level, it iterattes over all the layers in the ONNX model (called nodes in the graph), parses its contents by (1) sending some of its options to be parsed in f90 via fypp and (2) finding the weights that correspond to this layer and writing their dimensions to 'onnxModel.txt' and the weights to `onnxWeights.bin`. These two files will be read in by Fortran so it can store the weights and layers. Here is a pseudocode example from the "GEMM" layer in ONNX:
-
-```python
-#an additional elif branch must be added so the parser knows to parse this layer
-elif layer == "Gemm":
- #the layer name tells reader.f90 which read routine to call
- f.write(layer)
- f.write("\n")
- names = {n.name:n.i if n.type==2 else n.ints for n in node.attribute}
- #(the full branch also rejects attributes roseNNa cannot honour: transA, alpha, beta, and a bias that is not rank 1)
-
- #modelArch stores the layer and options for layer (fypp input later on)
- #ioMap is referenced to get the output name from the last layer (which is input to this layer)
- modelArch.append(("Gemm", [ioMap[node.input[0]], names.get('transB', 0)], None))
-
- #parsing the weight and bias inputs to the layer
- #(when the bias is absent, the full branch writes a zero bias instead)
- for inp in node.input[1:3]:
-
- #writing the dimensions to 'onnxModel.txt'
- for dim in initializer[inp][0]:
- f.write(str(dim)+ " ")
- f.write("\n")
-
- #writing the weights to 'onnxWeights.bin' as little-endian float64 in column-major (Fortran) order
- #findWeightsInitializer looks the tensor up by name among the initializers and Constant nodes
- f2.write(np.asarray(findWeightsInitializer(inp), dtype='
-
-void use_model(double * i0, double * o0);
-void initialize(const char * model_file, const char * weights_file);
+#include "gemm_small.h"
int main(void) {
+ /* One point: n_in = 2 values in, n_out = 3 values out. Plain row-major. */
+ const float x[2] = {1.0f, 1.0f};
+ float y[3];
- /* roseNNa expects column-major (Fortran) ordering. */
- double a[2] = {1, 1};
- double b[3];
-
- initialize("onnxModel.txt", "onnxWeights.bin");
- use_model(a, b);
+ gemm_small_infer(x, y);
- for (int i = 0; i < 3; i++) {
- printf("%f ", b[i]);
- }
+ for (int i = 0; i < 3; ++i) printf("%f ", (double)y[i]);
printf("\n");
return 0;
}
diff --git a/examples/capiTester.f90 b/examples/capiTester.f90
index aef705e..7a8b607 100644
--- a/examples/capiTester.f90
+++ b/examples/capiTester.f90
@@ -1,19 +1,19 @@
-program name
-
- USE rosenna
+! Calling a generated roseNNa model from Fortran.
+!
+! Built by run_basic.sh, which generates gemm_small first. gemm_small embeds its
+! weights, so there is no gemm_small_init to call; a larger model would call
+! call gemm_small_init("gemm_small.rwt", status)
+! once before the first infer.
+program capiTester
+ use gemm_small_model
+ use iso_fortran_env, only: real32
implicit none
- REAL (c_double), DIMENSION(1,2) :: inputs
- REAL (c_double), DIMENSION( 1, 3) :: output
-
- inputs = RESHAPE( (/1.0, 1.0/), (/1, 2/), order = [2 , 1 ])
-
- CALL initialize()
- CALL use_model(inputs, output)
+ ! One point: n_in = 2 values in, n_out = 3 values out.
+ real(real32) :: x(2), y(3)
- open(1, file = "test.txt")
- WRITE(1, *) SHAPE(output)
- WRITE(1, *) PACK(RESHAPE(output,(/SIZE(output, dim = 2), SIZE(output, dim = 1)/), order = [2, 1]),.true.)
- print *, output
+ x = 1.0_real32
+ call gemm_small_infer(x, y)
-end program name
+ print '(3(f0.6,1x))', y
+end program
diff --git a/examples/cns_closure/Makefile b/examples/cns_closure/Makefile
new file mode 100644
index 0000000..1d9c0f4
--- /dev/null
+++ b/examples/cns_closure/Makefile
@@ -0,0 +1,33 @@
+# cns_closure is C only, so it borrows the surrogates' toolchain block rather
+# than restating it: TOOLCHAIN=gnu|nvidia|amd, the offload and archive flags,
+# and OMP_TARGET_OFFLOAD=MANDATORY for the GPU toolchains all live there, and
+# the nvidia link flags in particular (-cuda -c++libs, not -lcudart) were
+# arrived at the hard way.
+MODEL := closure
+PROG := cns
+# `make train` re-exports closure.onnx, which is checked in like the
+# surrogates' models. It is an exporter, not a trainer -- see closure.py.
+TRAINER := closure.py
+LANGS := c
+GENLANG := c
+
+# A 3D box, so the default is small: NX=32 is 32^3 interior cells.
+NX ?= 32
+NSTEPS ?= 20
+DEFS := -DNSTEPS=$(NSTEPS)
+
+# BATCHED=1 builds the gather + one-call-per-field path instead of the
+# per-point one, which links lib$(MODEL).a.
+# NO_CLOSURE=1 drops the network entirely: nut stays zero, mu_eff = mu, and
+# the program is a plain compressible Navier-Stokes solver. It is the baseline
+# the closure's cost is measured against.
+ifeq ($(NO_CLOSURE),1)
+DEFS += -DNO_CLOSURE
+endif
+
+ifeq ($(BATCHED),1)
+DEFS += -DBATCHED
+ARCHIVE := 1
+endif
+
+include ../surrogates/common.mk
diff --git a/examples/cns_closure/README.md b/examples/cns_closure/README.md
new file mode 100644
index 0000000..1b05107
--- /dev/null
+++ b/examples/cns_closure/README.md
@@ -0,0 +1,272 @@
+# cns_closure: a learned closure inside a compressible Navier-Stokes solver
+
+`cns.c` is a compact 3D compressible Navier-Stokes solver in one file, with a
+roseNNa-generated MLP called once per cell inside its own offloaded loop. It
+builds, runs and checks itself, on the host and on a GPU.
+
+This example used to be `patch.md`: a documented diff against **microfd**, a
+compact compressible solver that is not part of this repository. A patch
+against a file we do not control cannot be compiled, run or tested, so
+everything about it was unverifiable by construction -- it described how the
+closure would plug in and said so honestly ("device path unvalidated"). The
+solver here is our own implementation, so the example is now an ordinary
+program that either works or fails the build. microfd is still what gave it
+its shape: padded blocks, one array per quantity, a `g` struct of pointers,
+and `LOCALS`/`IDX` macros. No microfd source is used.
+
+## What this is
+
+- **`cns.c`** -- finite volume on a uniform periodic box: MUSCL reconstruction
+ with a minmod limiter, HLLC flux, full Newtonian viscous stress plus Fourier
+ conduction, SSP-RK3 in time. Initial condition is a compressible
+ Taylor-Green vortex, which is smooth and produces a full velocity-gradient
+ tensor for the closure to consume. No MPI: like the other examples here it
+ is serial plus OpenMP target offload.
+- **`closure.py`** -- builds and exports `closure.onnx`: a 9-input, 16-hidden
+ (Tanh), 1-output MLP, deterministically initialized.
+- The closure maps the nine components of the local velocity-gradient tensor
+ to a turbulent viscosity, which is added to the molecular viscosity at every
+ face: `mu_eff = mu + rho * nut`. So the network's output feeds real
+ numerics rather than being computed and discarded.
+
+**The network is deterministically initialized, not trained.** Its output is a
+fixed but arbitrary function of the gradients, so `cns.c` floors it at zero and
+scales it to stay comparable to the molecular viscosity. This example is about
+the plumbing and the numerics around a learned closure, not about a physical
+model; a trained closure would need neither the floor nor the scale.
+
+## Build and run
+
+```
+make # host: gcc, -fopenmp
+make TOOLCHAIN=nvidia ARCH=cc80 # nvc, offloaded, OMP_TARGET_OFFLOAD=MANDATORY
+make TOOLCHAIN=amd ARCH=gfx90a # amdclang
+make TOOLCHAIN=nvidia BATCHED=1 # the batched path, linking libclosure.a
+make TOOLCHAIN=nvidia NO_CLOSURE=1 # no network at all: the plain NS solver
+make NX=16 NSTEPS=5 # smaller: NX is cells per direction, so NX^3
+```
+
+The Makefile borrows the surrogates' toolchain block
+(`../surrogates/common.mk`) rather than restating it, so the offload flags,
+the archive rules and the NVIDIA link flags are the same ones the other four
+examples use.
+
+Two ways of calling the model are built from the same source:
+
+- **per-point** (default): `closure_infer` from inside the solver's
+ `target teams distribute parallel for`. The plan embeds (177 parameters), so
+ `closure_infer` is `static inline` in `closure.h` with the weights baked in
+ -- no `closure_init`, nothing to link, and nothing allocated, transferred or
+ synchronised anywhere in the loop path.
+- **batched** (`BATCHED=1`): gather every cell's nine features into one device
+ array, then one `closure_infer_batch` call over the whole field. That is the
+ right trade for a larger network, where the per-call overhead starts to
+ matter. `closure_infer_batch` is not header-inline, so this path links
+ `libclosure.a` -- built by `closure.mk` with whichever `ROSENNA_BACKEND` the
+ toolchain selects (`cuda`, `hip` or `omp`).
+
+## What the run asserts
+
+It prints `OK` and exits 0 only if all of the following hold, so `make`
+succeeding is the assertion:
+
+1. **Mass and total energy are conserved** to round-off, which is what a
+ conservative flux-difference update on a periodic box owes you. This is a
+ check on the solver, not the network: it is what tells you the closure was
+ wired into the viscous flux without breaking conservation.
+2. **Density and pressure stay positive** and nothing goes non-finite.
+3. **The `nut` field the offloaded solver computed equals a host-side
+ evaluation of the same model on the same primitives.** This is the roseNNa
+ claim, checked inside a real solver rather than a harness.
+4. With `BATCHED=1`, **the batched path agrees with the per-point path** --
+ `closure_infer_batch` (the native CUDA/HIP kernel, or the OpenMP fallback)
+ against the header-inline `closure_infer`, compared directly rather than
+ each against the host.
+5. **Kinetic energy decreases.** A periodic box with no forcing can only lose
+ kinetic energy, to viscous and numerical dissipation. This is the one
+ assertion about the flow rather than about the code, and it is what would
+ catch a closure wired in with the wrong sign -- negative turbulent
+ viscosity adds energy.
+
+The conservation bound grows with the step count (`1e-12 + 1e-15 * NSTEPS`)
+rather than sitting at a fixed value a long run would trip for no reason:
+drift is 2.2e-14 at 5 steps on the host and 1.3e-13 at 2000 steps at 128^3,
+while a genuinely non-conservative update is wrong by many orders more.
+
+## Measured
+
+Correctness, on an A100 80GB with NVIDIA HPC SDK 25.11 at `NX=16 NSTEPS=5`,
+and on the same machine's host toolchain (gcc 13.3):
+
+| | mass drift | energy drift | nut vs host | batched vs per-point |
+|---|---|---|---|---|
+| `TOOLCHAIN=gnu` | 4.2e-15 | 2.2e-14 | 0 | -- |
+| `TOOLCHAIN=nvidia` | 1.1e-16 | 1.1e-15 | 1.4e-19 | -- |
+| `TOOLCHAIN=nvidia BATCHED=1` | 1.1e-16 | 1.1e-15 | 1.4e-19 | 0 |
+
+Both toolchains reach the same minimum density and pressure to every printed
+digit, and the native CUDA `infer_batch` kernel and the header-inline `infer`
+agree exactly. `TOOLCHAIN=amd` builds from the same source but has not been
+run here; the test suite runs it wherever an AMD GPU and `amdclang` are
+present.
+
+### The flow
+
+`NX=128 NSTEPS=2000` takes the Taylor-Green vortex to t = 1.19 (Re = 1000,
+M = 0.1):
+
+```
+128x128x128, 2000 steps, dt 5.950e-04, t 1.1901
+ mass drift 1.276e-13 (relative)
+ energy drift 9.619e-14 (relative)
+ min rho / p 0.997583 / 71.187575 non-finite cells 0
+ kinetic energy 3.100628e+01 -> 3.037493e+01 (-2.036%, dissipating)
+ closure mu_t mean 7.350e-05, max 3.101e-04 (molecular mu 1.000e-03)
+ closure nut vs host evaluation: worst |device-host| 2.168e-19
+OK
+```
+
+The closure contributes about 7% of the molecular viscosity on average and up
+to 31% at its peak, so it is changing the dissipation rather than rounding off
+under it -- which is the point of adding a closure at all. (At `NX=64` the
+timestep is twice as large, so the same 2000 steps reach t = 2.38 and lose
+16.6% of the kinetic energy: coarser grid, longer time, much more numerical
+dissipation. Not a resolution study.)
+
+### Speed of the solver itself, and what the closure adds
+
+`NO_CLOSURE=1` drops the network entirely -- `nut` stays zero, `mu_eff = mu`,
+and the program is a plain compressible Navier-Stokes solver. That is the
+baseline the closure's cost is measured against, rather than inferred from its
+share of a run that always includes it.
+
+A100 80GB, 100 steps, fp64, on an otherwise idle card:
+
+| | plain solver | with the closure | | |
+|---|---|---|---|---|
+| | ms/step (Mcell-updates/s) | ms/step (Mcell-updates/s) | slowdown | closure cost |
+| 64^3 (0.26 M cells) | 1.565 (168) | 1.833 (143) | 1.17x | 1.02 ns/cell-step |
+| 128^3 (2.10 M) | 6.941 (302) | 8.680 (242) | 1.25x | 0.83 ns/cell-step |
+| 256^3 (16.8 M) | 54.50 (308) | 68.00 (247) | 1.25x | 0.81 ns/cell-step |
+
+The plain solver saturates at about **308 Mcell-updates per second**, 3.25 ns
+per cell per timestep -- where a step is SSP-RK3, so three full RHS
+evaluations, each with MUSCL reconstruction, HLLC, the full Newtonian stress
+and Fourier conduction. Adding the closure costs 25% of wall-clock and
+0.81 ns per cell-step, which is three `closure_infer` calls at the 0.28 ns
+measured below. 64^3 is too small to fill the card, which is why it is both
+slower per cell and cheaper in relative terms.
+
+For contrast, the same plain solver on this machine's host toolchain at 64^3:
+281 ms/step on one thread, and **411 ms/step on all 128** -- slower with more
+threads. These loops are written for offload, and gcc's host fallback
+oversubscribes them exactly as `test_examples.py` documents for the
+surrogates. Treat the host path as a correctness fallback, not a CPU baseline;
+a CPU-tuned version of this solver would look nothing like it.
+
+### Speed of the closure call, A100 80GB, 20 steps
+
+The run prints this itself. Each step is SSP-RK3, so three closure calls;
+"per cell per call" divides by the range the closure covers.
+
+These come from an otherwise-idle GPU. **The numbers move by more than an
+order of magnitude on a shared machine** -- the same 64^3 case measured 2.0 ms
+per step on a quiet card and 63 ms per step with four other jobs saturating
+all four A100s, and the giveaway was 64^3 and 128^3 reporting the same total
+time, which is per-launch stall and not compute. Check `nvidia-smi` before
+trusting any of this, and pin with `CUDA_VISIBLE_DEVICES`.
+
+| | ns per cell per call | closure share of the step |
+|---|---|---|
+| per-point, 64^3 | 0.30 | 14.4% |
+| per-point, 128^3 | **0.28** | 21.7% |
+| batched, 64^3 | 0.56 | 23.9% |
+| batched, 128^3 | 0.43 | 29.4% |
+| host (gcc, 128 threads), 64^3 | 65 | 10.7% |
+| host (gcc, 1 thread), 64^3 | 305 | 51.6% |
+
+At 128^3 (2.2M cells) a closure call is 0.6 ms and the whole step 8.5 ms, so
+the learned closure costs about a fifth of a compressible Navier-Stokes step
+that is already doing MUSCL, HLLC and a full viscous stress. The A100
+per-point path is ~220x the host's 128 threads and ~1100x one thread.
+
+**The per-point path is faster than the batched one here, and the reason is
+worth knowing.** Splitting the batched path at 128^3 gives gather 42.7 ms,
+`infer_batch` plus its sync 13.4 ms, rescale 1.6 ms. So the native batched
+kernel is the cheapest part -- 0.097 ns per cell, about a third of A100 fp64
+peak for a network with 16 `tanh` -- and the gather that feeds it costs three
+times the inference, because it writes nine doubles per cell and reads them
+straight back. The fused per-point path never materialises the features: it
+reads the primitives it needs and keeps the nine gradients in registers.
+
+Batching wins when the network is large enough that per-call overhead
+dominates that extra traffic. For 177 parameters it does not, and this is the
+measurement to repeat before choosing the batched path for a bigger closure --
+not a reason to avoid it.
+
+### One thing worth knowing about nvc -O2
+
+`hllc()` copies the chosen star state into a local array instead of selecting
+it through a pointer:
+
+```c
+/* not: const double *S = SM >= 0 ? L : R; */
+double S[NV], Uk[NV], Fk[NV], Sk;
+if (SM >= 0) { ... } else { ... }
+```
+
+Selecting between two local arrays through a pointer miscompiles under
+`nvc -O2 -mp=gpu`: the kernel dies with `CUDA_ERROR_LAUNCH_FAILED` at the
+first write to a mapped array, while `-O1` is correct and
+`compute-sanitizer` reports no out-of-bounds access. Copying five doubles
+costs nothing next to the flux arithmetic. This is the second nvc codegen
+bailout this branch has had to work around -- see
+[`doc/nvhpc_teams_mapping/`](../../doc/nvhpc_teams_mapping/) for the first,
+which was about where a dense layer adds its bias.
+
+### `infer_batch` is asynchronous
+
+`closure_batched()` calls `closure_sync(0)` after `closure_infer_batch`, before
+anything reads `nut`. `closure.h` says the launch "is asynchronous on it", and
+the rescale loop right after it reads what the kernel wrote.
+
+Without that wait the example still printed the right answer, every time,
+because nvc's OpenMP target regions happen to serialize against the CUDA
+default stream. That is an implementation accident, not a guarantee, and it is
+the kind of thing that works until it is someone else's compiler. `closure_sync`
+is the backend-agnostic wait: a stream synchronize in the cuda/hip archive, a
+no-op in the omp one, whose loop is already synchronous.
+
+It also made the timing lie. With no sync, `infer_batch` measured 0.65 ms for
+60 launches over 2.2M cells -- 68 TFLOP/s of fp64, seven times what the card
+can do -- because the cost was landing in the next synchronizing region and
+showing up as an absurdly slow rescale.
+
+## Validating the device path more broadly
+
+This example checks its own closure against a host evaluation. To check the
+whole generated device contract on your hardware:
+
+```
+rosenna gpu-gate --cc nvc --fc nvfortran --flags "-mp=gpu -gpu=cc80" \
+ --backend cuda --devcc nvcc --out /tmp/rosenna-gate
+```
+
+That records `gate-report.md`: every command it ran, every line of output, the
+compiler versions, and nanoseconds per point for each harness. `--cc`/`--fc`
+must be a HOST compiler capable of OpenMP target offload (the pairing above,
+not a plain `gcc` -- `gcc-15` from Homebrew has no offload device to target
+and would silently run every per-point harness on the host even though
+`--backend cuda` asks for the native kernel). `rosenna gpu-gate --help` lists
+the AMD (`amdclang`/`amdflang`/`hip`) and no-GPU pairings too.
+
+Under `--backend cuda|hip` the gate builds two C archives per configuration
+(rulings R21/R22): the per-point C harness is compiled and linked by the HOST
+compiler with its offload flags against an `omp`-backend archive that the same
+host compiler built, and the `.cu` driver of the `infer_batch` harness is
+compiled and linked by the device compiler against the `cuda|hip` archive.
+That is the same rule a solver has to follow: a per-point OpenMP/OpenACC host
+calling `_infer` on a file-loaded model links the `omp`-backend archive
+its own compiler built, never the cuda/hip one (see the `Call it from C`
+section of `python/README.md`). This closure embeds, so it is not affected --
+but the `BATCHED=1` path does link the archive, and does follow that rule.
diff --git a/examples/cns_closure/closure.onnx b/examples/cns_closure/closure.onnx
new file mode 100644
index 0000000..600f1dc
Binary files /dev/null and b/examples/cns_closure/closure.onnx differ
diff --git a/examples/cns_closure/closure.py b/examples/cns_closure/closure.py
new file mode 100644
index 0000000..b77fad9
--- /dev/null
+++ b/examples/cns_closure/closure.py
@@ -0,0 +1,57 @@
+"""Export closure.onnx: the per-cell turbulence-closure MLP for cns.c.
+
+9 inputs -- the velocity-gradient tensor du_i/dx_j at one cell, flattened
+row-major (du/dx, du/dy, du/dz, dv/dx, dv/dy, dv/dz, dw/dx, dw/dy, dw/dz) --
+one hidden layer of 16 units with Tanh, and one output (a turbulent-viscosity
+correction, added to the molecular mu at a face in patch.md). No output
+activation: the closure is a signed correction, not a probability or a
+strictly positive quantity, and the network is free to clip or scale it
+downstream (patch.md's `muf` line does exactly that: mu + max(...)).
+
+Deterministic: torch.manual_seed pins the initialization so re-running this
+script reproduces the same closure.onnx byte for byte (module weight order
+and Kaiming/uniform default init are themselves deterministic given the
+seed).
+
+This is not a trained closure model: the weights are whatever the seeded
+initialization produced, so the network is a fixed but arbitrary function of
+the gradients. cns.c floors and scales its output accordingly. See README.md
+in this directory for what the example does and does not demonstrate.
+"""
+from pathlib import Path
+
+import torch
+import torch.nn as nn
+
+N_IN, N_HIDDEN, N_OUT = 9, 16, 1
+
+
+class Closure(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.hidden = nn.Linear(N_IN, N_HIDDEN)
+ self.act = nn.Tanh()
+ self.output = nn.Linear(N_HIDDEN, N_OUT)
+
+ def forward(self, x):
+ return self.output(self.act(self.hidden(x)))
+
+
+def main():
+ torch.manual_seed(0)
+ model = Closure().eval()
+ example = torch.zeros(1, N_IN)
+ out_path = Path(__file__).parent / "closure.onnx"
+ torch.onnx.export(
+ model, example, str(out_path),
+ export_params=True, dynamo=False,
+ opset_version=10,
+ do_constant_folding=True,
+ input_names=["input"],
+ output_names=["output"],
+ )
+ print(out_path)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/cns_closure/cns.c b/examples/cns_closure/cns.c
new file mode 100644
index 0000000..0540fa4
--- /dev/null
+++ b/examples/cns_closure/cns.c
@@ -0,0 +1,712 @@
+/* cns.c: a compact 3D compressible Navier-Stokes solver with a learned
+ * per-cell closure, in one file.
+ *
+ * Finite volume on a uniform periodic box: MUSCL reconstruction with a minmod
+ * limiter, HLLC flux, full Newtonian viscous stress plus Fourier conduction,
+ * SSP-RK3 in time. Everything resident on the device: the state arrays are
+ * mapped once before the time loop and nothing is transferred inside it.
+ *
+ * The closure is a rosenna-generated MLP that maps the nine components of the
+ * local velocity-gradient tensor to a turbulent viscosity, evaluated once per
+ * cell inside the solver's own offloaded loop (`closure()` below) and added to
+ * the molecular viscosity at every face. That is the point of the example: the
+ * network is called from inside a `target teams distribute parallel for`, with
+ * no allocation, transfer or synchronisation anywhere in the loop path.
+ *
+ * The network is DETERMINISTICALLY INITIALIZED, NOT TRAINED (see closure.py).
+ * Its output is a fixed, arbitrary function of the gradients -- this example
+ * demonstrates the plumbing and the numerics around it, not a physical model.
+ * So the closure's output is floored at zero and scaled to stay comparable to
+ * the molecular viscosity; an actual trained closure would need neither.
+ *
+ * What the run asserts (it prints OK and exits 0 only if all of it holds):
+ * 1. mass and total energy are conserved to round-off, which is what a
+ * conservative flux-difference update in a periodic box owes you;
+ * 2. density and pressure stay positive and nothing goes non-finite;
+ * 3. the nut field the offloaded solver computed equals a host-side
+ * evaluation of the same model on the same primitives. This is the
+ * roseNNa claim: the generated code gives the same answer on the device
+ * as on the host, inside a real solver rather than a harness.
+ * 4. with -DBATCHED, that the batched path (one `closure_infer_batch` call
+ * over the whole field, linking lib.a) agrees with the per-point
+ * path (`closure_infer`, header-inline) as well.
+ *
+ * The solver's shape -- padded blocks, one array per quantity, a `g` struct of
+ * pointers, LOCALS/IDX macros -- follows microfd, a compact compressible solver
+ * this example was originally written as a documented patch against. A patch
+ * against a file we do not control cannot be compiled or tested, so the solver
+ * is now its own implementation. No microfd source is used here.
+ */
+#include
+#include
+#include
+#include
+#include
+
+#include "closure.h"
+
+#ifndef NX
+#define NX 32 /* interior cells per direction */
+#endif
+#ifndef NSTEPS
+#define NSTEPS 20
+#endif
+
+#define NG 2 /* ghost layers; MUSCL needs two */
+#define NV 5 /* rho, rho u, rho v, rho w, E */
+#define PI 3.14159265358979323846
+#define NUT_SCALE 1e-3 /* see the note above: the network is untrained */
+
+static struct {
+ int n[3];
+ double h[3], L[3];
+ double gamma, mu, pr, cfl, dt, t;
+ double t_closure; /* seconds spent in the closure */
+ long n_closure; /* closure kernel launches */
+ double *q, *q1, *qs, *w, *F, *nut;
+} g;
+
+/* Padded-block conventions: one block of nc cells per quantity, index
+ * c = i + j*sx + k*sy. So c+1 steps +x, c+sx steps +y, c+sy steps +z. */
+#define LOCALS \
+ const int nx = g.n[0], ny = g.n[1], nz = g.n[2]; \
+ const long sx = nx + 2 * NG; \
+ const long sy = (long)(nx + 2 * NG) * (ny + 2 * NG); \
+ const long nc = sy * (long)(nz + 2 * NG); \
+ (void)nx; (void)ny; (void)nz; (void)sx; (void)sy; (void)nc
+
+#define IDX(i, j, k) ((long)(i) + (long)(j) * sx + (long)(k) * sy)
+#define STRIDE(d) ((d) == 0 ? 1L : (d) == 1 ? sx : sy)
+
+/* ------------------------------------------------------------------ halo */
+
+/* Periodic ghost layers, every quantity. Runs on the device over the state
+ * array in place; the three directions are separate loops because the corner
+ * ghosts must be filled from already-copied faces. */
+static void halo(double *q) {
+ LOCALS;
+ /* x, then y, then z: each pass reads what the previous one filled, which
+ * is what gets the edge and corner ghosts right. */
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int v = 0; v < NV; v++)
+ for (int k = 0; k < nz + 2 * NG; k++)
+ for (int j = 0; j < ny + 2 * NG; j++)
+ for (int i = 0; i < NG; i++) {
+ q[v * nc + IDX(i, j, k)] = q[v * nc + IDX(i + nx, j, k)];
+ q[v * nc + IDX(nx + NG + i, j, k)] = q[v * nc + IDX(NG + i, j, k)];
+ }
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int v = 0; v < NV; v++)
+ for (int k = 0; k < nz + 2 * NG; k++)
+ for (int i = 0; i < nx + 2 * NG; i++)
+ for (int j = 0; j < NG; j++) {
+ q[v * nc + IDX(i, j, k)] = q[v * nc + IDX(i, j + ny, k)];
+ q[v * nc + IDX(i, ny + NG + j, k)] = q[v * nc + IDX(i, NG + j, k)];
+ }
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int v = 0; v < NV; v++)
+ for (int j = 0; j < ny + 2 * NG; j++)
+ for (int i = 0; i < nx + 2 * NG; i++)
+ for (int k = 0; k < NG; k++) {
+ q[v * nc + IDX(i, j, k)] = q[v * nc + IDX(i, j, k + nz)];
+ q[v * nc + IDX(i, j, nz + NG + k)] = q[v * nc + IDX(i, j, NG + k)];
+ }
+}
+
+/* ------------------------------------------------------------------ prim */
+
+static void prim(const double *q) {
+ LOCALS;
+ double *w = g.w;
+ const double gm = g.gamma - 1;
+ #pragma omp target teams distribute parallel for
+ for (long c = 0; c < nc; c++) {
+ const double r = q[c], u = q[nc + c] / r, v = q[2 * nc + c] / r, s = q[3 * nc + c] / r;
+ w[c] = r;
+ w[nc + c] = u;
+ w[2 * nc + c] = v;
+ w[3 * nc + c] = s;
+ w[4 * nc + c] = gm * (q[4 * nc + c] - .5 * r * (u * u + v * v + s * s));
+ }
+}
+
+/* --------------------------------------------------------------- closure */
+
+#ifndef NO_CLOSURE
+/* The per-cell closure: nine velocity gradients in, one turbulent viscosity
+ * out, once per cell, inside the solver's own offload region. `closure_infer`
+ * needs no pragma of its own -- closure.h wraps it in a guarded
+ * `omp declare target` region, and with an embedded plan it is `static inline`
+ * with the weights baked in, so there is nothing to allocate, transfer or
+ * link for this path.
+ *
+ * The range is the padded block minus one layer on each side: the central
+ * differences below read index +/-1 in every direction, halo() has already
+ * filled every ghost layer, and face() reads nut one ghost cell into the low
+ * boundary of each direction. */
+static void closure(void) {
+ LOCALS;
+ const double *w = g.w;
+ double *nut = g.nut;
+ const double h0 = g.h[0], h1 = g.h[1], h2 = g.h[2];
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int k = 1; k < nz + 2 * NG - 1; k++)
+ for (int j = 1; j < ny + 2 * NG - 1; j++)
+ for (int i = 1; i < nx + 2 * NG - 1; i++) {
+ const long c = IDX(i, j, k);
+ const double *u = w + nc + c, *v = w + 2 * nc + c, *s = w + 3 * nc + c;
+ double feat[9] = {(u[1] - u[-1]) / (2 * h0), (u[sx] - u[-sx]) / (2 * h1),
+ (u[sy] - u[-sy]) / (2 * h2), (v[1] - v[-1]) / (2 * h0),
+ (v[sx] - v[-sx]) / (2 * h1), (v[sy] - v[-sy]) / (2 * h2),
+ (s[1] - s[-1]) / (2 * h0), (s[sx] - s[-sx]) / (2 * h1),
+ (s[sy] - s[-sy]) / (2 * h2)};
+ double out;
+ closure_infer(feat, &out);
+ nut[c] = NUT_SCALE * (out > 0 ? out : 0);
+ }
+}
+#endif /* NO_CLOSURE */
+
+#ifdef BATCHED
+/* The alternative for a larger network: gather every cell's nine features into
+ * one device array, then make a single `closure_infer_batch` call instead of
+ * one `closure_infer` per cell. Unlike `closure_infer` this is not
+ * header-inline, so this path links lib.a.
+ *
+ * `closure_infer_batch` runs over all nc cells, not just the range the gather
+ * loop fills, so the outermost single layer sees uninitialized features and
+ * writes a correspondingly meaningless nut. Harmless -- face() never reads nut
+ * that far into the ghost region -- but the gather buffer is zeroed once at
+ * allocation so the values are at least deterministic. */
+static double *g_feat;
+
+static void closure_batched(void) {
+ LOCALS;
+ const double *w = g.w;
+ double *nut = g.nut;
+ const double h0 = g.h[0], h1 = g.h[1], h2 = g.h[2];
+ double *feat = g_feat;
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int k = 1; k < nz + 2 * NG - 1; k++)
+ for (int j = 1; j < ny + 2 * NG - 1; j++)
+ for (int i = 1; i < nx + 2 * NG - 1; i++) {
+ const long c = IDX(i, j, k);
+ const double *u = w + nc + c, *v = w + 2 * nc + c, *s = w + 3 * nc + c;
+ double *f9 = feat + 9 * c;
+ f9[0] = (u[1] - u[-1]) / (2 * h0);
+ f9[1] = (u[sx] - u[-sx]) / (2 * h1);
+ f9[2] = (u[sy] - u[-sy]) / (2 * h2);
+ f9[3] = (v[1] - v[-1]) / (2 * h0);
+ f9[4] = (v[sx] - v[-sx]) / (2 * h1);
+ f9[5] = (v[sy] - v[-sy]) / (2 * h2);
+ f9[6] = (s[1] - s[-1]) / (2 * h0);
+ f9[7] = (s[sx] - s[-sx]) / (2 * h1);
+ f9[8] = (s[sy] - s[-sy]) / (2 * h2);
+ }
+ int status;
+ /* use_device_ptr, not use_device_addr: the list items are pointer
+ * variables, and use_device_addr takes the device address of the item
+ * itself, not of what it points to -- the kernel then faulted on an
+ * MI210 (amdclang), where the host build, with the clause inert, had
+ * passed. Both arrays are already mapped; ruling R5 says the call
+ * transfers nothing. */
+ #pragma omp target data use_device_ptr(feat, nut)
+ { status = closure_infer_batch((int)nc, feat, nut, 0); }
+ if (status) {
+ fprintf(stderr, "closure_infer_batch failed: %d\n", status);
+ exit(3);
+ }
+ /* closure.h: "the launch is asynchronous on it". Nothing may read nut
+ * until it has finished, and the rescale loop below does. Without this
+ * wait the example still printed the right answer, because nvc's OpenMP
+ * target regions happen to serialize against the CUDA default stream --
+ * an implementation accident, not a guarantee. closure_sync is the
+ * backend-agnostic wait: a stream synchronize in the cuda/hip archive,
+ * a no-op in the omp one, whose loop is already synchronous. */
+ status = closure_sync(0);
+ if (status) {
+ fprintf(stderr, "closure_sync failed: %d\n", status);
+ exit(3);
+ }
+ /* Scale and floor to match the per-point path. */
+ #pragma omp target teams distribute parallel for
+ for (long c = 0; c < nc; c++) nut[c] = NUT_SCALE * (nut[c] > 0 ? nut[c] : 0);
+}
+#endif
+
+/* ------------------------------------------------------------------ face */
+
+#pragma omp declare target
+static inline double minmod(double a, double b) {
+ if (a * b <= 0) return 0.0;
+ return fabs(a) < fabs(b) ? a : b;
+}
+
+/* HLLC flux for the Euler part, normal direction `d`, from the two
+ * reconstructed primitive states. `fl[NV]` receives the flux. */
+static inline void hllc(const double *L, const double *R, int d, double gamma, double *fl) {
+ const int mn = 1 + d; /* index of the normal momentum */
+ const double rL = L[0], pL = L[4], rR = R[0], pR = R[4];
+ const double unL = L[1 + d], unR = R[1 + d];
+ const double aL = sqrt(gamma * pL / rL), aR = sqrt(gamma * pR / rR);
+ const double keL = .5 * rL * (L[1] * L[1] + L[2] * L[2] + L[3] * L[3]);
+ const double keR = .5 * rR * (R[1] * R[1] + R[2] * R[2] + R[3] * R[3]);
+ const double EL = pL / (gamma - 1) + keL, ER = pR / (gamma - 1) + keR;
+
+ const double SL = fmin(unL - aL, unR - aR), SR = fmax(unL + aL, unR + aR);
+
+ double UL[NV], UR[NV], FL[NV], FR[NV];
+ UL[0] = rL; UR[0] = rR;
+ for (int i = 0; i < 3; i++) { UL[1 + i] = rL * L[1 + i]; UR[1 + i] = rR * R[1 + i]; }
+ UL[4] = EL; UR[4] = ER;
+ for (int v = 0; v < NV; v++) { FL[v] = unL * UL[v]; FR[v] = unR * UR[v]; }
+ FL[mn] += pL; FR[mn] += pR;
+ FL[4] += unL * pL; FR[4] += unR * pR;
+
+ if (SL >= 0) { for (int v = 0; v < NV; v++) fl[v] = FL[v]; return; }
+ if (SR <= 0) { for (int v = 0; v < NV; v++) fl[v] = FR[v]; return; }
+
+ const double num = pR - pL + rL * unL * (SL - unL) - rR * unR * (SR - unR);
+ const double den = rL * (SL - unL) - rR * (SR - unR);
+ const double SM = num / den;
+
+ /* Star states (Toro's HLLC). The chosen side is COPIED rather than
+ * selected through a pointer: `const double *S = SM >= 0 ? L : R` over
+ * local arrays miscompiles under nvc -O2 -mp=gpu, which turns this kernel
+ * into a launch failure (CUDA_ERROR_LAUNCH_FAILED) while -O1 is correct.
+ * Copying five doubles costs nothing next to the flux arithmetic. */
+ double S[NV], Uk[NV], Fk[NV], Sk;
+ if (SM >= 0) {
+ Sk = SL;
+ for (int v = 0; v < NV; v++) { S[v] = L[v]; Uk[v] = UL[v]; Fk[v] = FL[v]; }
+ } else {
+ Sk = SR;
+ for (int v = 0; v < NV; v++) { S[v] = R[v]; Uk[v] = UR[v]; Fk[v] = FR[v]; }
+ }
+ const double rk = S[0], pk = S[4], unk = S[1 + d];
+ const double Ek = pk / (gamma - 1) + .5 * rk * (S[1] * S[1] + S[2] * S[2] + S[3] * S[3]);
+ const double fac = rk * (Sk - unk) / (Sk - SM);
+
+ double Us[NV];
+ Us[0] = fac;
+ for (int i = 0; i < 3; i++) Us[1 + i] = fac * (i == d ? SM : S[1 + i]);
+ Us[4] = fac * (Ek / rk + (SM - unk) * (SM + pk / (rk * (Sk - unk))));
+
+ for (int v = 0; v < NV; v++) fl[v] = Fk[v] + Sk * (Us[v] - Uk[v]);
+}
+#pragma omp end declare target
+
+/* Flux through the face at c+1/2 normal to d, for every interior face,
+ * stored in F[d]. Euler part by MUSCL+HLLC on the primitives; viscous part by
+ * face-centred gradients with mu_eff = mu + rho*nut from the closure. */
+static void face(int d) {
+ LOCALS;
+ const double *w = g.w, *nut = g.nut;
+ /* The base pointer, not a pre-offset one: `g.F + d*NV*nc` computed here on
+ * the host is an address in the middle of the mapped region, and the
+ * runtime translates the base of a mapping, not an interior address --
+ * capturing the offset pointer dereferences a host address on the device.
+ * The `d` offset goes into the index below instead. */
+ double *F = g.F;
+ const long dof = (long)d * NV * nc;
+ const long s = STRIDE(d);
+ const long st[3] = {1, sx, sy};
+ const double hd = g.h[d], gamma = g.gamma, mu0 = g.mu, pr = g.pr;
+ const double h[3] = {g.h[0], g.h[1], g.h[2]};
+ const int i0 = NG - (d == 0), j0 = NG - (d == 1), k0 = NG - (d == 2);
+
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int k = k0; k < nz + NG; k++)
+ for (int j = j0; j < ny + NG; j++)
+ for (int i = i0; i < nx + NG; i++) {
+ const long c = IDX(i, j, k);
+
+ /* --- MUSCL: limited slopes, then the two face states. */
+ double L[NV], R[NV];
+ for (int v = 0; v < NV; v++) {
+ const double *a = w + v * nc + c;
+ const double dL = minmod(a[0] - a[-s], a[s] - a[0]);
+ const double dR = minmod(a[s] - a[0], a[2 * s] - a[s]);
+ L[v] = a[0] + .5 * dL;
+ R[v] = a[s] - .5 * dR;
+ }
+ /* A limiter cannot guarantee positivity of a reconstructed
+ * state on a coarse grid; fall back to first order where it
+ * fails, which is what keeps the scheme robust. */
+ if (L[0] <= 0 || L[4] <= 0 || R[0] <= 0 || R[4] <= 0)
+ for (int v = 0; v < NV; v++) { L[v] = w[v * nc + c]; R[v] = w[v * nc + c + s]; }
+
+ double fl[NV];
+ hllc(L, R, d, gamma, fl);
+
+ /* --- viscous: the 3x3 velocity gradient at the face. Normal
+ * derivative across the face; tangential ones averaged from
+ * the two cells' central differences. */
+ double du[3][3];
+ for (int a = 0; a < 3; a++) {
+ const double *ua = w + (1 + a) * nc + c;
+ for (int b = 0; b < 3; b++) {
+ if (b == d) {
+ du[a][b] = (ua[s] - ua[0]) / hd;
+ } else {
+ const long t = st[b];
+ du[a][b] = .5 * ((ua[t] - ua[-t]) / (2 * h[b]) +
+ (ua[s + t] - ua[s - t]) / (2 * h[b]));
+ }
+ }
+ }
+ const double rf = .5 * (w[c] + w[c + s]);
+ const double nf = .5 * (nut[c] + nut[c + s]);
+ const double mu = mu0 + rf * nf; /* the closure enters here */
+ const double div = du[0][0] + du[1][1] + du[2][2];
+
+ double tau[3];
+ for (int a = 0; a < 3; a++)
+ tau[a] = mu * (du[a][d] + du[d][a]) - (a == d ? 2.0 / 3.0 * mu * div : 0.0);
+
+ /* Fourier conduction, with T = p/rho (R = 1) and
+ * kappa = mu*cp/Pr. */
+ const double cp = gamma / (gamma - 1);
+ const double TL = w[4 * nc + c] / w[c], TR = w[4 * nc + c + s] / w[c + s];
+ const double qd = -(mu * cp / pr) * (TR - TL) / hd;
+
+ double uf[3];
+ for (int a = 0; a < 3; a++) uf[a] = .5 * (w[(1 + a) * nc + c] + w[(1 + a) * nc + c + s]);
+
+ for (int a = 0; a < 3; a++) fl[1 + a] -= tau[a];
+ fl[4] -= uf[0] * tau[0] + uf[1] * tau[1] + uf[2] * tau[2];
+ fl[4] += qd;
+
+ for (int v = 0; v < NV; v++) F[dof + v * nc + c] = fl[v];
+ }
+}
+
+/* ------------------------------------------------------------------- rhs */
+
+/* out = src + dt * (-div F), over the interior. */
+static void advance(const double *src, double *out, double dt) {
+ LOCALS;
+ const double *F = g.F;
+ const double h[3] = {g.h[0], g.h[1], g.h[2]};
+ const long st[3] = {1, sx, sy};
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int k = NG; k < nz + NG; k++)
+ for (int j = NG; j < ny + NG; j++)
+ for (int i = NG; i < nx + NG; i++) {
+ const long c = IDX(i, j, k);
+ for (int v = 0; v < NV; v++) {
+ double div = 0;
+ for (int d = 0; d < 3; d++) {
+ const double *Fd = F + (long)d * NV * nc + v * nc;
+ div += (Fd[c] - Fd[c - st[d]]) / h[d];
+ }
+ out[v * nc + c] = src[v * nc + c] - dt * div;
+ }
+ }
+}
+
+static void rhs_eval(double *q) {
+ halo(q);
+ prim(q);
+#ifndef NO_CLOSURE
+ /* Every target region in this file is synchronous -- none carries
+ * `nowait` -- so wall-clock around the call is the kernel's own cost. */
+ const double t0 = omp_get_wtime();
+#ifdef BATCHED
+ closure_batched();
+#else
+ closure();
+#endif
+ g.t_closure += omp_get_wtime() - t0;
+ g.n_closure++;
+#endif
+ for (int d = 0; d < 3; d++) face(d);
+}
+
+/* SSP-RK3. q1 and qs are scratch, both device-resident. */
+static void step(void) {
+ LOCALS;
+ double *q = g.q, *q1 = g.q1, *qs = g.qs;
+ const double dt = g.dt;
+
+ rhs_eval(q);
+ advance(q, q1, dt);
+
+ rhs_eval(q1);
+ advance(q1, qs, dt);
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int k = NG; k < nz + NG; k++)
+ for (int j = NG; j < ny + NG; j++)
+ for (int i = NG; i < nx + NG; i++) {
+ const long c = IDX(i, j, k);
+ for (int v = 0; v < NV; v++)
+ q1[v * nc + c] = .75 * q[v * nc + c] + .25 * qs[v * nc + c];
+ }
+
+ rhs_eval(q1);
+ advance(q1, qs, dt);
+ #pragma omp target teams distribute parallel for collapse(3)
+ for (int k = NG; k < nz + NG; k++)
+ for (int j = NG; j < ny + NG; j++)
+ for (int i = NG; i < nx + NG; i++) {
+ const long c = IDX(i, j, k);
+ for (int v = 0; v < NV; v++)
+ q[v * nc + c] = (1.0 / 3.0) * q[v * nc + c] + (2.0 / 3.0) * qs[v * nc + c];
+ }
+
+ g.t += dt;
+}
+
+/* ------------------------------------------------------------------ init */
+
+/* Compressible Taylor-Green: smooth, periodic, and it produces a full
+ * velocity-gradient tensor, which is what the closure consumes. */
+static void init(void) {
+ LOCALS;
+ const double M = 0.1, gamma = g.gamma;
+ const double p0 = 1.0 / (gamma * M * M), r0 = 1.0;
+ for (int k = 0; k < nz + 2 * NG; k++)
+ for (int j = 0; j < ny + 2 * NG; j++)
+ for (int i = 0; i < nx + 2 * NG; i++) {
+ const long c = IDX(i, j, k);
+ const double x = (i - NG + .5) * g.h[0], y = (j - NG + .5) * g.h[1],
+ z = (k - NG + .5) * g.h[2];
+ const double u = sin(x) * cos(y) * cos(z);
+ const double v = -cos(x) * sin(y) * cos(z);
+ const double s = 0.0;
+ const double p = p0 + (r0 / 16.0) * (cos(2 * x) + cos(2 * y)) * (cos(2 * z) + 2.0);
+ g.q[c] = r0;
+ g.q[nc + c] = r0 * u;
+ g.q[2 * nc + c] = r0 * v;
+ g.q[3 * nc + c] = r0 * s;
+ g.q[4 * nc + c] = p / (gamma - 1) + .5 * r0 * (u * u + v * v + s * s);
+ }
+}
+
+/* ---------------------------------------------------------------- checks */
+
+static void totals(const double *q, double *mass, double *energy, double *ke) {
+ LOCALS;
+ double m = 0, e = 0, k2 = 0;
+ for (int k = NG; k < nz + NG; k++)
+ for (int j = NG; j < ny + NG; j++)
+ for (int i = NG; i < nx + NG; i++) {
+ const long c = IDX(i, j, k);
+ m += q[c];
+ e += q[4 * nc + c];
+ k2 += .5 * (q[nc + c] * q[nc + c] + q[2 * nc + c] * q[2 * nc + c] +
+ q[3 * nc + c] * q[3 * nc + c]) / q[c];
+ }
+ const double dv = g.h[0] * g.h[1] * g.h[2];
+ *mass = m * dv;
+ *energy = e * dv;
+ *ke = k2 * dv;
+}
+
+#ifndef NO_CLOSURE
+/* The host's own evaluation of the same model on the same primitives, for
+ * comparison against what the offloaded closure() wrote. */
+static double nut_mismatch(void) {
+ LOCALS;
+ const double *w = g.w;
+ const double h0 = g.h[0], h1 = g.h[1], h2 = g.h[2];
+ double worst = 0;
+ for (int k = 1; k < nz + 2 * NG - 1; k++)
+ for (int j = 1; j < ny + 2 * NG - 1; j++)
+ for (int i = 1; i < nx + 2 * NG - 1; i++) {
+ const long c = IDX(i, j, k);
+ const double *u = w + nc + c, *v = w + 2 * nc + c, *s = w + 3 * nc + c;
+ double feat[9] = {(u[1] - u[-1]) / (2 * h0), (u[sx] - u[-sx]) / (2 * h1),
+ (u[sy] - u[-sy]) / (2 * h2), (v[1] - v[-1]) / (2 * h0),
+ (v[sx] - v[-sx]) / (2 * h1), (v[sy] - v[-sy]) / (2 * h2),
+ (s[1] - s[-1]) / (2 * h0), (s[sx] - s[-sx]) / (2 * h1),
+ (s[sy] - s[-sy]) / (2 * h2)};
+ double out;
+ closure_infer(feat, &out);
+ const double ref = NUT_SCALE * (out > 0 ? out : 0);
+ const double d = fabs(g.nut[c] - ref);
+ if (d > worst) worst = d;
+ }
+ return worst;
+}
+#endif /* NO_CLOSURE */
+
+int main(void) {
+ g.n[0] = g.n[1] = g.n[2] = NX;
+ g.L[0] = g.L[1] = g.L[2] = 2 * PI;
+ g.gamma = 1.4;
+ g.mu = 1e-3;
+ g.pr = 0.72;
+ g.cfl = 0.4;
+ for (int d = 0; d < 3; d++) g.h[d] = g.L[d] / g.n[d];
+ LOCALS;
+
+ const size_t blk = sizeof(double) * NV * nc;
+ g.q = malloc(blk); g.q1 = malloc(blk); g.qs = malloc(blk);
+ g.w = malloc(blk); g.F = malloc(blk * 3); g.nut = malloc(sizeof(double) * nc);
+ if (!g.q || !g.q1 || !g.qs || !g.w || !g.F || !g.nut) return 2;
+ memset(g.nut, 0, sizeof(double) * nc);
+ memset(g.F, 0, blk * 3);
+ init();
+
+ /* One fixed dt from the initial state: a device reduction per step would
+ * be a transfer in the loop path, which is the thing this example is
+ * demonstrating the absence of. */
+ double smax = 0;
+ for (long c = 0; c < nc; c++) {
+ const double r = g.q[c], u = g.q[nc + c] / r, v = g.q[2 * nc + c] / r,
+ s = g.q[3 * nc + c] / r;
+ const double p = (g.gamma - 1) * (g.q[4 * nc + c] - .5 * r * (u * u + v * v + s * s));
+ const double a = sqrt(g.gamma * p / r);
+ const double sp = fmax(fabs(u), fmax(fabs(v), fabs(s))) + a;
+ if (sp > smax) smax = sp;
+ }
+ const double hmin = fmin(g.h[0], fmin(g.h[1], g.h[2]));
+ g.dt = g.cfl * hmin / (3 * smax);
+
+ double m0, e0, ke0;
+ totals(g.q, &m0, &e0, &ke0);
+
+ double *q = g.q, *q1 = g.q1, *qs = g.qs, *w = g.w, *F = g.F, *nut = g.nut;
+ /* These are used only in the map clauses below, which a compiler built
+ * without offload support treats as no-ops -- hence -Wunused there. */
+ (void)q1; (void)qs; (void)w; (void)F; (void)nut;
+#ifdef BATCHED
+ g_feat = malloc(sizeof(double) * 9 * nc);
+ if (!g_feat) return 2;
+ memset(g_feat, 0, sizeof(double) * 9 * nc);
+ double *feat = g_feat;
+#endif
+ /* Mapped once, outside the time loop. Nothing below transfers. */
+ #pragma omp target enter data map(to: q[0:NV*nc], nut[0:nc]) \
+ map(alloc: q1[0:NV*nc], qs[0:NV*nc], w[0:NV*nc], F[0:3*NV*nc])
+#ifdef BATCHED
+ #pragma omp target enter data map(to: feat[0:9*nc])
+#endif
+
+ /* One untimed rhs_eval first: the first launch of each kernel pays for
+ * module load and any JIT, which would otherwise land entirely in step 1
+ * and dominate a short run. rhs_eval writes only w, nut and F, never q,
+ * so this does not change the answer. */
+ rhs_eval(g.q);
+ g.t_closure = 0;
+ g.n_closure = 0;
+
+ const double t_loop0 = omp_get_wtime();
+ for (int n = 0; n < NSTEPS; n++) step();
+ const double t_loop = omp_get_wtime() - t_loop0;
+
+ #pragma omp target exit data map(from: q[0:NV*nc], w[0:NV*nc], nut[0:nc]) \
+ map(release: q1[0:NV*nc], qs[0:NV*nc], F[0:3*NV*nc])
+#ifdef BATCHED
+ #pragma omp target exit data map(release: feat[0:9*nc])
+#endif
+
+ double m1, e1, ke1;
+ totals(q, &m1, &e1, &ke1);
+
+ /* 4. The two ways of calling the model, compared against each other
+ * directly rather than each against the host. The batched path ran during
+ * the time loop; re-run the per-point path on the same final primitives
+ * and require the same nut. Outside the loop, so it costs the loop path
+ * nothing. */
+ double nut_paths = 0;
+#ifdef BATCHED
+ double *nut_b = malloc(sizeof(double) * nc);
+ if (!nut_b) return 2;
+ memcpy(nut_b, nut, sizeof(double) * nc);
+ #pragma omp target enter data map(to: w[0:NV*nc], nut[0:nc])
+ closure();
+ #pragma omp target exit data map(from: nut[0:nc]) map(release: w[0:NV*nc])
+ for (int k = 1; k < nz + 2 * NG - 1; k++)
+ for (int j = 1; j < ny + 2 * NG - 1; j++)
+ for (int i = 1; i < nx + 2 * NG - 1; i++) {
+ const long c = IDX(i, j, k);
+ const double d = fabs(nut_b[c] - nut[c]);
+ if (d > nut_paths) nut_paths = d;
+ }
+#endif
+
+ /* 1. conservation */
+ const double dm = fabs(m1 - m0) / m0, de = fabs(e1 - e0) / e0;
+ /* 2. positivity and finiteness */
+ long bad = 0;
+ double rmin = 1e300, pmin = 1e300;
+ for (int k = NG; k < nz + NG; k++)
+ for (int j = NG; j < ny + NG; j++)
+ for (int i = NG; i < nx + NG; i++) {
+ const long c = IDX(i, j, k);
+ const double r = q[c], u = q[nc + c] / r, v = q[2 * nc + c] / r,
+ s = q[3 * nc + c] / r;
+ const double p = (g.gamma - 1) * (q[4 * nc + c] - .5 * r * (u * u + v * v + s * s));
+ if (!isfinite(r) || !isfinite(p)) { bad++; continue; }
+ if (r < rmin) rmin = r;
+ if (p < pmin) pmin = p;
+ }
+ /* 3. the closure agrees with a host evaluation of the same model */
+#ifdef NO_CLOSURE
+ const double nerr = 0; /* nothing to compare: nut stays zero */
+#else
+ const double nerr = nut_mismatch();
+#endif
+
+ /* How much viscosity the closure is actually contributing. */
+ double nut_sum = 0, nut_max = 0;
+ long nut_n = 0;
+ for (int k = NG; k < nz + NG; k++)
+ for (int j = NG; j < ny + NG; j++)
+ for (int i = NG; i < nx + NG; i++) {
+ const double v = nut[IDX(i, j, k)] * q[IDX(i, j, k)]; /* rho * nut */
+ nut_sum += v;
+ if (v > nut_max) nut_max = v;
+ nut_n++;
+ }
+
+ /* The closure runs over the padded block minus one layer on each side,
+ * which is the count to divide by -- not the interior cell count. */
+ const long ncl = (long)(nx + 2 * NG - 2) * (ny + 2 * NG - 2) * (nz + 2 * NG - 2);
+ (void)ncl;
+#ifndef NO_CLOSURE
+ const double per_cell_ns = 1e9 * g.t_closure / (double)(g.n_closure * ncl);
+#endif
+
+ printf("%dx%dx%d, %d steps, dt %.3e, t %.4f\n", NX, NX, NX, NSTEPS, g.dt, g.t);
+ printf(" loop %8.2f ms total, %7.3f ms/step\n", 1e3 * t_loop,
+ 1e3 * t_loop / NSTEPS);
+#ifdef NO_CLOSURE
+ puts(" closure none (NO_CLOSURE: nut = 0, mu_eff = mu)");
+#else
+ printf(" closure %8.2f ms total (%4.1f%% of the loop), %ld launches\n",
+ 1e3 * g.t_closure, 100 * g.t_closure / t_loop, g.n_closure);
+ printf(" %8.2f ns per cell per call, over %ld cells\n",
+ per_cell_ns, ncl);
+#endif
+ printf(" mass drift %.3e (relative)\n", dm);
+ printf(" energy drift %.3e (relative)\n", de);
+ printf(" min rho / p %.6f / %.6f non-finite cells %ld\n", rmin, pmin, bad);
+ printf(" kinetic energy %.6e -> %.6e (%+.3f%%, dissipating)\n",
+ ke0, ke1, 100 * (ke1 - ke0) / ke0);
+#ifndef NO_CLOSURE
+ printf(" closure mu_t mean %.3e, max %.3e (molecular mu %.3e)\n",
+ nut_sum / (double)nut_n, nut_max, g.mu);
+#endif
+#ifndef NO_CLOSURE
+ printf(" closure nut vs host evaluation: worst |device-host| %.3e\n", nerr);
+#endif
+#ifdef BATCHED
+ printf(" batched vs per-point: worst |infer_batch-infer| %.3e\n", nut_paths);
+#endif
+
+ /* Conservation drifts by roundoff per step, so the bound grows with the
+ * step count rather than sitting at a fixed value a long run would trip
+ * for no reason. Observed: 2.2e-14 at 5 steps on the host, 1.3e-13 at
+ * 2000 steps at 128^3. A genuinely non-conservative update is wrong by
+ * many orders more than this, so the floor is generous on purpose.
+ * Kinetic energy may only decrease: periodic box, no forcing, viscous and
+ * numerical dissipation only. */
+ const double cons_tol = 1e-12 + 1e-15 * NSTEPS;
+ const int ok = dm < cons_tol && de < cons_tol && bad == 0 && rmin > 0 && pmin > 0 &&
+ nerr < 1e-12 && nut_paths < 1e-12 && ke1 < ke0 && ke1 > 0;
+ puts(ok ? "OK" : "FAIL");
+ return ok ? 0 : 1;
+}
diff --git a/examples/run_basic.sh b/examples/run_basic.sh
new file mode 100755
index 0000000..a4a8d01
--- /dev/null
+++ b/examples/run_basic.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# End-to-end: build a model, generate code for it, compile and run both backends.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+# Overridable so the test suite can drive this script itself rather than a copy
+# of it, and so a machine whose Fortran is not gfortran can still run it. The
+# defaults are what a reader would type; PYTHON matters when the interpreter
+# holding torch is a virtualenv's rather than whatever `python3` resolves to.
+# ROSENNA is left unquoted on purpose: it may be a multi-word command such as
+# `python3 -m rosenna`.
+py=${PYTHON:-python3}
+# Where the golden model lives. Overridable because this script REGENERATES
+# gemm_small.onnx, and the generator is unseeded: pointed at the repository's
+# own tree it rewrites a file other tests read, which is an order dependency
+# that only shows up when they run concurrently.
+golden=${GOLDEN_DIR:-$PWD/../goldenFiles}
+rosenna=${ROSENNA:-rosenna}
+cc=${CC:-cc}
+fc=${FC:-gfortran}
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+
+# 1. Build the PyTorch model and export it to ONNX (writes gemm_small.onnx).
+# The script writes its ONNX to a path relative to the working directory, so
+# it is run from a scratch directory holding a link to the real tree.
+ln -sfn "$golden" "$work/goldenFiles"
+mkdir -p "$work/run"
+# Seeded through rosenna.golden's own GOLDEN_SEED, the way the test suite
+# and the gpu-gate run these generators. gemm_small is 2 -> 2 -> ReLU ->
+# 3 -> ReLU, and a fresh unseeded draw is sometimes DEAD -- every
+# pre-activation negative into the last ReLU, so the model outputs zeros and
+# step 5's verify refuses to compare against it. Taking the seed from the
+# package rather than repeating the number keeps one source of truth.
+(cd "$work/run" && "$py" -c 'import runpy, sys, torch
+from rosenna.golden import GOLDEN_SEED
+torch.manual_seed(GOLDEN_SEED)
+sys.argv = [sys.argv[1]]
+runpy.run_path(sys.argv[0], run_name="__main__")' ../goldenFiles/gemm_small/gemm_small.py >/dev/null)
+
+# 2. Generate Fortran and C for it.
+$rosenna generate "$golden/gemm_small/gemm_small.onnx" --lang both --out "$work"
+
+# 3. Build and run the C caller.
+"$cc" -O2 -I"$work" cAPI.c -lm -o "$work/capi"
+echo -n "C: "; "$work/capi"
+
+# 4. Build and run the Fortran caller.
+# Where to put (and find) the .mod: -J is gfortran's and flang's spelling,
+# -module is nvfortran's and ifx's. On a machine with an HPC SDK loaded, FC is
+# often already nvfortran, so guessing wrong here is the common case, not the
+# exotic one.
+# (the whole output, not its first line: nvfortran prints a blank one first)
+case "$("$fc" --version 2>&1)" in
+ *nvfortran*|*NVIDIA*|*ifx*|*IFX*) moddir=(-module "$work") ;;
+ *) moddir=("-J$work") ;;
+esac
+"$fc" -O2 -c "$work/gemm_small_model.F90" -o "$work/gemm_small_model.o" "${moddir[@]}"
+"$fc" -O2 -I"$work" capiTester.f90 "$work/gemm_small_model.o" -o "$work/fapi"
+echo -n "Fortran: "; "$work/fapi"
+
+# 5. Check both against onnxruntime.
+$rosenna verify "$golden/gemm_small/gemm_small.onnx" --lang both --cases 16
diff --git a/examples/run_basic_maclinux.sh b/examples/run_basic_maclinux.sh
deleted file mode 100755
index 6657340..0000000
--- a/examples/run_basic_maclinux.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#file to create pytorch model and convert to ONNX
-python3 ../goldenFiles/gemm_small/gemm_small.py
-
-#read and interpret the correspoding output files from last step
-python3 modelParserONNX.py -f ../goldenFiles/gemm_small/gemm_small.onnx
-
-#compile the library
-make library
-
-#compile "source files" (capiTester.f90), link to the library file created, and run
-gfortran -c ../examples/capiTester.f90 -IobjFiles/
-gfortran -o flibrary capiTester.o libcorelib.a
-./flibrary
-
-#check whether python output from PyTorch model = roseNNa's output
-python3 ../test/testChecker.py gemm_small
diff --git a/examples/surrogates/README.md b/examples/surrogates/README.md
new file mode 100644
index 0000000..fc71083
--- /dev/null
+++ b/examples/surrogates/README.md
@@ -0,0 +1,40 @@
+# Surrogate models inside PDE solvers
+
+Four self-contained solvers, each in C and Fortran, with a network called
+inside the time-step loop. They differ in where the network sits and what
+code structure that forces:
+
+| | PDE | Where the network sits | Structure |
+|---|---|---|---|
+| [burgers_closure](burgers_closure/) | coarse-grid Burgers | per-cell closure in the flux loop | embedded model, header-inline `infer` from the solver's own offload loop |
+| [reaction_patch](reaction_patch/) | 2-D FitzHugh-Nagumo | the time step, on a 3×3 patch | batched: gather → one `infer_batch` → scatter; file-loaded weights, `init`, an archive for the native backend, `_sync` |
+| [bubble_lstm](bubble_lstm/) | acoustics through bubbles | recurrent model per cell replacing a bubble population | stateful: an LSTM whose `(h, c)` stays on the device; several inputs and outputs concatenated in `x` and `y` |
+| [poisson_guess](poisson_guess/) | periodic Poisson | initial guess for the iterative solve | whole-field: the entire RHS as one input, one `infer_one` call per step, a launch per layer |
+
+Each runs its reference physics and its surrogate from the same held-out
+initial condition, prints an error and both timings, and exits 0 only if
+the surrogate did what its README says. The C and Fortran twins print the
+same numbers on an MI210.
+
+```
+cd burgers_closure
+make TOOLCHAIN=amd # amdclang / amdflang / hipcc
+make TOOLCHAIN=nvidia # nvc / nvfortran / nvcc
+make # gcc / gfortran, host only; NB=4 NX=64 for speed
+../run_all.sh amd # all four
+```
+
+`common.mk` holds the toolchain selection and the build rules; each
+example's Makefile names its model and program. The trained `.onnx` files
+are checked in; `make train` rebuilds one (a minute to a few minutes).
+
+Common to all four: the model goes to the device once (embedded, or
+uploaded by `init`); the solver's arrays are mapped once before the loop
+and swapped by pointer; Fortran passes arrays into target regions as
+explicit-shape dummies, since `amdflang` re-maps an allocatable's
+descriptor on every region entry; `burgers_closure` and `bubble_lstm` are ensembles of 1-D problems,
+because one is too small to occupy a GPU.
+
+Two things in the generator came out of these examples: register-blocked
+dense layers (`reaction_patch`, 3.4×) and `infer_one`, a launch per layer
+for models whose activations do not fit a thread (`poisson_guess`).
diff --git a/examples/surrogates/bubble_lstm/Makefile b/examples/surrogates/bubble_lstm/Makefile
new file mode 100644
index 0000000..ba71571
--- /dev/null
+++ b/examples/surrogates/bubble_lstm/Makefile
@@ -0,0 +1,7 @@
+MODEL := bubbles
+PROG := bubbles
+# Overridable so a slow runner can shrink the step count; the README
+# quotes the default.
+NSTEPS ?= 400
+DEFS := -DNSTEPS=$(NSTEPS)
+include ../common.mk
diff --git a/examples/surrogates/bubble_lstm/README.md b/examples/surrogates/bubble_lstm/README.md
new file mode 100644
index 0000000..b71e7b4
--- /dev/null
+++ b/examples/surrogates/bubble_lstm/README.md
@@ -0,0 +1,69 @@
+# Bubbly acoustics with a recurrent per-cell surrogate
+
+Wave equation coupled to stiff per-cell ODEs (1-D acoustics through a
+region of dispersed bubbles). The network replaces the per-cell bubble
+population, 8 Rayleigh-Plesset bins integrated by RK4 with 10 sub-steps
+per acoustic step, with one LSTM step per cell that maps the cell's
+pressure to the population's volume-fraction rate, the source the
+acoustics need. The stateful pattern: each cell's `(h, c)` stays on the
+device; the model has three inputs and three outputs, concatenated in `x`
+and `y`, and the solver copies `y`'s state slices back into its arrays
+for the next step.
+
+```
+$ rosenna info bubbles.onnx
+x: p[0:1] h[1:33] c[33:65]
+y: s[0:1] h_next[1:33] c_next[33:65]
+```
+
+```
+make TOOLCHAIN=amd | nvidia | gnu # gnu: host only, use NB=4
+```
+
+An ensemble of `NB` lines, each a right-going pulse of hashed amplitude
+and width into a bubbly region (`x ∈ [10, 20]`, coupling `β = 0.1`).
+Characteristic-form upwind at CFL = 1, so the transport is exact and the
+bubbles are the only physics. Exits 0 if the surrogate's pressure field
+is within 10% of the reference after 400 steps. MI210:
+
+```
+64 lines x 512 cells, 400 steps; 8 bins x 10 RK4 sub-steps per cell-step in the reference:
+ reference population 266.7 ms (20.3 ns per cell-step)
+ LSTM surrogate 255.9 ms (19.5 ns per cell-step)
+ relative L2 error of the surrogate's pressure field: 2.919e-02
+OK
+```
+
+A 32-unit LSTM costs about what 8 bins × 10 RK4 sub-steps cost on the
+GPU. Its cost is fixed; the population's grows with bins, sub-steps and
+per-bin physics.
+
+## Model
+
+`train.py`: `nn.LSTM(1, 32)` and a linear head, teacher-forced on 256
+random pressure sequences with the exact population response. `s` has
+rms 0.08, so the head is trained on `10 s` and the factor folded into its
+weights before export. The head reads the LSTM output with
+`torch.squeeze(out, 0)`; `out[0]` exports an int64 `Gather`, which the
+generator refuses.
+
+## Wiring
+
+```c
+x[0] = p;
+for (int i = 0; i < HID; ++i) { x[1 + i] = H[c * HID + i]; x[1 + HID + i] = C[c * HID + i]; }
+bubbles_infer(x, y);
+src[c] = y[0];
+for (int i = 0; i < HID; ++i) { H[c * HID + i] = y[1 + i]; C[c * HID + i] = y[1 + HID + i]; }
+```
+
+`H` and `C` are mapped once with the fields. In `bubbles.F90` the state
+arrays are `hs`/`cs` (Fortran is case-insensitive; `C` collided with the
+cell index) and the slice copies are element loops: a section assignment
+inside a target region needs a runtime call the device lacks under
+`amdflang`.
+
+## Note
+
+The explicit bubble-acoustic coupling is stable for `β` up to about 0.2
+at this `dt`; at 0.4 the reference itself diverges.
diff --git a/examples/surrogates/bubble_lstm/bubbles.F90 b/examples/surrogates/bubble_lstm/bubbles.F90
new file mode 100644
index 0000000..7f88008
--- /dev/null
+++ b/examples/surrogates/bubble_lstm/bubbles.F90
@@ -0,0 +1,181 @@
+! 1-D acoustics through a bubbly region with a recurrent per-cell surrogate;
+! the twin of bubbles.c. State arrays are hs/cs (Fortran is case-insensitive,
+! and C collided with the cell index c); the slice copies are element loops,
+! since a section assignment in a target region needs a runtime call the
+! device lacks under amdflang.
+program bubbles
+ use bubbles_model, only: bubbles_infer
+ use iso_fortran_env, only: real64
+ use omp_lib, only: omp_get_wtime
+ implicit none
+#ifndef NB
+#define NB 64
+#endif
+ integer, parameter :: nb_ = NB, nx = 512, ncell = nb_ * nx
+ real(real64), parameter :: dx = 0.05_real64, dt = 0.05_real64
+#ifndef NSTEPS
+#define NSTEPS 400
+#endif
+ integer, parameter :: nsteps = NSTEPS
+ real(real64), parameter :: beta0 = 0.1_real64 ! unstable above ~0.2
+ real(real64), parameter :: x_lo = 10.0_real64, x_hi = 20.0_real64, tol = 0.10_real64
+ integer, parameter :: nbin = 8, n_sub = 10
+ real(real64), parameter :: gam = 1.4_real64, mu = 0.05_real64
+ integer, parameter :: hid = 32, nin = 1 + 2 * hid, nout = 1 + 2 * hid
+
+ real(real64), allocatable :: wp(:), wm(:), wp2(:), wm2(:), pref(:), beta(:), src(:)
+ real(real64), allocatable :: R(:,:), V(:,:), hs(:,:), cs(:,:)
+ real(real64) :: t0, t_ref, t_nn, err
+ integer :: b, i, c, k
+
+ allocate(wp(ncell), wm(ncell), wp2(ncell), wm2(ncell), pref(ncell), beta(ncell), src(ncell))
+ allocate(R(nbin, ncell), V(nbin, ncell), hs(hid, ncell), cs(hid, ncell))
+
+ call set_pulse(wp, wm)
+ do b = 1, nb_
+ do i = 1, nx
+ c = (b - 1) * nx + i
+ beta(c) = merge(beta0, 0.0_real64, (i - 0.5_real64) * dx >= x_lo .and. (i - 0.5_real64) * dx <= x_hi)
+ do k = 1, nbin
+ R(k, c) = r0_of(k); V(k, c) = 0.0_real64
+ end do
+ end do
+ end do
+ hs = 0.0_real64; cs = 0.0_real64
+
+ !$omp target enter data map(to: wp, wm, beta, R, V) map(alloc: wp2, wm2, src)
+ t0 = omp_get_wtime()
+ call run(wp, wm, wp2, wm2, beta, R, V, hs, cs, src, .false.)
+ t_ref = omp_get_wtime() - t0
+ !$omp target exit data map(from: wp, wm) map(delete: wp2, wm2, src, beta, R, V)
+ pref = 0.5_real64 * (wp + wm)
+
+ call set_pulse(wp, wm)
+ !$omp target enter data map(to: wp, wm, beta, hs, cs) map(alloc: wp2, wm2, src)
+ t0 = omp_get_wtime()
+ call run(wp, wm, wp2, wm2, beta, R, V, hs, cs, src, .true.)
+ t_nn = omp_get_wtime() - t0
+ !$omp target exit data map(from: wp, wm) map(delete: wp2, wm2, src, beta, hs, cs)
+
+ err = sqrt(sum((0.5_real64 * (wp + wm) - pref)**2) / sum(pref**2))
+ print '(I0,A,I0,A,I0,A,I0,A,I0,A)', nb_, ' lines x ', nx, ' cells, ', nsteps, &
+ ' steps; ', nbin, ' bins x ', n_sub, ' RK4 sub-steps per cell-step in the reference:'
+ print '(A,F9.1,A,F6.1,A)', ' reference population ', 1e3 * t_ref, ' ms (', &
+ 1e9 * t_ref / (real(ncell, real64) * nsteps), ' ns per cell-step)'
+ print '(A,F9.1,A,F6.1,A)', ' LSTM surrogate ', 1e3 * t_nn, ' ms (', &
+ 1e9 * t_nn / (real(ncell, real64) * nsteps), ' ns per cell-step)'
+ print '(A,ES10.3)', ' relative L2 error of the surrogate''s pressure field: ', err
+ if (.not. (err < tol)) then
+ print '(A,F4.2)', 'FAIL: error above ', tol
+ stop 1
+ end if
+ print '(A)', 'OK'
+
+contains
+
+ pure real(real64) function r0_of(k) ! geomspace(0.5, 2, nbin), k = 1..nbin
+ !$omp declare target
+ integer, intent(in) :: k
+ r0_of = 0.5_real64 * 4.0_real64**(real(k - 1, real64) / (nbin - 1))
+ end function
+
+ pure subroutine rp_rhs(r, v, p, r0, dr, dv)
+ !$omp declare target
+ real(real64), intent(in) :: r, v, p, r0
+ real(real64), intent(out) :: dr, dv
+ dr = v
+ dv = ((r0 / r)**(3.0_real64 * gam) - 1.0_real64 - p - 4.0_real64 * mu * v / r - 1.5_real64 * v * v) / r
+ end subroutine
+
+ ! One acoustic step of one cell's bins; returns s = sum_k w_k 3 R_k^2 V_k / R0_k^3.
+ function population_step(rr, vv, p) result(s)
+ !$omp declare target
+ real(real64), intent(inout) :: rr(nbin), vv(nbin)
+ real(real64), intent(in) :: p
+ real(real64) :: s, hh, r, v, r0, k1r, k1v, k2r, k2v, k3r, k3v, k4r, k4v
+ integer :: k, sub
+ hh = dt / n_sub
+ s = 0.0_real64
+ do k = 1, nbin
+ r0 = r0_of(k); r = rr(k); v = vv(k)
+ do sub = 1, n_sub
+ call rp_rhs(r, v, p, r0, k1r, k1v)
+ call rp_rhs(r + 0.5_real64 * hh * k1r, v + 0.5_real64 * hh * k1v, p, r0, k2r, k2v)
+ call rp_rhs(r + 0.5_real64 * hh * k2r, v + 0.5_real64 * hh * k2v, p, r0, k3r, k3v)
+ call rp_rhs(r + hh * k3r, v + hh * k3v, p, r0, k4r, k4v)
+ r = r + hh / 6.0_real64 * (k1r + 2.0_real64 * k2r + 2.0_real64 * k3r + k4r)
+ v = v + hh / 6.0_real64 * (k1v + 2.0_real64 * k2v + 2.0_real64 * k3v + k4v)
+ end do
+ rr(k) = r; vv(k) = v
+ s = s + (1.0_real64 / nbin) * 3.0_real64 * r * r * v / r0**3
+ end do
+ end function
+
+ subroutine step(wp, wm, wp_new, wm_new, beta, R, V, hs, cs, src, use_nn)
+ real(real64), intent(in) :: wp(ncell), wm(ncell), beta(ncell)
+ real(real64), intent(out) :: wp_new(ncell), wm_new(ncell), src(ncell)
+ real(real64), intent(inout) :: R(nbin, ncell), V(nbin, ncell), hs(hid, ncell), cs(hid, ncell)
+ logical, intent(in) :: use_nn
+ real(real64) :: p, x(nin), y(nout)
+ integer :: c, b, i, l, r_, j
+ !$omp target teams distribute parallel do private(p, x, y, j)
+ do c = 1, ncell
+ p = 0.5_real64 * (wp(c) + wm(c))
+ if (use_nn) then
+ x(1) = p
+ do j = 1, hid
+ x(1 + j) = hs(j, c); x(1 + hid + j) = cs(j, c)
+ end do
+ call bubbles_infer(x, y)
+ src(c) = y(1)
+ do j = 1, hid
+ hs(j, c) = y(1 + j); cs(j, c) = y(1 + hid + j)
+ end do
+ else
+ src(c) = population_step(R(:, c), V(:, c), p)
+ end if
+ end do
+ !$omp target teams distribute parallel do collapse(2) private(c, l, r_)
+ do b = 1, nb_
+ do i = 1, nx
+ c = (b - 1) * nx + i
+ l = (b - 1) * nx + max(i - 1, 1); r_ = (b - 1) * nx + min(i + 1, nx)
+ wp_new(c) = wp(l) - dt * beta(c) * src(c)
+ wm_new(c) = wm(r_) - dt * beta(c) * src(c)
+ end do
+ end do
+ end subroutine
+
+ subroutine run(wp, wm, wp2, wm2, beta, R, V, hs, cs, src, use_nn)
+ real(real64), intent(inout) :: wp(ncell), wm(ncell), wp2(ncell), wm2(ncell), src(ncell)
+ real(real64), intent(in) :: beta(ncell)
+ real(real64), intent(inout) :: R(nbin, ncell), V(nbin, ncell), hs(hid, ncell), cs(hid, ncell)
+ logical, intent(in) :: use_nn
+ integer :: s
+ do s = 1, nsteps / 2
+ call step(wp, wm, wp2, wm2, beta, R, V, hs, cs, src, use_nn)
+ call step(wp2, wm2, wp, wm, beta, R, V, hs, cs, src, use_nn)
+ end do
+ end subroutine
+
+ pure real(real64) function hash01(a, b)
+ integer, intent(in) :: a, b
+ hash01 = real(mod((int(a, 8) * 40503_8 + int(b, 8)) * 2654435761_8, 4294967296_8), real64) &
+ / 4294967296.0_real64
+ end function
+
+ ! A right-going pulse per line, hashed amplitude and width.
+ subroutine set_pulse(wp, wm)
+ real(real64), intent(out) :: wp(ncell), wm(ncell)
+ real(real64) :: amp, sig, x
+ integer :: b, i
+ do b = 1, nb_
+ amp = 0.1_real64 + 0.25_real64 * hash01(b, 1); sig = 0.5_real64 + 1.0_real64 * hash01(b, 2)
+ do i = 1, nx
+ x = (i - 0.5_real64) * dx
+ wp((b - 1) * nx + i) = 2.0_real64 * amp * exp(-0.5_real64 * ((x - 4.0_real64) / sig)**2)
+ wm((b - 1) * nx + i) = 0.0_real64
+ end do
+ end do
+ end subroutine
+end program
diff --git a/examples/surrogates/bubble_lstm/bubbles.c b/examples/surrogates/bubble_lstm/bubbles.c
new file mode 100644
index 0000000..e925dfd
--- /dev/null
+++ b/examples/surrogates/bubble_lstm/bubbles.c
@@ -0,0 +1,162 @@
+/* 1-D acoustics through a bubbly region with a recurrent per-cell surrogate.
+ Each cell owns an LSTM state (h, c) kept on the device; per step the model
+ gets x = [p, h, c] and returns y = [s, h', c'] (layout from `rosenna info`:
+ x: p[0:1] h[1:33] c[33:65], y: s[0:1] h_next[1:33] c_next[33:65]), and s,
+ the bubble population's volume-fraction rate, is the acoustic source.
+ Reference: NBIN Rayleigh-Plesset bins per cell, RK4 with N_SUB sub-steps.
+ Characteristic upwind at CFL = 1 (exact transport). Embedded model.
+ Ensemble of NB lines with hashed pulses. Exits 0 if the surrogate's
+ pressure field is within TOL of the reference after NSTEPS steps. */
+#include
+#include
+#include
+#include
+#include "bubbles.h"
+
+#define NXC 512
+#define NCELL (NB * NXC)
+#define DX 0.05
+#define DT 0.05
+#ifndef NSTEPS
+#define NSTEPS 400
+#endif
+#define BETA 0.1 /* coupling in the bubbly region; unstable above ~0.2 */
+#define X_LO 10.0
+#define X_HI 20.0
+#define TOL 0.10
+#define NBIN 8
+#define GAMMA 1.4
+#define MU 0.05
+#define N_SUB 10
+#define HID 32
+#define NIN (1 + 2 * HID)
+#define NOUT (1 + 2 * HID)
+
+static double r0_of(int k) { return 0.5 * pow(4.0, (double)k / (NBIN - 1)); } /* geomspace(0.5, 2) */
+
+static void rp_rhs(double R, double V, double p, double r0, double *dR, double *dV) {
+ *dR = V;
+ *dV = (pow(r0 / R, 3.0 * GAMMA) - 1.0 - p - 4.0 * MU * V / R - 1.5 * V * V) / R;
+}
+
+/* One acoustic step of one cell's bins; returns s = sum_k w_k 3 R_k^2 V_k / R0_k^3. */
+static double population_step(double *R, double *V, double p) {
+ const double h = DT / N_SUB;
+ double s = 0.0;
+ for (int k = 0; k < NBIN; ++k) {
+ const double r0 = r0_of(k);
+ double r = R[k], v = V[k];
+ for (int sub = 0; sub < N_SUB; ++sub) {
+ double k1r, k1v, k2r, k2v, k3r, k3v, k4r, k4v;
+ rp_rhs(r, v, p, r0, &k1r, &k1v);
+ rp_rhs(r + 0.5 * h * k1r, v + 0.5 * h * k1v, p, r0, &k2r, &k2v);
+ rp_rhs(r + 0.5 * h * k2r, v + 0.5 * h * k2v, p, r0, &k3r, &k3v);
+ rp_rhs(r + h * k3r, v + h * k3v, p, r0, &k4r, &k4v);
+ r += h / 6.0 * (k1r + 2.0 * k2r + 2.0 * k3r + k4r);
+ v += h / 6.0 * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
+ }
+ R[k] = r; V[k] = v;
+ s += (1.0 / NBIN) * 3.0 * r * r * v / (r0 * r0 * r0);
+ }
+ return s;
+}
+
+static void step(const double *wp, const double *wm, double *wp_new, double *wm_new, const double *beta,
+ double *R, double *V, double *H, double *C, double *src, int use_nn) {
+#pragma omp target teams distribute parallel for
+ for (int c = 0; c < NCELL; ++c) {
+ const double p = 0.5 * (wp[c] + wm[c]);
+ if (use_nn) {
+ double x[NIN], y[NOUT];
+ x[0] = p;
+ for (int i = 0; i < HID; ++i) { x[1 + i] = H[(size_t)c * HID + i]; x[1 + HID + i] = C[(size_t)c * HID + i]; }
+ bubbles_infer(x, y);
+ src[c] = y[0];
+ for (int i = 0; i < HID; ++i) { H[(size_t)c * HID + i] = y[1 + i]; C[(size_t)c * HID + i] = y[1 + HID + i]; }
+ } else {
+ src[c] = population_step(R + (size_t)c * NBIN, V + (size_t)c * NBIN, p);
+ }
+ }
+#pragma omp target teams distribute parallel for collapse(2)
+ for (int b = 0; b < NB; ++b)
+ for (int i = 0; i < NXC; ++i) {
+ const int c = b * NXC + i;
+ const int l = b * NXC + (i > 0 ? i - 1 : 0), r = b * NXC + (i < NXC - 1 ? i + 1 : NXC - 1);
+ wp_new[c] = wp[l] - DT * beta[c] * src[c];
+ wm_new[c] = wm[r] - DT * beta[c] * src[c];
+ }
+}
+
+static void run(double *wp, double *wm, double *wp2, double *wm2, const double *beta,
+ double *R, double *V, double *H, double *C, double *src, int use_nn) {
+ for (int s = 0; s < NSTEPS / 2; ++s) {
+ step(wp, wm, wp2, wm2, beta, R, V, H, C, src, use_nn);
+ step(wp2, wm2, wp, wm, beta, R, V, H, C, src, use_nn);
+ }
+}
+
+static double hash01(long long a, long long b) {
+ return (double)(((a * 40503LL + b) * 2654435761LL) % 4294967296LL) / 4294967296.0;
+}
+
+/* A right-going pulse per line, hashed amplitude and width. */
+static void set_pulse(double *wp, double *wm) {
+ for (int b = 0; b < NB; ++b) {
+ const double amp = 0.1 + 0.25 * hash01(b + 1, 1), sig = 0.5 + 1.0 * hash01(b + 1, 2);
+ for (int i = 0; i < NXC; ++i) {
+ const double x = (i + 0.5) * DX;
+ wp[b * NXC + i] = 2.0 * amp * exp(-0.5 * ((x - 4.0) / sig) * ((x - 4.0) / sig));
+ wm[b * NXC + i] = 0.0;
+ }
+ }
+}
+
+int main(void) {
+ double *wp = malloc(sizeof(double) * NCELL), *wm = malloc(sizeof(double) * NCELL);
+ double *wp2 = malloc(sizeof(double) * NCELL), *wm2 = malloc(sizeof(double) * NCELL);
+ double *pref = malloc(sizeof(double) * NCELL), *beta = malloc(sizeof(double) * NCELL);
+ double *src = malloc(sizeof(double) * NCELL);
+ double *R = malloc(sizeof(double) * NCELL * NBIN), *V = malloc(sizeof(double) * NCELL * NBIN);
+ double *H = calloc((size_t)NCELL * HID, sizeof(double)), *C = calloc((size_t)NCELL * HID, sizeof(double));
+ const size_t nst = (size_t)NCELL * NBIN, nh = (size_t)NCELL * HID;
+ for (int c = 0; c < NCELL; ++c) {
+ const double x = (c % NXC + 0.5) * DX;
+ beta[c] = (x >= X_LO && x <= X_HI) ? BETA : 0.0;
+ for (int k = 0; k < NBIN; ++k) { R[(size_t)c * NBIN + k] = r0_of(k); V[(size_t)c * NBIN + k] = 0.0; }
+ }
+
+ set_pulse(wp, wm);
+#pragma omp target enter data map(to: wp[0:NCELL], wm[0:NCELL], beta[0:NCELL], R[0:nst], V[0:nst]) \
+ map(alloc: wp2[0:NCELL], wm2[0:NCELL], src[0:NCELL])
+ double t0 = omp_get_wtime();
+ run(wp, wm, wp2, wm2, beta, R, V, H, C, src, 0);
+ const double t_ref = omp_get_wtime() - t0;
+#pragma omp target exit data map(from: wp[0:NCELL], wm[0:NCELL]) \
+ map(delete: wp2[0:NCELL], wm2[0:NCELL], src[0:NCELL], beta[0:NCELL], R[0:nst], V[0:nst])
+ for (int c = 0; c < NCELL; ++c) pref[c] = 0.5 * (wp[c] + wm[c]);
+
+ set_pulse(wp, wm);
+#pragma omp target enter data map(to: wp[0:NCELL], wm[0:NCELL], beta[0:NCELL], H[0:nh], C[0:nh]) \
+ map(alloc: wp2[0:NCELL], wm2[0:NCELL], src[0:NCELL])
+ t0 = omp_get_wtime();
+ run(wp, wm, wp2, wm2, beta, R, V, H, C, src, 1);
+ const double t_nn = omp_get_wtime() - t0;
+#pragma omp target exit data map(from: wp[0:NCELL], wm[0:NCELL]) \
+ map(delete: wp2[0:NCELL], wm2[0:NCELL], src[0:NCELL], beta[0:NCELL], H[0:nh], C[0:nh])
+
+ double e = 0.0, r = 0.0;
+ for (int c = 0; c < NCELL; ++c) {
+ const double p = 0.5 * (wp[c] + wm[c]);
+ e += (p - pref[c]) * (p - pref[c]); r += pref[c] * pref[c];
+ }
+ const double err = sqrt(e / r);
+ printf("%d lines x %d cells, %d steps; %d bins x %d RK4 sub-steps per cell-step in the reference:\n",
+ NB, NXC, NSTEPS, NBIN, N_SUB);
+ printf(" reference population %7.1f ms (%.1f ns per cell-step)\n", 1e3 * t_ref, 1e9 * t_ref / ((double)NCELL * NSTEPS));
+ printf(" LSTM surrogate %7.1f ms (%.1f ns per cell-step)\n", 1e3 * t_nn, 1e9 * t_nn / ((double)NCELL * NSTEPS));
+ printf(" relative L2 error of the surrogate's pressure field: %.3e\n", err);
+ free(wp); free(wm); free(wp2); free(wm2); free(pref); free(beta); free(src); free(R); free(V); free(H); free(C);
+ if (!(err < TOL)) { printf("FAIL: error above %.2f\n", TOL); return 1; }
+ printf("OK\n");
+ return 0;
+}
diff --git a/examples/surrogates/bubble_lstm/bubbles.onnx b/examples/surrogates/bubble_lstm/bubbles.onnx
new file mode 100644
index 0000000..ca20f25
Binary files /dev/null and b/examples/surrogates/bubble_lstm/bubbles.onnx differ
diff --git a/examples/surrogates/bubble_lstm/train.py b/examples/surrogates/bubble_lstm/train.py
new file mode 100644
index 0000000..eba6cf4
--- /dev/null
+++ b/examples/surrogates/bubble_lstm/train.py
@@ -0,0 +1,131 @@
+"""Train a recurrent surrogate for a polydisperse bubble population; writes bubbles.onnx.
+
+Per cell of the acoustic solver, NBIN Rayleigh-Plesset bins driven by the
+cell's pressure p'(t),
+
+ R R'' + 3/2 R'^2 = p_g(R) - 1 - p' - 4 mu R'/R, p_g = (R0/R)^(3 gamma)
+
+(rho = c = p_ambient = 1), RK4 in N_SUB sub-steps per acoustic step DT. The
+acoustics need s(t) = d/dt sum_k w_k (R_k/R0_k)^3 back, the source in
+p_t = -(u_x + beta s). The surrogate is an LSTM cell per grid cell: input
+p', state (h, c) carried by the solver, output s. Exported with the state
+as graph inputs and outputs; the generator concatenates them in x and y.
+
+Teacher-forced on random pressure sequences (pulses and weak tones), the
+state flowing through whole sequences, as it is used. The head is trained
+on s / S_SCALE and the factor folded into its weights before export.
+"""
+import numpy as np
+import torch
+import torch.nn as nn
+
+NBIN = 8
+R0 = np.geomspace(0.5, 2.0, NBIN) # bin radii
+W = np.ones(NBIN) / NBIN # bin weights (volume fraction shares)
+GAMMA, MU = 1.4, 0.05
+DT, N_SUB = 0.05, 10
+HIDDEN = 32
+S_SCALE = 10.0 # s has rms ~0.08
+N_SEQ, T_SEQ = 256, 400
+EPOCHS = 1500
+SEED = 5
+
+
+def rp_rhs(R, V, p, r0):
+ """Rayleigh-Plesset right-hand side for radius R, velocity V, forcing p, rest radius r0."""
+ pg = (r0 / R) ** (3 * GAMMA)
+ return V, (pg - 1.0 - p - 4.0 * MU * V / R - 1.5 * V * V) / R
+
+
+def rk4_step(R, V, p, r0, h):
+ k1r, k1v = rp_rhs(R, V, p, r0)
+ k2r, k2v = rp_rhs(R + 0.5 * h * k1r, V + 0.5 * h * k1v, p, r0)
+ k3r, k3v = rp_rhs(R + 0.5 * h * k2r, V + 0.5 * h * k2v, p, r0)
+ k4r, k4v = rp_rhs(R + h * k3r, V + h * k3v, p, r0)
+ return (R + h / 6 * (k1r + 2 * k2r + 2 * k3r + k4r),
+ V + h / 6 * (k1v + 2 * k2v + 2 * k3v + k4v))
+
+
+def population_source(R, V, r0):
+ """s = d/dt sum_k w_k (R_k/R0_k)^3 = sum_k w_k 3 R_k^2 V_k / R0_k^3."""
+ return (W * 3.0 * R * R * V / r0 ** 3).sum(axis=-1)
+
+
+def pressure_signals(rng, n, t):
+ """A few pulses of random width and sign plus a weak tone, |p'| below about 0.5."""
+ p = np.zeros((n, len(t)))
+ for i in range(n):
+ for _ in range(rng.integers(1, 4)):
+ t0, sig, a = rng.uniform(2, 18), rng.uniform(0.3, 1.5), rng.uniform(-0.35, 0.35)
+ p[i] += a * np.exp(-0.5 * ((t - t0) / sig) ** 2)
+ p[i] += rng.uniform(0, 0.08) * np.sin(rng.uniform(0.3, 1.5) * t + rng.uniform(0, 6.28))
+ return p
+
+
+def make_sequences(rng):
+ """(N_SEQ, T_SEQ) pressures and the exact population source at every acoustic step."""
+ t = np.arange(T_SEQ) * DT
+ p = pressure_signals(rng, N_SEQ, t)
+ R = np.tile(R0, (N_SEQ, 1)); V = np.zeros((N_SEQ, NBIN)); r0 = np.tile(R0, (N_SEQ, 1))
+ s = np.zeros((N_SEQ, T_SEQ))
+ for n in range(T_SEQ):
+ for _ in range(N_SUB):
+ R, V = rk4_step(R, V, p[:, n:n + 1], r0, DT / N_SUB)
+ s[:, n] = population_source(R, V, r0)
+ return p, s
+
+
+class Bubbles(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.lstm = nn.LSTM(1, HIDDEN)
+ self.head = nn.Linear(HIDDEN, 1)
+
+ def forward(self, p, h, c):
+ out, (hn, cn) = self.lstm(p, (h, c))
+ # squeeze, not out[0]: indexing exports an int64 Gather
+ return self.head(torch.squeeze(out, 0)), hn, cn
+
+
+def main():
+ rng = np.random.default_rng(SEED)
+ torch.manual_seed(SEED)
+ p, s = make_sequences(rng)
+ print(f"source rms {np.sqrt((s ** 2).mean()):.3f}, max |p'| {np.abs(p).max():.2f}")
+ P = torch.tensor(p.T[:, :, None], dtype=torch.float32) # (T, N, 1)
+ S = torch.tensor(s.T[:, :, None] * S_SCALE, dtype=torch.float32)
+ model = Bubbles()
+ opt = torch.optim.Adam(model.parameters(), lr=3e-3)
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, EPOCHS)
+ for ep in range(EPOCHS):
+ out, _ = model.lstm(P)
+ pred = model.head(out)
+ loss = ((pred - S) ** 2).mean()
+ opt.zero_grad()
+ loss.backward()
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+ opt.step()
+ sched.step()
+ if ep % 250 == 0:
+ print(f"epoch {ep:4d} relative rms error {np.sqrt(loss.item()) / np.sqrt((S ** 2).mean().item()):.3f}")
+ with torch.no_grad():
+ model.head.weight /= S_SCALE
+ model.head.bias /= S_SCALE
+ model.eval()
+ with torch.no_grad(): # held-out, state fed back step by step
+ p2, s2 = make_sequences(np.random.default_rng(SEED + 1))
+ h = torch.zeros(1, N_SEQ, HIDDEN); c = torch.zeros(1, N_SEQ, HIDDEN)
+ pred = np.zeros_like(s2)
+ for n in range(T_SEQ):
+ y, h, c = model(torch.tensor(p2[:, n], dtype=torch.float32)[None, :, None], h, c)
+ pred[:, n] = y[:, 0].numpy()
+ err = np.sqrt(((pred - s2) ** 2).sum() / (s2 ** 2).sum())
+ print(f"held-out sequences, state fed back step by step: relative L2 error {err:.3e}")
+ torch.onnx.export(model, (torch.zeros(1, 1, 1), torch.zeros(1, 1, HIDDEN), torch.zeros(1, 1, HIDDEN)),
+ "bubbles.onnx", input_names=["p", "h", "c"], output_names=["s", "h_next", "c_next"],
+ opset_version=13, dynamo=False)
+ print("wrote bubbles.onnx")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/surrogates/burgers_closure/Makefile b/examples/surrogates/burgers_closure/Makefile
new file mode 100644
index 0000000..10558df
--- /dev/null
+++ b/examples/surrogates/burgers_closure/Makefile
@@ -0,0 +1,7 @@
+MODEL := closure
+PROG := burgers
+# Overridable so a slow runner can shrink the step count; the READMEs
+# quote the default.
+NSTEPS ?= 200
+DEFS := -DNSTEPS=$(NSTEPS)
+include ../common.mk
diff --git a/examples/surrogates/burgers_closure/README.md b/examples/surrogates/burgers_closure/README.md
new file mode 100644
index 0000000..9d0f4cb
--- /dev/null
+++ b/examples/surrogates/burgers_closure/README.md
@@ -0,0 +1,44 @@
+# Coarse-grid Burgers with a learned subgrid closure
+
+Hyperbolic PDE; the network is a per-cell closure called from the solver's
+own offload loop. Embedded model, header-inline `closure_infer`, no `init`,
+nothing to link. The solver's arrays are mapped once; nothing moves inside
+the time loop.
+
+```
+make TOOLCHAIN=amd | nvidia | gnu # gnu: host only, use NB=4
+```
+
+An ensemble of `NB` periodic Burgers realizations (three random sine
+modes each). For each: the fine reference (2048 cells, 64 sub-steps per
+coarse step) box-filtered to the coarse grid; the coarse scheme alone
+(128 cells, Godunov flux, central viscous term, forward Euler); the coarse
+scheme plus `closure_infer(stencil, corr)` on every cell. Exits 0 if the
+closure reduces the mean error after 200 steps. MI210, both languages:
+
+```
+64 realizations, 128 coarse cells, 200 steps; mean relative L2 error vs filtered fine:
+ coarse 4.3532e-02 (47.7 ms)
+ coarse + closure 3.1346e-02 (299.6 ms, 153.7 ns per cell-step for the closure)
+ fine, 2048 cells (3234.7 ms)
+OK: error reduced 1.4x
+```
+
+## Model
+
+`train.py`: 7-point stencil → 64 → 64 → 1, `tanh`. Trained with the coarse
+solver in the loop (a 200-step rollout in torch, relative trajectory
+error). An a-priori fit to the residual on filtered-fine states made the
+coarse run worse, since at run time the closure sees its own drifting
+state. `closure.onnx` is checked in (training takes a few minutes);
+`make train` rebuilds it.
+
+## Notes
+
+- One 128-cell realization cannot occupy a GPU and a kernel that small is
+ latency-bound; the ensemble is what makes the timing mean anything. Even
+ so the closure's ~150 ns per cell-step here is mostly latency; `rosenna
+ gpu-gate` measures ~2 ns per point over a million points.
+- `burgers.F90` passes arrays into target regions as explicit-shape
+ dummies. `amdflang` re-maps an allocatable's descriptor on every region
+ entry, two small copies per array per step.
diff --git a/examples/surrogates/burgers_closure/burgers.F90 b/examples/surrogates/burgers_closure/burgers.F90
new file mode 100644
index 0000000..9e0932d
--- /dev/null
+++ b/examples/surrogates/burgers_closure/burgers.F90
@@ -0,0 +1,140 @@
+! Coarse-grid Burgers with a learned subgrid closure; the twin of burgers.c.
+! Arrays inside target regions are explicit-shape dummies: amdflang re-maps
+! an allocatable's descriptor on every region entry.
+program burgers
+ use closure_model, only: closure_infer
+ use iso_fortran_env, only: real64
+ use omp_lib, only: omp_get_wtime
+ implicit none
+ integer, parameter :: nb_ = NB, nf = 2048, factor = 16, nc = nf / factor
+ real(real64), parameter :: nu = 0.02_real64, dt = 0.01_real64
+#ifndef NSTEPS
+#define NSTEPS 200
+#endif
+ integer, parameter :: n_sub = 64, nsteps = NSTEPS
+ real(real64), parameter :: pi = 3.14159265358979323846_real64, L = 2.0_real64 * pi
+
+ real(real64), allocatable :: uf(:,:), tf(:,:), ref(:,:), uc(:,:), tc(:,:), un(:,:), tn(:,:)
+ real(real64) :: dxf, dxc, t0, t_ref, t_coarse, t_nn, e_coarse, e_nn, amp(3), ph(3)
+ integer :: b, i, k
+
+ allocate(uf(nf, nb_), tf(nf, nb_), ref(nc, nb_), uc(nc, nb_), tc(nc, nb_), un(nc, nb_), tn(nc, nb_))
+ dxf = L / nf; dxc = L / nc
+ do b = 1, nb_ ! three sine modes, hashed amplitude and phase
+ do k = 1, 3
+ amp(k) = (2.0_real64 * hash01(b, 2 * k - 1) - 1.0_real64) / k
+ ph(k) = 2.0_real64 * pi * hash01(b, 2 * k)
+ end do
+ do i = 1, nf
+ uf(i, b) = sum([(amp(k) * sin(k * (i - 1) * dxf + ph(k)), k = 1, 3)])
+ end do
+ end do
+ call box_filter(uf, uc)
+ un = uc
+
+ t0 = omp_get_wtime()
+ call run(uf, tf, nf, dxf, dt / n_sub, nsteps * n_sub, .false.)
+ t_ref = omp_get_wtime() - t0
+ call box_filter(uf, ref)
+ t0 = omp_get_wtime()
+ call run(uc, tc, nc, dxc, dt, nsteps, .false.)
+ t_coarse = omp_get_wtime() - t0
+ t0 = omp_get_wtime()
+ call run(un, tn, nc, dxc, dt, nsteps, .true.)
+ t_nn = omp_get_wtime() - t0
+
+ e_coarse = mean_rel_l2(uc, ref); e_nn = mean_rel_l2(un, ref)
+ print '(I0,A,I0,A,I0,A)', nb_, ' realizations, ', nc, ' coarse cells, ', nsteps, &
+ ' steps; mean relative L2 error vs filtered fine:'
+ print '(A,ES11.4,A,F8.1,A)', ' coarse ', e_coarse, ' (', 1e3 * t_coarse, ' ms)'
+ print '(A,ES11.4,A,F8.1,A,F6.1,A)', ' coarse + closure ', e_nn, ' (', 1e3 * t_nn, ' ms, ', &
+ 1e9 * (t_nn - t_coarse) / (real(nb_, real64) * nc * nsteps), ' ns per cell-step for the closure)'
+ print '(A,I0,A,F8.1,A)', ' fine, ', nf, ' cells (', 1e3 * t_ref, ' ms)'
+ if (.not. (e_nn < e_coarse)) then
+ print '(A)', 'FAIL: closure did not help'
+ stop 1
+ end if
+ print '(A,F4.1,A)', 'OK: error reduced ', e_coarse / e_nn, 'x'
+
+contains
+
+ pure function godunov(ul, ur) result(f)
+ !$omp declare target
+ real(real64), intent(in) :: ul, ur
+ real(real64) :: f, a, b
+ a = 0.5_real64 * ul * ul; b = 0.5_real64 * ur * ur
+ if (ul <= ur) then
+ f = merge(0.0_real64, min(a, b), ul <= 0.0_real64 .and. ur >= 0.0_real64)
+ else
+ f = max(a, b)
+ end if
+ end function
+
+ ! Forward Euler, Godunov flux, central viscous term; NN(stencil) added when use_nn.
+ subroutine step(u, unew, n, dx, dt, use_nn)
+ integer, intent(in) :: n
+ real(real64), intent(in) :: u(n, nb_), dx, dt
+ real(real64), intent(out) :: unew(n, nb_)
+ logical, intent(in) :: use_nn
+ real(real64) :: rhs, stencil(7), corr(1)
+ integer :: b, i, im3, im2, im1, ip1, ip2, ip3
+ !$omp target teams distribute parallel do collapse(2) private(rhs, stencil, corr, im3, im2, im1, ip1, ip2, ip3)
+ do b = 1, nb_
+ do i = 1, n
+ im3 = modulo(i - 4, n) + 1; im2 = modulo(i - 3, n) + 1; im1 = modulo(i - 2, n) + 1
+ ip1 = modulo(i, n) + 1; ip2 = modulo(i + 1, n) + 1; ip3 = modulo(i + 2, n) + 1
+ rhs = -(godunov(u(i, b), u(ip1, b)) - godunov(u(im1, b), u(i, b))) / dx &
+ + nu * (u(ip1, b) - 2.0_real64 * u(i, b) + u(im1, b)) / (dx * dx)
+ if (use_nn) then
+ stencil = [u(im3, b), u(im2, b), u(im1, b), u(i, b), u(ip1, b), u(ip2, b), u(ip3, b)]
+ call closure_infer(stencil, corr)
+ rhs = rhs + corr(1)
+ end if
+ unew(i, b) = u(i, b) + dt * rhs
+ end do
+ end do
+ end subroutine
+
+ ! Map once, step nsteps times alternating the two device buffers, result in u.
+ subroutine run(u, tmp, n, dx, dt, nsteps_, use_nn)
+ integer, intent(in) :: n, nsteps_
+ real(real64), intent(inout) :: u(n, nb_), tmp(n, nb_)
+ real(real64), intent(in) :: dx, dt
+ logical, intent(in) :: use_nn
+ integer :: s
+ !$omp target enter data map(to: u) map(alloc: tmp)
+ do s = 1, nsteps_ / 2
+ call step(u, tmp, n, dx, dt, use_nn)
+ call step(tmp, u, n, dx, dt, use_nn)
+ end do
+ !$omp target exit data map(from: u) map(delete: tmp)
+ end subroutine
+
+ subroutine box_filter(fine, coarse)
+ real(real64), intent(in) :: fine(nf, nb_)
+ real(real64), intent(out) :: coarse(nc, nb_)
+ integer :: b, i
+ do b = 1, nb_
+ do i = 1, nc
+ coarse(i, b) = sum(fine((i - 1) * factor + 1:i * factor, b)) / factor
+ end do
+ end do
+ end subroutine
+
+ function mean_rel_l2(a, r) result(m)
+ real(real64), intent(in) :: a(nc, nb_), r(nc, nb_)
+ real(real64) :: m
+ integer :: b
+ m = 0.0_real64
+ do b = 1, nb_
+ m = m + sqrt(sum((a(:, b) - r(:, b))**2) / sum(r(:, b)**2))
+ end do
+ m = m / nb_
+ end function
+
+ pure real(real64) function hash01(a, b) ! the hash burgers.c uses
+ integer, intent(in) :: a, b
+ hash01 = real(mod((int(a, 8) * 40503_8 + int(b, 8)) * 2654435761_8, 4294967296_8), real64) &
+ / 4294967296.0_real64
+ end function
+end program
diff --git a/examples/surrogates/burgers_closure/burgers.c b/examples/surrogates/burgers_closure/burgers.c
new file mode 100644
index 0000000..4d9dcf6
--- /dev/null
+++ b/examples/surrogates/burgers_closure/burgers.c
@@ -0,0 +1,131 @@
+/* Coarse-grid Burgers with a learned subgrid closure: closure_infer is called
+ per cell from the solver's own offload loop. Embedded model, no init.
+ An ensemble of NB realizations, u[NB][n]; fine reference vs coarse vs
+ coarse + closure. Exits 0 if the closure reduces the coarse error. */
+#include
+#include
+#include
+#include
+#include "closure.h"
+
+#define NF 2048
+#define FACTOR 16
+#define NC (NF / FACTOR)
+#define NU 0.02
+#define DT 0.01
+#define N_SUB 64 /* fine sub-steps per coarse step */
+#ifndef NSTEPS
+#define NSTEPS 200
+#endif
+static const double L = 2.0 * M_PI;
+
+static double godunov(double ul, double ur) {
+ const double a = 0.5 * ul * ul, b = 0.5 * ur * ur;
+ return (ul <= ur) ? ((ul <= 0.0 && ur >= 0.0) ? 0.0 : fmin(a, b)) : fmax(a, b);
+}
+
+/* Forward Euler, Godunov flux, central viscous term; NN(stencil) added when use_nn. */
+static void step(const double *u, double *unew, int n, double dx, double dt, int use_nn) {
+#pragma omp target teams distribute parallel for collapse(2)
+ for (int b = 0; b < NB; ++b)
+ for (int i = 0; i < n; ++i) {
+ const double *r = u + (size_t)b * n;
+ const int im3 = (i - 3 + n) % n, im2 = (i - 2 + n) % n, im1 = (i - 1 + n) % n;
+ const int ip1 = (i + 1) % n, ip2 = (i + 2) % n, ip3 = (i + 3) % n;
+ double rhs = -(godunov(r[i], r[ip1]) - godunov(r[im1], r[i])) / dx
+ + NU * (r[ip1] - 2.0 * r[i] + r[im1]) / (dx * dx);
+ if (use_nn) {
+ const double stencil[7] = {r[im3], r[im2], r[im1], r[i], r[ip1], r[ip2], r[ip3]};
+ double corr[1];
+ closure_infer(stencil, corr);
+ rhs += corr[0];
+ }
+ unew[(size_t)b * n + i] = r[i] + dt * rhs;
+ }
+}
+
+/* Map once, step nsteps times swapping device buffers, result back in u. */
+static void run(double *u, double *tmp, int n, double dx, double dt, int nsteps, int use_nn) {
+ const size_t m = (size_t)NB * n;
+ double *cur = u, *nxt = tmp;
+#pragma omp target enter data map(to: cur[0:m]) map(alloc: nxt[0:m])
+ for (int s = 0; s < nsteps; ++s) {
+ step(cur, nxt, n, dx, dt, use_nn);
+ double *t = cur; cur = nxt; nxt = t;
+ }
+#pragma omp target exit data map(from: cur[0:m]) map(delete: nxt[0:m])
+ if (cur != u)
+ for (size_t i = 0; i < m; ++i) u[i] = cur[i];
+}
+
+static void box_filter(const double *fine, double *coarse) {
+ for (int b = 0; b < NB; ++b)
+ for (int i = 0; i < NC; ++i) {
+ double s = 0.0;
+ for (int k = 0; k < FACTOR; ++k) s += fine[(size_t)b * NF + i * FACTOR + k];
+ coarse[(size_t)b * NC + i] = s / FACTOR;
+ }
+}
+
+static double mean_rel_l2(const double *a, const double *ref) {
+ double total = 0.0;
+ for (int b = 0; b < NB; ++b) {
+ double e = 0.0, r = 0.0;
+ for (int i = 0; i < NC; ++i) {
+ const double d = a[(size_t)b * NC + i] - ref[(size_t)b * NC + i];
+ e += d * d; r += ref[(size_t)b * NC + i] * ref[(size_t)b * NC + i];
+ }
+ total += sqrt(e / r);
+ }
+ return total / NB;
+}
+
+/* Integer hash in [0, 1); every intermediate fits int64, so burgers.F90 reproduces it. */
+static double hash01(long long a, long long b) {
+ return (double)(((a * 40503LL + b) * 2654435761LL) % 4294967296LL) / 4294967296.0;
+}
+
+int main(void) {
+ double *uf = malloc(sizeof(double) * NB * NF), *tf = malloc(sizeof(double) * NB * NF);
+ double *ref = malloc(sizeof(double) * NB * NC), *uc = malloc(sizeof(double) * NB * NC);
+ double *tc = malloc(sizeof(double) * NB * NC), *un = malloc(sizeof(double) * NB * NC);
+ double *tn = malloc(sizeof(double) * NB * NC);
+ const double dxf = L / NF, dxc = L / NC;
+ for (int b = 0; b < NB; ++b) { /* three sine modes, hashed amplitude and phase */
+ double amp[3], ph[3];
+ for (int k = 0; k < 3; ++k) {
+ amp[k] = (2.0 * hash01(b + 1, 2 * k + 1) - 1.0) / (k + 1);
+ ph[k] = 2.0 * M_PI * hash01(b + 1, 2 * k + 2);
+ }
+ for (int i = 0; i < NF; ++i) {
+ double v = 0.0;
+ for (int k = 0; k < 3; ++k) v += amp[k] * sin((k + 1) * i * dxf + ph[k]);
+ uf[(size_t)b * NF + i] = v;
+ }
+ }
+ box_filter(uf, uc);
+ for (size_t i = 0; i < (size_t)NB * NC; ++i) un[i] = uc[i];
+
+ double t0 = omp_get_wtime();
+ run(uf, tf, NF, dxf, DT / N_SUB, NSTEPS * N_SUB, 0);
+ const double t_ref = omp_get_wtime() - t0;
+ box_filter(uf, ref);
+ t0 = omp_get_wtime();
+ run(uc, tc, NC, dxc, DT, NSTEPS, 0);
+ const double t_coarse = omp_get_wtime() - t0;
+ t0 = omp_get_wtime();
+ run(un, tn, NC, dxc, DT, NSTEPS, 1);
+ const double t_nn = omp_get_wtime() - t0;
+
+ const double e_coarse = mean_rel_l2(uc, ref), e_nn = mean_rel_l2(un, ref);
+ printf("%d realizations, %d coarse cells, %d steps; mean relative L2 error vs filtered fine:\n",
+ NB, NC, NSTEPS);
+ printf(" coarse %.4e (%.1f ms)\n", e_coarse, 1e3 * t_coarse);
+ printf(" coarse + closure %.4e (%.1f ms, %.1f ns per cell-step for the closure)\n",
+ e_nn, 1e3 * t_nn, 1e9 * (t_nn - t_coarse) / ((double)NB * NC * NSTEPS));
+ printf(" fine, %d cells (%.1f ms)\n", NF, 1e3 * t_ref);
+ free(uf); free(tf); free(ref); free(uc); free(tc); free(un); free(tn);
+ if (!(e_nn < e_coarse)) { printf("FAIL: closure did not help\n"); return 1; }
+ printf("OK: error reduced %.1fx\n", e_coarse / e_nn);
+ return 0;
+}
diff --git a/examples/surrogates/burgers_closure/closure.onnx b/examples/surrogates/burgers_closure/closure.onnx
new file mode 100644
index 0000000..c5ce74e
Binary files /dev/null and b/examples/surrogates/burgers_closure/closure.onnx differ
diff --git a/examples/surrogates/burgers_closure/train.py b/examples/surrogates/burgers_closure/train.py
new file mode 100644
index 0000000..92943a2
--- /dev/null
+++ b/examples/surrogates/burgers_closure/train.py
@@ -0,0 +1,136 @@
+"""Train the subgrid closure for coarse-grid Burgers; writes closure.onnx.
+
+Viscous Burgers, periodic, Godunov flux + central viscous term + forward
+Euler (the scheme burgers.c / burgers.F90 use). NF cells resolve it; the
+NC-cell coarse grid does not. The closure is an MLP from the 7-point stencil
+of the coarse solution to a per-cell correction of the coarse right-hand
+side.
+
+It is trained with the coarse solver in the loop: rhs_torch is the same
+scheme in torch, a full NSTEPS rollout with the closure inside is unrolled
+from each training initial condition, and the loss is the relative error of
+that trajectory against the box-filtered fine one. Fitting the closure a
+priori (to the residual on filtered-fine states) made the coarse run worse:
+at run time it sees its own drifting state. On held-out realizations this
+cuts the 200-step error by about 1.4x; a local closure cannot recover
+sub-cell structure, so that is about the ceiling here.
+"""
+import numpy as np
+import torch
+import torch.nn as nn
+
+NF, FACTOR = 2048, 16 # fine cells; spatial coarsening factor
+NC = NF // FACTOR
+NU = 0.02
+L = 2 * np.pi
+DT_C = 0.01
+N_SUB = 64 # fine sub-steps per coarse step (viscous stability on the fine grid)
+DT_F = DT_C / N_SUB
+N_IC, N_STEPS = 48, 200
+STENCIL = (3, 2, 1, 0, -1, -2, -3) # ubar_{i-3..i+3}
+ITERS = 600
+SEED = 7
+
+
+def rhs(u, dx, nu):
+ """Godunov flux for u^2/2 plus central viscous term, periodic; vectorised over rows."""
+ ul, ur = u, np.roll(u, -1, axis=-1)
+ fl, fr = 0.5 * ul * ul, 0.5 * ur * ur
+ f_min = np.where((ul <= 0) & (ur >= 0), 0.0, np.minimum(fl, fr))
+ f_max = np.maximum(fl, fr)
+ face = np.where(ul <= ur, f_min, f_max)
+ conv = -(face - np.roll(face, 1, axis=-1)) / dx
+ visc = nu * (np.roll(u, -1, axis=-1) - 2 * u + np.roll(u, 1, axis=-1)) / (dx * dx)
+ return conv + visc
+
+
+def box_filter(u_f):
+ return u_f.reshape(*u_f.shape[:-1], NC, FACTOR).mean(axis=-1)
+
+
+def initial_conditions(rng, n):
+ x = np.arange(NF) * (L / NF)
+ u = np.zeros((n, NF))
+ for k in range(1, 4):
+ a = rng.uniform(-1, 1, (n, 1)) / k
+ ph = rng.uniform(0, 2 * np.pi, (n, 1))
+ u += a * np.sin(k * x + ph)
+ return u
+
+
+class Closure(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.net = nn.Sequential(nn.Linear(len(STENCIL), 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(),
+ nn.Linear(64, 1))
+
+ def forward(self, s):
+ return self.net(s)
+
+
+def make_trajectories(rng):
+ """Filtered fine trajectories, shape (N_IC, N_STEPS + 1, NC), for the rollout loss."""
+ dx_f = L / NF
+ u = initial_conditions(rng, N_IC)
+ traj = [box_filter(u)]
+ for _ in range(N_STEPS):
+ for _ in range(N_SUB):
+ u = u + DT_F * rhs(u, dx_f, NU)
+ traj.append(box_filter(u))
+ return np.stack(traj, axis=1)
+
+
+def rhs_torch(u, dx, nu):
+ """rhs() in torch, for the rollout loss."""
+ ul, ur = u, torch.roll(u, -1, dims=-1)
+ fl, fr = 0.5 * ul * ul, 0.5 * ur * ur
+ f_min = torch.where((ul <= 0) & (ur >= 0), torch.zeros_like(u), torch.minimum(fl, fr))
+ f_max = torch.maximum(fl, fr)
+ face = torch.where(ul <= ur, f_min, f_max)
+ conv = -(face - torch.roll(face, 1, dims=-1)) / dx
+ visc = nu * (torch.roll(u, -1, dims=-1) - 2 * u + torch.roll(u, 1, dims=-1)) / (dx * dx)
+ return conv + visc
+
+
+def stencils_torch(u):
+ return torch.stack([torch.roll(u, s, dims=-1) for s in STENCIL], dim=-1)
+
+
+def rollout(model, u0, k, dx):
+ """k coarse steps with the closure inside, as the solvers step."""
+ u, out = u0, []
+ for _ in range(k):
+ corr = model(stencils_torch(u)).squeeze(-1)
+ u = u + DT_C * (rhs_torch(u, dx, NU) + corr)
+ out.append(u)
+ return torch.stack(out, dim=1)
+
+
+def main():
+ rng = np.random.default_rng(SEED)
+ torch.manual_seed(SEED)
+ traj = torch.tensor(make_trajectories(rng), dtype=torch.float32) # (N_IC, N_STEPS+1, NC)
+ dx_c = L / NC
+ model = Closure()
+ opt = torch.optim.Adam(model.parameters(), lr=1e-3)
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, ITERS)
+ for it in range(ITERS):
+ ic = torch.randint(0, N_IC, (8,))
+ pred = rollout(model, traj[ic, 0], N_STEPS, dx_c)
+ target = traj[ic, 1:N_STEPS + 1]
+ loss = (((pred - target) ** 2).sum(-1) / (target ** 2).sum(-1)).mean()
+ opt.zero_grad()
+ loss.backward()
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+ opt.step()
+ sched.step()
+ if it % 100 == 0:
+ print(f"iter {it:4d} relative rollout error {loss.item():.3e}")
+ model.eval()
+ torch.onnx.export(model, torch.zeros(1, len(STENCIL)), "closure.onnx", input_names=["stencil"],
+ output_names=["correction"], opset_version=13, dynamo=False)
+ print("wrote closure.onnx")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/surrogates/common.mk b/examples/surrogates/common.mk
new file mode 100644
index 0000000..5c305c0
--- /dev/null
+++ b/examples/surrogates/common.mk
@@ -0,0 +1,93 @@
+# Shared by the example Makefiles. Each one sets MODEL, PROG and GENFLAGS,
+# then includes this file. TOOLCHAIN=amd|nvidia|gnu; ARCH overrides the
+# GPU target; NB / NX shrink the problem for a host run. An example may add
+# its own -D flags in DEFS.
+TOOLCHAIN ?= gnu
+ROSENNA ?= rosenna
+PYTHON ?= python3
+NB ?= 64
+NX ?= 256
+CFLAGS ?= -O2
+FFLAGS ?= -O2
+
+ifeq ($(TOOLCHAIN),amd)
+ARCH ?= gfx90a
+CC := amdclang
+FC := amdflang
+OFFLOAD := -fopenmp --offload-arch=$(ARCH)
+BACKEND := hip
+DEVCC := hipcc
+DEVFLAGS := -O2 --offload-arch=$(ARCH)
+RTLIB := -L$(dir $(shell which hipcc))../lib -lamdhip64
+FMOD := -J
+else ifeq ($(TOOLCHAIN),nvidia)
+ARCH ?= cc80
+CC := nvc
+FC := nvfortran
+OFFLOAD := -mp=gpu -gpu=$(ARCH)
+BACKEND := cuda
+DEVCC := nvcc
+DEVFLAGS := -O2 -arch=sm_$(patsubst cc%,%,$(ARCH))
+# -cuda -c++libs, not -lcudart: both are nvc/nvfortran's own flags, so neither
+# hardcodes a path or a C++ runtime implementation.
+# -cuda links the CUDA runtime. A bare -lcudart needs a -L the HPC SDK
+# does not put on the default search path (its libcudart lives
+# under cuda/lib64, not beside nvcc), so it fails with
+# "cannot find -lcudart" on a stock install.
+# -c++libs nvcc compiles _kernel.cu as C++, and a per-op kernel's
+# function-local static leaves __cxa_guard_acquire/_release
+# undefined when the C or Fortran driver links the archive.
+RTLIB := -cuda -c++libs
+FMOD := -module
+else
+CC := gcc
+FC := gfortran
+OFFLOAD := -fopenmp
+BACKEND := omp
+RTLIB :=
+FMOD := -J
+endif
+
+# ARCHIVE=1: the program links lib$(MODEL).a (infer_batch for $(BACKEND)).
+# MODULE_OFFLOAD: flags for the Fortran module; empty builds it host-only.
+ARCHIVE ?=
+MODULE_OFFLOAD ?= $(OFFLOAD)
+# Which drivers to build and run, and what to ask `generate` for. The four
+# surrogates are both languages; an example may be one (cns_closure is C).
+LANGS ?= c f
+GENLANG ?= both
+SIZES := -DNB=$(NB) -DNX=$(NX) $(DEFS)
+LIB := $(if $(ARCHIVE),gen/lib$(MODEL).a $(RTLIB),)
+RUNENV := OMP_TARGET_OFFLOAD=$(if $(filter gnu,$(TOOLCHAIN)),DEFAULT,MANDATORY)
+
+.PHONY: all run train clean distclean
+all: run
+run: $(foreach l,$(LANGS),$(PROG)_$(l))
+ # && , not `;`: make checks the exit status of the recipe line, so a
+ # semicolon-separated list would hide every failure but the last one.
+ $(foreach l,$(LANGS),$(RUNENV) ./$(PROG)_$(l) &&) true
+
+TRAINER ?= train.py
+train: # the .onnx is checked in; this rebuilds it
+ $(PYTHON) $(TRAINER)
+
+gen/$(MODEL).h gen/$(MODEL)_model.F90 gen/$(MODEL).mk: $(MODEL).onnx
+ $(ROSENNA) generate $< --lang $(GENLANG) --precision double $(GENFLAGS) --out gen
+ @if [ -f gen/$(MODEL).rwt ]; then cp gen/$(MODEL).rwt .; fi
+
+gen/lib$(MODEL).a: gen/$(MODEL).mk
+ $(MAKE) -C gen -f $(MODEL).mk ROSENNA_BACKEND=$(BACKEND) CC=$(CC) "CFLAGS=$(CFLAGS)" \
+ "ROSENNA_OFFLOAD_FLAGS=$(OFFLOAD)" DEVCC=$(DEVCC) "DEVFLAGS=$(DEVFLAGS)"
+
+$(PROG)_c: $(PROG).c gen/$(MODEL).h $(if $(ARCHIVE),gen/lib$(MODEL).a)
+ $(CC) $(CFLAGS) $(OFFLOAD) $(SIZES) -Igen $< $(LIB) -lm -o $@
+
+gen/$(MODEL)_model.o: gen/$(MODEL)_model.F90
+ $(FC) $(FFLAGS) $(MODULE_OFFLOAD) $(FMOD) gen -c $< -o $@
+$(PROG)_f: $(PROG).F90 gen/$(MODEL)_model.o $(if $(ARCHIVE),gen/lib$(MODEL).a)
+ $(FC) $(FFLAGS) $(OFFLOAD) $(SIZES) -Igen $< gen/$(MODEL)_model.o $(LIB) -o $@
+
+clean:
+ rm -rf gen $(foreach l,$(LANGS),$(PROG)_$(l)) $(MODEL).rwt *.mod
+distclean: clean
+ rm -f $(MODEL).onnx
diff --git a/examples/surrogates/poisson_guess/Makefile b/examples/surrogates/poisson_guess/Makefile
new file mode 100644
index 0000000..2f7727b
--- /dev/null
+++ b/examples/surrogates/poisson_guess/Makefile
@@ -0,0 +1,9 @@
+MODEL := poisson_guess
+PROG := poisson
+ARCHIVE := 1
+NSTEPS ?= 20
+DEFS := -DNSTEPS=$(NSTEPS)
+# The module is built host-only: its own infer_batch would instantiate the whole-field
+# infer on the device (660 KB of locals). The program uses the archive's infer_one.
+MODULE_OFFLOAD :=
+include ../common.mk
diff --git a/examples/surrogates/poisson_guess/README.md b/examples/surrogates/poisson_guess/README.md
new file mode 100644
index 0000000..93af6a6
--- /dev/null
+++ b/examples/surrogates/poisson_guess/README.md
@@ -0,0 +1,65 @@
+# Poisson solves with a conv-net initial guess
+
+Elliptic PDE (periodic Poisson, the pressure solve of a projection
+method). The network does not replace the solver; it starts it. A small
+conv net maps the right-hand side to an initial guess and Jacobi iterates
+from there. The whole-field pattern: the entire field is one model input
+(NCHW `1×1×76×76`, `f` with the 6-cell periodic halo the solver builds) and
+one output (`1×1×64×64`), one call per step: `poisson_guess_infer_one`,
+which runs the model on the device as one launch per layer over device
+pointers, its activations in static device buffers.
+
+```
+make TOOLCHAIN=amd | nvidia | gnu
+```
+
+Twenty steps of a rotating right-hand side. Each step Jacobi runs to
+`|lap(φ) − f| / |f| < 1e-3` from three starts: zero, the previous step's
+solution, the NN guess. MI210, both languages:
+
+```
+64x64 periodic Poisson, 20 steps of a rotating right-hand side, Jacobi to 1e-03:
+ iterations per step from zero 560
+ iterations per step from the last solution 446
+ iterations per step from the NN guess 460 (guess: 3.0 ms per step)
+OK: NN guess saves 18% of the zero-start iterations
+```
+
+## Model
+
+`train.py`: three 5×5 convolutions, 1 → 8 → 8 → 1, `tanh` between, no
+padding; the receptive field is 13 cells. The loss is the residual of the
+guess, `|lap(NN(f)) − f|²`, not the distance to `φ`: Jacobi stops on the
+residual, and a guess fitted to `φ` carries high-mode error that the
+Laplacian amplifies by k². Fitted that way this net doubled the iteration
+count; on the residual it leaves 8% of the zero guess's.
+
+## Wiring
+
+```c
+#pragma omp target teams distribute parallel for collapse(2) /* periodic halo */
+for (int i = 0; i < NP; ++i)
+ for (int j = 0; j < NP; ++j)
+ fp[i * NP + j] = f[wrap(i - HALO) * N + wrap(j - HALO)];
+#pragma omp target data use_device_ptr(fp, phi_nn)
+status = poisson_guess_infer_one(fp, phi_nn, 0); /* the whole field, on the device */
+status = poisson_guess_sync(0);
+it_nn += jacobi(phi_nn, tmp, f, fnorm);
+```
+
+The per-point `infer` keeps its intermediate activations as locals, and
+for a whole-field model those are the field's: `8×72² + 8×68²` doubles,
+660 KB, more than a device thread's stack (`amdclang`: "stack frame size
+(663568) exceeds limit (131056)"). `infer_one` is the form for this
+model: a launch per layer with the thread index over the layer's output
+elements, the activations in static device buffers. Nothing is copied
+inside the step loop but the new right-hand side. The archive
+(`libpoisson_guess.a`, built for the toolchain's backend) provides it;
+with the gnu toolchain it is one OpenMP target loop per layer.
+
+Two notes on `poisson.F90`: the generated module's own `infer_batch`
+would instantiate the per-point `infer` on the device, so `Makefile`
+builds the module host-only (`MODULE_OFFLOAD :=`) and the program calls
+the archive through `poisson_guess_infer_one_dev`; and `fp` is indexed
+`(j, i)`, since the model's NCHW input is row-major and Fortran is
+column-major.
diff --git a/examples/surrogates/poisson_guess/poisson.F90 b/examples/surrogates/poisson_guess/poisson.F90
new file mode 100644
index 0000000..8bc189f
--- /dev/null
+++ b/examples/surrogates/poisson_guess/poisson.F90
@@ -0,0 +1,186 @@
+! Periodic Poisson solves with a conv-net initial guess; the twin of poisson.c.
+! The guess runs on the device through the archive's infer_one (bind(C)
+! route poisson_guess_infer_one_dev). fp is fp(np_, np_) indexed (j, i): the
+! model's NCHW input is row-major with the row index slowest, and Fortran is
+! column-major.
+program poisson
+ use poisson_guess_model, only: poisson_guess_infer_one_dev, poisson_guess_sync_dev
+ use iso_fortran_env, only: real64
+ use iso_c_binding, only: c_loc, c_null_ptr
+ use omp_lib, only: omp_get_wtime
+ implicit none
+#ifndef NSTEPS
+#define NSTEPS 20
+#endif
+ integer, parameter :: n = 64, halo = 6, np_ = n + 2 * halo, nsteps = NSTEPS
+ real(real64), parameter :: tol = 1.0e-3_real64
+ integer, parameter :: max_it = 20000, check_every = 20
+ real(real64), parameter :: pi = 3.14159265358979323846_real64
+
+ real(real64), allocatable, target :: f(:,:), tmp(:,:), fp(:,:), phi_nn(:,:), phi_zero(:,:), phi_warm(:,:)
+ integer(8) :: it_nn, it_zero, it_warm
+ real(real64) :: t0, t_guess, fnorm
+ integer :: s, status
+
+ allocate(f(n, n), tmp(n, n), fp(np_, np_), phi_nn(n, n), phi_zero(n, n), phi_warm(n, n))
+ phi_nn = 0.0_real64; phi_zero = 0.0_real64; phi_warm = 0.0_real64
+ it_nn = 0; it_zero = 0; it_warm = 0; t_guess = 0.0_real64
+ fnorm = sqrt(real(n * n, real64))
+
+ !$omp target enter data map(alloc: f, tmp, fp) map(to: phi_nn, phi_zero, phi_warm)
+ do s = 0, nsteps - 1
+ call rhs(f, s)
+ !$omp target update to(f)
+
+ t0 = omp_get_wtime()
+ call wrap_halo(f, fp)
+ !$omp target data use_device_addr(fp, phi_nn)
+ status = poisson_guess_infer_one_dev(c_loc(fp), c_loc(phi_nn), c_null_ptr) ! on the device
+ !$omp end target data
+ if (status == 0) status = poisson_guess_sync_dev(c_null_ptr)
+ if (status /= 0) then
+ print '(A,I0)', 'infer_one failed with status ', status
+ stop 3
+ end if
+ call zero_mean(phi_nn)
+ t_guess = t_guess + (omp_get_wtime() - t0)
+
+ it_nn = it_nn + jacobi(phi_nn, tmp, f, fnorm)
+ call clear(phi_zero)
+ it_zero = it_zero + jacobi(phi_zero, tmp, f, fnorm)
+ it_warm = it_warm + jacobi(phi_warm, tmp, f, fnorm)
+ end do
+ !$omp target exit data map(delete: f, tmp, fp, phi_nn, phi_zero, phi_warm)
+
+ print '(I0,A,I0,A,I0,A,ES7.0,A)', n, 'x', n, ' periodic Poisson, ', nsteps, &
+ ' steps of a rotating right-hand side, Jacobi to ', tol, ':'
+ print '(A,F6.0)', ' iterations per step from zero ', real(it_zero, real64) / nsteps
+ print '(A,F6.0)', ' iterations per step from the last solution ', real(it_warm, real64) / nsteps
+ print '(A,F6.0,A,F5.1,A)', ' iterations per step from the NN guess ', real(it_nn, real64) / nsteps, &
+ ' (guess: ', 1e3 * t_guess / nsteps, ' ms per step)'
+ if (it_nn >= int(max_it, 8) * nsteps .or. it_nn >= it_zero) then
+ print '(A)', 'FAIL: NN guess did not help'
+ stop 1
+ end if
+ print '(A,F3.0,A)', 'OK: NN guess saves ', 100.0_real64 * (1.0_real64 - real(it_nn, real64) / it_zero), &
+ '% of the zero-start iterations'
+
+contains
+
+ pure integer function wrap(i) ! 0-based periodic index
+ !$omp declare target
+ integer, intent(in) :: i
+ wrap = modulo(i, n)
+ end function
+
+ ! Six Fourier modes whose phases advance with the step; mean zero, unit rms.
+ subroutine rhs(f, s)
+ real(real64), intent(out) :: f(n, n)
+ integer, intent(in) :: s
+ integer, parameter :: p(6) = [1, 2, -3, 4, 5, -7], q(6) = [2, -1, 3, 1, -5, 2]
+ real(real64), parameter :: a(6) = [0.9_real64, -0.7_real64, 0.5_real64, 0.6_real64, -0.4_real64, 0.3_real64]
+ integer :: i, j, m
+ do i = 0, n - 1
+ do j = 0, n - 1
+ f(j + 1, i + 1) = 0.0_real64
+ do m = 1, 6
+ f(j + 1, i + 1) = f(j + 1, i + 1) + a(m) * cos(2.0_real64 * pi * (p(m) * i + q(m) * j) / n &
+ + 0.15_real64 * s * m)
+ end do
+ end do
+ end do
+ f = f - sum(f) / (n * n)
+ f = f / sqrt(sum(f**2) / (n * n))
+ end subroutine
+
+ subroutine wrap_halo(f, fp) ! periodic halo, on the device
+ real(real64), intent(in) :: f(n, n)
+ real(real64), intent(out) :: fp(np_, np_)
+ integer :: i, j
+ !$omp target teams distribute parallel do collapse(2)
+ do i = 0, np_ - 1
+ do j = 0, np_ - 1
+ fp(j + 1, i + 1) = f(wrap(j - halo) + 1, wrap(i - halo) + 1)
+ end do
+ end do
+ end subroutine
+
+ ! One Jacobi sweep; f(j, i) holds poisson.c's f[i*N + j].
+ subroutine sweep(phi, out, f)
+ real(real64), intent(in) :: phi(n, n), f(n, n)
+ real(real64), intent(out) :: out(n, n)
+ integer :: i, j
+ !$omp target teams distribute parallel do collapse(2)
+ do i = 1, n
+ do j = 1, n
+ out(j, i) = 0.25_real64 * (phi(j, wrap(i - 2) + 1) + phi(j, wrap(i) + 1) &
+ + phi(wrap(j - 2) + 1, i) + phi(wrap(j) + 1, i) - f(j, i))
+ end do
+ end do
+ end subroutine
+
+ real(real64) function residual(phi, f)
+ real(real64), intent(in) :: phi(n, n), f(n, n)
+ real(real64) :: r2, lap
+ integer :: i, j
+ r2 = 0.0_real64
+ !$omp target teams distribute parallel do collapse(2) reduction(+: r2) private(lap)
+ do i = 1, n
+ do j = 1, n
+ lap = phi(j, wrap(i - 2) + 1) + phi(j, wrap(i) + 1) + phi(wrap(j - 2) + 1, i) + phi(wrap(j) + 1, i) &
+ - 4.0_real64 * phi(j, i)
+ r2 = r2 + (lap - f(j, i))**2
+ end do
+ end do
+ residual = sqrt(r2)
+ end function
+
+ ! Jacobi in place until |lap(phi) - f| / |f| < tol; returns iterations.
+ integer function jacobi(phi, tmp, f, fnorm)
+ real(real64), intent(inout) :: phi(n, n), tmp(n, n)
+ real(real64), intent(in) :: f(n, n), fnorm
+ integer :: it
+ do it = 0, max_it - 2, 2
+ call sweep(phi, tmp, f)
+ call sweep(tmp, phi, f)
+ if (mod(it + 2, check_every) == 0) then
+ if (residual(phi, f) / fnorm < tol) then
+ jacobi = it + 2
+ return
+ end if
+ end if
+ end do
+ jacobi = max_it
+ end function
+
+ subroutine zero_mean(phi)
+ real(real64), intent(inout) :: phi(n, n)
+ real(real64) :: m
+ integer :: i, j
+ m = 0.0_real64
+ !$omp target teams distribute parallel do collapse(2) reduction(+: m)
+ do i = 1, n
+ do j = 1, n
+ m = m + phi(j, i)
+ end do
+ end do
+ m = m / (n * n)
+ !$omp target teams distribute parallel do collapse(2)
+ do i = 1, n
+ do j = 1, n
+ phi(j, i) = phi(j, i) - m
+ end do
+ end do
+ end subroutine
+
+ subroutine clear(phi)
+ real(real64), intent(out) :: phi(n, n)
+ integer :: i, j
+ !$omp target teams distribute parallel do collapse(2)
+ do i = 1, n
+ do j = 1, n
+ phi(j, i) = 0.0_real64
+ end do
+ end do
+ end subroutine
+end program
diff --git a/examples/surrogates/poisson_guess/poisson.c b/examples/surrogates/poisson_guess/poisson.c
new file mode 100644
index 0000000..d00a56a
--- /dev/null
+++ b/examples/surrogates/poisson_guess/poisson.c
@@ -0,0 +1,133 @@
+/* Periodic Poisson solves with a conv-net initial guess. The whole right-hand
+ side, with a 6-cell periodic halo the solver builds, is one model input
+ (NCHW 1 x 1 x 76 x 76); the guess (1 x 1 x 64 x 64) starts a Jacobi solve.
+ Everything stays on the device: poisson_guess_infer_one runs the model as
+ one launch per layer over device pointers, its activations in static device
+ buffers (the per-point infer would hold the field's 660 KB of activations
+ as locals). Iterations are counted against a zero start and a warm start.
+ Exits 0 if the NN start takes fewer iterations than the zero start. */
+#include
+#include
+#include
+#include
+#include "poisson_guess.h"
+
+#define N 64
+#define HALO 6
+#define NP (N + 2 * HALO)
+#ifndef NSTEPS
+#define NSTEPS 20
+#endif
+#define TOL 1e-3
+#define MAX_IT 20000
+#define CHECK_EVERY 20
+
+static inline int wrap(int i) { return (i + N) % N; }
+
+/* Six Fourier modes whose phases advance with the step; mean zero, unit rms. */
+static void rhs(double *f, int s) {
+ static const int P[6] = {1, 2, -3, 4, 5, -7}, Q[6] = {2, -1, 3, 1, -5, 2};
+ static const double A[6] = {0.9, -0.7, 0.5, 0.6, -0.4, 0.3};
+ double mean = 0.0, ss = 0.0;
+ for (int i = 0; i < N; ++i)
+ for (int j = 0; j < N; ++j) {
+ double v = 0.0;
+ for (int m = 0; m < 6; ++m)
+ v += A[m] * cos(2.0 * M_PI * (P[m] * i + Q[m] * j) / N + 0.15 * s * (m + 1));
+ f[i * N + j] = v; mean += v;
+ }
+ mean /= N * N;
+ for (int c = 0; c < N * N; ++c) { f[c] -= mean; ss += f[c] * f[c]; }
+ const double rms = sqrt(ss / (N * N));
+ for (int c = 0; c < N * N; ++c) f[c] /= rms;
+}
+
+/* Jacobi in place until |lap(phi) - f| / |f| < TOL; returns iterations. */
+static int jacobi(double *phi, double *tmp, const double *f, double fnorm) {
+ int it;
+ for (it = 0; it < MAX_IT; it += 2) {
+#pragma omp target teams distribute parallel for collapse(2)
+ for (int i = 0; i < N; ++i)
+ for (int j = 0; j < N; ++j)
+ tmp[i * N + j] = 0.25 * (phi[wrap(i - 1) * N + j] + phi[wrap(i + 1) * N + j]
+ + phi[i * N + wrap(j - 1)] + phi[i * N + wrap(j + 1)] - f[i * N + j]);
+#pragma omp target teams distribute parallel for collapse(2)
+ for (int i = 0; i < N; ++i)
+ for (int j = 0; j < N; ++j)
+ phi[i * N + j] = 0.25 * (tmp[wrap(i - 1) * N + j] + tmp[wrap(i + 1) * N + j]
+ + tmp[i * N + wrap(j - 1)] + tmp[i * N + wrap(j + 1)] - f[i * N + j]);
+ if ((it + 2) % CHECK_EVERY == 0) {
+ double r2 = 0.0;
+#pragma omp target teams distribute parallel for collapse(2) reduction(+: r2)
+ for (int i = 0; i < N; ++i)
+ for (int j = 0; j < N; ++j) {
+ const double lap = phi[wrap(i - 1) * N + j] + phi[wrap(i + 1) * N + j]
+ + phi[i * N + wrap(j - 1)] + phi[i * N + wrap(j + 1)] - 4.0 * phi[i * N + j];
+ r2 += (lap - f[i * N + j]) * (lap - f[i * N + j]);
+ }
+ if (sqrt(r2) / fnorm < TOL) return it + 2;
+ }
+ }
+ return MAX_IT;
+}
+
+static void zero_mean(double *phi) {
+ double m = 0.0;
+#pragma omp target teams distribute parallel for reduction(+: m)
+ for (int c = 0; c < N * N; ++c) m += phi[c];
+ m /= N * N;
+#pragma omp target teams distribute parallel for
+ for (int c = 0; c < N * N; ++c) phi[c] -= m;
+}
+
+int main(void) {
+ double *f = malloc(sizeof(double) * N * N), *tmp = malloc(sizeof(double) * N * N);
+ double *fp = malloc(sizeof(double) * NP * NP);
+ double *phi_nn = calloc(N * N, sizeof(double)), *phi_zero = calloc(N * N, sizeof(double));
+ double *phi_warm = calloc(N * N, sizeof(double));
+ long it_nn = 0, it_zero = 0, it_warm = 0;
+ double t_guess = 0.0;
+
+#pragma omp target enter data map(alloc: f[0:N * N], tmp[0:N * N], fp[0:NP * NP]) \
+ map(to: phi_nn[0:N * N], phi_zero[0:N * N], phi_warm[0:N * N])
+ for (int s = 0; s < NSTEPS; ++s) {
+ rhs(f, s);
+ const double fnorm = sqrt((double)(N * N));
+#pragma omp target update to(f[0:N * N])
+
+ const double t0 = omp_get_wtime();
+#pragma omp target teams distribute parallel for collapse(2) /* periodic halo */
+ for (int i = 0; i < NP; ++i)
+ for (int j = 0; j < NP; ++j)
+ fp[i * NP + j] = f[wrap(i - HALO) * N + wrap(j - HALO)];
+ int status;
+#pragma omp target data use_device_ptr(fp, phi_nn)
+ status = poisson_guess_infer_one(fp, phi_nn, 0); /* the whole field, on the device */
+ if (status == 0) status = poisson_guess_sync(0);
+ if (status) { printf("infer_one failed with status %d\n", status); return 3; }
+ zero_mean(phi_nn);
+ t_guess += omp_get_wtime() - t0;
+
+ it_nn += jacobi(phi_nn, tmp, f, fnorm);
+#pragma omp target teams distribute parallel for
+ for (int c = 0; c < N * N; ++c) phi_zero[c] = 0.0;
+ it_zero += jacobi(phi_zero, tmp, f, fnorm);
+ it_warm += jacobi(phi_warm, tmp, f, fnorm);
+ }
+#pragma omp target exit data map(delete: f[0:N * N], tmp[0:N * N], fp[0:NP * NP], phi_nn[0:N * N], \
+ phi_zero[0:N * N], phi_warm[0:N * N])
+
+ printf("%dx%d periodic Poisson, %d steps of a rotating right-hand side, Jacobi to %.0e:\n",
+ N, N, NSTEPS, TOL);
+ printf(" iterations per step from zero %6.0f\n", (double)it_zero / NSTEPS);
+ printf(" iterations per step from the last solution %6.0f\n", (double)it_warm / NSTEPS);
+ printf(" iterations per step from the NN guess %6.0f (guess: %.1f ms per step)\n",
+ (double)it_nn / NSTEPS, 1e3 * t_guess / NSTEPS);
+ free(f); free(tmp); free(fp); free(phi_nn); free(phi_zero); free(phi_warm);
+ if (it_nn >= (long)MAX_IT * NSTEPS || it_nn >= it_zero) {
+ printf("FAIL: NN guess did not help\n");
+ return 1;
+ }
+ printf("OK: NN guess saves %.0f%% of the zero-start iterations\n", 100.0 * (1.0 - (double)it_nn / it_zero));
+ return 0;
+}
diff --git a/examples/surrogates/poisson_guess/poisson_guess.onnx b/examples/surrogates/poisson_guess/poisson_guess.onnx
new file mode 100644
index 0000000..76e357a
Binary files /dev/null and b/examples/surrogates/poisson_guess/poisson_guess.onnx differ
diff --git a/examples/surrogates/poisson_guess/train.py b/examples/surrogates/poisson_guess/train.py
new file mode 100644
index 0000000..c44c1da
--- /dev/null
+++ b/examples/surrogates/poisson_guess/train.py
@@ -0,0 +1,112 @@
+"""Train a conv net that guesses the solution of a periodic Poisson problem; writes poisson_guess.onnx.
+
+lap(phi) = f on an N x N periodic grid, dx = 1, mean-zero f and phi. The
+solvers run Jacobi; the network starts it: phi0 = NN(f).
+
+Three 5x5 convolutions, 1 -> 8 -> 8 -> 1, tanh between, no padding: the
+solver supplies the 6-cell periodic halo (ONNX Conv only zero-pads), so the
+input is the whole field as NCHW 1 x 1 x 76 x 76 and the output 1 x 1 x 64 x 64.
+
+The loss is the residual |lap(NN(f)) - f|^2, not the distance to phi. Jacobi
+stops on the residual, and a guess fitted to phi carries high-mode error
+that the Laplacian amplifies by k^2: fitted that way, this net doubled the
+iteration count. On the residual it leaves 8% of the zero guess's.
+"""
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+N = 64
+HALO = 6 # three 5x5 valid convolutions, 2 cells each
+N_TRAIN = 512
+PHI_SCALE = 10.0 # phi has rms ~14; trained at phi / PHI_SCALE, folded into the last conv
+ITERS = 3000
+SEED = 13
+
+
+def random_rhs(rng, n):
+ """Mean-zero, unit-rms right-hand sides: six random Fourier modes, wavenumbers up to 8."""
+ x = np.arange(N)
+ kx, ky = np.meshgrid(x, x, indexing="ij")
+ f = np.zeros((n, N, N))
+ for i in range(n):
+ for _ in range(6):
+ p, q = rng.integers(-8, 9, 2)
+ if p == 0 and q == 0:
+ continue
+ f[i] += rng.normal() * np.cos(2 * np.pi * (p * kx + q * ky) / N + rng.uniform(0, 2 * np.pi))
+ f -= f.mean(axis=(1, 2), keepdims=True)
+ return f / np.sqrt((f ** 2).mean(axis=(1, 2), keepdims=True))
+
+
+def exact_solution(f):
+ """lap(phi) = f by the FFT of the 5-point stencil, mean(phi) = 0."""
+ k = np.fft.fftfreq(N) * N
+ kx, ky = np.meshgrid(k, k, indexing="ij")
+ eig = 2 * np.cos(2 * np.pi * kx / N) + 2 * np.cos(2 * np.pi * ky / N) - 4 # 5-point stencil symbol
+ eig[0, 0] = 1.0
+ phi_hat = np.fft.fft2(f) / eig
+ phi_hat[:, 0, 0] = 0.0
+ return np.real(np.fft.ifft2(phi_hat))
+
+
+def wrap(f, halo=HALO):
+ """Periodic halo, as the solvers build the model's input."""
+ return F.pad(f, (halo, halo, halo, halo), mode="circular")
+
+
+def laplacian(phi):
+ """The solvers' 5-point periodic Laplacian, dx = 1."""
+ p = F.pad(phi, (1, 1, 1, 1), mode="circular")
+ return p[:, :, :-2, 1:-1] + p[:, :, 2:, 1:-1] + p[:, :, 1:-1, :-2] + p[:, :, 1:-1, 2:] - 4 * phi
+
+
+class Guess(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.net = nn.Sequential(nn.Conv2d(1, 8, 5), nn.Tanh(), nn.Conv2d(8, 8, 5), nn.Tanh(),
+ nn.Conv2d(8, 1, 5))
+
+ def forward(self, f_padded):
+ return self.net(f_padded)
+
+
+def main():
+ rng = np.random.default_rng(SEED)
+ torch.manual_seed(SEED)
+ f = random_rhs(rng, N_TRAIN)
+ phi = exact_solution(f)
+ Ft = torch.tensor(f[:, None], dtype=torch.float32)
+ Fp = wrap(Ft) # (n, 1, N+12, N+12)
+ model = Guess()
+ opt = torch.optim.Adam(model.parameters(), lr=2e-3)
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, ITERS)
+ for it in range(ITERS):
+ idx = torch.randint(0, N_TRAIN, (32,))
+ loss = ((laplacian(model(Fp[idx]) * PHI_SCALE) - Ft[idx]) ** 2).mean()
+ opt.zero_grad()
+ loss.backward()
+ opt.step()
+ sched.step()
+ if it % 500 == 0:
+ print(f"iter {it:4d} residual mse {loss.item():.3e} (|f|^2 mean is 1)")
+ with torch.no_grad():
+ model.net[-1].weight *= PHI_SCALE
+ model.net[-1].bias *= PHI_SCALE
+ model.eval()
+ with torch.no_grad():
+ f2 = random_rhs(np.random.default_rng(SEED + 1), 16)
+ phi2 = exact_solution(f2)
+ Ft2 = torch.tensor(f2[:, None], dtype=torch.float32)
+ guess = model(wrap(Ft2))
+ res = torch.sqrt(((laplacian(guess) - Ft2) ** 2).sum() / (Ft2 ** 2).sum()).item()
+ rel = np.sqrt(((guess.numpy()[:, 0] - phi2) ** 2).sum() / (phi2 ** 2).sum())
+ print(f"held-out: residual of the guess {res:.3f} of the zero guess's; phi error {rel:.3f}")
+ torch.onnx.export(model, torch.zeros(1, 1, N + 2 * HALO, N + 2 * HALO), "poisson_guess.onnx",
+ input_names=["f_padded"], output_names=["phi_guess"], opset_version=13, dynamo=False)
+ print("wrote poisson_guess.onnx")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/surrogates/reaction_patch/Makefile b/examples/surrogates/reaction_patch/Makefile
new file mode 100644
index 0000000..8b3ada4
--- /dev/null
+++ b/examples/surrogates/reaction_patch/Makefile
@@ -0,0 +1,5 @@
+MODEL := stepper
+PROG := react
+GENFLAGS := --no-embed
+ARCHIVE := 1
+include ../common.mk
diff --git a/examples/surrogates/reaction_patch/README.md b/examples/surrogates/reaction_patch/README.md
new file mode 100644
index 0000000..ef6a5f2
--- /dev/null
+++ b/examples/surrogates/reaction_patch/README.md
@@ -0,0 +1,71 @@
+# Reaction-diffusion with a learned time-stepper on patches
+
+Parabolic, reactive PDE (2-D FitzHugh-Nagumo); the network is the time
+step: from each cell's 3×3 patch of `(u, v)` it predicts that cell's
+`(u, v)` ten fine steps later. The batched pattern: gather all patches
+into one array, one `stepper_infer_batch` over the field on device
+pointers, scatter back. The model is file-loaded (`--no-embed`), so
+`stepper_init` uploads the weights once before the loop, and the solver
+links `libstepper.a` built for the toolchain's native backend.
+
+```
+make TOOLCHAIN=amd | nvidia | gnu # gnu: host only, use NX=64
+```
+
+Reference: explicit Euler, 5-point Laplacian, `dt = 0.1`, periodic.
+Surrogate: `K = 10` of those per call, 100 calls from the same initial
+field. Exits 0 if the final relative L2 error is under 5%. MI210:
+
+```
+256x256 grid, 100 surrogate steps of 10 fine steps each:
+ fine reference 24.7 ms (0.25 ms per big step)
+ surrogate 230.7 ms (2.31 ms per big step)
+ relative L2 error of the surrogate: 1.999e-03
+OK
+```
+
+The surrogate is 9× slower than the physics it replaces here: an
+explicit FitzHugh-Nagumo step is a few flops per cell, the stepper 19k.
+The pattern pays when the fine step is expensive (stiff chemistry, an
+implicit solve).
+
+## Model
+
+`train.py`: 18 → 128 → 128 → 2, `tanh`, fitted to the one-step map with 3%
+Gaussian noise on the input patches. Fitted on clean inputs the map is
+1% accurate per step and diverges after ~40 of its own steps; with the
+noise it tracks 1000 fine steps to 0.2%. Unrolled training (as in `burgers_closure`)
+made it worse.
+
+## Wiring
+
+```c
+gather(u, v, feat);
+#pragma omp target data use_device_ptr(feat, out)
+status = stepper_infer_batch(NCELL, feat, out, 0);
+status = stepper_sync(0);
+scatter(out, u, v);
+```
+
+`feat` and `out` are mapped once. `stepper_sync` is needed with a
+cuda/hip archive: `infer_batch` launches on the null stream and returns,
+and the OpenMP scatter runs on the runtime's own queue with no ordering
+between them. With an omp archive it is a no-op.
+
+`react.F90` uses the module's `bind(C)` routes: `stepper_init_dev` (the
+archive's init; the module's own `stepper_init` serves the Fortran
+per-point path, not used here), `stepper_infer_batch_dev` with `c_loc`
+inside `target data use_device_addr`, `stepper_sync_dev`.
+
+## Notes
+
+- Building this example changed the generator: `infer_batch`'s OpenMP
+ fallback used `target teams loop`, which `amdclang` maps one point per
+ team (3.5 µs per point; `distribute parallel for` is 28× faster);
+ `_sync` and `_init_dev` did not exist.
+- This model's 128-wide layers are what the generator's register-blocked
+ dot products are for: 8 output columns per pass over the input vector,
+ so each activation load feeds 8 FMAs. That took the big step from 7.9 to
+ 2.3 ms (3.4×, ~550 GMAC/s). Staging the weights in shared memory was
+ tried first and was slower; the bound was the per-thread activation
+ loads, not the weights.
diff --git a/examples/surrogates/reaction_patch/react.F90 b/examples/surrogates/reaction_patch/react.F90
new file mode 100644
index 0000000..0f69d20
--- /dev/null
+++ b/examples/surrogates/reaction_patch/react.F90
@@ -0,0 +1,172 @@
+! 2-D FitzHugh-Nagumo with a learned time-stepper; the twin of react.c. The
+! batched call goes to the C archive through the module's bind(C) routes:
+! stepper_init_dev (the archive's init), stepper_infer_batch_dev with c_loc of
+! the arrays inside `target data use_device_addr`, and stepper_sync_dev.
+program react
+ use stepper_model, only: stepper_init_dev, stepper_infer_batch_dev, stepper_sync_dev
+ use iso_fortran_env, only: real64
+ use iso_c_binding, only: c_loc, c_null_ptr, c_null_char
+ use omp_lib, only: omp_get_wtime
+ implicit none
+#ifndef NX
+#define NX 256
+#endif
+ integer, parameter :: nx_ = NX, ncell = nx_ * nx_
+ real(real64), parameter :: du = 1.0_real64, dv = 0.05_real64
+ real(real64), parameter :: pa = 0.7_real64, pb = 0.8_real64, eps = 0.08_real64, dt = 0.1_real64
+ integer, parameter :: k = 10, nbig = 100, nfeat = 18
+ real(real64), parameter :: tol = 0.05_real64
+ real(real64), parameter :: pi = 3.14159265358979323846_real64
+
+ real(real64), allocatable, target :: u(:,:), v(:,:), ur(:,:), vr(:,:), ut(:,:), vt(:,:)
+ real(real64), allocatable, target :: feat(:,:), out(:,:)
+ real(real64) :: t0, t_ref, t_nn, err
+ integer :: status
+
+ status = stepper_init_dev('stepper.rwt' // c_null_char) ! the one upload
+ if (status /= 0) then
+ print '(A,I0)', 'stepper_init failed with status ', status
+ stop 2
+ end if
+
+ allocate(u(nx_, nx_), v(nx_, nx_), ur(nx_, nx_), vr(nx_, nx_), ut(nx_, nx_), vt(nx_, nx_))
+ allocate(feat(nfeat, ncell), out(2, ncell))
+ call initial_fields(u, v)
+ ur = u; vr = v
+
+ !$omp target enter data map(to: ur, vr) map(alloc: ut, vt)
+ t0 = omp_get_wtime()
+ call reference(ur, vr, ut, vt)
+ t_ref = omp_get_wtime() - t0
+ !$omp target exit data map(from: ur, vr) map(delete: ut, vt)
+
+ !$omp target enter data map(to: u, v) map(alloc: feat, out)
+ t0 = omp_get_wtime()
+ call surrogate(u, v, feat, out, status)
+ t_nn = omp_get_wtime() - t0
+ !$omp target exit data map(from: u, v) map(delete: feat, out)
+ if (status /= 0) then
+ print '(A,I0)', 'stepper_infer_batch failed with status ', status
+ stop 3
+ end if
+
+ err = sqrt((sum((u - ur)**2) + sum((v - vr)**2)) / (sum(ur**2) + sum(vr**2)))
+ print '(I0,A,I0,A,I0,A,I0,A)', nx_, 'x', nx_, ' grid, ', nbig, ' surrogate steps of ', k, ' fine steps each:'
+ print '(A,F8.1,A,F6.2,A)', ' fine reference ', 1e3 * t_ref, ' ms (', 1e3 * t_ref / nbig, ' ms per big step)'
+ print '(A,F8.1,A,F6.2,A)', ' surrogate ', 1e3 * t_nn, ' ms (', 1e3 * t_nn / nbig, ' ms per big step)'
+ print '(A,ES10.3)', ' relative L2 error of the surrogate: ', err
+ if (.not. (err < tol)) then
+ print '(A,F4.2)', 'FAIL: error above ', tol
+ stop 1
+ end if
+ print '(A)', 'OK'
+
+contains
+
+ pure integer function wrap(i)
+ !$omp declare target
+ integer, intent(in) :: i
+ wrap = modulo(i - 1, nx_) + 1
+ end function
+
+ subroutine fine_step(u, v, un, vn)
+ real(real64), intent(in) :: u(nx_, nx_), v(nx_, nx_)
+ real(real64), intent(out) :: un(nx_, nx_), vn(nx_, nx_)
+ real(real64) :: lu, lv
+ integer :: i, j
+ !$omp target teams distribute parallel do collapse(2) private(lu, lv)
+ do j = 1, nx_
+ do i = 1, nx_
+ lu = u(wrap(i - 1), j) + u(wrap(i + 1), j) + u(i, wrap(j - 1)) + u(i, wrap(j + 1)) - 4.0_real64 * u(i, j)
+ lv = v(wrap(i - 1), j) + v(wrap(i + 1), j) + v(i, wrap(j - 1)) + v(i, wrap(j + 1)) - 4.0_real64 * v(i, j)
+ un(i, j) = u(i, j) + dt * (du * lu + u(i, j) - u(i, j)**3 / 3.0_real64 - v(i, j))
+ vn(i, j) = v(i, j) + dt * (dv * lv + eps * (u(i, j) + pa - pb * v(i, j)))
+ end do
+ end do
+ end subroutine
+
+ subroutine reference(ur, vr, ut, vt)
+ real(real64), intent(inout) :: ur(nx_, nx_), vr(nx_, nx_), ut(nx_, nx_), vt(nx_, nx_)
+ integer :: s
+ do s = 1, nbig * k / 2
+ call fine_step(ur, vr, ut, vt)
+ call fine_step(ut, vt, ur, vr)
+ end do
+ end subroutine
+
+ ! Patch order as in train.py: u's 3x3 row-major (first index slowest, as
+ ! react.c's i), then v's. Cell c is react.c's i*NX + j.
+ subroutine gather(u, v, feat)
+ real(real64), intent(in) :: u(nx_, nx_), v(nx_, nx_)
+ real(real64), intent(out) :: feat(nfeat, ncell)
+ integer :: i, j, di, dj, c
+ !$omp target teams distribute parallel do collapse(2) private(di, dj, c)
+ do j = 1, nx_
+ do i = 1, nx_
+ c = (i - 1) * nx_ + j
+ do di = -1, 1
+ do dj = -1, 1
+ feat((di + 1) * 3 + (dj + 1) + 1, c) = u(wrap(i + di), wrap(j + dj))
+ feat(9 + (di + 1) * 3 + (dj + 1) + 1, c) = v(wrap(i + di), wrap(j + dj))
+ end do
+ end do
+ end do
+ end do
+ end subroutine
+
+ subroutine scatter(out, u, v)
+ real(real64), intent(in) :: out(2, ncell)
+ real(real64), intent(out) :: u(nx_, nx_), v(nx_, nx_)
+ integer :: i, j
+ !$omp target teams distribute parallel do collapse(2)
+ do j = 1, nx_
+ do i = 1, nx_
+ u(i, j) = out(1, (i - 1) * nx_ + j)
+ v(i, j) = out(2, (i - 1) * nx_ + j)
+ end do
+ end do
+ end subroutine
+
+ subroutine surrogate(u, v, feat, out, status)
+ real(real64), intent(inout), target :: u(nx_, nx_), v(nx_, nx_), feat(nfeat, ncell), out(2, ncell)
+ integer, intent(out) :: status
+ integer :: s
+ status = 0
+ do s = 1, nbig
+ call gather(u, v, feat)
+ !$omp target data use_device_addr(feat, out)
+ status = stepper_infer_batch_dev(ncell, c_loc(feat), c_loc(out), c_null_ptr)
+ !$omp end target data
+ if (status /= 0) return
+ status = stepper_sync_dev(c_null_ptr)
+ if (status /= 0) return
+ call scatter(out, u, v)
+ end do
+ end subroutine
+
+ pure real(real64) function hash01(a, b)
+ integer, intent(in) :: a, b
+ hash01 = real(mod((int(a, 8) * 40503_8 + int(b, 8)) * 2654435761_8, 4294967296_8), real64) &
+ / 4294967296.0_real64
+ end function
+
+ subroutine initial_fields(u, v)
+ real(real64), intent(out) :: u(nx_, nx_), v(nx_, nx_)
+ real(real64) :: x, y, su, sv, ph
+ integer :: i, j, m, p, q
+ do j = 1, nx_
+ do i = 1, nx_
+ x = real(i - 1, real64) / nx_; y = real(j - 1, real64) / nx_
+ su = 0.0_real64; sv = 0.0_real64
+ do m = 0, 3
+ p = 1 + int(3 * hash01(7, m)); q = 1 + int(3 * hash01(8, m))
+ ph = 2.0_real64 * pi * (p * x + q * y)
+ su = su + (2.0_real64 * hash01(9, m) - 1.0_real64) * sin(ph + 2.0_real64 * pi * hash01(10, m))
+ sv = sv + (hash01(11, m) - 0.5_real64) * sin(ph + 2.0_real64 * pi * hash01(12, m))
+ end do
+ u(i, j) = 2.0_real64 * tanh(su)
+ v(i, j) = 0.5_real64 * tanh(sv)
+ end do
+ end do
+ end subroutine
+end program
diff --git a/examples/surrogates/reaction_patch/react.c b/examples/surrogates/reaction_patch/react.c
new file mode 100644
index 0000000..c0666a5
--- /dev/null
+++ b/examples/surrogates/reaction_patch/react.c
@@ -0,0 +1,140 @@
+/* 2-D FitzHugh-Nagumo with a learned time-stepper on 3x3 patches. Each big
+ step: gather every cell's patch, one stepper_infer_batch over the field
+ (device pointers), scatter back. The model is file-loaded: stepper_init
+ uploads the weights once, before the loop. Reference: K fine steps per big
+ step. Exits 0 if the surrogate's error after NBIG big steps is under TOL. */
+#include
+#include
+#include
+#include
+#include "stepper.h"
+
+#define NCELL (NX * NX)
+#define DU 1.0
+#define DV 0.05
+#define PA 0.7
+#define PB 0.8
+#define EPS 0.08
+#define DT 0.1
+#define K 10 /* fine steps per surrogate step */
+#define NBIG 100
+#define TOL 0.05
+#define NFEAT 18
+
+static inline int wrap(int i) { return (i + NX) % NX; }
+
+static void fine_step(const double *u, const double *v, double *un, double *vn) {
+#pragma omp target teams distribute parallel for collapse(2)
+ for (int i = 0; i < NX; ++i)
+ for (int j = 0; j < NX; ++j) {
+ const int c = i * NX + j;
+ const int n = wrap(i - 1) * NX + j, s = wrap(i + 1) * NX + j;
+ const int w = i * NX + wrap(j - 1), e = i * NX + wrap(j + 1);
+ const double lu = u[n] + u[s] + u[w] + u[e] - 4.0 * u[c];
+ const double lv = v[n] + v[s] + v[w] + v[e] - 4.0 * v[c];
+ un[c] = u[c] + DT * (DU * lu + u[c] - u[c] * u[c] * u[c] / 3.0 - v[c]);
+ vn[c] = v[c] + DT * (DV * lv + EPS * (u[c] + PA - PB * v[c]));
+ }
+}
+
+/* Patch order as in train.py: u's 3x3 row-major, then v's. */
+static void gather(const double *u, const double *v, double *feat) {
+#pragma omp target teams distribute parallel for collapse(2)
+ for (int i = 0; i < NX; ++i)
+ for (int j = 0; j < NX; ++j) {
+ double *f = feat + (size_t)(i * NX + j) * NFEAT;
+ for (int di = -1; di <= 1; ++di)
+ for (int dj = -1; dj <= 1; ++dj) {
+ const int p = wrap(i + di) * NX + wrap(j + dj);
+ f[(di + 1) * 3 + (dj + 1)] = u[p];
+ f[9 + (di + 1) * 3 + (dj + 1)] = v[p];
+ }
+ }
+}
+
+static void scatter(const double *out, double *u, double *v) {
+#pragma omp target teams distribute parallel for
+ for (int c = 0; c < NCELL; ++c) {
+ u[c] = out[2 * c];
+ v[c] = out[2 * c + 1];
+ }
+}
+
+static int big_step(double *u, double *v, double *feat, double *out) {
+ gather(u, v, feat);
+ int status;
+#pragma omp target data use_device_ptr(feat, out)
+ status = stepper_infer_batch(NCELL, feat, out, 0);
+ if (status) return status;
+ /* A cuda/hip archive launches asynchronously; the OpenMP scatter below runs
+ on another queue. Waits in the cuda/hip archive, no-op in the omp one. */
+ status = stepper_sync(0);
+ if (status) return status;
+ scatter(out, u, v);
+ return 0;
+}
+
+static double hash01(long long a, long long b) {
+ return (double)(((a * 40503LL + b) * 2654435761LL) % 4294967296LL) / 4294967296.0;
+}
+
+/* Four hashed Fourier modes through a tanh, as train.py's fields. */
+static void initial_fields(double *u, double *v) {
+ for (int i = 0; i < NX; ++i)
+ for (int j = 0; j < NX; ++j) {
+ const double x = (double)i / NX, y = (double)j / NX;
+ double su = 0.0, sv = 0.0;
+ for (int m = 0; m < 4; ++m) {
+ const int p = 1 + (int)(3 * hash01(7, m)), q = 1 + (int)(3 * hash01(8, m));
+ const double ph = 2.0 * M_PI * (p * x + q * y);
+ su += (2.0 * hash01(9, m) - 1.0) * sin(ph + 2.0 * M_PI * hash01(10, m));
+ sv += (hash01(11, m) - 0.5) * sin(ph + 2.0 * M_PI * hash01(12, m));
+ }
+ u[i * NX + j] = 2.0 * tanh(su);
+ v[i * NX + j] = 0.5 * tanh(sv);
+ }
+}
+
+int main(void) {
+ const int st = stepper_init("stepper.rwt"); /* the one upload */
+ if (st != 0) { printf("stepper_init failed with status %d\n", st); return 2; }
+
+ double *u = malloc(sizeof(double) * NCELL), *v = malloc(sizeof(double) * NCELL);
+ double *ur = malloc(sizeof(double) * NCELL), *vr = malloc(sizeof(double) * NCELL);
+ double *ut = malloc(sizeof(double) * NCELL), *vt = malloc(sizeof(double) * NCELL);
+ double *feat = malloc(sizeof(double) * NCELL * NFEAT), *out = malloc(sizeof(double) * NCELL * 2);
+ initial_fields(u, v);
+ for (int c = 0; c < NCELL; ++c) { ur[c] = u[c]; vr[c] = v[c]; }
+
+#pragma omp target enter data map(to: ur[0:NCELL], vr[0:NCELL]) map(alloc: ut[0:NCELL], vt[0:NCELL])
+ double t0 = omp_get_wtime();
+ for (int s = 0; s < NBIG * K / 2; ++s) {
+ fine_step(ur, vr, ut, vt);
+ fine_step(ut, vt, ur, vr);
+ }
+ const double t_ref = omp_get_wtime() - t0;
+#pragma omp target exit data map(from: ur[0:NCELL], vr[0:NCELL]) map(delete: ut[0:NCELL], vt[0:NCELL])
+
+#pragma omp target enter data map(to: u[0:NCELL], v[0:NCELL]) map(alloc: feat[0:NCELL * NFEAT], out[0:NCELL * 2])
+ t0 = omp_get_wtime();
+ int status = 0;
+ for (int s = 0; s < NBIG && status == 0; ++s) status = big_step(u, v, feat, out);
+ const double t_nn = omp_get_wtime() - t0;
+#pragma omp target exit data map(from: u[0:NCELL], v[0:NCELL]) map(delete: feat[0:NCELL * NFEAT], out[0:NCELL * 2])
+ if (status) { printf("stepper_infer_batch failed with status %d\n", status); return 3; }
+
+ double e = 0.0, r = 0.0;
+ for (int c = 0; c < NCELL; ++c) {
+ e += (u[c] - ur[c]) * (u[c] - ur[c]) + (v[c] - vr[c]) * (v[c] - vr[c]);
+ r += ur[c] * ur[c] + vr[c] * vr[c];
+ }
+ const double err = sqrt(e / r);
+ printf("%dx%d grid, %d surrogate steps of %d fine steps each:\n", NX, NX, NBIG, K);
+ printf(" fine reference %7.1f ms (%.2f ms per big step)\n", 1e3 * t_ref, 1e3 * t_ref / NBIG);
+ printf(" surrogate %7.1f ms (%.2f ms per big step)\n", 1e3 * t_nn, 1e3 * t_nn / NBIG);
+ printf(" relative L2 error of the surrogate: %.3e\n", err);
+ free(u); free(v); free(ur); free(vr); free(ut); free(vt); free(feat); free(out);
+ if (!(err < TOL)) { printf("FAIL: error above %.2f\n", TOL); return 1; }
+ printf("OK\n");
+ return 0;
+}
diff --git a/examples/surrogates/reaction_patch/stepper.onnx b/examples/surrogates/reaction_patch/stepper.onnx
new file mode 100644
index 0000000..567c40d
Binary files /dev/null and b/examples/surrogates/reaction_patch/stepper.onnx differ
diff --git a/examples/surrogates/reaction_patch/train.py b/examples/surrogates/reaction_patch/train.py
new file mode 100644
index 0000000..f9d6639
--- /dev/null
+++ b/examples/surrogates/reaction_patch/train.py
@@ -0,0 +1,128 @@
+"""Train a learned time-stepper for 2-D FitzHugh-Nagumo; writes stepper.onnx.
+
+ u_t = Du lap(u) + u - u^3/3 - v, v_t = Dv lap(v) + eps (u + a - b v)
+
+periodic, explicit Euler, 5-point Laplacian, step DT (the scheme react.c /
+react.F90 use). The surrogate maps the 3x3 patch of (u, v) around a cell to
+that cell's (u, v) K fine steps later; K DT of diffusion reaches about
+sqrt(2 Du K DT) = 1.4 cells, so a 3x3 patch is enough.
+
+The fit is to the one-step map on every (patch, centre K steps later) pair,
+with Gaussian noise (NOISE) on the input patches. Without the noise the map
+is 1% accurate per step and blows up after about 40 of its own steps; with
+it the rollout tracks the fine solution for 100 steps to 0.2%, the largest
+error (about 8%) in the fast early transient. Unrolled training as in burgers_closure
+was tried and made this map worse.
+"""
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+N = 64 # training grid; the solvers use a bigger one
+DU, DV = 1.0, 0.05
+A, B, EPS = 0.7, 0.8, 0.08
+DT, K = 0.1, 10 # fine step; fine steps per surrogate step
+N_FIELDS, N_BIG = 32, 60 # training fields, big steps each
+NOISE = 0.03
+ITERS = 5000
+N_EVAL = 100 # held-out rollout length, in big steps
+SEED = 11
+
+
+def lap(f):
+ return (np.roll(f, 1, -1) + np.roll(f, -1, -1) + np.roll(f, 1, -2) + np.roll(f, -1, -2) - 4 * f)
+
+
+def fine_step(u, v):
+ un = u + DT * (DU * lap(u) + u - u ** 3 / 3 - v)
+ vn = v + DT * (DV * lap(v) + EPS * (u + A - B * v))
+ return un, vn
+
+
+def initial_fields(rng, n):
+ """Smooth random fields: a few Fourier modes with random phases, then a nonlinearity."""
+ x = np.arange(N) / N
+ kx, ky = np.meshgrid(x, x, indexing="ij")
+ u = np.zeros((n, N, N)); v = np.zeros((n, N, N))
+ for f in range(n):
+ for _ in range(4):
+ p, q = rng.integers(1, 4, 2)
+ u[f] += rng.uniform(-1, 1) * np.sin(2 * np.pi * (p * kx + q * ky) + rng.uniform(0, 2 * np.pi))
+ v[f] += rng.uniform(-0.5, 0.5) * np.sin(2 * np.pi * (p * kx + q * ky) + rng.uniform(0, 2 * np.pi))
+ return 2.0 * np.tanh(u), 0.5 * np.tanh(v)
+
+
+def make_trajectories(rng):
+ """(N_FIELDS, N_BIG + 1, 2, N, N): the fine solution sampled every K steps."""
+ u, v = initial_fields(rng, N_FIELDS)
+ out = [np.stack([u, v], 1)]
+ for _ in range(N_BIG):
+ for _ in range(K):
+ u, v = fine_step(u, v)
+ out.append(np.stack([u, v], 1))
+ return np.stack(out, 1)
+
+
+class Stepper(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.net = nn.Sequential(nn.Linear(18, 128), nn.Tanh(), nn.Linear(128, 128), nn.Tanh(),
+ nn.Linear(128, 2))
+
+ def forward(self, patch):
+ return self.net(patch)
+
+
+def patches(field):
+ """(B, 2, N, N) -> (B, N, N, 18): periodic 3x3 patches, u's 9 values row-major, then v's."""
+ f = F.pad(field, (1, 1, 1, 1), mode="circular")
+ p = f.unfold(2, 3, 1).unfold(3, 3, 1) # (B, 2, N, N, 3, 3)
+ return p.reshape(field.shape[0], 2, N, N, 9).permute(0, 2, 3, 1, 4).reshape(field.shape[0], N, N, 18)
+
+
+def big_step(model, field):
+ """One surrogate step over the whole field: gather, MLP, scatter."""
+ return model(patches(field)).permute(0, 3, 1, 2)
+
+
+def main():
+ rng = np.random.default_rng(SEED)
+ torch.manual_seed(SEED)
+ traj = torch.tensor(make_trajectories(rng), dtype=torch.float32) # (F, N_BIG+1, 2, N, N)
+ model = Stepper()
+
+ X = patches(traj[:, :-1].reshape(-1, 2, N, N)).reshape(-1, 18)
+ Y = traj[:, 1:].reshape(-1, 2, N, N).permute(0, 2, 3, 1).reshape(-1, 2)
+ opt = torch.optim.Adam(model.parameters(), lr=2e-3)
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, ITERS)
+ for it in range(ITERS):
+ idx = torch.randint(0, X.shape[0], (8192,))
+ loss = ((model(X[idx] + NOISE * torch.randn(len(idx), 18)) - Y[idx]) ** 2).mean()
+ opt.zero_grad()
+ loss.backward()
+ opt.step()
+ sched.step()
+ if it % 1000 == 0:
+ print(f"iter {it:4d} one-step mse (noisy inputs) {loss.item():.3e}")
+ model.eval()
+ with torch.no_grad():
+ rng2 = np.random.default_rng(SEED + 1)
+ u, v = initial_fields(rng2, 4)
+ state = torch.tensor(np.stack([u, v], 1), dtype=torch.float32)
+ uf, vf = u, v
+ for s in range(1, N_EVAL + 1):
+ state = big_step(model, state)
+ for _ in range(K):
+ uf, vf = fine_step(uf, vf)
+ if s in (1, 5, 20, 60, N_EVAL):
+ ref = np.stack([uf, vf], 1)
+ err = np.sqrt(((state.numpy() - ref) ** 2).sum() / (ref ** 2).sum())
+ print(f"held-out fields, {s:3d} surrogate steps: relative L2 error {err:.3e}")
+ torch.onnx.export(model, torch.zeros(1, 18), "stepper.onnx", input_names=["patch"],
+ output_names=["uv_next"], opset_version=13, dynamo=False)
+ print("wrote stepper.onnx")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/surrogates/run_all.sh b/examples/surrogates/run_all.sh
new file mode 100755
index 0000000..2d2d293
--- /dev/null
+++ b/examples/surrogates/run_all.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Build and run every example with one toolchain: ./run_all.sh [amd|nvidia|gnu]
+set -euo pipefail
+cd "$(dirname "$0")"
+tc=${1:-gnu}
+extra=()
+[ "$tc" = gnu ] && extra=(NB=4 NX=64)
+for d in burgers_closure reaction_patch bubble_lstm poisson_guess; do
+ echo "== $d ($tc)"
+ make -s -C "$d" TOOLCHAIN="$tc" "${extra[@]}"
+done
+echo "all four passed"
diff --git a/fLibrary/Makefile b/fLibrary/Makefile
deleted file mode 100644
index bea009a..0000000
--- a/fLibrary/Makefile
+++ /dev/null
@@ -1,25 +0,0 @@
-FC=gfortran
-FFLAGS=-O2 -JobjFiles
-SRC1=modelCreator.fpp rosenna.f90
-SRCBASE=activation_funcs.f90 derived_types.f90 layers.f90 reader.f90
-OBJ1=${SRC1:.fpp=.o}
-OBJ2=${OBJ1:.f90=.o}
-COMP=${SRCBASE:.f90=.o}
-
-output: $(COMP) $(OBJ2)
-
-.PRECIOUS : %.f90
-%.f90: %.fpp variables.fpp
- fypp $< $*.f90
-
-%.o: %.f90
- $(FC) $(FFLAGS) -c $< -o $@
-
-library: output
- mkdir -p objFiles
- ar crv libcorelib.a $(COMP) $(OBJ2)
-
-clean:
- rm -f *.o objFiles/*.mod
- rm -f modelCreator.f90 variables.fpp
- rm -f libcorelib.a
diff --git a/fLibrary/activation_funcs.f90 b/fLibrary/activation_funcs.f90
deleted file mode 100644
index fff6acd..0000000
--- a/fLibrary/activation_funcs.f90
+++ /dev/null
@@ -1,66 +0,0 @@
-module activation_functions
- use iso_c_binding
- implicit none
-
-contains
-
- FUNCTION sigmoid(x) result(output)
- REAL (c_double), intent(in) :: x(:)
- REAL (c_double) :: output(size(x))
-
- output = 1 / (1 + exp(-1 * x))
- END FUNCTION sigmoid
-
- FUNCTION sigmoid2d(x) result(output)
- REAL (c_double), intent(in) :: x(:,:)
- REAL (c_double) :: output(size(x,1), size(x,2))
-
- output = 1 / (1 + exp(-1 * x))
- END FUNCTION sigmoid2d
-
- FUNCTION relu(x) result(output)
- REAL (c_double), intent(in) :: x(:)
- REAL (c_double) :: output(size(x))
-
- where (x < 0)
- output = 0
- elsewhere
- output = x
- end where
- END FUNCTION relu
-
- FUNCTION relu2d(x) result(output)
- REAL (c_double), intent(in) :: x(:,:)
- REAL (c_double) :: output(size(x,1), size(x,2))
-
- where (x < 0)
- output = 0
- elsewhere
- output = x
- end where
- END FUNCTION relu2d
-
- FUNCTION relu4d(x) result(output)
- REAL (c_double), intent(in) :: x(:,:,:,:)
- REAL (c_double) :: output(size(x,1), size(x,2), size(x,3), size(x,4))
-
- where (x < 0)
- output = 0
- elsewhere
- output = x
- end where
- END FUNCTION relu4d
-
- FUNCTION tanhh(x) result(output)
- REAL (c_double), intent(in) :: x(:)
- REAL (c_double) :: output(size(x))
- output = tanh(x)
- END FUNCTION tanhh
-
- FUNCTION tanhh2d(x) result(output)
- REAL (c_double), intent(in) :: x(:,:)
- REAL (c_double) :: output(size(x,1),size(x,2))
- output = tanh(x)
- END FUNCTION tanhh2d
-
-end module activation_functions
\ No newline at end of file
diff --git a/fLibrary/derived_types.f90 b/fLibrary/derived_types.f90
deleted file mode 100644
index efd8bd7..0000000
--- a/fLibrary/derived_types.f90
+++ /dev/null
@@ -1,53 +0,0 @@
-module derived_types
- use iso_c_binding
- use activation_functions
-
- implicit none
-
- abstract interface
- function func (z) result(output)
- REAL, intent(in) :: z(:,:)
- real :: output(size(z,1), size(z,2))
- end function func
- end interface
-
- TYPE linLayer
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: weights
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: biases
- ENDTYPE linLayer
-
- TYPE lstmLayer
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: whh
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: wih
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: bhh
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: bih
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: hid
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: cell
- ENDTYPE lstmLayer
-
- TYPE convLayer
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: weights
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: biases
- !==stride
- ENDTYPE convLayer
-
- TYPE maxpoolLayer
- INTEGER :: kernel_size
- ENDTYPE maxpoolLayer
-
- TYPE avgpoolLayer
- INTEGER :: kernel_size
- ENDTYPE avgpoolLayer
-
- TYPE addLayer
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: adder
- ENDTYPE addLayer
-
- TYPE reshapeLayer
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: reshape2d
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: reshape3d
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: reshape4d
- ENDTYPE
-
-
-end module derived_types
\ No newline at end of file
diff --git a/fLibrary/layers.f90 b/fLibrary/layers.f90
deleted file mode 100644
index c600cbb..0000000
--- a/fLibrary/layers.f90
+++ /dev/null
@@ -1,297 +0,0 @@
-module model_layers
-
- USE activation_functions
- USE derived_types
- use iso_c_binding
-
- implicit none
-
-contains
- !=== for Gemm operations ======
- subroutine linear_layer(inp, lin, transInp)
- IMPLICIT NONE
- INTEGER, INTENT(IN) :: transInp
- REAL (c_double), ALLOCATABLE, intent(inout) :: inp(:,:) !===input is 2d usually (k,n), where n is usually 1
- TYPE(linLayer), INTENT(IN) :: lin !===stores the weights (m,k) and biases (m,1)
- REAL (c_double), ALLOCATABLE :: bias_broadcast(:,:)
-
- if (transInp == 0) THEN
- !== weights are (out,in): Y = X * W^T + b
- bias_broadcast = SPREAD(lin%biases, 1, size(inp,1))
- inp = matmul(inp, TRANSPOSE(lin%weights)) + bias_broadcast
- ELSE
- !== weights are (in,out): Y = X * W + b
- bias_broadcast = SPREAD(lin%biases, 1, size(inp,1))
- inp = matmul(inp, lin%weights) + bias_broadcast
- END IF
- end subroutine
-
- subroutine matmul2D(inp1, inp2)
- IMPLICIT NONE
- REAL (c_double), allocatable, intent(inout), dimension(:,:) :: inp1
- REAL (c_double), intent(in), dimension(:,:) :: inp2
-
- inp1 = matmul(inp1,inp2)
- end subroutine
-
- subroutine matmul3D(inp1, inp2)
- IMPLICIT NONE
- REAL (c_double), allocatable, intent(inout), dimension(:,:,:) :: inp1
- REAL (c_double), intent(in), dimension(:,:,:) :: inp2
- REAL (c_double), dimension(size(inp1,1),size(inp1,2),size(inp2,3)) :: out
- integer :: i
-
- DO i=1, size(inp1,1)
- out(i,:,:) = MATMUL(inp1(i,:,:),inp2(i,:,:))
- END DO
- inp1 = out
- end subroutine
-
- subroutine matmul4D(inp1, inp2)
- IMPLICIT NONE
- REAL (c_double), allocatable, intent(inout), dimension(:,:,:,:) :: inp1
- REAL (c_double), intent(in), dimension(:,:,:,:) :: inp2
- REAL (c_double), dimension(size(inp1,1),size(inp1,2),size(inp1,3),size(inp2,4)) :: out
- integer :: i
- integer :: j
-
- DO i=1, size(inp1,1)
- DO j=1,size(inp1,2)
- out(i,j,:,:) = MATMUL(inp1(i,j,:,:),inp2(i,j,:,:))
- END DO
- END DO
- inp1 = out
- end subroutine
-
- subroutine lstm_cell(input, hid1, cell1, Whh, Wih, Bih, Bhh)
- implicit none
- REAL (c_double), intent(in), DIMENSION(:,:) :: input !== (n,batch_size)
- REAL (c_double), intent(inout), ALLOCATABLE, DIMENSION(:,:) :: hid1 !==(m,batch_size)
- REAL (c_double), intent(inout), ALLOCATABLE, DIMENSION(:,:) :: cell1 !==(m,batch_size)
- REAL (c_double), intent(in), DIMENSION(:,:) :: Whh !==(4m,m)
- REAL (c_double), intent(in), DIMENSION(:,:) :: Wih !==(4m,n)
- REAL (c_double), intent(in), DIMENSION(:) :: Bhh !==(4m,1)
- REAL (c_double), intent(in), DIMENSION(:) :: Bih !==(4m,1)
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: hiddenOut !==(m,batch_size)
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: cellOut
-
-
- REAL (c_double), DIMENSION(size(Bhh, 1), size(input,2)) :: Bhh_broadcast !== (4m, batch_size)
- REAL (c_double), DIMENSION(size(Bhh, 1), size(input,2)) :: Bih_broadcast
- REAL (c_double), DIMENSION(size(Whh, dim=1), size(input,2)) :: gates_out
- REAL (c_double), DIMENSION(size(hid1,1),4,size(input,2)) :: chunks
- ALLOCATE(hiddenOut(size(hid1),size(input,2)))
- ALLOCATE(cellOut(size(cell1),size(input,2)))
-
- !======= applying bhh and bih to each column of input =====
- Bhh_broadcast = SPREAD(Bhh, 2, size(input,2))
- Bih_broadcast = SPREAD(Bih, 2, size(input,2))
-
- gates_out = MATMUL(Wih, input) + Bih_broadcast + MATMUL(Whh, hid1) + Bhh_broadcast !==(4m,batch_size) BROADCAST BIAS
-
- chunks = RESHAPE(gates_out, (/size(gates_out,1)/4, 4, size(input,2)/))
-
- chunks(:, 1, :) = sigmoid2d(chunks(:, 1, :))
- chunks(:, 2, :) = sigmoid2d(chunks(:, 2, :))
- chunks(:, 3, :) = tanhh2d(chunks(:, 3, :))
- chunks(:, 4, :) = sigmoid2d(chunks(:, 4, :))
-
- cellOut = (chunks(:, 2, :) * cell1) + (chunks(:, 1, :) * chunks(:, 3, :))
- hiddenOut = chunks(:, 4, :) * tanhh2d(cellOut)
- hid1 = hiddenOut
- cell1 = cellOut
- end subroutine
-
- subroutine lstm(input, hid1, cell1, Whh, Wih, Bih, Bhh, output)
- implicit none
- REAL (c_double), intent(inout), ALLOCATABLE, DIMENSION(:,:,:) :: input !== (timesteps,batch_size,n)
- REAL (c_double), intent(inout), ALLOCATABLE, DIMENSION(:,:,:) :: hid1 !==(num_directions,batch_size,m), add another dim for num dir
- REAL (c_double), intent(inout), ALLOCATABLE, DIMENSION(:,:,:) :: cell1 !==(num_directions,batch_size,m), add another dim for num dir
- REAL (c_double), intent(in), ALLOCATABLE, DIMENSION(:,:,:) :: Whh !==(num_directions,4m,m), add another dim for num dir
- REAL (c_double), intent(in), ALLOCATABLE, DIMENSION(:,:,:) :: Wih !==(num_directions,4m,n), add another dim for num dir
- REAL (c_double), intent(in), ALLOCATABLE, DIMENSION(:) :: Bhh !==(4m,1)
- REAL (c_double), intent(in), ALLOCATABLE, DIMENSION(:) :: Bih !==(4m,1)
- REAL (c_double), INTENT(OUT), ALLOCATABLE, DIMENSION(:,:,:,:) :: output !==(timesteps,num_directions,m,batch_size)
- INTEGER :: timesteps
- INTEGER :: i
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: hid1changed
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: cell1changed
- timesteps = size(input,1)
- input = reshape(input, (/SIZE(input,dim=1),SIZE(input,dim=3), SIZE(input,dim=2)/), order = [1,3,2])
- hid1 = reshape(hid1, (/SIZE(hid1,dim=1),SIZE(hid1,dim=3), SIZE(hid1,dim=2)/), order = [1,3,2])
- cell1 = reshape(cell1, (/SIZE(cell1,dim=1),SIZE(cell1,dim=3), SIZE(cell1,dim=2)/), order = [1,3,2])
- ALLOCATE(output(timesteps,size(hid1,dim=1),size(hid1,dim=2),size(hid1,dim=3)))
- hid1changed = hid1(1,:,:)
- cell1changed = cell1(1,:,:)
- DO i=1, timesteps
- CALL lstm_cell(input(i,:,:), hid1changed, cell1changed, Whh(1,:,:), Wih(1,:,:), Bih, Bhh)
- hid1(1,:,:) = hid1changed
- cell1(1,:,:) = cell1changed
- output(i,:,:,:) = hid1
- END DO
- hid1 = reshape(hid1, (/SIZE(hid1,dim=1),SIZE(hid1,dim=3), SIZE(hid1,dim=2)/), order = [1,3,2]) !==reshaped to (num_directions,batch_size,m)
- cell1 = reshape(cell1, (/SIZE(cell1,dim=1),SIZE(cell1,dim=3), SIZE(cell1,dim=2)/), order = [1,3,2]) !==(num_directions,batch_size,m)
- output = reshape(output,(/SIZE(output,dim=1),SIZE(output,dim=2), SIZE(output,dim=4),SIZE(output,dim=3)/), order = [1,2,4,3]) !==(timesteps,num_directions,batch_size,m)
- end subroutine
-
- function padding(arr, input) result(formatted)
- implicit none
- integer, dimension(:), intent(in) :: arr
- REAL (c_double), dimension(:,:,:,:), intent(in) :: input
- REAL (c_double), dimension(size(input,1),size(input,2), &
- size(input,3)+2*arr(1),size(input,4)+2*arr(2)) :: formatted
-
- formatted = 0.0d0
- formatted(:, :, arr(1)+1 : arr(1)+size(input,3), &
- arr(2)+1 : arr(2)+size(input,4)) = input
- end function padding
-
- subroutine conv(inp, convWeights, bias, dilations, pads, strides)
- implicit none
- REAL (c_double), INTENT(INOUT), ALLOCATABLE, DIMENSION(:,:,:,:) :: inp !==(batches,numImages,imageD1,imageD2)
- REAL (c_double), INTENT(IN), ALLOCATABLE, DIMENSION(:,:,:,:) :: convWeights !==(numConvRows,numConvCols,ConvRowDim,ConvColDim)
- REAL (c_double), INTENT(IN), ALLOCATABLE, DIMENSION(:) :: bias
- INTEGER, INTENT(IN), DIMENSION(:) :: dilations
- INTEGER, INTENT(IN), DIMENSION(:) :: pads
- INTEGER, INTENT(IN), DIMENSION(:) :: strides
- INTEGER :: in_channels !==numImages SHOULD BE INTENT(IN)
- INTEGER :: out_channels !==numConvCols SHOULD BE INTENT(IN)
- INTEGER :: kernel_size !==(ConvRowDim,ConvColDim) SHOULD BE INTENT(IN)
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: out
-
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: padded
-
- INTEGER :: outer
- INTEGER :: overImage
- INTEGER :: inner
- INTEGER :: outRowDim
- INTEGER :: outColDim
- INTEGER :: numBatches
- INTEGER :: itBatches
- REAL (c_double) :: sumini
- numBatches = size(inp,1)
- in_channels = SIZE(inp, dim=2)
- out_channels = SIZE(convWeights, dim=1)
- kernel_size = SIZE(convWeights, dim=3)
- allocate(padded, source=padding(pads, inp))
- ALLOCATE(out(numBatches,out_channels, (size(padded,dim=3)-kernel_size)/strides(1) + 1, &
- (size(padded,dim=4)-kernel_size)/strides(2)+1))
- outRowDim = size(out,4)
- outColDim = size(out,3)
-
- DO itBatches = 1, numBatches
- DO outer = 0, out_channels-1 !==iterating through each output image
- DO overImage = 0, (outRowDim*outColDim)-1 !==iterating kernel through the whole image
- sumini = 0
- DO inner = 0, in_channels-1 !==applying kernel to each input image
- sumini = sumini + SUM(padded(itBatches,inner+1, &
- (1 + (overImage/outRowDim)*strides(1)):((overImage/outRowDim)*strides(1)+kernel_size) &
- ,(1 + MODULO(overImage,outRowDim)*strides(2)):(MODULO(overImage,outRowDim)*strides(2)+kernel_size)) &
- * convWeights(outer+1,inner+1,:,:))
- END DO
- out(itBatches,outer+1,overImage/outRowDim + 1,MODULO(overImage,outRowDim)+1) = sumini + bias(outer+1)
- END DO
- END DO
- END DO
- inp = out
- DEALLOCATE(out)
-
- END subroutine
-
-
- subroutine max_pool(inp, maxpool, ceil_mode, pads, strides)
- implicit none
- REAL (c_double), INTENT(INOUT), ALLOCATABLE, DIMENSION(:,:,:,:) :: inp !==(batches,numImages,imageD1,imageD2)
- TYPE(maxpoolLayer), INTENT(IN) :: maxpool
- INTEGER, INTENT(IN) :: ceil_mode
- INTEGER, INTENT(IN), DIMENSION(:) :: pads
- INTEGER, INTENT(IN), DIMENSION(:) :: strides
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: out
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: padded
- INTEGER :: kernel_size, b, ch, orow, ocol, outRows, outCols, r0, c0
-
- allocate(padded, source=padding(pads, inp))
- kernel_size = maxpool%kernel_size
- outRows = (size(padded,3) - kernel_size)/strides(1) + 1
- outCols = (size(padded,4) - kernel_size)/strides(2) + 1
- ALLOCATE(out(size(padded,1), size(padded,2), outRows, outCols))
-
- DO b = 1, size(padded,1)
- DO ch = 1, size(padded,2)
- DO orow = 1, outRows
- r0 = (orow-1)*strides(1)
- DO ocol = 1, outCols
- c0 = (ocol-1)*strides(2)
- out(b,ch,orow,ocol) = MAXVAL(padded(b, ch, &
- r0+1 : r0+kernel_size, &
- c0+1 : c0+kernel_size))
- END DO
- END DO
- END DO
- END DO
- inp = out
- end subroutine
-
- subroutine avgpool(inp, avgpoolLay, ceil_mode, pads, strides)
- implicit none
- REAL (c_double), INTENT(INOUT), ALLOCATABLE, DIMENSION(:,:,:,:) :: inp
- TYPE(avgpoolLayer), INTENT(IN) :: avgpoolLay
- INTEGER, INTENT(IN) :: ceil_mode
- INTEGER, INTENT(IN), DIMENSION(:) :: pads
- INTEGER, INTENT(IN), DIMENSION(:) :: strides
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: out
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: padded
- INTEGER :: kernel_size, b, ch, orow, ocol, outRows, outCols, r0, c0
- REAL (c_double) :: total
-
- allocate(padded, source=padding(pads, inp))
- kernel_size = avgpoolLay%kernel_size
- total = REAL(kernel_size * kernel_size, c_double)
- outRows = (size(padded,3) - kernel_size)/strides(1) + 1
- outCols = (size(padded,4) - kernel_size)/strides(2) + 1
- ALLOCATE(out(size(padded,1), size(padded,2), outRows, outCols))
-
- DO b = 1, size(padded,1)
- DO ch = 1, size(padded,2)
- DO orow = 1, outRows
- r0 = (orow-1)*strides(1)
- DO ocol = 1, outCols
- c0 = (ocol-1)*strides(2)
- out(b,ch,orow,ocol) = SUM(padded(b, ch, &
- r0+1 : r0+kernel_size, &
- c0+1 : c0+kernel_size)) / total
- END DO
- END DO
- END DO
- END DO
- inp = out
- end subroutine
-
- function broadc(inp, trueShape, spreadInfo) result(out)
- implicit none
- REAL (c_double), dimension(:,:,:,:), intent(in) :: inp
- integer, dimension(4) :: trueShape
- integer, dimension(:,:), intent(in) :: spreadInfo
- REAL (c_double), ALLOCATABLE, dimension(:,:,:,:) :: out
-
- INTEGER, DIMENSION(4) :: true
- INTEGER, ALLOCATABLE, DIMENSION(:) :: inter
- INTEGER, DIMENSION(4) :: changing
- REAL (c_double), ALLOCATABLE, dimension(:,:,:,:) :: intermediate
-
- INTEGER :: i
- intermediate = inp
- inter = SHAPE(inp)
- DO i=1, size(spreadInfo,1)
- if (i .ne. size(spreadInfo,1)) then
- changing = [trueShape(:spreadInfo(i,1)),inter((spreadInfo(i,1)+1):)]
- intermediate = RESHAPE(SPREAD(intermediate,spreadInfo(i,1),spreadInfo(i,2)), changing)
- else
- intermediate = RESHAPE(SPREAD(intermediate,spreadInfo(i,1),spreadInfo(i,2)), trueShape)
- end if
- END DO
- out = intermediate
- end function
-
-
-end module model_layers
diff --git a/fLibrary/modelCreator.fpp b/fLibrary/modelCreator.fpp
deleted file mode 100644
index 4256a21..0000000
--- a/fLibrary/modelCreator.fpp
+++ /dev/null
@@ -1,177 +0,0 @@
-module model
- !---------------
- ! adding any number of layers to our neural network
- !----------------
-
-
- ! ===============================================================
- USE activation_functions ! 1}#, #{endif}##{endfor}#/), order = [#{for x in range(tup[1][1],0,-1)}#${x}$#{if x > 1}#, #{endif}##{endfor}#])
- ${tup[2][0]}$ = RESHAPE(${tup[1][0]}$,(/#{for index, num in enumerate(tup[3][0])}#${num}$#{if index < (len(tup[3][0])-1)}#, #{endif}##{endfor}#/), order = [#{for x in range(len(tup[3][0]),0,-1)}#${x}$#{if x > 1}#, #{endif}##{endfor}#])
- #: else
- reshapeLayers(${layer_dict[tup[0]]}$)%reshape${tup[1][1]}$d = RESHAPE(reshapeLayers(${layer_dict[tup[0]]}$)%reshape${tup[1][1]}$d,(/#{for num in range(tup[1][1],0,-1)}#SIZE(reshapeLayers(${layer_dict[tup[0]]}$)%reshape${tup[1][1]}$d, dim = ${num}$)#{if num > 1}#, #{endif}##{endfor}#/), order = [#{for x in range(tup[1][1],0,-1)}#${x}$#{if x > 1}#, #{endif}##{endfor}#])
- ${tup[2][0]}$ = RESHAPE(reshapeLayers(${layer_dict[tup[0]]}$)%reshape${tup[1][1]}$d,(/#{for index, num in enumerate(tup[3][0])}#${num}$#{if index < (len(tup[3][0])-1)}#, #{endif}##{endfor}#/), order = [#{for x in range(len(tup[3][0]),0,-1)}#${x}$#{if x > 1}#, #{endif}##{endfor}#])
- #: endif
-
- #!Squeeze
- #: elif tup[0] == 'Squeeze'
- !========Squeeze============
- ${tup[2][0]}$ = RESHAPE(${tup[1][0]}$,(/#{for num in range(tup[1][1])}##{if num not in tup[3][0]}#SIZE(${tup[1][0]}$, dim = ${num+1}$)#{if num < (tup[1][1]-1)}#, #{endif}##{endif}##{endfor}#/))
-
- #!Add
- #: elif tup[0] == 'Add'
- !===========Add============
- #: if len(tup[2][1]) == 0
- ${tup[1][0]}$ = ${tup[1][0]}$ + RESHAPE(addLayers(${layer_dict[tup[0]]}$)%adder, ${genArray(tup[2][0][-tup[2][2]:])}$)
- #: else
- ${tup[1][0]}$ = ${tup[1][0]}$ + RESHAPE(broadc(addLayers(${layer_dict[tup[0]]}$)%adder,${genArray(tup[2][0])}$,RESHAPE(${genArray(tup[2][1])}$,${genArray([int(len(tup[2][1])/2),2])}$, order=[2,1])), ${genArray(tup[2][0][-tup[2][2]:])}$)
- #: endif
-
- #!MatMul
- #: elif tup[0] == 'MatMul'
- !=======MatMul=========
- CALL matmul${tup[2][0]}$D(${tup[1][0]}$, ${tup[1][1]}$)
-
- #!ReLu
- #: elif tup[0] == 'Relu'
- ${tup[1][0]}$ = relu${tup[2][0]}$d(${tup[1][0]}$)
-
- #!Tanh
- #: elif tup[0] == 'Tanh'
- ${tup[1][0]}$ = tanhh${tup[2][0]}$d(${tup[1][0]}$)
-
- #!Sigmoid
- #: elif tup[0] == 'Sigmoid'
- ${tup[1][0]}$ = sigmoid${tup[2][0]}$d(${tup[1][0]}$)
-
- #: endif
- #: mute
- $: layer_dict.update([(tup[0],layer_dict[tup[0]]+1)])
- #: endmute
- #: endfor
- call CPU_TIME(T2)
- #:for index,out in enumerate(outputs)
- o${index}$ = ${outputs[out]}$
- #:endfor
- end SUBROUTINE
- !===================================================================================================================================================
-
-
-END module model
-
-!=======================================================================================
diff --git a/fLibrary/modelParserONNX.py b/fLibrary/modelParserONNX.py
deleted file mode 100644
index 4cd1164..0000000
--- a/fLibrary/modelParserONNX.py
+++ /dev/null
@@ -1,475 +0,0 @@
-import torch
-import torch.nn as nn
-import numpy as np
-import torch.onnx
-import onnx
-from onnx import numpy_helper
-import argparse
-import sys
-
-from onnx_helpers import (
- stranspose, reshapeParser,
- fourDTransform, fakeFourD, spreadInfo,
- regateLSTM, sanitize,
- checkSupported, checkPadIsNoop,
- checkLSTMSupported, checkGemmBias,
-)
-
-parser = argparse.ArgumentParser()
-
-parser.add_argument('--onnxfile',"-f", required=True, help="Please provide .onnx file of your pretrained model.")
-parser.add_argument('--weights',"-w", help="(Deprecated, ignored) A second unoptimized .onnx export is no longer needed; weights are read by name from the file given to -f.")
-parser.add_argument('--inferred',"-i", help="(Optional) Please provide .onnx file that has inferred shapes")
-
-
-args = parser.parse_args()
-
-file = args.onnxfile
-weights = args.weights
-inferred = args.inferred
-
-
-onnxModel = onnx.load(file)
-
-if weights is not None:
- print("note: --weights/-w is no longer needed and is ignored; "
- "weights are now read by name from the structure file.")
-
-# large models may supply precomputed inferred shapes in an external file
-try:
- inferred = onnx.load(inferred)
- value_info = inferred.graph.value_info
-except (TypeError, FileNotFoundError, onnx.checker.ValidationError):
- value_info = onnx.shape_inference.infer_shapes(onnxModel).graph.value_info
-
-nodes = onnxModel.graph.node #all layers of model that will be parsed
-
-ioMap = {} #mapping input names to output names
-initializer = {} #holds (weight dimensions, np.array of weights)
-intermediateShapes = {} #holds intermediate shapes of layers
-inputs = [] #general inputs to the model (including intermediary stuff needed for fortran to process)
-input_shapes = {} #shapes of inputs
-constants = {} #constants and initalizer weights are the places where weights of the model could be stored
-for inp in onnxModel.graph.input:
- ioMap[inp.name] = sanitize(inp.name)
- input_shapes[inp.name] = [d.dim_value for d in inp.type.tensor_type.shape.dim]
-
-for inter in value_info:
- intermediateShapes[inter.name] = [d.dim_value for d in inter.type.tensor_type.shape.dim]
-
-for weights in onnxModel.graph.initializer:
- initializer[weights.name] = (weights.dims,numpy_helper.to_array(weights))
-out = {}
-for x in onnxModel.graph.output:
- out[x.name] = [d.dim_value if d.dim_value!=0 else 1 for d in x.type.tensor_type.shape.dim]
-
-for x in onnxModel.graph.node:
- if x.op_type == "Constant":
- constants[x.output[0]] = numpy_helper.to_array(x.attribute[0].t)
-
-outputs = {} #what outputs corresponds to, need to export
-outShape = [] #what shape to instantiate the output name to, need to export
-modelArch = [] #need to export
-extra = "0"
-
-
-def findWeightsInitializer(input_name):
- if input_name in initializer:
- return initializer[input_name][1]
- if input_name in constants:
- return constants[input_name]
- raise KeyError(
- f"no weights found for '{input_name}'; it is neither an initializer "
- f"nor a Constant node output"
- )
-
-
-# onnxModel.txt: model structure
-# onnxWeights.bin: weights, float64, column-major, raw stream
-# ioMap: output name -> input name
-# initializer: weight dims
-# modelArch: (layer, inputs, params) consumed by fypp
-print("starting to write weights..")
-print("starting parsing...")
-for node in nodes:
- print(node.op_type)
-with open('onnxModel.txt','w') as f, open('onnxWeights.bin', 'wb') as f2:
- f.write(str(len(nodes)))
- f.write("\n")
- for node in nodes:
- layer = node.op_type
-
- for index,x in enumerate(node.output):
- if x in out:
- outShape.append([sanitize(x),out[x]])
- if layer == "Transpose": #for this, make sure order is set to tuple[2] and shape is set accordingly
- f.write(layer)
- f.write("\n")
- names = {n.name:n.i if n.type==2 else n.ints for n in node.attribute}
- try:
- default = [x for x in range(len(intermediateShapes[node.input[0]])-1,-1,-1)]
- except KeyError:
- default = [x for x in range(len(input_shapes[node.input[0]])-1,-1,-1)]
- attributes = names.get('perm', default)
- # no perm: reverse all dims
- modelArch.append(("Transpose",[ioMap[node.input[0]]], [list(map(lambda x: x+1,attributes))])) #"order"
-
- ioMap[node.output[0]] = ioMap[node.input[0]]
-
- elif layer == "LSTM": #changes shape
- # reject unimplemented attributes; absent ones take ONNX defaults
- lstmAttrs = {}
- for attr in node.attribute:
- if attr.name == "direction":
- lstmAttrs["direction"] = attr.s.decode("ASCII")
- elif attr.name == "activations":
- lstmAttrs["activations"] = [a.decode("ASCII") for a in attr.strings]
- elif attr.name == "clip":
- lstmAttrs["clip"] = attr.f
- elif attr.name in ("input_forget", "layout"):
- lstmAttrs[attr.name] = attr.i
- checkLSTMSupported(lstmAttrs)
- f.write(layer)
- f.write("\n")
- writeHCs = False
- try:
- modelArch.append(("LSTM", [ioMap[node.input[0]], ioMap[node.input[5]], ioMap[node.input[6]]], ["output"+extra], [0])) #input = ["input", "hidden_state", "cell_state"]
- f.write("0")
- f.write("\n")
- except (KeyError, IndexError):
- modelArch.append(("LSTM", [ioMap[node.input[0]], "output"+str(int(extra)+1),"output"+str(int(extra)+2)], ["output"+extra], [1])) #input = ["input", "hidden_state", "cell_state"]
- writeHCs = True
- f.write("1")
- f.write("\n")
- inputs.append(["output"+extra,len(intermediateShapes[node.output[0]])])
- for inp in node.input[1:3]: #represents ONNX's locations of weights
- for dim in initializer[inp][0]:
- f.write(str(dim)+" ")
- f2.write(np.asarray(regateLSTM(findWeightsInitializer(inp), axis=1), dtype='= 3:
- # read_linear expects a rank-1 bias
- checkGemmBias(np.shape(findWeightsInitializer(node.input[2])))
- f.write(layer)
- f.write("\n")
- names = {n.name:n.i if n.type==2 else n.ints for n in node.attribute}
- if names.get('transA', 0):
- raise NotImplementedError(
- "Gemm transA=1 is not supported by roseNNa")
- for attr in node.attribute:
- if attr.name in ('alpha', 'beta') and abs(attr.f - 1.0) > 1e-12:
- raise NotImplementedError(
- f"Gemm {attr.name}={attr.f} is not supported by roseNNa "
- f"(only 1.0)")
- attributes = names.get('transB', 0)
- modelArch.append(("Gemm", [ioMap[node.input[0]],attributes], None))
- numzs = 0
- # no bias: write zeros
- if len(node.input) < 3:
- for inp in node.input[1:]:
- numzs = initializer[inp][0][0]
- for dim in initializer[inp][0]:
- f.write(str(dim)+ " ")
- f2.write(np.asarray(findWeightsInitializer(inp), dtype=' 1:
- axes = findWeightsInitializer(node.input[-1]).tolist()
- else:
- for attr in node.attribute:
- if attr.name == "axes":
- axes = list(attr.ints)
- break
- if axes is None:
- # ONNX default: squeeze every axis of extent 1
- axes = [i for i, d in enumerate(intermediateShapes[node.input[0]]) if d == 1]
- axes = [a if a >= 0 else a + rank for a in axes]
- modelArch.append(("Squeeze", (ioMap[node.input[0]], rank),
- ["output" + extra], [axes]))
- inputs.append(["output"+extra, len(intermediateShapes[node.output[0]])])
- ioMap[node.output[0]] = "output" + extra
- extra = str(int(extra)+1)
-
-
- elif layer == "Reshape": #changes shape
- f.write(layer)
- f.write("\n")
- try:
- modelArch.append(("Reshape", (ioMap[node.input[0]], len(intermediateShapes[node.input[0]])),["output" + extra], [reshapeParser(findWeightsInitializer(node.input[-1]).tolist(), intermediateShapes[node.input[0]])],[0])) #new shape
- f.write("0")
- f.write("\n")
- except KeyError:
- modelArch.append(("Reshape", (ioMap[node.input[0]], len(initializer[node.input[0]][0])),["output" + extra], [reshapeParser(findWeightsInitializer(node.input[-1]).tolist(), initializer[node.input[0]][0])], [1])) #new shape
- f.write(str(len(initializer[node.input[0]][0])))
- f.write("\n")
- for dim in initializer[node.input[0]][0]:
- f.write(str(dim)+ " ")
- f.write("\n")
- f2.write(np.asarray(findWeightsInitializer(node.input[0]), dtype=' 1 rejected by checkSupported
- kernel_shape = attributes['kernel_shape'][0]
- pad_total = kernel_shape - 1
- pad = int(pad_total/2)
- if pad_total % 2 != 0:
- if attributes['auto_pad'] == "SAME_UPPER":
- attributes['pads'] = [pad,pad,pad+1,pad+1]
- else:
- attributes['pads'] = [pad+1,pad+1,pad,pad]
- else:
- attributes['pads'] = [pad]*4
- attributes.setdefault('pads', [0, 0, 0, 0])
- attributes.setdefault('strides', [1, 1])
- attributes.setdefault('dilations', [1, 1])
- checkSupported("Conv", attributes)
- names = {n.name:n.i if n.type==2 else n.ints for n in node.attribute}
- modelArch.append(("Conv", [ioMap[node.input[0]]], [names.get('dilations', [1,1]), attributes['kernel_shape'], attributes['pads'], names.get('strides', [1,1])])) #(dilations, kernel_shape, pads, strides)
-
- if len(node.input) < 3: #if bias does not exist, default = 0s
- numzs = 0
- for inp in node.input[1:]:
- numzs = initializer[inp][0][0]
- for dim in initializer[inp][0]:
- f.write(str(dim)+ " ")
- f2.write(np.asarray(findWeightsInitializer(inp), dtype=' 1 rejected by checkSupported
- kernel_shape = attributes['kernel_shape'][0]
- pad_total = kernel_shape - 1
- pad = int(pad_total/2)
- if pad_total % 2 != 0:
- if attributes['auto_pad'] == "SAME_UPPER":
- attributes['pads'] = [pad,pad,pad+1,pad+1]
- else:
- attributes['pads'] = [pad+1,pad+1,pad,pad]
- else:
- attributes['pads'] = [pad]*4
- checkSupported("MaxPool", attributes)
- modelArch.append(("MaxPool", [ioMap[node.input[0]]], [attributes['ceil_mode'],attributes['pads'],attributes['strides']])) #(ceil_mode, pads, strides)
- f.write(str(attributes['kernel_shape'][0]))
- f.write("\n")
- ioMap[node.output[0]] = ioMap[node.input[0]]
-
- elif layer == "AveragePool":
- f.write(layer)
- f.write("\n")
- #https://onnx.ai/onnx/api/mapping.html#l-onnx-types-mapping
- names = {n.name:n.i if n.type==2 else n.ints for n in node.attribute}
- poolAttrs = {
- 'ceil_mode': names.get('ceil_mode', 0),
- 'pads': names.get('pads', [0, 0, 0, 0]),
- 'strides': names.get('strides', [1, 1]),
- 'kernel_shape': names.get('kernel_shape'),
- 'count_include_pad': names.get('count_include_pad', 0),
- # string attribute; not in `names`
- 'auto_pad': next((a.s.decode('ASCII') for a in node.attribute if a.name == 'auto_pad'), 'NOTSET'),
- }
- checkSupported("AveragePool", poolAttrs)
- attributes = [poolAttrs['ceil_mode'], poolAttrs['pads'], poolAttrs['strides'], poolAttrs['kernel_shape']]
- modelArch.append(("AveragePool", [ioMap[node.input[0]]], attributes[:3])) #(ceil_mode, pads, strides)
- f.write(str(attributes[-1][0]))
- f.write("\n")
- ioMap[node.output[0]] = ioMap[node.input[0]]
-
- elif layer == "Add":
- f.write(layer)
- f.write("\n")
- fourd = fourDTransform(intermediateShapes[node.input[0]],findWeightsInitializer(node.input[-1]).shape)
- true = fakeFourD(intermediateShapes[node.input[0]])
- modelArch.append(("Add",[ioMap[node.input[0]]], [true, spreadInfo(true,fourd),len(intermediateShapes[node.input[0]])])) #[trueshape, need to be broadcasted and added SHAPE]
- for dim in fourd:
- f.write(str(dim) + " ")
- f.write("\n")
- f2.write(np.asarray(findWeightsInitializer(node.input[1]), dtype=' 4:
- raise ValueError(f"cannot broadcast a {len(t)}-D tensor into 4 dimensions")
- new = [1, 1, 1, 1]
- for i, d in enumerate(reversed(t)):
- new[3 - i] = d
- true4d = fakeFourD(list(trueshape))
- for i, (a, b) in enumerate(zip(true4d, new)):
- if b != 1 and b != a:
- raise ValueError(
- f"axis {i}: cannot broadcast {toBeTransformedShape} "
- f"against {trueshape} ({b} vs {a})"
- )
- return new
-
-
-def spreadInfo(trueShape, toBeTransformedShape):
- ret = []
- for index, dim in enumerate(toBeTransformedShape):
- if trueShape[index] != dim:
- ret.append(index + 1)
- ret.append(trueShape[index])
- return ret
-
-
-# ONNX gate order (i, o, f, c) -> lstm_cell order (i, f, c, o)
-ONNX_TO_ROSENNA_GATES = [0, 2, 3, 1]
-
-
-def regateLSTM(arr, axis=0):
- """Reorder LSTM W/R/B gate blocks along `axis`."""
- n = arr.shape[axis]
- if n % 4 != 0:
- raise ValueError(f"LSTM gate axis {axis} has length {n}, not a multiple of 4")
- h = n // 4
- blocks = [
- np.take(arr, range(g * h, (g + 1) * h), axis=axis)
- for g in ONNX_TO_ROSENNA_GATES
- ]
- return np.concatenate(blocks, axis=axis)
-
-
-_LOWER_IDENT = re.compile(r"^[a-z][a-z0-9_]{0,60}$")
-
-
-def sanitize(name):
- """Map an ONNX name to a collision-free Fortran identifier.
-
- The v_ prefix avoids generated locals, procedures, and intrinsics. Names that
- are not lowercase identifiers get a digest, since Fortran ignores case.
- """
- if _LOWER_IDENT.match(name):
- return "v_" + name
- cleaned = re.sub(r"[^A-Za-z0-9_]", "_", name).lower()
- digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:6]
- return f"v_{cleaned[:48]}_{digest}"
-
-
-def checkSupported(op, attrs):
- """Raise NotImplementedError for attributes roseNNa cannot honour."""
- kernel = attrs.get("kernel_shape")
- if op in ("MaxPool", "AveragePool") and not kernel:
- raise NotImplementedError(
- f"{op}: kernel_shape is required by ONNX but missing from this node")
-
- if op == "Conv" and int(attrs.get("group", 1)) != 1:
- raise NotImplementedError(
- f"Conv: group={attrs.get('group')} (grouped or depthwise convolution) "
- f"is not supported by roseNNa")
-
- auto_pad = attrs.get("auto_pad", "NOTSET")
- strides = list(attrs.get("strides", [1, 1]))
- if op in ("Conv", "MaxPool") and auto_pad in ("SAME_UPPER", "SAME_LOWER") \
- and any(int(s) != 1 for s in strides):
- raise NotImplementedError(
- f"{op}: auto_pad={auto_pad} with strides={strides} is not supported by "
- f"roseNNa, which computes SAME padding as kernel-1 (correct only for stride 1)")
-
- if op == "AveragePool" and attrs.get("auto_pad", "NOTSET") not in ("NOTSET", "VALID"):
- raise NotImplementedError(
- f"AveragePool: auto_pad={attrs.get('auto_pad')} is not supported by roseNNa")
-
- dilations = list(attrs.get("dilations", [1, 1]))
- if any(d != 1 for d in dilations):
- raise NotImplementedError(
- f"{op}: dilations={dilations} is parsed but ignored by roseNNa; "
- f"only dilations of 1 are supported")
-
- if int(attrs.get("ceil_mode", 0)) != 0:
- raise NotImplementedError(
- f"{op}: ceil_mode=1 is parsed but ignored by roseNNa; "
- f"output extents are always floored")
-
- kernel = list(kernel or [])
- if len(kernel) == 2 and kernel[0] != kernel[1]:
- raise NotImplementedError(
- f"{op}: non-square kernel {kernel} is not supported by roseNNa")
-
- pads = list(attrs.get("pads", [0, 0, 0, 0]))
- if len(pads) == 4 and (pads[0] != pads[2] or pads[1] != pads[3]):
- raise NotImplementedError(
- f"{op}: asymmetric pads {pads} are not supported by roseNNa; "
- f"padding is applied symmetrically")
-
- if op == "AveragePool" and any(p != 0 for p in pads) \
- and int(attrs.get("count_include_pad", 0)) != 1:
- raise NotImplementedError(
- f"AveragePool: count_include_pad=0 with pads {pads} is not supported "
- f"by roseNNa, which always divides by the full kernel area")
-
-
-def checkPadIsNoop(pads):
- """Raise unless all pads are zero."""
- pads = list(pads)
- if any(p != 0 for p in pads):
- raise NotImplementedError(f"Pad: pads={pads} is not supported by roseNNa")
-
-
-def checkLSTMSupported(attrs):
- """Raise NotImplementedError for LSTM attributes roseNNa does not implement."""
- direction = attrs.get("direction", "forward")
- if direction != "forward":
- raise NotImplementedError(
- f"LSTM: direction={direction} is not supported by roseNNa; "
- f"only one forward direction is computed")
-
- activations = attrs.get("activations")
- if activations is not None and [a.lower() for a in activations] != ["sigmoid", "tanh", "tanh"]:
- raise NotImplementedError(
- f"LSTM: activations={list(activations)} is not supported by roseNNa; "
- f"only the default Sigmoid, Tanh, Tanh is implemented")
-
- if attrs.get("clip") is not None:
- raise NotImplementedError(
- f"LSTM: clip={attrs.get('clip')} is not supported by roseNNa")
-
- if int(attrs.get("input_forget", 0)) != 0:
- raise NotImplementedError(
- f"LSTM: input_forget={attrs.get('input_forget')} is not supported by roseNNa")
-
- if int(attrs.get("layout", 0)) != 0:
- raise NotImplementedError(
- f"LSTM: layout={attrs.get('layout')} (batch-first) is not supported by roseNNa")
-
-
-def checkGemmBias(shape):
- """Raise NotImplementedError unless the Gemm bias is rank 1."""
- shape = list(shape)
- if len(shape) != 1:
- raise NotImplementedError(
- f"Gemm: bias C of shape {shape} (rank {len(shape)}) is not supported by "
- f"roseNNa; only a rank-1 bias is")
diff --git a/fLibrary/reader.f90 b/fLibrary/reader.f90
deleted file mode 100644
index 532396d..0000000
--- a/fLibrary/reader.f90
+++ /dev/null
@@ -1,443 +0,0 @@
-module reader
-
- USE derived_types
- USE activation_functions
- use iso_c_binding
- use iso_fortran_env, only: error_unit, int64
-
- implicit none
-
-
- TYPE(linLayer), ALLOCATABLE, DIMENSION(:) :: linLayers
- TYPE(lstmLayer), ALLOCATABLE, DIMENSION(:) :: lstmLayers
- TYPE(convLayer), ALLOCATABLE, DIMENSION(:) :: convLayers
- TYPE(maxpoolLayer), ALLOCATABLE, DIMENSION(:) :: maxpoolLayers
- TYPE(avgpoolLayer), ALLOCATABLE, DIMENSION(:) :: avgpoolLayers
- TYPE(addLayer), ALLOCATABLE, DIMENSION(:) :: addLayers
- TYPE(reshapeLayer), ALLOCATABLE, DIMENSION(:) :: reshapeLayers
-
- INTEGER :: numLayers
-
- contains
-
- function c_to_f_string(s) result(str)
- character(kind=c_char, len=1), intent(in) :: s(*)
- character(len=:), allocatable :: str
- integer :: i, n
- n = 0
- do while (n < 4096)
- if (s(n+1) == c_null_char) exit
- n = n + 1
- end do
- allocate(character(len=n) :: str)
- do i = 1, n
- str(i:i) = s(i)
- end do
- end function
-
- subroutine initialize(model_file, weights_file) bind(c,name="initialize")
- character(kind=c_char, len=1), intent(in), optional :: model_file(*)
- character(kind=c_char, len=1), intent(in), optional :: weights_file(*)
- INTEGER :: Reason, ios
- INTEGER :: modelUnit, weightsUnit
- character(len=:), allocatable :: mpath, wpath
- CHARACTER(LEN = 100) :: layerName
- INTEGER :: i
- INTEGER :: readOrNot
- LOGICAL :: binary, wexists
- character(len=4) :: ext
- integer :: k, wlen
- integer(int64) :: wpos, wsize
-
- mpath = "onnxModel.txt"
- wpath = "onnxWeights.bin"
- if (present(model_file)) mpath = c_to_f_string(model_file)
- if (present(weights_file)) wpath = c_to_f_string(weights_file)
-
- ALLOCATE(lstmLayers(0))
- ALLOCATE(linLayers(0))
- ALLOCATE(convLayers(0))
- ALLOCATE(maxpoolLayers(0))
- ALLOCATE(avgpoolLayers(0))
- ALLOCATE(addLayers(0))
- ALLOCATE(reshapeLayers(0))
-
- open(newunit=modelUnit, file=mpath, status='old', action='read', iostat=ios)
- if (ios /= 0) then
- write(error_unit,'(a)') "roseNNa: cannot open model file '"//mpath//"'"
- flush(error_unit)
- error stop 1
- end if
- ! no weights path and no .bin: use legacy .txt
- if (.not. present(weights_file)) then
- inquire(file=wpath, exist=wexists)
- if (.not. wexists) then
- wpath = "onnxWeights.txt"
- write(error_unit,'(a)') "roseNNa: onnxWeights.bin not found, reading legacy onnxWeights.txt"
- flush(error_unit)
- end if
- end if
- ! *.txt (any case) is legacy text, else binary
- wlen = len_trim(wpath)
- binary = .true.
- if (wlen >= 4) then
- ext = wpath(wlen-3:wlen)
- do k = 1, 4
- if (ext(k:k) >= 'A' .and. ext(k:k) <= 'Z') ext(k:k) = achar(iachar(ext(k:k)) + 32)
- end do
- binary = ext /= '.txt'
- end if
- if (binary) then
- open(newunit=weightsUnit, file=wpath, status='old', action='read', access='stream', &
- form='unformatted', iostat=ios)
- else
- open(newunit=weightsUnit, file=wpath, status='old', action='read', iostat=ios)
- end if
- if (ios /= 0) then
- write(error_unit,'(a)') "roseNNa: cannot open weights file '"//wpath//"'"
- flush(error_unit)
- error stop 1
- end if
-
- read(modelUnit, *, iostat=ios) numLayers
- if (ios /= 0) then
- write(error_unit,'(a)') "roseNNa: '"//mpath//"' is empty or malformed"
- flush(error_unit)
- error stop 1
- end if
-
- readloop: DO i = 1, numLayers
- read(modelUnit, *, IOSTAT=Reason) layerName
- if (Reason < 0) then
- exit readloop
- end if
- if (layerName .eq. "LSTM") then
- read(modelUnit,*) readOrNot
- CALL read_lstm(modelUnit, weightsUnit, readOrNot, binary)
- else if (layerName .eq. "Gemm") then
- CALL read_linear(modelUnit, weightsUnit, binary)
- else if (layerName .eq. "Conv") then
- CALL read_conv(modelUnit, weightsUnit, binary)
- else if (layerName .eq. "MaxPool") then
- CALL read_maxpool(modelUnit, weightsUnit)
- else if (layerName .eq. "AveragePool") then
- CALL read_avgpool(modelUnit, weightsUnit)
- else if (layerName .eq. "Add") then
- CALL read_add(modelUnit, weightsUnit, binary)
- else if (layerName .eq. "MatMul") then
- cycle
- else if (layerName .eq. "Reshape") then
- read(modelUnit, *) readOrNot
- if (readOrNot .eq. 2) then
- CALL read_reshape2d(modelUnit, weightsUnit, binary)
- else if (readOrNot .eq. 3) then
- CALL read_reshape3d(modelUnit, weightsUnit, binary)
- else if (readOrNot .eq. 4) then
- CALL read_reshape4d(modelUnit, weightsUnit, binary)
- endif
- else if (layerName .eq. "Transpose") then
- cycle
- else if (layerName .eq. "Squeeze") then
- cycle
- else if (layerName .eq. "Pad") then
- cycle
- else if (layerName .eq. "Relu") then
- cycle
- else
- cycle
- end if
-
-
-
- END DO readloop
-
- if (binary) then
- inquire(unit=weightsUnit, pos=wpos, size=wsize)
- if (wpos /= wsize + 1) then
- write(error_unit,'(a,i0,a,i0,a)') "roseNNa: weights file '"//trim(wpath)//"' has ", wsize, &
- " bytes but the model consumed ", wpos - 1, "; the weights do not match the model"
- flush(error_unit)
- error stop 1
- end if
- end if
-
- close(modelUnit)
- close(weightsUnit)
- end subroutine
-
- subroutine read_reshape2d(file1, file2, binary)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(reshapeLayer), ALLOCATABLE, DIMENSION(:) :: reshape
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: weights
- INTEGER :: w_dim1
- INTEGER :: w_dim2
- ALLOCATE(reshape(1))
- read(file1, *) w_dim1, w_dim2
- ALLOCATE(weights(w_dim1, w_dim2))
- if (binary) then
- read(file2) weights
- else
- read(file2, *) weights
- end if
- reshape(1)%reshape2d = weights
- DEALLOCATE(weights)
- reshapeLayers = [reshapeLayers, reshape]
- DEALLOCATE(reshape)
- end subroutine
-
- subroutine read_reshape3d(file1, file2, binary)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(reshapeLayer), ALLOCATABLE, DIMENSION(:) :: reshape
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: midWeights
- INTEGER :: w_dim1
- INTEGER :: w_dim2
- INTEGER :: w_dim3
- ALLOCATE(reshape(1))
- read(file1, *) w_dim1, w_dim2, w_dim3
- ALLOCATE(midWeights(w_dim1, w_dim2, w_dim3))
- if (binary) then
- read(file2) midWeights
- else
- read(file2, *) midWeights
- end if
- reshape(1)%reshape3d = midWeights
- DEALLOCATE(midWeights)
- reshapeLayers = [reshapeLayers, reshape]
- DEALLOCATE(reshape)
- end subroutine
-
- subroutine read_reshape4d(file1, file2, binary)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(reshapeLayer), ALLOCATABLE, DIMENSION(:) :: reshape
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: largeWeights
- INTEGER :: w_dim1
- INTEGER :: w_dim2
- INTEGER :: w_dim3
- INTEGER :: w_dim4
- ALLOCATE(reshape(1))
- read(file1, *) w_dim1, w_dim2, w_dim3, w_dim4
- ALLOCATE(largeWeights(w_dim1, w_dim2, w_dim3, w_dim4))
- if (binary) then
- read(file2) largeWeights
- else
- read(file2, *) largeWeights
- end if
- reshape(1)%reshape4d = largeWeights
- DEALLOCATE(largeWeights)
- reshapeLayers = [reshapeLayers, reshape]
- DEALLOCATE(reshape)
- end subroutine
-
- subroutine read_add(file1, file2, binary)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(addLayer), ALLOCATABLE, DIMENSION(:) :: add
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: largeWeights
- INTEGER :: w_dim1
- INTEGER :: w_dim2
- INTEGER :: w_dim3
- INTEGER :: w_dim4
- ALLOCATE(add(1))
- read(file1, *) w_dim1, w_dim2, w_dim3, w_dim4
- ALLOCATE(largeWeights(w_dim1, w_dim2, w_dim3, w_dim4))
- if (binary) then
- read(file2) largeWeights
- else
- read(file2, *) largeWeights
- end if
- add(1)%adder = largeWeights
- DEALLOCATE(largeWeights)
- addLayers = [addLayers, add]
- DEALLOCATE(add)
- end subroutine
-
- subroutine read_avgpool(file1, file2)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- TYPE(avgpoolLayer), ALLOCATABLE, DIMENSION(:) :: avgpool
- INTEGER :: w_dim1
- ALLOCATE(avgpool(1))
- read(file1, *) w_dim1
- avgpool(1)%kernel_size = w_dim1
- avgpoolLayers = [avgpoolLayers, avgpool]
- DEALLOCATE(avgpool)
- end subroutine
-
- subroutine read_maxpool(file1, file2)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- TYPE(maxpoolLayer), ALLOCATABLE, DIMENSION(:) :: maxpool
- INTEGER :: w_dim1
- ALLOCATE(maxpool(1))
- read(file1, *) w_dim1
- maxpool(1)%kernel_size = w_dim1
- maxpoolLayers = [maxpoolLayers, maxpool]
- DEALLOCATE(maxpool)
- end subroutine
- subroutine read_conv(file1, file2, binary)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(convLayer), ALLOCATABLE, DIMENSION(:) :: conv
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:,:) :: largeWeights
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: biases
- INTEGER :: w_dim1
- INTEGER :: w_dim2
- INTEGER :: w_dim3
- INTEGER :: w_dim4
- ALLOCATE(conv(1))
- read(file1, *) w_dim1, w_dim2, w_dim3, w_dim4
- ALLOCATE(largeWeights(w_dim1, w_dim2, w_dim3, w_dim4))
- if (binary) then
- read(file2) largeWeights
- else
- read(file2, *) largeWeights
- end if
- conv(1)%weights = largeWeights
- DEALLOCATE(largeWeights)
-
-
-
- read(file1, *) w_dim1
- ALLOCATE(biases(w_dim1))
- if (binary) then
- read(file2) biases
- else
- read(file2, *) biases
- end if
- conv(1)%biases = biases
- DEALLOCATE(biases)
-
-
- convLayers = [convLayers, conv]
-
- DEALLOCATE(conv)
- end subroutine
-
- subroutine read_lstm(file1, file2, readOrNot, binary)
- INTEGER, INTENT(IN) :: readOrNot
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(lstmLayer), ALLOCATABLE, DIMENSION(:) :: lstm
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:,:) :: midWeights
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: biases
- INTEGER :: w_dim1
- INTEGER :: w_dim2
- INTEGER :: w_dim3
- ALLOCATE(lstm(1))
- read(file1, *) w_dim1, w_dim2, w_dim3
- ALLOCATE(midWeights(w_dim1,w_dim2,w_dim3))
- if (binary) then
- read(file2) midWeights
- else
- read(file2, *) midWeights
- end if
- lstm(1)%wih = midWeights
- DEALLOCATE(midWeights)
-
- read(file1, *) w_dim1, w_dim2, w_dim3
- ALLOCATE(midWeights(w_dim1,w_dim2,w_dim3))
- if (binary) then
- read(file2) midWeights
- else
- read(file2, *) midWeights
- end if
- lstm(1)%whh = midWeights
- DEALLOCATE(midWeights)
-
-
- read(file1, *) w_dim1
- ALLOCATE(biases(w_dim1))
- if (binary) then
- read(file2) biases
- else
- read(file2, *) biases
- end if
- lstm(1)%bih = biases
- DEALLOCATE(biases)
-
- read(file1, *) w_dim1
- ALLOCATE(biases(w_dim1))
- if (binary) then
- read(file2) biases
- else
- read(file2, *) biases
- end if
- lstm(1)%bhh = biases
- DEALLOCATE(biases)
-
- if (readOrNot .eq. 1) then
- read(file1, *) w_dim1, w_dim2, w_dim3
- ALLOCATE(midWeights(w_dim1,w_dim2,w_dim3))
- if (binary) then
- read(file2) midWeights
- else
- read(file2, *) midWeights
- end if
- lstm(1)%hid = midWeights
- DEALLOCATE(midWeights)
-
- read(file1, *) w_dim1, w_dim2, w_dim3
- ALLOCATE(midWeights(w_dim1,w_dim2,w_dim3))
- if (binary) then
- read(file2) midWeights
- else
- read(file2, *) midWeights
- end if
- lstm(1)%cell = midWeights
- DEALLOCATE(midWeights)
- endif
-
-
-
- lstmLayers = [lstmLayers, lstm]
- DEALLOCATE(lstm)
- end subroutine
-
- subroutine read_linear(file1, file2, binary)
- INTEGER, INTENT(IN) :: file1
- INTEGER, INTENT(IN) :: file2
- LOGICAL, INTENT(IN) :: binary
- TYPE(linLayer), ALLOCATABLE,DIMENSION(:) :: lin
- REAL (c_double), ALLOCATABLE, DIMENSION(:,:) :: weights
- REAL (c_double), ALLOCATABLE, DIMENSION(:) :: biases
- INTEGER :: w_dim1
- INTEGER :: w_dim2
-
- ALLOCATE(lin(1))
- read(file1, *) w_dim1, w_dim2
- ALLOCATE(weights(w_dim1,w_dim2))
- if (binary) then
- read(file2) weights
- else
- read(file2, *) weights
- end if
-
- read(file1, *) w_dim1
- ALLOCATE(biases(w_dim1))
- if (binary) then
- read(file2) biases
- else
- read(file2, *) biases
- end if
-
- lin(1)%weights = weights
- lin(1)%biases = biases
-
- DEALLOCATE(weights)
- DEALLOCATE(biases)
- linLayers = [linLayers, lin]
- DEALLOCATE(lin)
- end subroutine
-
-
-
-end module
diff --git a/fLibrary/rosenna.f90 b/fLibrary/rosenna.f90
deleted file mode 100644
index 7463681..0000000
--- a/fLibrary/rosenna.f90
+++ /dev/null
@@ -1,19 +0,0 @@
-module rosenna
- !---------------
- ! adding any number of layers to our neural network
- !----------------
-
-
- ! ===============================================================
- USE activation_functions !.mk` and `make -f
+_fortran.mk` then build `lib.a` (C) and `lib_f.a`
+(Fortran, a module in the archive). `_infer` is a plain per-point
+function you call inside your own GPU loop, exactly like any other
+device-callable routine in your solver. Its weights are baked into the
+generated source as constants for a small model, or loaded once at startup
+by `_init` for a large one, and the generated code is written so that
+they live on the device in either case -- with the caveat, stated under
+[Verify](#verify), that the device path has not yet been run on a GPU.
+
+## Install
+
+```sh
+pip install -e python
+```
+
+The generator itself only needs Python (`onnx`, `numpy`, `onnxruntime` for
+`verify`). Building generated code needs a compiler:
+
+- host path (no accelerator, or the OpenMP-target host fallback): `gcc`/`gfortran`
+ with `-fopenmp` (on macOS, Homebrew's `gcc-15`/`gfortran-15` -- Apple's
+ `clang`-based `gcc` has no `-fopenmp`).
+- GPU path: `nvc`/`nvfortran` (NVIDIA HPC SDK) for OpenMP-target or OpenACC on
+ an NVIDIA GPU; `amdclang`/`amdflang` for OpenMP-target on an AMD GPU; `icx`/`ifx`
+ for OpenMP-target on an Intel GPU. `nvcc` or `hipcc` if you also want the
+ native batched kernel: `ROSENNA_BACKEND=cuda|hip` when you run the C recipe
+ (and `--backend cuda|hip` to `rosenna gpu-gate`); `generate` itself has no
+ backend flag and always writes every file (see [Call it from C](#call-it-from-c)).
+
+## Generate
+
+```sh
+rosenna generate model.onnx --lang both --precision single --out build/
+```
+
+`--lang` selects `fortran`, `c`, or `both` (default); `--precision` selects
+`single` or `double` and defaults to the model's own dtype (see
+[Precision](#precision)); `--name` sets the symbol prefix and defaults to the
+model file's stem. `generate` prints every file it wrote:
+
+| File | Written when | What it is |
+|---|---|---|
+| `.h` | `--lang c\|both` | the header: `_infer` (per-point, device-decorated), `_infer_batch` |
+| `.c` | `--lang c\|both` | weight loading and `_init` (file-loaded models only), the OpenMP-fallback `_infer_batch` |
+| `_kernel.cu` | `--lang c\|both` | the native CUDA/HIP batched kernel; inert unless built with `ROSENNA_BACKEND=cuda\|hip` |
+| `rosenna_rt.h` | `--lang c\|both` | the CUDA/HIP runtime macro mapping; identical for every model |
+| `.mk` | `--lang c\|both` | the C build recipe: builds `lib.a` for `ROSENNA_BACKEND=cuda\|hip\|omp` |
+| `_model.F90` | `--lang fortran\|both` | the Fortran module (capital `.F90`: `infer_batch`'s device-pointer clause is chosen by the preprocessor, since nvfortran does not implement `has_device_addr`) |
+| `_fortran.mk` | `--lang fortran\|both` | the Fortran build recipe: builds `lib_f.a` |
+| `.rwt` | file-loaded weights only | the weights file `_init` reads |
+
+Both recipes write into the same output directory and build there: a
+`--lang both` run gives you one directory holding both archives.
+
+A model embeds its weights as constants (`ROSENNA_CONST` in C, an
+initialized `protected` module array in Fortran, since gfortran's OpenACC
+cannot read a `parameter` array from a device routine) automatically when
+it has fewer than `EMBED_THRESHOLD`
+(1,000,000) parameters; above that it is file-loaded by default. `--embed-weights`
+forces embedding regardless of size; `--no-embed` forces a `.rwt` file
+regardless of size. An embedded model has no `_init` at all -- there is
+nothing to load -- and no `.rwt` file is written for it.
+
+## Call it from C
+
+Two paths call the same generated code. This example is generated from
+`gemm_small` with `--name model`; it embeds by default, so it has no
+`model_init` to call (the commented-out line below shows the file-loaded
+form). It reads its inputs from a fixed array, calls `model_infer` in its
+own offload loop, calls `model_infer_batch` once, and exits non-zero if the
+two disagree:
+
+```c
+#include
+#include
+#include "model.h"
+
+#define NPTS 4
+
+int main(void) {
+ /* Fixed inputs: NPTS points of n_in=2 values each. */
+ double x[NPTS * 2] = {
+ 0.10, 0.20,
+ 0.30, -0.10,
+ -0.20, 0.50,
+ 1.00, -1.00,
+ };
+ double y_loop[NPTS * 3];
+ double y_batch[NPTS * 3];
+ int status = 0;
+
+ /* File-loaded models only: gemm_small embeds by default, so this
+ generated header has no model_init to call.
+ if (model_init("model.rwt") != 0) return 1; */
+
+ /* (a) The per-point path: model_infer inside your own offload loop. */
+#if defined(_OPENMP)
+ #pragma omp target teams distribute parallel for map(to: x[0:NPTS * 2]) map(from: y_loop[0:NPTS * 3])
+#endif
+ for (int p = 0; p < NPTS; ++p)
+ model_infer(x + p * 2, y_loop + p * 3);
+
+ /* (b) The batched path: model_infer_batch takes device-resident data in
+ every backend and never allocates, transfers or synchronizes itself.
+ Under the omp backend the host maps its own arrays and hands
+ infer_batch the mapped device pointers (use_device_ptr needs a
+ pointer variable, not an array, hence xp/yp); a cuda/hip caller
+ passes raw device pointers here instead and skips this mapping. */
+#if defined(_OPENMP)
+ {
+ double *xp = x, *yp = y_batch;
+ #pragma omp target data map(to: x[0:NPTS * 2]) map(from: y_batch[0:NPTS * 3]) \
+ use_device_ptr(xp, yp)
+ {
+ status = model_infer_batch(NPTS, xp, yp, NULL);
+ }
+ }
+#else
+ status = model_infer_batch(NPTS, x, y_batch, NULL);
+#endif
+ if (status != 0) return 1;
+
+ for (int i = 0; i < NPTS * 3; ++i) {
+ if (fabs(y_loop[i] - y_batch[i]) > 1e-9) {
+ fprintf(stderr, "mismatch at %d: %.17g vs %.17g\n", i, y_loop[i], y_batch[i]);
+ return 1;
+ }
+ }
+ return 0;
+}
+```
+
+Generate and build it (`--precision double` here only to keep the example's
+own arithmetic in `double` throughout; see [Precision](#precision)):
+
+```sh
+rosenna generate model.onnx --lang c --precision double --out build/ --name model
+```
+```sh
+make -f model.mk ROSENNA_BACKEND=omp CC=gcc-15 ROSENNA_OFFLOAD_FLAGS=-fopenmp
+```
+
+`ROSENNA_BACKEND` selects which `model_infer_batch` the archive holds --
+`cuda`/`hip` build `model_kernel.cu` with `DEVCC` (default `nvcc`/`hipcc`)
+and launch the native kernel over raw device pointers; `omp` (the default)
+builds only `model.c` with the host compiler and runs the OpenMP-target
+fallback shown above. The two are never linked together. A cuda/hip build
+of a *file-loaded* model needs one more call: after every `model_init`, call
+`model_device_bind_here()` in every translation unit whose kernels call
+`model_infer` (an embedded model needs neither).
+
+A cuda/hip archive does not serve the per-point path of a file-loaded
+model. `nvcc`/`hipcc` compile `model.c` with `_OPENMP` and `_OPENACC`
+undefined, so the weight arrays get no `declare target` device copies and
+`model_init`'s `target update` is not compiled; a host translation unit
+compiled by `nvc -mp=gpu` (or `amdclang`, or `gcc` with offload) that calls
+`model_infer` inside its own offload loop then reads device copies that do
+not exist. So a per-point OpenMP or OpenACC host calling `model_infer` on a
+file-loaded model must link the `omp`-backend archive built by that same
+host compiler with its offload flags:
+
+```sh
+make -f model.mk ROSENNA_BACKEND=omp CC=nvc ROSENNA_OFFLOAD_FLAGS="-mp=gpu -gpu=cc80"
+```
+
+The cuda/hip archive serves `model_infer_batch` and your own CUDA/HIP
+kernels that call `model_infer` after `model_device_bind_here()`. Build
+both archives in separate directories if one program needs both. Embedded
+models are unaffected: every translation unit holds its own copy of the
+constants, so they work with every backend. See [Limits](#limits) for the
+planned resolution.
+
+Embedded weights on a CUDA/HIP build go to one of two storage classes,
+decided per model at generate time, not at build time: under 48 KB (12,288
+float32 or 6,144 float64 parameters) `ROSENNA_CONST` is `__constant__`
+(cached, broadcast to every thread reading the same address in a warp); at
+or over 48 KB it is `__device__ const` (ordinary global memory), because
+CUDA constant memory is 64 KB per module and the cut leaves 16 KB of that
+for anything else the translation unit puts there. Both storage classes
+compute the same result -- a model over the threshold still runs correctly,
+just without the constant-cache broadcast -- and the header's own comment
+on `ROSENNA_CONST` states which one a given model got, so check it there if
+a per-grid-point call's throughput is on the critical path.
+
+### Whole-field models: infer_one
+
+`_infer_one(x, y, stream)` runs one sample over device pointers as
+one launch per op, the thread index over the op's output elements, with
+the intermediate activations in static device buffers. It is for a model
+whose activations are too large for a thread's locals -- a conv net over
+a whole field: `infer` would hold 660 KB of locals for a 3-layer, 8-channel
+net on a 64x64 grid, more than a device thread's stack, and for such a
+plan `infer_batch` runs `infer_one` per point. The buffers are shared by
+every call, so calls on different streams must not overlap. Absent when
+the plan has an LSTM (a sequence, not a launch per op). The `omp` archive
+provides it as one target loop per op; Fortran reaches it through
+`_infer_one_dev`. `examples/surrogates/poisson_guess` uses it.
+
+### No transfers in the loop
+
+`_init` is the plan step and the only routine that allocates or
+transfers. Nothing in the loop path -- `_infer` or
+`_infer_batch` -- allocates, transfers or synchronizes; the caller
+owns the stream (`model_infer_batch`'s last argument), and `infer_batch`
+never even looks at it beyond passing it to the launch. An embedded model's
+`_infer` is also device-only under `nvcc`/`hipcc` -- its host
+instantiation asserts -- so on those compilers call it from a kernel, or use
+`infer_batch`.
+
+A solver's time-step loop is what the contract is for: the weights go to
+the device once, in `init` or as constants in the device image, and a
+loop calling `infer` from its own target region or `infer_batch` once per
+step moves no model data. `rosenna gpu-gate` measures that: every harness
+runs a 4-step loop over resident data inside a profiler range, and the
+transfer count inside the range must be zero. It is, for all three
+harnesses, embedded and file-loaded, on the MI210 (`rocprofv3`) and, for
+the `infer_batch` driver, on the A100 (`nsys`).
+
+For Fortran solvers: an `allocatable` referenced inside a target region
+carries a descriptor, and `amdflang` re-maps that descriptor on every
+region entry, two small copies per step for two arrays whose data is
+resident. The gate's Fortran harness reaches its arrays through
+explicit-shape dummies instead; a solver's step loop should do the same,
+or pass `c_ptr`s as the C path does.
+
+Host offload flags, for the per-point path and the `omp` backend:
+
+| Host compiler | Host flags (the per-point path and the `omp` backend) |
+|---|---|
+| nvc / nvfortran (NVIDIA, OpenMP) | `-mp=gpu -gpu=cc80` (or your `-gpu=` target) |
+| nvc / nvfortran (NVIDIA, OpenACC) | `-acc -gpu=cc80` |
+| amdclang / amdflang (AMD) | `-fopenmp --offload-arch=gfx90a` (or your arch) |
+| icx / ifx (Intel) | `-fopenmp -fopenmp-targets=spir64` |
+| gcc / gfortran, host fallback | `-fopenmp` |
+
+`DEVFLAGS`, for the batched backend:
+
+| Batched backend | `ROSENNA_BACKEND` | `DEVFLAGS` |
+|---|---|---|
+| CUDA | `cuda` | `-O2 -arch=sm_80` (or your arch) |
+| HIP | `hip` | `-O2 --offload-arch=gfx90a` (or your arch) |
+| OpenMP fallback | `omp` | none; uses the host flags |
+
+## Call it from Fortran
+
+The same two paths, through `use _model`. This is the same
+`gemm_small` model as above (`--name model`), built with `--lang fortran`,
+so it also embeds and has no `model_init`:
+
+```fortran
+program host
+ use model_model
+ use iso_fortran_env, only: real64
+ implicit none
+ integer, parameter :: npts = 4
+ real(real64) :: x(2, npts), y_loop(3, npts), y_batch(3, npts)
+ integer :: p, status
+
+ x(:, 1) = [ 0.10_real64, 0.20_real64]
+ x(:, 2) = [ 0.30_real64, -0.10_real64]
+ x(:, 3) = [-0.20_real64, 0.50_real64]
+ x(:, 4) = [ 1.00_real64, -1.00_real64]
+
+ ! File-loaded models only: gemm_small embeds by default, so this
+ ! generated module has no model_init to call.
+ ! call model_init('model.rwt', status)
+ ! if (status /= 0) stop 1
+
+ ! (a) The per-point path: model_infer inside your own offload loop.
+ !$omp target teams distribute parallel do map(to: x) map(from: y_loop)
+ do p = 1, npts
+ call model_infer(x(:, p), y_loop(:, p))
+ end do
+
+ ! (b) The batched path: model_infer_batch takes device-resident arrays.
+ ! The host maps its own arrays and hands infer_batch the mapped device
+ ! addresses (use_device_addr); a cuda/hip caller reaches the same
+ ! contract through model_infer_batch_dev and c_loc of device memory.
+ !$omp target data map(to: x) map(from: y_batch) use_device_addr(x, y_batch)
+ call model_infer_batch(npts, x, y_batch, status)
+ !$omp end target data
+ if (status /= 0) stop 1
+
+ if (maxval(abs(y_loop - y_batch)) > 1.0e-9_real64) stop 1
+end program
+```
+
+`model_infer` is `pure`; `model_infer_batch(n, x, y, status)` returns its
+status (0, 10 or 11 -- see [Status codes](#status-codes)) as an `intent(out)`
+argument rather than a function result, so it can be called from inside a
+plain (non-`pure`) host subroutine. Build and run it:
+
+```sh
+rosenna generate model.onnx --lang fortran --precision double --out build/ --name model
+```
+```sh
+make -f model_fortran.mk FC=gfortran ROSENNA_OFFLOAD_FLAGS=-fopenmp
+```
+```sh
+gfortran -O2 -std=f2008 -fopenmp -I. host.f90 -L. -lmodel_f -o host
+```
+
+`gfortran` drops `model_model.mod` next to the object it compiles; `-J DIR`
+during the library build sends it to `DIR` instead of the current
+directory, and a host that `use`s the module then needs `-I DIR` on its own
+compile line to find it (`-I.` above, since the example builds both in the
+same directory).
+
+A Fortran host reaches the batched path two ways. `model_infer_batch` as
+shown above is always Fortran's own OpenMP-target fallback, compiled
+straight into `lib_f.a`, so it links nothing else. The module also
+declares a second route straight to the native kernel: the `bind(C)`
+interface `model_infer_batch_dev`, bound to the plain C symbol
+`model_infer_batch` that `lib.a` provides -- whichever kernel its
+`ROSENNA_BACKEND` was built with (see [Call it from C](#call-it-from-c)).
+That route needs `c_ptr`s to device-resident memory, which OpenACC's
+`host_data use_device` produces from a mapped Fortran array:
+
+```fortran
+use iso_c_binding, only: c_loc, c_null_ptr
+integer :: status
+!$acc host_data use_device(x, y_batch)
+status = model_infer_batch_dev(npts, c_loc(x), c_loc(y_batch), c_null_ptr)
+!$acc end host_data
+```
+
+and links both archives plus the runtime the cuda/hip archive was built
+against, which a host that is not itself linked by `nvcc`/`hipcc` has to
+name explicitly: `-lmodel_f -lmodel -L$CUDA_HOME/lib64 -lcudart` for CUDA
+(or `nvfortran -cuda`, which links it for you), `-lmodel_f -lmodel
+-L$ROCM_PATH/lib -lamdhip64` for HIP. With an `omp`-backend `libmodel.a`
+nothing extra is needed.
+
+`model_infer` is not itself inlined across the `use model_model` boundary by
+every compiler, so a Fortran host's own offload loop generally gets a real
+call per point, not an inlined one, unless the build enables cross-module
+inlining (`gfortran -flto`, nvfortran `-Minline`).
+
+## Precision
+
+`--precision` defaults to the model's own dtype -- `float32` for a PyTorch
+export via `torch.onnx.export`, since that is what PyTorch trains and
+exports in. A double-precision host can still call single-precision
+generated code: `model_infer`'s `x`/`y` are the plan's own C `float` /
+Fortran `real(real32)`, so the host converts at the call site -- an
+implicit narrowing conversion for a C `double` array passed element by
+element, or an explicit `real(x, real32)` going in and `real(y_f32, real64)`
+coming back out in Fortran. `--precision single` is the usual GPU choice
+regardless of the host's own precision: consumer and even most datacenter
+GPUs run FP64 at a small fraction of their FP32 throughput, so a solver
+whose accuracy budget tolerates it gets a substantial speedup from
+generating (and calling) the single-precision code even from a
+double-precision caller.
+
+## Status codes
+
+`_init` and `_infer_batch` return one of these (rendered here
+from `rosenna.abi.STATUS_CODES`, the one place the table is defined):
+
+| Code | Meaning |
+|---|---|
+| 0 | success |
+| 1 | cannot open the weights file |
+| 2 | not a roseNNa weights file (bad magic) |
+| 3 | weights file version is not supported |
+| 4 | weights file dtype does not match this generated code |
+| 5 | weights file endianness does not match this machine |
+| 6 | weights file plan hash does not match this generated code |
+| 7 | weights file holds a tensor this model does not declare |
+| 8 | a name or rank in the weights file exceeds this model's capacity |
+| 9 | a read failed: the weights file is truncated or inconsistent |
+| 10 | device allocation or copy failed in init |
+| 11 | kernel launch failed |
+
+Codes 0-9 are `_init`'s; `_infer_batch` only ever returns 0, 10
+or 11 (10 and 11 are cuda/hip only -- the `omp` backend's fallback loop
+cannot itself fail once its arguments are device-resident, so it always
+returns 0).
+
+## Verify
+
+```sh
+rosenna verify model.onnx --lang both --cases 16
+```
+
+`verify` generates, compiles and runs the per-point `_infer` path on
+the host, for one or both languages, and compares its output against
+onnxruntime running the same model over the same random inputs. It proves
+the generated arithmetic is correct on the host; it never builds or runs the
+batched device path (`_infer_batch`, the native kernel, or the
+`omp`/`acc` fallbacks under a real offload device), because that needs a GPU
+this machine may not have.
+
+The comparison tolerance is keyed on the ONNX model's own dtype, not on
+`--precision`: onnxruntime always computes a float32 model's reference in
+float32, so `rosenna verify --precision double` on a float32 PyTorch export
+is still compared at float32 tolerance (`rtol=1e-5`, `atol=1e-6`), not
+float64, however precisely the generated code itself computes. A genuinely
+float64 ONNX model is compared at the tight tolerance (`rtol=1e-9`,
+`atol=1e-12`) regardless of `--precision`.
+
+```sh
+rosenna gpu-gate --help
+```
+```sh
+rosenna gpu-gate --cc gcc --fc gfortran --flags=-fopenmp --backend omp --host-fallback --out gate-report/
+```
+
+`gpu-gate` is the check that does exercise the device path: on a machine
+with a real accelerator (and the matching compilers -- `--help` lists the
+NVIDIA, AMD and no-GPU pairings), it generates a model, builds it for the
+chosen `--backend`, and runs three harnesses -- a per-point C host, a
+per-point Fortran host, and a host that hands device-resident data to
+`infer_batch` -- each compared against onnxruntime and timed, writing every
+command and its output to `gate-report.md`.
+
+The CUDA device path has been validated. `gpu-gate` was run on an NVIDIA
+A100 80GB (driver 590.48.01) with NVIDIA HPC SDK 25.11 -- `nvc`/`nvfortran`
+`-mp=gpu -gpu=cc80` as the host compilers, `nvcc` 13.0 as the device
+compiler -- and reported `PASS: every configuration matched`: all six
+harnesses (embedded and file-loaded x per-point C, per-point Fortran,
+`infer_batch`) matched onnxruntime, and the `nsys` capture scoped to the
+timed `infer_batch` call recorded **zero** `cudaMemcpy` calls in both
+configurations. Per point, over the same million distinct points with the
+data mapped outside the timed window in every harness, every route through
+the library lands within noise of every other: 1.7 ns calling `infer` from
+a C `target teams distribute parallel for`, 1.7-1.8 ns from the Fortran
+equivalent, and 1.5 ns through the native batched kernel, embedded and
+file-loaded alike. That is what should be expected of the same arithmetic
+over the same data, and two changes were needed to get there.
+
+The first is how a dense layer is written. Write it the obvious way --
+seed the accumulator with the bias, `acc = b[i]`, then
+add the dot product -- and nvc refuses to generate a `distribute parallel
+for` body at all: it emits a kernel that traps at runtime. Add the bias
+*after* the dot product instead and the same loop compiles and runs. Both
+emitters do it that way, so the two backends stay bit-comparable. Without
+that workaround the only form nvc accepts is `target teams loop`, which
+maps one point to one *team* -- 1,000,000 blocks of 32 threads with a
+single active lane each -- and costs ~47-49 ns per point, some 30x more.
+
+The second is where the embedded weights live. `__constant__` memory is
+fast only while the working set fits a per-SM cache of a couple of KB; past
+that every read misses, and `ncu` showed embedded `infer_batch` spending
+71% of its warp-issue stalls on constant-cache misses. Embedded weights now
+go to `__constant__` only below 2 KB and to `__device__ const` above it,
+which took embedded `infer_batch` from 4.7 to 1.5 ns per point.
+
+See [`doc/nvhpc_teams_mapping/`](../doc/nvhpc_teams_mapping/) for the
+PTX, the `ncu` geometry and stall counters, and a self-contained
+reproducer.
+
+The HIP path was validated the same way: `gpu-gate --backend hip` on an
+AMD Instinct MI210 (gfx90a), under ROCm 7.2.0 (`amdclang` / `amdflang`
+`-fopenmp --offload-arch=gfx90a`, `hipcc`) and under the TheRock AFAR
+23.2.1 drop, `PASS` both times. Per
+point: 2.0-3.1 ns for the C per-point host, 4.6-4.8 ns Fortran, 4.0 ns
+through the native HIP kernel embedded and 6.6-6.9 ns file-loaded. Three
+changes were needed, none in the generated arithmetic:
+
+- `__HIP__` is defined by clang's OpenMP AMDGPU device pass (from
+ `openmp_wrappers/math.h`), so a header that accepted it took the
+ `__device__` branch inside a plain OpenMP host build. hip-clang defines
+ `__HIPCC__` for any HIP compilation; the guards test only that.
+- `hipcc` does not include its runtime implicitly as `nvcc` does; the
+ gate's device harness includes `rosenna_rt.h`.
+- `hipcc` puts `-x hip` ahead of a `.cu` input and it applies to every
+ later input, so a bare `lib.a` after the `.cu` was compiled as source.
+ The gate links the archive as `-L`/`-l`.
+
+Transfer evidence on HIP: `rocprofv3` has no capture range, so the gate
+rebuilds each harness with a roctx range around its 4-step loop, runs it
+under `--hip-trace --marker-trace --memory-copy-trace`, and cuts both the
+HIP API trace and the memory-copy trace to the range (a small `hipMemcpy`
+is host-staged and never a copy operation; OpenMP offload's copies go over
+HSA and are never an API call). Zero transfers inside the loop for all
+three harnesses, embedded and file-loaded; the drivers' setup copies and
+`init`'s upload are in the same traces outside the range. On CUDA the
+same loops are bracketed with nvtx and `cudaMemcpy*` plus `cuMemcpy*`
+counted in `nsys`'s `cuda_api_sum`; run on an A100 for the `infer_batch`
+driver, not yet for the per-point harnesses. A compile-only `nvcc` job
+exists in CI (`.github/workflows/CI.yml`, `nvcc_compile`).
+
+For runnable examples of a model inside a solver's time loop, C and
+Fortran, see `examples/surrogates/`; `examples/cns_closure/` is a compressible
+Navier-Stokes solver with a learned per-cell closure.
+
+## Limits
+
+- **OpenACC per-point hosts want `gang vector`.** A plain `#pragma acc parallel
+ loop` over points lets nvc map ONE POINT PER GANG (measured: `num_gangs=4096,
+ vector_length=32`), and it then auto-vectorises `infer`'s own loops across the
+ 32 lanes -- the OpenACC shape of the `teams loop` cliff in
+ [`doc/nvhpc_teams_mapping/`](../doc/nvhpc_teams_mapping/). Saying `gang
+ vector` puts one point per thread (`num_gangs=32, vector_length=128`) and was
+ 1.9x faster. On an A100 over a million points, `gemm_big` per-point is
+ 6.03 ns with OpenACC against 1.74 ns with OpenMP target, so the two families
+ are not equivalent in speed even when both are correct.
+- `rosenna gpu-gate` measures the OpenMP family only: its per-point harnesses
+ carry `omp target` pragmas, so running it with `--flags "-acc=gpu"` compiles
+ those away and times the HOST (743 ns per point, and the nsys check then has
+ no device activity to parse). OpenACC offload is covered by the tests and by
+ hand, not by the gate.
+- The suite parallelises: `python3 -m pytest tests -n auto` runs it across
+ every core, which is a 5.5x cut here (264s -> 47s on 16 workers) and gives
+ byte-identical coverage. Most of it is compiling and running generated code,
+ so it scales with cores rather than having any one hot test. Golden-model
+ generation takes a lock, so a cold tree is safe too.
+- Supported ops: `Gemm`, `MatMul`, `Conv` (grouped/depthwise too), `Pad`, `Softmax`,
+ `BatchNormalization` (folded into the preceding `Conv`/`Gemm`), `MaxPool`, `AveragePool`, `LSTM`, `GRU`,
+ `Add`, `Concat`, `Reshape`, `Transpose`, `Squeeze`, `Unsqueeze`, `Flatten`,
+ `Identity`, `Relu`, `Tanh`, `Sigmoid`. Values may be rank 1 to 4; the
+ spatial ops are 2-D (rank-4 NCHW) only, `Conv` must be ungrouped, and
+ `LSTM` must be forward-direction with the default activations; its
+ initial state may be a graph input (it arrives in `x`) or a constant (it
+ becomes a weight). A `Gemm` bias must have one value per output, not a
+ broadcast `(1,)`. `Concat` joins runtime values and constants along one
+ axis. `GRU` is forward-direction too, and implements both values of
+ `linear_before_reset`. An inference `BatchNormalization` is folded into the
+ `Conv`/`Gemm` that feeds it rather than emitted.
+- Several inputs and several outputs are fine. Inputs arrive concatenated
+ in `x` in declaration order and outputs leave concatenated in `y`, so
+ `infer(x, y)`, `infer_batch`, the native kernel and the device contract
+ are unchanged. `rosenna info` prints both layouts (`x: p[0:1] h[1:5]
+ c[5:9]`, `y: Y[0:4] hn[4:8] cn[8:12]`). A recurrent model with
+ `initial_h`/`initial_c` as graph inputs and `Y_h`/`Y_c` as graph outputs
+ is called once per cell per step, and `y`'s state slices go back into
+ `x` for the next step on the device.
+- Everything constant is folded away at generation time, so a `Reshape` of a
+ weight or an int64 shape tensor never reaches the generated code. A
+ relabelling op on a runtime value (`Reshape`, `Squeeze`, `Flatten`, and any
+ `Transpose` that only moves size-1 axes) becomes a buffer alias: no code,
+ no copy.
+- A file-loaded model's `_infer` reads unset (zero-initialized static)
+ weights if `_init` was never called, or failed, before it. Nothing
+ in the loop path checks this -- checking it there would be the transfer
+ and synchronization ruled out under [No transfers in the loop](#no-transfers-in-the-loop).
+ The `omp` backend's `_infer_batch` fallback calls `_infer` per
+ point and has the same silent behavior; only the cuda/hip path's
+ `_infer_batch` catches this, returning status 10.
+- A cuda/hip archive of a file-loaded model serves `_infer_batch` and
+ CUDA/HIP kernels only; a per-point OpenMP/OpenACC host must link the
+ `omp`-backend archive built by its own compiler (see [Call it from
+ C](#call-it-from-c)). The planned resolution is that the host compiler
+ always compiles `.c` and `_kernel.cu` owns every CUDA/HIP
+ symbol behind `-DROSENNA_NATIVE_KERNEL`, so one archive serves both paths.
+- The native batched kernel (`ROSENNA_BACKEND=cuda|hip`) launches one thread
+ per point. Dense layers at least 96 wide compute 8 output columns per
+ pass over the input vector (`GEMM_BLOCK`), which is what made a
+ 128-wide MLP 3.4x faster on an MI210; staging weights in shared memory
+ was measured slower. An LSTM's layers are not blocked.
+- There is no SYCL backend. An Intel GPU is reached through the `omp`
+ fallback (`icx`/`ifx` with `-fopenmp -fopenmp-targets=spir64`), not a
+ native kernel.
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 252f4aa..ab8e2ab 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -2,7 +2,10 @@
name = "rosenna"
version = "0.1.0"
requires-python = ">=3.11"
-dependencies = ["onnx>=1.14", "numpy>=1.24", "onnxruntime>=1.15"]
+# protobuf is listed because cli.py imports google.protobuf directly (to
+# catch a DecodeError on a corrupt model). It happens to arrive with onnx
+# today, but a direct import is a direct dependency.
+dependencies = ["onnx>=1.14", "numpy>=1.24", "onnxruntime>=1.15", "protobuf>=3.20"]
[project.scripts]
rosenna = "rosenna.cli:main"
@@ -10,3 +13,21 @@ rosenna = "rosenna.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
+
+[tool.coverage.run]
+source = ["rosenna"]
+# gate.py drives real compilers and a real GPU: the test suite runs it once in
+# host-fallback mode, so most of its branches are reachable only from
+# `rosenna gpu-gate` on a machine with an accelerator. Measuring it here would
+# report a number that says more about the runner than about the tests.
+omit = ["*/gate.py", "*/__main__.py"]
+
+[tool.coverage.report]
+show_missing = true
+# The floor is what the suite reaches today, so a change that removes coverage
+# has to say so. Raise it when it climbs; do not lower it to make CI pass.
+fail_under = 97
+exclude_also = [
+ "raise AssertionError", # unreachable-by-construction guards
+ "if TYPE_CHECKING:",
+]
diff --git a/python/rosenna/__main__.py b/python/rosenna/__main__.py
new file mode 100644
index 0000000..72f0db8
--- /dev/null
+++ b/python/rosenna/__main__.py
@@ -0,0 +1,7 @@
+"""`python -m rosenna ...`: the same CLI as the `rosenna` entry point."""
+import sys
+
+from .cli import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/python/rosenna/abi.py b/python/rosenna/abi.py
index ea61aac..068f1ec 100644
--- a/python/rosenna/abi.py
+++ b/python/rosenna/abi.py
@@ -17,6 +17,8 @@
(7, "weights file holds a tensor this model does not declare"),
(8, "a name or rank in the weights file exceeds this model's capacity"),
(9, "a read failed: the weights file is truncated or inconsistent"),
+ (10, "device allocation or copy failed in init"),
+ (11, "kernel launch failed"),
]
# Floors for the buffers `_init` declares to parse the table of
@@ -38,8 +40,8 @@ def status_code_comment(prefix: str, model: str) -> list:
this generator's Python to find out what it means, so the table is emitted
next to the routine that returns it, in both languages.
"""
- lines = [f"{prefix} Status codes returned by {model}_init:"]
- lines += [f"{prefix} {code} {text}" for code, text in STATUS_CODES]
+ lines = [f"{prefix} Status codes ({model}_init; infer_batch returns 0, 10 or 11):"]
+ lines += [f"{prefix} {code:>2} {text}" for code, text in STATUS_CODES]
return lines
diff --git a/python/rosenna/cli.py b/python/rosenna/cli.py
index 0ae1889..4674e3e 100644
--- a/python/rosenna/cli.py
+++ b/python/rosenna/cli.py
@@ -4,10 +4,15 @@
import tempfile
from pathlib import Path
+import numpy as np
+
from google.protobuf.message import DecodeError
-from .emit_c import emit_c
-from .emit_fortran import emit_fortran
+from .emit_c import emit_c, emit_c_recipe
+from .emit_fortran import emit_fortran, emit_fortran_recipe
+from .emit_kernel import emit_kernel
+from .gate import run_gate
+from .rt_header import rt_header
from .frontend import UnsupportedModel, load_graph
from .plan import build_plan, validate_model_name
from .verify import VerificationError, verify_model
@@ -20,6 +25,15 @@ def _dtype_from_precision(precision: str | None) -> str | None:
return _PRECISION_TO_DTYPE.get(precision) if precision else None
+def _add_embed_flags(sub: argparse.ArgumentParser) -> None:
+ group = sub.add_mutually_exclusive_group()
+ group.add_argument("--embed-weights", dest="embed", action="store_true", default=None,
+ help="embed weights as constants in the header, regardless of size "
+ "(default: embed automatically below EMBED_THRESHOLD parameters)")
+ group.add_argument("--no-embed", dest="embed", action="store_false",
+ help="always load weights from a .rwt file at runtime")
+
+
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="rosenna", description="ONNX to Fortran/C inference code")
sub = p.add_subparsers(dest="command", required=True)
@@ -31,15 +45,76 @@ def build_parser() -> argparse.ArgumentParser:
help="default: the model's own dtype")
gen.add_argument("--out", default=".")
gen.add_argument("--name", default=None, help="symbol prefix; default: the model file stem")
+ _add_embed_flags(gen)
ver = sub.add_parser("verify", help="compile the generated code and compare against onnxruntime")
ver.add_argument("model")
ver.add_argument("--lang", choices=["fortran", "c", "both"], default="both")
ver.add_argument("--precision", choices=["single", "double"], default=None)
ver.add_argument("--cases", type=int, default=16, help="random inputs to compare")
+ ver.add_argument("--name", default=None,
+ help="symbol prefix; default: the model file stem (which must be a "
+ "valid identifier -- pass this when it is not)")
+ _add_embed_flags(ver)
info = sub.add_parser("info", help="report ops, shapes and whether the model is supported")
info.add_argument("model")
+ info.add_argument("--name", default=None,
+ help="symbol prefix; default: the model file stem. `info` validates the "
+ "name too, so a file whose stem is not an identifier needs this to "
+ "be reportable at all")
+
+ gate = sub.add_parser(
+ "gpu-gate",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ help="build and run the device-library validation harnesses on a GPU machine "
+ "(gemm_big, embedded and file-loaded, both languages, three harnesses); "
+ "writes gate-report.md",
+ epilog="""\
+Harnesses 1 and 2 (the per-point C and Fortran hosts) always need a HOST
+compiler capable of OpenMP target offload, regardless of --backend: the
+per-point infer() call always goes through the host compiler's own offload
+region, never through --devcc. Concrete pairings:
+
+ --cc nvc --fc nvfortran --flags "-mp=gpu -gpu=cc80" --backend cuda --devcc nvcc
+ --cc amdclang --fc amdflang --flags "-fopenmp --offload-arch=gfx90a" --backend hip --devcc hipcc
+ --cc gcc --fc gfortran --flags -fopenmp --backend omp --host-fallback (no GPU)
+
+Under --backend cuda|hip the gate builds TWO C archives per configuration:
+an omp-backend lib.a with --cc and --flags (in the configuration
+directory) that the per-point C harness links, and the cuda|hip lib.a
+with --devcc (in _lib/) that the infer_batch .cu driver links. That
+is a limitation of the generated library, not only of the gate: a per-point
+OpenMP or OpenACC host calling _infer on a FILE-LOADED model must link
+the omp-backend archive built by that same host compiler
+(make -f .mk ROSENNA_BACKEND=omp CC= ROSENNA_OFFLOAD_FLAGS=""),
+because only that build gives the weight arrays the declare-target device
+copies the host's offload loop reads; the cuda/hip archive serves
+_infer_batch and CUDA/HIP kernels that call _device_bind_here().
+Embedded models work in every backend. -Wall -Wextra -std=c11|f2008 are added
+only when --cc/--fc is gcc, gfortran, cc or clang (by basename); any other
+compiler gets -O2 and --flags.
+""")
+ gate.add_argument("--cc", required=True, help="host C compiler")
+ gate.add_argument("--fc", required=True, help="host Fortran compiler")
+ # A value here that itself starts with '-' (e.g. -fopenmp, or a
+ # multi-flag string like "-mp=gpu -gpu=cc80") is handled by
+ # _join_dash_valued_options below, not by argparse's own parsing.
+ gate.add_argument("--flags", default="", help="host offload flags, e.g. -fopenmp")
+ gate.add_argument("--backend", choices=["cuda", "hip", "omp"], required=True,
+ help="which infer_batch implementation to build and exercise")
+ gate.add_argument("--devcc", default=None,
+ help="device compiler for --backend cuda|hip: builds the cuda|hip "
+ "archive and compiles and links the infer_batch .cu driver "
+ "(ruling R22; the host compiler links every other harness); "
+ "may carry arguments (\"nvcc -ccbin nvc++\"); NOT required -- "
+ "default: nvcc for cuda, hipcc for hip")
+ # Same dash-valued handling as --flags; see the comment above.
+ gate.add_argument("--devflags", default="", help="device compiler flags")
+ gate.add_argument("--out", default=".", help="directory for generated sources and gate-report.md")
+ gate.add_argument("--host-fallback", action="store_true",
+ help="drop the OMP_TARGET_OFFLOAD=MANDATORY requirement so the omp "
+ "backend can be exercised on a machine with no accelerator")
return p
@@ -61,7 +136,7 @@ def shape_of(name: str) -> str:
def _cmd_generate(args) -> int:
graph = load_graph(args.model, name=args.name)
- plan = build_plan(graph, dtype=_dtype_from_precision(args.precision))
+ plan = build_plan(graph, dtype=_dtype_from_precision(args.precision), embed=args.embed)
validate_model_name(plan.model)
outdir = Path(args.out)
outdir.mkdir(parents=True, exist_ok=True)
@@ -70,20 +145,39 @@ def _cmd_generate(args) -> int:
written = []
if "fortran" in langs:
- f90_path = outdir / f"{name}_model.f90"
+ f90_path = outdir / f"{name}_model.F90"
+ fmk_path = outdir / f"{name}_fortran.mk"
f90_path.write_text(emit_fortran(plan))
- written.append(f90_path)
+ fmk_path.write_text(emit_fortran_recipe(plan))
+ written += [f90_path, fmk_path]
if "c" in langs:
source, header = emit_c(plan)
+ recipe = emit_c_recipe(plan)
c_path = outdir / f"{name}.c"
h_path = outdir / f"{name}.h"
+ mk_path = outdir / f"{name}.mk"
+ cu_path = outdir / f"{name}_kernel.cu"
+ rt_path = outdir / "rosenna_rt.h"
c_path.write_text(source)
h_path.write_text(header)
- written += [c_path, h_path]
+ mk_path.write_text(recipe)
+ # The native batched kernel and its runtime map: built only when the
+ # recipe runs with ROSENNA_BACKEND=cuda|hip, inert otherwise.
+ cu_path.write_text(emit_kernel(plan))
+ rt_path.write_text(rt_header())
+ written += [c_path, h_path, mk_path, cu_path, rt_path]
- rwt_path = outdir / f"{name}.rwt"
- write_weights(plan, graph, rwt_path)
- written.append(rwt_path)
+ # An embedded plan has no weights file to write in either language: every
+ # weight is already an initialized `protected`/ROSENNA_CONST array baked into the
+ # generated source (controller ruling R3, flipped by Task 4: Fortran now
+ # embeds by default too, so this no longer depends on which languages
+ # were requested).
+ if plan.embed:
+ print(f"embedded weights ({plan.n_params} parameters)")
+ else:
+ rwt_path = outdir / f"{name}.rwt"
+ write_weights(plan, graph, rwt_path)
+ written.append(rwt_path)
for path in written:
print(path)
@@ -93,7 +187,7 @@ def _cmd_generate(args) -> int:
def _cmd_verify(args) -> int:
with tempfile.TemporaryDirectory() as workdir:
results = verify_model(args.model, args.lang, _dtype_from_precision(args.precision),
- args.cases, workdir)
+ args.cases, workdir, embed=args.embed, name=args.name)
all_ok = True
for r in results:
status = "ok" if r.ok else "FAIL"
@@ -102,10 +196,31 @@ def _cmd_verify(args) -> int:
return 0 if all_ok else 1
+def _cmd_gate(args) -> int:
+ return run_gate(cc=args.cc, fc=args.fc, flags=args.flags, backend=args.backend,
+ devcc=args.devcc, devflags=args.devflags, out=args.out,
+ host_fallback=args.host_fallback)
+
+
+def _layout(graph, names) -> str:
+ """`name[start:stop]` per tensor: where each graph input sits in x, each output in y."""
+ parts, off = [], 0
+ for n in names:
+ length = int(np.prod(graph.values[n].shape))
+ parts.append(f"{n}[{off}:{off + length}]")
+ off += length
+ return " ".join(parts)
+
+
def _cmd_info(args) -> int:
- graph = load_graph(args.model)
+ graph = load_graph(args.model, args.name)
for line in _describe_ops(graph):
print(line)
+ # The flat layouts a caller programs against: several inputs arrive
+ # concatenated in x, several outputs leave concatenated in y, both in
+ # declaration order.
+ print(f"x: {_layout(graph, graph.inputs)}")
+ print(f"y: {_layout(graph, graph.outputs)}")
try:
build_plan(graph)
except UnsupportedModel as e:
@@ -115,7 +230,31 @@ def _cmd_info(args) -> int:
return 0
+_DASH_VALUED_OPTIONS = ("--flags", "--devflags")
+
+
+def _join_dash_valued_options(argv: list[str]) -> list[str]:
+ """Let --flags/--devflags take a value that itself starts with '-' (e.g. -fopenmp).
+
+ argparse treats any token starting with a prefix character as a
+ candidate option string, even one no parser here defines, so `--flags
+ -fopenmp` (two argv entries) fails with "expected one argument" --
+ exactly the invocation shape `rosenna gpu-gate` needs for real compiler
+ flags. Folding it into one `--flags=-fopenmp` entry first sidesteps
+ argparse's option-likely-string heuristic entirely; the `=` form always
+ works because argparse never re-examines what follows `=`.
+ """
+ out = list(argv)
+ i = 0
+ while i < len(out) - 1:
+ if out[i] in _DASH_VALUED_OPTIONS and out[i + 1].startswith("-"):
+ out[i:i + 2] = [f"{out[i]}={out[i + 1]}"]
+ i += 1
+ return out
+
+
def main(argv: list[str] | None = None) -> int:
+ argv = _join_dash_valued_options(sys.argv[1:] if argv is None else argv)
args = build_parser().parse_args(argv)
try:
if args.command == "generate":
@@ -124,6 +263,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_verify(args)
if args.command == "info":
return _cmd_info(args)
+ if args.command == "gpu-gate":
+ return _cmd_gate(args)
raise AssertionError(f"unhandled command {args.command!r}")
except UnsupportedModel as e:
print(f"rosenna: {e}", file=sys.stderr)
diff --git a/python/rosenna/emit_c.py b/python/rosenna/emit_c.py
index 740c712..5bb93d0 100644
--- a/python/rosenna/emit_c.py
+++ b/python/rosenna/emit_c.py
@@ -1,9 +1,94 @@
"""Render a plan as a self-contained C source/header pair."""
from .abi import name_capacity, rank_capacity, status_code_comment
-from .plan import Plan
+import re
+
+from .plan import Plan, gru_initial_state, lstm_initial_state
_CTYPE = {"f32": "float", "f64": "double"}
_DTYPE_CODE = {"f32": 0, "f64": 1}
+_ITEMSIZE = {"f32": 4, "f64": 8}
+# The CUDA/HIP compiler guard. __HIPCC__ is defined by hip-clang itself for
+# any HIP compilation (and by the hipcc wrapper besides), so it is the one
+# HIP macro to test. __HIP__ is NOT a HIP-compilation signal: clang's OpenMP
+# AMDGPU device pass defines it from openmp_wrappers/math.h to borrow HIP's
+# device math, so a header that accepted it took the __device__ branch under
+# `amdclang -fopenmp --offload-arch=gfx90a` (ruling R23, HIP twin).
+_IS_CUDA = "defined(__CUDACC__)"
+_IS_HIP = "defined(__HIPCC__)"
+_CUDA_GUARD = f"#if {_IS_CUDA} || {_IS_HIP}"
+_NOT_CUDA_GUARD = "#if !defined(__CUDACC__) && !defined(__HIPCC__)"
+# Which half of the library this translation unit provides, set by the recipe
+# rather than sniffed from the compiler. .c is ALWAYS built by the host
+# compiler with its offload flags -- it owns the declare-target weights and the
+# `target update` that puts them on the device, which only that compiler can
+# make sense of -- and _kernel.cu owns every CUDA/HIP symbol. Detecting
+# __CUDACC__ here instead is what forced the recipe to hand the whole of
+# .c to nvcc, silently disabling the declare-target on the weights, so a
+# per-point OpenMP/OpenACC host had to link a second omp-backend archive.
+_NATIVE_GUARD = "#ifdef ROSENNA_NATIVE_KERNEL"
+_NOT_NATIVE_GUARD = "#ifndef ROSENNA_NATIVE_KERNEL"
+# Device-pass guard: nvcc defines __CUDA_ARCH__ and hipcc __HIP_DEVICE_COMPILE__
+# only while compiling for the device, so a header-inline function can read one
+# storage in its host instantiation and another in its device instantiation.
+# Each arch macro is tested together with its compiler macro (ruling R23):
+# clang's OpenMP nvptx device pass defines __CUDA_ARCH__ without __CUDACC__,
+# and there the host arrays, not the __constant__ table (which only the
+# CUDA/HIP guard declares), are the storage that exists.
+_DEVICE_PASS_GUARD = (f"#if ({_IS_CUDA} && defined(__CUDA_ARCH__)) || "
+ f"({_IS_HIP} && defined(__HIP_DEVICE_COMPILE__))")
+
+# Controller ruling R4. CUDA __constant__ memory is 64 KB per module, while
+# a model embeds by default below EMBED_THRESHOLD (1M parameters, up to 8 MB
+# of f64), so an embedded model whose weights exceed the constant budget must
+# be placed in ordinary device memory or nvcc rejects the header. The decision
+# is per model, made once at generation time, and the header records which it
+# took.
+#
+# The cut is 2 KB of weight bytes, not the 48 KB the 64-KB-per-module bank
+# would allow, and the reason is the constant *cache*, not the bank. Constant
+# memory is fast only while the working set fits a per-SM cache of a couple of
+# KB; past that every weight read misses. Measured on an A100 (ncu,
+# smsp__warp_issue_stalled_imc_miss_per_warp_active), infer_batch spends 71% of
+# its warp-issue stalls on constant-cache misses for gemm_big (23 KB of
+# weights) and 90% for batchnet (16 KB), against 0.09% for the same models
+# reading the same weights from ordinary device memory:
+#
+# model weight bytes __constant__ __device__ const
+# gemm_small 120 0.027 ns/pt 0.028 ns/pt
+# gemm_nobias 160 0.027 0.028
+# droplet 344 0.041 0.041
+# batchnet 15,904 6.731 2.384
+# gemm_big 23,208 3.574 1.423
+#
+# so 2 KB keeps the models that measure the same and moves out the ones that
+# pay 2.5-2.8x. This is calibrated for the shape this library targets: a
+# per-point closure with a handful of inputs, where the weights dominate the
+# cache. A model with a wide input streams enough of x through L1 to change
+# the balance -- synthetic 16-input models measure ~1.3x the other way -- so
+# if one of those ever turns up, this wants to become a generate-time flag
+# rather than a different constant.
+CONSTANT_MEMORY_LIMIT = 2 * 1024
+# The one-thread-per-point kernel's block size (emit_kernel).
+KERNEL_TILE = 128
+# Output columns a dense layer computes per pass over its input vector. Each
+# element of the input is loaded once per pass, so the loads of a per-thread
+# activation vector -- scratch memory on a GPU, and the bound on a kernel
+# with wide layers -- are amortised over GEMM_BLOCK dot products. On an
+# MI210 (reaction_patch, 18-128-128-2) this is 3.2x over one column per
+# pass; staging the weights in shared memory instead was slower than either.
+# A layer narrower than GEMM_BLOCK_MIN_IN keeps one column per pass: there
+# the extra accumulators cost registers and buy little or nothing (gemm_big,
+# 2-40 wide, ran 3x slower blocked at a million points; at 64 wide C gained
+# 10% and Fortran lost 20%; at 128 both gained 3.5x).
+GEMM_BLOCK = 8
+GEMM_BLOCK_MIN_IN = 96
+
+
+def gemm_block(op) -> int:
+ return GEMM_BLOCK if op.n_in >= GEMM_BLOCK_MIN_IN else 1
+# Embedding must be lossless: %.17g round-trips any f64, %.9g any f32
+# (Steele & White / Ryu-style shortest-exact-decimal bounds).
+_EMBED_FMT = {"f32": "%.9g", "f64": "%.17g"}
# Activation expressions, per plan dtype. An f32 plan must call the float
# intrinsics: tanh/exp on a float promote the whole expression to double, so
@@ -43,6 +128,59 @@ def _weight_size(shape) -> int:
return size
+def _c_weight_symbol(model: str, symbol: str) -> str:
+ """The C external identifier for a plan weight symbol (controller ruling R1).
+
+ plan.py names every model's weights `w0`, `b0`, `w1`, ... uniformly:
+ those symbols enter the plan hash and Fortran's module scope, where the
+ `module` keyword already isolates them, so plan.py stays as it is.
+ Dropping `static` from the C weight definitions (this task) gives them
+ external linkage, though, and two different models linked into one host
+ would then collide on `_w0`/`_b0`. Every C site that names a weight
+ array goes through this one helper, prefixed with the model name, so no
+ site can drift out of sync with another.
+ """
+ return f"{model}_{symbol}"
+
+
+def _c_weight_ref_macro(model: str, symbol: str) -> str:
+ """The identifier `infer` uses to read a file-loaded weight.
+
+ A file-loaded plan's header holds two different storages for the same
+ weight -- the host array `` and, in the device pass of nvcc or
+ hipcc, an entry of the per-translation-unit __constant__ pointer table
+ (see _emit_device_weight_table) -- and `infer` is emitted exactly once,
+ so it cannot spell either name directly. This macro (itself prefixed with
+ the model-qualified symbol, so it carries ruling R1's collision safety
+ same as every other external name here) is `#define`d to whichever of
+ the two the device-pass guard selects; `infer`'s body reads only this
+ name. An embedded plan has no such split -- its weights are one
+ ROSENNA_CONST array reachable in either pass -- so `infer` reads the
+ plain symbol directly there and never goes through this macro.
+ """
+ return f"ROSENNA_REF_{_c_weight_symbol(model, symbol)}"
+
+
+def _weight_ref(plan: Plan, model: str, symbol: str) -> str:
+ return _c_weight_symbol(model, symbol) if plan.embed else _c_weight_ref_macro(model, symbol)
+
+
+def _format_embedded_value(v: float, dtype: str) -> str:
+ """Format one embedded weight, losslessly, as a C literal of the plan's dtype.
+
+ %g drops the decimal point for an exact integer (`1.0` -> `"1"`), and `1f`
+ is not a floating-constant in C -- the `f` suffix is only legal directly
+ after a decimal point or an exponent -- so an f32 value with neither gets
+ one inserted before the suffix is appended.
+ """
+ s = _EMBED_FMT[dtype] % v
+ if dtype == "f32":
+ if not any(c in s for c in ".eE"):
+ s += ".0"
+ s += "f"
+ return s
+
+
def _weight_index_c(weight_by_symbol: dict, op) -> str:
"""Decide the accumulation index order from the op's own transB flag.
@@ -69,61 +207,613 @@ def _weight_index_c(weight_by_symbol: dict, op) -> str:
def emit_c(plan: Plan) -> tuple:
ctype = _CTYPE[plan.dtype]
header = _emit_header(plan, ctype)
- lines = []
- lines += _emit_source_head(plan, ctype)
- lines += _emit_load(plan)
- lines += _emit_init(plan)
- lines += _emit_infer(plan, ctype)
+ # nvcc and hipcc compile this file as C++ (-x cu / -x hip in the recipe)
+ # so that init can call the runtime, so every external definition sits
+ # in an extern "C" block matching the header's declarations; under a C
+ # compiler the block is not there.
+ lines = _emit_source_head(plan, ctype)
+ if plan.embed:
+ # Every weight is a ROSENNA_CONST array in the header, so the source
+ # has nothing to define or load; it holds only the OpenMP-fallback
+ # infer_batch. (Under nvcc/hipcc that macro is __constant__ or
+ # __device__ const; the nvcc form is validated on an A100 by the GPU
+ # gate, the hipcc form is not.)
+ pass
+ else:
+ lines += _emit_load(plan)
+ lines += _emit_upload(plan)
+ lines += _emit_init(plan)
+ lines += _emit_fallback_infer_batch(plan, ctype)
+ lines += ["#if defined(__cplusplus)", "}", "#endif"]
source = "\n".join(lines) + "\n"
return source, header
+def emit_c_recipe(plan: Plan) -> str:
+ """A Makefile fragment that builds lib.a for one of three backends.
+
+ .c is built by the HOST compiler with the host's offload flags under
+ every backend, because it owns the declare-target weights and the `target
+ update` that puts them on the device -- only that compiler can act on
+ those. ROSENNA_BACKEND=cuda|hip adds _kernel.cu, built by nvcc or
+ hipcc, which owns every CUDA/HIP symbol, and defines ROSENNA_NATIVE_KERNEL
+ so .c yields the batched entry points to it.
+
+ That is what lets ONE archive serve both call paths. Before, the cuda/hip
+ recipe handed .c to nvcc as well, which silently disabled the
+ declare-target on the weights it defines -- so a per-point OpenMP/OpenACC
+ host could not use a file-loaded cuda/hip archive at all and had to link a
+ second omp-backend one built by its own compiler.
+ """
+ n = plan.model
+ return f"""# Generated by rosenna. Builds lib{n}.a; ROSENNA_BACKEND selects the batched path.
+CC ?= gcc
+CFLAGS ?= -O2 -Wall -Wextra -std=c11
+ROSENNA_OFFLOAD_FLAGS ?=
+DEVFLAGS ?= -O2
+# Position-independent, and kept out of CFLAGS so overriding those does not
+# drop it. Objects in this archive get linked into executables the generator
+# knows nothing about, and nvcc links PIE by default: without -fPIC a host
+# compiler that does not default to it (nvc does not, where gcc on most
+# distributions does) yields "relocation R_X86_64_32 against `.rodata' can
+# not be used when making a PIE object".
+ROSENNA_PIC ?= -fPIC
+# cuda | hip | omp
+ROSENNA_BACKEND ?= omp
+
+ifeq ($(ROSENNA_BACKEND),cuda)
+DEVCC ?= nvcc
+lib{n}.a: {n}.o {n}_kernel.o
+\tar rcs $@ $^
+{n}_kernel.o: {n}_kernel.cu {n}.h rosenna_rt.h
+\t$(DEVCC) $(DEVFLAGS) -c $< -o $@
+{n}.o: {n}.c {n}.h
+\t$(CC) $(CFLAGS) $(ROSENNA_PIC) $(ROSENNA_OFFLOAD_FLAGS) -DROSENNA_NATIVE_KERNEL -c $< -o $@
+else ifeq ($(ROSENNA_BACKEND),hip)
+DEVCC ?= hipcc
+lib{n}.a: {n}.o {n}_kernel.o
+\tar rcs $@ $^
+{n}_kernel.o: {n}_kernel.cu {n}.h rosenna_rt.h
+\t$(DEVCC) $(DEVFLAGS) -x hip -c $< -o $@
+{n}.o: {n}.c {n}.h
+\t$(CC) $(CFLAGS) $(ROSENNA_PIC) $(ROSENNA_OFFLOAD_FLAGS) -DROSENNA_NATIVE_KERNEL -c $< -o $@
+else
+lib{n}.a: {n}.o
+\tar rcs $@ $^
+{n}.o: {n}.c {n}.h
+\t$(CC) $(CFLAGS) $(ROSENNA_PIC) $(ROSENNA_OFFLOAD_FLAGS) -c $< -o $@
+endif
+clean:
+\trm -f {n}.o {n}_kernel.o lib{n}.a
+.PHONY: clean
+"""
+
+
+def _embedded_weight_bytes(plan: Plan) -> int:
+ return plan.n_params * _ITEMSIZE[plan.dtype] if plan.embed else 0
+
+
def _emit_header(plan: Plan, ctype: str) -> str:
m = plan.model
guard = f"ROSENNA_{m.upper()}_H"
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
lines = [
f"#ifndef {guard}",
f"#define {guard}",
"",
"/* Generated by rosenna. Do not edit. */",
"",
- f"int {m}_init(const char *path);",
- f"void {m}_infer(const {ctype} *restrict x, {ctype} *restrict y);",
+ "#include ",
+ ]
+ if plan.embed:
+ lines.append("#include ")
+ else:
+ lines += [
+ "/* The runtime macros the per-translation-unit bind below needs; a",
+ " host C compiler never sees this include. */",
+ _CUDA_GUARD,
+ '#include "rosenna_rt.h"',
+ "#endif",
+ ]
+ lines.append("")
+ lines += _emit_device_macros(plan)
+ lines += [
+ "#if defined(__cplusplus)",
+ 'extern "C" {',
+ "#endif",
+ "",
+ ]
+ if not plan.embed:
+ lines += _emit_weight_declarations(plan, ctype)
+ lines += [
+ f"int {m}_init(const char *path);",
+ "",
+ ]
+ lines += [
+ "/* Batched inference over n points stored contiguously: point p reads",
+ f" x + p * {n_in} and writes y + p * {n_out}. x and y must already be on",
+ " the device; init is the only routine that transfers. This call",
+ " allocates nothing, copies nothing and never synchronizes; the caller",
+ f" owns the stream. Which implementation the library holds is fixed when",
+ f" lib{m}.a is built (ROSENNA_BACKEND in {m}.mk); the two are never",
+ " linked together.",
+ " cuda/hip backend: x and y are raw device pointers holding n * n_in",
+ " and n * n_out values; stream is a cudaStream_t / hipStream_t, or",
+ " NULL for the default stream; the launch is asynchronous on it.",
+ " omp backend: x and y are device pointers the host obtained from",
+ " omp_target_alloc or from use_device_ptr on data it mapped (on a",
+ " host-only build those are the host pointers and the loop runs on",
+ " the CPU); stream is ignored.",
+ f" Returns 0; 10 if a device allocation or copy failed in {m}_init",
+ " (cuda/hip backend of a file-loaded model; init never having been",
+ " called counts as that); 11 if the kernel launch failed (cuda/hip). */",
+ f"int {m}_infer_batch(int n, const {ctype} *ROSENNA_RESTRICT x, {ctype} *ROSENNA_RESTRICT y, void *stream);",
+ "",
+ f"/* Wait for every {m}_infer_batch launched on `stream` to finish. The",
+ " backend-agnostic way for a host that has no stream of its own -- an",
+ " OpenMP host whose next target region would otherwise race a cuda/hip",
+ " launch -- to order the two: StreamSynchronize in the cuda/hip",
+ " archive, a no-op in the omp one, whose loop is synchronous. Returns",
+ " 0, or 11 if the wait reported an error (an asynchronous fault in the",
+ " kernel surfaces here). */",
+ f"int {m}_sync(void *stream);",
"",
+ *([f"/* One sample, x and y device-resident as for infer_batch, one launch per op",
+ " with the thread index over the op's output elements and the intermediate",
+ " activations in static device buffers: the form for a model over a whole",
+ " field, whose activations are too large for a thread's locals. The buffers",
+ " are shared by every call, so calls on different streams must not overlap.",
+ " Asynchronous like infer_batch; wait with " + m + "_sync. Same return codes. */",
+ f"int {m}_infer_one(const {ctype} *ROSENNA_RESTRICT x, {ctype} *ROSENNA_RESTRICT y, void *stream);",
+ ""] if has_infer_one(plan) else []),
+ "#if defined(__cplusplus)",
+ "}",
"#endif",
+ "",
]
+ if not plan.embed:
+ lines += _emit_device_weight_table(plan, ctype)
+ lines += _emit_device_region(plan, ctype)
+ lines.append("#endif")
return "\n".join(lines) + "\n"
+def _emit_device_macros(plan: Plan) -> list:
+ """The macros at the top of the header, and nothing else defines them.
+
+ ROSENNA_CONST (controller ruling R4): the embedded weights go to
+ __constant__ only while their total size stays under
+ CONSTANT_MEMORY_LIMIT; a larger embedded model reads them from
+ __device__ const global memory instead, which is both what the 64 KB
+ per-module bank requires above 64 KB and, well below that, what the
+ per-SM constant cache makes faster -- see CONSTANT_MEMORY_LIMIT for the
+ measurements behind the 2 KB cut. Both are `static` so that each translation unit that includes
+ the header -- .c compiled as C++, _kernel.cu, and any host
+ .cu -- gets its own copy with internal linkage: a namespace-scope
+ __constant__ definition with external linkage in a header is a duplicate
+ symbol the moment two objects include it.
+ """
+ nbytes = _embedded_weight_bytes(plan)
+ if plan.embed and nbytes < CONSTANT_MEMORY_LIMIT:
+ const_qual = "static __constant__"
+ const_note = (f"static __constant__: the {nbytes} bytes of embedded weights",
+ f" fit the {CONSTANT_MEMORY_LIMIT}-byte constant-memory budget.")
+ elif plan.embed:
+ const_qual = "static __device__ const"
+ const_note = (f"static __device__ const: the {nbytes} bytes of embedded weights",
+ f" exceed the {CONSTANT_MEMORY_LIMIT}-byte constant-memory budget.")
+ else:
+ const_qual = "static __constant__"
+ const_note = ("static __constant__ (unused: this model loads its weights",
+ " from a file).")
+ if plan.embed:
+ stub_note = [
+ " ROSENNA_INFER_HOST_STUB is 1 in the host pass of a CUDA/HIP build: the",
+ " embedded weights are device storage there, so the host instantiation",
+ " of infer is a stub that asserts (a no-op under NDEBUG); call infer from",
+ " a kernel, or use infer_batch. Any other compiler computes on the host.",
+ ]
+ else:
+ stub_note = []
+ return [
+ "/* Under nvcc/hipcc: ROSENNA_DEVICE_FN = __host__ __device__ (infer is",
+ " callable from device code), ROSENNA_RESTRICT = __restrict__, and",
+ f" ROSENNA_CONST = {const_note[0]}",
+ const_note[1],
+ " Otherwise (plain C, or a host OpenMP/OpenACC build): ROSENNA_DEVICE_FN",
+ " is empty, ROSENNA_CONST = static const, and ROSENNA_RESTRICT is",
+ " __restrict__ in C++ or restrict in C. infer is separately wrapped in a",
+ " guarded OpenMP declare-target region with a guarded OpenACC routine-seq",
+ " pragma below; both are no-ops unless that compiler defines",
+ " _OPENMP/_OPENACC.",
+ *stub_note,
+ " The macros are redefined (#undef first) so that headers of several",
+ " models can share one translation unit without redefinition warnings. */",
+ "#undef ROSENNA_DEVICE_FN",
+ "#undef ROSENNA_CONST",
+ "#undef ROSENNA_RESTRICT",
+ "#undef ROSENNA_INFER_HOST_STUB",
+ "#undef ROSENNA_UNROLL",
+ "/* A dense layer's dot-product loop, unrolled where the compiler takes the",
+ " hint: clang keeps the loop rolled otherwise and each weight load's",
+ " latency is exposed before its FMA (3x on a small kernel, MI210). nvcc",
+ " and nvc unroll on their own and get no hint. */",
+ "#if defined(__clang__)",
+ f'#define ROSENNA_UNROLL _Pragma("unroll {GEMM_BLOCK}")',
+ "#elif defined(__GNUC__) && !defined(__NVCOMPILER) && !defined(__NVCC__)",
+ f'#define ROSENNA_UNROLL _Pragma("GCC unroll {GEMM_BLOCK}")',
+ "#else",
+ "#define ROSENNA_UNROLL",
+ "#endif",
+ _CUDA_GUARD,
+ "#define ROSENNA_DEVICE_FN __host__ __device__",
+ f"#define ROSENNA_CONST {const_qual}",
+ "#define ROSENNA_RESTRICT __restrict__",
+ *(["#if defined(__CUDA_ARCH__) || defined(__HIP_DEVICE_COMPILE__)",
+ "#define ROSENNA_INFER_HOST_STUB 0",
+ "#else",
+ "#define ROSENNA_INFER_HOST_STUB 1",
+ "#endif"] if plan.embed else ["#define ROSENNA_INFER_HOST_STUB 0"]),
+ "#else",
+ "#define ROSENNA_DEVICE_FN",
+ "#define ROSENNA_CONST static const",
+ "#define ROSENNA_INFER_HOST_STUB 0",
+ "#if defined(__cplusplus)",
+ "#define ROSENNA_RESTRICT __restrict__",
+ "#else",
+ "#define ROSENNA_RESTRICT restrict",
+ "#endif",
+ "#endif",
+ "",
+ ]
+
+
+def _emit_weight_declarations(plan: Plan, ctype: str) -> list:
+ """A file-loaded plan's weights, inside the header's extern "C" block.
+
+ The host arrays are declared under every compiler: _init fills
+ them from the file, and the host instantiation of infer reads them even
+ when nvcc or hipcc is the compiler. The `_dev` pointers exist
+ only under nvcc/hipcc: _init allocates and fills each one (status
+ 10 on failure) and _infer_batch passes them to the kernel. Under
+ a plain or OpenMP host build the guard is false and no device pointer
+ is even declared.
+ """
+ m = plan.model
+ if not plan.weights:
+ return []
+ lines = [
+ "/* Host arrays filled by init from the weights file. Under an offloading",
+ " OpenMP or OpenACC build they also have device copies, which init",
+ " updates once the file is read (the plan step, ruling R5). */",
+ ]
+ lines += _omp_declare_target_begin()
+ for w in plan.weights:
+ sym = _c_weight_symbol(m, w.symbol)
+ lines.append(f"extern {ctype} {sym}[{_weight_size(w.shape)}];")
+ lines += _omp_declare_target_end()
+ lines += _acc_declare(plan, "create")
+ lines += [
+ "",
+ "/* Device copies of the arrays above: allocated and filled by",
+ f" {m}_init on a cuda/hip build, read by the batched kernel; not",
+ " referenced under a plain or OpenMP host build. Indexed by device,",
+ " so a multi-GPU host can hold weights on several at once -- one set",
+ " of pointers would have meant the second init silently replaced the",
+ " first device's, and the kernel on that device would then read an",
+ " address belonging to another. */",
+ _CUDA_GUARD,
+ ]
+ for w in plan.weights:
+ sym = _c_weight_symbol(m, w.symbol)
+ lines.append(f"extern {ctype} *{sym}_dev[ROSENNA_MAX_DEVICES];")
+ lines += [
+ f"/* Called by {m}_init once the copies above exist: publishes them to",
+ f" the kernel's translation unit ({m}_kernel.cu, where it is defined).",
+ " Part of the plan step, not of the host API. */",
+ f"int {_device_bind(m)}(void);",
+ "#endif",
+ f"/* The cuda/hip half of init, defined in {m}_kernel.cu: allocates the",
+ " device copies of the weights and binds them. Called by",
+ f" {m}_init when {m}.c is built with -DROSENNA_NATIVE_KERNEL.",
+ " Declared unguarded on purpose: the caller is a HOST compilation of",
+ f" {m}.c, which does not take the CUDA/HIP branch above, so declaring",
+ " it there left the call implicit -- an error under any compiler that",
+ " enforces C99. A declaration nothing calls costs nothing. */",
+ f"int {m}_upload_device(void);",
+ "",
+ ]
+ return lines
+
+
+def _omp_declare_target_begin() -> list:
+ return ["#ifdef _OPENMP", "#pragma omp declare target", "#endif"]
+
+
+def _omp_declare_target_end() -> list:
+ return ["#ifdef _OPENMP", "#pragma omp end declare target", "#endif"]
+
+
+def _weight_symbol_list(plan: Plan) -> str:
+ return ", ".join(_c_weight_symbol(plan.model, w.symbol) for w in plan.weights)
+
+
+def _acc_declare(plan: Plan, clause: str) -> list:
+ """`#pragma acc declare (every weight)`, guarded by _OPENACC.
+
+ gcc -fopenacc rejects a `routine seq` function that reads a file-scope
+ array with no `declare` directive, so every weight array infer reads
+ carries one: `create` for the file-loaded host arrays (init then does
+ `update device`), `copyin` for the embedded constants.
+ """
+ if not plan.weights:
+ return []
+ return ["#ifdef _OPENACC", f"#pragma acc declare {clause}({_weight_symbol_list(plan)})", "#endif"]
+
+
+def _device_bind(model: str) -> str:
+ """The plan-step function in _kernel.cu that binds the weight table."""
+ return f"{model}_device_bind"
+
+
+def _device_weight_table(model: str) -> str:
+ """The per-translation-unit __constant__ table of device weight pointers."""
+ return f"{model}_devw"
+
+
+def _emit_device_weight_table(plan: Plan, ctype: str) -> list:
+ """How infer reaches a file-loaded weight from device code.
+
+ Device code cannot read a host global, and without relocatable device
+ code a __device__ or __constant__ variable cannot be declared extern
+ across translation units, so the device addresses the host holds in
+ `_dev` reach the kernel through this `static __constant__` table,
+ one copy per translation unit, which _device_bind (in the same
+ translation unit as the kernel) fills once, at the end of init, through
+ ROSENNA_MEMCPY_TO_SYMBOL. The ROSENNA_REF_ macros then
+ select the table entry in the device pass and the host array otherwise,
+ so infer's one body reads the right storage in each of its two
+ instantiations without ever naming host storage from device code.
+ """
+ m = plan.model
+ if not plan.weights:
+ return []
+ table = _device_weight_table(m)
+ lines = [
+ "/* Device-side view of the file-loaded weights: a per-translation-unit",
+ f" __constant__ table of the device pointers, filled once, after {m}_init,",
+ f" by {_device_bind(m)}_here below. Device code reads its own translation",
+ " unit's table; host code, under any compiler, reads the host arrays. */",
+ _CUDA_GUARD,
+ f"static __constant__ const {ctype} *{table}[{len(plan.weights)}];",
+ "#endif",
+ _DEVICE_PASS_GUARD,
+ ]
+ for k, w in enumerate(plan.weights):
+ lines.append(f"#define {_c_weight_ref_macro(m, w.symbol)} {table}[{k}]")
+ lines.append("#else")
+ for w in plan.weights:
+ lines.append(f"#define {_c_weight_ref_macro(m, w.symbol)} {_c_weight_symbol(m, w.symbol)}")
+ lines += ["#endif", ""]
+ nw = len(plan.weights)
+ lines += [
+ f"/* In a CUDA/HIP build, call {m}_device_bind_here() after EVERY call to {m}_init()",
+ f" in every translation unit whose kernels call {m}_infer. Embedded",
+ " models need nothing. Each translation unit holds its own copy of the",
+ " table above; this fills the including translation unit's copy from",
+ f" the device addresses init made (lib{m}.a does it for its own kernel).",
+ " Part of the plan step: it transfers, so never call it from a loop.",
+ " Returns 0, or 10 if init has not made the device copies. */",
+ _CUDA_GUARD,
+ f"static inline int {_device_bind(m)}_here(void) {{",
+ " int rosenna_dev = 0;",
+ " if (ROSENNA_GET_DEVICE(&rosenna_dev) != ROSENNA_OK) return 10;",
+ " if (rosenna_dev < 0 || rosenna_dev >= ROSENNA_MAX_DEVICES) return 13;",
+ f" const {ctype} *table[{nw}] = {{",
+ ]
+ for w in plan.weights:
+ lines.append(f" {_c_weight_symbol(m, w.symbol)}_dev[rosenna_dev],")
+ lines += [
+ " };",
+ f" for (int k = 0; k < {nw}; ++k) if (table[k] == 0) return 10;",
+ f" if (ROSENNA_MEMCPY_TO_SYMBOL({table}, table, sizeof table) != ROSENNA_OK) return 10;",
+ " return 0;",
+ "}",
+ "#endif",
+ "",
+ ]
+ return lines
+
+
+def _emit_device_region(plan: Plan, ctype: str) -> list:
+ """The declare-target region: embedded weight consts (if any) plus infer.
+
+ Every OpenMP pragma is guarded by `_OPENMP` and every OpenACC pragma by
+ `_OPENACC` -- gcc -Wall warns `ignoring '#pragma acc ...'
+ [-Wunknown-pragmas]` on an unguarded one under a compiler without
+ OpenACC. Under nvcc/hipcc both guards are false and ROSENNA_DEVICE_FN
+ supplies the CUDA decoration instead.
+ """
+ lines = ["#ifdef _OPENMP", "#pragma omp declare target", "#endif", ""]
+ if plan.embed:
+ lines += _emit_embedded_weights(plan, ctype)
+ lines += ["#ifdef _OPENACC", "#pragma acc routine seq", "#endif"]
+ lines += _emit_infer(plan, ctype)
+ lines += _emit_elem_functions(plan, ctype)
+ lines += ["#ifdef _OPENMP", "#pragma omp end declare target", "#endif", ""]
+ return lines
+
+
+def _emit_embedded_weights(plan: Plan, ctype: str) -> list:
+ m = plan.model
+ lines = []
+ for w in plan.weights:
+ sym = _c_weight_symbol(m, w.symbol)
+ values = ", ".join(_format_embedded_value(v, plan.dtype) for v in w.values)
+ lines.append(f"ROSENNA_CONST {ctype} {sym}[{_weight_size(w.shape)}] = {{ {values} }};")
+ lines += _acc_declare(plan, "copyin")
+ if plan.weights:
+ lines.append("")
+ return lines
+
+
def _emit_source_head(plan: Plan, ctype: str) -> list:
m = plan.model
- hash_bytes = ", ".join(f"0x{b:02x}" for b in bytes.fromhex(plan.hash()))
lines = [
"/* Generated by rosenna. Do not edit. */",
f'#include "{m}.h"',
"",
- "#include ",
- "#include ",
- "#include ",
- "#include ",
+ "#include ",
+ ]
+ if not plan.embed:
+ lines += [
+ "#include ",
+ "#include ",
+ "#include ",
+ ]
+ lines += [
"",
+ "/* nvcc and hipcc compile this file as C++ (-x cu / -x hip, see the",
+ " recipe); the definitions below then carry C linkage to match the",
+ " header's declarations, so C and Fortran hosts link unchanged. */",
+ "#if defined(__cplusplus)",
+ 'extern "C" {',
+ "#endif",
+ "",
+ ]
+ if plan.embed:
+ return lines
+ hash_bytes = ", ".join(f"0x{b:02x}" for b in bytes.fromhex(plan.hash()))
+ lines += [
f"static const unsigned char expected_hash[32] = {{ {hash_bytes} }};",
"",
]
+ # The header's `acc declare create` on the extern declarations already
+ # covers these definitions (gcc rejects a second declare for the same
+ # variable); the OpenMP declare-target region is repeated, which is
+ # allowed and keeps the definition self-describing.
+ lines += _omp_declare_target_begin()
for w in plan.weights:
- lines.append(f"static {ctype} {w.symbol}[{_weight_size(w.shape)}];")
+ lines.append(f"{ctype} {_c_weight_symbol(m, w.symbol)}[{_weight_size(w.shape)}];")
+ lines += _omp_declare_target_end()
+ # The _dev pointers are DEFINED in _kernel.cu, beside the
+ # runtime calls that fill them; the header declares them extern so any
+ # translation unit's device_bind_here can read them. They used to be
+ # defined here, which is why this file needed nvcc.
lines.append("")
return lines
+def _emit_upload(plan: Plan) -> list:
+ """Nothing: the cuda/hip half of init lives in _kernel.cu now.
+
+ Every line of it is a CUDA/HIP runtime call and rosenna_rt.h is #error for
+ any other compiler, so keeping it here is what forced the recipe to build
+ .c with nvcc -- which silently turned off the declare-target on the
+ weights this same file defines. .c now only calls
+ _upload_device(); see emit_kernel._emit_upload_device.
+ """
+ return []
+
+
+def _emit_fallback_infer_batch(plan: Plan, ctype: str) -> list:
+ """The OpenMP-target infer_batch: the omp backend, over device pointers.
+
+ This returns 0 or nothing, where the cuda/hip form returns 11 on a failed
+ launch, and that asymmetry is not an omission. OpenMP has no launch-status
+ API: a target region either runs or the runtime terminates the program
+ (OMP_TARGET_OFFLOAD=MANDATORY makes the second explicit). Nor can the
+ contract be checked from here -- omp_target_is_present takes a HOST
+ pointer, and ruling R5 says x and y are already device pointers, so it
+ would answer about the wrong thing. And "no device is present" cannot be
+ an error either: --backend omp --host-fallback is a documented,
+ deviceless configuration of this same code. So there is nothing to
+ report, rather than something unreported.
+
+ Controller ruling R5: x and y are already on the device (is_device_ptr
+ / deviceptr), so the loop path maps, allocates and synchronizes nothing.
+ Compiled only when the compiler is not nvcc or hipcc; the cuda/hip
+ backends define the same function in _kernel.cu instead. Under a
+ host compiler without -fopenmp/-fopenacc the pragmas are inert and this
+ is a plain loop over host pointers, which is also what use_device_ptr
+ yields on a host-only build. The loop is `teams distribute parallel for`,
+ one point per thread: `teams loop` maps one point per TEAM under nvc (the
+ ~30x cliff python/README.md describes) and under amdclang (3.5 us per
+ point on an MI210, seen on examples/surrogates/B), and the bias
+ reordering that lets nvc compile the per-point harnesses' distribute
+ parallel for applies to this loop too.
+ """
+ m = plan.model
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ return [
+ _NOT_NATIVE_GUARD,
+ f"int {m}_infer_batch(int n, const {ctype} *ROSENNA_RESTRICT x, {ctype} *ROSENNA_RESTRICT y, void *stream) {{",
+ " (void)stream;",
+ " if (n <= 0) return 0;",
+ *([f" /* {m}_infer's locals exceed a device thread's stack: one sample at a time,",
+ " each a launch per op over the static buffers. */",
+ " int status = 0;",
+ f" for (int p = 0; p < n && status == 0; ++p)",
+ f" status = {m}_infer_one(x + (size_t)p * {n_in}, y + (size_t)p * {n_out}, stream);",
+ " return status;",
+ "}"] if large_locals(plan) else [
+ "#if defined(_OPENMP)",
+ "#pragma omp target teams distribute parallel for is_device_ptr(x, y)",
+ "#elif defined(_OPENACC)",
+ "#pragma acc parallel loop deviceptr(x, y)",
+ "#endif",
+ f" for (int p = 0; p < n; ++p) {m}_infer(x + (size_t)p * {n_in}, y + (size_t)p * {n_out});",
+ " return 0;",
+ "}"]),
+ "",
+ "/* The loop above is synchronous, so there is nothing to wait for. */",
+ f"int {m}_sync(void *stream) {{",
+ " (void)stream;",
+ " return 0;",
+ "}",
+ *_emit_fallback_infer_one(plan, ctype),
+ "#endif",
+ "",
+ ]
+
+
+def _emit_fallback_infer_one(plan: Plan, ctype: str) -> list:
+ """infer_one for the omp backend: one target loop per op over static declare-target buffers."""
+ if not has_infer_one(plan):
+ return []
+ m = plan.model
+ lines = ["", "/* infer_one's activations: device-resident globals, not a thread's locals. */"]
+ lines += _omp_declare_target_begin()
+ lines += [f"static {ctype} {field_buffer(m, sym)}[{plan.buffers[sym]}];" for sym in scratch_symbols(plan)]
+ lines += _omp_declare_target_end()
+ lines += [f"int {m}_infer_one(const {ctype} *ROSENNA_RESTRICT x, {ctype} *ROSENNA_RESTRICT y, void *stream) {{",
+ " (void)stream;"]
+ for k, op in enumerate(plan.ops):
+ if op.kind == "alias":
+ continue
+ lines += ["#if defined(_OPENMP)",
+ "#pragma omp target teams distribute parallel for is_device_ptr(x, y)",
+ "#elif defined(_OPENACC)",
+ "#pragma acc parallel loop deviceptr(x, y)",
+ "#endif",
+ f" for (int e = 0; e < {elem_length(op)}; ++e) {elem_call(plan, k, op)};"]
+ lines += [" return 0;", "}"]
+ return lines
+
+
def _emit_load(plan: Plan) -> list:
if not plan.weights:
return []
+ m = plan.model
lines = [
+ "/* seen[k] is set when plan weight k has been filled: a table of contents",
+ " that names a tensor twice, or not at all, is status 9 (inconsistent),",
+ " never a zero array that infer then runs on. */",
"static int load_tensor(FILE *f, const char *name, int32_t namelen, long pos,",
- " int64_t length) {",
+ " int64_t length, unsigned char *seen) {",
]
- for w in plan.weights:
+ for k, w in enumerate(plan.weights):
+ c_sym = _c_weight_symbol(m, w.symbol)
# `length` comes from the file's own table of contents; a tensor whose
# declared byte count disagrees with the array it is about to fill
# means a corrupt file, so reject it rather than short-read into the
@@ -131,9 +821,11 @@ def _emit_load(plan: Plan) -> list:
lines.append(
f" if (namelen == {len(w.name)} && "
f"memcmp(name, {_c_string(w.name)}, {len(w.name)}) == 0) {{")
- lines.append(f" if (length != (int64_t)sizeof {w.symbol}) return 9;")
+ lines.append(f" if (seen[{k}]) return 9;")
+ lines.append(f" seen[{k}] = 1;")
+ lines.append(f" if (length != (int64_t)sizeof {c_sym}) return 9;")
lines.append(" if (fseek(f, pos, SEEK_SET) != 0) return 9;")
- lines.append(f" if (fread({w.symbol}, sizeof {w.symbol}, 1, f) != 1) return 9;")
+ lines.append(f" if (fread({c_sym}, sizeof {c_sym}, 1, f) != 1) return 9;")
lines.append(" return 0;")
lines.append(" }")
lines += [" return 7;", "}", ""]
@@ -181,6 +873,7 @@ def _emit_init(plan: Plan) -> list:
" int32_t toclen;",
" if (fread(&toclen, sizeof toclen, 1, f) != 1) { fclose(f); return 9; }",
" long data_start = 60L + (long)toclen;",
+ f" unsigned char seen[{len(plan.weights)}] = {{0}};",
" for (int32_t k = 0; k < ntensors; ++k) {",
" int32_t namelen;",
" if (fread(&namelen, sizeof namelen, 1, f) != 1) { fclose(f); return 9; }",
@@ -199,18 +892,601 @@ def _emit_init(plan: Plan) -> list:
" if (fread(&off, sizeof off, 1, f) != 1) { fclose(f); return 9; }",
" if (fread(&length, sizeof length, 1, f) != 1) { fclose(f); return 9; }",
" long tocpos = ftell(f);",
- " int status = load_tensor(f, name, namelen, data_start + (long)off, length);",
+ " int status = load_tensor(f, name, namelen, data_start + (long)off, length, seen);",
" if (status != 0) { fclose(f); return status; }",
" if (fseek(f, tocpos, SEEK_SET) != 0) { fclose(f); return 9; }",
" }",
" fclose(f);",
+ f" for (int k = 0; k < {len(plan.weights)}; ++k) if (!seen[k]) return 9;",
+ " /* The plan step's transfer: the device copies of the arrays, under",
+ " whichever offload family this build has. */",
+ "#ifdef _OPENMP",
+ f"#pragma omp target update to({_weight_symbol_list(plan)})",
+ "#endif",
+ "#ifdef _OPENACC",
+ f"#pragma acc update device({_weight_symbol_list(plan)})",
+ "#endif",
+ _NATIVE_GUARD,
+ f" return {m}_upload_device();",
+ "#else",
" return 0;",
+ "#endif",
"}",
"",
]
return lines
+def _nest(loops, body, indent=" "):
+ """Wrap body lines in for-loops over `loops`, a list of (counter, extent)."""
+ L = []
+ for k, (nm, ext) in enumerate(loops):
+ tail = " {" if k == len(loops) - 1 else ""
+ L.append(f"{indent}for (int {nm} = 0; {nm} < {ext}; ++{nm}){tail}")
+ L += [indent + " " + b for b in body]
+ L.append(indent + "}")
+ return L
+
+
+def _decode(loops, body, indent=" "):
+ """Recover the counters of `loops` from the flat row-major element index e, then the body."""
+ L = [f"{indent}int rem = e;"]
+ for nm, ext in reversed(loops):
+ L.append(f"{indent}const int {nm} = rem % {ext}; rem /= {ext};")
+ L.append(f"{indent}(void)rem;")
+ L += [indent + b for b in body]
+ return L
+
+
+def _flat_index(names, shape, base=""):
+ """Row-major flat index from per-axis counters, as a C/Fortran expression."""
+ expr = names[0]
+ for k in range(1, len(shape)):
+ expr = f"({expr} * {shape[k]} + {names[k]})"
+ return expr + base
+
+
+def _emit_transpose_c(op, dst, src):
+ """A real axis permutation: one loop per output axis, gathering from the source.
+
+ Only reached when the permutation moves an axis with extent > 1 -- plan.py
+ turns the rest into buffer aliases, since those move no bytes.
+ """
+ names = [f"c{k}" for k in range(len(op.out_shape))]
+ terms = [nm if st == 1 else f"{nm} * {st}" for nm, st in zip(names, op.perm_strides) if st]
+ body = [f"{dst}[{_flat_index(names, op.out_shape)}] = {src}[{' + '.join(terms) if terms else '0'}];"]
+ return list(zip(names, op.out_shape)), body
+
+
+def _emit_lstm_c(op, ctype, act, dst, src, h0, c0, wsym, rsym, bsym, outs, zero):
+ """One forward LSTM, ONNX default activations, as a plain sequential loop.
+
+ Gate order in W/R/B is ONNX's i, o, f, c -- not the i, f, c, o most
+ references use -- so the four blocks are read at 0H, 1H, 2H, 3H in that
+ order. B holds Wb and Rb back to back, both of which are added.
+ """
+ sp = op.lstm
+ H, I, B, T = sp.hidden, sp.input_size, sp.batch, sp.seq
+ h, c, g = sp.h_sym, sp.c_sym, sp.g_sym
+ L = [f" for (int i = 0; i < {B * H}; ++i) {h}[i] = {h0 + '[i]' if h0 else zero};",
+ f" for (int i = 0; i < {B * H}; ++i) {c}[i] = {c0 + '[i]' if c0 else zero};",
+ f" for (int t = 0; t < {T}; ++t)",
+ f" for (int b = 0; b < {B}; ++b) {{",
+ f" for (int k = 0; k < {4 * H}; ++k) {{",
+ f" {ctype} acc = {zero};",
+ f" for (int j = 0; j < {I}; ++j) "
+ f"acc += {src}[(t * {B} + b) * {I} + j] * {wsym}[k * {I} + j];",
+ f" for (int j = 0; j < {H}; ++j) "
+ f"acc += {h}[b * {H} + j] * {rsym}[k * {H} + j];"]
+ if bsym:
+ L.append(f" acc += {bsym}[k] + {bsym}[{4 * H} + k];")
+ L += [f" {g}[k] = acc;",
+ " }",
+ f" for (int j = 0; j < {H}; ++j) {{",
+ f" const {ctype} gi = {act['sigmoid'].format(v=f'{g}[j]')};",
+ f" const {ctype} go = {act['sigmoid'].format(v=f'{g}[{H} + j]')};",
+ f" const {ctype} gf = {act['sigmoid'].format(v=f'{g}[{2 * H} + j]')};",
+ f" const {ctype} gc = {act['tanh'].format(v=f'{g}[{3 * H} + j]')};",
+ f" const {ctype} cn = gf * {c}[b * {H} + j] + gi * gc;",
+ f" {c}[b * {H} + j] = cn;",
+ f" {h}[b * {H} + j] = go * {act['tanh'].format(v='cn')};",
+ *([f" {dst}[(t * {B} + b) * {H} + j] = {h}[b * {H} + j];"]
+ if sp.emit_y else []),
+ " }",
+ " }"]
+ # outs is positional: [0] is Y_h and [1] is Y_c, "" for one nothing reads.
+ for sym, state in zip(outs, (h, c)):
+ if sym:
+ L.append(f" for (int i = 0; i < {B * H}; ++i) {sym}[i] = {state}[i];")
+ return L
+
+
+def _emit_gru_c(op, ctype, act, dst, src, h0, wsym, rsym, bsym, outs, zero):
+ """One forward GRU, ONNX default activations, as a plain sequential loop.
+
+ Gate order in W/R/B is z, r, h. B is 6*hidden: the three W biases at 0H,
+ 1H, 2H and the three R biases at 3H, 4H, 5H.
+
+ The z and r gates are the ordinary form -- X.W + H.R + Wb + Rb -- but the h
+ gate cannot join their loop, because it needs r, which is only known once
+ that loop has finished. It gets its own pass, and which pass depends on
+ `linear_before_reset`: the reset gate multiplies the STATE before the
+ recurrent matmul when it is 0, and the matmul's RESULT when it is 1. The
+ two agree only where r is 1, so both are emitted rather than one assumed.
+ """
+ sp = op.gru
+ H, I, B, T = sp.hidden, sp.input_size, sp.batch, sp.seq
+ h, g = sp.h_sym, sp.g_sym
+ L = [f" for (int i = 0; i < {B * H}; ++i) {h}[i] = {h0 + '[i]' if h0 else zero};",
+ f" for (int t = 0; t < {T}; ++t)",
+ f" for (int b = 0; b < {B}; ++b) {{",
+ f" for (int k = 0; k < {2 * H}; ++k) {{",
+ f" {ctype} acc = {zero};",
+ f" for (int j = 0; j < {I}; ++j) "
+ f"acc += {src}[(t * {B} + b) * {I} + j] * {wsym}[k * {I} + j];",
+ f" for (int j = 0; j < {H}; ++j) "
+ f"acc += {h}[b * {H} + j] * {rsym}[k * {H} + j];"]
+ if bsym:
+ L.append(f" acc += {bsym}[k] + {bsym}[{3 * H} + k];")
+ L += [f" {g}[k] = {act['sigmoid'].format(v='acc')};",
+ " }",
+ f" for (int j = 0; j < {H}; ++j) {{",
+ f" {ctype} acc = {zero};",
+ f" for (int m = 0; m < {I}; ++m) "
+ f"acc += {src}[(t * {B} + b) * {I} + m] * {wsym}[({2 * H} + j) * {I} + m];"]
+ if bsym:
+ L.append(f" acc += {bsym}[{2 * H} + j];")
+ if sp.linear_before_reset:
+ L += [f" {ctype} rh = {zero};",
+ f" for (int m = 0; m < {H}; ++m) "
+ f"rh += {h}[b * {H} + m] * {rsym}[({2 * H} + j) * {H} + m];"]
+ if bsym:
+ L.append(f" rh += {bsym}[{5 * H} + j];")
+ L.append(f" acc += {g}[{H} + j] * rh;")
+ else:
+ L.append(f" for (int m = 0; m < {H}; ++m) "
+ f"acc += {g}[{H} + m] * {h}[b * {H} + m] * {rsym}[({2 * H} + j) * {H} + m];")
+ if bsym:
+ L.append(f" acc += {bsym}[{5 * H} + j];")
+ L += [f" {g}[{2 * H} + j] = {act['tanh'].format(v='acc')};",
+ " }",
+ # A separate pass: the gates above read the PREVIOUS state for every
+ # j, so updating it inside that loop would feed j's new value to the
+ # gates of every j after it.
+ f" for (int j = 0; j < {H}; ++j) {{",
+ f" const {ctype} hn = (1 - {g}[j]) * {g}[{2 * H} + j] "
+ f"+ {g}[j] * {h}[b * {H} + j];",
+ f" {h}[b * {H} + j] = hn;",
+ *([f" {dst}[(t * {B} + b) * {H} + j] = hn;"] if sp.emit_y else []),
+ " }",
+ " }"]
+ if outs and outs[0]:
+ L.append(f" for (int i = 0; i < {B * H}; ++i) {outs[0]}[i] = {h}[i];")
+ return L
+
+
+def _concat_sources(op, runtime_names, const_names) -> list:
+ """The operands in ONNX order, each as (array name, block length)."""
+ rt, ct, out = iter(runtime_names), iter(const_names), []
+ for is_const, block in zip(op.concat.consts, op.concat.blocks):
+ out.append((next(ct) if is_const else next(rt), block))
+ return out
+
+
+def _emit_concat_c(op, dst, runtime_names, const_names):
+ """Concat along an axis: output element (o, i) comes from the operand whose block holds i."""
+ cc = op.concat
+ srcs = _concat_sources(op, runtime_names, const_names)
+ row = sum(cc.blocks)
+ body, off = [], 0
+ for k, (name, block) in enumerate(srcs):
+ cond = f"{'if' if k == 0 else 'else if'} (i < {off + block})" if k < len(srcs) - 1 else "else"
+ body.append(f"{cond} {dst}[o * {row} + i] = {name}[o * {block} + i - {off}];")
+ off += block
+ return [("o", cc.outer), ("i", row)], body
+
+
+def _emit_add_c(op, dst, src, wsym):
+ """Elementwise add of a broadcast constant: one loop per output axis.
+
+ Iterating the axes rather than the flat extent is what makes the constant's
+ index affine -- `c1 * stride1 + ...` with the broadcast axes contributing
+ nothing -- instead of a decomposition with divisions inside the loop.
+ """
+ bc = op.bcast
+ names = [f"c{k}" for k in range(len(bc.out_shape))]
+ flat = _flat_index(names, bc.out_shape)
+ terms = [nm if st == 1 else f"{nm} * {st}" for nm, st in zip(names, bc.strides) if st]
+ widx = " + ".join(terms) if terms else "0"
+ return list(zip(names, bc.out_shape)), [f"{dst}[{flat}] = {src}[{flat}] + {wsym}[{widx}];"]
+
+
+def _literal_c(value: float, ctype: str) -> str:
+ """The pad value as a C literal, at the generated code's precision."""
+ return f"{value!r}f" if ctype == "float" else repr(float(value))
+
+
+def _emit_pad_c(op, ctype: str, dst: str, src: str):
+ """Constant Pad: loop the output, read the input where the shift is in range.
+
+ Only axes that are actually padded get a bounds test -- on an unpadded axis
+ the output index IS the input index, so a test there would always pass and
+ would only make the generated nest harder to read.
+ """
+ pd = op.pad
+ names = [f"c{k}" for k in range(len(pd.out_shape))]
+ shifted, checks, pre = [], [], []
+ for k, (nm, b) in enumerate(zip(names, pd.begins)):
+ if b == 0 and pd.in_shape[k] == pd.out_shape[k]:
+ shifted.append(nm)
+ continue
+ # A negative begin is a crop: the output reads FURTHER into the input,
+ # so it is spelled as an addition. `(c - -1)` is legal C and a syntax
+ # error in Fortran, which is what made this worth spelling out rather
+ # than letting the sign fall out of the arithmetic.
+ expr = f"({nm} - {b})" if b > 0 else f"({nm} + {-b})" if b < 0 else nm
+ n_in = pd.in_shape[k]
+ if pd.mode in ("edge", "reflect"):
+ # Into a named local, not inlined: the expression appears three
+ # times in the ternary, and four of them in one subscript ran past
+ # Fortran's 132-column limit in the twin emitter. One name per
+ # padded axis keeps both readable and evaluates it once.
+ m = (f"{expr} < 0 ? 0 : ({expr} >= {n_in} ? {n_in - 1} : {expr})"
+ if pd.mode == "edge" else
+ f"{expr} < 0 ? -{expr} : ({expr} >= {n_in} ? {2 * (n_in - 1)} - {expr} : {expr})")
+ pre.append(f"const int pi{k} = {m};")
+ shifted.append(f"pi{k}")
+ else:
+ shifted.append(expr)
+ # Only a positive begin can put the read before the input; a crop
+ # cannot, so that half of the test would always pass.
+ if b > 0:
+ checks.append(f"{expr} >= 0")
+ checks.append(f"{expr} < {n_in}")
+ out_idx = _flat_index(names, pd.out_shape)
+ in_idx = _flat_index(shifted, pd.in_shape)
+ val = _literal_c(pd.value, ctype)
+ if not checks:
+ # edge and reflect always land on a real element, so there is no test
+ # and no pad value -- the index map is the whole of the operator.
+ return list(zip(names, pd.out_shape)), pre + [f"{dst}[{out_idx}] = {src}[{in_idx}];"]
+ return list(zip(names, pd.out_shape)), pre + [
+ f"{dst}[{out_idx}] = ({' && '.join(checks)}) ? {src}[{in_idx}] : {val};"]
+
+
+def _emit_softmax_c(op, ctype: str, dtype: str, dst: str, src: str, zero: str):
+ """Last-axis Softmax: max, then exp into the destination, then normalise.
+
+ Subtracting the row maximum before exponentiating is what keeps a logit of
+ +800 from overflowing to inf; it cancels exactly in the ratio, so it costs
+ only the extra pass.
+
+ The maximum uses `v > mx`, which the NaN rule in doc/adding-an-operator.md tells
+ you not to write -- deliberately, and this is the one op where it is
+ right. A NaN must LOSE the maximum here: a NaN-sticky maximum would make
+ every exponent NaN - NaN, whereas letting the NaN lose keeps `mx` a real
+ number, so `exp(NaN - mx)` is NaN, the row sum is NaN, and every output in
+ that row is NaN. The NaN propagates through the sum instead of through the
+ maximum, and no finite input can overflow on the way.
+ """
+ sm = op.softmax
+ expf = "expf" if dtype == "f32" else "exp"
+ c = sm.axis_len
+ at = f"n * {c}"
+ return [("n", sm.outer)], [
+ f"{ctype} mx = {src}[{at}];",
+ f"for (int j = 1; j < {c}; ++j) {{",
+ f" const {ctype} v = {src}[{at} + j];",
+ " if (v > mx) mx = v;",
+ "}",
+ f"{ctype} s = {zero};",
+ f"for (int j = 0; j < {c}; ++j) {{",
+ f" const {ctype} e = {expf}({src}[{at} + j] - mx);",
+ f" {dst}[{at} + j] = e;",
+ " s += e;",
+ "}",
+ f"for (int j = 0; j < {c}; ++j) {dst}[{at} + j] = {dst}[{at} + j] / s;",
+ ]
+
+
+def _emit_spatial_c(op, ctype, dst, src, weight_sym, bias_sym, zero):
+ """A 2-D Conv / MaxPool / AveragePool as an explicit loop nest over flat buffers.
+
+ Buffers stay rank 1 whatever the value's logical rank: NCHW is flattened
+ row-major and the index arithmetic is written out, which keeps one buffer
+ model for dense and spatial ops alike and keeps every bound a literal.
+
+ The `continue` on an out-of-range (ih, iw) is what implements padding:
+ nothing is materialised, a pad cell simply contributes nothing. That is
+ exactly right for Conv (pad = 0 contributes 0) and for MaxPool (ONNX pads
+ with -inf, i.e. a pad cell never wins); AveragePool needs to know how many
+ cells were real, which is what `cnt` counts.
+ """
+ sp = op.spatial
+ # AveragePool divides by the count of in-bounds cells only when a window
+ # can actually reach outside the input and the caller did not ask for the
+ # full-kernel divisor; otherwise the divisor is a literal.
+ needs_count = (op.kind == "avgpool"
+ and not (sp.every_window_is_inside or sp.count_include_pad))
+ L = []
+ # Grouped Conv: output channel oc belongs to group oc/c_out_per_group and
+ # reads only that group's c_in_per_group input channels, so the loop bound
+ # is the per-group count and the input channel is offset by the group. For
+ # group=1 c_in_per_group == c_in and every expression below collapses to
+ # exactly what it was, so an ordinary convolution emits identical code.
+ cpg = sp.c_in_per_group or sp.c_in
+ # The group's first input channel, hoisted: it depends only on oc, so
+ # recomputing it per element would put an integer division in the
+ # innermost index expression (and gfortran warns about the division under
+ # -Winteger-division there, which is a fair complaint about the shape of
+ # the code rather than a false positive).
+ in_c = "(icg + ic)" if sp.grouped else "ic"
+ idx_in = f"((n * {sp.c_in} + {in_c}) * {sp.h_in} + ih) * {sp.w_in} + iw"
+ idx_out = f"((n * {sp.c_out} + oc) * {sp.h_out} + oh) * {sp.w_out} + ow"
+ loops = [("n", sp.n), ("oc", sp.c_out), ("oh", sp.h_out), ("ow", sp.w_out)]
+
+ if op.kind == "conv":
+ L.append(f"{ctype} acc = {zero};")
+ if sp.grouped:
+ L.append(f"const int icg = oc / {sp.c_out_per_group} * {cpg};")
+ L.append(f"for (int ic = 0; ic < {cpg}; ++ic)")
+ elif op.kind == "maxpool":
+ # The first in-range cell seeds the running maximum; `seen` makes that
+ # independent of any sentinel value, so a window of all -inf inputs
+ # still yields -inf rather than a made-up number.
+ L.append(f"{ctype} best = {zero};")
+ L.append("int seen = 0;")
+ L.append("const int ic = oc;")
+ else:
+ L.append(f"{ctype} acc = {zero};")
+ # Only when the divisor is the count of cells that fell inside. With a
+ # constant divisor nothing reads it, and a counter that is incremented
+ # and never read is a warning clang reports and gcc does not.
+ if needs_count:
+ L.append("int cnt = 0;")
+ L.append("const int ic = oc;")
+
+ L.append(f"for (int kh = 0; kh < {sp.kh}; ++kh)")
+ L.append(f"for (int kw = 0; kw < {sp.kw}; ++kw) {{")
+ L.append(f" const int ih = oh * {sp.sh} - {sp.ph} + kh * {sp.dh};")
+ L.append(f" const int iw = ow * {sp.sw} - {sp.pw} + kw * {sp.dw};")
+ L.append(f" if (ih < 0 || ih >= {sp.h_in} || iw < 0 || iw >= {sp.w_in}) continue;")
+ if op.kind == "conv":
+ widx = f"((oc * {cpg} + ic) * {sp.kh} + kh) * {sp.kw} + kw"
+ L.append(f" acc += {src}[{idx_in}] * {weight_sym}[{widx}];")
+ L.append("}")
+ elif op.kind == "maxpool":
+ L.append(f" const {ctype} v = {src}[{idx_in}];")
+ # !(v <= best), not (v > best): a NaN loses every comparison, so the
+ # naive form drops it. This library is linked into solvers where a NaN
+ # out of a diverged run is the signal, so it has to survive a pool.
+ L.append(" if (!seen || !(v <= best)) { best = v; seen = 1; }")
+ L.append("}")
+ else:
+ L.append(f" acc += {src}[{idx_in}];")
+ if needs_count:
+ L.append(" ++cnt;")
+ L.append("}")
+
+ if op.kind == "conv":
+ if bias_sym:
+ L.append(f"acc += {bias_sym}[oc];")
+ L.append(f"{dst}[{idx_out}] = acc;")
+ elif op.kind == "maxpool":
+ L.append(f"{dst}[{idx_out}] = best;")
+ else:
+ full = sp.kh * sp.kw
+ if not needs_count:
+ # No pad cell can fall in a window, or the caller asked for the
+ # full-kernel divisor: a literal either way.
+ L.append(f"{dst}[{idx_out}] = acc / ({ctype}){full};")
+ else:
+ L.append(f"{dst}[{idx_out}] = cnt ? acc / ({ctype})cnt : {zero};")
+ return loops, L
+
+
+def _emit_gemm_c(op, ctype, dst, src, weight_sym, idx_expr, bias_sym, zero):
+ """A dense layer: output (r, i) is the dot product of input row r with weight column i.
+
+ The bias is added AFTER the dot product, not used to seed the
+ accumulator. Seeding it from a declare-target array is what makes nvc
+ refuse to generate a `distribute parallel for` body (it emits a kernel
+ that traps); adding it afterwards compiles, and unlocks a ~19x faster
+ per-point offload loop. See doc/nvhpc_teams_mapping/. emit_fortran
+ does the same, so the two backends stay bit-comparable. `r` indexes
+ independent rows sharing one weight (1 for a dense per-point model); it
+ is only emitted when there is more than one.
+ """
+ ri, ro = (f"r * {op.n_in} + ", f"r * {op.n_out} + ") if op.rows > 1 else ("", "")
+ loops = ([("r", op.rows)] if op.rows > 1 else []) + [("i", op.n_out)]
+ body = [f"{ctype} acc = {zero};",
+ f"for (int j = 0; j < {op.n_in}; ++j) acc += {src}[{ri}j] * {weight_sym}[{idx_expr}];"]
+ if bias_sym:
+ body.append(f"acc += {bias_sym}[i];")
+ body.append(f"{dst}[{ro}i] = acc;")
+ return loops, body
+
+
+def _emit_gemm_blocked_c(op, ctype, dst, src, weight_sym, idx_expr, bias_sym, zero, block=None):
+ """The nested-loop form of a dense layer, `block` output columns per pass over the input."""
+ block = block or gemm_block(op)
+ ri, ro = (f"r * {op.n_in} + ", f"r * {op.n_out} + ") if op.rows > 1 else ("", "")
+ col = lambda k: re.sub(r"\bi\b", f"(i + {k})", idx_expr)
+ L = []
+ if op.rows > 1:
+ L.append(f" for (int r = 0; r < {op.rows}; ++r) {{")
+ ind = " " if op.rows > 1 else " "
+ full = (op.n_out // block) * block if block > 1 else 0
+ if full:
+ L += [f"{ind}for (int i = 0; i < {full}; i += {block}) {{",
+ f"{ind} {ctype} " + ", ".join(f"a{k} = {zero}" for k in range(block)) + ";",
+ f"{ind} ROSENNA_UNROLL",
+ f"{ind} for (int j = 0; j < {op.n_in}; ++j) {{",
+ f"{ind} const {ctype} sj = {src}[{ri}j];",
+ *[f"{ind} a{k} += sj * {weight_sym}[{col(k)}];" for k in range(block)],
+ f"{ind} }}",
+ *[f"{ind} {dst}[{ro}i + {k}] = a{k}" + (f" + {bias_sym}[i + {k}];" if bias_sym else ";")
+ for k in range(block)],
+ f"{ind}}}"]
+ if full < op.n_out:
+ L += [f"{ind}for (int i = {full}; i < {op.n_out}; ++i) {{",
+ f"{ind} {ctype} acc = {zero};",
+ f"{ind} for (int j = 0; j < {op.n_in}; ++j) acc += {src}[{ri}j] * {weight_sym}[{idx_expr}];",
+ f"{ind} {dst}[{ro}i] = acc" + (f" + {bias_sym}[i];" if bias_sym else ";"),
+ f"{ind}}}"]
+ if op.rows > 1:
+ L.append(" }")
+ return L
+
+
+def _op_pieces(plan: Plan, ctype: str, op, dst, src, extra_srcs=None):
+ """(loops, body) for one op, or None for an alias / an LSTM (which has no per-element form).
+
+ `dst`/`src` are the array names the body reads and writes; `extra_srcs`
+ names a Concat's further runtime operands.
+ """
+ m = plan.model
+ act = _ACT_C[plan.dtype]
+ zero = _ZERO[plan.dtype]
+ weight_by_symbol = {w.symbol: w for w in plan.weights}
+ if op.kind == "gemm":
+ return _emit_gemm_c(op, ctype, dst, src, _weight_ref(plan, m, op.weight),
+ _weight_index_c(weight_by_symbol, op),
+ _weight_ref(plan, m, op.bias) if op.bias else None, zero)
+ if op.kind == "transpose":
+ return _emit_transpose_c(op, dst, src)
+ if op.kind == "add":
+ return _emit_add_c(op, dst, src, _weight_ref(plan, m, op.weight))
+ if op.kind == "concat":
+ return _emit_concat_c(op, dst, [src] + list(extra_srcs or []),
+ [_weight_ref(plan, m, sym) for sym in op.concat_syms])
+ if op.kind == "copy":
+ soff = f"{op.src_offset} + " if op.src_offset else ""
+ doff = f"{op.dst_offset} + " if op.dst_offset else ""
+ return [("i", op.n_out)], [f"{dst}[{doff}i] = {src}[{soff}i];"]
+ if op.kind == "pad":
+ return _emit_pad_c(op, ctype, dst, src)
+ if op.kind == "softmax":
+ return _emit_softmax_c(op, ctype, plan.dtype, dst, src, zero)
+ if op.kind in ("conv", "maxpool", "avgpool"):
+ return _emit_spatial_c(op, ctype, dst, src,
+ _weight_ref(plan, m, op.weight) if op.weight else None,
+ _weight_ref(plan, m, op.bias) if op.bias else None, zero)
+ if op.kind in act:
+ return [("i", op.n_out)], [f"{dst}[i] = {act[op.kind].format(v=f'{src}[i]')};"]
+ if op.kind in ("alias", "lstm", "gru"):
+ return None
+ raise AssertionError(f"unhandled op kind {op.kind!r}")
+
+
+def _emit_op_sequence(plan: Plan, ctype: str) -> list:
+ """The op sequence over plan.assignment's buffers, as infer runs it."""
+ m = plan.model
+ act = _ACT_C[plan.dtype]
+ lines = []
+ for op in plan.ops:
+ if op.kind == "alias":
+ continue
+ dst, src = plan.assignment.get(op.out), plan.assignment[op.inp]
+ if op.kind == "gru":
+ h0 = gru_initial_state(op, lambda sym: _weight_ref(plan, m, sym), plan.assignment)
+ lines += _emit_gru_c(
+ op, ctype, act, dst, src, h0,
+ _weight_ref(plan, m, op.weight), _weight_ref(plan, m, op.weight2),
+ _weight_ref(plan, m, op.bias) if op.bias else None,
+ [plan.assignment[o] if o else "" for o in op.outs], _ZERO[plan.dtype])
+ continue
+ if op.kind == "lstm":
+ h0, c0 = lstm_initial_state(op, lambda sym: _weight_ref(plan, m, sym), plan.assignment)
+ lines += _emit_lstm_c(
+ op, ctype, act, dst, src, h0, c0,
+ _weight_ref(plan, m, op.weight), _weight_ref(plan, m, op.weight2),
+ _weight_ref(plan, m, op.bias) if op.bias else None,
+ [plan.assignment[o] if o else "" for o in op.outs], _ZERO[plan.dtype])
+ continue
+ if op.kind == "gemm":
+ weight_by_symbol = {w.symbol: w for w in plan.weights}
+ lines += _emit_gemm_blocked_c(op, ctype, dst, src, _weight_ref(plan, m, op.weight),
+ _weight_index_c(weight_by_symbol, op),
+ _weight_ref(plan, m, op.bias) if op.bias else None,
+ _ZERO[plan.dtype])
+ continue
+ loops, body = _op_pieces(plan, ctype, op, dst, src, [plan.assignment[n] for n in op.extra_in])
+ lines += _nest(loops, body)
+ return lines
+
+
+def has_infer_one(plan: Plan) -> bool:
+ """infer_one runs one op per launch; an LSTM is a sequence, so a plan with one has no infer_one."""
+ return not any(op.kind in ("lstm", "gru") for op in plan.ops)
+
+
+def _elem_fn(model: str, k: int) -> str:
+ return f"{model}_op{k}"
+
+
+def _elem_params(op, ctype: str) -> str:
+ extra = "".join(f", const {ctype} *ROSENNA_RESTRICT s{j + 1}" for j in range(len(op.extra_in)))
+ return f"int e, const {ctype} *ROSENNA_RESTRICT src, {ctype} *ROSENNA_RESTRICT dst{extra}"
+
+
+def _emit_elem_functions(plan: Plan, ctype: str) -> list:
+ """One device-decorated function per op computing output element e: what infer_one launches over."""
+ if not has_infer_one(plan):
+ return []
+ m = plan.model
+ lines = [f"/* {m}_infer_one runs one op per launch, the thread index over the op's output",
+ " elements, through these; the same op bodies infer runs in nested loops. */"]
+ for k, op in enumerate(plan.ops):
+ if op.kind == "alias":
+ continue
+ pieces = _op_pieces(plan, ctype, op, "dst", "src", [f"s{j + 1}" for j in range(len(op.extra_in))])
+ lines.append(f"static inline ROSENNA_DEVICE_FN void {_elem_fn(m, k)}({_elem_params(op, ctype)}) {{")
+ if plan.embed:
+ lines += ["#if ROSENNA_INFER_HOST_STUB", " (void)e; (void)src; (void)dst;",
+ *[f" (void)s{j + 1};" for j in range(len(op.extra_in))],
+ f' assert(0 && "rosenna: {m}_infer_one is device-only in a CUDA/HIP build");',
+ "#else"]
+ lines += _decode(*pieces)
+ if plan.embed:
+ lines.append("#endif")
+ lines += ["}", ""]
+ return lines
+
+
+def elem_length(op) -> int:
+ """How many output elements op has: the launch/loop extent of its per-element function."""
+ return op.n_out * op.rows if op.kind == "gemm" else op.n_out
+
+
+def elem_call(plan: Plan, k: int, op) -> str:
+ """`_op(e, src, dst, ...)` over infer_one's buffers (x and y are its arguments)."""
+ m = plan.model
+ args = [field_buffer(m, plan.assignment[op.inp]), field_buffer(m, plan.assignment[op.out])]
+ args += [field_buffer(m, plan.assignment[n]) for n in op.extra_in]
+ return f"{_elem_fn(m, k)}(e, {', '.join(args)})"
+
+
+def scratch_symbols(plan: Plan) -> list:
+ return sorted((s for s in plan.buffers if s not in ("x", "y")), key=lambda s: int(s[1:]))
+
+
+# Bytes of per-point locals above which infer must not be instantiated as a
+# device thread's body: a thread's stack is 128 KB on AMD (hipcc refuses the
+# kernel) and less on NVIDIA. A whole-field model (a conv net over a grid)
+# is the case; its infer_batch runs infer_one per point instead.
+LOCALS_LIMIT = 64 * 1024
+
+
+def large_locals(plan: Plan) -> bool:
+ return sum(plan.buffers[sym] for sym in scratch_symbols(plan)) * _ITEMSIZE[plan.dtype] > LOCALS_LIMIT
+
+
+def field_buffer(model: str, sym: str) -> str:
+ """The static device buffer infer_one uses for scratch symbol `sym` (x and y are its arguments)."""
+ return sym if sym in ("x", "y") else f"{model}_f_{sym}"
+
+
def _emit_infer(plan: Plan, ctype: str) -> list:
"""Emit the inference loop nest straight from plan.buffers / plan.assignment.
@@ -221,36 +1497,32 @@ def _emit_infer(plan: Plan, ctype: str) -> list:
and the Fortran backend handles.
"""
m = plan.model
- n_in = plan.input.shape[0]
- act = _ACT_C[plan.dtype]
- weight_by_symbol = {w.symbol: w for w in plan.weights}
scratch = sorted((s for s in plan.buffers if s not in ("x", "y")),
key=lambda s: int(s[1:]))
- lines = [f"void {m}_infer(const {ctype} *restrict x, {ctype} *restrict y) {{"]
+ # Storage class first, then the attribute macro: the order CUDA's own
+ # headers use for `static inline __host__ __device__`.
+ lines = [f"static inline ROSENNA_DEVICE_FN void {m}_infer("
+ f"const {ctype} *ROSENNA_RESTRICT x, {ctype} *ROSENNA_RESTRICT y) {{"]
+ if plan.embed:
+ # Controller ruling R9: in the host pass of a CUDA/HIP build the
+ # embedded arrays are device storage (nvcc diagnoses a direct read,
+ # hip-clang's host shadow is undefined), so that instantiation must
+ # not touch them. The literals are not duplicated for a host twin.
+ lines += [
+ "#if ROSENNA_INFER_HOST_STUB",
+ " (void)x;",
+ " (void)y;",
+ f' assert(0 && "rosenna: {m}_infer is device-only in a CUDA/HIP build; '
+ f'call it from a kernel or use {m}_infer_batch");',
+ "#else",
+ ]
for sym in scratch:
lines.append(f" {ctype} {sym}[{plan.buffers[sym]}];")
+ lines += _emit_op_sequence(plan, ctype)
- cur_len = n_in
- for op in plan.ops:
- dst, src = plan.assignment[op.out], plan.assignment[op.inp]
- if op.kind == "gemm":
- idx_expr = _weight_index_c(weight_by_symbol, op)
- bias_init = f"{op.bias}[i]" if op.bias else _ZERO[plan.dtype]
- lines.append(f" for (int i = 0; i < {op.n_out}; ++i) {{")
- lines.append(f" {ctype} acc = {bias_init};")
- lines.append(
- f" for (int j = 0; j < {op.n_in}; ++j) "
- f"acc += {src}[j] * {op.weight}[{idx_expr}];")
- lines.append(f" {dst}[i] = acc;")
- lines.append(" }")
- cur_len = op.n_out
- elif op.kind in act:
- expr = act[op.kind].format(v=f"{src}[i]")
- lines.append(f" for (int i = 0; i < {cur_len}; ++i) {dst}[i] = {expr};")
- else:
- raise AssertionError(f"unhandled op kind {op.kind!r}")
-
+ if plan.embed:
+ lines.append("#endif")
lines.append("}")
lines.append("")
return lines
diff --git a/python/rosenna/emit_fortran.py b/python/rosenna/emit_fortran.py
index 0278c6f..f4b407b 100644
--- a/python/rosenna/emit_fortran.py
+++ b/python/rosenna/emit_fortran.py
@@ -1,6 +1,13 @@
-"""Render a plan as a self-contained Fortran module."""
+"""Render a plan as a self-contained Fortran module.
+
+Written to _model.F90, with a capital F: infer_batch's device-pointer
+clause is spelled one way for nvfortran and another for everyone else (see
+_emit_infer_batch), and a capital-F suffix is the one way to ask for the
+preprocessor that every Fortran compiler honours without a flag.
+"""
from .abi import name_capacity, rank_capacity, status_code_comment
-from .plan import Plan
+from .emit_c import GEMM_BLOCK, gemm_block, has_infer_one
+from .plan import Plan, gru_initial_state, lstm_initial_state
_KIND = {"f32": "real32", "f64": "real64"}
# relu is written as merge, not max: max(v, 0) returns 0 for a NaN input, and
@@ -10,6 +17,13 @@
"sigmoid": "1.0_wp / (1.0_wp + exp(-({v})))"}
_DTYPE_CODE = {"f32": 0, "f64": 1}
+# Embedding must be lossless: %.16e (17 significant digits: one before the
+# point, sixteen after) round-trips any f64, %.8e (9 significant digits) any
+# f32 -- the same Steele & White / Ryu-style bounds emit_c's %.17g/%.9g rely
+# on, spelled as a fixed-width Fortran-legal exponential literal instead of
+# C's shortest-form %g (which drops trailing digits %e always keeps).
+_EMBED_FMT = {"f32": "%.8e", "f64": "%.16e"}
+
# Free-form Fortran source is limited to 132 columns (F2008 3.3.2.1). gfortran
# 15 accepts a longer line silently, gfortran 13 rejects it with
# -Werror=line-truncation, and every gfortran rejects it under -std=f2008, so
@@ -70,34 +84,175 @@ def _weight_dims(plan: Plan, symbol: str) -> str:
return "(" + ",".join(str(d) for d in reversed(spec.shape)) + ")"
+def _weight_dims_list(plan: Plan, symbol: str) -> str:
+ spec = next(w for w in plan.weights if w.symbol == symbol)
+ return "[" + ",".join(str(d) for d in reversed(spec.shape)) + "]"
+
+
+def _format_embedded_value(v: float, dtype: str) -> str:
+ return (_EMBED_FMT[dtype] % v) + "_wp"
+
+
+def _weight_symbol_list(plan: Plan) -> str:
+ return ", ".join(w.symbol for w in plan.weights)
+
+
+# F2008 free-form source caps a single statement at 255 continuation lines
+# (3.3.2.4), independently of the 132-column limit each of those lines
+# obeys: gfortran diagnoses an over-long array constructor with "Warning:
+# Limit of 255 continuations exceeded", not a column complaint, so
+# _wrap_items alone cannot keep a several-hundred-element weight (e.g.
+# gemm_big's 40x30 = 1200-element layer) legal. Above _EMBED_CHUNK elements,
+# the flat literal list is split into several small `parameter` arrays
+# (each well under the continuation limit) and reassembled by the weight's
+# own initializer over their names -- a statement with a handful of short
+# identifiers, never close to either limit itself.
+_EMBED_CHUNK = 500
+
+
+def _emit_embedded_weights(plan: Plan) -> list:
+ """`plan.embed`'s weights, as initialized `protected` module arrays.
+
+ Not `parameter`: gfortran -fopenacc materializes a named-constant array
+ read inside a `routine seq` as a static and then demands an OpenACC
+ `declare` for it, which it refuses on a named constant ("not a
+ variable"), so an embedded module could not be compiled (-c; the error
+ is raised after the front end, so -fsyntax-only does not see it). An
+ initialized `protected` module variable takes `declare copyin` and
+ `declare target`, gets its device copy at program start under either
+ offload family, and is as read-only outside the module as a constant.
+ The chunk arrays a long literal list is split into (see _EMBED_CHUNK)
+ stay `parameter`: only the initializer names them, never the routine.
+
+ A rank-1 array (every bias) is a plain bracketed list. A rank-2 array
+ (every Gemm/MatMul weight) is `reshape([flat values], [dims])`: `values`
+ is already the C-order (row-major) flattening of the ONNX-shaped tensor
+ (plan.py's `np.ravel(order="C")`), and Fortran's default array
+ constructor fills its target in column-major order, so reshaping that
+ same flat sequence into the *reversed* shape reproduces exactly the
+ memory layout `_weight_dims` already declares for the file-loaded form
+ -- the same raw-bytes-in trick `load_tensor` relies on, done here at
+ compile time instead of at file-read time.
+ """
+ lines = []
+ for w in plan.weights:
+ dims = _weight_dims(plan, w.symbol)
+ items = [_format_embedded_value(v, plan.dtype) for v in w.values]
+ if len(items) <= _EMBED_CHUNK:
+ flat = items
+ else:
+ chunks = [items[i:i + _EMBED_CHUNK] for i in range(0, len(items), _EMBED_CHUNK)]
+ chunk_names = [f"{w.symbol}_c{k}" for k in range(len(chunks))]
+ for cname, chunk in zip(chunk_names, chunks):
+ lines += _wrap_items(f" real(wp), parameter :: {cname}({len(chunk)}) = [ ",
+ chunk, " ]", " " * 8)
+ flat = chunk_names
+ if len(w.shape) <= 1:
+ head, tail = f" real(wp), protected :: {w.symbol}{dims} = [ ", " ]"
+ else:
+ head = f" real(wp), protected :: {w.symbol}{dims} = reshape([ "
+ tail = f" ], {_weight_dims_list(plan, w.symbol)})"
+ lines += _wrap_items(head, flat, tail, " " * 8)
+ return lines
+
+
def emit_fortran(plan: Plan) -> str:
m, wp = plan.model, _KIND[plan.dtype]
- # Wrap each literal in an explicit int(..., int8) conversion: an
- # unsuffixed literal is default INTEGER(4), and initializing an
- # INTEGER(1) parameter array from those trips gfortran's -Wconversion
- # under -Wextra. A plain "_int8" kind suffix does not work either --
- # gfortran checks a literal's unsigned magnitude against the kind's
- # range before applying unary minus, so "-128_int8" is rejected as
- # "Integer too big for its kind" even though -128 is in range.
- hash_terms = [f"int({b if b < 128 else b - 256}, int8)" for b in bytes.fromhex(plan.hash())]
+ public = [f"{m}_infer", f"{m}_infer_batch", f"{m}_infer_batch_dev", f"{m}_sync_dev"]
+ if not plan.embed:
+ public.insert(0, f"{m}_init")
+ public.append(f"{m}_init_dev")
+ if has_infer_one(plan):
+ public.append(f"{m}_infer_one_dev")
lines = [
f"module {m}_model",
" ! Generated by rosenna. Do not edit.",
" use iso_fortran_env, only: int8, int32, int64, " + wp,
+ " use iso_c_binding, only: c_int, c_ptr, c_char",
" implicit none",
" private",
- f" public :: {m}_init, {m}_infer",
+ # One name per line: a long model name times five symbols runs past
+ # the 132-column limit (ruling R20).
+ *[f" public :: {name}" for name in public],
"",
f" integer, parameter :: wp = {wp}",
]
- lines += _wrap_items(" integer(int8), parameter :: expected_hash(32) = [ ", hash_terms, " ]",
- " " * 8)
- for w in plan.weights:
- lines.append(f" real(wp), protected :: {w.symbol}{_weight_dims(plan, w.symbol)}")
- lines += ["", "contains", ""]
- lines += _emit_init(plan)
- lines += _emit_load(plan)
+ if not plan.embed:
+ # Wrap each literal in an explicit int(..., int8) conversion: an
+ # unsuffixed literal is default INTEGER(4), and initializing an
+ # INTEGER(1) parameter array from those trips gfortran's -Wconversion
+ # under -Wextra. A plain "_int8" kind suffix does not work either --
+ # gfortran checks a literal's unsigned magnitude against the kind's
+ # range before applying unary minus, so "-128_int8" is rejected as
+ # "Integer too big for its kind" even though -128 is in range.
+ hash_terms = [f"int({b if b < 128 else b - 256}, int8)" for b in bytes.fromhex(plan.hash())]
+ lines += _wrap_items(" integer(int8), parameter :: expected_hash(32) = [ ", hash_terms, " ]",
+ " " * 8)
+ for w in plan.weights:
+ lines.append(f" real(wp), protected :: {w.symbol}{_weight_dims(plan, w.symbol)}")
+ else:
+ lines += _emit_embedded_weights(plan)
+ if plan.weights:
+ lines.append(f" !$omp declare target({_weight_symbol_list(plan)})")
+ # gfortran rejects a `routine seq` function that reads a module array
+ # with no OpenACC `declare` directive of its own: `create` for the
+ # file-loaded arrays (init then does `update device`), `copyin` for
+ # the embedded ones, whose initializer is the device copy's value.
+ clause = "copyin" if plan.embed else "create"
+ lines.append(f" !$acc declare {clause}({_weight_symbol_list(plan)})")
+ lines += [
+ "",
+ " interface",
+ f" function {m}_infer_batch_dev(n, x, y, stream) &",
+ f' bind(C, name="{m}_infer_batch") result(status)',
+ " import :: c_int, c_ptr",
+ " integer(c_int), value :: n",
+ " type(c_ptr), value :: x, y, stream",
+ " integer(c_int) :: status",
+ " end function",
+ " ! Waits for every infer_batch launched on `stream` (a no-op for an",
+ " ! omp-backend libmodel.a); see the C header.",
+ f" function {m}_sync_dev(stream) &",
+ f' bind(C, name="{m}_sync") result(status)',
+ " import :: c_int, c_ptr",
+ " type(c_ptr), value :: stream",
+ " integer(c_int) :: status",
+ " end function",
+ ]
+ if has_infer_one(plan):
+ lines += [
+ f" ! One sample over device pointers, one launch per op (see the C header).",
+ f" function {m}_infer_one_dev(x, y, stream) &",
+ f' bind(C, name="{m}_infer_one") result(status)',
+ " import :: c_int, c_ptr",
+ " type(c_ptr), value :: x, y, stream",
+ " integer(c_int) :: status",
+ " end function",
+ ]
+ if not plan.embed:
+ lines += [
+ f" ! The C archive's own init (lib{m}.a): a host calling {m}_infer_batch_dev",
+ " ! on a cuda/hip archive must run this, since only it uploads the archive's",
+ f" ! device copies -- {m}_init above fills this module's arrays for the",
+ " ! Fortran per-point path. Pass a C string (path // c_null_char).",
+ f" function {m}_init_dev(path) &",
+ f' bind(C, name="{m}_init") result(status)',
+ " import :: c_int, c_char",
+ " character(kind=c_char), dimension(*), intent(in) :: path",
+ " integer(c_int) :: status",
+ " end function",
+ ]
+ lines += [
+ " end interface",
+ "",
+ "contains",
+ "",
+ ]
+ if not plan.embed:
+ lines += _emit_init(plan)
+ lines += _emit_load(plan)
lines += _emit_infer(plan)
+ lines += _emit_infer_batch(plan)
lines.append(f"end module {m}_model")
return "\n".join(lines) + "\n"
@@ -120,6 +275,7 @@ def _emit_init(plan: Plan) -> list:
" integer :: toclen, k, namelen, rank",
" integer(int64) :: dims(%d), off, length, data_start, tocpos" % rank_capacity(plan),
" character(len=%d) :: name" % name_capacity(plan),
+ " logical :: seen(%d)" % len(plan.weights),
]
lines += [
" status = 0",
@@ -154,6 +310,7 @@ def _emit_init(plan: Plan) -> list:
" read(u, iostat=ios) toclen",
" if (ios /= 0) then; status = 9; close(u); return; end if",
" data_start = int(60, int64) + int(toclen, int64) + 1_int64",
+ " seen = .false.",
" do k = 1, ntensors",
" read(u, iostat=ios) namelen",
" if (ios /= 0) then; status = 9; close(u); return; end if",
@@ -173,12 +330,19 @@ def _emit_init(plan: Plan) -> list:
" read(u, iostat=ios) off, length",
" if (ios /= 0) then; status = 9; close(u); return; end if",
" inquire(unit=u, pos=tocpos)",
- " call load_tensor(u, name(1:namelen), data_start + off, length, status)",
+ " call load_tensor(u, name(1:namelen), data_start + off, length, seen, status)",
" if (status /= 0) then; close(u); return; end if",
" read(u, pos=tocpos, iostat=ios)",
" if (ios /= 0) then; status = 9; close(u); return; end if",
" end do",
" close(u)",
+ " if (.not. all(seen)) then; status = 9; return; end if",
+ " ! The plan step's transfer (ruling R5): make the freshly loaded",
+ " ! weights device-resident. Fortran has no runtime-API path without",
+ " ! CUDA Fortran, so this directive form is the whole of init's device",
+ " ! copy; one host call both loads and uploads.",
+ f" !$omp target update to({_weight_symbol_list(plan)})",
+ f" !$acc update device({_weight_symbol_list(plan)})",
" end subroutine",
"",
]
@@ -189,21 +353,27 @@ def _emit_load(plan: Plan) -> list:
if not plan.weights:
return []
lines = [
- " subroutine load_tensor(u, name, pos, length, status)",
+ " ! seen(k) is set when plan weight k has been filled: a table of",
+ " ! contents that names a tensor twice, or not at all, is status 9",
+ " ! (inconsistent), never a zero array that infer then runs on.",
+ " subroutine load_tensor(u, name, pos, length, seen, status)",
" integer, intent(in) :: u",
" character(*), intent(in) :: name",
" integer(int64), intent(in) :: pos, length",
+ " logical, intent(inout) :: seen(:)",
" integer, intent(out) :: status",
" integer :: ios",
" status = 0",
" ios = 0",
" select case (name)",
]
- for w in plan.weights:
+ for k, w in enumerate(plan.weights, start=1):
# `length` comes from the file's own table of contents; a tensor whose
# declared byte count disagrees with the array it is about to fill
# means a corrupt file, not a short read, so reject before reading.
lines += _wrap_literal(" case (", w.name, ")", " " * 12)
+ lines.append(f" if (seen({k})) then; status = 9; return; end if")
+ lines.append(f" seen({k}) = .true.")
lines.append(f" if (length /= {w.nbytes}_int64) then; status = 9; return; end if")
lines.append(f" read(u, pos=pos, iostat=ios) {w.symbol}")
lines += [
@@ -238,6 +408,33 @@ def _weight_index(weight_by_symbol: dict, op) -> str:
return "j, i" if op.trans_b else "i, j"
+def _gemm_blocked_f(op, dst, src, ri, ro, idx_expr) -> list:
+ """A dense layer, gemm_block(op) columns per pass, scalar accumulators (a0.. and acc are declared by infer)."""
+ block = gemm_block(op)
+ col = lambda k: idx_expr.replace("i", f"i + {k}")
+ bias = lambda k: f" + {op.bias}(i + {k})" if op.bias else ""
+ L = []
+ full = (op.n_out // block) * block if block > 1 else 0
+ if full:
+ L += [f" do i = 1, {full}, {block}",
+ " " + "; ".join(f"a{k} = 0.0_wp" for k in range(block)),
+ f" do j = 1, {op.n_in}",
+ f" sj = {src}({ri}j)",
+ *[f" a{k} = a{k} + sj * {op.weight}({col(k)})" for k in range(block)],
+ " end do",
+ *[f" {dst}({ro}i + {k}) = a{k}{bias(k)}" for k in range(block)],
+ " end do"]
+ if full < op.n_out:
+ L += [f" do i = {full + 1}, {op.n_out}",
+ " acc = 0.0_wp",
+ f" do j = 1, {op.n_in}",
+ f" acc = acc + {src}({ri}j) * {op.weight}({idx_expr})",
+ " end do",
+ f" {dst}({ro}i) = acc{bias(0).replace('i + 0', 'i')}",
+ " end do"]
+ return L
+
+
def _emit_infer(plan: Plan) -> list:
m = plan.model
n_in, n_out = plan.input.shape[0], plan.output.shape[0]
@@ -247,6 +444,8 @@ def _emit_infer(plan: Plan) -> list:
lines = [
f" pure subroutine {m}_infer(x, y)",
+ " !$omp declare target",
+ " !$acc routine seq",
f" real(wp), intent(in) :: x({n_in})",
f" real(wp), intent(out) :: y({n_out})",
]
@@ -254,11 +453,46 @@ def _emit_infer(plan: Plan) -> list:
lines.append(f" real(wp) :: {sym}({plan.buffers[sym]})")
# Declare only the loop variables some op actually uses: a weight-free
# model (x -> Relu -> y) has no inner accumulation loop, and an unused
- # 'j' is a warning in any tree built with -Werror.
- loop_vars = [v for v, used in (("i", bool(plan.ops)),
- ("j", any(op.kind == "gemm" for op in plan.ops))) if used]
+ # 'j' is a warning in any tree built with -Werror. The spatial nest brings
+ # its own set, and its accumulator has to be a named local because Fortran
+ # has no statement-scoped declarations the way the C nest does.
+ kinds = {op.kind for op in plan.ops}
+ spatial = kinds & {"conv", "maxpool", "avgpool"}
+ loop_vars = [v for v, used in (
+ ("i", any(k in ("gemm", "copy", "lstm", "concat") or k in _ACT for k in kinds)),
+ ("o", "concat" in kinds),
+ ("j", "lstm" in kinds and "gemm" not in kinds),
+ ("j", "gemm" in kinds),
+ ("r", any(op.kind == "gemm" and op.rows > 1 for op in plan.ops)),
+ ("n, oc, oh, ow, ic, kh, kw, ih, iw", bool(spatial)),
+ (", ".join(f"pi{k}" for k in range(_max_pad_rank(plan))), _max_pad_rank(plan) > 0),
+ ("icg", any(op.kind == "conv" and op.spatial.grouped for op in plan.ops)),
+ ("smn, smj", "softmax" in kinds),
+ ("seen", "maxpool" in kinds),
+ ("cnt", any(_avgpool_needs_count(op) for op in plan.ops)),
+ ("lt, lb, lk", "lstm" in kinds),
+ ("gt, gb, gk, gj, gm, gi", "gru" in kinds),
+ (", ".join(f"c{k}" for k in range(_max_counter_rank(plan))),
+ bool(kinds & {"add", "transpose", "pad"}))) if used]
if loop_vars:
lines.append(" integer :: " + ", ".join(loop_vars))
+ gemms = [op for op in plan.ops if op.kind == "gemm"]
+ blocked = any(gemm_block(op) > 1 and op.n_out >= GEMM_BLOCK for op in gemms)
+ remainder = any(gemm_block(op) == 1 or op.n_out % GEMM_BLOCK for op in gemms)
+ reals = ["acc"] if (spatial or "lstm" in kinds or "gru" in kinds or remainder) else []
+ if "maxpool" in kinds:
+ reals.append("v")
+ if "softmax" in kinds:
+ reals += ["smx", "ssum", "sexp"]
+ if "lstm" in kinds:
+ reals += ["lgi", "lgo", "lgf", "lgc", "lcn"]
+ if "gru" in kinds:
+ reals += ["ghn"] + (["grh"] if any(
+ op.kind == "gru" and op.gru.linear_before_reset for op in plan.ops) else [])
+ if blocked:
+ reals += ["sj"] + [f"a{k}" for k in range(GEMM_BLOCK)]
+ if reals:
+ lines.append(" real(wp) :: " + ", ".join(reals))
# Fusing an activation into its preceding gemm's loop would require the
# activation's output buffer to equal the gemm's output buffer. Task 4's
@@ -269,27 +503,65 @@ def _emit_infer(plan: Plan) -> list:
# coincidence, so there is no such branch here: every op gets its own
# loop, straight from plan.assignment and plan.buffers.
ops = plan.ops
- cur_len = n_in
for op in ops:
if op.kind == "gemm":
dst = plan.assignment[op.out]
src = plan.assignment[op.inp]
idx_expr = _weight_index(weight_by_symbol, op)
+ # The bias is added AFTER the dot product, not used to seed the
+ # accumulator. Seeding it from a declare-target array is what makes
+ # nvc/nvfortran refuse to generate a `distribute parallel for` body
+ # (it emits a kernel that traps); adding it afterwards compiles, and
+ # unlocks a ~19x faster per-point offload loop. See
+ # examples/nvhpc_teams_mapping/. Both emitters do this identically,
+ # so the C and Fortran backends stay bit-comparable.
+ # See emit_c: `r` indexes independent rows sharing one weight, and
+ # is only emitted when a model actually has more than one.
+ ri, ro = ((f"(r - 1) * {op.n_in} + ", f"(r - 1) * {op.n_out} + ")
+ if op.rows > 1 else ("", ""))
+ if op.rows > 1:
+ lines.append(f" do r = 1, {op.rows}")
+ lines += _gemm_blocked_f(op, dst, src, ri, ro, idx_expr)
+ if op.rows > 1:
+ lines.append(" end do")
+ elif op.kind == "alias":
+ continue
+ elif op.kind == "transpose":
+ lines += _emit_transpose_f(op, plan.assignment[op.out], plan.assignment[op.inp])
+ elif op.kind == "gru":
+ h0 = gru_initial_state(op, lambda sym: sym, plan.assignment)
+ lines += _emit_gru_f(
+ op, plan.assignment.get(op.out), plan.assignment[op.inp], h0,
+ op.weight, op.weight2, op.bias,
+ [plan.assignment[o] if o else "" for o in op.outs])
+ elif op.kind == "lstm":
+ h0, c0 = lstm_initial_state(op, lambda sym: sym, plan.assignment)
+ lines += _emit_lstm_f(
+ op, plan.assignment.get(op.out), plan.assignment[op.inp], h0, c0,
+ [plan.assignment[o] if o else "" for o in op.outs])
+ elif op.kind == "add":
+ lines += _emit_add_f(op, plan.assignment[op.out], plan.assignment[op.inp])
+ elif op.kind == "concat":
+ lines += _emit_concat_f(op, plan.assignment[op.out],
+ [plan.assignment[op.inp]] + [plan.assignment[n] for n in op.extra_in])
+ elif op.kind == "copy":
+ dst, src = plan.assignment.get(op.out), plan.assignment[op.inp]
+ soff = f"{op.src_offset} + " if op.src_offset else ""
+ doff = f"{op.dst_offset} + " if op.dst_offset else ""
lines.append(f" do i = 1, {op.n_out}")
- if op.bias:
- lines.append(f" {dst}(i) = {op.bias}(i)")
- else:
- lines.append(f" {dst}(i) = 0.0_wp")
- lines.append(f" do j = 1, {op.n_in}")
- lines.append(f" {dst}(i) = {dst}(i) + {src}(j) * {op.weight}({idx_expr})")
- lines.append(" end do")
+ lines.append(f" {dst}({doff}i) = {src}({soff}i)")
lines.append(" end do")
- cur_len = op.n_out
+ elif op.kind == "pad":
+ lines += _emit_pad_f(op, plan.assignment[op.out], plan.assignment[op.inp])
+ elif op.kind == "softmax":
+ lines += _emit_softmax_f(op, plan.assignment[op.out], plan.assignment[op.inp])
+ elif op.kind in ("conv", "maxpool", "avgpool"):
+ lines += _emit_spatial_f(op, plan.assignment[op.out], plan.assignment[op.inp])
elif op.kind in _ACT:
dst = plan.assignment[op.out]
src = plan.assignment[op.inp]
- lines.append(f" do i = 1, {cur_len}")
+ lines.append(f" do i = 1, {op.n_out}")
lines.append(f" {dst}(i) = {_ACT[op.kind].format(v=f'{src}(i)')}")
lines.append(" end do")
else:
@@ -298,3 +570,458 @@ def _emit_infer(plan: Plan) -> list:
lines.append(" end subroutine")
lines.append("")
return lines
+
+
+def _emit_pad_f(op, dst: str, src: str) -> list:
+ """The Fortran twin of _emit_pad_c: same nest, same bounds, 1-based subscripts."""
+ pd = op.pad
+ names = [f"c{k}" for k in range(len(pd.out_shape))]
+ shifted, checks, pre = [], [], []
+ for k, (nm, b) in enumerate(zip(names, pd.begins)):
+ if b == 0 and pd.in_shape[k] == pd.out_shape[k]:
+ shifted.append(nm)
+ continue
+ # See _emit_pad_c: a negative begin is a crop and is spelled as an
+ # addition, because `(c - -1)` is a Fortran syntax error.
+ expr = f"({nm} - {b})" if b > 0 else f"({nm} + {-b})" if b < 0 else nm
+ n_in = pd.in_shape[k]
+ # merge(tsource, fsource, mask) is Fortran's ternary. Both arms are
+ # evaluated, which is free here: integer index arithmetic with no side
+ # effects, and only the selected one indexes the array. Assigned to a
+ # named local per padded axis -- inlined, four of these in one
+ # subscript ran past Fortran's 132-column line limit.
+ if pd.mode == "edge":
+ pre.append(f" pi{k} = merge(0, merge({n_in - 1}, {expr}, "
+ f"{expr} >= {n_in}), {expr} < 0)")
+ shifted.append(f"pi{k}")
+ elif pd.mode == "reflect":
+ pre.append(f" pi{k} = merge(-{expr}, merge({2 * (n_in - 1)} - {expr}, "
+ f"{expr}, {expr} >= {n_in}), {expr} < 0)")
+ shifted.append(f"pi{k}")
+ else:
+ shifted.append(expr)
+ if b > 0:
+ checks.append(f"{expr} >= 0")
+ checks.append(f"{expr} < {n_in}")
+ L = []
+ for nm, extent in zip(names, pd.out_shape):
+ L.append(f" do {nm} = 0, {extent - 1}")
+ L += pre
+ out_idx = _flat_index_f(names, pd.out_shape)
+ in_idx = _flat_index_f(shifted, pd.in_shape)
+ if checks:
+ L.append(f" if ({' .and. '.join(checks)}) then")
+ L.append(f" {dst}({out_idx}) = {src}({in_idx})")
+ L.append(" else")
+ L.append(f" {dst}({out_idx}) = {pd.value!r}_wp")
+ L.append(" end if")
+ else:
+ L.append(f" {dst}({out_idx}) = {src}({in_idx})")
+ L += [" end do"] * len(names)
+ return L
+
+
+def _emit_gru_f(op, dst, src, h0, wsym, rsym, bsym, outs) -> list:
+ """The Fortran twin of _emit_gru_c, line for line.
+
+ Counters stay 0-based so the index arithmetic reads the same as C's; only
+ the subscript gains the `+ 1`. See _emit_gru_c for why the h gate needs its
+ own pass and why linear_before_reset changes the arithmetic rather than the
+ spelling.
+ """
+ sp = op.gru
+ H, I, B, T = sp.hidden, sp.input_size, sp.batch, sp.seq
+ h, g = sp.h_sym, sp.g_sym
+ src0 = f"{h0}(gi + 1)" if h0 else "0.0_wp"
+ L = [f" do gi = 0, {B * H - 1}",
+ f" {h}(gi + 1) = {src0}",
+ " end do",
+ f" do gt = 0, {T - 1}",
+ f" do gb = 0, {B - 1}",
+ f" do gk = 0, {2 * H - 1}",
+ " acc = 0.0_wp",
+ f" do gj = 0, {I - 1}",
+ f" acc = acc + {src}((gt * {B} + gb) * {I} + gj + 1) * "
+ f"{wsym}(gk * {I} + gj + 1)",
+ " end do",
+ f" do gj = 0, {H - 1}",
+ f" acc = acc + {h}(gb * {H} + gj + 1) * {rsym}(gk * {H} + gj + 1)",
+ " end do"]
+ if bsym:
+ L.append(f" acc = acc + {bsym}(gk + 1) + {bsym}({3 * H} + gk + 1)")
+ L += [f" {g}(gk + 1) = {_ACT['sigmoid'].format(v='acc')}",
+ " end do",
+ f" do gj = 0, {H - 1}",
+ " acc = 0.0_wp",
+ f" do gm = 0, {I - 1}",
+ f" acc = acc + {src}((gt * {B} + gb) * {I} + gm + 1) * "
+ f"{wsym}(({2 * H} + gj) * {I} + gm + 1)",
+ " end do"]
+ if bsym:
+ L.append(f" acc = acc + {bsym}({2 * H} + gj + 1)")
+ if sp.linear_before_reset:
+ L += [" grh = 0.0_wp",
+ f" do gm = 0, {H - 1}",
+ f" grh = grh + {h}(gb * {H} + gm + 1) * "
+ f"{rsym}(({2 * H} + gj) * {H} + gm + 1)",
+ " end do"]
+ if bsym:
+ L.append(f" grh = grh + {bsym}({5 * H} + gj + 1)")
+ L.append(f" acc = acc + {g}({H} + gj + 1) * grh")
+ else:
+ L += [f" do gm = 0, {H - 1}",
+ f" acc = acc + {g}({H} + gm + 1) * {h}(gb * {H} + gm + 1) * "
+ f"{rsym}(({2 * H} + gj) * {H} + gm + 1)",
+ " end do"]
+ if bsym:
+ L.append(f" acc = acc + {bsym}({5 * H} + gj + 1)")
+ L += [f" {g}({2 * H} + gj + 1) = {_ACT['tanh'].format(v='acc')}",
+ " end do",
+ f" do gj = 0, {H - 1}",
+ f" ghn = (1.0_wp - {g}(gj + 1)) * {g}({2 * H} + gj + 1) + "
+ f"{g}(gj + 1) * {h}(gb * {H} + gj + 1)",
+ f" {h}(gb * {H} + gj + 1) = ghn",
+ *([f" {dst}((gt * {B} + gb) * {H} + gj + 1) = ghn"] if sp.emit_y else []),
+ " end do",
+ " end do",
+ " end do"]
+ if outs and outs[0]:
+ L += [f" do gi = 0, {B * H - 1}",
+ f" {outs[0]}(gi + 1) = {h}(gi + 1)",
+ " end do"]
+ return L
+
+
+def _emit_softmax_f(op, dst: str, src: str) -> list:
+ """The Fortran twin of _emit_softmax_c, line for line.
+
+ Counters stay 0-based so the index arithmetic reads the same as C's; only
+ the subscript gains the `+ 1`. See _emit_softmax_c for why the maximum is
+ written `>` here rather than the NaN-preserving form the other reductions
+ use.
+ """
+ sm = op.softmax
+ c = sm.axis_len
+ at = f"smn * {c}"
+ return [
+ f" do smn = 0, {sm.outer - 1}",
+ f" smx = {src}({at} + 1)",
+ f" do smj = 1, {c - 1}",
+ f" if ({src}({at} + smj + 1) > smx) smx = {src}({at} + smj + 1)",
+ " end do",
+ " ssum = 0.0_wp",
+ f" do smj = 0, {c - 1}",
+ f" sexp = exp({src}({at} + smj + 1) - smx)",
+ f" {dst}({at} + smj + 1) = sexp",
+ " ssum = ssum + sexp",
+ " end do",
+ f" do smj = 0, {c - 1}",
+ f" {dst}({at} + smj + 1) = {dst}({at} + smj + 1) / ssum",
+ " end do",
+ " end do",
+ ]
+
+
+def _flat_index_f(names, shape):
+ """Row-major flat index, 1-based: the C expression plus one."""
+ expr = names[0]
+ for k in range(1, len(shape)):
+ expr = f"({expr} * {shape[k]} + {names[k]})"
+ return f"{expr} + 1"
+
+
+def _max_pad_rank(plan) -> int:
+ """How many pad index temporaries the module needs: one per padded axis.
+
+ Only edge/reflect use them; a constant Pad tests the bounds inline.
+ """
+ return max((len(op.pad.out_shape) for op in plan.ops
+ if op.kind == "pad" and op.pad.mode != "constant"), default=0)
+
+
+def _max_counter_rank(plan) -> int:
+ """How many c-counters the widest Add or Transpose nest in this model needs."""
+ return max((len(op.bcast.out_shape) if op.kind == "add"
+ else len(op.pad.out_shape) if op.kind == "pad"
+ else len(op.out_shape)
+ for op in plan.ops if op.kind in ("add", "transpose", "pad")), default=0)
+
+
+def _emit_concat_f(op, dst, runtime_names):
+ """The Fortran twin of _emit_concat_c; weights are module arrays, named by symbol."""
+ from .emit_c import _concat_sources
+ cc = op.concat
+ srcs = _concat_sources(op, runtime_names, list(op.concat_syms))
+ row = sum(cc.blocks)
+ L = [f" do o = 0, {cc.outer - 1}"]
+ off = 0
+ for name, block in srcs:
+ L.append(f" do i = 1, {block}")
+ L.append(f" {dst}(o * {row} + {off} + i) = {name}(o * {block} + i)")
+ L.append(" end do")
+ off += block
+ L.append(" end do")
+ return L
+
+
+def _emit_transpose_f(op, dst, src):
+ """The Fortran twin of _emit_transpose_c."""
+ names = [f"c{k}" for k in range(len(op.out_shape))]
+ L = [f" do {nm} = 0, {ext - 1}" for nm, ext in zip(names, op.out_shape)]
+ terms = [nm if st == 1 else f"{nm} * {st}" for nm, st in zip(names, op.perm_strides) if st]
+ rhs = (" + ".join(terms) + " + 1") if terms else "1"
+ L.append(f" {dst}({_flat_index_f(names, op.out_shape)}) = {src}({rhs})")
+ L += [" end do"] * len(names)
+ return L
+
+
+def _emit_lstm_f(op, dst, src, h0, c0, outs):
+ """The Fortran twin of _emit_lstm_c: same recurrence, same ONNX i,o,f,c order."""
+ sp = op.lstm
+ H, I, B, T = sp.hidden, sp.input_size, sp.batch, sp.seq
+ h, c, g = sp.h_sym, sp.c_sym, sp.g_sym
+ L = [f" do i = 1, {B * H}",
+ f" {h}(i) = " + (f"{h0}(i)" if h0 else "0.0_wp"),
+ " end do",
+ f" do i = 1, {B * H}",
+ f" {c}(i) = " + (f"{c0}(i)" if c0 else "0.0_wp"),
+ " end do",
+ f" do lt = 0, {T - 1}",
+ f" do lb = 0, {B - 1}",
+ f" do lk = 0, {4 * H - 1}",
+ " acc = 0.0_wp",
+ f" do j = 0, {I - 1}",
+ f" acc = acc + {src}((lt * {B} + lb) * {I} + j + 1) * "
+ f"{op.weight}(lk * {I} + j + 1)",
+ " end do",
+ f" do j = 0, {H - 1}",
+ f" acc = acc + {h}(lb * {H} + j + 1) * {op.weight2}(lk * {H} + j + 1)",
+ " end do"]
+ if op.bias:
+ L.append(f" acc = acc + {op.bias}(lk + 1) + {op.bias}({4 * H} + lk + 1)")
+ L += [f" {g}(lk + 1) = acc",
+ " end do",
+ f" do j = 0, {H - 1}",
+ f" lgi = {_ACT['sigmoid'].format(v=f'{g}(j + 1)')}",
+ f" lgo = {_ACT['sigmoid'].format(v=f'{g}({H} + j + 1)')}",
+ f" lgf = {_ACT['sigmoid'].format(v=f'{g}({2 * H} + j + 1)')}",
+ f" lgc = {_ACT['tanh'].format(v=f'{g}({3 * H} + j + 1)')}",
+ f" lcn = lgf * {c}(lb * {H} + j + 1) + lgi * lgc",
+ f" {c}(lb * {H} + j + 1) = lcn",
+ f" {h}(lb * {H} + j + 1) = lgo * {_ACT['tanh'].format(v='lcn')}",
+ *([f" {dst}((lt * {B} + lb) * {H} + j + 1) = {h}(lb * {H} + j + 1)"]
+ if sp.emit_y else []),
+ " end do",
+ " end do",
+ " end do"]
+ # outs is positional: [0] is Y_h and [1] is Y_c, "" for one nothing reads.
+ for sym, state in zip(outs, (h, c)):
+ if sym:
+ L += [f" do i = 1, {B * H}",
+ f" {sym}(i) = {state}(i)",
+ " end do"]
+ return L
+
+
+def _emit_add_f(op, dst, src):
+ """The Fortran twin of _emit_add_c. Counters stay 0-based; only the
+ subscript gains the +1, so the two emitters compute the same index."""
+ bc = op.bcast
+ names = [f"c{k}" for k in range(len(bc.out_shape))]
+ L = []
+ for nm, ext in zip(names, bc.out_shape):
+ L.append(f" do {nm} = 0, {ext - 1}")
+ flat = _flat_index_f(names, bc.out_shape)
+ terms = [nm if st == 1 else f"{nm} * {st}" for nm, st in zip(names, bc.strides) if st]
+ widx = (" + ".join(terms) + " + 1") if terms else "1"
+ L.append(f" {dst}({flat}) = {src}({flat}) + {op.weight}({widx})")
+ L += [" end do"] * len(names)
+ return L
+
+
+def _avgpool_needs_count(op) -> bool:
+ """True when AveragePool's divisor is the count of in-bounds cells.
+
+ Otherwise it is a literal and nothing reads the counter -- and a counter
+ set but never read is a warning (clang reports it on the C twin; keeping
+ the two emitters in step keeps the Fortran one quiet too).
+ """
+ if op.kind != "avgpool":
+ return False
+ sp = op.spatial
+ return not (sp.every_window_is_inside or sp.count_include_pad)
+
+
+def _emit_spatial_f(op, dst, src):
+ """The Fortran twin of _emit_spatial_c: same loop nest, same arithmetic.
+
+ Buffers are rank-1 here too, so the NCHW index is spelled out. The flat
+ index is the C one plus 1 (Fortran is 1-based); the loop counters stay
+ 0-based so the two emitters read as the same code and so the stride/pad
+ arithmetic is identical character for character.
+
+ The weight is the exception: emit_fortran declares it with the ONNX shape
+ REVERSED (see _weight_dims), and Fortran fills a column-major array from
+ the same C-order value list, so w(kw, kh, ic, oc) -- 1-based -- addresses
+ the very element C reaches as w[((oc*IC+ic)*KH+kh)*KW+kw].
+ """
+ sp = op.spatial
+ L = []
+ # Grouped Conv: output channel oc belongs to group oc/c_out_per_group and
+ # reads only that group's c_in_per_group input channels, so the loop bound
+ # is the per-group count and the input channel is offset by the group. For
+ # group=1 c_in_per_group == c_in and every expression below collapses to
+ # exactly what it was, so an ordinary convolution emits identical code.
+ cpg = sp.c_in_per_group or sp.c_in
+ # See _emit_spatial_c: hoisted out of the index expression, both because
+ # it is invariant there and because gfortran warns about the division.
+ in_c = "(icg + ic)" if sp.grouped else "ic"
+ idx_in = f"((n * {sp.c_in} + {in_c}) * {sp.h_in} + ih) * {sp.w_in} + iw + 1"
+ idx_out = f"((n * {sp.c_out} + oc) * {sp.h_out} + oh) * {sp.w_out} + ow + 1"
+ L.append(f" do n = 0, {sp.n - 1}")
+ L.append(f" do oc = 0, {sp.c_out - 1}")
+ L.append(f" do oh = 0, {sp.h_out - 1}")
+ L.append(f" do ow = 0, {sp.w_out - 1}")
+ if op.kind == "conv":
+ L.append(" acc = 0.0_wp")
+ elif op.kind == "maxpool":
+ L.append(" acc = 0.0_wp")
+ L.append(" seen = 0")
+ L.append(" ic = oc")
+ else:
+ L.append(" acc = 0.0_wp")
+ if _avgpool_needs_count(op):
+ L.append(" cnt = 0")
+ L.append(" ic = oc")
+ if op.kind == "conv":
+ if sp.grouped:
+ L.append(f" icg = oc / {sp.c_out_per_group} * {cpg}")
+ L.append(f" do ic = 0, {cpg - 1}")
+ L.append(f" do kh = 0, {sp.kh - 1}")
+ L.append(f" do kw = 0, {sp.kw - 1}")
+ L.append(f" ih = oh * {sp.sh} - {sp.ph} + kh * {sp.dh}")
+ L.append(f" iw = ow * {sp.sw} - {sp.pw} + kw * {sp.dw}")
+ L.append(f" if (ih >= 0 .and. ih < {sp.h_in} .and. "
+ f"iw >= 0 .and. iw < {sp.w_in}) then")
+ if op.kind == "conv":
+ L.append(f" acc = acc + {src}({idx_in}) * "
+ f"{op.weight}(kw + 1, kh + 1, ic + 1, oc + 1)")
+ elif op.kind == "maxpool":
+ L.append(f" v = {src}({idx_in})")
+ # .not. (v <= acc), not (v > acc): a NaN loses every comparison, so
+ # the naive form would drop it. Matches emit_c's !(v <= best).
+ L.append(" if (seen == 0 .or. .not. (v <= acc)) then")
+ L.append(" acc = v")
+ L.append(" seen = 1")
+ L.append(" end if")
+ else:
+ L.append(f" acc = acc + {src}({idx_in})")
+ if _avgpool_needs_count(op):
+ L.append(" cnt = cnt + 1")
+ L.append(" end if")
+ L.append(" end do")
+ L.append(" end do")
+ if op.kind == "conv":
+ L.append(" end do")
+ if op.bias:
+ L.append(f" acc = acc + {op.bias}(oc + 1)")
+ L.append(f" {dst}({idx_out}) = acc")
+ elif op.kind == "maxpool":
+ L.append(f" {dst}({idx_out}) = acc")
+ else:
+ full = sp.kh * sp.kw
+ if sp.every_window_is_inside or sp.count_include_pad:
+ L.append(f" {dst}({idx_out}) = acc / real({full}, wp)")
+ else:
+ L.append(" if (cnt > 0) then")
+ L.append(f" {dst}({idx_out}) = acc / real(cnt, wp)")
+ L.append(" else")
+ L.append(f" {dst}({idx_out}) = 0.0_wp")
+ L.append(" end if")
+ L.append(" end do")
+ L.append(" end do")
+ L.append(" end do")
+ L.append(" end do")
+ return L
+
+
+def _emit_infer_batch(plan: Plan) -> list:
+ """The OpenMP-target fallback infer_batch: over device-resident arrays.
+
+ Ruling R5: x and y are already on the device (has_device_addr / the
+ OpenACC deviceptr twin), so the loop path allocates, maps and transfers
+ nothing -- the host maps them itself (`!$omp target enter data`) once,
+ outside this call. `has_device_addr` on this explicit-shape dummy
+ (rather than the assumed-shape form the spec flags as a compiler risk)
+ is accepted, without warning, under gfortran 15 -fopenmp -std=f2008; see
+ task-4-report.md for which gfortran this was verified against.
+
+ nvfortran does not implement `has_device_addr` at all -- through 25.11 it
+ is a syntax error, not a diagnostic about an unsupported clause -- so the
+ module is emitted as .F90 and this one directive is chosen by the
+ preprocessor. `is_device_ptr` is what nvfortran accepts for a Fortran
+ array holding a device address (its pre-5.1 spelling); it was verified to
+ give correct values under nvfortran 25.11 -mp=gpu -gpu=cc80 on an A100,
+ via `rosenna gpu-gate`. gfortran keeps the standard 5.1 clause, since
+ OpenMP 5.1 restricts Fortran `is_device_ptr` to TYPE(C_PTR) and a future
+ gfortran is entitled to reject an array there.
+ """
+ m = plan.model
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ return [
+ f" subroutine {m}_infer_batch(n, x, y, status)",
+ " integer, intent(in) :: n",
+ f" real(wp), intent(in) :: x({n_in}, n)",
+ f" real(wp), intent(out) :: y({n_out}, n)",
+ " integer, intent(out) :: status",
+ " integer :: p",
+ " ! Ruling R5: x and y are already device-resident. No data clause and",
+ " ! nothing else here transfers, allocates or synchronizes.",
+ " ! distribute parallel do, one point per thread: teams loop maps one",
+ " ! point per TEAM under nvfortran and amdflang (see emit_c).",
+ "#ifdef __NVCOMPILER",
+ " !$omp target teams distribute parallel do is_device_ptr(x, y)",
+ "#else",
+ " !$omp target teams distribute parallel do has_device_addr(x, y)",
+ "#endif",
+ " !$acc parallel loop deviceptr(x, y)",
+ " do p = 1, n",
+ f" call {m}_infer(x(:, p), y(:, p))",
+ " end do",
+ " status = 0",
+ " end subroutine",
+ "",
+ ]
+
+
+def emit_fortran_recipe(plan: Plan) -> str:
+ """A Makefile fragment that builds lib_f.a from the generated module.
+
+ Mirrors emit_c_recipe's shape: FC/FFLAGS/ROSENNA_OFFLOAD_FLAGS are
+ override points for the host's own compiler and offload flags. gfortran
+ drops _model.mod alongside the object as a side effect of
+ compiling; nothing in the recipe needs to name it, but a host module
+ that `use`s this one needs that .mod on its include path.
+
+ Controller ruling R13: the archive is lib_f.a, not lib.a --
+ `generate --lang both` writes this recipe and emit_c_recipe's into ONE
+ output directory, and `ar rcs` APPENDS to an existing archive, so two
+ recipes sharing one archive name silently merge their objects into it
+ the moment both are built there (and either recipe's `clean` then
+ deletes the other's artifacts too). A Fortran host that also links the
+ native CUDA/HIP kernel links both archives: `-l_f -l`.
+ """
+ n = plan.model
+ return f"""# Generated by rosenna. Builds lib{n}_f.a from {n}_model.F90 (and {n}_model.mod).
+FC ?= gfortran
+FFLAGS ?= -O2 -Wall -Wextra -std=f2008
+ROSENNA_OFFLOAD_FLAGS ?=
+
+lib{n}_f.a: {n}_model.o
+\tar rcs $@ $^
+{n}_model.o: {n}_model.F90
+\t$(FC) $(FFLAGS) $(ROSENNA_OFFLOAD_FLAGS) -c $< -o $@
+clean:
+\trm -f {n}_model.o {n}_model.mod lib{n}_f.a
+.PHONY: clean
+"""
diff --git a/python/rosenna/emit_kernel.py b/python/rosenna/emit_kernel.py
new file mode 100644
index 0000000..b554167
--- /dev/null
+++ b/python/rosenna/emit_kernel.py
@@ -0,0 +1,295 @@
+"""Render _kernel.cu: the native device kernels for nvcc and hipcc.
+
+One source in the CUDA subset hipcc accepts unchanged; every runtime call
+goes through rosenna_rt.h. Two entry points:
+
+infer_batch: one thread per point calling the header's infer, whose dense
+layers are register-blocked (emit_c.GEMM_BLOCK). Staging the weights in
+shared memory was tried and measured slower on an MI210; the bound was the
+per-thread activation loads, which the blocking amortises.
+
+infer_one: one sample, a launch per op with the thread index over the op's
+output elements and the intermediate activations in static __device__
+buffers. The form a model over a whole field needs; absent when the plan
+has an LSTM. Those buffers are shared by every call, so infer_one orders
+itself across streams with an event (status 12 if the event API fails).
+
+Both are asynchronous on the caller's stream (ruling R5); the one transfer
+this file makes (_device_bind, file-loaded plans) is the plan step,
+called from init. Validated on an A100 (nvcc 13.0, HPC SDK 25.11) and an
+MI210 (ROCm 7.2.0, AFAR 23.2.1) by `rosenna gpu-gate`.
+"""
+from .emit_c import (KERNEL_TILE, _CTYPE, _c_weight_symbol, _device_bind, elem_call, elem_length,
+ field_buffer, has_infer_one, large_locals, scratch_symbols)
+from .plan import Plan
+
+# One block's threads for a fused run of small ops. An op whose output fits in
+# this many threads can share a kernel with its neighbours, because
+# __syncthreads() is a full barrier over a single block -- which is what makes
+# the fusion sound: after the barrier, every element the next op reads has been
+# written. An op larger than this keeps its own kernel and its own grid, where
+# it gets the parallelism it needs; squeezing it into one block to fuse it
+# would trade 5 us of launch for far more compute.
+FUSE_THREADS = 256
+
+
+def _fusion_runs(plan: Plan) -> list:
+ """The op sequence split into runs to fuse and ops to launch alone.
+
+ Returns [(fused, [(k, op), ...])]. A run of one is never worth fusing --
+ it is the same single launch either way -- so it comes back as solo.
+ """
+ live = [(k, op) for k, op in enumerate(plan.ops) if op.kind != "alias"]
+ runs, cur = [], []
+ for k, op in live:
+ if elem_length(op) <= FUSE_THREADS:
+ cur.append((k, op))
+ continue
+ if cur:
+ runs.append((len(cur) > 1, cur))
+ cur = []
+ runs.append((False, [(k, op)]))
+ if cur:
+ runs.append((len(cur) > 1, cur))
+ return runs
+
+
+def _emit_upload_device(plan: Plan, ctype: str) -> list:
+ """The cuda/hip half of init: the device copies of a file-loaded model's weights.
+
+ This lives here rather than in .c because every line is a CUDA/HIP
+ runtime call. .c owns the host arrays and the OpenMP declare-target
+ that puts them on the device for a per-point host; this owns the separate
+ copies the native kernel reads. Both are filled from the same host arrays
+ by the same init, so one archive now serves both call paths -- which is
+ the whole point of the split.
+
+ A repeated init frees the previous copies first (freeing a null pointer is
+ a no-op in both runtimes). A failed allocation, copy or bind releases
+ everything again and returns 10, so a later infer_batch refuses to launch
+ (its null check) rather than reading an unfilled buffer or launching over
+ a table that still holds the previous addresses. The bind is last: it
+ publishes the new addresses to this translation unit's table.
+ """
+ m = plan.model
+ syms = [_c_weight_symbol(m, w.symbol) for w in plan.weights]
+ lines = [
+ "/* The device copies of the weights. Declared extern in the header, so",
+ " any translation unit's device_bind_here can read them; defined here,",
+ " beside the runtime calls that fill them. */",
+ ]
+ lines += [f'extern "C" {ctype} *{s}_dev[ROSENNA_MAX_DEVICES] = {{0}};' for s in syms]
+ lines += [
+ "",
+ "/* Per device: a multi-GPU host calls init (or upload_device) once per",
+ " device with that device current, and each device's __constant__ table",
+ " is filled with that device's pointers. Freeing one device's copies",
+ " must not disturb another's. */",
+ f"static void {m}_release_device_at(int d) {{",
+ ]
+ for s in syms:
+ lines += [f" (void)ROSENNA_FREE({s}_dev[d]);", f" {s}_dev[d] = 0;"]
+ lines += [
+ "}",
+ "",
+ f'extern "C" int {m}_upload_device(void) {{',
+ " int d = 0;",
+ " if (ROSENNA_GET_DEVICE(&d) != ROSENNA_OK) return 10;",
+ " if (d < 0 || d >= ROSENNA_MAX_DEVICES) return 13;",
+ f" {m}_release_device_at(d);",
+ ]
+ fail = f"{{ {m}_release_device_at(d); return 10; }}"
+ for s in syms:
+ lines += [
+ f" if (ROSENNA_MALLOC(&{s}_dev[d], sizeof {s}) != ROSENNA_OK) {fail}",
+ f" if (ROSENNA_MEMCPY_H2D({s}_dev[d], {s}, sizeof {s}) != ROSENNA_OK) {fail}",
+ ]
+ lines += [
+ f" if ({_device_bind(m)}() != 0) {fail}",
+ " return 0;",
+ "}",
+ "",
+ ]
+ return lines
+
+
+def emit_kernel(plan: Plan) -> str:
+ m = plan.model
+ ctype = _CTYPE[plan.dtype]
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ # Which device is current, and has init run on THAT device? Both entry
+ # points need it: the null check is per device now, so a host that
+ # initialized device 0 and launched on device 1 gets status 10 instead of
+ # a kernel reading another device's address.
+ dev_prologue = ([
+ " int rdev = 0;",
+ " if (ROSENNA_GET_DEVICE(&rdev) != ROSENNA_OK) return 10;",
+ " if (rdev < 0 || rdev >= ROSENNA_MAX_DEVICES) return 13;",
+ ] + [f" if ({_c_weight_symbol(m, w.symbol)}_dev[rdev] == 0) return 10;"
+ for w in plan.weights]
+ if not plan.embed and plan.weights else [])
+ dev_check = dev_prologue
+ lines = [
+ "/* Generated by rosenna. Do not edit. Build with nvcc or hipcc. */",
+ '#include "rosenna_rt.h"',
+ f'#include "{m}.h"',
+ "",
+ "#include ",
+ "",
+ f"#define ROSENNA_TILE {KERNEL_TILE}",
+ f"#define ROSENNA_FUSE {FUSE_THREADS}",
+ "",
+ ]
+ if not plan.embed and plan.weights:
+ lines += _emit_upload_device(plan, ctype)
+ lines += [
+ "/* Plan step (controller ruling R5), called by init after it has made",
+ " the device copies: binds this translation unit's __constant__ table,",
+ " the one the kernel below reads, through the header's",
+ f" {_device_bind(m)}_here. Nothing else in this file transfers. */",
+ f'extern "C" int {_device_bind(m)}(void) {{',
+ f" return {_device_bind(m)}_here();",
+ "}",
+ "",
+ ]
+
+ # --- infer_one ---
+ if has_infer_one(plan):
+ lines += ["/* infer_one's activations: device globals, referenced by name from the",
+ " kernels (a __device__ variable's address is not a host value). */"]
+ lines += [f"static __device__ {ctype} {field_buffer(m, sym)}[{plan.buffers[sym]}];"
+ for sym in scratch_symbols(plan)]
+ lines.append("")
+ runs = _fusion_runs(plan)
+ for r, (fused, members) in enumerate(runs):
+ if not fused:
+ k, op = members[0]
+ lines += [
+ f"static __global__ void {m}_k{k}(const {ctype} *__restrict__ x, {ctype} *__restrict__ y) {{",
+ " const int e = (int)(blockIdx.x * blockDim.x + threadIdx.x);",
+ f" if (e < {elem_length(op)}) {elem_call(plan, k, op)};",
+ "}",
+ ]
+ continue
+ sizes = ", ".join(str(elem_length(op)) for _, op in members)
+ lines += [
+ f"/* {len(members)} consecutive ops in one launch, output lengths {sizes}:",
+ " each fits in a block, and __syncthreads() between them is a full",
+ " barrier over that block, so every element the next op reads is",
+ " written. One block, so the barrier covers every thread that runs.",
+ " The barrier sits between the loops, never inside one -- a",
+ " __syncthreads() some threads skip is undefined. */",
+ f"static __global__ void {m}_f{r}(const {ctype} *__restrict__ x, {ctype} *__restrict__ y) {{",
+ ]
+ for i, (k, op) in enumerate(members):
+ # Strided over the block rather than one element per thread, so
+ # the kernel is correct for ANY block size -- including a block
+ # of one, which is how the stubbed host build in the tests
+ # emulates a launch. That keeps FUSE_THREADS a performance
+ # choice instead of a correctness precondition.
+ lines += [
+ f" for (int e = (int)threadIdx.x; e < {elem_length(op)}; e += (int)blockDim.x)",
+ f" {elem_call(plan, k, op)};",
+ ]
+ if i + 1 < len(members):
+ lines.append(" __syncthreads();")
+ lines.append("}")
+ lines += [
+ "",
+ "/* The activation buffers above are shared by every infer_one call, so two",
+ " calls must not overlap. Calls on one stream are already ordered by the",
+ " stream itself; a call on a different stream is made to wait on an event",
+ " recorded after the previous call's last launch. Both the wait and the",
+ " record are asynchronous enqueues -- the loop path still never",
+ " synchronizes (ruling R5) -- and the event is created once, on the first",
+ " call, never again.",
+ "",
+ " This orders the device work. It does not make infer_one callable from",
+ " several host threads at once: the two statics below are plain host",
+ " state with no lock. */",
+ f"static ROSENNA_EVENT_T {m}_one_done[ROSENNA_MAX_DEVICES];",
+ f"static int {m}_one_ready[ROSENNA_MAX_DEVICES] = {{0}};",
+ f"static ROSENNA_STREAM_T {m}_one_stream[ROSENNA_MAX_DEVICES];",
+ "",
+ f'extern "C" int {m}_infer_one(const {ctype} *__restrict__ x, {ctype} *__restrict__ y, void *stream) {{',
+ " const ROSENNA_STREAM_T s = (ROSENNA_STREAM_T)stream;",
+ *(dev_check or [
+ " int rdev = 0;",
+ " if (ROSENNA_GET_DEVICE(&rdev) != ROSENNA_OK) return 12;",
+ " if (rdev < 0 || rdev >= ROSENNA_MAX_DEVICES) return 13;",
+ ]),
+ " /* The activation buffers are __device__, so they are already per",
+ " device; this ordering state is host-side and had to follow. */",
+ f" if (!{m}_one_ready[rdev]) {{",
+ f" if (ROSENNA_EVENT_CREATE(&{m}_one_done[rdev]) != ROSENNA_OK) return 12;",
+ f" {m}_one_ready[rdev] = 1;",
+ f" }} else if (s != {m}_one_stream[rdev]) {{",
+ f" if (ROSENNA_STREAM_WAIT_EVENT(s, {m}_one_done[rdev]) != ROSENNA_OK) return 12;",
+ " }",
+ f" {m}_one_stream[rdev] = s;",
+ ]
+ for r, (fused, members) in enumerate(runs):
+ if fused:
+ lines.append(f" ROSENNA_LAUNCH({m}_f{r}, 1, ROSENNA_FUSE, s, x, y);")
+ else:
+ k, op = members[0]
+ n = elem_length(op)
+ lines.append(f" ROSENNA_LAUNCH({m}_k{k}, ({n} + ROSENNA_TILE - 1) / ROSENNA_TILE, "
+ "ROSENNA_TILE, s, x, y);")
+ lines.append(" if (ROSENNA_LAUNCH_STATUS() != ROSENNA_OK) return 11;")
+ lines += [
+ f" if (ROSENNA_EVENT_RECORD({m}_one_done[rdev], s) != ROSENNA_OK) return 12;",
+ " return 0;", "}", ""]
+
+ # --- infer_batch ---
+ if large_locals(plan):
+ lines += [
+ f"/* {m}_infer's locals exceed a device thread's stack: infer_batch runs",
+ " infer_one per point, each a launch per op over the static buffers. */",
+ f'extern "C" int {m}_infer_batch(int n, const {ctype} *__restrict__ x, {ctype} *__restrict__ y, void *stream) {{',
+ " int status = 0;",
+ " for (int p = 0; p < n && status == 0; ++p)",
+ f" status = {m}_infer_one(x + (size_t)p * {n_in}, y + (size_t)p * {n_out}, stream);",
+ " return status;",
+ "}",
+ "",
+ ]
+ else:
+ lines += [
+ "/* One thread per point; each calls the same inline body the host uses,",
+ " now instantiated as a __device__ function. */",
+ f"static __global__ void {m}_kernel(int n, const {ctype} *__restrict__ x,",
+ f" {ctype} *__restrict__ y) {{",
+ " const int p = (int)(blockIdx.x * blockDim.x + threadIdx.x);",
+ " if (p >= n) return;",
+ f" {m}_infer(x + (size_t)p * {n_in}, y + (size_t)p * {n_out});",
+ "}",
+ "",
+ ]
+ lines += [
+ f'extern "C" int {m}_infer_batch(int n, const {ctype} *__restrict__ x, {ctype} *__restrict__ y, void *stream) {{',
+ " if (n <= 0) return 0;",
+ " const ROSENNA_STREAM_T s = (ROSENNA_STREAM_T)stream;",
+ # A null device copy means init never ran or failed: status 10 rather
+ # than a device fault. Reading the host globals is not a transfer.
+ *dev_check,
+ " const int grid = (n + ROSENNA_TILE - 1) / ROSENNA_TILE;",
+ f" ROSENNA_LAUNCH({m}_kernel, grid, ROSENNA_TILE, s, n, x, y);",
+ " /* GetLastError, not a sync (ruling R5): a bad configuration or stream",
+ " is reported now; asynchronous faults surface at the caller's sync. */",
+ " if (ROSENNA_LAUNCH_STATUS() != ROSENNA_OK) return 11;",
+ " return 0;",
+ "}",
+ "",
+ ]
+
+ lines += [
+ "/* The one synchronization in this file, and only when the caller asks:",
+ " a host with no stream of its own (an OpenMP host) waits here before",
+ " its next target region reads y. */",
+ f'extern "C" int {m}_sync(void *stream) {{',
+ " return ROSENNA_SYNC((ROSENNA_STREAM_T)stream) == ROSENNA_OK ? 0 : 11;",
+ "}",
+ "",
+ ]
+ return "\n".join(lines)
diff --git a/python/rosenna/errors.py b/python/rosenna/errors.py
new file mode 100644
index 0000000..9352655
--- /dev/null
+++ b/python/rosenna/errors.py
@@ -0,0 +1,9 @@
+"""The one exception type every pass raises, in a module that imports nothing.
+
+frontend and fold both need it, and frontend calls fold while fold builds a
+frontend Graph -- so the exception cannot live in either without a cycle.
+"""
+
+
+class UnsupportedModel(ValueError):
+ """The model uses something roseNNa cannot generate code for."""
diff --git a/python/rosenna/fold.py b/python/rosenna/fold.py
new file mode 100644
index 0000000..d41300e
--- /dev/null
+++ b/python/rosenna/fold.py
@@ -0,0 +1,342 @@
+"""Evaluate at generation time everything that does not depend on the input.
+
+A real ONNX export is full of nodes that only ever see constants: the shape
+tensor of a Reshape, a `Constant` holding an LSTM's initial state, a weight
+that is transposed once on the way in. None of that belongs in a per-point
+inference loop, and some of it could not be emitted at all -- the shape
+tensors are int64, a dtype the generated code never carries.
+
+So this pass runs them now. What is left is a graph whose every remaining node
+genuinely depends on the input, and whose initializers are all floating-point
+arrays the emitters know how to lay out.
+"""
+import numpy as np
+
+from .errors import UnsupportedModel
+
+# Nodes that are pure functions of their inputs and cheap to evaluate here.
+# Anything not listed is left for the emitters, even if its inputs happen to
+# all be constant: this list is "what fold knows how to compute", not "what is
+# foldable in principle".
+FOLDABLE = {"Reshape", "Transpose", "Squeeze", "Unsqueeze", "Flatten", "Identity", "Concat"}
+
+
+def _resolve_shape(target, source_shape) -> tuple:
+ """ONNX Reshape target semantics: 0 copies the input's dim, -1 is inferred."""
+ dims = [int(d) for d in target]
+ out = []
+ for i, d in enumerate(dims):
+ if d == 0:
+ if i >= len(source_shape):
+ raise UnsupportedModel(f"Reshape: a 0 at axis {i} has no matching input axis")
+ out.append(int(source_shape[i]))
+ else:
+ out.append(d)
+ if out.count(-1) > 1:
+ raise UnsupportedModel("Reshape: at most one -1 may be inferred")
+ if -1 in out:
+ known = 1
+ for d in out:
+ if d != -1:
+ known *= d
+ total = int(np.prod(source_shape)) if len(source_shape) else 1
+ if known == 0 or total % known:
+ raise UnsupportedModel(f"Reshape: cannot infer -1 for {tuple(target)} from {tuple(source_shape)}")
+ out[out.index(-1)] = total // known
+ return tuple(out)
+
+
+def _axes(node, inits, rank) -> tuple:
+ """Squeeze/Unsqueeze axes, from the attribute (opset < 13) or input 1 (>= 13)."""
+ if "axes" in node.attrs:
+ axes = node.attrs["axes"]
+ elif len(node.inputs) > 1 and node.inputs[1]:
+ if node.inputs[1] not in inits:
+ raise UnsupportedModel(
+ f"node '{node.name}': {node.op} axes must be constant")
+ axes = inits[node.inputs[1]].tolist()
+ else:
+ return ()
+ return tuple(int(a) % rank if int(a) >= 0 else int(a) + rank for a in axes)
+
+
+def _evaluate(node, inits):
+ """Compute one foldable node's output from constant inputs."""
+ a = inits[node.inputs[0]]
+ if node.op == "Identity":
+ return a
+ if node.op == "Concat":
+ axis = int(node.attrs.get("axis", 0))
+ return np.concatenate([inits[i] for i in node.inputs], axis=axis)
+ if node.op == "Reshape":
+ if len(node.inputs) < 2 or node.inputs[1] not in inits:
+ raise UnsupportedModel(f"node '{node.name}': Reshape needs a constant shape input")
+ return a.reshape(_resolve_shape(inits[node.inputs[1]], a.shape))
+ if node.op == "Transpose":
+ perm = node.attrs.get("perm")
+ return np.transpose(a, tuple(int(p) for p in perm) if perm else None)
+ if node.op == "Squeeze":
+ axes = _axes(node, inits, a.ndim)
+ return np.squeeze(a, axis=axes or None)
+ if node.op == "Unsqueeze":
+ # Negative axes count from the OUTPUT rank (ONNX): with two axes to
+ # add, -1 is the last of ndim + 2, not of ndim + 1. Resolve against
+ # the output rank, then insert in ascending order so each position
+ # is final when it is written.
+ n_new = len(_axes(node, inits, a.ndim + 1))
+ out = a
+ for ax in sorted(_axes(node, inits, a.ndim + n_new)):
+ out = np.expand_dims(out, ax)
+ return out
+ if node.op == "Flatten":
+ axis = int(node.attrs.get("axis", 1))
+ rows = int(np.prod(a.shape[:axis])) if axis else 1
+ return a.reshape(rows, -1)
+ raise AssertionError(f"not foldable: {node.op}")
+
+
+def _constant_value(node):
+ """The array a Constant node produces, from whichever attribute carries it."""
+ for key in ("value", "value_float", "value_floats", "value_int", "value_ints"):
+ if key in node.attrs:
+ return np.asarray(node.attrs[key])
+ raise UnsupportedModel(f"node '{node.name}': Constant without a value this generator reads")
+
+
+def fold_constants(graph):
+ """Return a graph with every constant-only node evaluated away."""
+ from .frontend import Graph # deferred: frontend imports this module
+ inits = dict(graph.initializers)
+ values = dict(graph.values)
+ nodes = []
+ for node in graph.nodes:
+ if node.op == "Constant":
+ inits[node.outputs[0]] = _constant_value(node)
+ values.pop(node.outputs[0], None)
+ continue
+ if node.op in FOLDABLE and all(i in inits for i in node.inputs if i):
+ inits[node.outputs[0]] = np.ascontiguousarray(_evaluate(node, inits))
+ values.pop(node.outputs[0], None)
+ continue
+ nodes.append(node)
+
+ # Drop initializers nothing refers to any more -- the int64 shape tensors
+ # a folded Reshape consumed, above all, which no later pass could lay out.
+ used = {i for n in nodes for i in n.inputs if i} | set(graph.outputs)
+ inits = {k: v for k, v in inits.items() if k in used}
+ for name in list(values):
+ if name in inits:
+ values.pop(name)
+ return Graph(graph.name, tuple(nodes), values, inits, graph.inputs, graph.outputs)
+
+
+def _bn_foldable_into(graph, bn, consumers):
+ """The Conv/Gemm this BatchNormalization can be folded into, or None.
+
+ Every condition here is a reason the fold would change the model's meaning
+ rather than preserve it, so failing one leaves the BatchNormalization in
+ the graph, where validate.py refuses it by name.
+ """
+ inits = graph.initializers
+ if int(bn.attrs.get("training_mode", 0)) != 0:
+ return None
+ # The running-stat outputs only exist in training mode; a graph that reads
+ # one is not doing inference, whatever training_mode says.
+ if any(o for o in bn.outputs[1:]):
+ return None
+ if len(bn.inputs) < 5 or not all(i in inits for i in bn.inputs[1:5]):
+ return None
+
+ src = bn.inputs[0]
+ producers = [n for n in graph.nodes if src in n.outputs]
+ if len(producers) != 1:
+ return None
+ prod = producers[0]
+ if prod.op not in ("Conv", "Gemm"):
+ return None
+ # Folding rewrites the producer's weights, so anything else reading the
+ # pre-BN value would silently start seeing post-BN numbers.
+ if len(consumers[src]) != 1 or src in graph.outputs:
+ return None
+ if len(prod.inputs) < 2 or prod.inputs[1] not in inits:
+ return None
+ if len(prod.inputs) > 2 and prod.inputs[2] and prod.inputs[2] not in inits:
+ return None
+ if prod.op == "Gemm":
+ # alpha/beta != 1 would not compose with the scale this way. validate
+ # refuses them anyway; not folding keeps the message about the real
+ # problem instead of about a weight this pass had already rewritten.
+ if any(abs(float(prod.attrs.get(a, 1.0)) - 1.0) > 1e-12 for a in ("alpha", "beta")):
+ return None
+ if int(prod.attrs.get("transA", 0)) != 0:
+ return None
+
+ w = inits[prod.inputs[1]]
+ n_out = (w.shape[0] if prod.op == "Conv" or int(prod.attrs.get("transB", 0))
+ else w.shape[-1])
+ if any(inits[i].ndim != 1 or inits[i].shape[0] != n_out for i in bn.inputs[1:5]):
+ return None
+ return prod
+
+
+def fold_batchnorm(graph):
+ """Fold an inference BatchNormalization into the Conv or Gemm that feeds it.
+
+ At inference a BatchNormalization is an affine map per channel:
+
+ y = scale * (x - mean) / sqrt(var + eps) + B
+
+ and a Conv or Gemm already applies an affine map, so the two compose into
+ one: multiply the per-output-channel factor into the weight and push the
+ shift through the bias. The operator then disappears before validate.py
+ ever sees it, which is why there is no BatchNormalization loop nest in
+ either emitter -- the alternative, a runtime op, would read five extra
+ arrays per channel to compute what is by then a constant.
+
+ s = scale / sqrt(var + eps)
+ W' = W * s (broadcast along the output-channel axis)
+ b' = (b - mean) * s + B
+
+ A producer with no bias gains one: the shift is not optional, and a new
+ initializer is cheaper than a second op.
+ """
+ from .frontend import Graph, Node
+ consumers = {}
+ for n in graph.nodes:
+ for i in n.inputs:
+ if i:
+ consumers.setdefault(i, []).append(n)
+
+ folded = {} # producer name -> rewritten Node
+ drop = set() # BatchNormalization nodes removed
+ inits = dict(graph.initializers)
+ values = dict(graph.values)
+ for bn in graph.nodes:
+ if bn.op != "BatchNormalization":
+ continue
+ prod = _bn_foldable_into(graph, bn, consumers)
+ if prod is None or prod.name in folded:
+ continue
+
+ eps = float(bn.attrs.get("epsilon", 1e-5))
+ scale, shift, mean, var = (inits[bn.inputs[k]] for k in (1, 2, 3, 4))
+ w = inits[prod.inputs[1]]
+ s = (scale / np.sqrt(var + eps)).astype(w.dtype)
+
+ has_bias = len(prod.inputs) > 2 and prod.inputs[2]
+ b = inits[prod.inputs[2]] if has_bias else np.zeros(s.shape, w.dtype)
+ # Conv weights are [OC, IC, KH, KW] and a transB=1 Gemm's are
+ # [OUT, IN]: the output channel leads, so s broadcasts along the
+ # trailing axes. A transB=0 Gemm's are [IN, OUT], so it broadcasts
+ # along the last.
+ lead = prod.op == "Conv" or int(prod.attrs.get("transB", 0))
+ s_w = s.reshape((-1,) + (1,) * (w.ndim - 1)) if lead else s.reshape((1,) * (w.ndim - 1) + (-1,))
+ inits[prod.inputs[1]] = np.ascontiguousarray(w * s_w)
+ bias_name = prod.inputs[2] if has_bias else f"{prod.name}_bn_bias"
+ inits[bias_name] = np.ascontiguousarray(
+ ((b - mean) * s + shift).astype(w.dtype))
+
+ # The producer takes over the BN's output, so the value between them
+ # stops existing.
+ values.pop(prod.outputs[0], None)
+ folded[prod.name] = Node(prod.op, prod.name,
+ tuple(prod.inputs[:2]) + (bias_name,),
+ (bn.outputs[0],) + tuple(prod.outputs[1:]),
+ prod.attrs)
+ drop.add(bn.name)
+
+ if not drop:
+ return graph
+ nodes = tuple(folded.get(n.name, n) for n in graph.nodes if n.name not in drop)
+ used = {i for n in nodes for i in n.inputs if i} | set(graph.outputs)
+ inits = {k: v for k, v in inits.items() if k in used}
+ return Graph(graph.name, nodes, values, inits, graph.inputs, graph.outputs)
+
+
+def absorb_pad_inputs(graph):
+ """Move a Pad's constant `pads`/`constant_value` operands into its attributes.
+
+ ONNX moved these from attributes (opset 2) to inputs (opset 11), and the
+ input form is a problem here for a reason that has nothing to do with Pad:
+ `pads` is an int64 tensor, and validate.py refuses any initializer that is
+ not floating-point, because everything that survives to the emitters gets
+ laid out as a weight. Normalising back to attributes means plan.py reads
+ literal integers -- as it already does for auto_pad -- and the int64 array
+ never reaches the layout pass at all.
+
+ A Pad whose operands are not constant is left alone, and validate.py
+ refuses it by name.
+ """
+ from .frontend import Graph, Node
+ inits = graph.initializers
+ nodes, changed = [], False
+ for n in graph.nodes:
+ if n.op != "Pad" or len(n.inputs) < 2 or n.inputs[1] not in inits:
+ nodes.append(n)
+ continue
+ attrs = dict(n.attrs)
+ pads = [int(v) for v in np.asarray(inits[n.inputs[1]]).ravel()]
+ if len(n.inputs) > 2 and n.inputs[2]:
+ if n.inputs[2] not in inits:
+ nodes.append(n)
+ continue
+ attrs["value"] = float(np.asarray(inits[n.inputs[2]]).ravel()[0])
+ # `axes` (opset 18) says which axes `pads` counts, and the rest are
+ # unpadded. Expanding it to a full-rank pads here is the whole of its
+ # meaning, so nothing downstream has to know the operand existed.
+ if len(n.inputs) > 3 and n.inputs[3]:
+ if n.inputs[3] not in inits:
+ nodes.append(n)
+ continue
+ shape = graph.values.get(n.inputs[0])
+ if shape is None:
+ nodes.append(n)
+ continue
+ rank = len(shape.shape)
+ axes = [int(a) for a in np.asarray(inits[n.inputs[3]]).ravel()]
+ axes = [a + rank if a < 0 else a for a in axes]
+ if len(pads) != 2 * len(axes) or any(not 0 <= a < rank for a in axes) \
+ or len(set(axes)) != len(axes):
+ nodes.append(n)
+ continue
+ full = [0] * (2 * rank)
+ for k, a in enumerate(axes):
+ full[a] = pads[k]
+ full[a + rank] = pads[k + len(axes)]
+ pads = full
+ attrs["pads"] = tuple(pads)
+ nodes.append(Node(n.op, n.name, n.inputs[:1], n.outputs, attrs))
+ changed = True
+ if not changed:
+ return graph
+ used = {i for n in nodes for i in n.inputs if i} | set(graph.outputs)
+ return Graph(graph.name, tuple(nodes), graph.values,
+ {k: v for k, v in graph.initializers.items() if k in used},
+ graph.inputs, graph.outputs)
+
+
+# Shape ops that only relabel axes: on a flat row-major buffer the bytes are
+# unchanged, so the value they "produce" is the value they were given. They are
+# NOT removed from the graph -- a later Transpose's perm counts axes, so the
+# relabelled value has to keep its own shape. plan.py turns them into buffer
+# aliases instead, which costs no code and no copy.
+RELABEL = {"Reshape", "Squeeze", "Unsqueeze", "Flatten", "Identity"}
+
+
+def strip_shape_inputs(graph):
+ """Drop the metadata operands of relabelling ops, and anything left unused.
+
+ A Reshape whose data is a runtime value cannot be folded, but its shape
+ operand is still pure metadata: plan.py turns the node into a buffer alias
+ and never reads it. Left in place it would be an int64 initializer nothing
+ downstream can lay out, so it goes here -- along with any other initializer
+ that no surviving node reads any more.
+ """
+ from .frontend import Graph, Node
+
+ nodes = tuple(
+ Node(n.op, n.name, n.inputs[:1], n.outputs, n.attrs) if n.op in RELABEL else n
+ for n in graph.nodes)
+ used = {i for n in nodes for i in n.inputs if i} | set(graph.outputs)
+ inits = {k: v for k, v in graph.initializers.items() if k in used}
+ return Graph(graph.name, nodes, graph.values, inits, graph.inputs, graph.outputs)
diff --git a/python/rosenna/frontend.py b/python/rosenna/frontend.py
index d5a244f..a0d6c90 100644
--- a/python/rosenna/frontend.py
+++ b/python/rosenna/frontend.py
@@ -5,11 +5,10 @@
import onnx
from onnx import numpy_helper, shape_inference
-_DTYPES = {onnx.TensorProto.FLOAT: "f32", onnx.TensorProto.DOUBLE: "f64"}
-
+from .errors import UnsupportedModel
+from .fold import absorb_pad_inputs, fold_batchnorm, fold_constants, strip_shape_inputs
-class UnsupportedModel(ValueError):
- """The model uses something roseNNa cannot generate code for."""
+_DTYPES = {onnx.TensorProto.FLOAT: "f32", onnx.TensorProto.DOUBLE: "f64"}
@dataclass(frozen=True)
@@ -57,6 +56,10 @@ def _attr_value(a, node_name: str):
return tuple(s.decode("ascii") for s in a.strings)
except UnicodeDecodeError:
raise UnsupportedModel(f"node '{node_name}' attribute '{a.name}': non-ASCII string")
+ if a.type == onnx.AttributeProto.TENSOR:
+ # A Constant node's payload. Kept as an array so fold.py can evaluate
+ # the node away; nothing downstream of folding ever sees one.
+ return numpy_helper.to_array(a.t)
raise UnsupportedModel(f"node '{node_name}' attribute '{a.name}': unsupported attribute type {a.type}")
@@ -83,10 +86,18 @@ def load_graph(path, name: str | None = None) -> Graph:
model = shape_inference.infer_shapes(onnx.load(path))
g = model.graph
initializers = {t.name: numpy_helper.to_array(t) for t in g.initializer}
- values = {}
+ values, unsupported_dtype = {}, {}
for vi in list(g.input) + list(g.output) + list(g.value_info):
if vi.name in initializers:
continue
+ code = vi.type.tensor_type.elem_type
+ if code not in _DTYPES:
+ # Deferred, not refused: an int64 value is almost always a shape
+ # tensor that fold_constants is about to evaluate away. If one
+ # survives folding it is a real dtype the emitters cannot carry,
+ # and the check below says so then.
+ unsupported_dtype[vi.name] = code
+ continue
values[vi.name] = Tensor(vi.name, _shape(vi), _dtype(vi))
nodes = []
for i, n in enumerate(g.node):
@@ -100,8 +111,19 @@ def load_graph(path, name: str | None = None) -> Graph:
))
inputs = tuple(vi.name for vi in g.input if vi.name not in initializers)
outputs = tuple(vi.name for vi in g.output)
+ # fold_constants first: a BatchNormalization's scale/B/mean/var reach the
+ # graph as Constant nodes in some exports, and fold_batchnorm needs them as
+ # initializers to multiply.
+ graph = strip_shape_inputs(absorb_pad_inputs(fold_batchnorm(fold_constants(
+ Graph(name or path.stem, tuple(nodes), values, initializers, inputs, outputs)))))
+ for n in graph.nodes:
+ for v in tuple(n.inputs) + tuple(n.outputs):
+ if v in unsupported_dtype:
+ raise UnsupportedModel(
+ f"value '{v}' has element type {unsupported_dtype[v]}; "
+ f"only float32 and float64")
# Defensive check: catch outputs whose names collide with initializers.
- missing = [v for v in inputs + outputs if v not in values]
+ missing = [v for v in graph.inputs + graph.outputs if v not in graph.values]
if missing:
raise UnsupportedModel(f"shape inference produced no shape for {missing}")
- return Graph(name or path.stem, tuple(nodes), values, initializers, inputs, outputs)
+ return graph
diff --git a/python/rosenna/gate.py b/python/rosenna/gate.py
new file mode 100644
index 0000000..70474f3
--- /dev/null
+++ b/python/rosenna/gate.py
@@ -0,0 +1,1279 @@
+"""rosenna gpu-gate: the script a user runs on a real GPU machine.
+
+None of the CUDA/HIP path had ever been compiled or run on the machine that
+wrote it (no nvcc, hipcc, or GPU). This script is the evidence that fact
+cannot produce: it generates the gemm_big plan embedded and file-loaded, in
+both languages, builds the omp-backend C archive and the Fortran library
+with the host compiler and, under --backend cuda|hip, the native-kernel
+archive with the device compiler as well (ruling R21: the per-point host
+harness links the host compiler's own archive in every backend, the
+infer_batch driver links the device compiler's), then runs three harnesses
+-- a microfd-shaped per-point host in C, the same in Fortran, and a host
+that hands device-resident data to infer_batch -- each compared against
+onnxruntime and timed per point. Every command, every line of its output,
+the compiler versions and the timings go into gate-report.md; a failure at
+any step still writes the report and the process exits 1.
+
+`--host-fallback` drops the OMP_TARGET_OFFLOAD=MANDATORY requirement so the
+omp backend can be exercised end to end on a machine with no accelerator
+(this is how the test suite runs this script); without it, a machine with no
+working offload device fails loudly instead of silently falling back to the
+host, which is the whole point of running under MANDATORY in the first
+place.
+
+Device residency is not claimed anywhere until this script has actually run
+on a GPU machine and its report recorded. For CUDA that has now happened:
+`--backend cuda` PASSes on an A100 under NVIDIA HPC SDK 25.11 (nvc,
+nvfortran -mp=gpu -gpu=cc80, nvcc 13.0), with zero cudaMemcpy inside the
+timed infer_batch call. For HIP too: `--backend hip` PASSes on an MI210
+(gfx90a) under ROCm 7.2.0 (amdclang, amdflang -fopenmp --offload-arch=gfx90a,
+hipcc) and under the TheRock AFAR 23.2.1 drop, with zero transfers inside the
+roctx-scoped 4-step loop of every harness (rocprofv3, the nsys check's twin
+over both the HIP API trace and the memory-copy trace).
+"""
+import csv
+import io
+import os
+import platform
+import shlex
+import shutil
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+
+from .emit_c import emit_c, emit_c_recipe
+from .emit_fortran import emit_fortran, emit_fortran_recipe
+from .emit_kernel import emit_kernel
+from .frontend import load_graph
+from .golden import golden_generator_run, golden_model_path
+from .plan import build_plan, validate_model_name
+from .rt_header import rt_header
+from .verify import _live_reference
+from .weights import write_weights
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_MODEL = "gemm_big"
+_TIMED_ITERS = 1_000_000
+# Every timed harness runs this many time steps over the same device-resident
+# points, the way a solver calls the model once per step. The transfer check
+# brackets the whole step loop, so a copy that recurred per step -- the
+# weights re-mapped on each target region entry, say -- would be counted
+# NSTEPS times, not hidden inside a single call.
+_NSTEPS = 4
+_RTOL, _ATOL = 1e-5, 1e-6
+_RUN_TIMEOUT = 300
+# Ruling R24: -Wall -Wextra -std=c11|f2008 are added only for a compiler whose
+# basename says it takes them; any other --cc/--fc (nvc, nvfortran, amdclang,
+# amdflang, flang, icx, ifx) gets -O2 and the user's --flags, nothing else.
+_GNU_STYLE_PREFIXES = ("gcc", "gfortran", "cc", "clang")
+# The generated C sources one configuration directory holds; the device
+# compiler's archive is built from a copy of them in its own subdirectory.
+_C_SOURCE_FILES = ("{name}.c", "{name}.h", "rosenna_rt.h", "{name}_kernel.cu", "{name}.mk")
+
+
+class _Report:
+ """Accumulates gate-report.md; every command and every output line goes in."""
+
+ def __init__(self):
+ self.lines = []
+
+ def h(self, text: str, level: int = 2) -> None:
+ self.lines.append(f"\n{'#' * level} {text}\n")
+
+ def p(self, text: str) -> None:
+ self.lines.append(text)
+
+ def block(self, label: str, text: str) -> None:
+ self.lines.append(f"{label}:\n```\n{text}\n```")
+
+ def command(self, label: str, args: list, cwd=None) -> None:
+ """Log one command, shell-quoted so a multi-word argument reads back as one."""
+ where = f" (in {cwd})" if cwd is not None else ""
+ self.lines.append(f"\n**{label}**{where}\n\n```\n$ {shlex.join(str(a) for a in args)}\n```")
+
+ def outcome(self, proc) -> None:
+ self.lines.append(f"exit status: {proc.returncode}")
+ if proc.stdout:
+ self.block("stdout", proc.stdout)
+ if proc.stderr:
+ self.block("stderr", proc.stderr)
+
+ def text(self) -> str:
+ return "\n".join(self.lines) + "\n"
+
+
+class _FakeProc:
+ """Stands in for subprocess.CompletedProcess when the executable itself is missing."""
+
+ def __init__(self, returncode, stderr):
+ self.returncode = returncode
+ self.stdout = ""
+ self.stderr = stderr
+
+
+def _sh(report: _Report, label: str, args: list, cwd=None, env=None, input_text=None,
+ timeout=_RUN_TIMEOUT):
+ """Run one subprocess step, logging the command and its full output either way."""
+ report.command(label, args, cwd)
+ try:
+ proc = subprocess.run(args, cwd=cwd, env=env, input=input_text,
+ capture_output=True, text=True, timeout=timeout)
+ except OSError as e:
+ # A missing executable, or one the previous step left unrunnable:
+ # recorded as a failed step, so the remaining configurations still run.
+ proc = _FakeProc(127, f"{args[0]}: cannot run ({e.strerror})")
+ except subprocess.TimeoutExpired as e:
+ proc = _FakeProc(124, f"timed out after {timeout}s\nstdout so far:\n{e.stdout}\nstderr so far:\n{e.stderr}")
+ report.outcome(proc)
+ return proc
+
+
+def _link_archive(lib: Path, cwd: Path) -> list:
+ """`-L -l` for lib.a, relative to cwd (see the hipcc note at its use)."""
+ assert lib.name.startswith("lib") and lib.suffix == ".a", lib
+ return [f"-L{lib.parent.relative_to(cwd)}", f"-l{lib.name[3:-2]}"]
+
+
+def _gnu_style(compiler: str) -> bool:
+ return Path(shlex.split(compiler)[0]).name.startswith(_GNU_STYLE_PREFIXES)
+
+
+def _c_flags(cc: str) -> list:
+ """The C flags the gate adds before the user's --flags (ruling R24)."""
+ return ["-O2", "-Wall", "-Wextra", "-std=c11"] if _gnu_style(cc) else ["-O2"]
+
+
+def _f_flags(fc: str) -> list:
+ """The Fortran flags the gate adds before the user's --flags (ruling R24)."""
+ return ["-O2", "-Wall", "-Wextra", "-std=f2008"] if _gnu_style(fc) else ["-O2"]
+
+
+def _record_versions(report: _Report, cc: str, fc: str, devcc) -> None:
+ report.h("toolchain", 3)
+ report.p(f"platform: {platform.platform()}")
+ for label, exe in (("cc", cc), ("fc", fc), ("devcc", devcc)):
+ if not exe:
+ continue
+ # --devcc may carry its own arguments ("nvcc -ccbin nvc++"), so it is
+ # split like a shell word list wherever it becomes argv.
+ try:
+ proc = subprocess.run([*shlex.split(exe), "--version"], capture_output=True, text=True)
+ text = proc.stdout or proc.stderr or "(no output)"
+ except FileNotFoundError as e:
+ text = f"{exe}: not found ({e.strerror})"
+ report.block(f"{label} ({exe}) --version", text)
+
+
+def _ensure_model(report: _Report) -> Path:
+ onnx_path = golden_model_path(_REPO_ROOT, _MODEL)
+ if not onnx_path.exists():
+ with golden_generator_run(_REPO_ROOT, _MODEL) as (argv, cwd, env):
+ _sh(report, "generate the golden gemm_big model", argv, cwd=cwd, env=env)
+ return onnx_path
+
+
+def _generate(outdir: Path, onnx_path: Path, embed: bool):
+ outdir.mkdir(parents=True, exist_ok=True)
+ graph = load_graph(str(onnx_path))
+ plan = build_plan(graph, dtype="f64", embed=embed)
+ validate_model_name(plan.model)
+ name = plan.model
+ (outdir / f"{name}_model.F90").write_text(emit_fortran(plan))
+ (outdir / f"{name}_fortran.mk").write_text(emit_fortran_recipe(plan))
+ source, header = emit_c(plan)
+ (outdir / f"{name}.c").write_text(source)
+ (outdir / f"{name}.h").write_text(header)
+ (outdir / f"{name}.mk").write_text(emit_c_recipe(plan))
+ (outdir / f"{name}_kernel.cu").write_text(emit_kernel(plan))
+ (outdir / "rosenna_rt.h").write_text(rt_header())
+ if not plan.embed:
+ write_weights(plan, graph, outdir / f"{name}.rwt")
+ return plan
+
+
+@dataclass(frozen=True)
+class _CLibs:
+ """The C archives one configuration builds (ruling R21).
+
+ `host`: lib.a built by the HOST compiler (ROSENNA_BACKEND=omp with
+ the host offload flags) in the configuration directory, for the per-point
+ C harness in every backend. A file-loaded model's weight arrays reach the
+ host compiler's offload region only through the `declare target` device
+ copies that this build makes and that init's `target update` fills; an
+ nvcc/hipcc build of .c has neither (_OPENMP is not defined there),
+ so linking that archive into a per-point OpenMP/OpenACC host leaves the
+ offload loop reading weights that do not exist on the device.
+ `dev`: lib.a built by the device compiler (ROSENNA_BACKEND=cuda|hip)
+ from a copy of the sources in _lib/, for the .cu driver of the
+ infer_batch harness. None under --backend omp, where that harness links
+ `host` instead. Either is None when its build failed.
+ """
+ host: Path | None
+ dev: Path | None
+
+
+def _build_c_libs(report: _Report, outdir: Path, plan, cc, flags, backend, devcc, devflags) -> _CLibs:
+ name = plan.model
+ label = "embedded" if plan.embed else "file-loaded"
+ # CFLAGS is passed explicitly (ruling R24): the recipe's own default is the
+ # gcc-style set, which must never reach a vendor host compiler.
+ args = ["make", "-f", f"{name}.mk", "ROSENNA_BACKEND=omp", f"CC={cc}",
+ f"CFLAGS={' '.join(_c_flags(cc))}", f"ROSENNA_OFFLOAD_FLAGS={flags}"]
+ proc = _sh(report, f"build c library ({label}, backend=omp, host compiler: "
+ "serves the per-point harness)", args, cwd=outdir)
+ host = outdir / f"lib{name}.a"
+ if not (proc.returncode == 0 and host.exists()):
+ host = None
+ if backend == "omp":
+ return _CLibs(host, None)
+ dev_dir = outdir / f"{backend}_lib"
+ dev_dir.mkdir(exist_ok=True)
+ for f in _C_SOURCE_FILES:
+ shutil.copyfile(outdir / f.format(name=name), dev_dir / f.format(name=name))
+ args = ["make", "-f", f"{name}.mk", f"ROSENNA_BACKEND={backend}", f"DEVCC={devcc}"]
+ if devflags:
+ args.append(f"DEVFLAGS={devflags}")
+ proc = _sh(report, f"build c library ({label}, backend={backend}, device compiler, in "
+ f"{dev_dir.name}/: serves the infer_batch harness)", args, cwd=dev_dir)
+ dev = dev_dir / f"lib{name}.a"
+ if not (proc.returncode == 0 and dev.exists()):
+ dev = None
+ return _CLibs(host, dev)
+
+
+def _build_fortran_lib(report: _Report, outdir: Path, plan, fc, flags) -> bool:
+ name = plan.model
+ label = "embedded" if plan.embed else "file-loaded"
+ # FFLAGS explicitly, for the same reason as CFLAGS above (ruling R24):
+ # flang rejects the recipe's default -std=f2008.
+ args = ["make", "-f", f"{name}_fortran.mk", f"FC={fc}", f"FFLAGS={' '.join(_f_flags(fc))}",
+ f"ROSENNA_OFFLOAD_FLAGS={flags}"]
+ proc = _sh(report, f"build fortran library ({label})", args, cwd=outdir)
+ return proc.returncode == 0 and (outdir / f"lib{name}_f.a").exists()
+
+
+def _stdin_for(inputs) -> str:
+ return f"{len(inputs)}\n" + "\n".join(" ".join(repr(float(v)) for v in row) for row in inputs)
+
+
+def _check_output(report: _Report, stdout: str, expected) -> tuple:
+ lines = [l for l in stdout.strip().splitlines() if l.strip()]
+ data_lines = [l for l in lines if not l.startswith("TIMING")]
+ timing_lines = [l for l in lines if l.startswith("TIMING")]
+ if len(data_lines) != len(expected):
+ report.p(f"FAIL: expected {len(expected)} output rows from onnxruntime's own batch, "
+ f"got {len(data_lines)}")
+ return False, None
+ got = np.array([[float(v) for v in l.split()] for l in data_lines])
+ try:
+ np.testing.assert_allclose(got, expected, rtol=_RTOL, atol=_ATOL)
+ except AssertionError as e:
+ report.block("FAIL: output does not match the onnxruntime reference", str(e))
+ return False, None
+ report.p(f"matches the onnxruntime reference (rtol={_RTOL}, atol={_ATOL})")
+ ns = None
+ if timing_lines:
+ ns = float(timing_lines[-1].split()[-1])
+ report.p(f"{ns:.3f} ns per point")
+ else:
+ report.p("FAIL: no TIMING line in the harness output")
+ return False, None
+ return True, ns
+
+
+_C_HARNESS1 = """/* rosenna gpu-gate: microfd-shaped per-point host, C. */
+#include
+#include
+#include
+#include
+#include "{name}.h"
+#ifdef ROSENNA_GATE_MARKERS
+#include {marker_header}
+#endif
+int main(void) {{
+ {init}
+ int n;
+ if (scanf("%d", &n) != 1) return 1;
+ double *x = malloc(sizeof(double) * (size_t)n * {n_in});
+ double *y = malloc(sizeof(double) * (size_t)n * {n_out});
+ for (int c = 0; c < n * {n_in}; ++c) if (scanf("%lf", &x[c]) != 1) return 1;
+#ifdef _OPENMP
+ #pragma omp target teams distribute parallel for map(to: x[0:n*{n_in}]) map(from: y[0:n*{n_out}])
+#endif
+ for (int p = 0; p < n; ++p) {name}_infer(x + p * {n_in}, y + p * {n_out}); /* microfd's own target loop calling the header inline */
+ for (int p = 0; p < n; ++p) {{
+ for (int i = 0; i < {n_out}; ++i) printf("%.17e ", y[p * {n_out} + i]);
+ printf("\\n");
+ }}
+ /* Timing: one offload region over ntime points, each reading its own
+ input slot and writing its own output slot, with the points tiled on
+ the host and mapped BEFORE the clock starts (ruling R16, as in the
+ infer_batch driver). Both of those matter for the comparison this
+ gate's report invites: a timing loop that cycles a handful of points
+ measures cached reads and a million-way store collision on a few
+ slots, not the per-point cost of the same distinct-point work
+ infer_batch does, and a map inside the timed window charges this path
+ for a transfer the other one makes outside it. */
+ long ntime = {ntime}L;
+ double *xt = malloc(sizeof(double) * (size_t)ntime * {n_in});
+ double *yt = malloc(sizeof(double) * (size_t)ntime * {n_out});
+ for (long t = 0; t < ntime; ++t) {{
+ long b = t % n;
+ for (int i = 0; i < {n_in}; ++i) xt[t * {n_in} + i] = x[b * {n_in} + i];
+ }}
+#ifdef _OPENMP
+ #pragma omp target enter data map(to: xt[0:ntime*{n_in}]) map(alloc: yt[0:ntime*{n_out}])
+#endif
+ /* {nsteps} time steps over the resident data, as a solver would call the
+ model once per step; the transfer check brackets the whole loop. */
+#ifdef ROSENNA_GATE_MARKERS
+ {marker_push}("rosenna_timed");
+#endif
+ double t0 = omp_get_wtime();
+ for (int step = 0; step < {nsteps}; ++step) {{
+#ifdef _OPENMP
+ #pragma omp target teams distribute parallel for
+#endif
+ for (long p = 0; p < ntime; ++p) {name}_infer(xt + p * {n_in}, yt + p * {n_out});
+ }}
+ double t1 = omp_get_wtime();
+#ifdef ROSENNA_GATE_MARKERS
+ {marker_pop}();
+#endif
+#ifdef _OPENMP
+ #pragma omp target exit data map(from: yt[0:ntime*{n_out}]) map(delete: xt[0:ntime*{n_in}])
+#endif
+ /* The timed loop's own results are checked, not just the correctness
+ loop's: xt/yt that failed to map would leave this timing a loop over
+ garbage, and nothing else here would notice. Every timed point is a
+ tile of one of the n correctness points, so yt[t] must reproduce
+ y[t % n] -- to a tolerance, since the two loops are separate regions
+ the compiler may schedule (and contract) differently. */
+ for (long t = 0; t < ntime; ++t) {{
+ long b = t % n;
+ for (int i = 0; i < {n_out}; ++i) {{
+ double got = yt[t * {n_out} + i], want = y[b * {n_out} + i];
+ if (!(fabs(got - want) <= 1e-9 + 1e-9 * fabs(want))) {{
+ printf("TIMED MISMATCH at point %ld slot %d: %.17e vs %.17e\\n",
+ t, i, got, want);
+ return 5;
+ }}
+ }}
+ }}
+ printf("TIMING %.6f\\n", (t1 - t0) * 1.0e9 / ((double)ntime * {nsteps}));
+ free(x); free(y); free(xt); free(yt);
+ return 0;
+}}
+"""
+
+# The Fortran harnesses are .F90 so the marker bracket can be preprocessed
+# in the same way; the push/pop are bound to the C symbols directly.
+_F_MARKERS = """
+#ifdef ROSENNA_GATE_MARKERS
+ interface
+ function rosenna_range_push(msg) bind(C, name="{marker_push}") result(r)
+ use iso_c_binding, only: c_char, c_int
+ character(kind=c_char), dimension(*), intent(in) :: msg
+ integer(c_int) :: r
+ end function
+ function rosenna_range_pop() bind(C, name="{marker_pop}") result(r)
+ use iso_c_binding, only: c_int
+ integer(c_int) :: r
+ end function
+ end interface
+ integer(c_int) :: irange
+#endif
+"""
+
+_F_HARNESS2 = """
+program host
+ use {name}_model
+ use iso_fortran_env, only: real64
+ use iso_c_binding, only: c_int, c_null_char
+ implicit none
+ real(real64), allocatable :: x(:,:), y(:,:), xt(:,:), yt(:,:)
+ integer :: n, p, status, b, ntime, t
+ integer(8) :: c0, c1, crate
+ real(real64) :: ns_per_point
+{f_markers}
+ status = 0
+ {init_lines}
+ read(*,*) n
+ allocate(x({n_in}, n), y({n_out}, n))
+ read(*,*) x
+ !$omp target teams distribute parallel do map(to: x) map(from: y)
+ do p = 1, n
+ call {name}_infer(x(:, p), y(:, p))
+ end do
+ do p = 1, n
+ print '({n_out}(es24.16,1x))', y(:, p)
+ end do
+ ! Timing: one offload region over ntime points, each reading its own
+ ! input column and writing its own output column, with the points tiled
+ ! on the host and mapped BEFORE the clock starts (ruling R16, as in the
+ ! infer_batch driver). Both of those matter for the comparison this
+ ! gate's report invites: a timing loop that cycles a handful of points
+ ! measures cached reads and a million-way store collision on a few
+ ! columns, not the per-point cost of the same distinct-point work
+ ! infer_batch does, and a map inside the timed window charges this path
+ ! for a transfer the other one makes outside it.
+ ntime = {ntime}
+ allocate(xt({n_in}, ntime), yt({n_out}, ntime))
+ do t = 1, ntime
+ b = mod(t - 1, n) + 1
+ xt(:, t) = x(:, b)
+ end do
+ !$omp target enter data map(to: xt) map(alloc: yt)
+ ! {nsteps} time steps over the resident data, as a solver would call the
+ ! model once per step; the transfer check brackets the whole loop.
+#ifdef ROSENNA_GATE_MARKERS
+ irange = rosenna_range_push("rosenna_timed" // c_null_char)
+#endif
+ call system_clock(count=c0, count_rate=crate)
+ call step_loop(xt, yt, ntime)
+ call system_clock(count=c1)
+#ifdef ROSENNA_GATE_MARKERS
+ irange = rosenna_range_pop()
+#endif
+ !$omp target exit data map(from: yt) map(delete: xt)
+ ! The timed loop's own results are checked, not just the correctness
+ ! loop's: xt/yt that failed to map would leave this timing a loop over
+ ! garbage, and nothing else here would notice. Every timed point is a
+ ! tile of one of the n correctness points, so yt(:, t) must reproduce
+ ! y(:, mod(t-1,n)+1) -- to a tolerance, since the two loops are separate
+ ! regions the compiler may schedule (and contract) differently.
+ do t = 1, ntime
+ b = mod(t - 1, n) + 1
+ if (any(abs(yt(:, t) - y(:, b)) > 1.0e-9_real64 + 1.0e-9_real64 * abs(y(:, b)))) then
+ print '(A, I0)', 'TIMED MISMATCH at point ', t
+ stop 5
+ end if
+ end do
+ ns_per_point = real(c1 - c0, real64) / real(crate, real64) * 1.0e9_real64 &
+ / (real(ntime, real64) * {nsteps}.0_real64)
+ print '(A, ES24.16)', 'TIMING ', ns_per_point
+contains
+ ! The step loop sees the arrays as explicit-shape dummies, not as the
+ ! allocatables they are in the caller: an allocatable carries a
+ ! descriptor, and flang's OpenMP re-maps that descriptor (two small
+ ! host-to-device copies here, one per array) on EVERY target-region
+ ! entry, i.e. once per time step -- seen on an MI210 with amdflang. An
+ ! explicit-shape dummy has no descriptor, so the resident data is
+ ! reached with no transfer at all, which is what a solver's own step
+ ! loop should do too.
+ subroutine step_loop(xt, yt, ntime)
+ integer, intent(in) :: ntime
+ real(real64), intent(in) :: xt({n_in}, ntime)
+ real(real64), intent(inout) :: yt({n_out}, ntime)
+ integer :: step, t
+ do step = 1, {nsteps}
+ !$omp target teams distribute parallel do
+ do t = 1, ntime
+ call {name}_infer(xt(:, t), yt(:, t))
+ end do
+ end do
+ end subroutine
+end program
+"""
+
+_C_HARNESS3_OMP = """/* rosenna gpu-gate: infer_batch over device-resident data (omp backend), C. */
+#include
+#include
+#include
+#include "{name}.h"
+int main(void) {{
+ {init}
+ int n;
+ if (scanf("%d", &n) != 1) return 1;
+ double *x = malloc(sizeof(double) * (size_t)n * {n_in});
+ double *y = malloc(sizeof(double) * (size_t)n * {n_out});
+ for (int c = 0; c < n * {n_in}; ++c) if (scanf("%lf", &x[c]) != 1) return 1;
+ int status;
+#ifdef _OPENMP
+ #pragma omp target enter data map(to: x[0:n*{n_in}]) map(alloc: y[0:n*{n_out}])
+ #pragma omp target data use_device_ptr(x, y)
+#endif
+ {{
+ status = {name}_infer_batch(n, x, y, 0);
+ }}
+#ifdef _OPENMP
+ #pragma omp target exit data map(from: y[0:n*{n_out}]) map(delete: x[0:n*{n_in}])
+#endif
+ if (status != 0) return 20 + status;
+ for (int p = 0; p < n; ++p) {{
+ for (int i = 0; i < {n_out}; ++i) printf("%.17e ", y[p * {n_out} + i]);
+ printf("\\n");
+ }}
+ /* Timing: ONE call over ntime points (a batch is meant to be called once
+ over many points, not called many times over a small batch -- the
+ latter pays a construct-entry cost per call and times that instead of
+ the kernel). Values are the correctness batch tiled; only infer_batch's
+ own cost is timed, not the tiling. */
+ long ntime = {ntime}L;
+ double *xt = malloc(sizeof(double) * (size_t)ntime * {n_in});
+ double *yt = malloc(sizeof(double) * (size_t)ntime * {n_out});
+ for (long t = 0; t < ntime; ++t) {{
+ int b = (int)(t % n);
+ for (int i = 0; i < {n_in}; ++i) xt[t * {n_in} + i] = x[b * {n_in} + i];
+ }}
+ /* Ruling R16: the mapping (a real transfer, exempt from R5 since it is
+ the harness's own setup, not inside infer_batch) happens before t0
+ and is undone after t1, so the timed window holds only the call --
+ matching what the cuda/hip .cu driver already does. */
+#ifdef _OPENMP
+ #pragma omp target enter data map(to: xt[0:ntime*{n_in}]) map(alloc: yt[0:ntime*{n_out}])
+#endif
+ double t0 = omp_get_wtime();
+#ifdef _OPENMP
+ #pragma omp target data use_device_ptr(xt, yt)
+#endif
+ {{
+ for (int step = 0; step < {nsteps} && status == 0; ++step)
+ status = {name}_infer_batch((int)ntime, xt, yt, 0);
+ }}
+ double t1 = omp_get_wtime();
+#ifdef _OPENMP
+ #pragma omp target exit data map(from: yt[0:ntime*{n_out}]) map(delete: xt[0:ntime*{n_in}])
+#endif
+ if (status != 0) return 30 + status;
+ printf("TIMING %.6f\\n", (t1 - t0) * 1.0e9 / ((double)ntime * {nsteps}));
+ free(x); free(y); free(xt); free(yt);
+ return 0;
+}}
+"""
+
+_F_HARNESS3_OMP = """
+program host
+ use {name}_model
+ use iso_fortran_env, only: real64
+ implicit none
+ real(real64), allocatable :: x(:,:), y(:,:), xt(:,:), yt(:,:)
+ integer :: n, p, status, ntime, t, b, step
+ integer(8) :: c0, c1, crate
+ real(real64) :: ns_per_point
+ status = 0
+ {init_lines}
+ read(*,*) n
+ allocate(x({n_in}, n), y({n_out}, n))
+ read(*,*) x
+ ! x and y are mapped first, and the call sees their device addresses
+ ! (use_device_addr): infer_batch's has_device_addr clause needs those,
+ ! not the host addresses (ruling R5).
+ !$omp target enter data map(to: x) map(alloc: y)
+ !$omp target data use_device_addr(x, y)
+ call {name}_infer_batch(n, x, y, status)
+ !$omp end target data
+ !$omp target exit data map(from: y) map(delete: x)
+ if (status /= 0) stop 20
+ do p = 1, n
+ print '({n_out}(es24.16,1x))', y(:, p)
+ end do
+ ! Timing: ONE call over ntime points (a batch is meant to be called once
+ ! over many points, not called many times over a small batch), values
+ ! tiled from the correctness batch above; only infer_batch itself is timed.
+ ntime = {ntime}
+ allocate(xt({n_in}, ntime), yt({n_out}, ntime))
+ do t = 1, ntime
+ b = mod(t - 1, n) + 1
+ xt(:, t) = x(:, b)
+ end do
+ ! Ruling R16: map before c0 and unmap after c1, so the timed window
+ ! holds only the infer_batch call, matching the cuda/hip .cu driver.
+ !$omp target enter data map(to: xt) map(alloc: yt)
+ !$omp target data use_device_addr(xt, yt)
+ call system_clock(count=c0, count_rate=crate)
+ do step = 1, {nsteps}
+ if (status == 0) call {name}_infer_batch(ntime, xt, yt, status)
+ end do
+ call system_clock(count=c1)
+ !$omp end target data
+ !$omp target exit data map(from: yt) map(delete: xt)
+ if (status /= 0) stop 21
+ ns_per_point = real(c1 - c0, real64) / real(crate, real64) * 1.0e9_real64 &
+ / (real(ntime, real64) * {nsteps}.0_real64)
+ print '(A, ES24.16)', 'TIMING ', ns_per_point
+end program
+"""
+
+_DEV_HARNESS3 = """/* rosenna gpu-gate: infer_batch over raw device pointers ({backend}), written by the gate. */
+#include
+#include
+#include
+/* rosenna_rt.h includes cuda_runtime.h or hip/hip_runtime.h for whichever
+ compiler this is: nvcc includes its runtime implicitly, hipcc does not. */
+#include "rosenna_rt.h"
+#include "{name}.h"
+#ifdef ROSENNA_GATE_MARKERS
+/* Ruling R15: only defined (via -DROSENNA_GATE_MARKERS=1) for the separate
+ build the profiler check compiles, so the ordinary timed run never needs
+ this header: nvtx3 for nsys (documented as header-only; _run_nsys_check
+ retries the link with -lnvToolsExt if the no-link form fails), roctx for
+ rocprofv3 (linked with -lrocprofiler-sdk-roctx). */
+#include {marker_header}
+#endif
+int main(void) {{
+ {init}
+ int n;
+ if (scanf("%d", &n) != 1) return 1;
+ double *hx = (double*)malloc(sizeof(double) * (size_t)n * {n_in});
+ double *hy = (double*)malloc(sizeof(double) * (size_t)n * {n_out});
+ for (int c = 0; c < n * {n_in}; ++c) if (scanf("%lf", &hx[c]) != 1) return 1;
+ double *dx = 0, *dy = 0;
+ if ({p}Malloc((void**)&dx, sizeof(double) * (size_t)n * {n_in}) != {p}Success) return 2;
+ if ({p}Malloc((void**)&dy, sizeof(double) * (size_t)n * {n_out}) != {p}Success) return 2;
+ if ({p}Memcpy(dx, hx, sizeof(double) * (size_t)n * {n_in}, {p}MemcpyHostToDevice) != {p}Success) return 2;
+ int status = {name}_infer_batch(n, dx, dy, 0);
+ if (status != 0) return 20 + status;
+ if ({p}Memcpy(hy, dy, sizeof(double) * (size_t)n * {n_out}, {p}MemcpyDeviceToHost) != {p}Success) return 3;
+ for (int p2 = 0; p2 < n; ++p2) {{
+ for (int i = 0; i < {n_out}; ++i) printf("%.17e ", hy[p2 * {n_out} + i]);
+ printf("\\n");
+ }}
+ /* Timing: ONE call over ntime points (a batch is meant to be called once
+ over many points), values tiled from the correctness batch above on
+ the host, copied to the device once, before the clock starts. */
+ long ntime = {ntime}L;
+ double *hxt = (double*)malloc(sizeof(double) * (size_t)ntime * {n_in});
+ for (long t = 0; t < ntime; ++t) {{
+ int b = (int)(t % n);
+ for (int i = 0; i < {n_in}; ++i) hxt[t * {n_in} + i] = hx[b * {n_in} + i];
+ }}
+ double *dxt = 0, *dyt = 0;
+ if ({p}Malloc((void**)&dxt, sizeof(double) * (size_t)ntime * {n_in}) != {p}Success) return 4;
+ if ({p}Malloc((void**)&dyt, sizeof(double) * (size_t)ntime * {n_out}) != {p}Success) return 4;
+ if ({p}Memcpy(dxt, hxt, sizeof(double) * (size_t)ntime * {n_in}, {p}MemcpyHostToDevice) != {p}Success) return 4;
+ struct timespec t0, t1;
+ clock_gettime(CLOCK_MONOTONIC, &t0);
+ /* No cudaMemcpy/hipMemcpy in this call (ruling R5). Ruling R15: the
+ profiler check brackets ONLY this call with a named range (nvtx under
+ nsys, which captures just that range; roctx under rocprofv3, whose
+ trace is then cut to the range's timestamps), so the memcpy count is
+ scoped to the call itself, not to this driver's untimed setup above
+ (which legitimately memcpys) -- counting across the whole profile
+ would fail an R5-compliant infer_batch. */
+#ifdef ROSENNA_GATE_MARKERS
+ {marker_push}("rosenna_timed");
+#endif
+ for (int step = 0; step < {nsteps} && status == 0; ++step)
+ status = {name}_infer_batch((int)ntime, dxt, dyt, 0);
+ {p}DeviceSynchronize();
+#ifdef ROSENNA_GATE_MARKERS
+ {marker_pop}();
+#endif
+ if (status != 0) return 30 + status;
+ clock_gettime(CLOCK_MONOTONIC, &t1);
+ double secs = (double)(t1.tv_sec - t0.tv_sec) + (double)(t1.tv_nsec - t0.tv_nsec) * 1e-9;
+ printf("TIMING %.6f\\n", secs * 1.0e9 / ((double)ntime * {nsteps}));
+ {p}Free(dx); {p}Free(dy); {p}Free(dxt); {p}Free(dyt);
+ free(hx); free(hy); free(hxt);
+ return 0;
+}}
+"""
+
+
+def _c_harness1(plan, backend: str) -> str:
+ name = plan.model
+ header, push, pop = _MARKERS.get(backend, ("", "", ""))
+ return _C_HARNESS1.format(
+ name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0],
+ init="" if plan.embed else f'if ({name}_init("{name}.rwt")) return 2;',
+ ntime=_TIMED_ITERS, nsteps=_NSTEPS, marker_header=header, marker_push=push, marker_pop=pop)
+
+
+def _f_harness2(plan, backend: str) -> str:
+ name = plan.model
+ _, push, pop = _MARKERS.get(backend, ("", "", ""))
+ return _F_HARNESS2.format(
+ name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0],
+ init_lines="" if plan.embed else f'call {name}_init("{name}.rwt", status); if (status /= 0) stop 2',
+ ntime=_TIMED_ITERS, nsteps=_NSTEPS,
+ f_markers=_F_MARKERS.format(marker_push=push, marker_pop=pop))
+
+
+def _run_c_harness1(report, cfg_dir, plan, cc, flags, host_lib, inputs, expected, env, backend) -> bool:
+ (cfg_dir / "gate_harness1.c").write_text(_c_harness1(plan, backend))
+ # Rulings R21/R22: the HOST compiler, with its own offload flags (nvc's
+ # -mp=gpu -gpu=cc80, amdclang's -fopenmp --offload-arch=..., or plain
+ # -fopenmp), both compiles and links this harness in every backend. The
+ # archive it links (file-loaded plans only) is the omp-backend one the
+ # same host compiler built (_CLibs.host), so no CUDA/HIP runtime is
+ # involved and nothing is forwarded to a device compiler: nvcc's default
+ # host compiler is g++, which rejects -mp=gpu, so a device-compiler link
+ # of this object cannot work, and the omp archive is the only one whose
+ # weight arrays have the declare-target copies this offload loop reads.
+ cc_proc = _sh(report, "compile c per-point harness (host compiler, host offload flags)",
+ [cc, *_c_flags(cc), *flags.split(),
+ "-c", "gate_harness1.c", "-o", "gate_harness1.o"], cwd=cfg_dir)
+ if cc_proc.returncode != 0:
+ return False
+ objs = ["gate_harness1.o"]
+ if not plan.embed:
+ objs.append(str(host_lib.relative_to(cfg_dir)))
+ link_proc = _sh(report, "link c per-point harness (host compiler, host offload flags, "
+ "omp-backend archive)",
+ [cc, *flags.split(), *objs, "-lm", "-o", "gate_harness1"], cwd=cfg_dir)
+ if link_proc.returncode != 0:
+ return False
+ run_proc = _sh(report, "run c per-point harness", ["./gate_harness1"], cwd=cfg_dir,
+ env=env, input_text=_stdin_for(inputs))
+ if run_proc.returncode != 0:
+ return False
+ ok, _ = _check_output(report, run_proc.stdout, expected)
+ return ok
+
+
+def _run_fortran_harness2(report, cfg_dir, plan, fc, flags, inputs, expected, env, backend) -> bool:
+ name = plan.model
+ (cfg_dir / "gate_harness2.F90").write_text(_f_harness2(plan, backend))
+ fc_proc = _sh(report, "compile fortran per-point harness",
+ [fc, *_f_flags(fc), *flags.split(),
+ "gate_harness2.F90", f"lib{name}_f.a", "-o", "gate_harness2"], cwd=cfg_dir)
+ if fc_proc.returncode != 0:
+ return False
+ run_proc = _sh(report, "run fortran per-point harness", ["./gate_harness2"], cwd=cfg_dir,
+ env=env, input_text=_stdin_for(inputs))
+ if run_proc.returncode != 0:
+ return False
+ ok, _ = _check_output(report, run_proc.stdout, expected)
+ return ok
+
+
+def _run_c_harness3_omp(report, cfg_dir, plan, cc, flags, host_lib, inputs, expected, env) -> bool:
+ name = plan.model
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ init = "" if plan.embed else f'if ({name}_init("{name}.rwt")) return 2;'
+ (cfg_dir / "gate_harness3.c").write_text(_C_HARNESS3_OMP.format(
+ name=name, n_in=n_in, n_out=n_out, init=init, ntime=_TIMED_ITERS, nsteps=_NSTEPS))
+ cc_proc = _sh(report, "compile c infer_batch harness (omp)",
+ [cc, *_c_flags(cc), *flags.split(), "gate_harness3.c",
+ str(host_lib.relative_to(cfg_dir)), "-lm", "-o", "gate_harness3"], cwd=cfg_dir)
+ if cc_proc.returncode != 0:
+ return False
+ run_proc = _sh(report, "run c infer_batch harness (omp)", ["./gate_harness3"], cwd=cfg_dir,
+ env=env, input_text=_stdin_for(inputs))
+ if run_proc.returncode != 0:
+ return False
+ ok, _ = _check_output(report, run_proc.stdout, expected)
+ return ok
+
+
+def _run_fortran_harness3_omp(report, cfg_dir, plan, fc, flags, inputs, expected, env) -> bool:
+ name = plan.model
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ init_lines = "" if plan.embed else (
+ f'call {name}_init("{name}.rwt", status); if (status /= 0) stop 2')
+ (cfg_dir / "gate_harness3.f90").write_text(_F_HARNESS3_OMP.format(
+ name=name, n_in=n_in, n_out=n_out, init_lines=init_lines, ntime=_TIMED_ITERS, nsteps=_NSTEPS))
+ fc_proc = _sh(report, "compile fortran infer_batch harness (omp)",
+ [fc, *_f_flags(fc), *flags.split(),
+ "gate_harness3.f90", f"lib{name}_f.a", "-o", "gate_harness3_f"], cwd=cfg_dir)
+ if fc_proc.returncode != 0:
+ return False
+ run_proc = _sh(report, "run fortran infer_batch harness (omp)", ["./gate_harness3_f"],
+ cwd=cfg_dir, env=env, input_text=_stdin_for(inputs))
+ if run_proc.returncode != 0:
+ return False
+ ok, _ = _check_output(report, run_proc.stdout, expected)
+ return ok
+
+
+def _run_dev_harness3(report, cfg_dir, plan, devcc, devflags, backend, dev_lib, inputs, expected) -> bool:
+ name = plan.model
+ (cfg_dir / "gate_harness3.cu").write_text(_dev_harness3(plan, backend))
+ # Ruling R22: the device compiler compiles AND links this driver (it
+ # supplies its own runtime), against the archive it built itself. The
+ # archive goes to the linker as -L/-l rather than as a bare path: hipcc
+ # injects `-x hip` ahead of a .cu input, and that applies to every input
+ # after it, so a bare libfoo.a after the .cu is compiled as HIP source
+ # ("!: expected unqualified-id"). nvcc dispatches by extension and
+ # takes either form. Seen on an MI210 with ROCm 7.2.
+ proc = _sh(report, f"compile and link {backend} infer_batch harness (device compiler)",
+ [*shlex.split(devcc), *devflags.split(),
+ "gate_harness3.cu", *_link_archive(dev_lib, cfg_dir), "-o", "gate_harness3_dev"],
+ cwd=cfg_dir)
+ if proc.returncode != 0:
+ return False
+ run_proc = _sh(report, f"run {backend} infer_batch harness", ["./gate_harness3_dev"],
+ cwd=cfg_dir, input_text=_stdin_for(inputs))
+ if run_proc.returncode != 0:
+ return False
+ ok, _ = _check_output(report, run_proc.stdout, expected)
+ return ok
+
+
+# Per backend: the marker header the profiler check's harness includes, and
+# the range push/pop it calls. nvtx3 is what nsys captures on; roctx (the
+# rocprofiler-sdk one, ROCm >= 6.2) is what rocprofv3 --marker-trace records.
+_MARKERS = {
+ "cuda": ("", "nvtxRangePushA", "nvtxRangePop"),
+ "hip": ("", "roctxRangePushA", "roctxRangePop"),
+}
+
+
+def _dev_harness3(plan, backend: str) -> str:
+ """Render the infer_batch driver; the marker bracket is inert without -DROSENNA_GATE_MARKERS."""
+ name = plan.model
+ header, push, pop = _MARKERS[backend]
+ return _DEV_HARNESS3.format(
+ name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0],
+ init="" if plan.embed else f'if ({name}_init("{name}.rwt")) return 2;',
+ ntime=_TIMED_ITERS, nsteps=_NSTEPS, p=backend, backend=backend,
+ marker_header=header, marker_push=push, marker_pop=pop)
+
+
+def _probe_marker_header(report: _Report, cfg_dir: Path, devcc: str, devflags: str,
+ header: str) -> bool:
+ """Compile-only probe for the marker header with the device compiler.
+
+ Ruling R15: if the header is not found, the profiler check is skipped
+ with a named reason rather than failing the gate or attempting to build
+ the instrumented variant anyway.
+ """
+ (cfg_dir / "gate_marker_probe.cu").write_text(
+ f"#include {header}\nint main(void){{return 0;}}\n")
+ proc = _sh(report, f"probe for {header}",
+ [*shlex.split(devcc), *devflags.split(), "-c", "gate_marker_probe.cu",
+ "-o", "gate_marker_probe.o"], cwd=cfg_dir)
+ return proc.returncode == 0
+
+
+@dataclass(frozen=True)
+class NsysParseResult:
+ """Ruling R18: a structured parse result, not a bare int.
+
+ `parsed` is True only when the Name/Num Calls columns were both
+ recognised AND at least one row was actually read -- proving the CSV
+ was genuinely parsed, not just that an empty or unrelated header
+ happened to match nothing. `count` (the sum of Num Calls over every row
+ whose Name starts with "cudaMemcpy" or "cuMemcpy") is meaningful only when `parsed`
+ is True: an unparsed 0 must never be read as a passing zero, since the
+ whole point of this check is R5 evidence.
+ """
+ parsed: bool
+ count: int
+
+
+def _sum_cudamemcpy_calls(csv_text: str) -> NsysParseResult:
+ """Parse `nsys stats --report cuda_api_sum --format csv` output.
+
+ On stdout the CSV comes after a preamble ("Generating SQLite file ...",
+ "Processing ...", a "** CUDA API Summary" title); the gate asks for -q
+ to drop it, but does not rely on that: the header is the first line
+ naming both a "Num Calls" and a "Name" column, case-insensitively, and
+ parsing starts there. Column names/casing can drift slightly across
+ Nsight Systems versions, so the columns are then matched by substring
+ rather than exact string.
+ """
+ lines = csv_text.splitlines()
+ start = next((i for i, line in enumerate(lines)
+ if "num calls" in line.lower() and "name" in line.lower()), None)
+ if start is None:
+ return NsysParseResult(False, 0)
+ reader = csv.DictReader(io.StringIO("\n".join(lines[start:])))
+ if not reader.fieldnames:
+ return NsysParseResult(False, 0)
+ name_col = next((f for f in reader.fieldnames if "name" in f.lower()), None)
+ calls_col = next((f for f in reader.fieldnames
+ if "num calls" in f.lower() or "numcalls" in f.lower().replace(" ", "")),
+ None)
+ if not name_col or not calls_col:
+ return NsysParseResult(False, 0)
+ rows_read = 0
+ total = 0
+ for row in reader:
+ rows_read += 1
+ name = (row.get(name_col) or "").strip()
+ # Runtime API (cudaMemcpy*) and driver API (cuMemcpy*, which is what
+ # nvc's OpenMP offload calls) alike.
+ if name.startswith("cudaMemcpy") or name.startswith("cuMemcpy"):
+ total += int(float(row.get(calls_col) or 0))
+ if rows_read == 0:
+ # Header recognised but no data rows: still inconclusive, not a
+ # genuine (parsed) zero -- an empty cuda_api_sum table is at least
+ # as likely to mean "nsys produced nothing useful" as "zero calls".
+ return NsysParseResult(False, 0)
+ return NsysParseResult(True, total)
+
+
+@dataclass(frozen=True)
+class _Instrumented:
+ """One harness rebuilt with its marker bracket, for the profiler.
+
+ `sources` are the inputs the ordinary build used (the same files: the
+ bracket is inert without -DROSENNA_GATE_MARKERS); `compiler` is the
+ command that built them, and `env` what the run needs (MANDATORY for the
+ OpenMP harnesses). Every harness a configuration ran is profiled: the
+ per-point C and Fortran hosts are the shape a solver actually has -- the
+ model called from its own target loop, once per time step -- and the
+ infer_batch driver is the native-kernel path.
+ """
+ label: str
+ exe: str
+ compiler: list
+ sources: list
+ env: dict | None
+
+
+def _rocm_root() -> Path | None:
+ """The ROCm install rocprofv3 came from; where its roctx library lives."""
+ rocprof = shutil.which("rocprofv3")
+ return Path(rocprof).resolve().parent.parent if rocprof else None
+
+
+def _marker_link(backend: str) -> list:
+ """Linker arguments for the marker library, for a host compiler.
+
+ hipcc knows its own lib dir; amdclang/amdflang do not, so the roctx
+ library is named with its directory. nvtx3 is header-only for C (it
+ loads libnvToolsExt at runtime); the Fortran bracket binds the symbols
+ directly and so needs the library -- _build_instrumented retries with
+ -lnvToolsExt and then nvfortran's -cudalib=nvtx.
+ """
+ if backend == "hip":
+ root = _rocm_root()
+ return ([f"-L{root / 'lib'}", f"-I{root / 'include'}"] if root else []) + ["-lrocprofiler-sdk-roctx"]
+ return []
+
+
+def _build_instrumented(report: _Report, cfg_dir: Path, h: _Instrumented, backend: str) -> bool:
+ base = [*h.compiler, "-DROSENNA_GATE_MARKERS=1", *h.sources, *_marker_link(backend)]
+ attempts = [([], "")]
+ if backend == "cuda":
+ attempts += [(["-lnvToolsExt"], " (retry: -lnvToolsExt)"),
+ (["-cudalib=nvtx"], " (retry: -cudalib=nvtx)")]
+ for extra, note in attempts:
+ proc = _sh(report, f"compile marker-bracketed {h.label} harness{note}",
+ [*base, *extra, "-o", h.exe], cwd=cfg_dir)
+ if proc.returncode == 0:
+ return True
+ return False
+
+
+def _run_nsys_check(report: _Report, cfg_dir: Path, h: _Instrumented, inputs) -> bool:
+ """Ruling R15 under nsys: zero cudaMemcpy/cuMemcpy inside the nvtx-scoped step loop.
+
+ Profiling the whole harness and counting memcpys would fail an
+ R5-compliant model: the driver's own untimed setup (H2D copies before
+ the clock starts) legitimately copies, and so does init. Scoped instead
+ with an nvtx range around only the timed step loop, `nsys profile
+ --capture-range=nvtx --nvtx-capture=rosenna_timed`, and the count read
+ from `nsys stats --report cuda_api_sum --format csv`, which lists the
+ runtime API (cudaMemcpy*) and the driver API (cuMemcpy*, what nvc's
+ OpenMP offload uses) alike. Validated on an A100 for the infer_batch
+ driver; the per-point harnesses go through the same path unexercised.
+ """
+ nsys = shutil.which("nsys")
+ stats_base = (cfg_dir / f"gate_nsys_{h.exe}").resolve()
+ # --capture-range-end=stop: profiling stops when the nvtx range closes
+ # and the harness runs on to completion (the default for an nvtx
+ # capture range shuts the application down instead).
+ # -e NSYS_NVTX_PROFILER_REGISTER_ONLY=0: nsys only honours a capture
+ # range named by a REGISTERED nvtx string unless this is off, and the
+ # harness pushes a plain nvtxRangePushA. Without it the capture range
+ # never opens, nothing is collected, and nsys exits 0 having written no
+ # .nsys-rep at all ("No reports were generated") -- verified on nsys
+ # 2025.5 (HPC SDK 25.11) against an A100. The report path is absolute:
+ # nsys runs with cwd=cfg_dir and would resolve a relative one twice.
+ profile_proc = _sh(
+ report, f"nsys profile --capture-range=nvtx --nvtx-capture=rosenna_timed ({h.label})",
+ [nsys, "profile", "-e", "NSYS_NVTX_PROFILER_REGISTER_ONLY=0",
+ "--capture-range=nvtx", "--nvtx-capture=rosenna_timed",
+ "--capture-range-end=stop", "--stats=true", "--force-overwrite=true",
+ "-o", str(stats_base), f"./{h.exe}"], cwd=cfg_dir, env=h.env,
+ input_text=_stdin_for(inputs))
+ if profile_proc.returncode != 0:
+ report.p("FAIL: nsys profile did not complete successfully")
+ return False
+ report_file = stats_base.with_suffix(".nsys-rep")
+ if not report_file.exists():
+ # nsys exits 0 having written nothing both when the capture range
+ # never opened and when it could not create the file.
+ report.p(f"FAIL: nsys profile exited 0 but wrote no {report_file} -- either the "
+ "nvtx capture range never opened or the report could not be created; "
+ "the stderr above says which.")
+ return False
+ # --force-export=true: `nsys profile --stats=true` above already wrote a
+ # .sqlite beside the report, and nsys refuses to read one it considers
+ # older than the .nsys-rep (which its own finalization makes it).
+ stats_proc = _sh(report, "nsys stats -q --force-export=true --report cuda_api_sum --format csv",
+ [nsys, "stats", "-q", "--force-export=true",
+ "--report", "cuda_api_sum", "--format", "csv",
+ str(report_file)], cwd=cfg_dir)
+ if stats_proc.returncode != 0:
+ report.p("FAIL: nsys stats did not complete successfully")
+ return False
+ result = _sum_cudamemcpy_calls(stats_proc.stdout)
+ if not result.parsed:
+ # Ruling R18: an unparseable export must never read as a passing zero.
+ report.p("nsys check inconclusive: could not parse cuda_api_sum "
+ "(no recognised Name/Num Calls columns, or no data rows read).")
+ raw_lines = stats_proc.stdout.splitlines()[:8]
+ report.block("first lines of `nsys stats --report cuda_api_sum --format csv`",
+ "\n".join(raw_lines) if raw_lines else "(empty output)")
+ report.p("FAIL: an inconclusive parse counts as a gate failure, not a pass.")
+ return False
+ report.p(f"{h.label}: Memcpy API calls (cudaMemcpy*, cuMemcpy*) inside the nvtx-scoped "
+ f"{_NSTEPS}-step loop, from cuda_api_sum: {result.count}")
+ if result.count != 0:
+ report.p("FAIL: nothing in the step loop may transfer (ruling R5)")
+ return False
+ return True
+
+
+def _scope_hip_transfers(marker_csv: str, api_csv, copy_csv) -> NsysParseResult:
+ """Count HIP transfers whose whole interval lies inside the rosenna_timed range.
+
+ rocprofv3 has no nsys-style capture range, but `--hip-trace
+ --marker-trace --memory-copy-trace -f csv` writes every HIP API call,
+ every copy the runtime performed and every roctx range with
+ Start_Timestamp/End_Timestamp on one clock, so the scoping is done here.
+ Both traces count: a small hipMemcpy is staged by the host and never
+ appears as a MEMORY_COPY, and OpenMP offload's copies go over HSA and
+ never appear as a HIP API call -- an OpenMP harness makes no HIP API
+ calls at all, so its api_csv is None. Ruling R18 as for nsys: `parsed`
+ is True only when exactly one rosenna_timed range was found AND at
+ least one trace had recognised columns and at least one row; otherwise
+ the count is not evidence.
+ """
+ def rows(text, name_col):
+ if text is None:
+ return None
+ reader = csv.DictReader(io.StringIO(text))
+ fields = reader.fieldnames or []
+ need = (name_col, "Start_Timestamp", "End_Timestamp")
+ if not all(any(n.lower() == f.lower() for f in fields) for n in need):
+ return None
+ col = {n: next(f for f in fields if f.lower() == n.lower()) for n in need}
+ return [(r[col[name_col]], int(r[col["Start_Timestamp"]]), int(r[col["End_Timestamp"]]))
+ for r in reader]
+
+ markers = rows(marker_csv, "Function")
+ if markers is None:
+ return NsysParseResult(False, 0)
+ ranges = [(t0, t1) for fn, t0, t1 in markers if fn == "rosenna_timed"]
+ if len(ranges) != 1:
+ return NsysParseResult(False, 0)
+ t0, t1 = ranges[0]
+ calls, copies = rows(api_csv, "Function"), rows(copy_csv, "Kind")
+ if not calls and not copies:
+ return NsysParseResult(False, 0)
+ inside = lambda a, b: a >= t0 and b <= t1
+ count = (sum(1 for fn, a, b in (calls or []) if fn.startswith("hipMemcpy") and inside(a, b))
+ + sum(1 for _, a, b in (copies or []) if inside(a, b)))
+ return NsysParseResult(True, count)
+
+
+def _run_rocprof_check(report: _Report, cfg_dir: Path, h: _Instrumented, inputs) -> bool:
+ """The HIP twin of _run_nsys_check: zero transfers inside the roctx-scoped step loop.
+
+ Validated on an MI210 under ROCm 7.2.0 and AFAR 23.2.1 for all three
+ harnesses, embedded and file-loaded.
+ """
+ rocprof = shutil.which("rocprofv3")
+ prof_dir = (cfg_dir / f"gate_rocprof_{h.exe}").resolve()
+ shutil.rmtree(prof_dir, ignore_errors=True)
+ profile_proc = _sh(
+ report, f"rocprofv3 --hip-trace --marker-trace --memory-copy-trace -f csv ({h.label})",
+ [rocprof, "--hip-trace", "--marker-trace", "--memory-copy-trace", "-f", "csv",
+ "-d", str(prof_dir), "-o", "prof", "--", f"./{h.exe}"], cwd=cfg_dir, env=h.env,
+ input_text=_stdin_for(inputs))
+ if profile_proc.returncode != 0:
+ report.p("FAIL: rocprofv3 did not complete successfully")
+ return False
+ marker_csv = prof_dir / "prof_marker_api_trace.csv"
+ if not marker_csv.exists():
+ report.p(f"FAIL: rocprofv3 exited 0 but wrote no {marker_csv.name} in {prof_dir}")
+ return False
+ # rocprofv3 writes a trace file only for a domain that had events: an
+ # OpenMP harness has no HIP API trace, and a harness with no copies at
+ # all would have no memory-copy trace (every one here copies in setup).
+ read = lambda name: (prof_dir / name).read_text() if (prof_dir / name).exists() else None
+ result = _scope_hip_transfers(marker_csv.read_text(), read("prof_hip_api_trace.csv"),
+ read("prof_memory_copy_trace.csv"))
+ if not result.parsed:
+ # Ruling R18: an unparseable trace is a gate failure, not a pass.
+ report.p("rocprof check inconclusive: no single rosenna_timed range in the marker "
+ "trace, or no trace rows at all.")
+ report.block(f"first lines of {marker_csv.name}",
+ "\n".join(marker_csv.read_text().splitlines()[:8]) or "(empty)")
+ report.p("FAIL: an inconclusive parse counts as a gate failure, not a pass.")
+ return False
+ report.p(f"{h.label}: hipMemcpy* API calls plus MEMORY_COPY operations inside the "
+ f"roctx-scoped {_NSTEPS}-step loop: {result.count}")
+ if result.count != 0:
+ report.p("FAIL: nothing in the step loop may transfer (ruling R5)")
+ return False
+ return True
+
+
+def _run_transfer_check(report: _Report, cfg_dir: Path, backend: str, devcc: str,
+ devflags: str, harnesses: list, inputs) -> bool:
+ """Ruling R15 for every harness this configuration ran, with the backend's profiler.
+
+ The profiler and the marker header are looked for once; if either is
+ missing the check is skipped with a named reason (that does not fail
+ the gate on its own). A harness whose instrumented build fails is
+ likewise recorded and skipped -- loudly, since it is the evidence.
+ """
+ profiler, header = (("rocprofv3", _MARKERS["hip"][0]) if backend == "hip"
+ else (("nsys", _MARKERS["cuda"][0])))
+ report.h(f"{profiler} check: transfers inside the marker-scoped {_NSTEPS}-step loop, "
+ f"every harness (ruling R15)", 4)
+ if not shutil.which(profiler):
+ report.p(f"{profiler} not found on PATH; the transfer-count check was NOT run "
+ "(recorded here rather than silently skipped).")
+ return True
+ if not _probe_marker_header(report, cfg_dir, devcc, devflags, header):
+ report.p(f"{profiler} check skipped: marker header {header} did not compile "
+ "with the device compiler.")
+ return True
+ check = _run_rocprof_check if backend == "hip" else _run_nsys_check
+ ok = True
+ for h in harnesses:
+ if not _build_instrumented(report, cfg_dir, h, backend):
+ report.p(f"{profiler} check NOT run for the {h.label} harness: its "
+ "marker-bracketed build failed (see above). This harness has no "
+ "transfer evidence in this report.")
+ continue
+ if not check(report, cfg_dir, h, inputs):
+ ok = False
+ return ok
+
+
+def run_gate(*, cc: str, fc: str, flags: str, backend: str, devcc=None, devflags: str = "",
+ out: str = ".", host_fallback: bool = False) -> int:
+ out_dir = Path(out)
+ out_dir.mkdir(parents=True, exist_ok=True)
+ report_path = out_dir / "gate-report.md"
+ report = _Report()
+ ok = True
+ try:
+ report.h("rosenna gpu-gate report", 1)
+ report.p(f"- model: {_MODEL}")
+ report.p(f"- backend: {backend}")
+ report.p(f"- host-fallback: {host_fallback}")
+ report.p(f"- cc: {cc}")
+ report.p(f"- fc: {fc}")
+ report.p(f"- flags: {flags!r}")
+ if backend != "omp":
+ report.p(f"- devcc: {devcc}")
+ report.p(f"- devflags: {devflags!r}")
+ if host_fallback:
+ report.p("- OMP_TARGET_OFFLOAD=MANDATORY is NOT set (host-fallback mode): "
+ "this run exercises the omp-backend contract end to end on a machine "
+ "with no accelerator, the same host-fallback contract "
+ "tests/test_device_c.py and tests/test_device_fortran.py already cover.")
+ else:
+ report.p("- OMP_TARGET_OFFLOAD=MANDATORY is set for every omp-backend harness: "
+ "a machine with no working offload device must fail here, loudly, "
+ "rather than silently pass by falling back to the host.")
+
+ # Resolved once, used everywhere a device compiler command is needed:
+ # --devcc has a default (it is not required, see --help), so every
+ # call site uses this instead of repeating the fallback logic.
+ resolved_devcc = devcc or ("nvcc" if backend == "cuda" else "hipcc")
+
+ _record_versions(report, cc, fc, resolved_devcc if backend != "omp" else None)
+
+ onnx_path = _ensure_model(report)
+ session = ort.InferenceSession(str(onnx_path))
+ shape = session.get_inputs()[0].shape
+ inputs, expected = _live_reference(session, shape, np.float64, seed=42, batch=8)
+ if inputs is None:
+ report.p("FATAL: the onnxruntime reference for gemm_big is dead across every "
+ "resampled batch; nothing here would demonstrate anything.")
+ report_path.write_text(report.text())
+ return 1
+
+ env = dict(os.environ)
+ if not host_fallback:
+ env["OMP_TARGET_OFFLOAD"] = "MANDATORY"
+
+ for embed in (True, False):
+ label = "embedded" if embed else "file-loaded"
+ report.h(f"{_MODEL}: {label}", 2)
+ cfg_dir = out_dir / ("embedded" if embed else "file_loaded")
+ plan = _generate(cfg_dir, onnx_path, embed)
+
+ libs = _build_c_libs(report, cfg_dir, plan, cc, flags, backend, resolved_devcc, devflags)
+ f_built = _build_fortran_lib(report, cfg_dir, plan, fc, flags)
+
+ harnesses = []
+ report.h("c harness: per-point infer via target teams distribute parallel for", 3)
+ if libs.host is not None:
+ if not _run_c_harness1(report, cfg_dir, plan, cc, flags, libs.host, inputs,
+ expected, env, backend):
+ ok = False
+ else:
+ harnesses.append(_Instrumented(
+ "c per-point", "gate_harness1_prof", [cc, *_c_flags(cc), *flags.split()],
+ ["gate_harness1.c"] + ([] if plan.embed else [str(libs.host.relative_to(cfg_dir))])
+ + ["-lm"], env))
+ else:
+ report.p("skipped: c library build (omp backend, host compiler) failed")
+ ok = False
+
+ report.h("fortran harness: per-point infer via target teams distribute parallel do", 3)
+ if f_built:
+ if not _run_fortran_harness2(report, cfg_dir, plan, fc, flags, inputs, expected,
+ env, backend):
+ ok = False
+ else:
+ harnesses.append(_Instrumented(
+ "fortran per-point", "gate_harness2_prof", [fc, *_f_flags(fc), *flags.split()],
+ ["gate_harness2.F90", f"lib{plan.model}_f.a"], env))
+ else:
+ report.p("skipped: fortran library build failed")
+ ok = False
+
+ report.h("infer_batch harness: device-resident data", 3)
+ if backend == "omp":
+ if libs.host is not None:
+ if not _run_c_harness3_omp(report, cfg_dir, plan, cc, flags, libs.host,
+ inputs, expected, env):
+ ok = False
+ else:
+ report.p("skipped: c library build failed")
+ ok = False
+ if f_built:
+ if not _run_fortran_harness3_omp(report, cfg_dir, plan, fc, flags, inputs, expected, env):
+ ok = False
+ else:
+ report.p("skipped: fortran library build failed")
+ ok = False
+ else:
+ if libs.dev is not None:
+ dev_ok = _run_dev_harness3(report, cfg_dir, plan, resolved_devcc,
+ devflags, backend, libs.dev, inputs, expected)
+ if not dev_ok:
+ ok = False
+ else:
+ harnesses.append(_Instrumented(
+ f"{backend} infer_batch", "gate_harness3_prof",
+ [*shlex.split(resolved_devcc), *devflags.split()],
+ ["gate_harness3.cu", *_link_archive(libs.dev, cfg_dir)], None))
+ else:
+ report.p(f"skipped: c library build (backend={backend}, device compiler) failed")
+ ok = False
+ # Ruling R15: nsys for cuda, rocprofv3 for hip, over every
+ # harness that ran; the check skips itself (with a named
+ # reason) when the profiler or the marker header is
+ # unavailable, which does not fail the gate on its own.
+ if not _run_transfer_check(report, cfg_dir, backend, resolved_devcc, devflags,
+ harnesses, inputs):
+ ok = False
+
+ report.h("result", 2)
+ report.p("PASS: every configuration matched." if ok else
+ "FAIL: at least one configuration above did not match or did not run.")
+ report_path.write_text(report.text())
+ return 0 if ok else 1
+ except Exception as e: # noqa: BLE001 -- the report must still be written
+ report.h("FATAL", 2)
+ report.p(f"gate raised an unexpected exception: {type(e).__name__}: {e}")
+ report_path.write_text(report.text())
+ return 1
diff --git a/python/rosenna/golden.py b/python/rosenna/golden.py
new file mode 100644
index 0000000..03b5447
--- /dev/null
+++ b/python/rosenna/golden.py
@@ -0,0 +1,49 @@
+"""Run a goldenFiles//.py generator so its ONNX lands in the tree.
+
+Each generator writes its model to a hard-coded `../goldenFiles//` and
+drops scratch output (an `inputs.fpp`) beside its cwd, so it is run from a
+throwaway directory holding a `goldenFiles` symlink: the model lands in the
+real tree and the scratch output is discarded with the temp directory. (The
+generators used to run in `test/`, which served the retired runtime library's
+shell suite and no longer exists.) The LSTM generators `import nnLSTM`, a
+helper that lives beside them in goldenFiles/, so that directory goes on
+PYTHONPATH. Shared by the test fixture and the gpu-gate so both run the
+generators the same way.
+
+The generators themselves are unseeded, and a fresh draw of a tiny model
+(gemm_small is 2 -> 2 -> ReLU -> 3 -> ReLU) is sometimes dead -- every
+pre-activation negative into the final ReLU, an all-zero network -- on which
+verify's non-degeneracy check refuses to run, so the golden suite failed on
+one CI runner and passed on the other from the same commit. The seed is set
+here, once, for every generator: the models are then the same on every
+machine, and a draw that is live stays live.
+"""
+import os
+import sys
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+
+GOLDEN_SEED = 20260914
+_SEEDED_RUNNER = (
+ "import runpy, sys, torch; torch.manual_seed({seed}); "
+ "sys.argv = [sys.argv[1]]; runpy.run_path(sys.argv[0], run_name='__main__')"
+)
+
+
+def golden_model_path(root: Path, name: str) -> Path:
+ return root / "goldenFiles" / name / f"{name}.onnx"
+
+
+@contextmanager
+def golden_generator_run(root: Path, name: str):
+ """Yield (argv, cwd, env) that runs the generator; cwd exists only inside the block."""
+ with tempfile.TemporaryDirectory() as tmp:
+ cwd = Path(tmp) / "run"
+ cwd.mkdir()
+ (Path(tmp) / "goldenFiles").symlink_to(root / "goldenFiles", target_is_directory=True)
+ env = dict(os.environ)
+ env["PYTHONPATH"] = os.pathsep.join(
+ [str(root / "goldenFiles")] + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []))
+ script = str(root / "goldenFiles" / name / f"{name}.py")
+ yield [sys.executable, "-c", _SEEDED_RUNNER.format(seed=GOLDEN_SEED), script], cwd, env
diff --git a/python/rosenna/plan.py b/python/rosenna/plan.py
index da909f4..c08e816 100644
--- a/python/rosenna/plan.py
+++ b/python/rosenna/plan.py
@@ -2,7 +2,7 @@
import hashlib
import json
import re
-from dataclasses import dataclass, asdict
+from dataclasses import dataclass, asdict, replace
import numpy as np
@@ -11,8 +11,28 @@
_ACTIVATIONS = {"Relu": "relu", "Tanh": "tanh", "Sigmoid": "sigmoid"}
_ITEMSIZE = {"f32": 4, "f64": 8}
+_NUMPY_DTYPE = {"f32": np.float32, "f64": np.float64}
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
+# Measured on this machine (gcc-15 -O2 -c on an empty translation unit that
+# only #includes the generated header; see tests/measure_embed_threshold.py
+# and task-2-report.md for the full table, including an extended sweep past
+# 1e6 that locates where the real 5-second crossover falls):
+#
+# params compile time (s)
+# 1056 0.05
+# 10100 0.06
+# 100172 0.12
+# 300852 0.25
+# 1001000 0.80
+#
+# Every one of the five measured sizes compiles in under a second, so the
+# largest of them -- already a round number -- is the threshold: a model
+# under 1,000,000 parameters embeds by default. (The crossover past 5s does
+# not occur until several million parameters; see the report for that
+# supporting data point.)
+EMBED_THRESHOLD = 1_000_000
+
def validate_model_name(name: str) -> None:
"""Reject a model name that cannot be interpolated into a Fortran/C identifier.
@@ -37,6 +57,181 @@ class WeightSpec:
shape: tuple
offset: int
nbytes: int
+ # Populated only when the owning Plan embeds its weights (Plan.embed):
+ # the flattened values, at the plan's own dtype, that emit_c prints as a
+ # ROSENNA_CONST array literal. A file-loaded plan leaves this None; the
+ # values live in the .rwt file instead, and this field enters the hash
+ # only for an embedded plan (embed is itself part of the hash, so the
+ # two forms of the same model are already distinct artifacts).
+ values: tuple | None = None
+
+
+@dataclass(frozen=True)
+class Spatial:
+ """Everything a 2-D Conv/pool loop nest needs, resolved at generation time.
+
+ The emitters never see an ONNX attribute: auto_pad is already turned into
+ begin-pads here (it depends on the input shape, which is literal), and
+ every extent is a plain int the emitted loop bounds interpolate directly.
+ End-pads are not carried because nothing reads them -- the output extent
+ they would determine is taken from ONNX shape inference instead, so a
+ window that would run off the end simply never exists.
+ """
+ n: int
+ c_in: int
+ h_in: int
+ w_in: int
+ c_out: int
+ h_out: int
+ w_out: int
+ kh: int
+ kw: int
+ sh: int
+ sw: int
+ ph: int
+ pw: int
+ dh: int
+ dw: int
+ # AveragePool only: divide by the full kernel (True) or by the count of
+ # cells that actually fell inside the input (False, the ONNX default).
+ count_include_pad: bool = False
+ # Conv only. group=1 is an ordinary convolution; group=c_in with
+ # c_out=c_in is a depthwise one. The weight's channel axis is c_in/group,
+ # and output channel oc reads only its own group's input channels, so the
+ # loop bound and the input-channel offset both change. Carried as the two
+ # derived extents the loop actually needs rather than as `group`.
+ c_in_per_group: int = 0
+ c_out_per_group: int = 0
+
+ @property
+ def grouped(self) -> bool:
+ return self.c_in_per_group not in (0, self.c_in)
+
+ @property
+ def every_window_is_inside(self) -> bool:
+ """True when no window reaches past the input on any side.
+
+ The begin pads say whether the first window starts early; the last
+ window's reach says whether it runs off the end -- an end-only pad
+ (pads=[0,0,1,1]) is exactly that case, and it is not carried here, so
+ it has to be read off the output extent. An AveragePool divides every
+ window by the full kernel only when this holds (or count_include_pad).
+ """
+ return (self.ph == 0 and self.pw == 0
+ and (self.h_out - 1) * self.sh + (self.kh - 1) * self.dh + 1 <= self.h_in
+ and (self.w_out - 1) * self.sw + (self.kw - 1) * self.dw + 1 <= self.w_in)
+
+
+@dataclass(frozen=True)
+class Softmax:
+ """A last-axis Softmax, as `outer` independent rows of `axis_len` each.
+
+ The axis is resolved to the trailing one in validate.py, so the emitters
+ see a flat [outer, axis_len] view of a buffer that is already row-major
+ and never learn that `axis` existed.
+ """
+ outer: int
+ axis_len: int
+
+
+@dataclass(frozen=True)
+class Pad:
+ """Constant-mode Pad: where the input block sits inside the output.
+
+ `begins` is one offset per axis; the emitters loop over the output and
+ read the input where the shifted index is in range, writing `value`
+ everywhere else.
+ """
+ in_shape: tuple
+ out_shape: tuple
+ begins: tuple
+ value: float
+ # "constant", "edge" or "reflect". constant tests the bounds and writes
+ # `value` outside them; the other two transform the index instead, so
+ # every output element reads some input element and there is no test.
+ mode: str = "constant"
+
+
+@dataclass(frozen=True)
+class Broadcast:
+ """How a constant operand maps onto the output of an elementwise op.
+
+ `strides` is one entry per output axis: the step to take in the constant's
+ flat layout when that axis advances, and 0 where the constant is broadcast
+ along it. Resolved here so the emitters write plain affine arithmetic and
+ never reason about ranks or alignment.
+ """
+ out_shape: tuple
+ strides: tuple
+
+
+@dataclass(frozen=True)
+class Concat:
+ """A Concat resolved to copy extents.
+
+ Row-major, concatenating along `axis` means: for each of `outer` index
+ tuples over the axes before it, the output row is the inputs' blocks laid
+ end to end, block j being input j's extent along the axis times the
+ inner size. `blocks` is one entry per operand, in order; `consts` marks
+ which operands are weights (read through their symbol) rather than
+ runtime buffers. The emitters write two loops and no shape arithmetic.
+ """
+ outer: int
+ blocks: tuple
+ consts: tuple
+
+
+@dataclass(frozen=True)
+class Lstm:
+ """A forward LSTM with the ONNX default activations, resolved to extents.
+
+ ONNX orders the gates i, o, f, c in W, R and B -- not the i, f, c, o that
+ most papers and most other runtimes use -- and the emitters read the gate
+ blocks at those offsets directly, so the order is recorded here once rather
+ than rediscovered in two emitters.
+ """
+ seq: int
+ batch: int
+ input_size: int
+ hidden: int
+ has_bias: bool
+ has_initial: bool
+ # False when nothing reads Y: the recurrence then keeps its state
+ # but never stores the per-timestep output, and Y gets no buffer.
+ emit_y: bool = True
+ # Buffer symbols for the carried state and the per-step gate vector.
+ h_sym: str = ""
+ c_sym: str = ""
+ g_sym: str = ""
+
+
+@dataclass(frozen=True)
+class Gru:
+ """A forward GRU with the ONNX default activations, resolved to extents.
+
+ Three gates, ordered z (update), r (reset), h (new) in W, R and B, and no
+ cell state -- so it carries only H. B is 6*hidden: the three W biases then
+ the three R biases.
+
+ `linear_before_reset` changes the arithmetic of the h gate, not just its
+ spelling, so it is carried rather than assumed:
+
+ 0 (the ONNX default): h~ = g(Xt.Wh + (r . Ht-1).Rh + Rbh + Wbh)
+ 1 (what PyTorch exports): h~ = g(Xt.Wh + r . (Ht-1.Rh + Rbh) + Wbh)
+
+ The reset gate multiplies the state before the recurrent matmul in the
+ first and the matmul's result in the second; they agree only when r is 1.
+ """
+ seq: int
+ batch: int
+ input_size: int
+ hidden: int
+ has_bias: bool
+ has_initial: bool
+ linear_before_reset: bool = False
+ emit_y: bool = True
+ h_sym: str = ""
+ g_sym: str = ""
@dataclass(frozen=True)
@@ -53,6 +248,45 @@ class Op:
# comparing the weight's shape against n_out is ambiguous whenever
# n_in == n_out, and a square weight then gets read transposed.
trans_b: bool = False
+ # Set for kind in ("conv", "maxpool", "avgpool"); None for everything else.
+ spatial: Spatial | None = None
+ # Set for kind == "add".
+ bcast: Broadcast | None = None
+ # Set for kind == "softmax".
+ softmax: "Softmax | None" = None
+ # Set for kind == "pad".
+ pad: "Pad | None" = None
+ # kind == "concat": the operands are inp (the first runtime one) plus
+ # extra_in (the remaining runtime ones) and the weight symbols in
+ # concat_syms, interleaved in ONNX input order as concat.consts says.
+ concat: "Concat | None" = None
+ concat_syms: tuple = ()
+ # kind == "lstm": the recurrence weight R (weight/bias carry W and B).
+ weight2: str | None = None
+ # kind == "lstm": the recurrent shape, and the names of the extra operands
+ # and results an LSTM has beyond the single in/out every other op uses.
+ lstm: "Lstm | None" = None
+ # kind == "gru".
+ gru: "Gru | None" = None
+ extra_in: tuple = ()
+ outs: tuple = ()
+ # kind == "lstm": weight symbols holding a constant (initializer) initial
+ # hidden and cell state -- the case a folded `Constant` node leaves behind.
+ # Exclusive with extra_in, which names them when they are graph values.
+ init_syms: tuple = ()
+ # kind == "copy": read the source starting this far into its buffer (a
+ # secondary graph input's slice of the concatenated x), or write the
+ # destination starting this far into its buffer (a secondary graph
+ # output's slice of the concatenated y).
+ src_offset: int = 0
+ dst_offset: int = 0
+ # kind == "transpose": per-output-axis stride into the source buffer.
+ perm_strides: tuple = ()
+ out_shape: tuple = ()
+ # kind == "gemm": how many independent rows share the weight. 1 for a dense
+ # per-point model; an LSTM whose sequence output feeds a Gemm applies it
+ # once per timestep, and the leading axis carries that count.
+ rows: int = 1
@dataclass(frozen=True)
@@ -65,6 +299,8 @@ class Plan:
buffers: dict
assignment: dict
weights: tuple
+ embed: bool
+ n_params: int
def to_json(self) -> str:
return json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
@@ -77,70 +313,540 @@ def _length(t: Tensor) -> int:
return int(np.prod(t.shape)) if t.shape else 1
-def build_plan(graph: Graph, dtype: str | None = None) -> Plan:
+def _weight_elems(shape: tuple) -> int:
+ n = 1
+ for d in shape:
+ n *= d
+ return n
+
+
+_POOL_KIND = {"MaxPool": "maxpool", "AveragePool": "avgpool"}
+_RELABEL = {"Reshape", "Squeeze", "Unsqueeze", "Flatten", "Identity"}
+
+
+def _pair(value, default):
+ """An ONNX 2-D attribute as (h, w), defaulting when the attribute is absent."""
+ if value is None:
+ return default, default
+ return int(value[0]), int(value[1])
+
+
+def _begin_pads(node, in_hw, out_hw, k_hw, s_hw, d_hw):
+ """Resolve pads -- explicit, VALID, or auto_pad SAME -- to (begin_h, begin_w).
+
+ Only the begin-pads reach the emitted index arithmetic: `ih = oh*s - ph +
+ kh*d`. SAME_UPPER/SAME_LOWER depend on the input extent, which is literal
+ here, so the whole auto_pad concept is resolved now and never appears in
+ generated code. The total padding SAME needs is derived from the output
+ extent ONNX shape inference already computed, so this agrees with the
+ reference by construction rather than by re-deriving the formula.
+ """
+ auto_pad = node.attrs.get("auto_pad", "NOTSET")
+ if auto_pad in ("NOTSET", ""):
+ pads = node.attrs.get("pads")
+ return (0, 0) if pads is None else (int(pads[0]), int(pads[1]))
+ if auto_pad == "VALID":
+ return 0, 0
+ begin = []
+ for i in (0, 1):
+ span = (k_hw[i] - 1) * d_hw[i] + 1
+ total = max(0, (out_hw[i] - 1) * s_hw[i] + span - in_hw[i])
+ # SAME_UPPER puts the odd pad at the end, SAME_LOWER at the beginning.
+ begin.append(total // 2 if auto_pad == "SAME_UPPER" else (total + 1) // 2)
+ return begin[0], begin[1]
+
+
+def _lift_1d(node):
+ """A rank-3 spatial node's attributes, as the 2-D ones that mean the same.
+
+ A 1-D op is a 2-D op whose height is 1, and on a flat row-major buffer the
+ two are the SAME BYTES: (N,C,W) and (N,C,1,W) have identical indices, and
+ so do a weight's (OC,IC,KW) and (OC,IC,1,KW). So 1-D needs no loop nest of
+ its own -- it is the existing nest with h_in=h_out=kh=1, sh=1, ph=0, dh=1,
+ which the compiler folds away. Only the attributes have to be lifted.
+ """
+ from .frontend import Node
+ a = dict(node.attrs)
+ if "kernel_shape" in a:
+ a["kernel_shape"] = (1, int(a["kernel_shape"][0]))
+ for name in ("strides", "dilations"):
+ if name in a:
+ a[name] = (1, int(a[name][0]))
+ if "pads" in a:
+ begin, end = (int(v) for v in a["pads"])
+ a["pads"] = (0, begin, 0, end)
+ return Node(node.op, node.name, node.inputs, node.outputs, a)
+
+
+def _spatial(graph: Graph, node) -> Spatial:
+ """Lower one Conv/MaxPool/AveragePool node to literal loop extents."""
+ x = graph.values[node.inputs[0]]
+ out = graph.values[node.outputs[0]]
+ if len(x.shape) == 3:
+ node = _lift_1d(node)
+ n, c_in, w_in = (int(d) for d in x.shape)
+ _, c_out, w_out = (int(d) for d in out.shape)
+ h_in = h_out = 1
+ else:
+ n, c_in, h_in, w_in = (int(d) for d in x.shape)
+ _, c_out, h_out, w_out = (int(d) for d in out.shape)
+ if node.op == "Conv":
+ w = graph.initializers[node.inputs[1]]
+ kh, kw = (1, int(w.shape[2])) if w.ndim == 3 else (int(w.shape[2]), int(w.shape[3]))
+ else:
+ kh, kw = _pair(node.attrs.get("kernel_shape"), 1)
+ sh, sw = _pair(node.attrs.get("strides"), 1)
+ dh, dw = _pair(node.attrs.get("dilations"), 1)
+ ph, pw = _begin_pads(node, (h_in, w_in), (h_out, w_out), (kh, kw), (sh, sw), (dh, dw))
+ group = int(node.attrs.get("group", 1)) if node.op == "Conv" else 1
+ return Spatial(n=n, c_in=c_in, h_in=h_in, w_in=w_in,
+ c_out=c_out, h_out=h_out, w_out=w_out,
+ kh=kh, kw=kw, sh=sh, sw=sw, ph=ph, pw=pw, dh=dh, dw=dw,
+ count_include_pad=bool(int(node.attrs.get("count_include_pad", 0))),
+ c_in_per_group=c_in // group, c_out_per_group=c_out // group)
+
+
+def _pad_mode(node) -> str:
+ mode = node.attrs.get("mode", "constant")
+ return mode.decode() if isinstance(mode, bytes) else str(mode)
+
+
+def _broadcast(out_shape, const_shape) -> Broadcast:
+ """Right-align the constant against the output and give each axis a stride."""
+ out_shape = tuple(int(d) for d in out_shape)
+ const_shape = tuple(int(d) for d in const_shape)
+ pad = len(out_shape) - len(const_shape)
+ aligned = (1,) * pad + const_shape
+ strides, step = [], 1
+ for dim in reversed(aligned):
+ strides.append(0 if dim == 1 else step)
+ step *= dim
+ return Broadcast(out_shape, tuple(reversed(strides)))
+
+
+def _flat_preserving(in_shape, perm) -> bool:
+ """True when a Transpose only moves size-1 axes, so the flat bytes are unchanged.
+
+ Row-major order is decided by the axes that actually have extent, in the
+ order they appear. Moving a length-1 axis past them changes the shape and
+ nothing else -- which is every Transpose a PyTorch LSTM export emits, since
+ those only swap the batch axis of a batch-1 model.
+ """
+ kept = [a for a in perm if in_shape[a] != 1]
+ return kept == sorted(kept)
+
+
+def lstm_initial_state(op, weight_ref, assignment) -> tuple:
+ """(h0, c0) array names for an LSTM op, for either emitter.
+
+ `weight_ref` renders a weight symbol the way that emitter spells it;
+ `assignment` maps a value name to its buffer. Constant states are weights,
+ caller-supplied ones are buffers, and an LSTM without them gets None.
+ """
+ if op.init_syms:
+ return tuple(weight_ref(sym) for sym in op.init_syms)
+ return (assignment[op.extra_in[0]] if op.extra_in else None,
+ assignment[op.extra_in[1]] if len(op.extra_in) > 1 else None)
+
+
+def _transpose_strides(in_shape, perm) -> tuple:
+ """Per-output-axis stride into the source's flat layout."""
+ src_stride, step = [0] * len(in_shape), 1
+ for axis in reversed(range(len(in_shape))):
+ src_stride[axis] = step
+ step *= in_shape[axis]
+ return tuple(src_stride[a] for a in perm)
+
+
+def _lstm_spec(graph: Graph, node) -> Lstm:
+ x = graph.values[node.inputs[0]]
+ seq, batch, input_size = (int(d) for d in x.shape)
+ hidden = int(node.attrs["hidden_size"]) if "hidden_size" in node.attrs else \
+ int(graph.initializers[node.inputs[2]].shape[2])
+ has_bias = len(node.inputs) > 3 and bool(node.inputs[3])
+ has_initial = len(node.inputs) > 5 and bool(node.inputs[5])
+ return Lstm(seq=seq, batch=batch, input_size=input_size, hidden=hidden,
+ has_bias=has_bias, has_initial=has_initial)
+
+
+def _gru_spec(graph: Graph, node) -> Gru:
+ x = graph.values[node.inputs[0]]
+ seq, batch, input_size = (int(d) for d in x.shape)
+ hidden = int(node.attrs["hidden_size"]) if "hidden_size" in node.attrs else \
+ int(graph.initializers[node.inputs[2]].shape[2])
+ return Gru(seq=seq, batch=batch, input_size=input_size, hidden=hidden,
+ has_bias=len(node.inputs) > 3 and bool(node.inputs[3]),
+ has_initial=len(node.inputs) > 5 and bool(node.inputs[5]),
+ linear_before_reset=bool(int(node.attrs.get("linear_before_reset", 0))))
+
+
+def gru_initial_state(op, weight_ref, assignment):
+ """The GRU's initial H: a weight symbol, a mapped buffer, or None for zeros."""
+ if op.init_syms:
+ return weight_ref(op.init_syms[0])
+ return assignment[op.extra_in[0]] if op.extra_in else None
+
+
+def build_plan(graph: Graph, dtype: str | None = None, embed: bool | None = None) -> Plan:
validate(graph)
- if len(graph.inputs) != 1 or len(graph.outputs) != 1:
+ if len(graph.inputs) < 1 or len(graph.outputs) < 1:
raise UnsupportedModel(
- f"this generator handles one input and one output; "
- f"got {len(graph.inputs)} and {len(graph.outputs)}")
+ f"this generator needs at least one input and one output; "
+ f"got {len(graph.inputs)} inputs and {len(graph.outputs)} outputs")
dtype = dtype or graph.values[graph.inputs[0]].dtype
if dtype not in _ITEMSIZE:
raise UnsupportedModel(f"dtype {dtype} is not supported")
+ consumed = {i for n in graph.nodes for i in n.inputs if i} | set(graph.outputs)
ops, weights, offset, widx = [], [], 0, 0
+ by_name = {}
+
+ def weight(name: str, symbol: str, shape: tuple) -> str:
+ """Register initializer `name` once and return its symbol.
+
+ A second node using the same initializer (a tied weight) gets the
+ first node's symbol: one WeightSpec, one file entry, one array. The
+ loaders match file entries by name, so a duplicate spec was filled
+ once in C (the other stayed zero) and was a duplicate CASE in Fortran.
+ """
+ nonlocal offset
+ if name in by_name:
+ prior = by_name[name]
+ if prior.shape != shape:
+ raise UnsupportedModel(
+ f"initializer '{name}' is used by two nodes that need it declared "
+ f"with different shapes ({prior.shape} and {shape})")
+ return prior.symbol
+ a = graph.initializers[name]
+ spec = WeightSpec(name, symbol, shape, offset, a.size * _ITEMSIZE[dtype])
+ weights.append(spec)
+ by_name[name] = spec
+ offset += spec.nbytes
+ return spec.symbol
for node in graph.nodes:
if node.op in _ACTIVATIONS:
- ops.append(Op(_ACTIVATIONS[node.op], node.outputs[0], node.inputs[0], None, None, 0, 0))
+ # n_in/n_out carry the activation's OWN length, taken from the
+ # value it produces. The emitters used to bound an activation's
+ # loop by a running "length of the previous op's output", which is
+ # the same number only in an unbranched chain: give a value a
+ # second consumer and the later read gets the wider op's bound,
+ # running off the end of a fixed-size local array in both
+ # directions. plan.py knows the real length here, so it says it.
+ act_len = _length(graph.values[node.outputs[0]])
+ ops.append(Op(_ACTIVATIONS[node.op], node.outputs[0], node.inputs[0],
+ None, None, act_len, act_len))
+ continue
+ if node.op == "Pad":
+ in_shape = tuple(int(d) for d in graph.values[node.inputs[0]].shape)
+ out_shape = tuple(int(d) for d in graph.values[node.outputs[0]].shape)
+ pads = tuple(int(v) for v in node.attrs["pads"])
+ ops.append(Op("pad", node.outputs[0], node.inputs[0], None, None,
+ _length(graph.values[node.inputs[0]]),
+ _length(graph.values[node.outputs[0]]),
+ pad=Pad(in_shape, out_shape, pads[:len(in_shape)],
+ float(node.attrs.get("value", 0.0)),
+ _pad_mode(node))))
+ continue
+ if node.op == "Softmax":
+ shape = tuple(int(d) for d in graph.values[node.outputs[0]].shape)
+ axis_len = shape[-1]
+ n = _length(graph.values[node.outputs[0]])
+ ops.append(Op("softmax", node.outputs[0], node.inputs[0], None, None, n, n,
+ softmax=Softmax(outer=n // axis_len, axis_len=axis_len)))
+ continue
+ if node.op in _RELABEL or (node.op == "Transpose" and _flat_preserving(
+ graph.values[node.inputs[0]].shape,
+ node.attrs.get("perm", tuple(reversed(range(len(graph.values[node.inputs[0]].shape))))))):
+ # Relabels the axes without moving a byte. It becomes a buffer
+ # alias -- no code, no copy -- unless it produces the graph output,
+ # which has to land in the caller's own y.
+ n = _length(graph.values[node.outputs[0]])
+ kind = "copy" if node.outputs[0] in graph.outputs else "alias"
+ ops.append(Op(kind, node.outputs[0], node.inputs[0], None, None, n, n))
+ continue
+ if node.op == "Transpose":
+ in_t = graph.values[node.inputs[0]]
+ out_t = graph.values[node.outputs[0]]
+ perm = node.attrs.get("perm", tuple(reversed(range(len(in_t.shape)))))
+ ops.append(Op("transpose", node.outputs[0], node.inputs[0], None, None,
+ _length(in_t), _length(out_t),
+ perm_strides=_transpose_strides(
+ tuple(int(d) for d in in_t.shape), tuple(int(p) for p in perm)),
+ out_shape=tuple(int(d) for d in out_t.shape)))
+ continue
+ if node.op == "GRU":
+ spec = _gru_spec(graph, node)
+ syms = {}
+ for role, idx in (("weight", 1), ("weight2", 2), ("bias", 3)):
+ if idx < len(node.inputs) and node.inputs[idx]:
+ a = graph.initializers[node.inputs[idx]]
+ sym = f"{'w' if role != 'bias' else 'b'}{widx}{'r' if role == 'weight2' else ''}"
+ syms[role] = weight(node.inputs[idx], sym, (int(a.size),))
+ states = (node.inputs[5],) if len(node.inputs) > 5 and node.inputs[5] else ()
+ if states and states[0] in graph.initializers:
+ init_syms = (weight(states[0], f"h{widx}",
+ (int(graph.initializers[states[0]].size),)),)
+ extra = ()
+ else:
+ init_syms, extra = (), states
+ # Positional like the LSTM's, but a GRU has only Y_h beyond Y.
+ tail = tuple(node.outputs[1:2]) + ("",) * (1 - len(node.outputs[1:2]))
+ outs = tuple(o if (o and o in consumed) else "" for o in tail)
+ spec = replace(spec, emit_y=node.outputs[0] in consumed)
+ ops.append(Op("gru", node.outputs[0], node.inputs[0],
+ syms.get("weight"), syms.get("bias"),
+ _length(graph.values[node.inputs[0]]),
+ _length(graph.values[node.outputs[0]]),
+ weight2=syms.get("weight2"), gru=spec,
+ extra_in=extra, outs=outs, init_syms=init_syms))
+ widx += 1
+ continue
+ if node.op == "LSTM":
+ spec = _lstm_spec(graph, node)
+ syms = {}
+ for role, idx in (("weight", 1), ("weight2", 2), ("bias", 3)):
+ if idx < len(node.inputs) and node.inputs[idx]:
+ a = graph.initializers[node.inputs[idx]]
+ sym = f"{'w' if role != 'bias' else 'b'}{widx}{'r' if role == 'weight2' else ''}"
+ syms[role] = weight(node.inputs[idx], sym, (int(a.size),))
+ states = tuple(i for i in node.inputs[5:7] if i) if len(node.inputs) > 5 else ()
+ if states and states[0] in graph.initializers:
+ # Constant initial state (validate checked both are): a weight
+ # each, flat, read like any other by the emitters.
+ init_syms = tuple(weight(nm, f"{role}{widx}", (int(graph.initializers[nm].size),))
+ for role, nm in zip(("h", "c"), states))
+ extra = ()
+ else:
+ init_syms, extra = (), states
+ # Only the outputs something downstream reads. An LSTM always
+ # produces Y, Y_h and Y_c, and a model typically wants one of
+ # them; carrying the others cost a buffer and a dead copy per
+ # call -- per thread, on a device -- and left the generated code
+ # warning on any compiler asked to look.
+ #
+ # POSITIONAL: index 0 is always Y_h and index 1 always Y_c, and a
+ # dropped one is "" rather than absent. Compacting the tuple moves
+ # Y_c into Y_h's slot, and the emitters copy h into it.
+ tail = tuple(node.outputs[1:3]) + ("",) * (2 - len(node.outputs[1:3]))
+ outs = tuple(o if (o and o in consumed) else "" for o in tail)
+ spec = replace(spec, emit_y=node.outputs[0] in consumed)
+ ops.append(Op("lstm", node.outputs[0], node.inputs[0],
+ syms.get("weight"), syms.get("bias"),
+ _length(graph.values[node.inputs[0]]),
+ _length(graph.values[node.outputs[0]]),
+ weight2=syms.get("weight2"), lstm=spec,
+ extra_in=extra, outs=outs, init_syms=init_syms))
+ widx += 1
+ continue
+ if node.op == "Concat":
+ shapes = [tuple(int(d) for d in (graph.initializers[i].shape if i in graph.initializers
+ else graph.values[i].shape)) for i in node.inputs]
+ rank = len(shapes[0])
+ axis = int(node.attrs.get("axis", 0))
+ axis = axis + rank if axis < 0 else axis
+ outer = int(np.prod(shapes[0][:axis])) if axis else 1
+ inner = int(np.prod(shapes[0][axis + 1:])) if axis + 1 < rank else 1
+ blocks = tuple(sh[axis] * inner for sh in shapes)
+ consts = tuple(i in graph.initializers for i in node.inputs)
+ syms = []
+ for i in node.inputs:
+ if i in graph.initializers:
+ syms.append(weight(i, f"w{widx}_{len(syms)}", (int(graph.initializers[i].size),)))
+ runtime = [i for i in node.inputs if i not in graph.initializers]
+ if not runtime:
+ raise UnsupportedModel(f"node '{node.name}': all-constant Concat should have folded")
+ out_t = graph.values[node.outputs[0]]
+ ops.append(Op("concat", node.outputs[0], runtime[0], None, None,
+ _length(graph.values[runtime[0]]), _length(out_t),
+ extra_in=tuple(runtime[1:]),
+ concat=Concat(outer=outer, blocks=blocks, consts=consts),
+ concat_syms=tuple(syms)))
+ if syms:
+ widx += 1
+ continue
+ if node.op == "Add":
+ const_name = next(i for i in node.inputs if i in graph.initializers)
+ src_name = next(i for i in node.inputs if i not in graph.initializers)
+ c = graph.initializers[const_name]
+ # Declared flat, not with the ONNX shape: the broadcast strides are
+ # offsets into the constant's row-major flat layout, so a rank-1
+ # declaration is what both emitters subscript. (emit_fortran would
+ # otherwise declare a rank-3 (1,1,8) array and reject the single
+ # subscript the stride arithmetic produces.)
+ csym = weight(const_name, f"w{widx}", (int(c.size),))
+ out_t = graph.values[node.outputs[0]]
+ ops.append(Op("add", node.outputs[0], src_name, csym, None,
+ _length(out_t), _length(out_t),
+ bcast=_broadcast(out_t.shape, c.shape)))
+ widx += 1
+ continue
+ if node.op in _POOL_KIND:
+ sp = _spatial(graph, node)
+ ops.append(Op(_POOL_KIND[node.op], node.outputs[0], node.inputs[0], None, None,
+ _length(graph.values[node.inputs[0]]),
+ _length(graph.values[node.outputs[0]]), spatial=sp))
+ continue
+ if node.op == "Conv":
+ sp = _spatial(graph, node)
+ w = graph.initializers[node.inputs[1]]
+ # Registered at the lifted rank for a 1-D conv: the flat bytes are
+ # the same either way, but emit_fortran DECLARES the weight with
+ # the ONNX shape reversed and the nest writes four subscripts, so a
+ # rank-3 declaration would not match w(kw, kh, ic, oc).
+ wshape = ((int(w.shape[0]), int(w.shape[1]), 1, int(w.shape[2]))
+ if w.ndim == 3 else tuple(int(d) for d in w.shape))
+ wsym = weight(node.inputs[1], f"w{widx}", wshape)
+ bsym = None
+ if len(node.inputs) > 2 and node.inputs[2]:
+ b = graph.initializers[node.inputs[2]]
+ bsym = weight(node.inputs[2], f"b{widx}", tuple(int(d) for d in b.shape))
+ ops.append(Op("conv", node.outputs[0], node.inputs[0], wsym, bsym,
+ _length(graph.values[node.inputs[0]]),
+ _length(graph.values[node.outputs[0]]), spatial=sp))
+ widx += 1
continue
w = graph.initializers[node.inputs[1]]
trans_b = int(node.attrs.get("transB", 0)) if node.op == "Gemm" else 0
n_out, n_in = (w.shape[0], w.shape[1]) if trans_b else (w.shape[1], w.shape[0])
- wsym = f"w{widx}"
- weights.append(WeightSpec(node.inputs[1], wsym, tuple(int(d) for d in w.shape),
- offset, w.size * _ITEMSIZE[dtype]))
- offset += weights[-1].nbytes
+ wsym = weight(node.inputs[1], f"w{widx}", tuple(int(d) for d in w.shape))
bsym = None
if node.op == "Gemm" and len(node.inputs) > 2:
b = graph.initializers[node.inputs[2]]
- bsym = f"b{widx}"
- weights.append(WeightSpec(node.inputs[2], bsym, tuple(int(d) for d in b.shape), offset,
- b.size * _ITEMSIZE[dtype]))
- offset += weights[-1].nbytes
+ bsym = weight(node.inputs[2], f"b{widx}", tuple(int(d) for d in b.shape))
+ in_len = _length(graph.values[node.inputs[0]])
+ if in_len % int(n_in):
+ raise UnsupportedModel(
+ f"node '{node.name}': input holds {in_len} values, not a whole number of "
+ f"rows of {n_in}")
ops.append(Op("gemm", node.outputs[0], node.inputs[0], wsym, bsym,
- int(n_in), int(n_out), bool(trans_b)))
+ int(n_in), int(n_out), bool(trans_b), rows=in_len // int(n_in)))
widx += 1
+ # One entry point, one input buffer, one output buffer: a model with
+ # several graph inputs (an LSTM's initial hidden and cell state, say)
+ # takes them concatenated in declaration order, each secondary input
+ # copied out of its slice of x below; a model with several graph outputs
+ # (that LSTM's Y, Y_h and Y_c) writes them concatenated in declaration
+ # order, each secondary output copied into its slice of y after the last
+ # op. Keeping infer(x, y) intact is what keeps infer_batch, the native
+ # kernel, the weights ABI and the whole device contract unchanged, and
+ # is what lets a solver keep a recurrent model's state resident: y's
+ # h'/c' slices go straight back into x's h/c slices next step.
+ in_lens = [_length(graph.values[n]) for n in graph.inputs]
+ out_lens = [_length(graph.values[n]) for n in graph.outputs]
in_t = graph.values[graph.inputs[0]]
out_t = graph.values[graph.outputs[0]]
- flat_in = Tensor(in_t.name, (_length(in_t),), dtype)
- flat_out = Tensor(out_t.name, (_length(out_t),), dtype)
+ flat_in = Tensor(in_t.name, (sum(in_lens),), dtype)
+ flat_out = Tensor(out_t.name, (sum(out_lens),), dtype)
+ slice_ops, off = [], in_lens[0]
+ for name, n in zip(graph.inputs[1:], in_lens[1:]):
+ slice_ops.append(Op("copy", name, graph.inputs[0], None, None, n, n, src_offset=off))
+ off += n
+ gather_ops, off = [], out_lens[0]
+ for name, n in zip(graph.outputs[1:], out_lens[1:]):
+ gather_ops.append(Op("copy", f"{name}->y", name, None, None, n, n, dst_offset=off))
+ off += n
+ ops = slice_ops + ops + gather_ops
buffers, assignment = _assign_buffers(graph, ops, flat_in, flat_out)
- return Plan(graph.name, dtype, flat_in, flat_out, tuple(ops), buffers, assignment, tuple(weights))
+
+ n_params = sum(_weight_elems(w.shape) for w in weights)
+ if embed is None:
+ embed = n_params < EMBED_THRESHOLD
+ if embed:
+ np_dtype = _NUMPY_DTYPE[dtype]
+ weights = [
+ WeightSpec(w.name, w.symbol, w.shape, w.offset, w.nbytes,
+ values=tuple(np.asarray(graph.initializers[w.name], dtype=np_dtype)
+ .ravel(order="C").tolist()))
+ for w in weights
+ ]
+
+ return Plan(graph.name, dtype, flat_in, flat_out, tuple(ops), buffers, assignment,
+ tuple(weights), embed, n_params)
+
+
+def op_outputs(op) -> tuple:
+ """The values this op actually writes: no dead LSTM Y, no dropped Y_h/Y_c."""
+ head = () if (op.kind == "lstm" and not op.lstm.emit_y) else (op.out,)
+ return head + tuple(o for o in op.outs if o)
def _assign_buffers(graph: Graph, ops, flat_in: Tensor, flat_out: Tensor):
"""Give the input and output dedicated buffers; rotate intermediates through a pool.
- Assumes: every op has a single main input and no branching or merging,
- so each produced value has exactly one consumer. If the op set grows a branching
- op, the free-list logic needs revisiting.
+ An "alias" op contributes no buffer of its own: it hands its output the
+ symbol its input already holds, because a relabelling op moves no bytes.
+ Liveness is therefore tracked on the *root* of an alias chain, so a buffer
+ is only returned to the free list after the last read of anything that
+ shares it -- reading through an alias counts.
+
+ Multiple consumers of one value are fine: last_use records the last op that
+ reads it, not the first.
"""
+ alias = {op.out: op.inp for op in ops if op.kind == "alias"}
+
+ def root(name):
+ seen = set()
+ while name in alias and name not in seen:
+ seen.add(name)
+ name = alias[name]
+ return name
+
buffers = {"x": flat_in.shape[0], "y": flat_out.shape[0]}
assignment = {flat_in.name: "x", flat_out.name: "y"}
+ # A secondary graph output's gather copy writes into y at its offset.
+ for op in ops:
+ if op.kind == "copy" and op.dst_offset:
+ assignment[op.out] = "y"
last_use = {}
for i, op in enumerate(ops):
- last_use[op.inp] = i
+ for src in (op.inp,) + tuple(op.extra_in):
+ last_use[root(src)] = i
free, pool = [], 0
+
+ def take(length):
+ nonlocal pool
+ if free:
+ return free.pop()
+ sym = f"t{pool}"
+ pool += 1
+ return sym
+
for i, op in enumerate(ops):
- if op.out not in assignment:
- if free:
- sym = free.pop()
- else:
+ if op.kind == "alias":
+ assignment[op.out] = assignment[root(op.inp)]
+ continue
+ if op.kind == "copy" and op.dst_offset:
+ # A gather into y: its destination is y itself (assigned above), and
+ # y's length is already the whole concatenated output.
+ continue
+ for out in op_outputs(op):
+ if out not in assignment:
+ sym = take(0)
+ assignment[out] = sym
+ length = _length(graph.values[out])
+ buffers[assignment[out]] = max(buffers.get(assignment[out], 0), length)
+ if op.kind == "gru":
+ sp = op.gru
+ for role, size in (("h_sym", sp.batch * sp.hidden), ("g_sym", 3 * sp.hidden)):
+ sym = f"t{pool}"
+ pool += 1
+ buffers[sym] = size
+ object.__setattr__(sp, role, sym)
+ if op.kind == "lstm":
+ # Carried state and the per-step gate vector: internal to the op,
+ # so they get their own buffers rather than sharing the pool (they
+ # stay live across the whole sequence loop).
+ sp = op.lstm
+ for role, size in (("h_sym", sp.batch * sp.hidden),
+ ("c_sym", sp.batch * sp.hidden),
+ ("g_sym", 4 * sp.hidden)):
sym = f"t{pool}"
pool += 1
- assignment[op.out] = sym
- length = _length(graph.values[op.out])
- buffers[sym] = max(buffers.get(sym, 0), length)
- if op.inp in assignment and assignment[op.inp].startswith("t") and last_use.get(op.inp) == i:
- free.append(assignment[op.inp])
+ buffers[sym] = size
+ object.__setattr__(sp, role, sym)
+ for src in (op.inp,) + tuple(op.extra_in):
+ r = root(src)
+ if r in assignment and assignment[r].startswith("t") and last_use.get(r) == i:
+ free.append(assignment[r])
return buffers, assignment
diff --git a/python/rosenna/rt_header.py b/python/rosenna/rt_header.py
new file mode 100644
index 0000000..2059536
--- /dev/null
+++ b/python/rosenna/rt_header.py
@@ -0,0 +1,71 @@
+"""Render rosenna_rt.h: the one file that names the CUDA or HIP runtime API.
+
+The generated kernel (`emit_kernel`) and the CUDA/HIP branch of the generated
+`.c` reach the runtime only through these macros, so one source builds under
+both nvcc and hipcc. The file is identical for every model.
+"""
+
+_RT_HEADER = """\
+/* Generated by rosenna. Do not edit.
+ Maps the runtime calls the generated sources make onto CUDA or HIP; no
+ other generated file names a cuda* or hip* symbol. Compiled only by nvcc
+ (__CUDACC__) or hip-clang (__HIPCC__): a host C compiler never sees it.
+ __HIP__ is deliberately not tested: clang's OpenMP AMDGPU device pass
+ defines it without being a HIP compilation. */
+#ifndef ROSENNA_RT_H
+#define ROSENNA_RT_H
+/* How many devices one process may hold weights on. The device copies and the
+ per-translation-unit __constant__ table are per DEVICE, so a multi-GPU host
+ calls init (or upload_device) once per device with that device current, and
+ each device's table is filled with that device's pointers. Raise this if a
+ machine has more. */
+#ifndef ROSENNA_MAX_DEVICES
+#define ROSENNA_MAX_DEVICES 16
+#endif
+#if defined(__HIPCC__)
+#include
+#define ROSENNA_STREAM_T hipStream_t
+#define ROSENNA_MALLOC(p, n) hipMalloc((void **)(p), (n))
+#define ROSENNA_MEMCPY_H2D(d, h, n) hipMemcpy((d), (h), (n), hipMemcpyHostToDevice)
+/* HIP_SYMBOL is how HIP spells a symbol argument portably: it expands to X
+ on hip-clang and to &X on the retired hcc path. */
+#define ROSENNA_MEMCPY_TO_SYMBOL(sym, src, n) \\
+ hipMemcpyToSymbol(HIP_SYMBOL(sym), (src), (n), 0, hipMemcpyHostToDevice)
+#define ROSENNA_FREE(p) hipFree(p)
+#define ROSENNA_GET_DEVICE(p) hipGetDevice(p)
+#define ROSENNA_OK hipSuccess
+#define ROSENNA_SYNC(s) hipStreamSynchronize(s)
+#define ROSENNA_LAUNCH(k, g, b, s, ...) k<<<(g), (b), 0, (s)>>>(__VA_ARGS__)
+#define ROSENNA_LAUNCH_STATUS() hipGetLastError()
+/* Events, for infer_one's cross-stream ordering. Creating one is a host call;
+ recording and waiting are asynchronous enqueues, not host synchronization. */
+#define ROSENNA_EVENT_T hipEvent_t
+#define ROSENNA_EVENT_CREATE(e) hipEventCreateWithFlags((e), hipEventDisableTiming)
+#define ROSENNA_EVENT_RECORD(e, s) hipEventRecord((e), (s))
+#define ROSENNA_STREAM_WAIT_EVENT(s, e) hipStreamWaitEvent((s), (e), 0)
+#elif defined(__CUDACC__)
+#include
+#define ROSENNA_STREAM_T cudaStream_t
+#define ROSENNA_MALLOC(p, n) cudaMalloc((void **)(p), (n))
+#define ROSENNA_MEMCPY_H2D(d, h, n) cudaMemcpy((d), (h), (n), cudaMemcpyHostToDevice)
+#define ROSENNA_MEMCPY_TO_SYMBOL(sym, src, n) \\
+ cudaMemcpyToSymbol((sym), (src), (n), 0, cudaMemcpyHostToDevice)
+#define ROSENNA_FREE(p) cudaFree(p)
+#define ROSENNA_GET_DEVICE(p) cudaGetDevice(p)
+#define ROSENNA_OK cudaSuccess
+#define ROSENNA_SYNC(s) cudaStreamSynchronize(s)
+#define ROSENNA_LAUNCH(k, g, b, s, ...) k<<<(g), (b), 0, (s)>>>(__VA_ARGS__)
+#define ROSENNA_LAUNCH_STATUS() cudaGetLastError()
+#define ROSENNA_EVENT_T cudaEvent_t
+#define ROSENNA_EVENT_CREATE(e) cudaEventCreateWithFlags((e), cudaEventDisableTiming)
+#define ROSENNA_EVENT_RECORD(e, s) cudaEventRecord((e), (s))
+#define ROSENNA_STREAM_WAIT_EVENT(s, e) cudaStreamWaitEvent((s), (e), 0)
+#else
+#error "rosenna_rt.h is for nvcc or hipcc only"
+#endif
+#endif
+"""
+
+
+def rt_header() -> str:
+ return _RT_HEADER
diff --git a/python/rosenna/validate.py b/python/rosenna/validate.py
index 0d6c5b3..369f14b 100644
--- a/python/rosenna/validate.py
+++ b/python/rosenna/validate.py
@@ -2,11 +2,35 @@
import numpy as np
from .frontend import Graph, UnsupportedModel
-SUPPORTED = {"Gemm", "MatMul", "Relu", "Tanh", "Sigmoid"}
+# Dense ops (rank 1-2), the 2-D spatial ops (rank 4), LSTM, and the
+# relabelling ops plan.py turns into buffer aliases. Everything here is
+# lowered by plan.py into explicit loop nests over flat buffers; an op that is
+# not here is refused by name rather than silently mis-lowered.
+SUPPORTED = {"Gemm", "MatMul", "Relu", "Tanh", "Sigmoid", "Softmax", "Pad", "GRU",
+ "Conv", "MaxPool", "AveragePool", "Add", "Transpose", "LSTM", "Concat",
+ # Relabelling ops: fold.resolve_shape_ops deletes these outright
+ # unless one produces the graph output, where it becomes a copy.
+ "Reshape", "Squeeze", "Unsqueeze", "Flatten", "Identity"}
+
+# The spatial ops are 2-D only: kernel_shape, strides, pads and dilations all
+# have to describe exactly two spatial axes, which is what a rank-4 NCHW value
+# carries. A 1-D or 3-D convolution would need a different loop nest.
+_SPATIAL = {"Conv", "MaxPool", "AveragePool"}
+MAX_RANK = 4
def validate(graph: Graph) -> None:
for node in graph.nodes:
+ if node.op == "BatchNormalization":
+ # It is supported, but only by disappearing: fold.fold_batchnorm
+ # multiplies it into the Conv/Gemm that feeds it. Reaching here
+ # means that did not apply, and the generic "not supported"
+ # message would be actively misleading about why.
+ raise UnsupportedModel(
+ f"node '{node.name}': BatchNormalization is supported only when it can "
+ f"be folded into the Conv or Gemm that produces its input -- which needs "
+ f"inference mode, constant scale/B/mean/var of the right length, and that "
+ f"intermediate value read by nothing else. This one could not be folded")
if node.op not in SUPPORTED:
raise UnsupportedModel(
f"node '{node.name}': {node.op} is not supported; "
@@ -21,18 +45,35 @@ def validate(graph: Graph) -> None:
raise UnsupportedModel(
f"node '{node.name}': MatMul needs a constant second input; "
f"'{node.inputs[1]}' is computed at runtime")
+ rhs = graph.initializers[node.inputs[1]]
+ if rhs.ndim != 2:
+ raise UnsupportedModel(
+ f"node '{node.name}': MatMul weight '{node.inputs[1]}' has rank "
+ f"{rhs.ndim}; only rank 2 is supported")
+ if node.op == "Softmax":
+ _validate_softmax(graph, node)
+ if node.op == "Pad":
+ _validate_pad(graph, node)
+ if node.op == "Add":
+ _validate_add(graph, node)
+ if node.op == "Concat":
+ _validate_concat(graph, node)
+ if node.op == "LSTM":
+ _validate_lstm(graph, node)
+ if node.op == "GRU":
+ _validate_gru(graph, node)
+ if node.op in _SPATIAL:
+ _validate_spatial(graph, node)
for name, t in graph.values.items():
- if len(t.shape) not in (1, 2):
+ if not 1 <= len(t.shape) <= MAX_RANK:
raise UnsupportedModel(
- f"value '{name}' has rank {len(t.shape)}; this generator handles rank 1 and 2")
- if len(t.shape) == 2 and t.shape[0] != 1:
- raise UnsupportedModel(
- f"value '{name}' has leading dimension {t.shape[0]}; "
- f"this generator infers one point per call")
+ f"value '{name}' has rank {len(t.shape)}; "
+ f"this generator handles rank 1 to {MAX_RANK}")
for name, init in graph.initializers.items():
- if init.ndim not in (1, 2):
+ if not 1 <= init.ndim <= MAX_RANK:
raise UnsupportedModel(
- f"initializer '{name}' has rank {init.ndim}; this generator handles rank 1 and 2")
+ f"initializer '{name}' has rank {init.ndim}; "
+ f"this generator handles rank 1 to {MAX_RANK}")
if not np.issubdtype(init.dtype, np.floating):
raise UnsupportedModel(
f"initializer '{name}' has dtype {init.dtype}; only floating-point types are supported")
@@ -51,6 +92,14 @@ def _validate_gemm(graph: Graph, node) -> None:
if node.inputs[1] not in graph.initializers:
raise UnsupportedModel(
f"node '{node.name}': Gemm weight '{node.inputs[1]}' must be a constant")
+ # Rank is checked per op, not globally: the global initializer bound had to
+ # widen to 4 for Conv's OCxICxKHxKW kernels, and a rank-3 Gemm weight would
+ # otherwise slip through and be read as if it were a matrix.
+ gw = graph.initializers[node.inputs[1]]
+ if gw.ndim != 2:
+ raise UnsupportedModel(
+ f"node '{node.name}': Gemm weight '{node.inputs[1]}' has rank {gw.ndim}; "
+ f"only rank 2 is supported")
if len(node.inputs) > 2:
bias = graph.initializers.get(node.inputs[2])
if bias is None:
@@ -58,3 +107,365 @@ def _validate_gemm(graph: Graph, node) -> None:
if bias.ndim != 1:
raise UnsupportedModel(
f"node '{node.name}': Gemm bias has rank {bias.ndim}; only rank 1 is supported")
+ # The emitters index b[i] for every output: a (1,) bias, though a
+ # legal ONNX broadcast, would be read past its end.
+ n_out = gw.shape[0] if int(node.attrs.get("transB", 0)) else gw.shape[1]
+ if bias.shape[0] != n_out:
+ raise UnsupportedModel(
+ f"node '{node.name}': Gemm bias has {bias.shape[0]} values for {n_out} outputs; "
+ f"a broadcast bias is not supported")
+
+
+def _validate_softmax(graph: Graph, node) -> None:
+ """Softmax along the last axis only.
+
+ ONNX changed this operator at opset 13. Before, `axis` coerced the input to
+ 2-D and normalised every trailing axis together, with a default of 1;
+ after, it normalises along that one axis, with a default of -1. The two
+ readings agree exactly when the normalised axis is the last one, so
+ requiring that makes the emitted loop correct under either opset instead of
+ silently picking one. An absent `axis` is accepted only at rank 2, where
+ both defaults land on the last axis anyway.
+ """
+ where = f"node '{node.name}'"
+ x = graph.values.get(node.inputs[0])
+ if x is None:
+ raise UnsupportedModel(f"{where}: input '{node.inputs[0]}' has no inferred shape")
+ rank = len(x.shape)
+ if "axis" not in node.attrs:
+ if rank != 2:
+ raise UnsupportedModel(
+ f"{where}: Softmax without an explicit axis on a rank-{rank} input is "
+ f"ambiguous across opsets (the default is 1 before opset 13 and -1 from "
+ f"13); only rank 2, where both mean the last axis, is supported")
+ return
+ axis = int(node.attrs["axis"])
+ resolved = axis + rank if axis < 0 else axis
+ if resolved != rank - 1:
+ raise UnsupportedModel(
+ f"{where}: Softmax axis={axis} normalises axis {resolved} of a rank-{rank} "
+ f"input; only the last axis is supported")
+
+
+def _validate_pad(graph: Graph, node) -> None:
+ """Constant-mode Pad with non-negative, constant pads.
+
+ fold.absorb_pad_inputs has already moved the operand form into attributes,
+ so a Pad still carrying them is one whose pads are computed at runtime --
+ which this generator cannot turn into literal loop bounds.
+ """
+ where = f"node '{node.name}'"
+ x, out = graph.values.get(node.inputs[0]), graph.values.get(node.outputs[0])
+ if x is None or out is None:
+ raise UnsupportedModel(f"{where}: Pad operands must have inferred shapes")
+ if len(node.inputs) > 1:
+ raise UnsupportedModel(
+ f"{where}: Pad needs constant pads; '{node.inputs[1]}' is computed at runtime "
+ f"(or an `axes` operand is present, which is not supported)")
+ mode = node.attrs.get("mode", "constant")
+ if isinstance(mode, bytes):
+ mode = mode.decode()
+ if mode not in ("constant", "edge", "reflect"):
+ raise UnsupportedModel(
+ f"{where}: Pad mode='{mode}' is not supported; only 'constant', 'edge' "
+ f"and 'reflect'")
+ pads = node.attrs.get("pads")
+ if pads is None:
+ raise UnsupportedModel(f"{where}: Pad needs pads")
+ rank = len(x.shape)
+ if len(pads) != 2 * rank:
+ raise UnsupportedModel(
+ f"{where}: pads has {len(pads)} entries for a rank-{rank} input; expected {2 * rank}")
+ if mode == "reflect":
+ # One reflection only: the index map is (IN-1) - |(IN-1) - |e||, which
+ # covers e in [-(IN-1), 2*(IN-1)] and no further. A pad at least as
+ # wide as the axis would need repeated reflection, and the formula
+ # would quietly fold to the wrong element instead of failing.
+ for axis, extent in enumerate(x.shape):
+ reach = max(int(pads[axis]), int(pads[axis + rank]))
+ if reach > int(extent) - 1:
+ raise UnsupportedModel(
+ f"{where}: reflect pad of {reach} on axis {axis} of extent {extent} "
+ f"needs more than one reflection; only pads up to {int(extent) - 1} "
+ f"are supported")
+ for axis, (i, o) in enumerate(zip(x.shape, out.shape)):
+ if int(i) + int(pads[axis]) + int(pads[axis + rank]) != int(o):
+ raise UnsupportedModel(
+ f"{where}: axis {axis} is {i} padded by ({pads[axis]}, {pads[axis + rank]}), "
+ f"which is {int(i) + int(pads[axis]) + int(pads[axis + rank])}, but the output "
+ f"says {o}")
+
+
+def _validate_spatial(graph: Graph, node) -> None:
+ """Conv, MaxPool and AveragePool: 2-D spatial, rank-4 NCHW, no exotic attributes.
+
+ Every attribute this refuses is one whose meaning the emitted loop nest
+ does not implement -- so a model using it would otherwise get plausible
+ numbers that are wrong, which is the failure mode this whole file exists
+ to prevent.
+ """
+ where = f"node '{node.name}'"
+ x = graph.values.get(node.inputs[0])
+ if x is None:
+ raise UnsupportedModel(f"{where}: input '{node.inputs[0]}' has no inferred shape")
+ rank = len(x.shape)
+ if rank not in (3, 4):
+ raise UnsupportedModel(
+ f"{where}: {node.op} input has rank {rank}; only rank-3 NCW (1-D) and "
+ f"rank-4 NCHW (2-D) are supported")
+ nd = rank - 2 # spatial axes: 1 or 2
+ out = graph.values.get(node.outputs[0])
+ if out is None or len(out.shape) != rank:
+ raise UnsupportedModel(
+ f"{where}: {node.op} output must be a rank-{rank} value with an inferred shape")
+
+ kernel = node.attrs.get("kernel_shape")
+ if node.op == "Conv" and kernel is None:
+ w = graph.initializers.get(node.inputs[1])
+ kernel = tuple(int(d) for d in w.shape[2:]) if w is not None else None
+ if kernel is None:
+ raise UnsupportedModel(f"{where}: {node.op} needs kernel_shape")
+ if len(kernel) != nd:
+ raise UnsupportedModel(
+ f"{where}: kernel_shape has {len(kernel)} spatial axes but the input has "
+ f"{nd}; they must agree")
+
+ for attr in ("strides", "dilations"):
+ v = node.attrs.get(attr)
+ if v is not None and len(v) != nd:
+ raise UnsupportedModel(
+ f"{where}: {attr} has {len(v)} entries; a {nd}-D op takes {nd}")
+ pads = node.attrs.get("pads")
+ if pads is not None and len(pads) != 2 * nd:
+ raise UnsupportedModel(
+ f"{where}: pads has {len(pads)} entries; a {nd}-D op takes {2 * nd} "
+ f"(every begin, then every end)")
+ auto_pad = node.attrs.get("auto_pad", "NOTSET")
+ if auto_pad not in ("NOTSET", "VALID", "SAME_UPPER", "SAME_LOWER"):
+ raise UnsupportedModel(f"{where}: auto_pad='{auto_pad}' is not supported")
+ if auto_pad != "NOTSET" and pads is not None and any(pads):
+ raise UnsupportedModel(
+ f"{where}: auto_pad='{auto_pad}' with an explicit non-zero pads is ambiguous")
+ if int(node.attrs.get("ceil_mode", 0)) != 0:
+ raise UnsupportedModel(f"{where}: ceil_mode=1 is not supported")
+
+ if node.op == "Conv":
+ if len(node.inputs) < 2 or node.inputs[1] not in graph.initializers:
+ raise UnsupportedModel(f"{where}: Conv weight must be a constant initializer")
+ w = graph.initializers[node.inputs[1]]
+ if w.ndim != rank:
+ raise UnsupportedModel(
+ f"{where}: Conv weight has rank {w.ndim}; a rank-{rank} input needs a "
+ f"rank-{rank} weight")
+ group = int(node.attrs.get("group", 1))
+ c_in, c_out = int(x.shape[1]), int(w.shape[0])
+ if group < 1:
+ raise UnsupportedModel(f"{where}: Conv group={group} must be at least 1")
+ # A group that does not divide either channel count leaves some
+ # channel in no group at all, and the emitted loop would read across a
+ # group boundary rather than refusing.
+ if c_in % group or c_out % group:
+ raise UnsupportedModel(
+ f"{where}: Conv group={group} divides neither {c_in} input channels "
+ f"nor {c_out} output channels evenly")
+ if w.shape[1] != c_in // group:
+ raise UnsupportedModel(
+ f"{where}: Conv input has {c_in} channels in {group} group(s), so the "
+ f"weight's channel axis should be {c_in // group}; it is {w.shape[1]}")
+ if len(node.inputs) > 2 and node.inputs[2]:
+ b = graph.initializers.get(node.inputs[2])
+ if b is None:
+ raise UnsupportedModel(f"{where}: Conv bias '{node.inputs[2]}' must be a constant")
+ if b.ndim != 1 or b.shape[0] != w.shape[0]:
+ raise UnsupportedModel(
+ f"{where}: Conv bias must be rank 1 with one value per output channel")
+ else:
+ if len(node.outputs) > 1 and node.outputs[1]:
+ raise UnsupportedModel(
+ f"{where}: {node.op} with a second (indices) output is not supported")
+ if int(node.attrs.get("storage_order", 0)) != 0:
+ raise UnsupportedModel(f"{where}: MaxPool storage_order=1 (column major) is not supported")
+
+
+def _validate_concat(graph: Graph, node) -> None:
+ """Concat of runtime values and constants along one axis, shapes agreeing elsewhere."""
+ where = f"node '{node.name}'"
+ if not node.inputs:
+ raise UnsupportedModel(f"{where}: Concat needs at least one input")
+ shapes = []
+ for name in node.inputs:
+ if name in graph.initializers:
+ shapes.append(tuple(int(d) for d in graph.initializers[name].shape))
+ elif name in graph.values:
+ shapes.append(tuple(int(d) for d in graph.values[name].shape))
+ else:
+ raise UnsupportedModel(f"{where}: Concat input '{name}' has no known shape")
+ rank = len(shapes[0])
+ if any(len(sh) != rank for sh in shapes):
+ raise UnsupportedModel(f"{where}: Concat inputs must all have the same rank; got {shapes}")
+ axis = int(node.attrs.get("axis", 0))
+ axis = axis + rank if axis < 0 else axis
+ if not 0 <= axis < rank:
+ raise UnsupportedModel(f"{where}: Concat axis {node.attrs.get('axis')} is out of range for rank {rank}")
+ for sh in shapes[1:]:
+ if any(a != b for k, (a, b) in enumerate(zip(shapes[0], sh)) if k != axis):
+ raise UnsupportedModel(
+ f"{where}: Concat inputs differ off the concatenation axis {axis}: {shapes}")
+
+
+def _validate_add(graph: Graph, node) -> None:
+ """Add of a runtime value and a constant, broadcast right-aligned.
+
+ Two runtime operands would need two live buffers reaching one op, which the
+ single-input Op model does not carry; a constant operand is the shape that
+ actually turns up (a per-channel bias a Conv export did not fold in).
+ """
+ where = f"node '{node.name}'"
+ if len(node.inputs) != 2:
+ raise UnsupportedModel(f"{where}: Add takes exactly 2 inputs")
+ runtime = [i for i in node.inputs if i not in graph.initializers]
+ if len(runtime) != 1:
+ raise UnsupportedModel(
+ f"{where}: Add needs exactly one runtime operand and one constant; "
+ f"got {len(runtime)} runtime")
+ out = graph.values.get(node.outputs[0])
+ x = graph.values.get(runtime[0])
+ if out is None or x is None:
+ raise UnsupportedModel(f"{where}: Add operands must have inferred shapes")
+ if tuple(out.shape) != tuple(x.shape):
+ raise UnsupportedModel(
+ f"{where}: Add broadcasts its runtime operand from {tuple(x.shape)} to "
+ f"{tuple(out.shape)}; only the constant operand may broadcast")
+ const = graph.initializers[[i for i in node.inputs if i in graph.initializers][0]]
+ if const.ndim > len(out.shape):
+ raise UnsupportedModel(
+ f"{where}: Add constant has rank {const.ndim}, wider than the output's "
+ f"{len(out.shape)}")
+ for axis, (o, c) in enumerate(zip(out.shape[len(out.shape) - const.ndim:], const.shape)):
+ if c not in (1, o):
+ raise UnsupportedModel(
+ f"{where}: Add constant axis {axis} has extent {c}, which neither "
+ f"matches the output's {o} nor broadcasts")
+
+
+def _validate_gru(graph: Graph, node) -> None:
+ """Forward GRU, ONNX default activations, no clipping.
+
+ Everything refused here changes the recurrence itself, so a model using it
+ would run and return confident nonsense. `linear_before_reset` is NOT
+ refused: both readings are implemented, because PyTorch exports 1 and the
+ ONNX default is 0, and picking one would have been wrong half the time.
+ """
+ where = f"node '{node.name}'"
+ if not node.outputs or not node.outputs[0]:
+ raise UnsupportedModel(
+ f"{where}: GRU must produce its Y output (the sequence); a graph that asks "
+ f"only for Y_h is not supported")
+ direction = node.attrs.get("direction", "forward")
+ if direction != "forward":
+ raise UnsupportedModel(f"{where}: direction='{direction}'; only 'forward' is supported")
+ if "activations" in node.attrs:
+ raise UnsupportedModel(
+ f"{where}: custom activations are not supported; only the defaults "
+ f"(sigmoid on the gates, tanh on the new-state candidate)")
+ for attr in ("clip", "layout"):
+ if node.attrs.get(attr):
+ raise UnsupportedModel(f"{where}: {attr}={node.attrs[attr]} is not supported")
+ if len(node.inputs) > 4 and node.inputs[4]:
+ raise UnsupportedModel(f"{where}: a sequence_lens input is not supported")
+ x = graph.values.get(node.inputs[0])
+ if x is None or len(x.shape) != 3:
+ raise UnsupportedModel(
+ f"{where}: GRU input must be a rank-3 (seq, batch, input_size) value")
+ for idx, role, mult in ((1, "W", 3), (2, "R", 3)):
+ if idx >= len(node.inputs) or node.inputs[idx] not in graph.initializers:
+ raise UnsupportedModel(f"{where}: GRU {role} must be a constant initializer")
+ a = graph.initializers[node.inputs[idx]]
+ if a.ndim != 3 or a.shape[0] != 1:
+ raise UnsupportedModel(
+ f"{where}: GRU {role} must have shape (1, {mult}*hidden, k); got {tuple(a.shape)}")
+ hidden = int(graph.initializers[node.inputs[2]].shape[2])
+ if int(graph.initializers[node.inputs[1]].shape[1]) != 3 * hidden:
+ raise UnsupportedModel(
+ f"{where}: GRU W has {graph.initializers[node.inputs[1]].shape[1]} rows for "
+ f"hidden {hidden}; a GRU has three gates, so it needs {3 * hidden}")
+ if len(node.inputs) > 3 and node.inputs[3]:
+ b = graph.initializers.get(node.inputs[3])
+ if b is None or b.ndim != 2 or b.shape[0] != 1 or int(b.shape[1]) != 6 * hidden:
+ raise UnsupportedModel(
+ f"{where}: GRU B must be a constant of shape (1, 6*hidden) -- the three W "
+ f"biases then the three R biases")
+ if len(node.inputs) > 5 and node.inputs[5]:
+ name = node.inputs[5]
+ shape = (graph.initializers[name].shape if name in graph.initializers
+ else (graph.values[name].shape if name in graph.values else ()))
+ if len(shape) != 3:
+ raise UnsupportedModel(
+ f"{where}: initial_h must be a rank-3 (num_directions, batch, hidden) "
+ f"value or initializer")
+
+
+def _validate_lstm(graph: Graph, node) -> None:
+ """Forward-direction LSTM with the ONNX default activations and no clipping.
+
+ Everything refused here changes the recurrence itself, so a model using it
+ would run and return confident nonsense.
+ """
+ where = f"node '{node.name}'"
+ if not node.outputs or not node.outputs[0]:
+ # The plan's op is keyed on Y (the full sequence); Y_h and Y_c are
+ # extra results copied out beside it, not stand-ins for it.
+ raise UnsupportedModel(
+ f"{where}: LSTM must produce its Y output (the sequence); a graph that asks "
+ f"only for Y_h or Y_c is not supported")
+ direction = node.attrs.get("direction", "forward")
+ if direction != "forward":
+ raise UnsupportedModel(f"{where}: direction='{direction}'; only 'forward' is supported")
+ if "activations" in node.attrs:
+ raise UnsupportedModel(
+ f"{where}: custom activations are not supported; only the defaults "
+ f"(sigmoid on the gates, tanh on the cell and the output)")
+ for attr in ("clip", "input_forget", "layout"):
+ if node.attrs.get(attr):
+ raise UnsupportedModel(f"{where}: {attr}={node.attrs[attr]} is not supported")
+ if len(node.inputs) > 4 and node.inputs[4]:
+ raise UnsupportedModel(f"{where}: a sequence_lens input is not supported")
+ if len(node.inputs) > 7 and node.inputs[7]:
+ raise UnsupportedModel(f"{where}: peephole weights (input P) are not supported")
+ x = graph.values.get(node.inputs[0])
+ if x is None or len(x.shape) != 3:
+ raise UnsupportedModel(
+ f"{where}: LSTM input must be a rank-3 (seq, batch, input_size) value")
+ for idx, role in ((1, "W"), (2, "R")):
+ if idx >= len(node.inputs) or node.inputs[idx] not in graph.initializers:
+ raise UnsupportedModel(f"{where}: LSTM {role} must be a constant initializer")
+ a = graph.initializers[node.inputs[idx]]
+ if a.ndim != 3 or a.shape[0] != 1:
+ raise UnsupportedModel(
+ f"{where}: LSTM {role} must have shape (1, 4*hidden, k); got {tuple(a.shape)}")
+ if len(node.inputs) > 3 and node.inputs[3]:
+ b = graph.initializers.get(node.inputs[3])
+ if b is None or b.ndim != 2 or b.shape[0] != 1:
+ raise UnsupportedModel(f"{where}: LSTM B must be a constant of shape (1, 8*hidden)")
+ # The initial state is either a graph value (the caller supplies it, in
+ # x) or an initializer (a folded Constant: it becomes a weight); either
+ # way rank 3, and both of the pair the same way.
+ kinds = set()
+ for idx, role in ((5, "initial_h"), (6, "initial_c")):
+ if len(node.inputs) > idx and node.inputs[idx]:
+ name = node.inputs[idx]
+ if name in graph.initializers:
+ shape, kinds = graph.initializers[name].shape, kinds | {"initializer"}
+ else:
+ v = graph.values.get(name)
+ shape, kinds = (v.shape if v is not None else ()), kinds | {"value"}
+ if len(shape) != 3:
+ raise UnsupportedModel(
+ f"{where}: {role} must be a rank-3 (num_directions, batch, hidden) "
+ f"value or initializer")
+ if (len(node.inputs) > 5 and bool(node.inputs[5])) != (len(node.inputs) > 6 and bool(node.inputs[6])):
+ raise UnsupportedModel(
+ f"{where}: initial_h and initial_c must be supplied together or not at all")
+ if len(kinds) > 1:
+ raise UnsupportedModel(
+ f"{where}: initial_h and initial_c must both be values or both be initializers")
diff --git a/python/rosenna/verify.py b/python/rosenna/verify.py
index 4e99306..ff83d0f 100644
--- a/python/rosenna/verify.py
+++ b/python/rosenna/verify.py
@@ -1,4 +1,5 @@
"""Compile the generated code and compare its output against onnxruntime."""
+import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -22,6 +23,44 @@ class VerificationError(RuntimeError):
"""A comparison would not mean anything (e.g. the reference is dead)."""
+def _live_reference(session, shape, dtype, seed=0, batch=8, max_attempts=10):
+ """Resample input batches until the onnxruntime reference itself is alive.
+
+ Non-degeneracy is a property of the randomly generated fixture, not of
+ the code under test: several golden models (e.g. gemm_small) have no
+ manual_seed, so their weights differ on every regeneration, and an
+ all-zero reference (a dead model, e.g. every pre-activation negative
+ into a final ReLU) is a property of that draw of weights -- correct
+ generated code reproducing a dead model must *also* be all zero, so no
+ assertion on our own output can tell the two cases apart. The fix
+ belongs here, on the reference, before we ever build or run anything.
+
+ `dtype` is a numpy dtype (e.g. np.float64), not a plan dtype string
+ ("f32"/"f64") -- this helper draws and feeds inputs at that numpy dtype
+ directly.
+
+ Returns (inputs, expected) for the first batch whose reference has at
+ least two non-zero values across the whole batch, or (None, None) if
+ max_attempts batches all came back dead.
+
+ Moved here (from tests/test_emit_fortran.py) so that `rosenna/gate.py`
+ can reuse it without importing test code; tests/test_emit_fortran.py
+ re-exports the same name so every existing `from tests.test_emit_fortran
+ import _live_reference` keeps working unchanged.
+ """
+ rng = np.random.default_rng(seed)
+ for _ in range(max_attempts):
+ inputs = rng.uniform(-2, 2, (batch, int(np.prod(shape)))).astype(dtype)
+ expected = np.array([
+ np.concatenate([o.ravel() for o in session.run(
+ None, {session.get_inputs()[0].name: row.reshape(shape).astype(np.float32)})])
+ for row in inputs
+ ])
+ if np.count_nonzero(expected) >= 2:
+ return inputs, expected
+ return None, None
+
+
@dataclass(frozen=True)
class VerifyResult:
lang: str # "fortran" | "c"
@@ -31,7 +70,27 @@ class VerifyResult:
ok: bool
-def verify_model(model_path, lang: str, dtype: str | None, cases: int, workdir) -> list:
+# ROSENNA_CC / ROSENNA_FC point verify at a different compiler. The generated
+# code is plain C11 and Fortran 2008, so any conforming compiler should build
+# and run it and reach the same numbers; a CI job or a developer checking a new
+# toolchain needs a way to say so without editing this file.
+#
+# -Wall -Wextra -std= are added only for a compiler whose basename says it
+# takes them; flang, ifx and nvfortran get -O2 and nothing else.
+_GNU_STYLE = ("gcc", "gfortran", "cc", "clang")
+
+
+def _compiler(role: str) -> str:
+ return os.environ.get(f"ROSENNA_{role}", {"CC": "gcc", "FC": "gfortran"}[role])
+
+
+def _warn_flags(tool: str, std: str) -> list:
+ from pathlib import Path as _P
+ return ["-Wall", "-Wextra", std] if _P(tool).name.startswith(_GNU_STYLE) else []
+
+
+def verify_model(model_path, lang: str, dtype: str | None, cases: int, workdir,
+ embed: bool | None = None, name: str | None = None) -> list:
"""Generate, compile and run `lang` backend(s) for `model_path`, and compare to onnxruntime.
Draws `cases` random inputs from a fixed seed and compares every backend's output
@@ -55,8 +114,8 @@ def verify_model(model_path, lang: str, dtype: str | None, cases: int, workdir)
if no resampled batch is alive.
"""
workdir = Path(workdir)
- graph = load_graph(model_path)
- plan = build_plan(graph, dtype=dtype)
+ graph = load_graph(model_path, name)
+ plan = build_plan(graph, dtype=dtype, embed=embed)
validate_model_name(plan.model)
# Model's own dtype, read before --precision is applied: this is what onnxruntime
@@ -67,16 +126,30 @@ def verify_model(model_path, lang: str, dtype: str | None, cases: int, workdir)
model_dtype = graph.values[graph.inputs[0]].dtype
session = ort.InferenceSession(str(model_path))
- shape = session.get_inputs()[0].shape
- inputs, expected = _live_inputs(session, shape, cases, model_path, _NUMPY[model_dtype])
+ shapes = [i.shape for i in session.get_inputs()]
+ inputs, expected = _live_inputs(session, shapes, cases, model_path, _NUMPY[model_dtype])
backends = ["fortran", "c"] if lang == "both" else [lang]
- rtol, atol = _TOL[model_dtype]
+ # Tolerance follows the COARSER of the reference's precision and the build's.
+ # The reference is computed at the model's own dtype, so f64 code compared
+ # against an f32 reference is still held to the f32 tolerance (generating
+ # f64 cannot make onnxruntime's f32 answer more accurate). The converse was
+ # a false FAIL: a genuine float64 model built `--precision single` was held
+ # to the f64 tolerance it had no way of meeting, so that configuration could
+ # not be verified at all. Whichever side rounds more coarsely sets the bar.
+ compare_dtype = "f32" if "f32" in (model_dtype, plan.dtype) else "f64"
+ rtol, atol = _TOL[compare_dtype]
+ atol = atol + _cancellation_atol(plan, compare_dtype, expected)
results = []
for backend in backends:
backend_dir = workdir / backend
backend_dir.mkdir(parents=True, exist_ok=True)
- write_weights(plan, graph, backend_dir / f"{plan.model}.rwt")
+ # Both backends now embed by default: a plan that embeds has no
+ # weights file to load in either language (every weight is baked into
+ # the generated source -- an initialized `protected` module array in
+ # Fortran, deliberately not `parameter`, and ROSENNA_CONST in C).
+ if not plan.embed:
+ write_weights(plan, graph, backend_dir / f"{plan.model}.rwt")
got = _run_backend(backend, plan, backend_dir, inputs)
abs_err = np.abs(got - expected)
denom = np.maximum(np.abs(expected), np.finfo(np.float64).tiny)
@@ -86,22 +159,33 @@ def verify_model(model_path, lang: str, dtype: str | None, cases: int, workdir)
return results
-def _live_inputs(session, shape, cases: int, model_path, np_dtype):
+def _live_inputs(session, shapes, cases: int, model_path, np_dtype):
"""Resample input batches (fixed seed) until the onnxruntime reference is alive.
See verify_model's docstring: a dead reference is a property of the model's own
(possibly unseeded) weights, not of the code under test, and no comparison against
it can distinguish "correct" from "also dead".
+
+ A model with several graph inputs (an LSTM's initial hidden and cell state)
+ gets one drawn row per case holding all of them concatenated in declaration
+ order -- exactly the layout the generated infer(x, y) expects -- and the row
+ is split back up to feed onnxruntime.
"""
- input_name = session.get_inputs()[0].name
- n = int(np.prod(shape))
+ names = [i.name for i in session.get_inputs()]
+ lens = [int(np.prod(sh)) for sh in shapes]
+ n = sum(lens)
for attempt in range(_MAX_ATTEMPTS):
rng = np.random.default_rng(_SEED + attempt)
inputs = rng.uniform(-2, 2, (cases, n)).astype(np_dtype)
- expected = np.array([
- session.run(None, {input_name: row.reshape(shape).astype(np_dtype)})[0].ravel()
- for row in inputs
- ])
+ expected = []
+ for row in inputs:
+ feed, off = {}, 0
+ for name, sh, ln in zip(names, shapes, lens):
+ feed[name] = row[off:off + ln].reshape(sh).astype(np_dtype)
+ off += ln
+ # Every graph output, flat, in declaration order: the y layout.
+ expected.append(np.concatenate([o.ravel() for o in session.run(None, feed)]))
+ expected = np.array(expected)
if np.count_nonzero(expected) >= 2:
return inputs, expected
raise VerificationError(
@@ -110,8 +194,18 @@ def _live_inputs(session, shape, cases: int, model_path, np_dtype):
f"passing comparison here would not demonstrate anything")
-def _fortran_driver(name: str, n_in: int, n_out: int, dtype: str) -> str:
+def _fortran_driver(name: str, n_in: int, n_out: int, dtype: str, embed: bool) -> str:
real_kind = "real64" if dtype == "f64" else "real32"
+ # An embedded plan has no `_init`: every weight is already an initialized
+ # `protected` array in the generated module, resident from program load.
+ # (`protected`, not `parameter`: gfortran -fopenacc will not take a
+ # `declare` on a named constant. See _emit_embedded_weights.)
+ init = "" if embed else f"""
+ call {name}_init('{name}.rwt', status)
+ if (status /= 0) then
+ print *, 'init status', status
+ stop 1
+ end if"""
return f"""
program verify_main
use {name}_model
@@ -119,12 +213,7 @@ def _fortran_driver(name: str, n_in: int, n_out: int, dtype: str) -> str:
implicit none
real({real_kind}) :: x({n_in}), y({n_out})
integer :: status, i, ncases
- read(*,*) ncases
- call {name}_init('{name}.rwt', status)
- if (status /= 0) then
- print *, 'init status', status
- stop 1
- end if
+ read(*,*) ncases{init}
do i = 1, ncases
read(*,*) x
call {name}_infer(x, y)
@@ -134,16 +223,20 @@ def _fortran_driver(name: str, n_in: int, n_out: int, dtype: str) -> str:
"""
-def _c_driver(name: str, n_in: int, n_out: int, dtype: str) -> str:
+def _c_driver(name: str, n_in: int, n_out: int, dtype: str, embed: bool) -> str:
c_type = "double" if dtype == "f64" else "float"
fmt = "%lf" if dtype == "f64" else "%f"
+ # An embedded plan has no `_init`: every weight is already a ROSENNA_CONST
+ # array in the header, resident from program load.
+ init = "" if embed else (
+ f'int status = {name}_init("{name}.rwt");\n'
+ f' if (status != 0) {{ printf("init status %d\\n", status); return 1; }}\n ')
return f"""
#include
#include "{name}.h"
int main(void) {{
{c_type} x[{n_in}], y[{n_out}];
- int ncases, status = {name}_init("{name}.rwt");
- if (status != 0) {{ printf("init status %d\\n", status); return 1; }}
+ {init}int ncases;
if (scanf("%d", &ncases) != 1) return 1;
for (int c = 0; c < ncases; ++c) {{
for (int i = 0; i < {n_in}; ++i) if (scanf("{fmt}", &x[i]) != 1) return 1;
@@ -187,20 +280,48 @@ def _run_backend(backend: str, plan, workdir: Path, inputs):
n_in, n_out = plan.input.shape[0], plan.output.shape[0]
dtype = plan.dtype
if backend == "fortran":
- (workdir / f"{name}_model.f90").write_text(emit_fortran(plan))
- (workdir / "verify_main.f90").write_text(_fortran_driver(name, n_in, n_out, dtype))
+ (workdir / f"{name}_model.F90").write_text(emit_fortran(plan))
+ (workdir / "verify_main.f90").write_text(_fortran_driver(name, n_in, n_out, dtype, plan.embed))
+ # Compile the module to an object, archive it, and link the driver
+ # against the archive -- the library form -- rather than compiling
+ # both sources together, mirroring the C backend below.
+ fc = _compiler("FC")
+ fw = _warn_flags(fc, "-std=f2008")
+ _run("compile", backend,
+ [fc, "-O2", *fw, "-c", f"{name}_model.F90"],
+ cwd=workdir)
+ # lib_f.a, not lib.a (ruling R13): the C backend's own
+ # archive is lib.a, and although verify's fortran/c backends
+ # build in separate directories (no collision here), the two names
+ # must never be the same anywhere a caller might build both recipes
+ # in one place (cli.py's `generate --lang both`, in particular).
+ _run("archive", backend,
+ ["ar", "rcs", f"lib{name}_f.a", f"{name}_model.o"],
+ cwd=workdir)
_run("compile/link", backend,
- ["gfortran", "-O2", "-Wall", "-Wextra", "-o", "verify_run",
- f"{name}_model.f90", "verify_main.f90"],
+ [fc, "-O2", *fw, "-o", "verify_run",
+ "verify_main.f90", f"lib{name}_f.a"],
cwd=workdir)
elif backend == "c":
source, header = emit_c(plan)
(workdir / f"{name}.c").write_text(source)
(workdir / f"{name}.h").write_text(header)
- (workdir / "verify_main.c").write_text(_c_driver(name, n_in, n_out, dtype))
+ (workdir / "verify_main.c").write_text(_c_driver(name, n_in, n_out, dtype, plan.embed))
+ # Compile the generated source to an object, archive it, and link the
+ # driver against the archive -- the library form -- rather than
+ # compiling both sources together, so `verify` exercises the same
+ # delivery shape a downstream host build uses.
+ cc = _compiler("CC")
+ cw = _warn_flags(cc, "-std=c11")
+ _run("compile", backend,
+ [cc, "-O2", *cw, "-c", f"{name}.c", "-o", f"{name}.o"],
+ cwd=workdir)
+ _run("archive", backend,
+ ["ar", "rcs", f"lib{name}.a", f"{name}.o"],
+ cwd=workdir)
_run("compile/link", backend,
- ["gcc", "-O2", "-Wall", "-Wextra", "-std=c11", "-o", "verify_run",
- f"{name}.c", "verify_main.c", "-lm"],
+ [cc, "-O2", *cw, "-o", "verify_run",
+ "verify_main.c", f"lib{name}.a", "-lm"],
cwd=workdir)
else:
raise ValueError(f"unknown backend {backend!r}")
@@ -208,3 +329,43 @@ def _run_backend(backend: str, plan, workdir: Path, inputs):
stdin = f"{len(inputs)}\n" + "\n".join(" ".join(repr(float(v)) for v in row) for row in inputs)
out = _run("run", backend, ["./verify_run"], cwd=workdir, input=stdin).stdout
return np.array([[float(v) for v in line.split()] for line in out.strip().splitlines()])
+
+
+def _reduction_depth(op) -> int:
+ """How many terms the longest single summation inside this op adds up."""
+ if op.kind == "gemm":
+ return op.n_in
+ if op.kind == "conv":
+ return op.spatial.c_in * op.spatial.kh * op.spatial.kw
+ if op.kind == "avgpool":
+ return op.spatial.kh * op.spatial.kw
+ if op.kind == "lstm":
+ return op.lstm.input_size + op.lstm.hidden
+ return 1
+
+
+def _cancellation_atol(plan, model_dtype: str, expected) -> float:
+ """Slack for the one error a relative-to-output tolerance cannot express.
+
+ onnxruntime and the generated code compute in the same precision but not in
+ the same order -- ORT blocks and vectorises its convolutions and GEMMs. The
+ classical bound on summing n terms is n * eps * sum|terms|, and when the sum
+ cancels, sum|terms| is far larger than |result|: the error is then large
+ relative to the output while both implementations are perfectly correct.
+ A tolerance written as rtol * |expected| cannot see that and will reject a
+ correct implementation.
+
+ sum|terms| is not observable from here, so the batch's largest |expected|
+ stands in for the scale of the computation. That is a proxy, and a
+ deliberately generous one -- it is the only quantity available that tracks
+ the magnitude the accumulation actually works at.
+
+ mnist is the case that forced this: a 256-term MatMul, an output of
+ magnitude 0.05 carrying 4.6e-6 of absolute error, and the same plan built
+ in double matching an independent float64 reference to 2.4e-15. Models
+ whose deepest reduction is short get a negligible bump and keep the flat
+ tolerance in practice.
+ """
+ depth = max((_reduction_depth(op) for op in plan.ops), default=1)
+ scale = float(np.max(np.abs(expected))) if expected.size else 0.0
+ return depth * float(np.finfo(_NUMPY[model_dtype]).eps) * scale
diff --git a/python/tests/conftest.py b/python/tests/conftest.py
index e26c9e6..22e7266 100644
--- a/python/tests/conftest.py
+++ b/python/tests/conftest.py
@@ -1,6 +1,9 @@
"""Fixtures for golden file models and for inline models built with onnx.helper."""
+import os
+import platform
+import re
import subprocess
-import sys
+import time
from pathlib import Path
import numpy as np
@@ -8,6 +11,56 @@
import pytest
from onnx import helper, numpy_helper, TensorProto
+from rosenna.golden import golden_generator_run, golden_model_path
+
+# A diagnostic about the generated source carries a :: location.
+# A driver-level notice instead names the tool as its "location" -- for
+# example Apple clang on the macOS CI runner prints, on every invocation and
+# whatever the source,
+# clang: warning: overriding deployment version from '16.0' to '26.0' [-Woverriding-deployment-version]
+# which is about the SDK versus the deployment target and nothing to do with
+# our C (ruling R21). gfortran's own multi-line diagnostics keep their
+# `:::` header and a bare `Warning: ...` line, neither of
+# which this pattern matches, so they survive.
+_DRIVER_NOTICE = re.compile(r"^[^\s:]+: (warning|note): ")
+
+
+def _source_diagnostics(stderr: str):
+ """Split compiler stderr into (about the source, driver-level noise)."""
+ kept, dropped = [], []
+ for line in stderr.splitlines():
+ (dropped if _DRIVER_NOTICE.match(line) else kept).append(line)
+ return "\n".join(kept).strip(), "\n".join(dropped).strip()
+
+
+def skip_unless_libgomp_enforces_mandatory(cc: str, tmp_path: Path) -> None:
+ """Skip when this libgomp runs a target region to completion under MANDATORY.
+
+ A libgomp built with no offload plugins (a plain distro gcc < 13, say)
+ ignores OMP_TARGET_OFFLOAD=MANDATORY and falls back to the host, so a test
+ whose evidence is "the program was refused" cannot run there. Skip, naming
+ the toolchain, rather than fail; the caller keeps a platform-independent
+ assertion (the object references GOMP_target_ext) as its primary evidence.
+ """
+ probe = tmp_path / "mandatory_probe.c"
+ probe.write_text("int main(void) {\n int v = 0;\n"
+ " #pragma omp target map(tofrom: v)\n v = 1;\n return v ? 0 : 3;\n}\n")
+ build = subprocess.run([cc, "-fopenmp", str(probe), "-o", str(tmp_path / "mandatory_probe")],
+ capture_output=True, text=True)
+ assert build.returncode == 0, build.stderr
+ run = subprocess.run([str(tmp_path / "mandatory_probe")], capture_output=True, text=True,
+ env={**os.environ, "OMP_TARGET_OFFLOAD": "MANDATORY"})
+ if run.returncode == 0:
+ version = subprocess.run([cc, "--version"], capture_output=True, text=True).stdout.splitlines()[0]
+ pytest.skip(f"libgomp did not enforce OMP_TARGET_OFFLOAD=MANDATORY for a C target region "
+ f"on {platform.platform()} with {version}")
+
+
+def _assert_warning_free(lang: str, stderr: str) -> None:
+ kept, dropped = _source_diagnostics(stderr)
+ assert kept == "", (f"{lang}: diagnostics about the generated source:\n{kept}\n"
+ f"(driver-level notices ignored: {dropped or 'none'})")
+
def save_model(directory, name, nodes, inits, in_shape, out_shape, elem=TensorProto.FLOAT):
"""Save a one-input, one-output ONNX graph as /.onnx and return the path."""
@@ -38,27 +91,71 @@ def live_gemm_model(tmp_path):
return save_model(tmp_path, "livegemm", [node], [w, b], (1, 3), (1, 2))
+def _run_generator(root, name):
+ with golden_generator_run(root, name) as (argv, cwd, env):
+ subprocess.run(argv, cwd=cwd, check=True, env=env)
+
+
+def _generate_once(model_path, generate):
+ """Run `generate` once, even with several pytest-xdist workers running.
+
+ The generators write into the shared goldenFiles tree, so two workers that
+ both find a model missing would write the same file at the same time. Each
+ xdist worker is its own process with its own session fixtures, so the
+ session scope above is no protection. O_EXCL on a sidecar is the whole
+ lock: the loser waits for the winner's file rather than generating too.
+ """
+ lock = model_path.with_name(model_path.name + ".lock")
+ deadline = time.time() + 600
+ while True:
+ try:
+ os.close(os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY))
+ except FileExistsError:
+ while time.time() < deadline:
+ if model_path.exists():
+ return
+ time.sleep(0.1)
+ # The holder died without cleaning up; take the lock over.
+ lock.unlink(missing_ok=True)
+ continue
+ try:
+ if not model_path.exists():
+ generate()
+ finally:
+ lock.unlink(missing_ok=True)
+ return
+
+
@pytest.fixture(scope="session")
def golden_model():
"""Return a helper that generates a golden ONNX model by running its generator script.
The helper takes a model name (e.g. "gemm_small"), returns the path to the ONNX file
- (../goldenFiles//.onnx), and generates it if it does not exist.
- Runs the generator script from test/ as the working directory so filePath resolution
- and side effects (inputs.fpp) stay in a disposable directory.
+ (goldenFiles//.onnx), and generates it if it does not exist. How the
+ generator is run is rosenna.golden's business, shared with the gpu-gate.
"""
+ root = Path(__file__).resolve().parents[2]
generated = {}
def _get_model_path(name: str) -> Path:
if name not in generated:
- model_path = Path(f"../goldenFiles/{name}/{name}.onnx")
+ model_path = golden_model_path(root, name)
+ if not model_path.exists():
+ _generate_once(model_path, lambda: _run_generator(root, name))
if not model_path.exists():
- subprocess.run(
- [sys.executable, f"../goldenFiles/{name}/{name}.py"],
- cwd="../test",
- check=True,
- )
+ # goldenFiles/mnist/mnist.py reads its .onnx rather than
+ # writing one -- that model is checked in. Say so, instead of
+ # handing back a path that does not exist.
+ raise FileNotFoundError(
+ f"{model_path} is missing and {name}.py did not create it; "
+ f"if it is a checked-in model, restore it with git checkout")
generated[name] = model_path
return generated[name]
return _get_model_path
+
+
+@pytest.fixture
+def repo_root():
+ """The repository root, from this file's location rather than the cwd."""
+ return Path(__file__).resolve().parents[2]
diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py
index 64ba357..8f26717 100644
--- a/python/tests/test_cli.py
+++ b/python/tests/test_cli.py
@@ -1,18 +1,145 @@
+import subprocess
+
+import pytest
+
+import onnx
+from onnx import helper, TensorProto
+
import rosenna.verify as verify_mod
from rosenna.cli import main
+from tests.test_library_form import _cc
+from tests.test_device_fortran import _omp_fc
+
+
+def _unsupported_model(tmp_path, name="softmaxed"):
+ """A minimal model using an op the generator does not lower.
+
+ Not a golden file: the golden set is what the generator is growing to
+ cover, so pinning a rejection test to one of them turns every genuine
+ coverage win into a spurious failure (mnist did exactly that once Conv,
+ MaxPool, Add and the shape ops landed).
+
+ The op here has to be one that is NOT on the roadmap, for the same reason
+ one level up: this test used Softmax until Softmax was implemented, and
+ then three tests failed for a coverage win. `Erf` is elementwise and
+ unremarkable, so if it is ever added, move this to another op the
+ generator does not intend to handle rather than deleting the check.
+ """
+ x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3])
+ y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3])
+ node = helper.make_node("Erf", ["x"], ["y"], name="sm0")
+ m = helper.make_model(helper.make_graph([node], "t", [x], [y]),
+ opset_imports=[helper.make_opsetid("", 13)])
+ m.ir_version = 8
+ path = tmp_path / f"{name}.onnx"
+ onnx.save(m, str(path))
+ return path
def test_generate_writes_all_artifacts(tmp_path, capsys, golden_model):
+ # --no-embed: gemm_small auto-embeds (well under EMBED_THRESHOLD) in both
+ # languages now (Task 4), so this test forces the file-loaded contract to
+ # exercise the .rwt-writing path it asserts on.
onnx_path = golden_model("gemm_small")
rc = main(["generate", str(onnx_path),
- "--lang", "both", "--precision", "double", "--out", str(tmp_path)])
+ "--lang", "both", "--precision", "double", "--out", str(tmp_path), "--no-embed"])
assert rc == 0
- for f in ["gemm_small_model.f90", "gemm_small.c", "gemm_small.h", "gemm_small.rwt"]:
+ for f in ["gemm_small_model.F90", "gemm_small.c", "gemm_small.h", "gemm_small.rwt"]:
assert (tmp_path / f).exists(), f
out = capsys.readouterr().out
assert "gemm_small.rwt" in out
+def test_generate_writes_rwt_only_when_not_embedding(tmp_path, capsys, golden_model):
+ # gemm_small auto-embeds (well under EMBED_THRESHOLD) as of Task 4 in
+ # both languages, so neither --lang both nor --lang c writes a .rwt by
+ # default; --no-embed is what brings it back, regardless of --lang.
+ onnx_path = golden_model("gemm_small")
+
+ rc = main(["generate", str(onnx_path), "--lang", "both", "--out", str(tmp_path / "both")])
+ assert rc == 0
+ assert not (tmp_path / "both" / "gemm_small.rwt").exists()
+
+ rc = main(["generate", str(onnx_path), "--lang", "c", "--out", str(tmp_path / "c")])
+ assert rc == 0
+ assert not (tmp_path / "c" / "gemm_small.rwt").exists()
+ out = capsys.readouterr().out
+ assert "embedded weights" in out
+
+ rc = main(["generate", str(onnx_path), "--lang", "both", "--no-embed",
+ "--out", str(tmp_path / "noembed")])
+ assert rc == 0
+ assert (tmp_path / "noembed" / "gemm_small.rwt").exists()
+
+
+def test_generate_writes_the_fortran_recipe(tmp_path, golden_model):
+ onnx_path = golden_model("gemm_small")
+ rc = main(["generate", str(onnx_path), "--lang", "fortran", "--out", str(tmp_path)])
+ assert rc == 0
+ assert (tmp_path / "gemm_small_model.F90").exists()
+ mk = tmp_path / "gemm_small_fortran.mk"
+ assert mk.exists()
+ # Ruling R13: the Fortran archive is lib_f.a, not lib.a --
+ # the latter is the C recipe's archive, and the two must never collide
+ # when both recipes build in the same directory (see the two-archive
+ # test below, which is what would have caught that defect).
+ assert "libgemm_small_f.a: gemm_small_model.o" in mk.read_text()
+ # The C recipe (a separate file, a separate object) is untouched by a
+ # Fortran-only generate.
+ assert not (tmp_path / "gemm_small.mk").exists()
+
+
+def test_both_recipes_build_distinct_archives_in_one_directory(tmp_path, golden_model):
+ # Controller ruling R13, reproducing the reviewer's finding on 352c14a:
+ # `generate --lang both` writes both .mk (C) and _fortran.mk
+ # (Fortran) into ONE output directory, and both recipes used to archive
+ # into the same lib.a -- `ar rcs` APPENDS, so building both there
+ # in sequence silently merged gemm_small_model.o into gemm_small.a's own
+ # archive, and either recipe's `clean` then deleted the shared file.
+ # This is exactly the scenario the bind(C) interface serves: a Fortran
+ # host that `use`s the module AND links the native CUDA/HIP kernel needs
+ # both archives to coexist, distinctly, in one place.
+ name = "gemm_small"
+ onnx_path = golden_model(name)
+ rc = main(["generate", str(onnx_path), "--lang", "both", "--out", str(tmp_path), "--no-embed"])
+ assert rc == 0
+
+ cc, fc = _cc(), _omp_fc()
+ subprocess.run(["make", "-f", f"{name}.mk", f"CC={cc}"], cwd=tmp_path,
+ check=True, capture_output=True, text=True)
+ subprocess.run(["make", "-f", f"{name}_fortran.mk", f"FC={fc}"], cwd=tmp_path,
+ check=True, capture_output=True, text=True)
+
+ c_archive, f_archive = tmp_path / f"lib{name}.a", tmp_path / f"lib{name}_f.a"
+ assert c_archive.exists() and f_archive.exists()
+ assert c_archive != f_archive
+
+ def _members(archive):
+ # One member per line; BSD ar (macOS) also lists a "__.SYMDEF SORTED"
+ # pseudo-member (its own symbol table) that GNU ar's `ar t` omits --
+ # filter it out rather than split() on whitespace, since its name
+ # itself contains a space.
+ out = subprocess.run(["ar", "t", str(archive)], cwd=tmp_path,
+ check=True, capture_output=True, text=True).stdout
+ return [line for line in out.splitlines() if not line.startswith("__.SYMDEF")]
+
+ # Each archive holds only its own object -- not the other's, and not both
+ # (the merged-archive defect: one .a holding gemm_small_model.o AND
+ # gemm_small.o together, silently, because `ar rcs` appends).
+ assert _members(c_archive) == [f"{name}.o"]
+ assert _members(f_archive) == [f"{name}_model.o"]
+
+ # `clean` on one recipe never touches the other's archive or object.
+ subprocess.run(["make", "-f", f"{name}_fortran.mk", "clean"], cwd=tmp_path,
+ check=True, capture_output=True, text=True)
+ assert not f_archive.exists() and not (tmp_path / f"{name}_model.o").exists()
+ assert c_archive.exists() and (tmp_path / f"{name}.o").exists()
+
+ subprocess.run(["make", "-f", f"{name}.mk", "clean"], cwd=tmp_path,
+ check=True, capture_output=True, text=True)
+ assert not c_archive.exists() and not (tmp_path / f"{name}.o").exists()
+
+
def test_verify_passes_on_a_dense_model(capsys, golden_model):
onnx_path = golden_model("gemm_small")
rc = main(["verify", str(onnx_path), "--cases", "4"])
@@ -56,22 +183,21 @@ def test_verify_reports_compiler_diagnostic_on_a_compile_failure(capsys, live_ge
assert "Error" in err # gfortran's own diagnostic text
-def test_info_reports_unsupported(capsys, golden_model):
- onnx_path = golden_model("mnist")
+def test_info_reports_unsupported(tmp_path, capsys):
+ onnx_path = _unsupported_model(tmp_path)
rc = main(["info", str(onnx_path)])
out = capsys.readouterr().out
assert rc == 1
- # The actual rejection: mnist's node list is not in execution order, and the first
- # unsupported op validate() hits is a Reshape, not a Conv -- assert on the real
- # rejection text, not merely on an op name that _describe_ops would print either way.
- assert "Reshape is not supported" in out
+ # Assert on the real rejection text, not merely on an op name _describe_ops
+ # would print either way.
+ assert "Erf is not supported" in out
# If the rejection branch silently disappeared, build_plan would have to have
# succeeded, and the success branch's bare "supported" line would appear instead.
assert "supported" not in out.splitlines()
-def test_generate_rejects_unsupported_model(tmp_path, capsys, golden_model):
- onnx_path = golden_model("mnist")
+def test_generate_rejects_unsupported_model(tmp_path, capsys):
+ onnx_path = _unsupported_model(tmp_path)
rc = main(["generate", str(onnx_path), "--out", str(tmp_path)])
assert rc == 1
assert "rosenna:" in capsys.readouterr().err
@@ -137,3 +263,35 @@ def test_live_gemm_model_verifies_end_to_end(capsys, live_gemm_model):
out = capsys.readouterr().out
assert rc == 0, out
assert "all-zero" not in out and "FAIL" not in out
+
+
+def test_python_dash_m_runs_the_cli(tmp_path):
+ # A Makefile or CI job without the console script on PATH runs
+ # `python -m rosenna`; `python -m rosenna.cli` used to exit 0 having done
+ # nothing, since cli.py has no __main__ guard.
+ import subprocess, sys
+ r = subprocess.run([sys.executable, "-m", "rosenna", "--help"], capture_output=True, text=True)
+ assert r.returncode == 0 and "generate" in r.stdout, r.stdout + r.stderr
+
+
+# --- from test_cli_smoke.py ------------------------------------------------
+
+def test_help_exits_zero(capsys):
+ with pytest.raises(SystemExit) as e:
+ main(["--help"])
+ assert e.value.code == 0
+ assert "generate" in capsys.readouterr().out
+
+def test_generate_writes_model_file(tmp_path, capsys, golden_model):
+ # --no-embed: gemm_small auto-embeds by default in both languages (Task 4),
+ # which would leave no .rwt to assert on below.
+ onnx_path = golden_model("gemm_small")
+ rc = main(["generate", str(onnx_path), "--lang", "both", "--precision", "double",
+ "--out", str(tmp_path), "--name", "mymodel", "--no-embed"])
+ assert rc == 0
+ assert (tmp_path / "mymodel_model.F90").exists()
+ assert (tmp_path / "mymodel.c").exists()
+ assert (tmp_path / "mymodel.h").exists()
+ assert (tmp_path / "mymodel.rwt").exists()
+ out = capsys.readouterr().out
+ assert "mymodel.rwt" in out
diff --git a/python/tests/test_cli_smoke.py b/python/tests/test_cli_smoke.py
deleted file mode 100644
index ea212f6..0000000
--- a/python/tests/test_cli_smoke.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import pytest
-from rosenna.cli import main
-
-def test_help_exits_zero(capsys):
- with pytest.raises(SystemExit) as e:
- main(["--help"])
- assert e.value.code == 0
- assert "generate" in capsys.readouterr().out
-
-def test_generate_writes_model_file(tmp_path, capsys, golden_model):
- onnx_path = golden_model("gemm_small")
- rc = main(["generate", str(onnx_path), "--lang", "both", "--precision", "double",
- "--out", str(tmp_path), "--name", "mymodel"])
- assert rc == 0
- assert (tmp_path / "mymodel_model.f90").exists()
- assert (tmp_path / "mymodel.c").exists()
- assert (tmp_path / "mymodel.h").exists()
- assert (tmp_path / "mymodel.rwt").exists()
- out = capsys.readouterr().out
- assert "mymodel.rwt" in out
diff --git a/python/tests/test_device_c.py b/python/tests/test_device_c.py
new file mode 100644
index 0000000..8b65231
--- /dev/null
+++ b/python/tests/test_device_c.py
@@ -0,0 +1,218 @@
+import os
+import platform
+import shutil
+import subprocess
+import numpy as np
+import onnxruntime as ort
+import pytest
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.weights import write_weights
+from rosenna.emit_c import emit_c
+from tests.conftest import _assert_warning_free
+from tests.test_emit_fortran import _live_reference
+
+
+def _omp_cc():
+ for cand in ("gcc-15", "gcc-14", "gcc-13", "gcc"):
+ path = shutil.which(cand)
+ if not path:
+ continue
+ probe = subprocess.run([cand, "-fopenmp", "-x", "c", "-", "-o", os.devnull],
+ input="int main(void){return 0;}", capture_output=True, text=True)
+ if probe.returncode == 0:
+ return cand
+ pytest.skip("no C compiler with -fopenmp found")
+
+
+def _write(tmp_path, name, plan, graph):
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source)
+ (tmp_path / f"{name}.h").write_text(header)
+ if not plan.embed:
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+
+
+HOST = """
+#include
+#include "{name}.h"
+int main(void) {{
+ int n; {init}
+ if (scanf("%d", &n) != 1) return 1;
+ double x[64 * {n_in}], y[64 * {n_out}];
+ for (int c = 0; c < n * {n_in}; ++c) if (scanf("%lf", &x[c]) != 1) return 1;
+#ifdef _OPENMP
+ #pragma omp target teams loop map(to: x[0:n*{n_in}]) map(from: y[0:n*{n_out}])
+#endif
+ for (int p = 0; p < n; ++p) {name}_infer(x + p * {n_in}, y + p * {n_out}); /* the host's own region calls the header inline */
+ for (int p = 0; p < n; ++p) {{ for (int i = 0; i < {n_out}; ++i) printf("%.17e ", y[p * {n_out} + i]); printf("\\n"); }}
+ return 0;
+}}
+"""
+
+
+def _build_and_run(tmp_path, name, plan, graph, cc, flags, inputs, env=None):
+ _write(tmp_path, name, plan, graph)
+ init = "" if plan.embed else f'if ({name}_init("{name}.rwt")) return 2;'
+ (tmp_path / "host.c").write_text(HOST.format(name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0], init=init))
+ objs = ["host.c"]
+ if not plan.embed:
+ r = subprocess.run([cc, *flags, "-c", f"{name}.c"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ subprocess.run(["ar", "rcs", f"lib{name}.a", f"{name}.o"], cwd=tmp_path, check=True)
+ objs.append(f"lib{name}.a")
+ r = subprocess.run([cc, *flags, *objs, "-lm", "-o", "host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ stdin = f"{len(inputs)}\n" + " ".join(repr(float(v)) for v in inputs.ravel())
+ return subprocess.run(["./host"], cwd=tmp_path, input=stdin, capture_output=True, text=True, env=env)
+
+
+@pytest.mark.parametrize("name", ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"])
+@pytest.mark.parametrize("embed", [True, False])
+def test_host_region_calls_header_inline_and_matches(tmp_path, golden_model, name, embed):
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64", embed=embed)
+ session = ort.InferenceSession(golden_model(name))
+ inputs, expected = _live_reference(session, session.get_inputs()[0].shape, np.float64, seed=5, batch=8)
+ if inputs is None:
+ pytest.skip(f"{name}: onnxruntime reference is all-zero across 10 resampled "
+ f"batches; its golden-file weights produced a dead model")
+ r = _build_and_run(tmp_path, name, plan, graph, _omp_cc(), ["-O2", "-Wall", "-Wextra", "-std=c11", "-fopenmp"], inputs)
+ assert r.returncode == 0, r.stderr
+ got = np.array([[float(v) for v in line.split()] for line in r.stdout.strip().splitlines()])
+ np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_target_regions_are_real(tmp_path, golden_model):
+ # Two-tier evidence (controller ruling R31). The only evidence here used to be that a
+ # host-only libgomp refuses a target region under OMP_TARGET_OFFLOAD=MANDATORY: if the
+ # pragmas were missing or ignored the program would succeed. That held locally, and this
+ # test itself passed on the macOS CI runner (Homebrew GCC 13.4) too -- but its Fortran twin
+ # (tests/test_device_fortran.py::test_fortran_target_regions_are_real) ran to completion and
+ # returned 0 -- not refused -- on that same runner and compiler. So MANDATORY enforcement is
+ # not portable evidence by itself, even here. The primary, platform-independent assertion is
+ # instead that the compiled host object references GOMP_target_ext: a real
+ # `#pragma omp target` region cannot be compiled without a call to it. The MANDATORY run is
+ # kept as corroborating evidence where libgomp does enforce it, and downgraded to a skip (not
+ # a failure) where it does not, naming the toolchain that let it through so the CI log
+ # records exactly which combination did this.
+ name = "gemm_small"
+ graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64")
+ inputs = np.full((1, plan.input.shape[0]), 0.5)
+ cc = _omp_cc()
+ flags = ["-O2", "-std=c11", "-fopenmp"]
+ r = _build_and_run(tmp_path, name, plan, graph, cc, flags, inputs,
+ env={**os.environ, "OMP_TARGET_OFFLOAD": "MANDATORY"})
+
+ # host.c was written by _build_and_run; compile it standalone (-c) to inspect exactly
+ # what the host's own target region compiled to.
+ obj = subprocess.run([cc, *flags, "-c", "host.c", "-o", "host_check.o"], cwd=tmp_path,
+ capture_output=True, text=True)
+ assert obj.returncode == 0, obj.stderr
+
+ nm = shutil.which("nm")
+ if not nm:
+ pytest.skip("no nm")
+ nm_out = subprocess.run([nm, "-u", "host_check.o"], cwd=tmp_path, capture_output=True, text=True).stdout
+ undefined = {line.split()[-1] for line in nm_out.splitlines() if line.strip()}
+ assert any("GOMP_target_ext" in sym for sym in undefined), nm_out
+
+ if r.returncode == 0:
+ version = subprocess.run([cc, "--version"], capture_output=True, text=True).stdout.splitlines()[0]
+ pytest.skip(f"libgomp did not enforce OMP_TARGET_OFFLOAD=MANDATORY for a C target region "
+ f"on {platform.platform()} with {version}")
+ assert "MANDATORY" in r.stderr, r.stderr
+
+
+def test_plain_compiler_without_openmp_still_matches(tmp_path, golden_model):
+ # Every decoration is inert under a compiler with no -fopenmp and no CUDA; the numbers must not change.
+ name = "gemm_small"
+ graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64")
+ cc = shutil.which("clang") or shutil.which("cc") or _omp_cc()
+ session = ort.InferenceSession(golden_model(name))
+ inputs, expected = _live_reference(session, session.get_inputs()[0].shape, np.float64, seed=6, batch=4)
+ if inputs is None:
+ pytest.skip(f"{name}: onnxruntime reference is all-zero across 10 resampled "
+ f"batches; its golden-file weights produced a dead model")
+ r = _build_and_run(tmp_path, name, plan, graph, cc, ["-O2", "-Wall", "-Wextra", "-std=c11"], inputs)
+ assert r.returncode == 0, r.stderr
+ got = np.array([[float(v) for v in line.split()] for line in r.stdout.strip().splitlines()])
+ np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_header_carries_exactly_the_three_macros(golden_model):
+ from rosenna.emit_c import _CUDA_GUARD, _DEVICE_PASS_GUARD
+ # A CUDA/HIP host must see __host__ __device__ and __constant__; nothing else in the header may mention CUDA.
+ # Controller ruling P2: take the golden path through the golden_model fixture (tests.conftest has no
+ # standalone golden_path function). Controller ruling P3: a third macro, ROSENNA_RESTRICT, sits next to
+ # the two above so `restrict` -- not a keyword once this header reaches a C++ (nvcc) translation unit --
+ # never appears bare in a signature; assert all three macro names are present.
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64")
+ _, header = emit_c(plan)
+ assert header.count("__CUDACC__") == 1 and "__host__ __device__" in header and "__constant__" in header
+ assert "ROSENNA_DEVICE_FN" in header and "ROSENNA_CONST" in header and "ROSENNA_RESTRICT" in header
+ # The file-loaded header mentions __CUDACC__ more often (the rosenna_rt.h
+ # include, the _dev declarations, the __constant__ table and its bind, and
+ # the device-pass guard), but only ever on one of the two guard lines:
+ # every occurrence in the emitted text is accounted for by those two.
+ _, header_f = emit_c(build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False))
+ guard_lines = [l for l in header_f.splitlines() if "__CUDACC__" in l]
+ assert guard_lines and all(l in (_CUDA_GUARD, _DEVICE_PASS_GUARD) for l in guard_lines), guard_lines
+ assert header_f.count("__CUDACC__") == len(guard_lines)
+ assert header_f.count(_CUDA_GUARD) == 5 and header_f.count(_DEVICE_PASS_GUARD) == 1
+
+
+def test_device_pass_guard_needs_the_cuda_compiler_not_just_the_arch(tmp_path, golden_model):
+ # Ruling R23: clang's OpenMP nvptx device pass defines __CUDA_ARCH__ without
+ # __CUDACC__ (reproduced with `clang -cc1 -triple nvptx64-nvidia-cuda
+ # -fopenmp -fopenmp-is-target-device -E -dM`). The device-pass guard must
+ # test the compiler macro together with the arch macro, or ROSENNA_REF_*
+ # selects the __constant__ table, which only the CUDA/HIP guard declares:
+ # an undeclared identifier. Under a plain compiler with __CUDA_ARCH__
+ # forced on, the file-loaded header has to compile and read the host arrays.
+ name = "gemm_big"
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64", embed=False)
+ _write(tmp_path, name, plan, graph)
+ (tmp_path / "host.c").write_text(HOST.format(name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0],
+ init=f'if ({name}_init("{name}.rwt")) return 2;'))
+ cc = shutil.which("clang") or shutil.which("cc") or _omp_cc()
+ flags = ["-O2", "-Wall", "-Wextra", "-std=c11", "-D__CUDA_ARCH__=800"]
+ for src in ("host.c", f"{name}.c"):
+ r = subprocess.run([cc, *flags, "-c", src], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, (src, r.stderr)
+ _assert_warning_free("gcc", r.stderr)
+ pre = subprocess.run([cc, *flags, "-E", "host.c"], cwd=tmp_path, capture_output=True, text=True)
+ assert pre.returncode == 0
+ assert f"{name}_devw" not in pre.stdout and f"{name}_w0[" in pre.stdout
+ # The guard the emitter writes is the compound one, and the arch macro
+ # never stands alone on a guard line.
+ header = (tmp_path / f"{name}.h").read_text()
+ assert ("#if (defined(__CUDACC__) && defined(__CUDA_ARCH__)) || "
+ "(defined(__HIPCC__) && defined(__HIP_DEVICE_COMPILE__))") in header
+ for line in header.splitlines():
+ if "__CUDA_ARCH__" in line and line.startswith("#if"):
+ assert "__CUDACC__" in line, line
+
+
+def test_cuda_hip_guard_ignores_openmp_amdgpu_device_pass_macros(tmp_path, golden_model):
+ # The HIP twin of ruling R23: clang's OpenMP AMDGPU device pass defines
+ # __HIP__ (openmp_wrappers/math.h, to borrow HIP's device math) and
+ # __AMDGCN__, but never __HIPCC__ -- reproduced on an MI210 with
+ # `amdclang -fopenmp --offload-arch=gfx90a`, where a header that accepted
+ # __HIP__ emitted `static __device__ const` into a plain OpenMP host build.
+ # Under a plain compiler with both macros forced on, the header must take
+ # the host branch and compile.
+ name = "gemm_big"
+ graph = load_graph(golden_model(name))
+ _write(tmp_path, name, build_plan(graph, dtype="f64", embed=True), graph)
+ (tmp_path / "host.c").write_text(HOST.format(name=name, n_in=2, n_out=1, init=""))
+ cc = shutil.which("clang") or shutil.which("cc") or _omp_cc()
+ flags = ["-O2", "-Wall", "-Wextra", "-std=c11", "-D__HIP__=1", "-D__AMDGCN__=1"]
+ r = subprocess.run([cc, *flags, "-c", "host.c"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ header = (tmp_path / f"{name}.h").read_text()
+ assert "__HIP__" not in header
diff --git a/python/tests/test_device_fortran.py b/python/tests/test_device_fortran.py
new file mode 100644
index 0000000..84866aa
--- /dev/null
+++ b/python/tests/test_device_fortran.py
@@ -0,0 +1,234 @@
+import os
+import platform
+import shutil
+import subprocess
+import numpy as np
+import onnxruntime as ort
+import pytest
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.weights import write_weights
+from rosenna.emit_fortran import emit_fortran, emit_fortran_recipe
+from tests.conftest import _assert_warning_free
+from tests.test_emit_fortran import _live_reference
+
+
+def _omp_fc():
+ fc = shutil.which("gfortran")
+ if not fc:
+ pytest.skip("no gfortran")
+ probe = subprocess.run([fc, "-fopenmp", "-x", "f95", "-", "-o", os.devnull],
+ input="end\n", capture_output=True, text=True)
+ if probe.returncode != 0:
+ pytest.skip("gfortran without -fopenmp")
+ return fc
+
+
+HOST = """
+program host
+ use {name}_model
+ use iso_fortran_env, only: real64
+ implicit none
+ real(real64) :: x({n_in}, 64), y({n_out}, 64), yb({n_out}, 64)
+ integer :: n, p, status
+ {init_lines}
+ read(*,*) n
+ read(*,*) x(:, 1:n)
+ !$omp target enter data map(to: x) map(alloc: y, yb)
+ !$omp target teams loop
+ do p = 1, n
+ call {name}_infer(x(:, p), y(:, p))
+ end do
+ ! infer_batch's has_device_addr wants the mapped arrays' device addresses,
+ ! which use_device_addr supplies (ruling R5; on this host-only build they
+ ! are the host addresses, so the omission would have been invisible).
+ !$omp target data use_device_addr(x, yb)
+ call {name}_infer_batch(n, x, yb, status)
+ !$omp end target data
+ !$omp target exit data map(from: y, yb) map(delete: x)
+ if (status /= 0) stop 4
+ ! abs(...) > 0, not /=: an exact-bits comparison without tripping
+ ! gfortran's -Wcompare-reals on a bare real (in)equality.
+ if (any(abs(y(:, 1:n) - yb(:, 1:n)) > 0.0_real64)) stop 5
+ do p = 1, n
+ print '({n_out}(es24.16,1x))', y(:, p)
+ end do
+end program
+"""
+
+
+def _build_and_run(tmp_path, name, embed, inputs, golden_model):
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64", embed=embed)
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ (tmp_path / f"{name}_model.F90").write_text(emit_fortran(plan))
+ init_lines = "" if embed else f'call {name}_init("{name}.rwt", status); if (status /= 0) stop 2'
+ if not embed:
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+ (tmp_path / "host.f90").write_text(HOST.format(name=name, n_in=n_in, n_out=n_out, init_lines=init_lines))
+ fc = _omp_fc()
+ flags = ["-O2", "-Wall", "-Wextra", "-std=f2008", "-fopenmp"]
+ r = subprocess.run([fc, *flags, "-c", f"{name}_model.F90"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gfortran", r.stderr)
+ subprocess.run(["ar", "rcs", f"lib{name}.a", f"{name}_model.o"], cwd=tmp_path, check=True)
+ r = subprocess.run([fc, *flags, "host.f90", f"lib{name}.a", "-o", "host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gfortran", r.stderr)
+ stdin = f"{len(inputs)}\n" + "\n".join(" ".join(repr(float(v)) for v in row) for row in inputs)
+ out = subprocess.run(["./host"], cwd=tmp_path, input=stdin, capture_output=True, text=True, check=True).stdout
+ return np.array([[float(v) for v in line.split()] for line in out.strip().splitlines()])
+
+
+@pytest.mark.parametrize("name", ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"])
+@pytest.mark.parametrize("embed", [True, False])
+def test_host_region_calls_module_infer_and_matches(tmp_path, golden_model, name, embed):
+ session = ort.InferenceSession(golden_model(name))
+ inputs, expected = _live_reference(session, session.get_inputs()[0].shape, np.float64, seed=8, batch=8)
+ if inputs is None:
+ pytest.skip(f"{name}: onnxruntime reference is all-zero across 10 resampled "
+ f"batches; its golden-file weights produced a dead model")
+ got = _build_and_run(tmp_path, name, embed, inputs, golden_model)
+ np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_fortran_target_regions_are_real(tmp_path, golden_model):
+ # Two-tier evidence (controller ruling R31). The only evidence here used to be that a
+ # host-only libgomp refuses a target region under OMP_TARGET_OFFLOAD=MANDATORY: if the
+ # pragmas were missing or ignored the program would succeed. That held locally (gfortran
+ # 15) but on the macOS CI runner (Homebrew GCC 13.4, `gfortran` -> `gfortran-13`) this
+ # program ran to completion and returned 0 -- not refused -- while the identical C test
+ # (tests/test_device_c.py::test_target_regions_are_real) passed on that same runner and
+ # compiler. So MANDATORY enforcement is not portable evidence by itself. The primary,
+ # platform-independent assertion is instead that the compiled host object references
+ # GOMP_target_ext: a real `!$omp target` region cannot be compiled without a call to it.
+ # The MANDATORY run is kept as corroborating evidence where libgomp does enforce it, and
+ # downgraded to a skip (not a failure) where it does not, naming the toolchain that let it
+ # through so the CI log records exactly which combination did this. Neither tier compares
+ # against onnxruntime, so a constant input (not _live_reference) is enough, and this test
+ # cannot itself be a dead-model false pass/fail like test_host_region_calls_module_infer_
+ # and_matches above needs to guard against.
+ name = "gemm_small"
+ graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=True)
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ inputs = np.full((1, n_in), 0.5)
+ (tmp_path / f"{name}_model.F90").write_text(emit_fortran(plan))
+ (tmp_path / "host.f90").write_text(HOST.format(name=name, n_in=n_in, n_out=n_out, init_lines=""))
+ fc = _omp_fc()
+ flags = ["-O2", "-std=f2008", "-fopenmp"]
+
+ # Compile the module first (for its .mod) and the host as a standalone object, so the
+ # symbol check below inspects exactly what the host's own target region compiled to.
+ r = subprocess.run([fc, *flags, "-c", f"{name}_model.F90"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ r = subprocess.run([fc, *flags, "-c", "host.f90", "-o", "host.o"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+
+ nm = shutil.which("nm")
+ if not nm:
+ pytest.skip("no nm")
+ nm_out = subprocess.run([nm, "-u", "host.o"], cwd=tmp_path, capture_output=True, text=True).stdout
+ undefined = {line.split()[-1] for line in nm_out.splitlines() if line.strip()}
+ assert any("GOMP_target_ext" in sym for sym in undefined), nm_out
+
+ r = subprocess.run([fc, *flags, "host.o", f"{name}_model.o", "-o", "host"], cwd=tmp_path,
+ capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ stdin = "1\n" + " ".join(repr(float(v)) for v in inputs[0])
+ r = subprocess.run(["./host"], cwd=tmp_path, input=stdin, capture_output=True, text=True,
+ env={**os.environ, "OMP_TARGET_OFFLOAD": "MANDATORY"})
+ if r.returncode == 0:
+ version = subprocess.run([fc, "--version"], capture_output=True, text=True).stdout.splitlines()[0]
+ pytest.skip(f"libgomp did not enforce OMP_TARGET_OFFLOAD=MANDATORY for a Fortran target "
+ f"region on {platform.platform()} with {version}")
+ assert "MANDATORY" in r.stderr, r.stderr
+
+
+def test_generated_fortran_still_fits_in_132_columns(golden_model):
+ for name in ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"]:
+ for embed in (True, False):
+ src = emit_fortran(build_plan(load_graph(golden_model(name)), dtype="f64", embed=embed))
+ longest = max(len(line) for line in src.splitlines())
+ assert longest <= 132, (name, embed, longest)
+
+
+def test_infer_batch_body_never_transfers(golden_model):
+ # Ruling R5, structural check: infer_batch's own body holds no map/copyin/
+ # copyout/update/enter-exit-data token -- init is exempt (it is the plan
+ # step and the only routine that transfers).
+ forbidden = ("map(", "copyin", "copyout", "target update", "update device", "enter data", "exit data")
+ for name in ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"]:
+ for embed in (True, False):
+ plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=embed)
+ src = emit_fortran(plan)
+ start = src.index(f"subroutine {name}_infer_batch(")
+ end = src.index("end subroutine", start)
+ body = src[start:end]
+ for token in forbidden:
+ assert token not in body, (name, embed, token)
+
+
+def test_embedded_module_has_no_init_and_file_loaded_does(golden_model):
+ for embed, expect_init in ((True, False), (False, True)):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=embed)
+ src = emit_fortran(plan)
+ assert ("subroutine gemm_small_init(" in src) == expect_init
+ # Both forms are `protected` module arrays; the embedded one carries
+ # its initializer (see _emit_embedded_weights for why not `parameter`).
+ assert ("real(wp), protected :: w0(2,2) = reshape([" in src) == embed
+ assert ("real(wp), protected :: w0(2,2)\n" in src) == (not embed)
+ assert ("!$acc declare copyin(w0, b0, w1, b1)" in src) == embed
+ assert ("!$acc declare create(w0, b0, w1, b1)" in src) == (not embed)
+
+
+def test_generated_fortran_is_warning_free_under_openacc(tmp_path, golden_model):
+ # Mirrors tests/test_kernel.py::test_generated_c_is_warning_free_under_openacc.
+ # gfortran -fopenacc rejects a `routine seq` function reading a module
+ # array with no `declare` directive of its own, and refuses a `declare`
+ # on a `parameter` array (why _emit_embedded_weights emits initialized
+ # `protected` arrays with `declare copyin`). A real compile (-c), not
+ # -fsyntax-only: the diagnostic comes after the front end and
+ # -fsyntax-only let an uncompilable embedded module through.
+ fc = shutil.which("gfortran")
+ if not fc:
+ pytest.skip("no gfortran")
+ probe = subprocess.run([fc, "-fopenacc", "-x", "f95", "-", "-o", os.devnull],
+ input="end\n", capture_output=True, text=True)
+ if probe.returncode != 0:
+ pytest.skip(f"{fc} does not accept -fopenacc")
+ for name in ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"]:
+ for embed in (True, False):
+ plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=embed)
+ src_path = tmp_path / f"{name}_{embed}_model.F90"
+ src_path.write_text(emit_fortran(plan))
+ r = subprocess.run(
+ [fc, "-O2", "-Wall", "-Wextra", "-std=f2008", "-fopenacc", "-c",
+ src_path.name, "-o", f"{name}_{embed}_model.o"],
+ cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, (name, embed, r.stderr)
+ _assert_warning_free("gfortran", r.stderr)
+
+
+def test_bind_c_interface_targets_the_c_infer_batch_symbol(golden_model):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64")
+ src = emit_fortran(plan)
+ assert 'bind(C, name="gemm_small_infer_batch") result(status)' in src
+ assert "gemm_small_infer_batch_dev" in src
+ assert "type(c_ptr), value :: x, y, stream" in src
+
+
+def test_fortran_recipe_builds_the_library(tmp_path, golden_model):
+ name = "gemm_small"
+ plan = build_plan(load_graph(golden_model(name)), dtype="f64")
+ (tmp_path / f"{name}_model.F90").write_text(emit_fortran(plan))
+ (tmp_path / "Makefile").write_text(emit_fortran_recipe(plan))
+ fc = _omp_fc()
+ subprocess.run(["make", f"FC={fc}"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ # lib_f.a, not lib.a (ruling R13): see
+ # tests/test_cli.py::test_both_recipes_build_distinct_archives_in_one_directory
+ # for why the two names must never collide.
+ assert (tmp_path / f"lib{name}_f.a").exists()
+ assert (tmp_path / f"{name}_model.o").exists()
+ subprocess.run(["make", "clean"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ assert not (tmp_path / f"lib{name}_f.a").exists()
+ assert not (tmp_path / f"{name}_model.o").exists()
diff --git a/python/tests/test_docs.py b/python/tests/test_docs.py
new file mode 100644
index 0000000..4e9378f
--- /dev/null
+++ b/python/tests/test_docs.py
@@ -0,0 +1,92 @@
+import re
+import shutil
+import subprocess
+from pathlib import Path
+import pytest
+from rosenna.abi import STATUS_CODES
+from tests.test_device_c import _omp_cc
+
+README = Path(__file__).resolve().parents[1] / "README.md"
+
+
+def _blocks(lang):
+ text = README.read_text()
+ return re.findall(rf"```{lang}\n(.*?)```", text, re.S)
+
+
+def test_readme_has_every_required_section():
+ text = README.read_text()
+ for heading in ["What you get", "Install", "Generate", "Call it from C", "Call it from Fortran",
+ "Precision", "Status codes", "Verify", "Limits"]:
+ assert f"## {heading}" in text, heading
+
+
+def test_status_table_matches_abi():
+ text = README.read_text()
+ for code, meaning in STATUS_CODES:
+ assert f"| {code} |" in text and meaning in text, (code, meaning)
+
+
+def test_every_shell_command_in_the_readme_parses():
+ # Each ```sh block is a sequence of commands the user is told to run; every one must at least
+ # name a real subcommand or make target, so a renamed flag cannot leave the docs stale.
+ from rosenna.cli import build_parser
+ parser = build_parser()
+ for block in _blocks("sh"):
+ for line in block.strip().splitlines():
+ if line.startswith("rosenna "):
+ args = line.split()[1:]
+ try:
+ parser.parse_args([a for a in args if not a.startswith("<")] or ["--help"])
+ except SystemExit as e:
+ assert e.code == 0, line
+
+
+def test_c_example_in_the_readme_compiles_and_runs(tmp_path, golden_model):
+ # The README's C example is generated against gemm_small and must build and run as printed.
+ from rosenna.cli import main
+ assert main(["generate", str(golden_model("gemm_small")), "--lang", "c", "--precision", "double",
+ "--out", str(tmp_path), "--name", "model"]) == 0
+ (blocks,) = [b for b in _blocks("c") if "model_infer(" in b][:1] or [None]
+ assert blocks, "README has no C example calling model_infer"
+ (tmp_path / "host.c").write_text(blocks)
+ cc = _omp_cc()
+ subprocess.run(["make", "-f", "model.mk", f"CC={cc}", "ROSENNA_OFFLOAD_FLAGS=-fopenmp"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ r = subprocess.run([cc, "-O2", "-std=c11", "-fopenmp", "host.c", "-L.", "-lmodel", "-lm", "-o", "host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ assert subprocess.run(["./host"], cwd=tmp_path, capture_output=True, text=True).returncode == 0
+
+
+def test_fortran_example_in_the_readme_compiles_and_runs(tmp_path, golden_model):
+ from rosenna.cli import main
+ assert main(["generate", str(golden_model("gemm_small")), "--lang", "fortran", "--precision", "double",
+ "--out", str(tmp_path), "--name", "model"]) == 0
+ (blocks,) = [b for b in _blocks("fortran") if "model_infer(" in b][:1] or [None]
+ assert blocks, "README has no Fortran example calling model_infer"
+ (tmp_path / "host.f90").write_text(blocks)
+ fc = shutil.which("gfortran") or pytest.skip("no gfortran")
+ subprocess.run(["make", "-f", "model_fortran.mk", f"FC={fc}", "ROSENNA_OFFLOAD_FLAGS=-fopenmp"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ # --lang fortran writes libmodel_f.a (model_fortran.mk), not libmodel.a: --lang
+ # c/both's C recipe (model.mk -> libmodel.a) is not generated by this call, so the
+ # link line below names the archive the Fortran recipe actually built.
+ r = subprocess.run([fc, "-O2", "-std=f2008", "-fopenmp", "-I.", "host.f90", "-L.", "-lmodel_f", "-o", "host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ assert subprocess.run(["./host"], cwd=tmp_path, capture_output=True, text=True).returncode == 0
+
+
+def test_the_coverage_badge_states_the_floor_ci_actually_enforces(repo_root):
+ """A badge that drifts from the floor is worse than no badge.
+
+ The README advertises a lower bound rather than a snapshot, because the
+ bound is what CI guarantees -- so it only has to change when the floor
+ does, and this test is what makes sure it does change then.
+ """
+ import re
+ floor = re.search(r"^fail_under = (\d+)", (repo_root / "python" / "pyproject.toml").read_text(),
+ re.M)
+ assert floor, "pyproject.toml no longer sets a coverage floor"
+ readme = (repo_root / "README.md").read_text()
+ badge = re.search(r"img\.shields\.io/badge/coverage-%E2%89%A5(\d+)%25", readme)
+ assert badge, "README has no coverage badge"
+ assert badge.group(1) == floor.group(1), (
+ f"the badge says >={badge.group(1)}% but pyproject enforces {floor.group(1)}%")
diff --git a/python/tests/test_embed.py b/python/tests/test_embed.py
new file mode 100644
index 0000000..445e3cc
--- /dev/null
+++ b/python/tests/test_embed.py
@@ -0,0 +1,39 @@
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan, EMBED_THRESHOLD
+from rosenna.emit_c import emit_c
+
+
+def test_small_model_embeds_by_default(golden_model):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64")
+ assert plan.embed is True and plan.n_params < EMBED_THRESHOLD
+ source, header = emit_c(plan)
+ assert "ROSENNA_CONST double w0[4] = {" not in header # symbols carry the model prefix (ruling R1)
+ assert "ROSENNA_CONST double gemm_small_w0[4] = {" in header
+ assert "_init(" not in header and "fopen" not in source
+
+
+def test_no_embed_flag_keeps_the_file_path(golden_model):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ assert "_init(" in header and "fopen" in source
+
+
+def test_embed_changes_the_hash(golden_model):
+ g = load_graph(golden_model("gemm_small"))
+ assert build_plan(g, dtype="f64", embed=True).hash() != build_plan(g, dtype="f64", embed=False).hash()
+
+
+def test_embedded_and_file_loaded_agree_exactly(tmp_path, golden_model):
+ # Embedding prints every weight at full precision; the two builds must produce identical bits.
+ from tests.test_device_c import _build_and_run, _omp_cc
+ import numpy as np
+ name = "gemm_big"; graph = load_graph(golden_model(name))
+ inputs = np.random.default_rng(11).uniform(-2, 2, (8, build_plan(graph).input.shape[0]))
+ outs = []
+ for embed in (True, False):
+ d = tmp_path / ("e" if embed else "f"); d.mkdir()
+ r = _build_and_run(d, name, build_plan(graph, dtype="f64", embed=embed), graph, _omp_cc(),
+ ["-O2", "-std=c11", "-fopenmp"], inputs)
+ assert r.returncode == 0, r.stderr
+ outs.append(r.stdout)
+ assert outs[0] == outs[1]
diff --git a/python/tests/test_emit_c.py b/python/tests/test_emit_c.py
index 28987c5..4abe0ef 100644
--- a/python/tests/test_emit_c.py
+++ b/python/tests/test_emit_c.py
@@ -12,8 +12,11 @@
def _build_and_run(tmp_path, onnx_path, name, inputs, dtype="f64"):
+ # embed=False: this helper's driver always calls `_init` against a
+ # written .rwt file, the file-loaded contract. The dedicated embed=True/
+ # False matrix lives in tests/test_device_c.py and tests/test_embed.py.
graph = load_graph(onnx_path)
- plan = build_plan(graph, dtype=dtype)
+ plan = build_plan(graph, dtype=dtype, embed=False)
source, header = emit_c(plan)
(tmp_path / f"{name}.c").write_text(source)
(tmp_path / f"{name}.h").write_text(header)
@@ -83,22 +86,29 @@ def test_matches_onnxruntime_f32(tmp_path, golden_model):
def test_infer_is_pure_and_has_literal_bounds(golden_model):
plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64")
source, header = emit_c(plan)
- assert "void gemm_small_infer(const double *restrict x, double *restrict y) {" in source
+ # `infer` is now defined only in the header (a static inline callable
+ # from inside the host's own offload region); the source never defines
+ # it.
+ assert ("static inline ROSENNA_DEVICE_FN void gemm_small_infer("
+ "const double *ROSENNA_RESTRICT x, double *ROSENNA_RESTRICT y) {") in header
# The scratch buffers come from plan.buffers now (ruling R13), not from a
# second allocator private to this emitter: gemm_small's t0 is reused by
# both gemms, so the plan sizes it at the larger of the two (3), and the
# Fortran backend declares exactly the same set.
- assert "double t0[3];" in source
- assert "double t1[2];" in source
+ assert "double t0[3];" in header
+ assert "double t1[2];" in header
assert "malloc" not in source
+ assert "malloc" not in header
assert "restrict" in header
def test_init_rejects_a_foreign_weights_file(tmp_path, golden_model):
+ # embed=False: this test is specifically about `_init`, which an
+ # embedded plan's header does not declare.
graph = load_graph(golden_model("gemm_small"))
- plan = build_plan(graph, dtype="f64")
+ plan = build_plan(graph, dtype="f64", embed=False)
other_graph = load_graph(golden_model("gemm_big"))
- other_plan = build_plan(other_graph, dtype="f64")
+ other_plan = build_plan(other_graph, dtype="f64", embed=False)
source, header = emit_c(plan)
(tmp_path / "gemm_small.c").write_text(source)
(tmp_path / "gemm_small.h").write_text(header)
@@ -138,11 +148,11 @@ def test_both_backends_agree(tmp_path, golden_model):
def test_f32_plan_uses_single_precision_math(golden_model):
"""An f32 build must call tanhf/expf, not promote every activation to double."""
plan = build_plan(load_graph(golden_model("gemm_big")), dtype="f32")
- source, _ = emit_c(plan)
- assert "tanhf(" in source
- assert "expf(" in source
- assert "0.0f" in source
- body = "\n".join(l for l in source.splitlines() if "_infer" not in l)
+ _, header = emit_c(plan)
+ assert "tanhf(" in header
+ assert "expf(" in header
+ assert "0.0f" in header
+ body = "\n".join(l for l in header.splitlines() if "_infer" not in l)
assert " tanh(" not in body and "=tanh(" not in body
assert " exp(" not in body and "(exp(" not in body
diff --git a/python/tests/test_emit_fortran.py b/python/tests/test_emit_fortran.py
index 509e200..c22cffe 100644
--- a/python/tests/test_emit_fortran.py
+++ b/python/tests/test_emit_fortran.py
@@ -6,14 +6,22 @@
from rosenna.plan import build_plan
from rosenna.weights import write_weights
from rosenna.emit_fortran import emit_fortran
+# _live_reference now lives in rosenna/verify.py (rosenna/gate.py needs it too,
+# and cannot import test code); re-exported here under its original name so
+# every existing `from tests.test_emit_fortran import _live_reference` keeps
+# working unchanged.
+from rosenna.verify import _live_reference
DENSE = ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"]
def _build_and_run(tmp_path, onnx_path, name, inputs, dtype="f64"):
+ # embed=False: this helper's driver always calls `_init` against a
+ # written .rwt file, the file-loaded contract. The dedicated embed=True/
+ # False matrix lives in tests/test_device_fortran.py and tests/test_embed.py.
graph = load_graph(onnx_path)
- plan = build_plan(graph, dtype=dtype)
- (tmp_path / f"{name}_model.f90").write_text(emit_fortran(plan))
+ plan = build_plan(graph, dtype=dtype, embed=False)
+ (tmp_path / f"{name}_model.F90").write_text(emit_fortran(plan))
write_weights(plan, graph, tmp_path / f"{name}.rwt")
n_in, n_out = plan.input.shape[0], plan.output.shape[0]
real_kind = "real64" if dtype == "f64" else "real32"
@@ -38,7 +46,7 @@ def _build_and_run(tmp_path, onnx_path, name, inputs, dtype="f64"):
end program
""")
subprocess.run(["gfortran", "-O2", "-Wall", "-Wextra", "-o", "run",
- f"{name}_model.f90", "main.f90"], cwd=tmp_path, check=True,
+ f"{name}_model.F90", "main.f90"], cwd=tmp_path, check=True,
capture_output=True, text=True)
stdin = f"{len(inputs)}\n" + "\n".join(" ".join(repr(float(v)) for v in row) for row in inputs)
out = subprocess.run(["./run"], cwd=tmp_path, input=stdin, capture_output=True,
@@ -46,35 +54,6 @@ def _build_and_run(tmp_path, onnx_path, name, inputs, dtype="f64"):
return np.array([[float(v) for v in line.split()] for line in out.strip().splitlines()])
-def _live_reference(session, shape, dtype, seed=0, batch=8, max_attempts=10):
- """Resample input batches until the onnxruntime reference itself is alive.
-
- Non-degeneracy is a property of the randomly generated fixture, not of
- the code under test: several golden models (e.g. gemm_small) have no
- manual_seed, so their weights differ on every regeneration, and an
- all-zero reference (a dead model, e.g. every pre-activation negative
- into a final ReLU) is a property of that draw of weights -- correct
- generated code reproducing a dead model must *also* be all zero, so no
- assertion on our own output can tell the two cases apart. The fix
- belongs here, on the reference, before we ever build or run anything.
-
- Returns (inputs, expected) for the first batch whose reference has at
- least two non-zero values across the whole batch, or (None, None) if
- max_attempts batches all came back dead.
- """
- rng = np.random.default_rng(seed)
- for _ in range(max_attempts):
- inputs = rng.uniform(-2, 2, (batch, int(np.prod(shape)))).astype(dtype)
- expected = np.array([
- session.run(None, {session.get_inputs()[0].name:
- row.reshape(shape).astype(np.float32)})[0].ravel()
- for row in inputs
- ])
- if np.count_nonzero(expected) >= 2:
- return inputs, expected
- return None, None
-
-
@pytest.mark.parametrize("name", DENSE)
def test_matches_onnxruntime(tmp_path, golden_model, name):
onnx_path = golden_model(name)
@@ -120,7 +99,10 @@ def test_matches_onnxruntime_f32(tmp_path, golden_model):
def test_infer_is_pure_and_has_literal_bounds(golden_model):
- plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64")
+ # embed=False: this test is specifically about the file-loaded contract
+ # (`protected` module variables filled by `init`), which an embedded
+ # plan's module does not declare.
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False)
src = emit_fortran(plan)
assert "pure subroutine gemm_small_infer" in src
assert "real(wp), protected :: w0(2,2)" in src
@@ -128,11 +110,13 @@ def test_infer_is_pure_and_has_literal_bounds(golden_model):
def test_init_rejects_a_foreign_weights_file(tmp_path, golden_model):
+ # embed=False: this test is specifically about `_init`, which an
+ # embedded plan's module does not declare.
graph = load_graph(golden_model("gemm_small"))
- plan = build_plan(graph, dtype="f64")
+ plan = build_plan(graph, dtype="f64", embed=False)
other_graph = load_graph(golden_model("gemm_big"))
- other_plan = build_plan(other_graph, dtype="f64")
- (tmp_path / "gemm_small_model.f90").write_text(emit_fortran(plan))
+ other_plan = build_plan(other_graph, dtype="f64", embed=False)
+ (tmp_path / "gemm_small_model.F90").write_text(emit_fortran(plan))
write_weights(other_plan, other_graph, tmp_path / "gemm_small.rwt")
(tmp_path / "main.f90").write_text("""
program main
@@ -143,7 +127,7 @@ def test_init_rejects_a_foreign_weights_file(tmp_path, golden_model):
print *, status
end program
""")
- subprocess.run(["gfortran", "-O2", "-o", "run", "gemm_small_model.f90", "main.f90"],
+ subprocess.run(["gfortran", "-O2", "-o", "run", "gemm_small_model.F90", "main.f90"],
cwd=tmp_path, check=True, capture_output=True, text=True)
out = subprocess.run(["./run"], cwd=tmp_path, capture_output=True, text=True, check=True)
assert out.stdout.split() == ["6"] # plan hash mismatch
@@ -162,8 +146,10 @@ def test_case_labels_escape_quotes(tmp_path):
m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 13)])
path = tmp_path / "quoted.onnx"
onnx.save(m, path)
- src = emit_fortran(build_plan(load_graph(path), dtype="f64"))
+ # embed=False: the case-label select lives in `load_tensor`, which an
+ # embedded plan's module does not emit.
+ src = emit_fortran(build_plan(load_graph(path), dtype="f64", embed=False))
assert "case ('layer.0''weight')" in src
- (tmp_path / "quoted_model.f90").write_text(src)
- subprocess.run(["gfortran", "-O2", "-Wall", "-Wextra", "-c", "quoted_model.f90"],
+ (tmp_path / "quoted_model.F90").write_text(src)
+ subprocess.run(["gfortran", "-O2", "-Wall", "-Wextra", "-c", "quoted_model.F90"],
cwd=tmp_path, check=True, capture_output=True, text=True)
diff --git a/python/tests/test_examples.py b/python/tests/test_examples.py
new file mode 100644
index 0000000..7aa210d
--- /dev/null
+++ b/python/tests/test_examples.py
@@ -0,0 +1,227 @@
+"""The surrogate examples build and run on the host toolchain, at their small sizes.
+
+Each example is a solver plus a surrogate that prints an error and exits
+non-zero if the surrogate failed to do what its README claims, so `make`
+succeeding is the assertion. The trained models are checked in; this
+generates, compiles (gcc / gfortran, -fopenmp on the host) and runs.
+"""
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from tests.test_device_c import _omp_cc
+
+EXAMPLES = ["burgers_closure", "reaction_patch", "bubble_lstm", "poisson_guess"]
+ROOT = Path(__file__).resolve().parents[2] / "examples" / "surrogates"
+
+
+@pytest.mark.parametrize("example", EXAMPLES)
+def test_example_builds_and_runs_on_the_host(example, tmp_path):
+ cc = _omp_cc() # a C compiler that takes -fopenmp (gcc-15.. on macOS)
+ for tool in ("gfortran", "make"):
+ if not shutil.which(tool):
+ pytest.skip(f"no {tool}")
+ work = tmp_path / example
+ shutil.copytree(ROOT / example, work, ignore=shutil.ignore_patterns("gen", "*.rwt"))
+ shutil.copy(ROOT / "common.mk", tmp_path / "common.mk")
+ # One thread, deliberately. These examples step a small field, so each
+ # OpenMP region is tiny and thread coordination dominates: on this machine
+ # poisson_guess takes 406 ms on one thread and 6.4 s on four, and with
+ # OMP_NUM_THREADS unset (all cores) 145 s. macOS runners have the same
+ # shape of problem worse -- Homebrew libgomp wakes threads slowly -- and
+ # four different examples have timed out there in turn, each "fixed" by
+ # shrinking its step count, which was treating the symptom.
+ #
+ # The test asserts the surrogate computes what its README claims, not that
+ # it is fast, so the thread count is free to be whatever runs cleanest.
+ env = {**os.environ, "OMP_NUM_THREADS": "1"}
+ # NSTEPS=3 for poisson_guess: 20 steps is ~100k OpenMP regions, which a
+ # macOS runner's libgomp takes minutes to wake threads for.
+ r = subprocess.run(["make", "-s", "TOOLCHAIN=gnu", "NB=4", "NX=64", "NSTEPS=3", f"CC={cc}",
+ f"ROSENNA={sys.executable} -m rosenna"],
+ cwd=work, env=env, capture_output=True, text=True, timeout=900)
+ assert r.returncode == 0, r.stdout[-3000:] + r.stderr[-3000:]
+ assert "OK" in r.stdout, r.stdout
+
+
+# --- the GPU toolchains ---------------------------------------------------
+#
+# TOOLCHAIN=nvidia and amd were not covered by anything until three of the four
+# examples turned out not to link under nvidia at all: the branch had never
+# been run. The host test above cannot catch that -- it builds with gcc.
+#
+# These run the same examples through the real offload compilers, and under
+# OMP_TARGET_OFFLOAD=MANDATORY (common.mk sets it for every non-gnu
+# toolchain), so a silent fall back to the host fails instead of passing.
+
+def _nvidia_arch():
+ """`ccXY` for the installed GPU, or None if there is no usable NVIDIA GPU."""
+ if not all(shutil.which(t) for t in ("nvc", "nvfortran", "nvcc", "nvidia-smi")):
+ return None
+ r = subprocess.run(["nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader"],
+ capture_output=True, text=True)
+ caps = [l.strip() for l in r.stdout.splitlines() if l.strip()]
+ if r.returncode != 0 or not caps:
+ return None
+ return "cc" + caps[0].replace(".", "")
+
+
+def _amd_arch():
+ """The first `gfx...` target, or None if there is no usable AMD GPU."""
+ if not all(shutil.which(t) for t in ("amdclang", "amdflang", "hipcc", "rocminfo")):
+ return None
+ r = subprocess.run(["rocminfo"], capture_output=True, text=True)
+ for tok in r.stdout.split():
+ if tok.startswith("gfx"):
+ return tok.strip(":")
+ return None
+
+
+GPU_TOOLCHAINS = [
+ pytest.param("nvidia", _nvidia_arch,
+ marks=pytest.mark.skipif(_nvidia_arch() is None,
+ reason="no nvc/nvfortran/nvcc + NVIDIA GPU")),
+ pytest.param("amd", _amd_arch,
+ marks=pytest.mark.skipif(_amd_arch() is None,
+ reason="no amdclang/amdflang/hipcc + AMD GPU")),
+]
+
+
+@pytest.mark.parametrize("toolchain,arch", GPU_TOOLCHAINS)
+@pytest.mark.parametrize("example", EXAMPLES)
+def test_example_builds_and_runs_on_a_gpu(example, toolchain, arch, tmp_path):
+ if not shutil.which("make"):
+ pytest.skip("no make")
+ work = tmp_path / example
+ shutil.copytree(ROOT / example, work, ignore=shutil.ignore_patterns("gen", "*.rwt"))
+ shutil.copy(ROOT / "common.mk", tmp_path / "common.mk")
+ # Small sizes: this is a "does the toolchain link and run" check, not a
+ # benchmark. The sizes the READMEs quote are what run_all.sh uses.
+ r = subprocess.run(["make", "-s", f"TOOLCHAIN={toolchain}", f"ARCH={arch()}",
+ "NB=4", "NX=64", "NSTEPS=3",
+ f"ROSENNA={sys.executable} -m rosenna"],
+ cwd=work, env=os.environ, capture_output=True, text=True, timeout=1800)
+ assert r.returncode == 0, r.stdout[-3000:] + r.stderr[-3000:]
+ assert "OK" in r.stdout, r.stdout
+
+
+# --- the hello-world path -------------------------------------------------
+#
+# `examples/run_basic.sh` is the shortest path through the whole tool and the
+# first thing a reader runs: export gemm_small, generate both backends, call
+# the result from C (`cAPI.c`) and from Fortran (`capiTester.f90`), then verify
+# against onnxruntime. Nothing ran it. Both callers are hand-written against
+# the generated API -- the header's signature, the module name, whether an
+# `_init` is needed -- so a change to what `generate` emits would leave them
+# stale with only a reader to notice.
+#
+# The test drives the script itself rather than a copy of its commands, which
+# is the point: a copy would keep passing after the script rotted.
+
+def test_the_basic_example_script_runs_both_callers(tmp_path):
+ cc = _omp_cc()
+ for tool in ("gfortran", "bash"):
+ if not shutil.which(tool):
+ pytest.skip(f"no {tool}")
+ script = ROOT.parent / "run_basic.sh"
+ # A private goldenFiles: the script regenerates gemm_small.onnx with an
+ # unseeded generator, so pointed at the repository's tree it rewrites a
+ # file the golden-suite tests read. Harmless in a serial run that happens
+ # to order them favourably, a flake under -n.
+ golden = tmp_path / "goldenFiles"
+ shutil.copytree(ROOT.parents[1] / "goldenFiles" / "gemm_small", golden / "gemm_small")
+ env = {**os.environ, "PYTHON": sys.executable, "GOLDEN_DIR": str(golden),
+ "ROSENNA": f"{sys.executable} -m rosenna", "CC": cc}
+ r = subprocess.run(["bash", str(script)], env=env,
+ capture_output=True, text=True, timeout=900)
+ assert r.returncode == 0, r.stdout[-3000:] + r.stderr[-3000:]
+
+ # Both callers feed the same input to the same model, so their output is
+ # the same three numbers. The script's own `verify` step is what checks
+ # those numbers against onnxruntime; this checks that the two hand-written
+ # callers agree, which verify never sees.
+ printed = {}
+ for line in r.stdout.splitlines():
+ for label in ("C:", "Fortran:"):
+ if line.startswith(label):
+ printed[label] = [float(v) for v in line[len(label):].split()]
+ assert set(printed) == {"C:", "Fortran:"}, r.stdout
+ assert len(printed["C:"]) == 3, r.stdout
+ # Fortran's f0.6 drops the leading zero (".535685"); float() reads both.
+ assert printed["C:"] == printed["Fortran:"], r.stdout
+
+ # And the script's last step, which is the one that cannot come out
+ # vacuous: gemm_small is re-exported unseeded every run and sometimes
+ # returns all zeros, so "the two callers agree" can be 0 == 0. `verify`
+ # reports a line per language ending in `ok` or `FAIL`.
+ verdicts = {line.split()[0]: line.split()[-1]
+ for line in r.stdout.splitlines()
+ if line.split()[:1] in (["c"], ["fortran"])}
+ assert verdicts == {"c": "ok", "fortran": "ok"}, r.stdout
+
+
+# --- the compressible-NS example ------------------------------------------
+#
+# cns_closure is not one of the four surrogates: it is C only and lives beside
+# them, so it needs its own parametrization rather than a fifth EXAMPLES entry.
+# It replaced a patch.md written against a solver this repository does not
+# contain, which could not be compiled or tested at all -- the whole point of
+# vendoring the solver is that this test can exist.
+#
+# BATCHED=1 is the variant that links lib.a and calls the native
+# batched kernel; it asserts, inside the program, that the batched path and
+# the header-inline per-point path produce the same nut field.
+
+CNS = Path(__file__).resolve().parents[2] / "examples" / "cns_closure"
+
+
+def _run_cns(work, tmp_path, extra, timeout):
+ shutil.copytree(CNS, work, ignore=shutil.ignore_patterns("gen", "*.rwt", "cns_c"))
+ (tmp_path / "surrogates").mkdir(exist_ok=True)
+ shutil.copy(CNS.parent / "surrogates" / "common.mk", tmp_path / "surrogates" / "common.mk")
+ env = {**os.environ, "OMP_NUM_THREADS": "1"}
+ r = subprocess.run(["make", "-s", "NX=16", "NSTEPS=5",
+ f"ROSENNA={sys.executable} -m rosenna"] + extra,
+ cwd=work, env=env, capture_output=True, text=True, timeout=timeout)
+ assert r.returncode == 0, r.stdout[-3000:] + r.stderr[-3000:]
+ assert "OK" in r.stdout, r.stdout
+ return r.stdout
+
+
+# "plain" is NO_CLOSURE=1: no network at all, the baseline the closure's cost
+# is measured against. It is a build variant nothing else compiles, so without
+# it here the #ifdefs would rot unnoticed.
+@pytest.mark.parametrize("variant", ["per_point", "batched", "plain"])
+def test_cns_closure_builds_and_runs_on_the_host(variant, tmp_path):
+ cc = _omp_cc()
+ if not shutil.which("make"):
+ pytest.skip("no make")
+ extra = ["TOOLCHAIN=gnu", f"CC={cc}"]
+ if variant == "batched":
+ extra.append("BATCHED=1")
+ elif variant == "plain":
+ extra.append("NO_CLOSURE=1")
+ out = _run_cns(tmp_path / "cns_closure", tmp_path, extra, 900)
+ # The solver's own conservation and closure-agreement checks are what make
+ # `OK` mean something; assert the lines are actually there, so a future
+ # `OK` printed by a stripped-down main cannot pass silently.
+ assert "mass drift" in out and "kinetic energy" in out, out
+ if variant == "plain":
+ assert "closure none" in out, out
+ assert "nut vs host" not in out, "no closure means no comparison to report"
+ else:
+ assert "closure nut vs host evaluation" in out, out
+ if variant == "batched":
+ assert "batched vs per-point" in out, out
+
+
+@pytest.mark.parametrize("toolchain,arch", GPU_TOOLCHAINS)
+def test_cns_closure_builds_and_runs_on_a_gpu(toolchain, arch, tmp_path):
+ if not shutil.which("make"):
+ pytest.skip("no make")
+ _run_cns(tmp_path / "cns_closure", tmp_path,
+ [f"TOOLCHAIN={toolchain}", f"ARCH={arch()}", "BATCHED=1"], 1800)
diff --git a/python/tests/test_fold.py b/python/tests/test_fold.py
new file mode 100644
index 0000000..a89d223
--- /dev/null
+++ b/python/tests/test_fold.py
@@ -0,0 +1,249 @@
+"""The constant-folding evaluators, exercised directly.
+
+`fold.py` runs at generation time on nodes whose inputs are all constants, so
+nothing it computes is ever checked against onnxruntime -- a wrong fold is a
+wrong *weight*, and every later comparison agrees with it. The golden models
+reach only the two shapes a PyTorch export happens to emit (a `Reshape` of a
+weight, a `Constant`), which left most of this file untested.
+
+These call the evaluators on arrays whose right answer is obvious by
+inspection, and pin the refusals for the shapes it cannot fold.
+"""
+import numpy as np
+import pytest
+
+from rosenna.errors import UnsupportedModel
+from rosenna.fold import _evaluate, _resolve_shape, fold_constants
+from rosenna.frontend import Graph, Node, Tensor
+
+
+def _node(op, inputs, outputs=("out",), **attrs):
+ return Node(op, f"{op.lower()}0", tuple(inputs), tuple(outputs), attrs)
+
+
+# --- Reshape target semantics ---------------------------------------------
+
+def test_resolve_shape_copies_a_zero_from_the_input():
+ assert _resolve_shape([0, 3], (4, 6)) == (4, 3)
+
+
+def test_resolve_shape_infers_a_single_minus_one():
+ assert _resolve_shape([-1, 3], (4, 6)) == (8, 3)
+ assert _resolve_shape([2, -1], (4, 6)) == (2, 12)
+
+
+def test_resolve_shape_refuses_a_zero_with_no_matching_axis():
+ with pytest.raises(UnsupportedModel, match="no matching input axis"):
+ _resolve_shape([3, 0], (4,))
+
+
+def test_resolve_shape_refuses_two_inferred_axes():
+ with pytest.raises(UnsupportedModel, match="at most one -1"):
+ _resolve_shape([-1, -1], (4, 6))
+
+
+def test_resolve_shape_refuses_an_indivisible_inference():
+ with pytest.raises(UnsupportedModel, match="cannot infer"):
+ _resolve_shape([-1, 5], (4, 6))
+
+
+# --- the evaluators -------------------------------------------------------
+
+def test_identity_returns_its_input():
+ a = np.arange(6.0).reshape(2, 3)
+ assert np.array_equal(_evaluate(_node("Identity", ["a"]), {"a": a}), a)
+
+
+def test_reshape_uses_the_constant_shape():
+ a = np.arange(12.0).reshape(3, 4)
+ inits = {"a": a, "s": np.array([2, 6], np.int64)}
+ assert _evaluate(_node("Reshape", ["a", "s"]), inits).shape == (2, 6)
+
+
+def test_reshape_refuses_a_runtime_shape():
+ with pytest.raises(UnsupportedModel, match="needs a constant shape"):
+ _evaluate(_node("Reshape", ["a"]), {"a": np.zeros((2, 2))})
+
+
+def test_transpose_honours_perm_and_defaults_to_reversing():
+ a = np.arange(24.0).reshape(2, 3, 4)
+ assert _evaluate(_node("Transpose", ["a"], perm=(1, 0, 2)), {"a": a}).shape == (3, 2, 4)
+ assert _evaluate(_node("Transpose", ["a"]), {"a": a}).shape == (4, 3, 2)
+
+
+def test_squeeze_takes_axes_from_the_attribute_or_from_input_one():
+ a = np.zeros((1, 3, 1, 4))
+ assert _evaluate(_node("Squeeze", ["a"], axes=(0, 2)), {"a": a}).shape == (3, 4)
+ inits = {"a": a, "ax": np.array([0], np.int64)}
+ assert _evaluate(_node("Squeeze", ["a", "ax"]), inits).shape == (3, 1, 4)
+ # No axes at all: every length-1 axis goes.
+ assert _evaluate(_node("Squeeze", ["a"]), {"a": a}).shape == (3, 4)
+
+
+def test_squeeze_refuses_runtime_axes():
+ with pytest.raises(UnsupportedModel, match="axes must be constant"):
+ _evaluate(_node("Squeeze", ["a", "runtime"]), {"a": np.zeros((1, 3))})
+
+
+def test_unsqueeze_inserts_each_axis():
+ a = np.zeros((3, 4))
+ inits = {"a": a, "ax": np.array([0], np.int64)}
+ assert _evaluate(_node("Unsqueeze", ["a", "ax"]), inits).shape == (1, 3, 4)
+
+
+def test_flatten_splits_at_its_axis():
+ a = np.arange(24.0).reshape(2, 3, 4)
+ assert _evaluate(_node("Flatten", ["a"], axis=1), {"a": a}).shape == (2, 12)
+ assert _evaluate(_node("Flatten", ["a"], axis=2), {"a": a}).shape == (6, 4)
+ # axis 0 means "one row", not "no rows".
+ assert _evaluate(_node("Flatten", ["a"], axis=0), {"a": a}).shape == (1, 24)
+
+
+def test_concat_of_constants_folds():
+ inits = {"a": np.zeros((2, 3)), "b": np.ones((2, 5))}
+ assert _evaluate(_node("Concat", ["a", "b"], axis=1), inits).shape == (2, 8)
+
+
+# --- the pass itself ------------------------------------------------------
+
+def test_folding_removes_the_node_and_its_now_unused_shape_tensor():
+ a = np.arange(12.0, dtype=np.float32).reshape(3, 4)
+ graph = Graph(
+ "g",
+ (_node("Reshape", ["a", "s"], ("folded",)),
+ Node("Relu", "r0", ("folded",), ("y",), {})),
+ {"folded": Tensor("folded", (2, 6), "f32"), "y": Tensor("y", (2, 6), "f32")},
+ {"a": a, "s": np.array([2, 6], np.int64)},
+ (), ("y",))
+ out = fold_constants(graph)
+ assert [n.op for n in out.nodes] == ["Relu"], "the Reshape should be gone"
+ assert out.initializers["folded"].shape == (2, 6)
+ assert "folded" not in out.values, "a folded value is an initializer, not a value"
+ assert "s" not in out.initializers, "the int64 shape tensor is unreferenced now"
+
+
+def test_a_constant_node_without_a_readable_value_is_refused():
+ graph = Graph("g", (Node("Constant", "c0", (), ("v",), {}),),
+ {"v": Tensor("v", (1,), "f32")}, {}, (), ("v",))
+ with pytest.raises(UnsupportedModel, match="Constant without a value"):
+ fold_constants(graph)
+
+
+def test_a_constant_node_folds_from_each_value_attribute():
+ for attr, payload in (("value", np.array([1.0, 2.0], np.float32)),
+ ("value_floats", (1.0, 2.0)),
+ ("value_ints", (1, 2))):
+ graph = Graph("g", (Node("Constant", "c0", (), ("v",), {attr: payload}),),
+ {"v": Tensor("v", (2,), "f32")}, {}, (), ("v",))
+ out = fold_constants(graph)
+ assert out.nodes == (), f"{attr}: the Constant should be folded away"
+ assert out.initializers["v"].shape == (2,)
+
+
+# --- BatchNormalization folding -------------------------------------------
+#
+# The fold is what makes BatchNormalization work at all: there is no loop nest
+# for it in either emitter, so if the pass stops firing the op does not get
+# slower, it stops being supported. These check both that it fires and that
+# the arithmetic it folds is right.
+
+def _bn(name, x, out, chan, dtype=np.float32, **attrs):
+ """A BatchNormalization node plus its four constant parameters."""
+ rng = np.random.default_rng(abs(hash(name)) % 2**32)
+ params = {
+ f"{name}_scale": rng.uniform(0.5, 2.0, chan).astype(dtype),
+ f"{name}_B": rng.uniform(-1, 1, chan).astype(dtype),
+ f"{name}_mean": rng.uniform(-1, 1, chan).astype(dtype),
+ f"{name}_var": rng.uniform(0.5, 2.0, chan).astype(dtype),
+ }
+ node = Node("BatchNormalization", name, (x,) + tuple(params), (out,), attrs)
+ return node, params
+
+
+def _conv_bn_graph(**bn_attrs):
+ """Conv(3->4, 3x3) -> BatchNormalization, as a Graph ready for the pass."""
+ rng = np.random.default_rng(1)
+ w = rng.uniform(-1, 1, (4, 3, 3, 3)).astype(np.float32)
+ b = rng.uniform(-1, 1, 4).astype(np.float32)
+ conv = Node("Conv", "c0", ("x", "w", "b"), ("h",),
+ {"kernel_shape": (3, 3), "pads": (1, 1, 1, 1)})
+ bn, params = _bn("bn0", "h", "y", 4, **bn_attrs)
+ return Graph("g", (conv, bn),
+ {"x": Tensor("x", (1, 3, 8, 8), "f32"),
+ "h": Tensor("h", (1, 4, 8, 8), "f32"), "y": Tensor("y", (1, 4, 8, 8), "f32")},
+ {"w": w, "b": b, **params}, ("x",), ("y",))
+
+
+def test_batchnorm_folds_into_a_conv_and_matches_the_reference_arithmetic():
+ from rosenna.fold import fold_batchnorm
+ g = _conv_bn_graph()
+ w0, b0 = g.initializers["w"].copy(), g.initializers["b"].copy()
+ scale, shift = g.initializers["bn0_scale"], g.initializers["bn0_B"]
+ mean, var = g.initializers["bn0_mean"], g.initializers["bn0_var"]
+
+ out = fold_batchnorm(g)
+ assert [n.op for n in out.nodes] == ["Conv"], "the BatchNormalization should be gone"
+ assert out.nodes[0].outputs == ("y",), "the Conv takes over the BN's output"
+ assert "h" not in out.values, "the value between them stops existing"
+
+ s = scale / np.sqrt(var + 1e-5)
+ assert np.allclose(out.initializers["w"], w0 * s.reshape(-1, 1, 1, 1))
+ assert np.allclose(out.initializers[out.nodes[0].inputs[2]], (b0 - mean) * s + shift)
+ # The BN's own parameters are unreferenced now and must not ship.
+ assert not any(k.startswith("bn0_") for k in out.initializers), sorted(out.initializers)
+
+
+@pytest.mark.parametrize("trans_b", [0, 1])
+def test_batchnorm_folds_into_a_gemm_on_either_weight_layout(trans_b):
+ """transB decides which axis of the weight the channel scale broadcasts along."""
+ from rosenna.fold import fold_batchnorm
+ rng = np.random.default_rng(2)
+ shape = (5, 6) if trans_b else (6, 5)
+ w = rng.uniform(-1, 1, shape).astype(np.float32)
+ gemm = Node("Gemm", "g0", ("x", "w"), ("h",), {"transB": trans_b})
+ bn, params = _bn("bn1", "h", "y", 5)
+ g = Graph("g", (gemm, bn),
+ {"h": Tensor("h", (1, 5), "f32"), "y": Tensor("y", (1, 5), "f32")},
+ {"w": w, **params}, ("x",), ("y",))
+ out = fold_batchnorm(g)
+ assert [n.op for n in out.nodes] == ["Gemm"]
+ s = params["bn1_scale"] / np.sqrt(params["bn1_var"] + 1e-5)
+ want = w * (s.reshape(-1, 1) if trans_b else s.reshape(1, -1))
+ assert np.allclose(out.initializers["w"], want)
+ # The Gemm had no bias; the shift is not optional, so it gains one.
+ assert len(out.nodes[0].inputs) == 3
+ assert np.allclose(out.initializers[out.nodes[0].inputs[2]],
+ (0 - params["bn1_mean"]) * s + params["bn1_B"])
+
+
+@pytest.mark.parametrize("why,mutate", [
+ ("training mode", lambda g: _retag(g, {"training_mode": 1})),
+ ("a second reader of the intermediate", lambda g: _add_consumer(g)),
+ ("the intermediate is a graph output", lambda g: g._replace(outputs=("y", "h"))
+ if hasattr(g, "_replace") else _also_output(g)),
+])
+def test_batchnorm_is_left_in_place_when_folding_would_change_the_model(why, mutate):
+ """Each of these makes the fold unsound, so the op survives and is refused."""
+ from rosenna.fold import fold_batchnorm
+ from rosenna.validate import validate
+ g = mutate(_conv_bn_graph())
+ out = fold_batchnorm(g)
+ assert any(n.op == "BatchNormalization" for n in out.nodes), why
+ with pytest.raises(UnsupportedModel, match="could not be folded"):
+ validate(out)
+
+
+def _retag(g, attrs):
+ nodes = tuple(Node(n.op, n.name, n.inputs, n.outputs, {**n.attrs, **attrs})
+ if n.op == "BatchNormalization" else n for n in g.nodes)
+ return Graph(g.name, nodes, g.values, g.initializers, g.inputs, g.outputs)
+
+
+def _add_consumer(g):
+ extra = Node("Relu", "r0", ("h",), ("z",), {})
+ return Graph(g.name, g.nodes + (extra,), {**g.values, "z": Tensor("z", (1, 4, 8, 8), "f32")},
+ g.initializers, g.inputs, g.outputs + ("z",))
+
+
+def _also_output(g):
+ return Graph(g.name, g.nodes, g.values, g.initializers, g.inputs, ("y", "h"))
diff --git a/python/tests/test_frontend.py b/python/tests/test_frontend.py
index ebe8b3d..7f0e032 100644
--- a/python/tests/test_frontend.py
+++ b/python/tests/test_frontend.py
@@ -52,11 +52,12 @@ def test_unsupported_attribute_names_node(tmp_path):
x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3])
y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3])
node = helper.make_node("Relu", ["x"], ["y"], name="test_relu")
- # Add an unsupported TENSOR attribute
- tensor_attr = AttributeProto()
- tensor_attr.name = "bad_attr"
- tensor_attr.type = AttributeProto.TENSOR
- node.attribute.append(tensor_attr)
+ # A GRAPH attribute: still unsupported. (TENSOR is read now -- it is how a
+ # Constant node carries its payload for the folding pass.)
+ graph_attr = AttributeProto()
+ graph_attr.name = "bad_attr"
+ graph_attr.type = AttributeProto.GRAPH
+ node.attribute.append(graph_attr)
m = helper.make_model(helper.make_graph([node], "test", [x], [y]))
p = tmp_path / "bad_attr.onnx"
onnx.save(m, p)
diff --git a/python/tests/test_fuzz.py b/python/tests/test_fuzz.py
new file mode 100644
index 0000000..73dda94
--- /dev/null
+++ b/python/tests/test_fuzz.py
@@ -0,0 +1,287 @@
+"""Random graphs of supported ops, verified against onnxruntime.
+
+The golden set is 21 models a person wrote, so it tests the shapes a person
+thought of. The bugs this branch actually shipped were not those: an
+activation bounded by the *previous* op's output length (only wrong on a
+branching graph), a `Gemm` after an LSTM sequence (only wrong when the leading
+axis is not 1), an LSTM output list compacted out of its positional slots
+(only wrong when the model reads Y_c and not Y_h). Each needed a graph nobody
+had drawn.
+
+So this draws them. Every case is a seed: a failure prints the seed, and
+`_model_for_seed` rebuilds exactly that graph for debugging. SEEDS is a fixed
+list rather than a fresh draw per run, so CI is deterministic and a failure is
+reproducible from the parameter id alone; widen it to hunt.
+"""
+import re
+
+import numpy as np
+import onnx
+import pytest
+from onnx import helper, numpy_helper, TensorProto
+
+from rosenna.verify import VerificationError, verify_model
+
+# Fixed, not random per run: a fuzzer whose corpus changes every run reports a
+# failure nobody can reproduce, and turns an unrelated CI run red.
+SEEDS = list(range(48))
+
+_ACTS = ["Relu", "Tanh", "Sigmoid"]
+
+
+def _dense_chain(rng, nodes, inits, cur, width, n_gemm):
+ """A few dense layers with activations, returning the new value and width."""
+ for _ in range(int(rng.integers(1, 4))):
+ out_w = int(rng.integers(1, 9))
+ w = numpy_helper.from_array(
+ rng.uniform(-1, 1, (width, out_w)).astype(np.float32), f"w{n_gemm[0]}")
+ inits.append(w)
+ nxt = f"v{len(nodes)}"
+ if rng.random() < 0.5:
+ b = numpy_helper.from_array(
+ rng.uniform(-1, 1, (out_w,)).astype(np.float32), f"b{n_gemm[0]}")
+ inits.append(b)
+ nodes.append(helper.make_node("Gemm", [cur, w.name, b.name], [nxt],
+ name=f"g{len(nodes)}"))
+ else:
+ nodes.append(helper.make_node("MatMul", [cur, w.name], [nxt], name=f"m{len(nodes)}"))
+ n_gemm[0] += 1
+ cur, width = nxt, out_w
+ if rng.random() < 0.35:
+ # Add a constant, broadcast from one of the shapes that reaches the
+ # per-axis stride arithmetic: a full row, a single value, or a
+ # rank-1 vector that has to be right-aligned against a rank-2 value.
+ shp = [(1, width), (1, 1), (width,)][int(rng.integers(0, 3))]
+ c = numpy_helper.from_array(
+ rng.uniform(-1, 1, shp).astype(np.float32), f"ad{n_gemm[0]}_{len(nodes)}")
+ inits.append(c)
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node("Add", [cur, c.name], [nxt], name=f"ad{len(nodes)}"))
+ cur = nxt
+ if rng.random() < 0.7:
+ act = _ACTS[int(rng.integers(0, len(_ACTS)))]
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node(act, [cur], [nxt], name=f"a{len(nodes)}"))
+ cur = nxt
+ return cur, width
+
+
+def _spatial_chain(rng, nodes, inits, cur, c, h, w, n_conv):
+ """Conv / MaxPool / AveragePool until the field is too small to shrink."""
+ for _ in range(int(rng.integers(1, 4))):
+ if h < 3 or w < 3:
+ break
+ pick = rng.random()
+ if pick < 0.5:
+ oc, k = int(rng.integers(1, 5)), int(rng.integers(1, 4))
+ pad = int(rng.integers(0, 2))
+ weight = numpy_helper.from_array(
+ rng.uniform(-1, 1, (oc, c, k, k)).astype(np.float32), f"cw{n_conv[0]}")
+ inits.append(weight)
+ args = [cur, weight.name]
+ if rng.random() < 0.5:
+ bias = numpy_helper.from_array(
+ rng.uniform(-1, 1, (oc,)).astype(np.float32), f"cb{n_conv[0]}")
+ inits.append(bias)
+ args.append(bias.name)
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node("Conv", args, [nxt], name=f"c{len(nodes)}",
+ kernel_shape=[k, k], pads=[pad] * 4,
+ strides=[1, 1], group=1))
+ h, w, c = h + 2 * pad - k + 1, w + 2 * pad - k + 1, oc
+ n_conv[0] += 1
+ cur = nxt
+ if rng.random() < 0.35:
+ # A per-channel bias: (C,1,1) against (1,C,H,W) is the mnist
+ # shape, and the one whose strides are (0, 1, 0, 0).
+ cb = numpy_helper.from_array(
+ rng.uniform(-1, 1, (c, 1, 1)).astype(np.float32), f"pc{n_conv[0]}")
+ inits.append(cb)
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node("Add", [cur, cb.name], [nxt], name=f"pa{len(nodes)}"))
+ cur = nxt
+ else:
+ k = int(rng.integers(2, 4))
+ st = int(rng.integers(1, k + 1))
+ op = "MaxPool" if rng.random() < 0.5 else "AveragePool"
+ if (h - k) // st + 1 < 1 or (w - k) // st + 1 < 1:
+ break
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node(op, [cur], [nxt], name=f"p{len(nodes)}",
+ kernel_shape=[k, k], strides=[st, st],
+ pads=[0, 0, 0, 0]))
+ h, w = (h - k) // st + 1, (w - k) // st + 1
+ cur = nxt
+ if rng.random() < 0.5:
+ act = _ACTS[int(rng.integers(0, len(_ACTS)))]
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node(act, [cur], [nxt], name=f"a{len(nodes)}"))
+ cur = nxt
+ return cur, c, h, w
+
+
+def _lstm_head(rng, nodes, inits, graph_in):
+ """An LSTM over a random sequence, reduced to a rank-2 value for the tail.
+
+ ONNX wants X as (seq, batch, input) and hands back Y (seq, 1, batch, H),
+ Y_h and Y_c (1, batch, H). batch is 1, as in every golden LSTM, so the
+ Squeeze/Transpose a PyTorch export emits are the ones plan.py folds into
+ buffer aliases. Which of the three outputs the tail reads is drawn, because
+ an unread one has to be dropped without shifting the others out of their
+ positional slots -- that shipped as a bug.
+ """
+ seq = int(rng.integers(2, 5))
+ inp = int(rng.integers(2, 5))
+ hid = int(rng.integers(2, 5))
+ graph_in.append(helper.make_tensor_value_info("x", TensorProto.FLOAT, [seq, 1, inp]))
+ W = numpy_helper.from_array(rng.uniform(-1, 1, (1, 4 * hid, inp)).astype(np.float32), "lw")
+ R = numpy_helper.from_array(rng.uniform(-1, 1, (1, 4 * hid, hid)).astype(np.float32), "lr")
+ args = ["x", "lw", "lr"]
+ inits += [W, R]
+ if rng.random() < 0.6:
+ B = numpy_helper.from_array(rng.uniform(-1, 1, (1, 8 * hid)).astype(np.float32), "lb")
+ inits.append(B)
+ args.append("lb")
+ outs = ["Y", "Y_h", "Y_c"]
+ nodes.append(helper.make_node("LSTM", args, outs, name="lstm0", hidden_size=hid))
+ pick = int(rng.integers(0, 3))
+ if pick == 0: # Y: (seq,1,1,H) -> squeeze to (seq,H)
+ sq_axes = numpy_helper.from_array(np.array([1, 2], np.int64), "sq_axes")
+ inits.append(sq_axes)
+ nodes.append(helper.make_node("Squeeze", ["Y", "sq_axes"], ["yflat"], name="sq0"))
+ return "yflat", hid, seq
+ # Y_h or Y_c: (1,1,H) -> squeeze the leading axis to (1,H)
+ src = outs[1 + (pick - 1)]
+ ax = numpy_helper.from_array(np.array([0], np.int64), f"sq_{src}")
+ inits.append(ax)
+ nodes.append(helper.make_node("Squeeze", [src, ax.name], ["yflat"], name="sq1"))
+ return "yflat", hid, 1
+
+
+def _model_for_seed(seed: int, path):
+ """Build one random supported model. Deterministic in `seed`."""
+ rng = np.random.default_rng(seed)
+ nodes, inits = [], []
+ n_gemm = [0]
+ kind = rng.random()
+ if kind < 0.25:
+ graph_in = []
+ cur, width, rows = _lstm_head(rng, nodes, inits, graph_in)
+ cur, width = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+ graph = helper.make_graph(
+ nodes, f"fuzz{seed}", graph_in,
+ [helper.make_tensor_value_info(cur, TensorProto.FLOAT, [rows, width])], inits)
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ model = onnx.shape_inference.infer_shapes(model)
+ onnx.checker.check_model(model)
+ onnx.save(model, str(path))
+ return model
+ spatial = kind < 0.6
+ if spatial:
+ c, h, w = int(rng.integers(1, 3)), int(rng.integers(6, 13)), int(rng.integers(6, 13))
+ in_shape = [1, c, h, w]
+ cur, c, h, w = _spatial_chain(rng, nodes, inits, "x", c, h, w, [0])
+ width = c * h * w
+ # ONNX Gemm/MatMul take rank 2, so the field is flattened first -- the
+ # same Reshape mnist has, and the one plan.py turns into a buffer alias.
+ shape = numpy_helper.from_array(np.array([1, width], np.int64), "flat_shape")
+ inits.append(shape)
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node("Reshape", [cur, shape.name], [nxt], name=f"r{len(nodes)}"))
+ cur = nxt
+ else:
+ width = int(rng.integers(2, 7))
+ in_shape = [1, width]
+ cur = "x"
+ cur, width = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+ # A branch: one value feeding two chains that are then concatenated. This is
+ # the shape the buffer planner is least safe on -- liveness has to keep the
+ # shared value alive across both arms, and an activation's loop bound has to
+ # come from its own value rather than from whatever op ran last. Both of
+ # those shipped as bugs, and neither is expressible in the golden set.
+ if rng.random() < 0.5:
+ left, lw = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+ right, rw = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node("Concat", [left, right], [nxt],
+ name=f"cat{len(nodes)}", axis=1))
+ cur, width = nxt, lw + rw
+ cur, width = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+ # A second graph input, consumed by its own chain and merged in. The
+ # generated entry point takes every input concatenated in x, in declaration
+ # order, so this exercises the slice-copy that hands each secondary input
+ # its part -- and the offsets, which nothing else here varies.
+ graph_in = [helper.make_tensor_value_info("x", TensorProto.FLOAT, in_shape)]
+ if rng.random() < 0.4:
+ w2 = int(rng.integers(2, 6))
+ graph_in.append(helper.make_tensor_value_info("x2", TensorProto.FLOAT, [1, w2]))
+ side, sw = _dense_chain(rng, nodes, inits, "x2", w2, n_gemm)
+ nxt = f"v{len(nodes)}"
+ nodes.append(helper.make_node("Concat", [cur, side], [nxt],
+ name=f"jin{len(nodes)}", axis=1))
+ cur, width = nxt, width + sw
+ cur, width = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+
+ # A second graph output, which leaves concatenated in y.
+ graph_out = [helper.make_tensor_value_info(cur, TensorProto.FLOAT, [1, width])]
+ if rng.random() < 0.4:
+ tail, tw = _dense_chain(rng, nodes, inits, cur, width, n_gemm)
+ if tail != cur:
+ graph_out.append(helper.make_tensor_value_info(tail, TensorProto.FLOAT, [1, tw]))
+
+ graph = helper.make_graph(nodes, f"fuzz{seed}", graph_in, graph_out, inits)
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ model = onnx.shape_inference.infer_shapes(model)
+ onnx.checker.check_model(model)
+ onnx.save(model, str(path))
+ return model
+
+
+@pytest.mark.parametrize("seed", SEEDS)
+def test_random_supported_graph_matches_onnxruntime(seed, tmp_path):
+ path = tmp_path / f"fuzz{seed}.onnx"
+ _model_for_seed(seed, path)
+ try:
+ results = verify_model(path, "both", None, 8, tmp_path, name=f"fuzz{seed}")
+ except VerificationError as e:
+ # A random net whose last layer is a ReLU is sometimes dead -- every
+ # output zero for every input. verify refuses to compare against that,
+ # rightly: reproducing an all-zero reference demonstrates nothing. It
+ # is a property of the draw, not of the code under test.
+ pytest.skip(f"seed {seed} drew a dead network: {e}")
+ for r in results:
+ assert r.ok, (f"seed {seed} / {r.lang}: max_abs={r.max_abs:.3e} "
+ f"max_rel={r.max_rel:.3e}; rebuild with "
+ f"tests.test_fuzz._model_for_seed({seed}, path)")
+
+
+def test_the_corpus_is_reproducible(tmp_path):
+ """The same seed builds the same bytes, so a reported failure can be rebuilt."""
+ a, b = tmp_path / "a.onnx", tmp_path / "b.onnx"
+ _model_for_seed(7, a)
+ _model_for_seed(7, b)
+ assert a.read_bytes() == b.read_bytes()
+
+
+def test_the_corpus_covers_every_shape_it_claims_to(tmp_path):
+ """The pinned seeds actually draw each construction this file is for.
+
+ A fuzzer that quietly stops drawing LSTMs, or branches, looks exactly like
+ one that is finding nothing. This asserts the corpus still reaches each
+ construction, so a change to the generator that narrows it fails here
+ rather than silently reducing what the other tests cover.
+ """
+ seen = {k: 0 for k in ("lstm", "spatial", "branch", "add", "multi_in", "multi_out")}
+ for seed in SEEDS:
+ model = _model_for_seed(seed, tmp_path / f"c{seed}.onnx")
+ ops = {n.op_type for n in model.graph.node}
+ seen["lstm"] += "LSTM" in ops
+ seen["spatial"] += bool(ops & {"Conv", "MaxPool", "AveragePool"})
+ seen["branch"] += "Concat" in ops
+ seen["add"] += "Add" in ops
+ seen["multi_in"] += len(model.graph.input) > 1
+ seen["multi_out"] += len(model.graph.output) > 1
+ thin = {k: v for k, v in seen.items() if v < 3}
+ assert not thin, f"corpus covers these too thinly: {thin} (full tally {seen})"
diff --git a/python/tests/test_gate.py b/python/tests/test_gate.py
new file mode 100644
index 0000000..c822313
--- /dev/null
+++ b/python/tests/test_gate.py
@@ -0,0 +1,201 @@
+import shutil
+import pytest
+from rosenna.cli import main
+from rosenna.gate import _scope_hip_transfers, _sum_cudamemcpy_calls
+from tests.conftest import skip_unless_libgomp_enforces_mandatory
+from tests.test_device_c import _omp_cc
+
+# One header shape `nsys stats --report cuda_api_sum --format csv` actually
+# produces, close enough to exercise the parser's real column-matching path
+# rather than a hand-simplified stand-in.
+_NSYS_CSV_HEADER = (
+ '"Time (%)","Total Time (ns)","Num Calls","Avg (ns)","Med (ns)",'
+ '"Min (ns)","Max (ns)","StdDev (ns)","Name"\n'
+)
+
+
+def test_sum_cudamemcpy_calls_sums_the_matching_rows():
+ # Ruling R18 (a): two cudaMemcpy* rows (3 + 2 = 5 calls) plus one
+ # unrelated cudaLaunchKernel row; only the memcpy rows count.
+ csv_text = _NSYS_CSV_HEADER + (
+ '45.0,12345,3,4115.0,4000.0,3900.0,4500.0,120.5,"cudaMemcpyAsync"\n'
+ '30.0,8000,2,4000.0,4000.0,3900.0,4100.0,50.0,"cudaMemcpyHtoD"\n'
+ '25.0,6000,10,600.0,600.0,500.0,700.0,20.0,"cudaLaunchKernel"\n'
+ )
+ result = _sum_cudamemcpy_calls(csv_text)
+ assert result.parsed is True
+ assert result.count == 5
+
+
+def test_sum_cudamemcpy_calls_is_a_parsed_zero_with_no_memcpy_rows():
+ # Ruling R18 (b): a genuinely parsed export with zero cudaMemcpy* rows
+ # (only a launch row) is a real pass, not a fallback/unparsed zero --
+ # `parsed` distinguishes the two.
+ csv_text = _NSYS_CSV_HEADER + (
+ '100.0,6000,10,600.0,600.0,500.0,700.0,20.0,"cudaLaunchKernel"\n'
+ )
+ result = _sum_cudamemcpy_calls(csv_text)
+ assert result.parsed is True
+ assert result.count == 0
+
+
+def test_sum_cudamemcpy_calls_reports_not_parsed_rather_than_a_false_zero():
+ # Ruling R18 (c): neither an empty string nor an unrelated-columns CSV
+ # may come back as `parsed=True, count=0` -- that would be a silent
+ # pass on a check whose whole purpose is R5 evidence.
+ empty = _sum_cudamemcpy_calls("")
+ assert empty.parsed is False
+ assert empty.count == 0
+
+ unrelated = _sum_cudamemcpy_calls("foo,bar\n1,2\n3,4\n")
+ assert unrelated.parsed is False
+ assert unrelated.count == 0
+
+
+def test_sum_cudamemcpy_calls_skips_the_stdout_preamble():
+ # `nsys stats --format csv` on stdout is preceded by progress lines and a
+ # report title; the header is the first line naming Num Calls and Name,
+ # not the first line of stdout.
+ preamble = (
+ "Generating SQLite file gate_nsys_profile.sqlite from gate_nsys_profile.nsys-rep\n"
+ "Processing [gate_nsys_profile.sqlite] with [/opt/nvidia/nsight-systems/reports/cuda_api_sum.py]...\n"
+ "\n"
+ " ** CUDA API Summary (cuda_api_sum):\n"
+ "\n"
+ )
+ csv_text = preamble + _NSYS_CSV_HEADER + (
+ '60.0,12000,3,4000.0,4000.0,3900.0,4100.0,50.0,"cudaMemcpy"\n'
+ '40.0,6000,10,600.0,600.0,500.0,700.0,20.0,"cudaLaunchKernel"\n'
+ )
+ result = _sum_cudamemcpy_calls(csv_text)
+ assert result.parsed is True
+ assert result.count == 3
+ # A preamble with no CSV after it is still not parsed.
+ assert _sum_cudamemcpy_calls(preamble) == _sum_cudamemcpy_calls("")
+
+
+def test_gate_flags_follow_the_compiler_basename():
+ # Ruling R24: the gcc-style warning and -std flags only for gcc, gfortran,
+ # cc and clang; a vendor compiler gets -O2 and the user's --flags.
+ from rosenna.gate import _c_flags, _f_flags
+ assert _c_flags("gcc-15") == ["-O2", "-Wall", "-Wextra", "-std=c11"]
+ assert _c_flags("/usr/bin/clang") == ["-O2", "-Wall", "-Wextra", "-std=c11"]
+ assert _c_flags("cc") == ["-O2", "-Wall", "-Wextra", "-std=c11"]
+ assert _f_flags("gfortran") == ["-O2", "-Wall", "-Wextra", "-std=f2008"]
+ for vendor in ("nvc", "nvfortran", "amdclang", "amdflang", "flang", "icx", "ifx",
+ "/opt/nvidia/hpc_sdk/Linux_x86_64/24.5/compilers/bin/nvc"):
+ assert _c_flags(vendor) == ["-O2"], vendor
+ assert _f_flags(vendor) == ["-O2"], vendor
+
+
+def test_gate_runs_in_host_fallback_mode_and_writes_a_report(tmp_path, golden_model):
+ # On a machine without a GPU the gate runs with --host-fallback, which drops the
+ # MANDATORY requirement but exercises every other step, so the script itself is tested.
+ golden_model("gemm_big")
+ cc = _omp_cc()
+ fc = shutil.which("gfortran") or pytest.skip("no gfortran")
+ rc = main(["gpu-gate", "--cc", cc, "--fc", fc, "--flags", "-fopenmp", "--backend", "omp",
+ "--host-fallback", "--out", str(tmp_path)])
+ assert rc == 0
+ report = (tmp_path / "gate-report.md").read_text()
+ for key in ("gemm_big", "embedded", "file-loaded", "fortran", "c", "infer_batch",
+ "backend: omp", "ns per point", "host-fallback"):
+ assert key in report
+ # Rulings R21/R24: the recipes get CFLAGS/FFLAGS explicitly and the host
+ # compiler links the per-point harness against the omp-backend archive.
+ assert f"CC={cc} 'CFLAGS=-O2 -Wall -Wextra -std=c11' ROSENNA_OFFLOAD_FLAGS=-fopenmp" in report
+ assert f"FC={fc} 'FFLAGS=-O2 -Wall -Wextra -std=f2008' ROSENNA_OFFLOAD_FLAGS=-fopenmp" in report
+ assert "backend=omp, host compiler: serves the per-point harness" in report
+ assert f"$ {cc} -fopenmp gate_harness1.o libgemm_big.a -lm -o gate_harness1" in report
+
+
+def test_gate_fails_loudly_when_offload_is_mandatory_and_absent(tmp_path, golden_model):
+ # The gate's rc=1 here IS libgomp refusing the harness under MANDATORY;
+ # where libgomp ignores MANDATORY there is nothing to observe, so skip.
+ golden_model("gemm_big")
+ cc = _omp_cc()
+ fc = shutil.which("gfortran") or pytest.skip("no gfortran")
+ skip_unless_libgomp_enforces_mandatory(cc, tmp_path)
+ rc = main(["gpu-gate", "--cc", cc, "--fc", fc, "--flags", "-fopenmp", "--backend", "omp",
+ "--out", str(tmp_path)])
+ assert rc == 1
+ assert "MANDATORY" in (tmp_path / "gate-report.md").read_text()
+
+
+# rocprofv3 --hip-trace --marker-trace --memory-copy-trace -f csv writes one
+# CSV per domain. The marker CSV names the roctx range, the HIP one every API
+# call, the memory-copy one every copy the runtime actually performed (which
+# is how OpenMP offload's copies, made over HSA rather than the HIP API,
+# appear) -- all on the same clock. These are the real headers ROCm 7.2 wrote.
+_ROCPROF_HEADER = ('"Domain","Function","Process_Id","Thread_Id","Correlation_Id",'
+ '"Start_Timestamp","End_Timestamp"\n')
+_COPY_HEADER = ('"Kind","Direction","Stream_Id","Source_Agent_Id","Destination_Agent_Id",'
+ '"Correlation_Id","Start_Timestamp","End_Timestamp"\n')
+_MARKER_CSV = _ROCPROF_HEADER + '"MARKER_CORE_RANGE_API","rosenna_timed",1,1,14,1000,2000\n'
+
+
+def test_scope_hip_transfers_counts_api_calls_and_copies_inside_the_timed_range():
+ # One hipMemcpy and one copy before the range (setup), two API calls and
+ # one copy inside, one of each after: 3.
+ hip_csv = _ROCPROF_HEADER + (
+ '"HIP_RUNTIME_API","hipMemcpy",1,1,2,500,600\n'
+ '"HIP_RUNTIME_API","hipMemcpy",1,1,3,1100,1200\n'
+ '"HIP_RUNTIME_API","hipMemcpyAsync",1,1,4,1300,1400\n'
+ '"HIP_RUNTIME_API","hipLaunchKernel",1,1,5,1500,1600\n'
+ '"HIP_RUNTIME_API","hipMemcpy",1,1,6,2100,2200\n'
+ )
+ copy_csv = _COPY_HEADER + (
+ '"MEMORY_COPY","MEMORY_COPY_HOST_TO_DEVICE",0,"Agent 0","Agent 1",7,500,700\n'
+ '"MEMORY_COPY","MEMORY_COPY_HOST_TO_DEVICE",0,"Agent 0","Agent 1",8,1250,1260\n'
+ '"MEMORY_COPY","MEMORY_COPY_DEVICE_TO_HOST",0,"Agent 1","Agent 0",9,2100,2300\n'
+ )
+ result = _scope_hip_transfers(_MARKER_CSV, hip_csv, copy_csv)
+ assert result.parsed is True
+ assert result.count == 3
+
+
+def test_scope_hip_transfers_is_a_parsed_zero_when_only_launches_are_inside():
+ hip_csv = _ROCPROF_HEADER + (
+ '"HIP_RUNTIME_API","hipMemcpy",1,1,2,500,600\n'
+ '"HIP_RUNTIME_API","hipLaunchKernel",1,1,5,1500,1600\n'
+ )
+ copy_csv = _COPY_HEADER + '"MEMORY_COPY","MEMORY_COPY_HOST_TO_DEVICE",0,"Agent 0","Agent 1",7,500,700\n'
+ result = _scope_hip_transfers(_MARKER_CSV, hip_csv, copy_csv)
+ assert result.parsed is True
+ assert result.count == 0
+
+
+def test_scope_hip_transfers_takes_an_absent_api_trace_for_an_openmp_harness():
+ # An OpenMP-offload harness makes no HIP API calls, so rocprofv3 writes
+ # no hip_api_trace.csv at all; the memory-copy trace alone is then the
+ # evidence, and its rows outside the range prove it was recorded.
+ copy_csv = _COPY_HEADER + '"MEMORY_COPY","MEMORY_COPY_HOST_TO_DEVICE",0,"Agent 0","Agent 1",7,500,700\n'
+ result = _scope_hip_transfers(_MARKER_CSV, None, copy_csv)
+ assert result.parsed is True
+ assert result.count == 0
+
+
+def test_scope_hip_transfers_reports_not_parsed_rather_than_a_false_zero():
+ # No range named rosenna_timed, no trace rows at all, or unrelated
+ # columns: none of these is a passing zero.
+ hip_csv = _ROCPROF_HEADER + '"HIP_RUNTIME_API","hipLaunchKernel",1,1,5,1500,1600\n'
+ assert _scope_hip_transfers(_ROCPROF_HEADER, hip_csv, None).parsed is False
+ assert _scope_hip_transfers(_MARKER_CSV, _ROCPROF_HEADER, _COPY_HEADER).parsed is False
+ assert _scope_hip_transfers(_MARKER_CSV, None, None).parsed is False
+ assert _scope_hip_transfers(_MARKER_CSV, "foo,bar\n1,2\n", None).parsed is False
+ assert _scope_hip_transfers("", "", "").parsed is False
+
+
+def test_sum_cudamemcpy_calls_counts_driver_api_memcpys_too():
+ # nvc's OpenMP offload transfers through the CUDA driver API
+ # (cuMemcpyHtoDAsync_v2 and friends), which nsys lists in cuda_api_sum
+ # next to the runtime's cudaMemcpy*; a per-point harness's copies are
+ # only visible if both spellings count.
+ csv_text = _NSYS_CSV_HEADER + (
+ '45.0,12345,3,4115.0,4000.0,3900.0,4500.0,120.5,"cuMemcpyHtoDAsync_v2"\n'
+ '30.0,8000,2,4000.0,4000.0,3900.0,4100.0,50.0,"cudaMemcpy"\n'
+ '25.0,6000,10,600.0,600.0,500.0,700.0,20.0,"cuLaunchKernel"\n'
+ )
+ result = _sum_cudamemcpy_calls(csv_text)
+ assert result.parsed is True
+ assert result.count == 5
diff --git a/python/tests/test_golden_suite.py b/python/tests/test_golden_suite.py
new file mode 100644
index 0000000..5141bc9
--- /dev/null
+++ b/python/tests/test_golden_suite.py
@@ -0,0 +1,66 @@
+"""Every golden model, both backends, against onnxruntime.
+
+This is the replacement for `test/run.sh`, the shell suite that drove the
+original `fLibrary/` runtime parser: same 21 models, but generated rather than
+parsed at runtime, and compared against onnxruntime instead of against recorded
+`.txt` output from the PyTorch script that built the model.
+
+Comparing against onnxruntime rather than a recorded file is the point. A
+recorded golden file pins whatever the library did on the day it was recorded,
+so a wrong-but-stable implementation records its own error as the expectation;
+onnxruntime is an independent implementation of the same ONNX semantics.
+"""
+import re
+import subprocess
+import sys
+
+import pytest
+
+from rosenna.verify import verify_model
+
+# Kept as a literal list rather than a glob so that a golden model quietly
+# disappearing is a failure, not a silently smaller suite.
+GOLDEN = [
+ "avgpool_basic", "batchnet", "conv_basic", "conv_grouped", "conv_padding",
+ "conv1d_stack", "conv_padding-stride", "conv_strides", "droplet", "gemm_big",
+ "gemm_nobias", "gemm_small", "gru_cell", "lstm_cell", "lstm_gemm", "lstm_gemm_hid",
+ "lstm_nostate", "lstm_output", "maxpool_basic", "maxpool_nonsquare",
+ "maxpool_padding", "maxpool_strides", "mnist", "pool_batch",
+ "softmax_head",
+]
+
+
+def test_the_suite_covers_every_golden_directory(repo_root):
+ """The list above is the whole golden set, not a subset someone trimmed."""
+ on_disk = {d.name for d in (repo_root / "goldenFiles").iterdir()
+ if d.is_dir() and (d / f"{d.name}.py").exists()}
+ assert on_disk == set(GOLDEN), (
+ f"golden set changed: only on disk {sorted(on_disk - set(GOLDEN))}, "
+ f"only in this list {sorted(set(GOLDEN) - on_disk)}")
+
+
+@pytest.mark.parametrize("model", GOLDEN)
+def test_golden_model_matches_onnxruntime_on_both_backends(model, tmp_path, golden_model):
+ onnx_path = golden_model(model)
+ # The generated symbols become Fortran/C identifiers, and one golden model
+ # is named with a hyphen.
+ name = re.sub(r"[^0-9A-Za-z_]", "_", model)
+ results = verify_model(onnx_path, "both", None, 8, tmp_path, name=name)
+ assert {r.lang for r in results} == {"fortran", "c"}
+ for r in results:
+ assert r.ok, (f"{model}/{r.lang}: max_abs={r.max_abs:.3e} "
+ f"max_rel={r.max_rel:.3e} against onnxruntime")
+
+
+@pytest.mark.parametrize("model", GOLDEN)
+def test_both_backends_agree_with_each_other(model, tmp_path, golden_model):
+ """C and Fortran are rendered from one plan, so they must agree far more
+ tightly than either agrees with onnxruntime: same order, same arithmetic."""
+ onnx_path = golden_model(model)
+ name = re.sub(r"[^0-9A-Za-z_]", "_", model)
+ results = verify_model(onnx_path, "both", "f64", 8, tmp_path, name=name)
+ by_lang = {r.lang: r for r in results}
+ # Both are compared against the same reference, so equal error figures mean
+ # equal outputs -- to the last digit printed, across every case.
+ assert by_lang["c"].max_abs == pytest.approx(by_lang["fortran"].max_abs, rel=1e-12), \
+ f"{model}: backends disagree (c {by_lang['c'].max_abs}, f {by_lang['fortran'].max_abs})"
diff --git a/python/tests/test_kernel.py b/python/tests/test_kernel.py
new file mode 100644
index 0000000..fe188d1
--- /dev/null
+++ b/python/tests/test_kernel.py
@@ -0,0 +1,774 @@
+import os
+import re
+import shutil
+import subprocess
+import numpy as np
+import pytest
+from onnx import helper, numpy_helper
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.emit_kernel import emit_kernel
+from rosenna.rt_header import rt_header
+from rosenna.emit_c import emit_c, emit_c_recipe, elem_length, CONSTANT_MEMORY_LIMIT
+from rosenna.emit_fortran import emit_fortran
+from tests.conftest import skip_unless_libgomp_enforces_mandatory
+from tests.conftest import _assert_warning_free, save_model
+
+
+def test_kernel_source_uses_only_the_rt_macros(golden_model):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64")
+ cu = emit_kernel(plan)
+ assert "rosenna_rt.h" in cu and "__global__" in cu and 'extern "C" int gemm_small_infer_batch(' in cu
+ for forbidden in ("cudaMalloc", "hipMalloc", "cudaMemcpy", "hipMemcpy", "<<<"):
+ assert forbidden not in cu, forbidden # only rosenna_rt.h may name the runtime
+
+
+def test_kernel_source_names_no_runtime_symbol_for_a_file_loaded_plan(golden_model):
+ # The file-loaded kernel file also holds the plan-step bind of the device
+ # weight pointers (called by init); that too must go through
+ # rosenna_rt.h, never a cuda*/hip* name.
+ plan = build_plan(load_graph(golden_model("gemm_big")), dtype="f64", embed=False)
+ cu = emit_kernel(plan)
+ assert re.search(r"\b(cuda|hip)[A-Z]", cu) is None
+ assert 'extern "C" int gemm_big_device_bind(void) {' in cu
+ assert "return gemm_big_device_bind_here();" in cu
+ assert "ROSENNA_LAUNCH(gemm_big_kernel" in cu
+ # Ruling R10/R11: the launch is checked with GetLastError (never a sync), status 11.
+ assert "if (ROSENNA_LAUNCH_STATUS() != ROSENNA_OK) return 11;" in cu
+ # An embedded plan reads its ROSENNA_CONST arrays directly and binds nothing.
+ cu_e = emit_kernel(build_plan(load_graph(golden_model("gemm_big")), dtype="f64", embed=True))
+ assert "ROSENNA_MEMCPY_TO_SYMBOL" not in cu_e and "device_bind" not in cu_e
+
+
+def _function_body(text: str, signature_start: str) -> str:
+ """The text of one top-level C function, from its signature to its closing brace."""
+ start = text.index(signature_start)
+ end = text.index("\n}\n", start) + 3
+ return text[start:end]
+
+
+_LOOP_PATH_FORBIDDEN = ("map(to", "map(from", "map(tofrom", "copyin", "copyout",
+ "ROSENNA_MALLOC", "ROSENNA_MEMCPY", "ROSENNA_SYNC",
+ "cudaMemcpy", "hipMemcpy", "cudaMalloc", "hipMalloc",
+ "cudaDeviceSynchronize", "hipDeviceSynchronize",
+ "target update", "update device", "enter data", "exit data",
+ "omp_target_memcpy", "acc_memcpy")
+
+
+def test_loop_path_never_transfers(golden_model):
+ # Controller rulings R5/R6: init is the plan step and the only routine that
+ # allocates, transfers or synchronizes; x and y are device-resident in
+ # every backend. No transfer can be observed on a host-only build, so
+ # this structural check is the CI-able guarantee. Exempt: the body of
+ # `_init` and of `_upload`, the static tail of init that
+ # holds the cuda/hip allocation and copies (init calls it and nothing
+ # else does). Everything else in the .c, and the whole kernel file, must
+ # be free of every transfer token. The kernel file's `_device_bind`
+ # only forwards to the header's `_device_bind_here`, which is where
+ # the one symbol copy of the plan step lives; that header function is
+ # checked here to be the sole holder of ROSENNA_MEMCPY_TO_SYMBOL. Also
+ # exempt: `_sync`, the wait a host asks for by name (a caller with
+ # no stream of its own has no other way to order its next target region
+ # after a cuda/hip launch); infer and infer_batch themselves never sync.
+ for embed in (True, False):
+ name = "gemm_big"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=embed)
+ source, header = emit_c(plan)
+ cu = emit_kernel(plan)
+ sync = _function_body(cu, f'extern "C" int {name}_sync(')
+ assert "ROSENNA_SYNC" in sync
+ cu = cu.replace(sync, "")
+ rest_c = source
+ if not embed:
+ init = _function_body(source, f"int {name}_init(")
+ # The allocation and copies now live in the KERNEL translation
+ # unit, because every line of them is a CUDA/HIP runtime call and
+ # .c is built by the host compiler. init still drives them.
+ upload = _function_body(cu, f'extern "C" int {name}_upload_device(')
+ assert f"return {name}_upload_device();" in init and "ROSENNA_MALLOC" in upload
+ rest_c = rest_c.replace(init, "")
+ cu = cu.replace(upload, "")
+ # _release_device (called by upload) only frees: no transfer.
+ for forbidden in _LOOP_PATH_FORBIDDEN:
+ assert forbidden not in rest_c, (embed, forbidden)
+ assert forbidden not in cu, (embed, forbidden)
+ if not embed:
+ bind_here = _function_body(header, f"static inline int {name}_device_bind_here(")
+ assert "ROSENNA_MEMCPY_TO_SYMBOL" in bind_here
+ assert header.count("ROSENNA_MEMCPY_TO_SYMBOL") == 1
+ assert f"return {name}_device_bind_here();" in _function_body(cu, f'extern "C" int {name}_device_bind(')
+
+
+def test_rt_header_maps_both_runtimes():
+ h = rt_header()
+ assert "__HIPCC__" in h and "__CUDACC__" in h and "ROSENNA_LAUNCH" in h
+ # Every macro the generated sources use is defined once per runtime.
+ for macro in ("ROSENNA_STREAM_T", "ROSENNA_MALLOC", "ROSENNA_MEMCPY_H2D", "ROSENNA_FREE",
+ "ROSENNA_OK", "ROSENNA_SYNC", "ROSENNA_LAUNCH", "ROSENNA_MEMCPY_TO_SYMBOL",
+ "ROSENNA_LAUNCH_STATUS"):
+ assert h.count(f"#define {macro}(") + h.count(f"#define {macro} ") == 2, macro
+
+
+def test_header_declares_infer_batch_with_c_linkage_on_both_forms(golden_model):
+ for embed in (True, False):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=embed)
+ source, header = emit_c(plan)
+ # Ruling R7: the batch's pointers are restrict-qualified.
+ assert ("int gemm_small_infer_batch(int n, const double *ROSENNA_RESTRICT x, "
+ "double *ROSENNA_RESTRICT y, void *stream);") in header
+ assert 'extern "C" {' in header and "__cplusplus" in header
+ assert "x and y must already be on\n the device; init is the only routine that transfers." in header
+ # The OpenMP fallback lives in the .c under the negation of the ROLE
+ # guard -- set by the recipe, not sniffed from the compiler -- over
+ # device pointers (ruling R5), each pragma under its own guard.
+ assert ("int gemm_small_infer_batch(int n, const double *ROSENNA_RESTRICT x, "
+ "double *ROSENNA_RESTRICT y, void *stream) {") in source
+ assert ('extern "C" int gemm_small_infer_batch(int n, const double *__restrict__ x, '
+ "double *__restrict__ y, void *stream) {") in emit_kernel(plan)
+ assert "#ifndef ROSENNA_NATIVE_KERNEL" in source
+ assert "#if !defined(__CUDACC__) && !defined(__HIPCC__)" not in source, \
+ "the .c is built by the host compiler now; nothing in it may sniff for nvcc"
+ # distribute parallel for, not teams loop: teams loop maps one point per
+ # TEAM under nvc (the ~30x cliff the README describes) and under
+ # amdclang (3.5 us per point on an MI210, measured on the reaction-
+ # diffusion example); the bias reordering that lets nvc compile the
+ # per-point harnesses' distribute parallel for applies here too.
+ assert ("#if defined(_OPENMP)\n#pragma omp target teams distribute parallel for "
+ "is_device_ptr(x, y)\n") in source
+ assert "#elif defined(_OPENACC)\n#pragma acc parallel loop deviceptr(x, y)\n#endif" in source
+
+
+def test_file_loaded_kernel_owns_the_device_copies(golden_model):
+ """The cuda/hip half of init lives in the kernel TU, not in .c.
+
+ That is the whole of the one-archive change: .c is built by the host
+ compiler so its declare-target weights actually reach the device, and
+ every CUDA/HIP runtime call sits beside the kernel that needs it.
+ """
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ cu = emit_kernel(plan)
+ # Indexed by device: one set of pointers meant a second init silently
+ # replaced the first device's, and that device's kernel then read an
+ # address belonging to another.
+ assert 'extern "C" double *gemm_small_w0_dev[ROSENNA_MAX_DEVICES] = {0};' in cu
+ assert "ROSENNA_MALLOC(&gemm_small_w0_dev[d], sizeof gemm_small_w0)" in cu
+ assert "ROSENNA_MEMCPY_H2D(gemm_small_w0_dev[d], gemm_small_w0, sizeof gemm_small_w0)" in cu
+ assert "if (ROSENNA_GET_DEVICE(&d) != ROSENNA_OK) return 10;" in cu
+ # And none of it is left in the .c, which no host compiler could build.
+ for token in ("ROSENNA_MALLOC", "ROSENNA_MEMCPY_H2D", "ROSENNA_FREE"):
+ assert token not in source, token
+ assert '#include "rosenna_rt.h"' not in source # the header includes it, guarded
+ # init ends by publishing the copies to the kernel's translation unit,
+ # through the header's per-translation-unit bind (ruling R8), which any
+ # user kernel's translation unit must call as well.
+ assert ("if (gemm_small_device_bind() != 0) "
+ "{ gemm_small_release_device_at(d); return 10; }") in cu
+ # A failed bind (like a failed allocation or copy) frees and nulls every
+ # copy -- the same release a repeated init starts with -- so infer_batch
+ # then returns 10 instead of launching over a table that still holds the
+ # previous addresses.
+ release = _function_body(cu, "static void gemm_small_release_device_at(int d) {")
+ for sym in ("w0", "b0", "w1", "b1"):
+ assert (f"(void)ROSENNA_FREE(gemm_small_{sym}_dev[d]);\n"
+ f" gemm_small_{sym}_dev[d] = 0;") in release
+ upload = _function_body(cu, 'extern "C" int gemm_small_upload_device(void) {')
+ assert upload.count("{ gemm_small_release_device_at(d); return 10; }") == 2 * 4 + 1
+ assert " gemm_small_release_device_at(d);\n" in upload
+ assert "int gemm_small_device_bind(void);" in header
+ assert "static inline int gemm_small_device_bind_here(void) {" in header
+ assert ("call gemm_small_device_bind_here() after EVERY call to gemm_small_init()\n"
+ " in every translation unit whose kernels call gemm_small_infer. Embedded\n"
+ " models need nothing.") in header
+ assert '#include "rosenna_rt.h"' in header
+ # Under an offloading OpenMP/OpenACC build the host arrays have device
+ # copies that init updates in the same call (the plan step); each
+ # directive under its own guard.
+ weights = "gemm_small_w0, gemm_small_b0, gemm_small_w1, gemm_small_b1"
+ assert f"#ifdef _OPENMP\n#pragma omp target update to({weights})\n#endif" in source
+ assert f"#ifdef _OPENACC\n#pragma acc update device({weights})\n#endif" in source
+ assert f"#pragma acc declare create({weights})" in header
+ assert "#pragma omp declare target\n#endif\nextern double gemm_small_w0[4];" in header
+
+
+def test_generated_c_is_warning_free_under_openacc(tmp_path, golden_model):
+ # gcc -fopenacc rejects a `routine seq` function reading a file-scope
+ # array with no `declare` directive (HEAD before this task failed here);
+ # both forms must now compile clean, including a host calling the
+ # `parallel loop deviceptr(x, y)` fallback.
+ from tests.test_device_c import _omp_cc
+ cc = _omp_cc()
+ probe = subprocess.run([cc, "-fopenacc", "-x", "c", "-", "-o", os.devnull],
+ input="int main(void){return 0;}", capture_output=True, text=True)
+ if probe.returncode != 0:
+ pytest.skip(f"{cc} does not accept -fopenacc")
+ for embed in (True, False):
+ name = "gemm_small"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=embed)
+ d = tmp_path / ("e" if embed else "f"); d.mkdir()
+ source, header = emit_c(plan)
+ (d / f"{name}.c").write_text(source); (d / f"{name}.h").write_text(header)
+ (d / "host.c").write_text(f"""
+#include "{name}.h"
+int main(void) {{ double x[2] = {{0.5, 0.5}}, y[3]; return {name}_infer_batch(1, x, y, 0); }}
+""")
+ r = subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-fopenacc", f"{name}.c", "host.c", "-lm", "-o", "host"],
+ cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ if embed:
+ assert subprocess.run(["./host"], cwd=d, capture_output=True).returncode == 0
+ # Status 10 is in the emitted legend, next to the routine that returns it.
+ assert "10 device allocation or copy failed in init" in source
+ # Device code reads the per-translation-unit __constant__ pointer table;
+ # host code, under any compiler, reads the host arrays.
+ assert "static __constant__ const double *gemm_small_devw[4];" in header
+ assert "#define ROSENNA_REF_gemm_small_w0 gemm_small_devw[0]" in header
+ assert "#define ROSENNA_REF_gemm_small_w0 gemm_small_w0" in header
+ assert "__CUDA_ARCH__" in header and "__HIP_DEVICE_COMPILE__" in header
+
+
+def test_embedded_infer_is_a_stub_in_the_host_pass_of_a_device_build(golden_model):
+ # Ruling R9: the host instantiation of __host__ __device__ infer must not
+ # read the __constant__/__device__ arrays (nvcc diagnoses it; hip-clang's
+ # host shadow is undefined). It asserts instead, a no-op under NDEBUG.
+ # The literals are emitted once; there is no host twin.
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=True)
+ _, header = emit_c(plan)
+ assert "#include " in header
+ assert "#if defined(__CUDA_ARCH__) || defined(__HIP_DEVICE_COMPILE__)\n#define ROSENNA_INFER_HOST_STUB 0\n#else\n#define ROSENNA_INFER_HOST_STUB 1\n#endif" in header
+ assert ('#if ROSENNA_INFER_HOST_STUB\n (void)x;\n (void)y;\n assert(0 && "rosenna: gemm_small_infer '
+ 'is device-only in a CUDA/HIP build; call it from a kernel or use gemm_small_infer_batch");\n#else') in header
+ assert header.count("gemm_small_w0[4] = {") == 1
+ assert "host instantiation\n of infer is a stub" in header
+ # A file-loaded plan computes on the host in every pass (it reads the host arrays).
+ _, header_f = emit_c(build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False))
+ assert "ROSENNA_INFER_HOST_STUB 1" not in header_f and "assert(" not in header_f
+ # Both headers can share one translation unit: every macro is #undef'd first.
+ for macro in ("ROSENNA_DEVICE_FN", "ROSENNA_CONST", "ROSENNA_RESTRICT", "ROSENNA_INFER_HOST_STUB"):
+ assert f"#undef {macro}" in header and f"#undef {macro}" in header_f
+
+
+def _embedded_plan(tmp_path, name, n_in, n_out):
+ w = numpy_helper.from_array(np.random.default_rng(1).uniform(-1, 1, (n_in, n_out)).astype(np.float32), "w")
+ node = helper.make_node("MatMul", ["x", "w"], ["y"], name="m0")
+ path = save_model(tmp_path, name, [node], [w], (1, n_in), (1, n_out))
+ return build_plan(load_graph(path), dtype="f64", embed=True)
+
+
+def test_constant_memory_limit_selects_the_device_qualifier(tmp_path):
+ # Controller ruling R4: an embedded model past the constant budget goes to
+ # __device__ const instead. The cut is 2 KB of weight bytes -- set by the
+ # per-SM constant cache, not the 64 KB per-module bank, since past the
+ # cache every weight read misses and the models roseNNa targets measure
+ # 2.5-2.8x slower in __constant__ (see CONSTANT_MEMORY_LIMIT). A header
+ # states which it chose.
+ assert CONSTANT_MEMORY_LIMIT == 2 * 1024
+ small = _embedded_plan(tmp_path, "under", 15, 15) # 225 f64 = 1800 B < 2 KB
+ big = _embedded_plan(tmp_path, "over", 16, 20) # 320 f64 = 2560 B > 2 KB
+ _, h_small = emit_c(small)
+ _, h_big = emit_c(big)
+ assert "#define ROSENNA_CONST static __constant__" in h_small
+ assert "__device__ const" not in h_small
+ assert "#define ROSENNA_CONST static __device__ const" in h_big
+ assert "__constant__" not in h_big
+ assert "1800" in h_small and "2560" in h_big # the comment names the byte count it judged
+
+
+def _omp_host(name, n_in, n_out, npts, init):
+ """A host that maps its data first and hands infer_batch device pointers (R5).
+
+ Under the gcc host fallback every step is the identity and the batch must
+ agree bit for bit with the host's own calls of the header inline.
+ """
+ return f"""
+#include
+#include
+#include "{name}.h"
+int main(void) {{
+ const int nx = {npts} * {n_in}, ny = {npts} * {n_out};
+ double *x = malloc(nx * sizeof *x), *y = malloc(ny * sizeof *y), *yb = malloc(ny * sizeof *yb);
+ int status = 0;
+ {init}
+ for (int c = 0; c < nx; ++c) x[c] = 0.01 * c - 0.3;
+ for (int p = 0; p < {npts}; ++p) {name}_infer(x + p * {n_in}, y + p * {n_out});
+#ifdef _OPENMP
+ #pragma omp target enter data map(to: x[0:nx]) map(alloc: yb[0:ny])
+ #pragma omp target data use_device_ptr(x, yb)
+#endif
+ {{
+ status = {name}_infer_batch({npts}, x, yb, 0);
+ if (status == 0) status = {name}_infer_batch(0, x, yb, 0) ? 4 : 0;
+ }}
+#ifdef _OPENMP
+ #pragma omp target exit data map(from: yb[0:ny]) map(delete: x[0:nx])
+#endif
+ if (status) return status;
+ for (int c = 0; c < ny; ++c) if (y[c] != yb[c]) return 5;
+ puts("agree"); return 0; }}
+"""
+
+
+def _omp_build_and_run(tmp_path, name, plan, cc, init):
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source); (tmp_path / f"{name}.h").write_text(header)
+ (tmp_path / "Makefile").write_text(emit_c_recipe(plan))
+ r = subprocess.run(["make", f"CC={cc}", "ROSENNA_BACKEND=omp", "ROSENNA_OFFLOAD_FLAGS=-fopenmp"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ assert "warning" not in r.stderr, r.stderr
+ (tmp_path / "host.c").write_text(_omp_host(name, plan.input.shape[0], plan.output.shape[0], 16, init))
+ r = subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-fopenmp", "host.c", f"lib{name}.a", "-lm", "-o", "host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ return subprocess.run(["./host"], cwd=tmp_path, capture_output=True, text=True)
+
+
+def test_omp_backend_infer_batch_matches_inline(tmp_path, golden_model):
+ # The fallback backend: a library-side target loop over device pointers
+ # (is_device_ptr) must agree exactly with the host's own loop over the
+ # header inline. The host maps x and y first and passes what
+ # use_device_ptr yields, per ruling R5.
+ from tests.test_device_c import _omp_cc
+ from rosenna.weights import write_weights
+ name = "gemm_big"; graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=False)
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+ r = _omp_build_and_run(tmp_path, name, plan, _omp_cc(), f'if ({name}_init("{name}.rwt")) return 2;')
+ assert r.returncode == 0 and r.stdout.strip() == "agree", (r.returncode, r.stdout, r.stderr)
+
+
+def test_omp_backend_builds_an_embedded_plan_too(tmp_path, golden_model):
+ # An embedded plan's .c holds only the fallback infer_batch; the recipe
+ # must still produce a library for it, and the batch must agree with the
+ # inline.
+ from tests.test_device_c import _omp_cc
+ name = "gemm_small"; graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=True)
+ r = _omp_build_and_run(tmp_path, name, plan, _omp_cc(), "")
+ assert r.returncode == 0 and r.stdout.strip() == "agree", (r.returncode, r.stdout, r.stderr)
+
+
+def test_omp_target_loop_in_infer_batch_is_real(tmp_path, golden_model):
+ # Two-tier evidence, as in test_device_c (ruling R31). Primary: the
+ # compiled library object references GOMP_target_ext, which a real
+ # `#pragma omp target` cannot be compiled without. Corroborating: a
+ # host-only libgomp that enforces OMP_TARGET_OFFLOAD=MANDATORY refuses the
+ # library's own loop; where libgomp ignores MANDATORY (a plain gcc < 13
+ # with no offload plugins) that half is skipped, not failed.
+ from tests.test_device_c import _omp_cc
+ name = "gemm_small"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=True)
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source); (tmp_path / f"{name}.h").write_text(header)
+ (tmp_path / "host.c").write_text(f"""
+#include "{name}.h"
+int main(void) {{ double x[2] = {{0.5, 0.5}}, y[3]; return {name}_infer_batch(1, x, y, 0); }}
+""")
+ cc = _omp_cc()
+ flags = ["-O2", "-std=c11", "-fopenmp"]
+ r = subprocess.run([cc, *flags, "-c", f"{name}.c", "-o", f"{name}.o"], cwd=tmp_path,
+ capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ nm = shutil.which("nm") or pytest.skip("no nm")
+ nm_out = subprocess.run([nm, "-u", f"{name}.o"], cwd=tmp_path, capture_output=True, text=True).stdout
+ assert "GOMP_target_ext" in nm_out, nm_out
+
+ skip_unless_libgomp_enforces_mandatory(cc, tmp_path)
+ r = subprocess.run([cc, *flags, "host.c", f"{name}.o", "-lm", "-o", "host"], cwd=tmp_path,
+ capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ r = subprocess.run(["./host"], cwd=tmp_path, capture_output=True, text=True,
+ env={**os.environ, "OMP_TARGET_OFFLOAD": "MANDATORY"})
+ assert r.returncode != 0 and "MANDATORY" in r.stderr
+
+
+def test_generated_c_is_warning_free_under_a_plain_compiler(tmp_path, golden_model):
+ # clang without -fopenmp and without CUDA: every guard is false and the
+ # fallback still has to compile clean under -Wall -Wextra.
+ cc = shutil.which("clang") or shutil.which("cc")
+ if cc is None:
+ pytest.skip("no plain C compiler found")
+ for embed in (True, False):
+ name = "gemm_small"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=embed)
+ d = tmp_path / ("e" if embed else "f"); d.mkdir()
+ source, header = emit_c(plan)
+ (d / f"{name}.c").write_text(source); (d / f"{name}.h").write_text(header)
+ r = subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-c", f"{name}.c"], cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+
+
+def test_generated_c_compiles_as_cpp_with_the_rt_header_stubbed(tmp_path, golden_model):
+ # nvcc and hipcc compile _kernel.cu as C++. No such compiler runs
+ # here, so this is the nearest local check: a host C++ compiler with
+ # __CUDACC__ forced on and the CUDA keywords and runtime replaced by inert
+ # stand-ins. It catches C-only constructs in the .c, linkage mismatches
+ # between the header's extern "C" block and the definitions, and any use
+ # of a runtime name outside rosenna_rt.h. It proves nothing about device
+ # code generation; that waits for the nvcc CI job.
+ #
+ # Two variants of the kernel translation unit. With __CUDA_ARCH__ defined
+ # (as in nvcc's device pass) infer reads the file-loaded weights through
+ # the per-translation-unit table that device_bind_here fills, and the
+ # embedded body is the real one: init -> upload -> bind -> launch must
+ # agree bit for bit with the inline, and an unbound table is status 10.
+ # Without it (nvcc's host pass) the file-loaded kernel reads the host
+ # arrays and the embedded kernel is the R9 stub, so that variant is
+ # compiled and linked, and run only for the file-loaded plan.
+ cxx = shutil.which("clang++") or shutil.which("g++")
+ if cxx is None:
+ pytest.skip("no C++ compiler found")
+ stub = """
+#ifndef ROSENNA_RT_H
+#define ROSENNA_RT_H
+#include
+#include
+#include
+typedef void *ROSENNA_STREAM_T_;
+static inline int rosenna_stub_malloc(void **p, size_t n) { *p = malloc(n); return *p ? 0 : 1; }
+static inline int rosenna_stub_h2d(void *d, const void *h, size_t n) { memcpy(d, h, n); return 0; }
+static inline int rosenna_stub_free(void *p) { free(p); return 0; }
+static inline int rosenna_stub_sync(void *s) { (void)s; return 0; }
+#define ROSENNA_STREAM_T ROSENNA_STREAM_T_
+#define ROSENNA_MALLOC(p, n) rosenna_stub_malloc((void **)(p), (n))
+#define ROSENNA_MEMCPY_H2D(d, h, n) rosenna_stub_h2d((d), (h), (n))
+#define ROSENNA_MEMCPY_TO_SYMBOL(sym, src, n) rosenna_stub_h2d((sym), (src), (n))
+#define ROSENNA_FREE(p) rosenna_stub_free(p)
+#define ROSENNA_OK 0
+#define ROSENNA_SYNC(s) rosenna_stub_sync(s)
+#define ROSENNA_LAUNCH(k, g, b, s, ...) \\
+ do { for (blockIdx.x = 0; blockIdx.x < (unsigned)((g) * (b)); ++blockIdx.x) k(__VA_ARGS__); } while (0)
+#define ROSENNA_LAUNCH_STATUS() 0
+#define ROSENNA_MAX_DEVICES 16
+#define ROSENNA_GET_DEVICE(p) (*(p) = 0, 0)
+/* infer_one's cross-stream ordering: inert here, since the stub launcher runs
+ every kernel synchronously on the host. */
+typedef int ROSENNA_EVENT_T_;
+static inline int rosenna_stub_event(void *e) { (void)e; return 0; }
+static inline int rosenna_stub_wait(void *s, int e) { (void)s; (void)e; return 0; }
+#define ROSENNA_EVENT_T ROSENNA_EVENT_T_
+#define ROSENNA_EVENT_CREATE(e) rosenna_stub_event((void *)(e))
+#define ROSENNA_EVENT_RECORD(e, s) rosenna_stub_wait((void *)(s), (e))
+#define ROSENNA_STREAM_WAIT_EVENT(s, e) rosenna_stub_wait((void *)(s), (e))
+#endif
+"""
+ from rosenna.weights import write_weights
+ for embed in (True, False):
+ for arch in (None, "800"):
+ name = "gemm_small"; graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=embed)
+ d = tmp_path / f"{'e' if embed else 'f'}{arch or ''}"; d.mkdir()
+ source, header = emit_c(plan)
+ (d / f"{name}.c").write_text(source); (d / f"{name}.h").write_text(header)
+ (d / "rosenna_rt.h").write_text(stub)
+ if not embed:
+ write_weights(plan, graph, d / f"{name}.rwt")
+ (d / f"{name}_kernel.cu").write_text(emit_kernel(plan))
+ # blockIdx/blockDim/threadIdx are CUDA builtins; give the stub the
+ # three as plain objects so the kernel body parses, with one thread
+ # per block so the stub launch's loop over blockIdx.x walks the points.
+ (d / "builtins.h").write_text(
+ "struct rosenna_dim3 { unsigned int x, y, z; };\n"
+ "static struct rosenna_dim3 blockIdx = {0, 0, 0};\n"
+ "static const struct rosenna_dim3 blockDim = {1, 0, 0}, threadIdx = {0, 0, 0};\n"
+ "static inline void __syncthreads(void) {}\n")
+ # -ffp-contract=off on every translation unit: the host may be built
+ # by a different compiler than the library, and clang contracts
+ # `acc += a * b` to an FMA by default where g++ in ISO mode does not,
+ # which breaks the bit-for-bit comparison below for no real reason.
+ common = [cxx, "-x", "c++", "-std=c++11", "-ffp-contract=off", "-Wall", "-Wextra", "-c",
+ "-D__CUDACC__=1", "-D__host__=", "-D__device__=", "-D__constant__=", "-D__global__=",
+ "-D__shared__=",
+ "-include", "builtins.h"]
+ cc = shutil.which("clang") or shutil.which("cc") or shutil.which("gcc")
+ # .c is built by the HOST compiler as plain C, with the role
+ # macro that yields the batched entry points to the kernel -- which
+ # is what the recipe now does, and the reason one archive can serve
+ # both call paths. Compiling it as CUDA C++ here (as this test used
+ # to, mirroring the old recipe) would also define _sync twice.
+ # -Werror=implicit-function-declaration, explicitly: gcc still warns
+ # where clang errors, and the difference is exactly what let a
+ # declaration guarded behind __CUDACC__ pass here and fail CI.
+ r = subprocess.run([cc, "-std=c11", "-ffp-contract=off", "-Wall", "-Wextra", "-c",
+ "-Werror=implicit-function-declaration",
+ "-DROSENNA_NATIVE_KERNEL", f"{name}.c", "-o", f"{name}.o"],
+ cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ arch_flag = [f"-D__CUDA_ARCH__={arch}"] if arch else []
+ r = subprocess.run(common + arch_flag + [f"{name}_kernel.cu", "-o", f"{name}_kernel.o"], cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ # A plain C host TU links against the C++-compiled objects: the API
+ # has C linkage. With the stubbed runtime, init's upload and bind run
+ # on host memory and the "launch" is a serial call of the kernel body.
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ unbound = "" if embed else f"if ({name}_infer_batch(4, x, yb, 0) != 10) return 6;"
+ init = "" if embed else f'if ({name}_init("none.rwt") != 1) return 2; if ({name}_init("{name}.rwt") != 0) return 3;'
+ (d / "host.c").write_text(f"""
+#include "{name}.h"
+int main(void) {{ double x[4 * {n_in}], y[4 * {n_out}], yb[4 * {n_out}];
+ for (int c = 0; c < 4 * {n_in}; ++c) x[c] = 0.1 * c - 0.2;
+ {unbound}
+ {init}
+ for (int p = 0; p < 4; ++p) {name}_infer(x + p * {n_in}, y + p * {n_out});
+ if ({name}_infer_batch(4, x, yb, 0)) return 4;
+ for (int c = 0; c < 4 * {n_out}; ++c) if (y[c] != yb[c]) return 5;
+ if ({name}_infer_one(x + 2 * {n_in}, yb, 0)) return 7;
+ for (int c = 0; c < {n_out}; ++c) if (y[2 * {n_out} + c] != yb[c]) return 8;
+ return {name}_infer_batch(0, x, yb, 0); }}
+""")
+ r = subprocess.run([cc, "-std=c11", "-ffp-contract=off", "-c", "host.c", "-o", "host.o"], cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ r = subprocess.run([cxx, "host.o", f"{name}.o", f"{name}_kernel.o", "-lm", "-o", "host"], cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ if embed and arch is None:
+ continue # the kernel would hit the R9 host stub's assert
+ r = subprocess.run(["./host"], cwd=d, capture_output=True, text=True)
+ assert r.returncode == 0, (embed, arch, r.returncode, r.stderr)
+
+
+def test_recipe_selects_the_backend(golden_model):
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False)
+ mk = emit_c_recipe(plan)
+ assert "ROSENNA_BACKEND ?= omp" in mk
+ assert "ifeq ($(ROSENNA_BACKEND),cuda)" in mk and "else ifeq ($(ROSENNA_BACKEND),hip)" in mk
+ assert "DEVCC ?= nvcc" in mk and "DEVCC ?= hipcc" in mk
+ # Only the KERNEL goes to the device compiler. .c is built by the
+ # host compiler under every backend -- it owns the declare-target weights,
+ # which only that compiler can act on -- with the role macro telling it to
+ # yield the batched entry points to the kernel.
+ assert "-x hip -c $< -o $@" in mk, "the hip kernel is still compiled as HIP"
+ assert "-x cu -c $< -o $@" not in mk, "nothing is handed to nvcc as CUDA source any more"
+ assert "gemm_small_kernel.o: gemm_small_kernel.cu gemm_small.h rosenna_rt.h" in mk
+ assert mk.count("$(CC) $(CFLAGS) $(ROSENNA_PIC) $(ROSENNA_OFFLOAD_FLAGS) "
+ "-DROSENNA_NATIVE_KERNEL -c $< -o $@") == 2, \
+ "cuda and hip both build the .c with the host compiler"
+ assert "$(CC) $(CFLAGS) $(ROSENNA_PIC) $(ROSENNA_OFFLOAD_FLAGS) -c $< -o $@" in mk # omp
+ # -fPIC lives outside CFLAGS so overriding those cannot drop it; nvcc links
+ # PIE by default and nvc does not emit PIC by default, which is the pair
+ # that fails.
+ assert "ROSENNA_PIC ?= -fPIC" in mk
+
+
+def test_generate_writes_the_kernel_and_rt_header(tmp_path, golden_model):
+ from rosenna.cli import main
+ rc = main(["generate", str(golden_model("gemm_small")), "--lang", "c", "--out", str(tmp_path)])
+ assert rc == 0
+ for f in ["gemm_small.c", "gemm_small.h", "gemm_small.mk", "gemm_small_kernel.cu", "rosenna_rt.h"]:
+ assert (tmp_path / f).exists(), f
+ assert (tmp_path / "rosenna_rt.h").read_text() == rt_header()
+ rc = main(["generate", str(golden_model("gemm_small")), "--lang", "fortran", "--out", str(tmp_path / "f")])
+ assert rc == 0
+ assert not (tmp_path / "f" / "rosenna_rt.h").exists()
+
+
+_DEV = {"cuda": ("nvcc", "DEVFLAGS=-O2 -arch=sm_80"), "hip": ("hipcc", "DEVFLAGS=-O2 --offload-arch=gfx90a")}
+
+
+def _dev_build(d, name, plan, backend):
+ """Build lib.a with the backend's device compiler; compile only, no GPU needed."""
+ source, header = emit_c(plan)
+ (d / f"{name}.c").write_text(source); (d / f"{name}.h").write_text(header)
+ (d / f"{name}_kernel.cu").write_text(emit_kernel(plan)); (d / "rosenna_rt.h").write_text(rt_header())
+ (d / "Makefile").write_text(emit_c_recipe(plan))
+ devcc, flags = _DEV[backend]
+ r = subprocess.run(["make", f"ROSENNA_BACKEND={backend}", flags], cwd=d, capture_output=True, text=True)
+ # The device compiler's own diagnostics (warnings included) go to the CI
+ # log under -s: they are the only view of this code such a compiler gives us.
+ print(f"\n--- {devcc} {name} embed={plan.embed} dtype={plan.dtype} ---\n{r.stdout}{r.stderr}")
+ assert r.returncode == 0, r.stderr
+ assert (d / f"lib{name}.a").exists()
+
+
+def _devcc(backend):
+ return shutil.which(_DEV[backend][0])
+
+
+BACKENDS = [pytest.param(b, marks=pytest.mark.skipif(not shutil.which(_DEV[b][0]), reason=f"{_DEV[b][0]} not installed"))
+ for b in ("cuda", "hip")]
+
+
+@pytest.mark.parametrize("backend", BACKENDS)
+def test_device_backend_compiles(tmp_path, golden_model, backend):
+ # Compile only: the device compiler builds device code with no GPU present.
+ # Running needs the GPU gate.
+ for embed in (True, False):
+ name = "gemm_small"; graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=embed)
+ d = tmp_path / ("e" if embed else "f"); d.mkdir()
+ _dev_build(d, name, plan, backend)
+
+
+@pytest.mark.parametrize("backend", BACKENDS)
+@pytest.mark.parametrize("name", ["gemm_big", "gemm_nobias", "droplet", "batchnet"])
+@pytest.mark.parametrize("dtype", ["f32", "f64"])
+@pytest.mark.parametrize("embed", [True, False])
+def test_device_backend_compiles_every_dense_model(tmp_path, golden_model, name, dtype, embed, backend):
+ # The activations (tanhf/expf and their double forms) and every weight
+ # layout the plan can produce must also pass the device compiler.
+ plan = build_plan(load_graph(golden_model(name)), dtype=dtype, embed=embed)
+ _dev_build(tmp_path, name, plan, backend)
+
+
+@pytest.mark.parametrize("backend", BACKENDS)
+def test_device_backend_compiles_past_the_constant_memory_budget(tmp_path, backend):
+ # An embedded model over CONSTANT_MEMORY_LIMIT takes the __device__ const
+ # path (ruling R4); the device compiler must accept that header too.
+ plan = _embedded_plan(tmp_path, "over", 64, 100)
+ assert "__device__ const" in emit_c(plan)[1]
+ d = tmp_path / "b"; d.mkdir()
+ _dev_build(d, "over", plan, backend)
+
+
+@pytest.mark.parametrize("backend", BACKENDS)
+def test_device_backend_compiles_a_whole_field_model(tmp_path, backend):
+ # A plan whose per-point locals exceed a thread's stack: the archive's
+ # infer_batch must route through infer_one, or hipcc refuses the kernel
+ # ("stack frame size exceeds limit").
+ from tests.test_layer_kernels import _whole_field_plan
+ plan = _whole_field_plan(tmp_path)
+ d = tmp_path / "w"; d.mkdir()
+ _dev_build(d, "field", plan, backend)
+
+
+@pytest.mark.parametrize("backend", BACKENDS)
+def test_device_compiler_links_a_driver_against_the_archive(tmp_path, golden_model, backend):
+ # The way the gate and the examples link: the driver .cu plus the archive
+ # as -L/-l. A bare lib.a after the .cu is compiled as source by
+ # hipcc (it puts -x hip ahead of the .cu and it applies to what follows).
+ name = "gemm_small"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=True)
+ _dev_build(tmp_path, name, plan, backend)
+ (tmp_path / "driver.cu").write_text(f"""
+#include "rosenna_rt.h"
+#include "{name}.h"
+int main(void) {{ double *x = 0, *y = 0; (void)x; (void)y;
+ return {name}_infer_batch(0, x, y, 0) + {name}_infer_one(x, y, 0) * 0 + {name}_sync(0) * 0; }}
+""")
+ devcc, flags = _DEV[backend]
+ r = subprocess.run([devcc, *flags.split("=", 1)[1].split(), "driver.cu", "-L.", f"-l{name}", "-o", "driver"],
+ cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+
+
+def test_every_backend_defines_a_sync_the_host_can_call(golden_model):
+ # infer_batch never synchronizes (ruling R5: the caller owns the stream),
+ # but an OpenMP host has no stream of its own: it maps arrays, hands
+ # infer_batch their device addresses, and its next target region runs on
+ # libomptarget's queue with no ordering against a HIP/CUDA null-stream
+ # launch. Seen on an MI210: an OpenMP scatter read a hip infer_batch's
+ # output before the kernel finished. _sync(stream) is the
+ # backend-agnostic wait: StreamSynchronize in the cuda/hip archive, a
+ # no-op in the omp one (whose loop is synchronous), so one solver source
+ # links against any ROSENNA_BACKEND.
+ name = "gemm_small"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=True)
+ source, header = emit_c(plan)
+ assert f"int {name}_sync(void *stream);" in header
+ assert f"int {name}_sync(void *stream) {{\n (void)stream;\n return 0;\n}}" in source
+ kernel = emit_kernel(plan)
+ assert f'extern "C" int {name}_sync(void *stream) {{' in kernel
+ assert "ROSENNA_SYNC((ROSENNA_STREAM_T)stream)" in kernel
+ fortran = emit_fortran(plan)
+ assert f'bind(C, name="{name}_sync")' in fortran
+
+
+def test_fortran_module_binds_the_archive_init_for_a_file_loaded_plan(golden_model):
+ # A Fortran host that calls a file-loaded model's native kernel through
+ # _infer_batch_dev must also run the ARCHIVE's init (the C one
+ # that uploads the archive's device copies); the module's own init only
+ # fills the module's arrays for the Fortran per-point path. Seen on an
+ # MI210: infer_batch_dev returned 10 after the Fortran init alone.
+ name = "gemm_big"; plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=False)
+ fortran = emit_fortran(plan)
+ assert f'bind(C, name="{name}_init")' in fortran
+ assert f"public :: {name}_init_dev" in fortran
+ # An embedded plan has no init on either side.
+ plan_e = build_plan(load_graph(golden_model(name)), dtype="f64", embed=True)
+ assert "_init_dev" not in emit_fortran(plan_e)
+
+
+# --- infer_one launch fusion ----------------------------------------------
+
+def test_fusion_groups_runs_of_small_ops(golden_model):
+ """batchnet is all small ops, mnist is large ones with a small tail.
+
+ infer_one launched one kernel per op, which for batchnet measured 60.07 us
+ a call on an A100 -- 5.46 us per launch for eleven ops totalling ~200
+ elements, so almost pure launch overhead. One fused kernel took that to
+ 13.44 us.
+ """
+ from rosenna.emit_kernel import FUSE_THREADS, _fusion_runs
+ plan = build_plan(load_graph(golden_model("batchnet")), dtype="f64", embed=True)
+ runs = _fusion_runs(plan)
+ assert len(runs) == 1 and runs[0][0], "every batchnet op fits in a block"
+ assert len(runs[0][1]) == 11
+
+ plan = build_plan(load_graph(golden_model("mnist")), dtype="f64", embed=True)
+ runs = _fusion_runs(plan)
+ fused = [members for fusedp, members in runs if fusedp]
+ assert len(fused) == 1, "only the small tail fuses"
+ assert [elem_length(op) for _, op in fused[0]] == [256, 10, 10]
+ # The big ops keep their own grid: one block could not give them the
+ # parallelism they need, so fusing them would trade 5 us for much more.
+ for fusedp, members in runs:
+ if not fusedp:
+ assert elem_length(members[0][1]) > FUSE_THREADS or len(members) == 1
+
+
+def test_a_fused_kernel_barriers_between_ops_and_launches_one_block(golden_model):
+ """The barrier count and the grid are what make the fusion sound.
+
+ __syncthreads() is a full barrier over a single BLOCK, so the launch has to
+ be one block; and the barrier must sit outside the `if (e < len)` guard,
+ because a __syncthreads() that only some threads reach is undefined.
+ """
+ plan = build_plan(load_graph(golden_model("batchnet")), dtype="f64", embed=True)
+ cu = emit_kernel(plan)
+ body = _function_body(cu, "static __global__ void batchnet_f0(")
+ assert body.count("__syncthreads();") == 10, "one barrier between each of 11 ops"
+ for line in body.splitlines():
+ s = line.strip()
+ if s.startswith("for (int e ="):
+ assert "__syncthreads" not in s, f"barrier inside a loop: {s}"
+ # Strided over the block, so a block of one still computes every element --
+ # which is what the stubbed host build in this file relies on.
+ assert "e += (int)blockDim.x)" in body
+ assert "ROSENNA_LAUNCH(batchnet_f0, 1, ROSENNA_FUSE, s, x, y);" in cu
+ # And infer_one is now one launch, not eleven.
+ one = _function_body(cu, 'extern "C" int batchnet_infer_one(')
+ assert one.count("ROSENNA_LAUNCH(") == 1, one
+
+
+def test_device_state_is_per_device(golden_model):
+ """A multi-GPU host holds weights on several devices at once.
+
+ One set of _dev pointers meant the second init replaced the first
+ device's addresses, and the first device's __constant__ table then pointed
+ at memory belonging to another device -- a kernel reading a valid-looking
+ address on the wrong one. Verified on two A100s: both devices' infer_batch
+ matched a host reference to 1.11e-16.
+ """
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ cu = emit_kernel(plan)
+ assert "extern double *gemm_small_w0_dev[ROSENNA_MAX_DEVICES];" in header
+ # The per-translation-unit bind reads the CURRENT device's slot.
+ bind = _function_body(header, "static inline int gemm_small_device_bind_here(")
+ assert "ROSENNA_GET_DEVICE(&rosenna_dev)" in bind
+ assert "gemm_small_w0_dev[rosenna_dev]," in bind
+ assert "rosenna_dev >= ROSENNA_MAX_DEVICES) return 13;" in bind
+ # infer_one's ordering state is host-side and had to follow the buffers,
+ # which are __device__ and so already per device.
+ one = _function_body(cu, 'extern "C" int gemm_small_infer_one(')
+ assert "gemm_small_one_done[rdev]" in one and "gemm_small_one_stream[rdev]" in one
+ # And the null check is per device, so initializing device 0 and launching
+ # on device 1 is status 10 rather than a wrong answer.
+ batch = _function_body(cu, 'extern "C" int gemm_small_infer_batch(')
+ assert "gemm_small_w0_dev[rdev] == 0) return 10;" in batch
+
+
+def test_the_omp_fallback_explains_why_it_has_no_launch_status(golden_model):
+ """The asymmetry with the cuda/hip form's status 11 is reasoned, not missing.
+
+ OpenMP has no launch-status API; omp_target_is_present takes a host
+ pointer while ruling R5 says x and y are device pointers; and a deviceless
+ run is a documented configuration (--backend omp --host-fallback), so it
+ cannot be an error either.
+ """
+ import inspect
+ from rosenna import emit_c as ec
+ # Whitespace-normalised: the phrases wrap across lines in the source.
+ doc = " ".join((inspect.getdoc(ec._emit_fallback_infer_batch) or "").split())
+ assert "no launch-status API" in doc
+ assert "omp_target_is_present" in doc and "host-fallback" in doc
diff --git a/python/tests/test_layer_kernels.py b/python/tests/test_layer_kernels.py
new file mode 100644
index 0000000..c8979c6
--- /dev/null
+++ b/python/tests/test_layer_kernels.py
@@ -0,0 +1,225 @@
+"""Register-blocked dense layers, and the layer-parallel infer_one.
+
+A dense layer computes GEMM_BLOCK output columns per pass over its input
+vector, in every backend and both languages. infer_one runs ONE sample with
+a launch per op and the thread index over the op's output elements, the
+intermediate activations in static device buffers rather than per-thread
+locals -- the form a whole-field model (a conv net over a grid) needs. It
+shares the op bodies with the header's per-point infer.
+"""
+import os
+import shutil
+import subprocess
+
+import numpy as np
+import pytest
+
+from rosenna.emit_c import GEMM_BLOCK, emit_c, emit_c_recipe, large_locals
+from rosenna.emit_fortran import emit_fortran
+from rosenna.emit_kernel import emit_kernel
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.rt_header import rt_header
+from rosenna.weights import write_weights
+from tests.conftest import _assert_warning_free
+from tests.test_device_c import _omp_cc
+
+DENSE = "gemm_big"
+CONV = "conv_padding"
+LSTM = "lstm_gemm_hid"
+
+
+def test_wide_dense_layers_are_register_blocked_in_both_languages(tmp_path, golden_model):
+ # GEMM_BLOCK output columns per pass over the input vector, in the header's
+ # infer (which the batched kernel calls per point) and in the Fortran
+ # module, for a layer at least GEMM_BLOCK_MIN_IN wide; narrower layers
+ # (all of gemm_big's) keep the one-column loop.
+ plan = _wide_plan(tmp_path)
+ source, header = emit_c(plan)
+ assert f"a{GEMM_BLOCK - 1} += sj *" in header and "const double sj = " in header
+ assert f"a{GEMM_BLOCK - 1} = a{GEMM_BLOCK - 1} + sj *" in emit_fortran(plan)
+ cu = emit_kernel(plan)
+ assert "wide_infer(" in cu and "__shared__" not in cu
+ narrow = build_plan(load_graph(golden_model(DENSE)), dtype="f64", embed=True)
+ assert "sj" not in emit_c(narrow)[1] and "sj" not in emit_fortran(narrow)
+
+
+def _wide_plan(tmp_path):
+ from onnx import helper, numpy_helper
+ from tests.conftest import save_model
+ rng = np.random.default_rng(2)
+ w = lambda shape, name: numpy_helper.from_array(rng.uniform(-0.1, 0.1, shape).astype(np.float32), name)
+ nodes = [helper.make_node("Gemm", ["x", "w0", "b0"], ["a"], name="g0", transB=1),
+ helper.make_node("Tanh", ["a"], ["b"], name="t0"),
+ helper.make_node("Gemm", ["b", "w1", "b1"], ["y"], name="g1", transB=1)]
+ path = save_model(tmp_path, "wide", nodes, [w((64, 128), "w0"), w((64,), "b0"), w((3, 64), "w1"), w((3,), "b1")],
+ (1, 128), (1, 3))
+ return build_plan(load_graph(path), dtype="f64", embed=True)
+
+
+def test_infer_one_is_declared_and_defined_unless_the_plan_has_an_lstm(golden_model):
+ for name in (DENSE, CONV):
+ plan = build_plan(load_graph(golden_model(name)), dtype="f64", embed=True)
+ source, header = emit_c(plan)
+ assert f"int {name}_infer_one(const double *ROSENNA_RESTRICT x, double *ROSENNA_RESTRICT y, void *stream);" in header
+ assert f"int {name}_infer_one(" in source # the omp fallback
+ assert f'extern "C" int {name}_infer_one(' in emit_kernel(plan)
+ assert f'bind(C, name="{name}_infer_one")' in emit_fortran(plan)
+ plan = build_plan(load_graph(golden_model(LSTM)), dtype="f64", embed=True)
+ source, header = emit_c(plan)
+ assert "_infer_one" not in header and "_infer_one" not in source
+ assert "_infer_one" not in emit_kernel(plan) and "_infer_one" not in emit_fortran(plan)
+
+
+_HOST = """
+#include
+#include
+#include "{name}.h"
+int main(void) {{
+ {init}
+ double x[{n_in}], y[{n_out}], y1[{n_out}];
+ for (int i = 0; i < {n_in}; ++i) x[i] = sin(0.37 * i) - 0.2;
+ {name}_infer(x, y);
+ if ({name}_infer_one(x, y1, 0)) return 3;
+ for (int i = 0; i < {n_out}; ++i)
+ if (!(fabs(y[i] - y1[i]) <= 1e-12 + 1e-12 * fabs(y[i]))) {{ printf("differ at %d: %.17g %.17g\\n", i, y[i], y1[i]); return 4; }}
+ printf("agree\\n");
+ return 0;
+}}
+"""
+
+
+@pytest.mark.parametrize("name,embed", [(DENSE, True), (DENSE, False), (CONV, True), ("batchnet", True),
+ ("maxpool_padding", True), ("avgpool_basic", True)])
+def test_omp_infer_one_matches_the_per_point_infer_on_the_host(tmp_path, golden_model, name, embed):
+ # The omp fallback of infer_one: one target loop per op over the static
+ # buffers. On a host-only build the loops are plain loops, so the
+ # layer-parallel form is checked numerically here, without a GPU.
+ graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=embed)
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source); (tmp_path / f"{name}.h").write_text(header)
+ (tmp_path / "Makefile").write_text(emit_c_recipe(plan))
+ init = ""
+ if not embed:
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+ init = f'if ({name}_init("{name}.rwt")) return 2;'
+ cc = _omp_cc()
+ r = subprocess.run(["make", f"CC={cc}", "ROSENNA_BACKEND=omp", "ROSENNA_OFFLOAD_FLAGS=-fopenmp"],
+ cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ (tmp_path / "host.c").write_text(_HOST.format(name=name, n_in=plan.input.shape[0],
+ n_out=plan.output.shape[0], init=init))
+ r = subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-fopenmp", "host.c", f"lib{name}.a",
+ "-lm", "-o", "host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ _assert_warning_free("gcc", r.stderr)
+ r = subprocess.run(["./host"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0 and r.stdout.strip() == "agree", (r.returncode, r.stdout, r.stderr)
+
+
+# --- on a GPU: hipcc-built archive, infer_batch (staged) and infer_one against the host infer ---
+
+_DEV = """
+#include
+#include
+#include
+#include "rosenna_rt.h"
+#include "{name}.h"
+#define NPTS 1000
+int main(void) {{
+ {init}
+ double *hx = (double*)malloc(sizeof(double) * NPTS * {n_in}), *hy = (double*)malloc(sizeof(double) * NPTS * {n_out});
+ double *ref = (double*)malloc(sizeof(double) * NPTS * {n_out}), *one = (double*)malloc(sizeof(double) * {n_out});
+ for (int c = 0; c < NPTS * {n_in}; ++c) hx[c] = sin(0.37 * c) - 0.2;
+ double *dx, *dy;
+ ROSENNA_MALLOC(&dx, sizeof(double) * NPTS * {n_in}); ROSENNA_MALLOC(&dy, sizeof(double) * NPTS * {n_out});
+ ROSENNA_MEMCPY_H2D(dx, hx, sizeof(double) * NPTS * {n_in});
+ if ({name}_infer_batch(NPTS, dx, dy, 0)) return 3;
+ if ({name}_sync(0)) return 3;
+ {memcpy_d2h}(hy, dy, sizeof(double) * NPTS * {n_out});
+ {infer_one}
+ /* reference: the host infer, on the host (file-loaded) or via a one-thread kernel (embedded) */
+ {reference}
+ int bad = 0;
+ for (int c = 0; c < NPTS * {n_out}; ++c) if (!(fabs(hy[c] - ref[c]) <= 1e-9 + 1e-9 * fabs(ref[c]))) ++bad;
+ printf("infer_batch: %d of %d mismatch\\n", bad, NPTS * {n_out});
+ {check_one}
+ return bad ? 4 : 0;
+}}
+"""
+
+
+def _gpu_available():
+ return shutil.which("hipcc") and shutil.which("rocminfo") and \
+ "gfx" in subprocess.run(["rocminfo"], capture_output=True, text=True).stdout
+
+
+@pytest.mark.parametrize("name", [DENSE, CONV])
+def test_hip_kernels_match_the_host_infer(tmp_path, golden_model, name):
+ if not _gpu_available():
+ pytest.skip("no hipcc + AMD GPU")
+ graph = load_graph(golden_model(name)); plan = build_plan(graph, dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ for fn, text in ((f"{name}.c", source), (f"{name}.h", header), (f"{name}_kernel.cu", emit_kernel(plan)),
+ ("rosenna_rt.h", rt_header()), ("Makefile", emit_c_recipe(plan))):
+ (tmp_path / fn).write_text(text)
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+ r = subprocess.run(["make", "ROSENNA_BACKEND=hip"], cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ (tmp_path / "driver.cu").write_text(_DEV.format(
+ name=name, n_in=n_in, n_out=n_out, init=f'if ({name}_init("{name}.rwt")) return 2;',
+ memcpy_d2h="hipMemcpy_d2h",
+ infer_one=f"if ({name}_infer_one(dx + 3 * {n_in}, dy, 0)) return 5; if ({name}_sync(0)) return 5; "
+ f"hipMemcpy_d2h(one, dy, sizeof(double) * {n_out});",
+ reference=f"for (int p = 0; p < NPTS; ++p) {name}_infer(hx + p * {n_in}, ref + p * {n_out});",
+ check_one=f"int bad1 = 0; for (int i = 0; i < {n_out}; ++i) if (!(fabs(one[i] - ref[3 * {n_out} + i]) <= 1e-9 + 1e-9 * fabs(ref[3 * {n_out} + i]))) ++bad1; "
+ f'printf("infer_one: %d of %d mismatch\\n", bad1, {n_out}); bad += bad1;'))
+ (tmp_path / "driver.cu").write_text(
+ "#define hipMemcpy_d2h(h, d, n) hipMemcpy((h), (d), (n), hipMemcpyDeviceToHost)\n"
+ + (tmp_path / "driver.cu").read_text())
+ r = subprocess.run(["hipcc", "-O2", "driver.cu", "-L.", f"-l{name}", "-o", "driver"],
+ cwd=tmp_path, capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+ r = subprocess.run(["./driver"], cwd=tmp_path, capture_output=True, text=True, timeout=300)
+ assert r.returncode == 0, (r.returncode, r.stdout, r.stderr)
+
+
+def _whole_field_plan(tmp_path):
+ """A conv net over a 64x64 field: 8 x 72 x 72 + 8 x 68 x 68 doubles of activations, 660 KB."""
+ from onnx import helper, numpy_helper
+ from tests.conftest import save_model
+ rng = np.random.default_rng(1)
+ w = lambda shape, name: numpy_helper.from_array(rng.uniform(-0.1, 0.1, shape).astype(np.float32), name)
+ nodes = [helper.make_node("Conv", ["x", "w0"], ["a"], name="c0", kernel_shape=[5, 5]),
+ helper.make_node("Tanh", ["a"], ["b"], name="t0"),
+ helper.make_node("Conv", ["b", "w1"], ["c"], name="c1", kernel_shape=[5, 5]),
+ helper.make_node("Tanh", ["c"], ["d"], name="t1"),
+ helper.make_node("Conv", ["d", "w2"], ["y"], name="c2", kernel_shape=[5, 5])]
+ path = save_model(tmp_path, "field", nodes, [w((8, 1, 5, 5), "w0"), w((8, 8, 5, 5), "w1"), w((1, 8, 5, 5), "w2")],
+ (1, 1, 76, 76), (1, 1, 64, 64))
+ return build_plan(load_graph(path), dtype="f64", embed=True)
+
+
+def test_infer_batch_of_a_whole_field_model_runs_infer_one_per_point(tmp_path):
+ # The per-point infer of this plan would hold 660 KB of locals, more than a
+ # device thread's stack (hipcc: "stack frame size exceeds limit"), so the
+ # archive's infer_batch must not instantiate it: it loops infer_one over the
+ # points instead, and the omp fallback does the same.
+ plan = _whole_field_plan(tmp_path)
+ assert large_locals(plan)
+ cu = emit_kernel(plan)
+ assert "field_kernel" not in cu and "field_infer_one(x + (size_t)p * 5776" in cu
+ source, _ = emit_c(plan)
+ batch = source[source.index("int field_infer_batch("):]
+ assert "field_infer_one(x + (size_t)p * 5776" in batch and "field_infer(x" not in batch.split("\n}")[0]
+ small = build_plan(load_graph(_golden(tmp_path)), dtype="f64", embed=True)
+ assert not large_locals(small)
+
+
+def _golden(tmp_path):
+ from tests.conftest import save_model
+ from onnx import helper, numpy_helper
+ w = numpy_helper.from_array(np.eye(2, dtype=np.float32), "w")
+ return save_model(tmp_path, "tiny", [helper.make_node("MatMul", ["x", "w"], ["y"], name="m")], [w], (1, 2), (1, 2))
diff --git a/python/tests/test_library_form.py b/python/tests/test_library_form.py
new file mode 100644
index 0000000..42c2f8a
--- /dev/null
+++ b/python/tests/test_library_form.py
@@ -0,0 +1,254 @@
+import re
+import shutil
+import subprocess
+import numpy as np
+import onnxruntime as ort
+import pytest
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.weights import write_weights
+from rosenna.emit_c import emit_c, emit_c_recipe
+from tests.test_emit_fortran import _live_reference
+
+
+def _cc():
+ for cand in ("gcc-15", "gcc-14", "gcc-13", "gcc"):
+ if shutil.which(cand):
+ return cand
+ pytest.skip("no C compiler found")
+
+
+def test_header_defines_inline_infer_and_source_does_not(golden_model):
+ # embed=False: this test is specifically about the file-loaded contract
+ # (extern declaration in the header, definition in the source).
+ plan = build_plan(load_graph(golden_model("gemm_small")), dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ assert "static inline ROSENNA_DEVICE_FN void gemm_small_infer(" in header
+ # Structural check (controller ruling P1): infer must be defined only in
+ # the header, never in the source, regardless of how the source happens
+ # to spell a call to it.
+ assert "static inline" not in source
+ assert re.search(r"^void gemm_small_infer\(", source, re.MULTILINE) is None
+ # Weight symbols carry the model-name prefix (controller ruling R1) so
+ # two different models' weight arrays never collide once `static` is
+ # dropped and they gain external linkage.
+ assert "extern double gemm_small_w0[" in header
+ assert "\ndouble gemm_small_w0[" in source and "static double gemm_small_w0[" not in source
+
+
+def test_library_and_header_inline_agree(tmp_path, golden_model):
+ name = "gemm_small"
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source)
+ (tmp_path / f"{name}.h").write_text(header)
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ (tmp_path / "host.c").write_text(f"""
+#include
+#include "{name}.h"
+int main(void) {{
+ double x[{n_in}], y[{n_out}];
+ int n; if ({name}_init("{name}.rwt")) return 2;
+ if (scanf("%d", &n) != 1) return 1;
+ for (int c = 0; c < n; ++c) {{
+ for (int i = 0; i < {n_in}; ++i) if (scanf("%lf", &x[i]) != 1) return 1;
+ {name}_infer(x, y); /* the header inline, called from the host TU */
+ for (int i = 0; i < {n_out}; ++i) printf("%.17e ", y[i]);
+ printf("\\n");
+ }}
+ return 0;
+}}
+""")
+ cc = _cc()
+ subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-c", f"{name}.c"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ subprocess.run(["ar", "rcs", f"lib{name}.a", f"{name}.o"], cwd=tmp_path, check=True)
+ subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "host.c", f"lib{name}.a", "-lm", "-o", "host"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ session = ort.InferenceSession(golden_model(name))
+ inputs, expected = _live_reference(session, session.get_inputs()[0].shape, np.float64, seed=3, batch=8)
+ if inputs is None:
+ pytest.skip(f"{name}: onnxruntime reference is all-zero across 10 resampled "
+ f"batches; its golden-file weights produced a dead model")
+ stdin = f"{len(inputs)}\n" + "\n".join(" ".join(repr(float(v)) for v in row) for row in inputs)
+ out = subprocess.run(["./host"], cwd=tmp_path, input=stdin, capture_output=True, text=True, check=True).stdout
+ got = np.array([[float(v) for v in line.split()] for line in out.strip().splitlines()])
+ np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_two_models_link_into_one_host(tmp_path, golden_model):
+ """Two different models' libraries must link into one host binary.
+
+ Reproduces the defect the reviewer found in 2e60a5d: dropping `static`
+ from the weight definitions, without also prefixing them with the model
+ name, gives every model's `w0`/`b0`/... external linkage under the same
+ names (plan.py names weights identically across models), so `gemm_small`
+ and `gemm_nobias` linked into one binary fail with
+ `ld: duplicate symbols '_w0'`. Controller ruling R1: every C weight
+ symbol is prefixed with the model name, so this must link, run, and each
+ model's output must match its own onnxruntime reference.
+ """
+ names = ["gemm_small", "gemm_nobias"]
+ plans, sessions = {}, {}
+ cc = _cc()
+ obj_args = []
+ for name in names:
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64", embed=False)
+ plans[name] = plan
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source)
+ (tmp_path / f"{name}.h").write_text(header)
+ write_weights(plan, graph, tmp_path / f"{name}.rwt")
+ subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-c", f"{name}.c"],
+ cwd=tmp_path, check=True, capture_output=True, text=True)
+ subprocess.run(["ar", "rcs", f"lib{name}.a", f"{name}.o"], cwd=tmp_path, check=True)
+ obj_args.append(f"lib{name}.a")
+ sessions[name] = ort.InferenceSession(golden_model(name))
+
+ references = {}
+ for name in names:
+ session = sessions[name]
+ shape = session.get_inputs()[0].shape
+ inputs, expected = _live_reference(session, shape, np.float64, seed=5, batch=8)
+ if inputs is None:
+ pytest.skip(f"{name}: onnxruntime reference is all-zero across 10 resampled "
+ f"batches; its golden-file weights produced a dead model")
+ references[name] = (inputs, expected)
+
+ host_lines = ["#include "]
+ host_lines += [f'#include "{name}.h"' for name in names]
+ host_lines.append("int main(void) {")
+ for name in names:
+ plan = plans[name]
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ host_lines.append(f" double {name}_x[{n_in}], {name}_y[{n_out}];")
+ host_lines.append(f' if ({name}_init("{name}.rwt")) return 2;')
+ for name in names:
+ plan = plans[name]
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ host_lines.append(" { int n; if (scanf(\"%d\", &n) != 1) return 1;")
+ host_lines.append(" for (int c = 0; c < n; ++c) {")
+ host_lines.append(
+ f" for (int i = 0; i < {n_in}; ++i) "
+ f"if (scanf(\"%lf\", &{name}_x[i]) != 1) return 1;")
+ host_lines.append(f" {name}_infer({name}_x, {name}_y);")
+ host_lines.append(
+ f" for (int i = 0; i < {n_out}; ++i) printf(\"%.17e \", {name}_y[i]);")
+ host_lines.append(' printf("\\n");')
+ host_lines.append(" } }")
+ host_lines.append(" return 0;")
+ host_lines.append("}")
+ (tmp_path / "host.c").write_text("\n".join(host_lines) + "\n")
+
+ subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "host.c", *obj_args, "-lm", "-o", "host"],
+ cwd=tmp_path, check=True, capture_output=True, text=True)
+
+ stdin_parts = []
+ for name in names:
+ inputs, _ = references[name]
+ stdin_parts.append(str(len(inputs)))
+ stdin_parts.append("\n".join(" ".join(repr(float(v)) for v in row) for row in inputs))
+ stdin = "\n".join(stdin_parts) + "\n"
+ out = subprocess.run(["./host"], cwd=tmp_path, input=stdin, capture_output=True, text=True, check=True).stdout
+ all_lines = out.strip().splitlines()
+ pos = 0
+ for name in names:
+ inputs, expected = references[name]
+ got = np.array([[float(v) for v in line.split()] for line in all_lines[pos:pos + len(inputs)]])
+ pos += len(inputs)
+ np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_two_embedded_models_link_into_one_host(tmp_path, golden_model):
+ """Two different models' EMBEDDED weights must link into one host binary.
+
+ Mirrors test_two_models_link_into_one_host above, but for embed=True
+ (the default for these small models). ROSENNA_CONST resolves to `static
+ const` on the host, which gives each array internal linkage -- so an
+ unprefixed `w0` in two headers would not raise a linker collision the
+ way the file-loaded case's external `w0` did -- but both headers still
+ land in the same translation unit here (host.c #includes both), and an
+ unprefixed `w0` would be a duplicate *definition* inside that one TU
+ regardless of linkage. Ruling R1 already prefixes embedded weight
+ symbols with the model name (see emit_c._emit_embedded_weights); this
+ test proves that rather than assuming it.
+ """
+ names = ["gemm_small", "gemm_nobias"]
+ plans, sessions = {}, {}
+ cc = _cc()
+ for name in names:
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64")
+ assert plan.embed is True, f"{name}: expected to auto-embed for this test to be meaningful"
+ plans[name] = plan
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source)
+ (tmp_path / f"{name}.h").write_text(header)
+ sessions[name] = ort.InferenceSession(golden_model(name))
+
+ references = {}
+ for name in names:
+ session = sessions[name]
+ shape = session.get_inputs()[0].shape
+ inputs, expected = _live_reference(session, shape, np.float64, seed=7, batch=8)
+ if inputs is None:
+ pytest.skip(f"{name}: onnxruntime reference is all-zero across 10 resampled "
+ f"batches; its golden-file weights produced a dead model")
+ references[name] = (inputs, expected)
+
+ host_lines = ["#include "]
+ host_lines += [f'#include "{name}.h"' for name in names]
+ host_lines.append("int main(void) {")
+ for name in names:
+ plan = plans[name]
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ host_lines.append(f" double {name}_x[{n_in}], {name}_y[{n_out}];")
+ for name in names:
+ plan = plans[name]
+ n_in, n_out = plan.input.shape[0], plan.output.shape[0]
+ host_lines.append(" { int n; if (scanf(\"%d\", &n) != 1) return 1;")
+ host_lines.append(" for (int c = 0; c < n; ++c) {")
+ host_lines.append(
+ f" for (int i = 0; i < {n_in}; ++i) "
+ f"if (scanf(\"%lf\", &{name}_x[i]) != 1) return 1;")
+ host_lines.append(f" {name}_infer({name}_x, {name}_y);")
+ host_lines.append(
+ f" for (int i = 0; i < {n_out}; ++i) printf(\"%.17e \", {name}_y[i]);")
+ host_lines.append(' printf("\\n");')
+ host_lines.append(" } }")
+ host_lines.append(" return 0;")
+ host_lines.append("}")
+ (tmp_path / "host.c").write_text("\n".join(host_lines) + "\n")
+
+ # No lib{name}.a to link: an embedded plan's .c is nearly empty and
+ # infer lives entirely in the header, so the host TU alone suffices.
+ subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "host.c", "-lm", "-o", "host"],
+ cwd=tmp_path, check=True, capture_output=True, text=True)
+
+ stdin_parts = []
+ for name in names:
+ inputs, _ = references[name]
+ stdin_parts.append(str(len(inputs)))
+ stdin_parts.append("\n".join(" ".join(repr(float(v)) for v in row) for row in inputs))
+ stdin = "\n".join(stdin_parts) + "\n"
+ out = subprocess.run(["./host"], cwd=tmp_path, input=stdin, capture_output=True, text=True, check=True).stdout
+ all_lines = out.strip().splitlines()
+ pos = 0
+ for name in names:
+ inputs, expected = references[name]
+ got = np.array([[float(v) for v in line.split()] for line in all_lines[pos:pos + len(inputs)]])
+ pos += len(inputs)
+ np.testing.assert_allclose(got, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_recipe_builds_the_library(tmp_path, golden_model):
+ name = "gemm_small"
+ graph = load_graph(golden_model(name))
+ plan = build_plan(graph, dtype="f64", embed=False)
+ source, header = emit_c(plan)
+ (tmp_path / f"{name}.c").write_text(source)
+ (tmp_path / f"{name}.h").write_text(header)
+ (tmp_path / "Makefile").write_text(emit_c_recipe(plan))
+ subprocess.run(["make", f"CC={_cc()}"], cwd=tmp_path, check=True, capture_output=True, text=True)
+ assert (tmp_path / f"lib{name}.a").exists()
diff --git a/python/tests/test_multi_output_concat.py b/python/tests/test_multi_output_concat.py
new file mode 100644
index 0000000..851abc8
--- /dev/null
+++ b/python/tests/test_multi_output_concat.py
@@ -0,0 +1,143 @@
+"""Several graph outputs, concatenated in y, and the Concat op.
+
+The multi-output contract mirrors the multi-input one: every graph output
+lands in the single y buffer, flat, in declaration order, so infer(x, y),
+infer_batch, the native kernel and the device contract are unchanged. That
+is what lets a recurrent model hand its new hidden and cell state back to
+a solver that keeps them resident across time steps.
+"""
+import numpy as np
+import onnx
+import onnxruntime as ort
+import pytest
+from onnx import helper, numpy_helper, TensorProto
+
+from rosenna.cli import main
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from tests.test_regressions import _both_backends, _reference
+
+
+def _f32(rng, shape, name):
+ return numpy_helper.from_array(rng.uniform(-1, 1, shape).astype(np.float32), name)
+
+
+def _save(directory, name, nodes, inits, inputs, outputs):
+ """Like conftest.save_model but with any number of inputs and outputs."""
+ ins = [helper.make_tensor_value_info(n, TensorProto.FLOAT, list(sh)) for n, sh in inputs]
+ outs = [helper.make_tensor_value_info(n, TensorProto.FLOAT, list(sh)) for n, sh in outputs]
+ g = helper.make_graph(nodes, name, ins, outs, initializer=inits)
+ m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 13)])
+ path = directory / f"{name}.onnx"
+ onnx.save(m, path)
+ return path
+
+
+def _ort_concat(session, x_rows):
+ """onnxruntime's outputs for each row of x, concatenated flat -- the y layout."""
+ names = [i.name for i in session.get_inputs()]
+ shapes = [[int(d) for d in i.shape] for i in session.get_inputs()]
+ lens = [int(np.prod(sh)) for sh in shapes]
+ out = []
+ for row in x_rows:
+ feed, off = {}, 0
+ for n, sh, ln in zip(names, shapes, lens):
+ feed[n] = row[off:off + ln].reshape(sh).astype(np.float32)
+ off += ln
+ out.append(np.concatenate([o.ravel() for o in session.run(None, feed)]))
+ return np.array(out)
+
+
+def test_two_outputs_land_concatenated_in_y(tmp_path):
+ # y = [r (1,3), g (1,3)]: the activation's output first, then the
+ # pre-activation it was computed from, in declaration order.
+ rng = np.random.default_rng(1)
+ w = _f32(rng, (2, 3), "w")
+ nodes = [
+ helper.make_node("MatMul", ["x", "w"], ["g"], name="mm0"),
+ helper.make_node("Relu", ["g"], ["r"], name="r0"),
+ ]
+ path = _save(tmp_path, "two", nodes, [w], [("x", (1, 2))], [("r", (1, 3)), ("g", (1, 3))])
+ plan = build_plan(load_graph(path), dtype="f64", embed=False)
+ assert plan.output.shape == (6,)
+ x = np.random.default_rng(2).uniform(-2, 2, (5, 2))
+ expected = _ort_concat(ort.InferenceSession(str(path)), x)
+ f_out, c_out = _both_backends(tmp_path, path, "two", x)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_recurrent_lstm_round_trips_its_state_through_x_and_y(tmp_path):
+ # The model a solver keeps per cell: x = [p, h, c], y = [Y, h', c'].
+ # Two steps are chained by feeding y's h'/c' back into x, and the second
+ # step must match onnxruntime driven the same way.
+ rng = np.random.default_rng(3)
+ H, I = 4, 1
+ W, R, B = _f32(rng, (1, 4 * H, I), "W"), _f32(rng, (1, 4 * H, H), "R"), _f32(rng, (1, 8 * H), "B")
+ nodes = [helper.make_node("LSTM", ["p", "W", "R", "B", "", "h", "c"], ["Y", "hn", "cn"],
+ name="l0", hidden_size=H)]
+ path = _save(tmp_path, "cell", nodes, [W, R, B],
+ [("p", (1, 1, I)), ("h", (1, 1, H)), ("c", (1, 1, H))],
+ [("Y", (1, 1, 1, H)), ("hn", (1, 1, H)), ("cn", (1, 1, H))])
+ plan = build_plan(load_graph(path), dtype="f64", embed=True)
+ assert plan.input.shape == (I + 2 * H,) and plan.output.shape == (3 * H,)
+ session = ort.InferenceSession(str(path))
+ x0 = np.random.default_rng(4).uniform(-1, 1, (3, I + 2 * H))
+ y0 = _ort_concat(session, x0)
+ x1 = np.concatenate([x0[:, :I], y0[:, H:2 * H], y0[:, 2 * H:]], axis=1) # feed h', c' back
+ expected = np.concatenate([y0, _ort_concat(session, x1)])
+ f_out, c_out = _both_backends(tmp_path, path, "cell", np.concatenate([x0, x1]))
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_info_prints_the_x_and_y_layouts(tmp_path, capsys):
+ rng = np.random.default_rng(3)
+ H, I = 4, 1
+ W, R = _f32(rng, (1, 4 * H, I), "W"), _f32(rng, (1, 4 * H, H), "R")
+ nodes = [helper.make_node("LSTM", ["p", "W", "R", "", "", "h", "c"], ["Y", "hn", "cn"],
+ name="l0", hidden_size=H)]
+ path = _save(tmp_path, "cell", nodes, [W, R],
+ [("p", (1, 1, I)), ("h", (1, 1, H)), ("c", (1, 1, H))],
+ [("Y", (1, 1, 1, H)), ("hn", (1, 1, H)), ("cn", (1, 1, H))])
+ assert main(["info", str(path)]) == 0
+ out = capsys.readouterr().out
+ for line in ("x: p[0:1] h[1:5] c[5:9]", "y: Y[0:4] hn[4:8] cn[8:12]"):
+ assert line in out, out
+
+
+@pytest.mark.parametrize("axis", [1, 2])
+def test_concat_of_runtime_values_along_an_axis(tmp_path, axis):
+ # Two branches of x concatenated along a non-leading axis: the emitted
+ # loop is outer x (block per input), resolved at generation time.
+ rng = np.random.default_rng(5)
+ w0, w1 = _f32(rng, (3, 3), "w0"), _f32(rng, (3, 3), "w1")
+ nodes = [
+ helper.make_node("MatMul", ["x", "w0"], ["a"], name="mm0"),
+ helper.make_node("MatMul", ["x", "w1"], ["b"], name="mm1"),
+ helper.make_node("Relu", ["b"], ["r"], name="r0"),
+ helper.make_node("Concat", ["a", "r"], ["y"], name="c0", axis=axis),
+ ]
+ out_shape = (1, 4, 3) if axis == 1 else (1, 2, 6)
+ path = _save(tmp_path, f"cat{axis}", nodes, [w0, w1], [("x", (1, 2, 3))], [("y", out_shape)])
+ inputs, expected = _reference(path, [1, 2, 3])
+ f_out, c_out = _both_backends(tmp_path, path, f"cat{axis}", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_concat_with_a_constant_operand(tmp_path):
+ # A constant concatenated onto a runtime value (a bias-like feature
+ # appended before a dense layer) becomes a weight.
+ rng = np.random.default_rng(6)
+ k = numpy_helper.from_array(np.array([[0.5, -1.5]], np.float32), "k")
+ w = _f32(rng, (4, 2), "w")
+ nodes = [
+ helper.make_node("Concat", ["x", "k"], ["xk"], name="c0", axis=1),
+ helper.make_node("Gemm", ["xk", "w"], ["y"], name="g0", transB=0),
+ ]
+ path = _save(tmp_path, "catk", nodes, [k, w], [("x", (1, 2))], [("y", (1, 2))])
+ inputs, expected = _reference(path, [1, 2])
+ f_out, c_out = _both_backends(tmp_path, path, "catk", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
diff --git a/python/tests/test_plan.py b/python/tests/test_plan.py
index b7765ab..d3cdf8f 100644
--- a/python/tests/test_plan.py
+++ b/python/tests/test_plan.py
@@ -50,7 +50,10 @@ def test_nobias_gemm_from_matmul(golden_model):
assert all(o.bias is None for o in p.ops)
-def test_multi_output_graph_raises_unsupported_model(tmp_path):
+def test_multi_output_graph_concatenates_its_outputs_in_y(tmp_path):
+ # Several graph outputs are the mirror of several inputs: y is the
+ # concatenation in declaration order, and each secondary output is a
+ # copy into its slice of y after the last op.
import onnx
from onnx import helper, TensorProto
x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 2])
@@ -62,5 +65,8 @@ def test_multi_output_graph_raises_unsupported_model(tmp_path):
m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 13)])
p = tmp_path / "multi_output.onnx"
onnx.save(m, p)
- with pytest.raises(UnsupportedModel, match="one input and one output"):
- build_plan(load_graph(p))
+ plan = build_plan(load_graph(p))
+ assert plan.output.shape == (4,)
+ gather = [op for op in plan.ops if op.kind == "copy" and op.dst_offset]
+ assert [(op.inp, op.dst_offset, op.n_out) for op in gather] == [("z", 2, 2)]
+ assert plan.assignment[gather[0].out] == "y"
diff --git a/python/tests/test_regressions.py b/python/tests/test_regressions.py
index 53dba4f..2b74f06 100644
--- a/python/tests/test_regressions.py
+++ b/python/tests/test_regressions.py
@@ -6,6 +6,7 @@
initializer name longer than the emitters' old fixed buffer, a genuine
float64 model, or a NaN travelling through a Relu.
"""
+import os
import re
import struct
import subprocess
@@ -16,17 +17,20 @@
from onnx import helper, numpy_helper, TensorProto
from rosenna.cli import main
+from rosenna.errors import UnsupportedModel
from rosenna.emit_c import emit_c
from rosenna.emit_fortran import emit_fortran
from rosenna.frontend import load_graph
from rosenna.plan import build_plan
from rosenna.weights import write_weights
-from tests.conftest import save_model
+from tests.conftest import _assert_warning_free, _source_diagnostics, save_model
from tests.test_emit_c import _build_and_run as _c_build_and_run
from tests.test_emit_fortran import _build_and_run as _f_build_and_run
from tests.test_emit_fortran import _live_reference
+from tests.conftest import save_model
DENSE = ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"]
+from tests.test_golden_suite import GOLDEN as GOLDEN_NAMES
# A PyTorch export names its initializers after the dotted module path, which
# routinely runs past the 128-character buffer both emitters used to declare.
@@ -54,10 +58,14 @@ def _init_status(tmp_path, lang, onnx_path, name, rwt_bytes):
"""Emit `name`'s init for `lang`, hand it `rwt_bytes`, return (exit code, stdout)."""
work = tmp_path / lang
work.mkdir(exist_ok=True)
- plan = build_plan(load_graph(onnx_path), dtype="f64")
+ # embed=False: this helper hands `_init` crafted/corrupted .rwt
+ # bytes and checks the status it returns, so it always needs the
+ # file-loaded contract (and its hash must match the caller's plan, which
+ # also builds with embed=False -- see the two call sites below).
+ plan = build_plan(load_graph(onnx_path), dtype="f64", embed=False)
(work / f"{name}.rwt").write_bytes(rwt_bytes)
if lang == "fortran":
- (work / f"{name}_model.f90").write_text(emit_fortran(plan))
+ (work / f"{name}_model.F90").write_text(emit_fortran(plan))
(work / "main.f90").write_text(f"""
program main
use {name}_model
@@ -67,7 +75,7 @@ def _init_status(tmp_path, lang, onnx_path, name, rwt_bytes):
print *, status
end program
""")
- subprocess.run(["gfortran", "-O2", "-o", "run", f"{name}_model.f90", "main.f90"],
+ subprocess.run(["gfortran", "-O2", "-o", "run", f"{name}_model.F90", "main.f90"],
cwd=work, check=True, capture_output=True, text=True)
else:
source, header = emit_c(plan)
@@ -158,6 +166,30 @@ def test_c_emitter_handles_a_gemm_output_with_two_consumers(tmp_path):
np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+def test_activation_after_a_wider_op_reads_its_own_length(tmp_path):
+ # The activation loop used to be bounded by a running "length of the
+ # previous op's output". Here h is 5 wide, the op emitted before the Relu
+ # is 40 wide, and the Relu reads h: with the old bound it ran 40
+ # iterations over a double[5], reading and writing 35 elements past a
+ # stack array with no diagnostic. The bound now comes from the plan.
+ rng = np.random.default_rng(17)
+ w0 = numpy_helper.from_array(rng.uniform(-1, 1, (2, 5)).astype(np.float32), "w0")
+ w1 = numpy_helper.from_array(rng.uniform(-1, 1, (5, 40)).astype(np.float32), "w1")
+ w2 = numpy_helper.from_array(rng.uniform(-1, 1, (5, 2)).astype(np.float32), "w2")
+ nodes = [
+ helper.make_node("MatMul", ["x", "w0"], ["h"], name="mm0"),
+ helper.make_node("MatMul", ["h", "w1"], ["wide"], name="mm1"),
+ helper.make_node("Relu", ["h"], ["r"], name="late_relu"),
+ helper.make_node("MatMul", ["r", "w2"], ["y"], name="mm2"),
+ ]
+ path = _save(tmp_path, "latewide", nodes, [w0, w1, w2], (1, 2), (1, 2))
+ inputs, expected = _reference(path, [1, 2])
+ assert inputs is not None
+ f_out, c_out = _both_backends(tmp_path, path, "latewide", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
# --- item 2: names and ranks from the file must not overflow a fixed buffer -
def _long_name_model(tmp_path):
@@ -169,7 +201,9 @@ def _long_name_model(tmp_path):
def test_long_initializer_name_round_trips(tmp_path):
assert len(_LONG_NAME) > 128
path = _long_name_model(tmp_path)
- plan = build_plan(load_graph(path), dtype="f64")
+ # embed=False: this test is about the name buffer inside `init`'s table-
+ # of-contents reader, which an embedded plan's source does not emit.
+ plan = build_plan(load_graph(path), dtype="f64", embed=False)
fsrc = emit_fortran(plan)
csrc, _ = emit_c(plan)
@@ -192,7 +226,10 @@ def test_oversized_name_length_in_the_file_is_rejected(tmp_path, golden_model, l
# byte 60; overwrite the first tensor's name length with 4000.
onnx_path = golden_model("gemm_small")
graph = load_graph(onnx_path)
- plan = build_plan(graph, dtype="f64")
+ # embed=False to match _init_status's own plan -- both build the same
+ # model the same way, or their plan hashes (and thus this file's
+ # embedded expected_hash) would disagree.
+ plan = build_plan(graph, dtype="f64", embed=False)
good = tmp_path / "good.rwt"
write_weights(plan, graph, good)
blob = bytearray(good.read_bytes())
@@ -245,7 +282,8 @@ def test_relu_propagates_nan_in_both_backends(tmp_path):
def test_truncated_weights_file_returns_a_status(tmp_path, golden_model, lang):
onnx_path = golden_model("gemm_small")
graph = load_graph(onnx_path)
- plan = build_plan(graph, dtype="f64")
+ # embed=False: see the comment in the sibling test above.
+ plan = build_plan(graph, dtype="f64", embed=False)
good = tmp_path / "good.rwt"
write_weights(plan, graph, good)
rc, out = _init_status(tmp_path, lang, onnx_path, "gemm_small", good.read_bytes()[:-12])
@@ -254,47 +292,33 @@ def test_truncated_weights_file_returns_a_status(tmp_path, golden_model, lang):
# --- item 10 / verification 3: generated code must compile warning-free ----
-
-# A diagnostic about the generated source carries a :: location.
-# A driver-level notice instead names the tool as its "location" -- for
-# example Apple clang on the macOS CI runner prints, on every invocation and
-# whatever the source,
+#
+# _DRIVER_NOTICE / _source_diagnostics / _assert_warning_free live in
+# conftest.py so every test module (device/kernel tests included) can filter
+# driver-level toolchain notices, e.g. Apple clang's on the macOS CI runner:
# clang: warning: overriding deployment version from '16.0' to '26.0' [-Woverriding-deployment-version]
-# which is about the SDK versus the deployment target and nothing to do with
-# our C (ruling R21). gfortran's own multi-line diagnostics keep their
-# `:::` header and a bare `Warning: ...` line, neither of
-# which this pattern matches, so they survive.
-_DRIVER_NOTICE = re.compile(r"^[^\s:]+: (warning|note): ")
-
-
-def _source_diagnostics(stderr: str):
- """Split compiler stderr into (about the source, driver-level noise)."""
- kept, dropped = [], []
- for line in stderr.splitlines():
- (dropped if _DRIVER_NOTICE.match(line) else kept).append(line)
- return "\n".join(kept).strip(), "\n".join(dropped).strip()
-
-
-def _assert_warning_free(lang: str, stderr: str) -> None:
- kept, dropped = _source_diagnostics(stderr)
- assert kept == "", (f"{lang}: diagnostics about the generated source:\n{kept}\n"
- f"(driver-level notices ignored: {dropped or 'none'})")
+# (ruling R21, R30).
def _compile_warnings(tmp_path, onnx_path, name, dtype="f64"):
- plan = build_plan(load_graph(onnx_path), dtype=dtype)
+ plan = build_plan(load_graph(onnx_path, name), dtype=dtype)
work = tmp_path / f"{name}_{dtype}"
work.mkdir(exist_ok=True)
- (work / f"{name}_model.f90").write_text(emit_fortran(plan))
+ (work / f"{name}_model.F90").write_text(emit_fortran(plan))
source, header = emit_c(plan)
(work / f"{name}.c").write_text(source)
(work / f"{name}.h").write_text(header)
# -std=f2008 makes every gfortran enforce the 132-column limit (ruling R20)
# and anything else non-standard, rather than only the CI compiler.
- f = subprocess.run(["gfortran", "-std=f2008", "-O2", "-Wall", "-Wextra", "-c",
- f"{name}_model.f90"],
+ # ROSENNA_CC / ROSENNA_FC let a CI job point this at a second compiler.
+ # Without them the clang job would re-run gcc and prove nothing, which is
+ # the failure mode the widened parametrization above already fell into.
+ cc = os.environ.get("ROSENNA_CC", "gcc")
+ fc = os.environ.get("ROSENNA_FC", "gfortran")
+ f = subprocess.run([fc, "-std=f2008", "-O2", "-Wall", "-Wextra", "-c",
+ f"{name}_model.F90"],
cwd=work, capture_output=True, text=True, check=True)
- c = subprocess.run(["gcc", "-O2", "-Wall", "-Wextra", "-std=c11", "-c", f"{name}.c"],
+ c = subprocess.run([cc, "-O2", "-Wall", "-Wextra", "-std=c11", "-c", f"{name}.c"],
cwd=work, capture_output=True, text=True, check=True)
return f.stderr, c.stderr
@@ -314,6 +338,19 @@ def test_dense_models_compile_without_warnings(tmp_path, golden_model, name, dty
_assert_warning_free("gcc", c_err)
+# Every golden model, not just the dense ones. The dense-only list above dated
+# from when dense was all the generator emitted, and it quietly stopped being
+# "the generated code" once Conv, pooling, LSTM and the shape ops landed: the
+# LSTM emitter was writing buffers for outputs the model never reads, and gcc
+# reported it in every CI run that never compiled an LSTM model.
+@pytest.mark.parametrize("name", GOLDEN_NAMES)
+def test_every_golden_model_compiles_without_warnings(tmp_path, golden_model, name):
+ safe = re.sub(r"[^0-9A-Za-z_]", "_", name)
+ f_err, c_err = _compile_warnings(tmp_path, golden_model(name), safe)
+ _assert_warning_free("gfortran", f_err)
+ _assert_warning_free("gcc", c_err)
+
+
def test_source_diagnostics_filter_keeps_real_warnings_and_drops_driver_noise():
clang_noise = ("clang: warning: overriding deployment version from '16.0' to '26.0' "
"[-Woverriding-deployment-version]")
@@ -362,10 +399,692 @@ def test_generated_fortran_fits_in_132_columns(golden_model, name, dtype):
def test_generated_fortran_fits_in_132_columns_with_a_long_tensor_name(tmp_path):
# The name buffer is plan-derived now, so a 176-character initializer name
# becomes a 176-character case label unless the literal is continued.
- src = emit_fortran(build_plan(load_graph(_long_name_model(tmp_path)), dtype="f64"))
+ # embed=False: the case label this test asserts on lives in `load_tensor`,
+ # which an embedded plan's module does not emit.
+ src = emit_fortran(build_plan(load_graph(_long_name_model(tmp_path)), dtype="f64", embed=False))
assert _over_long_fortran_lines(src) == []
# Wrapped, not dropped: the literal is continued across lines, so the
# head of the name is still there and the tail follows a leading `&`.
assert "case ('" + _LONG_NAME[:20] in src
assert _LONG_NAME[-12:] + "')" in src
assert any(l.strip().startswith("&") for l in src.splitlines())
+
+
+def test_a_float64_model_built_single_is_held_to_the_single_tolerance(tmp_path):
+ # Tolerance used to follow the model's dtype alone, so a genuine float64
+ # model built --precision single was held to the f64 tolerance it had no
+ # way of meeting: a false FAIL, and that configuration could not be
+ # verified at all. It now follows whichever side rounds more coarsely.
+ rng = np.random.default_rng(5)
+ w = numpy_helper.from_array(rng.uniform(-1, 1, (4, 3)).astype(np.float64), "w")
+ b = numpy_helper.from_array(rng.uniform(-1, 1, (3,)).astype(np.float64), "b")
+ graph = helper.make_graph(
+ [helper.make_node("Gemm", ["x", "w", "b"], ["y"], name="g0")], "f64model",
+ [helper.make_tensor_value_info("x", TensorProto.DOUBLE, [1, 4])],
+ [helper.make_tensor_value_info("y", TensorProto.DOUBLE, [1, 3])], [w, b])
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / "f64model.onnx"
+ _onnx.save(model, str(path))
+ from rosenna.verify import verify_model
+ for precision in ("f64", "f32"):
+ r = verify_model(path, "c", precision, 8, tmp_path / precision)[0]
+ assert r.ok, f"--precision {precision}: max_abs={r.max_abs:.3e} max_rel={r.max_rel:.3e}"
+ # And the f64 build really is the more accurate one, so the looser bar for
+ # the single build is not hiding a wrong answer.
+ f64 = verify_model(path, "c", "f64", 8, tmp_path / "again64")[0]
+ f32 = verify_model(path, "c", "f32", 8, tmp_path / "again32")[0]
+ assert f64.max_abs < f32.max_abs
+
+
+# ===========================================================================
+# From the review of the device-library branch (PR #12). Everything above is
+# from the earlier codegen-dense review; both are the same kind of test -- a
+# shape the golden set cannot express, pinned after it shipped as a bug.
+#
+# Regressions from the review of the device-library branch (PR #12).
+#
+# Every model here is built inline with onnx.helper; each one is a shape the
+# golden set does not contain: a tied initializer, an end-only padded pool, a
+# real Transpose in a graph with no Add, an Unsqueeze with several negative
+# axes, a (1,) Gemm bias, a weights file whose table of contents is short, and
+# a Constant LSTM initial state.
+# ===========================================================================
+
+def _f32(rng, shape, name):
+ return numpy_helper.from_array(rng.uniform(-1, 1, shape).astype(np.float32), name)
+
+
+def test_tied_initializer_is_loaded_into_every_use(tmp_path):
+ # x @ w -> Relu -> @ w: one initializer, two nodes. The plan made one
+ # WeightSpec per use; C's loader filled the first and returned, leaving
+ # the second all zeros, and Fortran's select-case had two labels for 'w'.
+ rng = np.random.default_rng(3)
+ w = _f32(rng, (3, 3), "w")
+ nodes = [
+ helper.make_node("MatMul", ["x", "w"], ["g"], name="mm0"),
+ helper.make_node("Relu", ["g"], ["r"], name="r0"),
+ helper.make_node("MatMul", ["r", "w"], ["y"], name="mm1"),
+ ]
+ path = save_model(tmp_path, "tied", nodes, [w], (1, 3), (1, 3))
+ inputs, expected = _reference(path, [1, 3])
+ f_out, c_out = _both_backends(tmp_path, path, "tied", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_average_pool_with_end_only_padding_divides_by_the_window_count(tmp_path):
+ # kernel 2, stride 2, pads [0,0,1,1] on a 3x3 input: the last row and
+ # column of windows hang off the end by one. ONNX (count_include_pad=0)
+ # divides those by the cells that exist; the emitters divided every
+ # window by the full kernel because the *begin* pads were zero.
+ nodes = [helper.make_node("AveragePool", ["x"], ["y"], name="p0",
+ kernel_shape=[2, 2], strides=[2, 2], pads=[0, 0, 1, 1])]
+ path = save_model(tmp_path, "endpad", nodes, [], (1, 1, 3, 3), (1, 1, 2, 2))
+ inputs, expected = _reference(path, [1, 1, 3, 3])
+ f_out, c_out = _both_backends(tmp_path, path, "endpad", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_real_transpose_compiles_in_fortran_without_an_add(tmp_path):
+ # Transpose(perm=[0,2,1]) on (1,3,4) moves a real axis, so it is a loop
+ # over counters c0, c1, ...; the Fortran emitter only declared those
+ # when the model also had an Add.
+ rng = np.random.default_rng(5)
+ w = _f32(rng, (3, 2), "w")
+ nodes = [
+ helper.make_node("Transpose", ["x"], ["t"], name="t0", perm=[0, 2, 1]),
+ helper.make_node("MatMul", ["t", "w"], ["y"], name="mm0"),
+ ]
+ path = save_model(tmp_path, "tr", nodes, [w], (1, 3, 4), (1, 4, 2))
+ inputs, expected = _reference(path, [1, 3, 4])
+ f_out, c_out = _both_backends(tmp_path, path, "tr", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_unsqueeze_with_several_negative_axes_folds_to_the_right_shape(tmp_path):
+ # Unsqueeze(c[3], axes=[-1, -2]) is (3,1,1) -- negatives count from the
+ # OUTPUT rank -- and the fold resolved them against ndim+1, giving
+ # (1,1,3). Added to x of shape (1,3,1,3) both broadcast, so a wrong fold
+ # is a silently wrong answer, not an error.
+ c = numpy_helper.from_array(np.array([1.0, 2.0, 3.0], np.float32), "c")
+ axes = numpy_helper.from_array(np.array([-1, -2], np.int64), "axes")
+ nodes = [
+ helper.make_node("Unsqueeze", ["c", "axes"], ["cu"], name="u0"),
+ helper.make_node("Add", ["x", "cu"], ["y"], name="a0"),
+ ]
+ path = save_model(tmp_path, "unsq", nodes, [c, axes], (1, 3, 1, 3), (1, 3, 1, 3))
+ inputs, expected = _reference(path, [1, 3, 1, 3])
+ f_out, c_out = _both_backends(tmp_path, path, "unsq", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_gemm_bias_of_length_one_is_rejected(tmp_path):
+ # A (1,) bias is a legal ONNX unidirectional broadcast, but the emitters
+ # index b[i] for every output, so validation must refuse it rather than
+ # let generated code read past a one-element array.
+ rng = np.random.default_rng(7)
+ w = _f32(rng, (2, 3), "w")
+ b = numpy_helper.from_array(np.array([0.5], np.float32), "b")
+ nodes = [helper.make_node("Gemm", ["x", "w", "b"], ["y"], name="g0")]
+ path = save_model(tmp_path, "b1", nodes, [w, b], (1, 2), (1, 3))
+ with pytest.raises(UnsupportedModel, match="bias"):
+ build_plan(load_graph(path), dtype="f64", embed=False)
+
+
+@pytest.mark.parametrize("lang", ["c", "fortran"])
+def test_weights_file_missing_a_tensor_returns_status_9(tmp_path, golden_model, lang):
+ # A file with the right hash whose table of contents lists fewer tensors
+ # than the plan has: init returned 0 and infer ran on zeroed arrays.
+ onnx_path = golden_model("gemm_small")
+ graph = load_graph(onnx_path)
+ plan = build_plan(graph, dtype="f64", embed=False)
+ good = tmp_path / "good.rwt"
+ write_weights(plan, graph, good)
+ data = good.read_bytes()
+ # Header layout is write_weights' business; the count is the one
+ # little-endian uint32 that equals the number of plan weights and sits
+ # in the first 64 bytes.
+ n = len(plan.weights)
+ off = next(i for i in range(0, 64, 4) if struct.unpack_from("= {"h0", "c0"}
+ inputs, expected = _reference(path, [1, 1, n_in])
+ f_out, c_out = _both_backends(tmp_path, path, "lstmk", inputs)
+ np.testing.assert_allclose(f_out, expected, rtol=1e-5, atol=1e-6)
+ np.testing.assert_allclose(c_out, expected, rtol=1e-5, atol=1e-6)
+
+
+def test_lstm_without_its_y_output_is_refused_not_a_traceback(tmp_path):
+ # ONNX lets a graph ask for Y_h alone (outputs ["", "yh"]). The plan reads
+ # the first output's shape and raised KeyError('') on the empty name; the
+ # answer is an UnsupportedModel naming what is missing.
+ rng = np.random.default_rng(9)
+ hidden, n_in = 4, 3
+ W = _f32(rng, (1, 4 * hidden, n_in), "W")
+ R = _f32(rng, (1, 4 * hidden, hidden), "R")
+ nodes = [
+ helper.make_node("LSTM", ["x", "W", "R"], ["", "yh"], name="l0", hidden_size=hidden),
+ helper.make_node("Flatten", ["yh"], ["y"], name="f0"),
+ ]
+ path = save_model(tmp_path, "lstmy", nodes, [W, R], (1, 1, n_in), (1, hidden))
+ with pytest.raises(UnsupportedModel, match="Y"):
+ build_plan(load_graph(path), dtype="f64", embed=False)
+
+
+# --- Softmax ---------------------------------------------------------------
+
+def _softmax_model(path, shape, axis=None, out_shape=None):
+ """A Softmax-only graph, so nothing upstream can mask what it computes."""
+ attrs = {} if axis is None else {"axis": axis}
+ graph = helper.make_graph(
+ [helper.make_node("Softmax", ["x"], ["y"], name="s0", **attrs)], "smx",
+ [helper.make_tensor_value_info("x", TensorProto.DOUBLE, list(shape))],
+ [helper.make_tensor_value_info("y", TensorProto.DOUBLE, list(out_shape or shape))], [])
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ _onnx.save(model, str(path))
+ return path
+
+
+def test_softmax_matches_onnxruntime_on_both_backends(tmp_path):
+ path = _softmax_model(tmp_path / "smx.onnx", (1, 7))
+ rng = np.random.default_rng(11)
+ x = rng.uniform(-4, 4, (1, 7))
+ f, c = _both_backends(tmp_path, path, "smx", x)
+ session = ort.InferenceSession(str(path))
+ want = session.run(None, {"x": x})[0].ravel()
+ for got, lang in ((f, "fortran"), (c, "c")):
+ assert np.allclose(got, want, rtol=1e-12, atol=1e-14), f"{lang}: {got} != {want}"
+ # Whatever else it is, a softmax is a distribution.
+ assert abs(float(np.sum(c)) - 1.0) < 1e-12
+
+
+def test_softmax_over_the_last_axis_of_a_rank4_value(tmp_path):
+ """Each of the 2*3*4 rows normalises independently, not the whole buffer."""
+ path = _softmax_model(tmp_path / "smx4.onnx", (2, 3, 4, 5), axis=-1)
+ rng = np.random.default_rng(12)
+ x = rng.uniform(-3, 3, (2, 3, 4, 5))
+ # The driver takes a list of cases, each a flat n_in vector: one case here.
+ f, c = _both_backends(tmp_path, path, "smx4", x.reshape(1, -1))
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want, rtol=1e-12, atol=1e-14)
+ assert np.allclose(f, want, rtol=1e-12, atol=1e-14)
+ # 24 rows each summing to 1, which a single global softmax would fail.
+ assert np.allclose(np.asarray(c).reshape(2, 3, 4, 5).sum(axis=-1), 1.0)
+
+
+def test_softmax_does_not_overflow_on_a_large_logit(tmp_path):
+ """exp(800) is inf; subtracting the row maximum is what avoids it.
+
+ Without the max-subtraction pass this returns nan (inf/inf), which is why
+ the pass is there rather than being an optimisation.
+ """
+ path = _softmax_model(tmp_path / "big.onnx", (1, 4))
+ x = np.array([[800.0, 799.0, -800.0, 0.0]])
+ f, c = _both_backends(tmp_path, path, "big", x)
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ for got, lang in ((f, "fortran"), (c, "c")):
+ assert np.all(np.isfinite(got)), f"{lang}: {got}"
+ assert np.allclose(got, want, rtol=1e-12, atol=1e-14), f"{lang}: {got} != {want}"
+
+
+def test_softmax_propagates_a_nan_through_the_row_sum(tmp_path):
+ """A NaN anywhere in a row makes that row all-NaN, and leaves others alone.
+
+ The maximum is written `v > mx`, so a NaN loses it -- the propagation comes
+ from the row sum instead (exp(nan - mx) is nan, so the sum is nan and every
+ ratio in the row is nan). A NaN-sticky maximum would give nan - nan for
+ every element and lose the reason. See _emit_softmax_c.
+ """
+ path = _softmax_model(tmp_path / "nan.onnx", (2, 3), axis=-1)
+ x = np.array([[1.0, np.nan, 2.0], [1.0, 2.0, 3.0]])
+ f, c = _both_backends(tmp_path, path, "nan", x.reshape(1, -1))
+ for got, lang in ((np.asarray(f), "fortran"), (np.asarray(c), "c")):
+ row0, row1 = got.reshape(2, 3)
+ assert np.all(np.isnan(row0)), f"{lang}: the NaN row should be all NaN, got {row0}"
+ assert np.all(np.isfinite(row1)), f"{lang}: the clean row should survive, got {row1}"
+ assert abs(float(row1.sum()) - 1.0) < 1e-12, f"{lang}: {row1}"
+
+
+@pytest.mark.parametrize("shape,axis,fragment", [
+ ((2, 3, 4), 1, "only the last axis"),
+ ((2, 3, 4), -2, "only the last axis"),
+ ((2, 3, 4), 0, "only the last axis"),
+ ((2, 3, 4), None, "ambiguous across opsets"),
+])
+def test_softmax_refuses_what_its_loop_does_not_compute(tmp_path, shape, axis, fragment):
+ from rosenna.validate import validate
+ out = (1,) if axis is None else shape
+ path = _softmax_model(tmp_path / "bad.onnx", shape, axis=axis, out_shape=shape)
+ with pytest.raises(UnsupportedModel, match=fragment):
+ validate(load_graph(path))
+
+
+# --- BatchNormalization ----------------------------------------------------
+
+def test_conv_batchnorm_folds_away_and_still_matches_onnxruntime(tmp_path):
+ """End to end: the fold is only correct if the generated code agrees with ORT.
+
+ The unit tests in test_fold.py check the arithmetic of the rewrite; this
+ checks that the rewritten graph, compiled, computes what the original
+ model means -- which is the claim that matters, and the one a sign error
+ in the broadcast axis would break.
+ """
+ # float32: onnxruntime has no float64 Conv kernel, so a f64 model cannot be
+ # given a reference at all.
+ rng = np.random.default_rng(21)
+ ini = [numpy_helper.from_array(a.astype(np.float32), n) for a, n in (
+ (rng.uniform(-1, 1, (4, 3, 3, 3)), "w"),
+ (rng.uniform(-1, 1, 4), "b"),
+ (rng.uniform(0.5, 2.0, 4), "scale"),
+ (rng.uniform(-1, 1, 4), "B"),
+ (rng.uniform(-1, 1, 4), "mean"),
+ (rng.uniform(0.5, 2.0, 4), "var"))]
+ graph = helper.make_graph(
+ [helper.make_node("Conv", ["x", "w", "b"], ["h"], name="c0",
+ kernel_shape=[3, 3], pads=[1, 1, 1, 1]),
+ helper.make_node("BatchNormalization", ["h", "scale", "B", "mean", "var"],
+ ["bn"], name="n0", epsilon=1e-5),
+ helper.make_node("Relu", ["bn"], ["y"], name="r0")], "convbn",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3, 6, 6])],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 4, 6, 6])], ini)
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / "convbn.onnx"
+ _onnx.save(model, str(path))
+
+ # The op is gone by the time anything downstream sees the graph.
+ assert not any(n.op == "BatchNormalization" for n in load_graph(path).nodes)
+
+ x = rng.uniform(-2, 2, (1, 3, 6, 6)).astype(np.float32)
+ f, c = _both_backends(tmp_path, path, "convbn", x.reshape(1, -1), dtype="f32")
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want, rtol=1e-5, atol=1e-6), f"c: {np.max(np.abs(c - want)):.3e}"
+ assert np.allclose(f, want, rtol=1e-5, atol=1e-6), f"fortran: {np.max(np.abs(f - want)):.3e}"
+
+
+@pytest.mark.parametrize("c_in,c_out,group,label", [
+ (4, 4, 4, "depthwise"), # one input channel per output channel
+ (4, 6, 2, "uneven groups"), # c_in_per_group 2, c_out_per_group 3
+ (6, 6, 3, "square groups"),
+])
+def test_grouped_conv_reads_only_its_own_group(tmp_path, c_in, c_out, group, label):
+ """A grouped Conv is wrong in a way that still runs: it reads the neighbouring
+ group's channels. Only a reference catches that, so compare to onnxruntime.
+
+ The uneven case matters most: with c_in_per_group != c_out_per_group an
+ off-by-one in the group offset lands inside the buffer and returns
+ plausible numbers.
+ """
+ rng = np.random.default_rng(31 + group)
+ w = numpy_helper.from_array(
+ rng.uniform(-1, 1, (c_out, c_in // group, 3, 3)).astype(np.float32), "w")
+ b = numpy_helper.from_array(rng.uniform(-1, 1, c_out).astype(np.float32), "b")
+ graph = helper.make_graph(
+ [helper.make_node("Conv", ["x", "w", "b"], ["y"], name="c0",
+ kernel_shape=[3, 3], pads=[1, 1, 1, 1], group=group)], "grp",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, c_in, 5, 5])],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, c_out, 5, 5])], [w, b])
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / f"grp{group}.onnx"
+ _onnx.save(model, str(path))
+
+ x = rng.uniform(-2, 2, (1, c_in, 5, 5)).astype(np.float32)
+ f, c = _both_backends(tmp_path, path, f"grp{group}", x.reshape(1, -1), dtype="f32")
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ for got, lang in ((f, "fortran"), (c, "c")):
+ assert np.allclose(got, want, rtol=1e-5, atol=1e-6), \
+ f"{label} {lang}: max |diff| {np.max(np.abs(np.asarray(got).ravel() - want)):.3e}"
+
+
+# --- Pad -------------------------------------------------------------------
+
+def _pad_model(path, in_shape, pads, value=0.0, mode="constant", runtime_pads=False, axes=None):
+ rank = len(in_shape)
+ if axes is None:
+ out = tuple(int(d) + pads[k] + pads[k + rank] for k, d in enumerate(in_shape))
+ else:
+ norm = [a + rank if a < 0 else a for a in axes]
+ out = list(int(d) for d in in_shape)
+ for k, a in enumerate(norm):
+ out[a] += pads[k] + pads[k + len(axes)]
+ out = tuple(out)
+ ini = [] if runtime_pads else [
+ numpy_helper.from_array(np.array(pads, np.int64), "p"),
+ numpy_helper.from_array(np.array(value, np.float64), "v")]
+ if axes is not None:
+ ini.append(numpy_helper.from_array(np.array(axes, np.int64), "a"))
+ ins = [helper.make_tensor_value_info("x", TensorProto.DOUBLE, list(in_shape))]
+ if runtime_pads:
+ ins.append(helper.make_tensor_value_info("p", TensorProto.INT64, [2 * rank]))
+ graph = helper.make_graph(
+ [helper.make_node("Pad", ["x", "p"] + ([] if runtime_pads else ["v"])
+ + ([] if axes is None else ["a"]),
+ ["y"], name="p0", mode=mode)], "pad", ins,
+ [helper.make_tensor_value_info("y", TensorProto.DOUBLE, list(out))], ini)
+ # `axes` only exists from opset 18; onnxruntime rejects a 4-input Pad
+ # against the opset-13 schema, so the reference could not even be built.
+ opset = 18 if axes is not None else 13
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", opset)])
+ model.ir_version = 9 if axes is not None else 8
+ import onnx as _onnx
+ _onnx.save(model, str(path))
+ return path, out
+
+
+def test_pad_places_the_input_block_and_fills_the_rest(tmp_path):
+ """Asymmetric pads on two axes, so a begins/ends mix-up cannot pass."""
+ path, out = _pad_model(tmp_path / "pad.onnx", (1, 2, 4, 5),
+ [0, 0, 1, 2, 0, 0, 3, 1], value=-1.5)
+ rng = np.random.default_rng(41)
+ x = rng.uniform(-2, 2, (1, 2, 4, 5))
+ f, c = _both_backends(tmp_path, path, "pad", x.reshape(1, -1))
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want) and np.allclose(f, want)
+ # The fill value really is the constant, and the block really moved.
+ got = np.asarray(c).reshape(out)
+ assert got[0, 0, 0, 0] == -1.5
+ assert np.allclose(got[0, :, 1:5, 2:7], x)
+
+
+def test_pad_feeding_a_conv_is_the_shape_that_turns_up(tmp_path):
+ """An explicit Pad before a Conv: what an export emits for asymmetric padding."""
+ rng = np.random.default_rng(42)
+ w = numpy_helper.from_array(rng.uniform(-1, 1, (3, 2, 3, 3)).astype(np.float32), "w")
+ pads = numpy_helper.from_array(np.array([0, 0, 1, 1, 0, 0, 1, 1], np.int64), "p")
+ graph = helper.make_graph(
+ [helper.make_node("Pad", ["x", "p"], ["xp"], name="p0", mode="constant"),
+ helper.make_node("Conv", ["xp", "w"], ["y"], name="c0", kernel_shape=[3, 3])],
+ "padconv",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 2, 6, 6])],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3, 6, 6])], [w, pads])
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / "padconv.onnx"
+ _onnx.save(model, str(path))
+ x = rng.uniform(-2, 2, (1, 2, 6, 6)).astype(np.float32)
+ f, c = _both_backends(tmp_path, path, "padconv", x.reshape(1, -1), dtype="f32")
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want, rtol=1e-5, atol=1e-6)
+ assert np.allclose(f, want, rtol=1e-5, atol=1e-6)
+
+
+def test_pad_with_negative_pads_crops(tmp_path):
+ """A negative pad removes elements, and the same nest already computes it.
+
+ The output reads FURTHER into the input, so the shift is an addition. It
+ was refused at first out of caution; the only thing actually wrong was the
+ spelling -- `(c - -1)` is legal C and a Fortran syntax error. The mixed
+ case is the one worth testing: cropping the front of an axis while padding
+ its back means the read can still run off the end, so the upper bounds
+ test has to survive.
+ """
+ path, out = _pad_model(tmp_path / "crop.onnx", (1, 2, 6, 6),
+ [0, 0, -1, 2, 0, 0, -2, 1], value=7.0)
+ assert out == (1, 2, 3, 9), out
+ rng = np.random.default_rng(43)
+ x = rng.uniform(-2, 2, (1, 2, 6, 6))
+ f, c = _both_backends(tmp_path, path, "crop", x.reshape(1, -1))
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want) and np.allclose(f, want)
+ got = np.asarray(c).reshape(out)
+ # Cropped off the front of h, and the far end of w is past the input.
+ assert np.allclose(got[0, :, 0, 2:8], x[0, :, 1, 0:6])
+ assert got[0, 0, 0, 8] == 7.0
+
+
+@pytest.mark.parametrize("kwargs,fragment", [
+ (dict(pads=[0, 0, 1, 1, 0, 0, 1, 1], mode="wrap"), "only 'constant', 'edge' and 'reflect'"),
+ # One reflection only: a pad as wide as the axis would need repeated
+ # reflection, and the index map would fold to the wrong element.
+ (dict(pads=[0, 0, 4, 0, 0, 0, 0, 0], mode="reflect"), "more than one reflection"),
+ # A runtime `pads` is an int64 graph input, which the frontend refuses on
+ # dtype before validate sees the node at all. Still named, still refused,
+ # just earlier -- pinning the message that actually fires rather than the
+ # one _validate_pad would have given.
+ (dict(pads=[0, 0, 1, 1, 0, 0, 1, 1], runtime_pads=True), "only float32 and float64"),
+])
+def test_pad_refuses_what_its_loop_does_not_compute(tmp_path, kwargs, fragment):
+ from rosenna.validate import validate
+ pads = kwargs.pop("pads")
+ # A negative pad's output shape is smaller, which the helper computes, so
+ # the graph is well-formed and only validate should object.
+ path, _ = _pad_model(tmp_path / "bad.onnx", (1, 2, 4, 5), pads, **kwargs)
+ with pytest.raises(UnsupportedModel, match=fragment):
+ validate(load_graph(path))
+
+
+@pytest.mark.parametrize("mode", ["edge", "reflect"])
+def test_pad_edge_and_reflect_land_on_real_elements(tmp_path, mode):
+ """Neither mode ever writes a pad value: both are index maps.
+
+ Asymmetric pads on both spatial axes, so an edge/reflect mix-up or an
+ off-by-one in the mirror shows up as a mismatch rather than cancelling.
+ """
+ path, out = _pad_model(tmp_path / f"{mode}.onnx", (1, 2, 5, 6),
+ [0, 0, 2, 3, 0, 0, 1, 2], mode=mode)
+ rng = np.random.default_rng(51)
+ x = rng.uniform(-2, 2, (1, 2, 5, 6))
+ f, c = _both_backends(tmp_path, path, mode, x.reshape(1, -1))
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want) and np.allclose(f, want)
+ got = np.asarray(c).reshape(out)
+ if mode == "edge":
+ # The first two rows are copies of the input's first row.
+ assert np.allclose(got[0, :, 0, 3:9], x[0, :, 0, :])
+ assert np.allclose(got[0, :, 1, 3:9], x[0, :, 0, :])
+ else:
+ # Mirrored without repeating the edge: row 2 is the input's row 0,
+ # so rows 1 and 0 are its rows 1 and 2.
+ assert np.allclose(got[0, :, 1, 3:9], x[0, :, 1, :])
+ assert np.allclose(got[0, :, 0, 3:9], x[0, :, 2, :])
+
+
+def test_pad_axes_operand_expands_to_a_full_rank_pads(tmp_path):
+ """opset-18 `axes` names which axes `pads` counts; a negative axis counts back."""
+ path, out = _pad_model(tmp_path / "axes.onnx", (1, 2, 4, 5),
+ [1, 2, 3, 1], value=-3.0, axes=[2, -1])
+ assert out == (1, 2, 8, 8), out
+ rng = np.random.default_rng(52)
+ x = rng.uniform(-2, 2, (1, 2, 4, 5))
+ f, c = _both_backends(tmp_path, path, "axes", x.reshape(1, -1))
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ assert np.allclose(c, want) and np.allclose(f, want)
+ # Axes 0 and 1 were not named, so they are unpadded.
+ assert np.asarray(c).reshape(out).shape[:2] == (1, 2)
+
+
+# --- 1-D spatial ops -------------------------------------------------------
+
+@pytest.mark.parametrize("op,attrs,c_in,c_out,w_in,w_out", [
+ ("Conv", dict(kernel_shape=[3], pads=[1, 1]), 3, 5, 16, 16),
+ ("Conv", dict(kernel_shape=[3], pads=[2, 2], strides=[2], dilations=[2]), 3, 5, 16, 8),
+ ("Conv", dict(kernel_shape=[3], pads=[1, 1], group=2), 4, 6, 16, 16),
+ ("MaxPool", dict(kernel_shape=[3], strides=[2], pads=[1, 1]), 3, 3, 20, 10),
+ ("AveragePool", dict(kernel_shape=[2], strides=[2]), 3, 3, 20, 10),
+])
+def test_one_dimensional_spatial_ops_match_onnxruntime(tmp_path, op, attrs, c_in, c_out,
+ w_in, w_out):
+ """A 1-D op is the 2-D nest with a height of 1.
+
+ On a flat row-major buffer (N,C,W) and (N,C,1,W) are the same bytes, so
+ this needs no loop nest of its own -- but that equivalence is exactly the
+ kind of claim that is either right or silently off by a stride, so each
+ variant is compared against onnxruntime.
+ """
+ rng = np.random.default_rng(61)
+ ini = []
+ inputs = ["x"]
+ if op == "Conv":
+ group = attrs.get("group", 1)
+ ini = [numpy_helper.from_array(
+ rng.uniform(-1, 1, (c_out, c_in // group, attrs["kernel_shape"][0])).astype(np.float32), "w"),
+ numpy_helper.from_array(rng.uniform(-1, 1, c_out).astype(np.float32), "b")]
+ inputs += ["w", "b"]
+ graph = helper.make_graph(
+ [helper.make_node(op, inputs, ["y"], name="s0", **attrs)], "d1",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, c_in, w_in])],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, c_out, w_out])], ini)
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / "d1.onnx"
+ _onnx.save(model, str(path))
+
+ x = rng.uniform(-2, 2, (1, c_in, w_in)).astype(np.float32)
+ f, c = _both_backends(tmp_path, path, "d1", x.reshape(1, -1), dtype="f32")
+ want = ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel()
+ for got, lang in ((f, "fortran"), (c, "c")):
+ assert np.allclose(got, want, rtol=1e-5, atol=1e-6), \
+ f"{op} {lang}: max |diff| {np.max(np.abs(np.asarray(got).ravel() - want)):.3e}"
+
+
+@pytest.mark.parametrize("attrs,fragment", [
+ (dict(kernel_shape=[3, 3], pads=[1, 1]), "they must agree"),
+ (dict(kernel_shape=[3], strides=[1, 1], pads=[1, 1]), "a 1-D op takes 1"),
+ (dict(kernel_shape=[3], pads=[1, 1, 1, 1]), "a 1-D op takes 2"),
+])
+def test_a_1d_op_refuses_attributes_of_the_wrong_arity(tmp_path, attrs, fragment):
+ from rosenna.validate import validate
+ graph = helper.make_graph(
+ [helper.make_node("MaxPool", ["x"], ["y"], name="s0", **attrs)], "bad",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3, 16])],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3, 16])], [])
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / "bad.onnx"
+ _onnx.save(model, str(path))
+ with pytest.raises(UnsupportedModel, match=fragment):
+ validate(load_graph(path))
+
+
+# --- GRU -------------------------------------------------------------------
+
+def _gru_model(path, lbr, bias, init, T=5, B=1, I=4, H=6, seed=31):
+ rng = np.random.default_rng(seed)
+ ini = [numpy_helper.from_array(rng.uniform(-1, 1, (1, 3 * H, I)).astype(np.float32), "W"),
+ numpy_helper.from_array(rng.uniform(-1, 1, (1, 3 * H, H)).astype(np.float32), "R")]
+ inputs = ["x", "W", "R"]
+ if bias:
+ ini.append(numpy_helper.from_array(
+ rng.uniform(-1, 1, (1, 6 * H)).astype(np.float32), "B"))
+ inputs.append("B")
+ else:
+ inputs.append("")
+ if init:
+ inputs += ["", "h0"]
+ ini.append(numpy_helper.from_array(
+ rng.uniform(-1, 1, (1, B, H)).astype(np.float32), "h0"))
+ graph = helper.make_graph(
+ [helper.make_node("GRU", inputs, ["Y", "Yh"], name="g0",
+ hidden_size=H, linear_before_reset=lbr)], "gru",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [T, B, I])],
+ [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [T, 1, B, H]),
+ helper.make_tensor_value_info("Yh", TensorProto.FLOAT, [1, B, H])], ini)
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ _onnx.save(model, str(path))
+ return path, (T, B, I)
+
+
+@pytest.mark.parametrize("lbr", [0, 1])
+@pytest.mark.parametrize("bias", [True, False])
+@pytest.mark.parametrize("init", [True, False])
+def test_gru_matches_onnxruntime_on_both_backends(tmp_path, lbr, bias, init):
+ """Both readings of linear_before_reset, with and without B and initial_h.
+
+ linear_before_reset changes the ARITHMETIC of the h gate, not its spelling:
+ the reset gate multiplies the state before the recurrent matmul when it is
+ 0 and the matmul's result when it is 1, and the two agree only where r is
+ 1. PyTorch exports 1 while the ONNX default is 0, so picking one would
+ have been wrong half the time -- hence both, and hence this matrix.
+ """
+ path, (T, B, I) = _gru_model(tmp_path / f"gru{lbr}{int(bias)}{int(init)}.onnx",
+ lbr, bias, init)
+ rng = np.random.default_rng(77)
+ x = rng.uniform(-1.5, 1.5, (T, B, I)).astype(np.float32)
+ f, c = _both_backends(tmp_path, path, path.stem, x.reshape(1, -1), dtype="f32")
+ want = np.concatenate([a.ravel() for a in
+ ort.InferenceSession(str(path)).run(None, {"x": x})])
+ for got, lang in ((f, "fortran"), (c, "c")):
+ assert np.allclose(np.asarray(got).ravel(), want, rtol=1e-4, atol=1e-5), \
+ f"lbr={lbr} bias={bias} init={init} {lang}: " \
+ f"max |diff| {np.max(np.abs(np.asarray(got).ravel() - want)):.3e}"
+
+
+def test_the_two_linear_before_reset_readings_actually_differ(tmp_path):
+ """Guards the matrix above: if they agreed, it would be testing one thing twice."""
+ rng = np.random.default_rng(78)
+ x = rng.uniform(-1.5, 1.5, (5, 1, 4)).astype(np.float32)
+ outs = []
+ for lbr in (0, 1):
+ path, _ = _gru_model(tmp_path / f"d{lbr}.onnx", lbr, True, True)
+ outs.append(ort.InferenceSession(str(path)).run(None, {"x": x})[0].ravel())
+ assert not np.allclose(outs[0], outs[1], rtol=1e-3), \
+ "the two readings gave the same answer; this model does not distinguish them"
+
+
+@pytest.mark.parametrize("attrs,fragment", [
+ (dict(direction="reverse"), "only 'forward'"),
+ (dict(clip=1.0), "clip"),
+ (dict(activations=["Sigmoid", "Tanh", "Tanh"]), "custom activations"),
+])
+def test_gru_refuses_what_changes_the_recurrence(tmp_path, attrs, fragment):
+ from rosenna.validate import validate
+ T, B, I, H = 3, 1, 4, 5
+ rng = np.random.default_rng(79)
+ ini = [numpy_helper.from_array(rng.uniform(-1, 1, (1, 3 * H, I)).astype(np.float32), "W"),
+ numpy_helper.from_array(rng.uniform(-1, 1, (1, 3 * H, H)).astype(np.float32), "R")]
+ graph = helper.make_graph(
+ [helper.make_node("GRU", ["x", "W", "R"], ["Y", "Yh"], name="g0",
+ hidden_size=H, **attrs)], "bad",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [T, B, I])],
+ [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [T, 1, B, H]),
+ helper.make_tensor_value_info("Yh", TensorProto.FLOAT, [1, B, H])], ini)
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ import onnx as _onnx
+ path = tmp_path / "bad.onnx"
+ _onnx.save(model, str(path))
+ with pytest.raises(UnsupportedModel, match=fragment):
+ validate(load_graph(path))
diff --git a/python/tests/test_sanitizers.py b/python/tests/test_sanitizers.py
new file mode 100644
index 0000000..86490e6
--- /dev/null
+++ b/python/tests/test_sanitizers.py
@@ -0,0 +1,125 @@
+"""Every golden model, run under AddressSanitizer and UndefinedBehaviorSanitizer.
+
+A compiler warning cannot see the bug class this catches. The generated code
+is loop nests over fixed-size local arrays whose bounds come from the plan, so
+the way it goes wrong is an index computed from the wrong extent -- which reads
+and writes past a stack array and produces plausible numbers. That is exactly
+what happened once already: an activation's loop was bounded by the previous
+op's output length, and a branching graph ran 40 iterations over a double[5].
+gcc compiled it silently, and it matched onnxruntime on every model that did
+not branch.
+
+ASan sees the access itself, so it does not depend on anyone having thought of
+the shape of graph that triggers it.
+"""
+import os
+import re
+import shutil
+import subprocess
+
+import pytest
+
+from rosenna.emit_c import emit_c
+from rosenna.emit_fortran import emit_fortran
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.weights import write_weights
+from tests.test_golden_suite import GOLDEN
+
+SAN = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-g", "-O1"]
+# ASan reports on stderr and, with the flag above, exits non-zero. Both are
+# checked: a report that somehow did not set the exit status still fails.
+_REPORT = re.compile(r"AddressSanitizer|runtime error|LeakSanitizer|SEGV", re.I)
+
+_C_DRIVER = """
+#include
+#include
+#include "{name}.h"
+
+#define NPTS 3
+
+int main(void) {{
+ static double x[NPTS * {n_in}], y[NPTS * {n_out}], yb[NPTS * {n_out}];
+ for (int i = 0; i < NPTS * {n_in}; ++i) x[i] = 0.25 + 0.01 * (double)(i % 17);
+ {init}
+ for (int p = 0; p < NPTS; ++p) {name}_infer(x + p * {n_in}, y + p * {n_out});
+ if ({name}_infer_batch(NPTS, x, yb, NULL) != 0) return 2;
+ double s = 0.0;
+ for (int i = 0; i < NPTS * {n_out}; ++i) s += y[i] + yb[i];
+ printf("%g\\n", s);
+ return 0;
+}}
+"""
+
+_F_DRIVER = """
+program san
+ use {name}_model
+ use iso_fortran_env, only: real64
+ implicit none
+ real(real64) :: x({n_in}), y({n_out})
+ integer :: i
+ {init}
+ do i = 1, {n_in}
+ x(i) = 0.25_real64 + 0.01_real64 * real(mod(i - 1, 17), real64)
+ end do
+ call {name}_infer(x, y)
+ print '(es16.8)', sum(y)
+end program
+"""
+
+
+def _sanitize(work, name, plan, graph, lang):
+ """Build one backend under the sanitizers, run it, and return its output."""
+ env = {**os.environ, "UBSAN_OPTIONS": "print_stacktrace=1", "ASAN_OPTIONS": "detect_leaks=0"}
+ if lang == "c":
+ source, header = emit_c(plan)
+ (work / f"{name}.c").write_text(source)
+ (work / f"{name}.h").write_text(header)
+ init = "" if plan.embed else f'if ({name}_init("{name}.rwt") != 0) return 1;'
+ (work / "drv.c").write_text(_C_DRIVER.format(
+ name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0], init=init))
+ build = ["gcc", *SAN, "-std=c11", "-I.", "drv.c", f"{name}.c", "-lm", "-o", "run"]
+ else:
+ (work / f"{name}_model.F90").write_text(emit_fortran(plan))
+ init = "" if plan.embed else f'call {name}_init("{name}.rwt", i)'
+ (work / "drv.f90").write_text(_F_DRIVER.format(
+ name=name, n_in=plan.input.shape[0], n_out=plan.output.shape[0], init=init))
+ build = ["gfortran", *SAN, f"{name}_model.F90", "drv.f90", "-o", "run"]
+ b = subprocess.run(build, cwd=work, capture_output=True, text=True, env=env)
+ assert b.returncode == 0, b.stderr[-2500:]
+ r = subprocess.run(["./run"], cwd=work, capture_output=True, text=True, env=env, timeout=600)
+ return r
+
+
+def _can_sanitize(tool, source, tmp_path):
+ """Can this toolchain actually build a sanitized binary?
+
+ Probed, not assumed: Homebrew's gcc on macOS accepts -fsanitize=address
+ and then fails at link with `ld: library 'asan' not found`, because the
+ runtime ships with Apple's clang rather than with gcc. Checking the
+ compiler exists is not the same question.
+ """
+ if not shutil.which(tool):
+ return False
+ d = tmp_path / f"probe_{tool}"
+ d.mkdir(exist_ok=True)
+ (d / source).write_text("int main(void){return 0;}\n" if source.endswith(".c")
+ else "program p\nend program\n")
+ r = subprocess.run([tool, *SAN, source, "-o", "probe"], cwd=d, capture_output=True, text=True)
+ return r.returncode == 0
+
+
+@pytest.mark.parametrize("lang", ["c", "fortran"])
+@pytest.mark.parametrize("name", GOLDEN)
+def test_golden_model_is_clean_under_asan_and_ubsan(name, lang, tmp_path, golden_model):
+ tool, probe = ("gcc", "p.c") if lang == "c" else ("gfortran", "p.f90")
+ if not _can_sanitize(tool, probe, tmp_path):
+ pytest.skip(f"{tool} cannot link a sanitized binary here")
+ safe = re.sub(r"[^0-9A-Za-z_]", "_", name)
+ graph = load_graph(golden_model(name), safe)
+ plan = build_plan(graph, dtype="f64")
+ if not plan.embed:
+ write_weights(plan, graph, tmp_path / f"{safe}.rwt")
+ r = _sanitize(tmp_path, safe, plan, graph, lang)
+ assert not _REPORT.search(r.stderr), f"{name}/{lang}:\n{r.stderr[-3000:]}"
+ assert r.returncode == 0, f"{name}/{lang} exited {r.returncode}:\n{r.stderr[-3000:]}"
diff --git a/python/tests/test_validate.py b/python/tests/test_validate.py
index 7c6cd45..1780cec 100644
--- a/python/tests/test_validate.py
+++ b/python/tests/test_validate.py
@@ -1,10 +1,273 @@
+"""Every rejection `validate` promises, exercised.
+
+`validate.py` exists so that a model roseNNa cannot lower is refused by name
+rather than silently mis-compiled -- its own docstring says the failure mode it
+prevents is "plausible numbers that are wrong". A rejection nobody has run is
+not that guarantee: the condition may be inverted, may raise `TypeError` or
+`KeyError` before reaching the `raise`, or may name the wrong node.
+
+Coverage of validate.py was 75% before this file, and every uncovered line was
+a `raise`.
+
+These build `frontend.Graph` objects directly rather than going through ONNX.
+That is deliberate: most of these models are ones `onnx.checker` would reject
+too, and the point here is what *roseNNa* says about them, not whether ONNX can
+represent them.
+The acceptance cases at the end round-trip through ONNX instead, because what
+they assert is that a *real* model survives the whole frontend.
+"""
import numpy as np
import onnx
import pytest
from onnx import helper, numpy_helper, TensorProto
-from rosenna.frontend import load_graph, UnsupportedModel
+
+from rosenna.errors import UnsupportedModel
+from rosenna.frontend import Graph, Node, Tensor, load_graph
from rosenna.validate import validate
+F32 = "f32"
+
+
+def _t(name, shape):
+ return Tensor(name, tuple(shape), F32)
+
+
+def _graph(node, values, inits=None, inputs=("x",), outputs=("y",)):
+ return Graph("t", (node,), dict(values), dict(inits or {}), tuple(inputs), tuple(outputs))
+
+
+def _reject(graph, fragment):
+ with pytest.raises(UnsupportedModel, match=fragment):
+ validate(graph)
+
+
+def _arr(*shape):
+ return np.ones(shape, dtype=np.float32)
+
+
+# --- Gemm / MatMul --------------------------------------------------------
+
+def test_gemm_rejections():
+ vals = {"x": _t("x", (1, 3)), "y": _t("y", (1, 2))}
+ w, b = _arr(3, 2), _arr(2)
+ _reject(_graph(Node("Gemm", "g", ("x",), ("y",), {}), vals, {}),
+ "requires at least 2 inputs")
+ _reject(_graph(Node("Gemm", "g", ("x", "w"), ("y",), {"transA": 1}), vals, {"w": w}),
+ "transA=1")
+ for attr in ("alpha", "beta"):
+ _reject(_graph(Node("Gemm", "g", ("x", "w"), ("y",), {attr: 2.0}), vals, {"w": w}),
+ f"Gemm {attr}")
+ _reject(_graph(Node("Gemm", "g", ("x", "runtime"), ("y",), {}),
+ {**vals, "runtime": _t("runtime", (3, 2))}, {}),
+ "must be a constant")
+ _reject(_graph(Node("Gemm", "g", ("x", "w"), ("y",), {}), vals, {"w": _arr(2, 3, 2)}),
+ "has rank 3")
+ _reject(_graph(Node("Gemm", "g", ("x", "w", "bad"), ("y",), {}),
+ {**vals, "bad": _t("bad", (2,))}, {"w": w}),
+ "bias 'bad' must be a constant")
+ _reject(_graph(Node("Gemm", "g", ("x", "w", "b"), ("y",), {}), vals, {"w": w, "b": _arr(1, 2)}),
+ "bias has rank 2")
+ _reject(_graph(Node("Gemm", "g", ("x", "w", "b"), ("y",), {}), vals, {"w": w, "b": _arr(5)}),
+ "values for 2 outputs")
+
+
+def test_matmul_rejections():
+ vals = {"x": _t("x", (1, 3)), "y": _t("y", (1, 2))}
+ _reject(_graph(Node("MatMul", "m", ("x",), ("y",), {}), vals, {}),
+ "requires at least 2 inputs")
+ _reject(_graph(Node("MatMul", "m", ("x", "r"), ("y",), {}),
+ {**vals, "r": _t("r", (3, 2))}, {}),
+ "needs a constant second input")
+ _reject(_graph(Node("MatMul", "m", ("x", "w"), ("y",), {}), vals, {"w": _arr(2, 3, 2)}),
+ "has rank 3")
+
+
+# --- Conv / pooling -------------------------------------------------------
+
+def _spatial_vals(in_shape=(1, 2, 8, 8), out_shape=(1, 2, 6, 6)):
+ return {"x": _t("x", in_shape), "y": _t("y", out_shape)}
+
+
+def test_spatial_shape_rejections():
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {"kernel_shape": (2, 2)}),
+ {"x": _t("x", (1, 8)), "y": _t("y", (1, 4))}),
+ "has rank 2; only rank-3 NCW \\(1-D\\) and rank-4 NCHW")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {"kernel_shape": (2, 2)}),
+ {"x": _t("x", (1, 2, 8, 8)), "y": _t("y", (1, 4))}),
+ "must be a rank-4 value")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {}), _spatial_vals()),
+ "needs kernel_shape")
+ # The arity now comes from the input's rank, so a 3-axis kernel_shape on a
+ # rank-4 (2-D) input is refused for disagreeing rather than for not being 2-D.
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {"kernel_shape": (2, 2, 2)}),
+ _spatial_vals()),
+ "has 3 spatial axes but the input has 2")
+
+
+def test_spatial_attribute_rejections():
+ base = {"kernel_shape": (3, 3)}
+ for attr in ("strides", "dilations"):
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {**base, attr: (1, 1, 1)}),
+ _spatial_vals()),
+ f"{attr} has 3 entries")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {**base, "pads": (0, 0)}), _spatial_vals()),
+ "pads has 2 entries")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {**base, "auto_pad": "WEIRD"}),
+ _spatial_vals()),
+ "auto_pad='WEIRD'")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",),
+ {**base, "auto_pad": "SAME_UPPER", "pads": (1, 1, 1, 1)}), _spatial_vals()),
+ "is ambiguous")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {**base, "ceil_mode": 1}), _spatial_vals()),
+ "ceil_mode=1")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y", "idx"), base), _spatial_vals()),
+ "second .indices. output")
+ _reject(_graph(Node("MaxPool", "p", ("x",), ("y",), {**base, "storage_order": 1}),
+ _spatial_vals()),
+ "storage_order=1")
+
+
+def test_conv_rejections():
+ vals = _spatial_vals()
+ good = _arr(2, 2, 3, 3)
+ _reject(_graph(Node("Conv", "c", ("x", "rt"), ("y",), {"kernel_shape": (3, 3)}),
+ {**vals, "rt": _t("rt", (2, 2, 3, 3))}, {}),
+ "weight must be a constant initializer")
+ _reject(_graph(Node("Conv", "c", ("x", "w"), ("y",), {"kernel_shape": (3, 3)}),
+ vals, {"w": _arr(2, 2, 3)}),
+ "weight has rank 3")
+ # Grouped Conv is supported now, so what is refused is a group that does
+ # not describe the weight: with 2 input channels in 2 groups the weight's
+ # channel axis has to be 1, and `good` has 2.
+ _reject(_graph(Node("Conv", "c", ("x", "w"), ("y",), {"group": 2}), vals, {"w": good}),
+ "channel axis should be 1")
+ _reject(_graph(Node("Conv", "c", ("x", "w"), ("y",), {"group": 3}), vals, {"w": _arr(2, 1, 3, 3)}),
+ "divides neither")
+ _reject(_graph(Node("Conv", "c", ("x", "w"), ("y",), {"group": 0}), vals, {"w": good}),
+ "must be at least 1")
+ _reject(_graph(Node("Conv", "c", ("x", "w"), ("y",), {}), vals, {"w": _arr(2, 5, 3, 3)}),
+ "channel axis should be 2")
+ _reject(_graph(Node("Conv", "c", ("x", "w", "rb"), ("y",), {}),
+ {**vals, "rb": _t("rb", (2,))}, {"w": good}),
+ "bias 'rb' must be a constant")
+ _reject(_graph(Node("Conv", "c", ("x", "w", "b"), ("y",), {}), vals,
+ {"w": good, "b": _arr(9)}),
+ "one value per output channel")
+
+
+# --- Concat ---------------------------------------------------------------
+
+def test_concat_rejections():
+ a, b = _t("a", (1, 3)), _t("b", (1, 4))
+ vals = {"x": _t("x", (1, 3)), "a": a, "b": b, "y": _t("y", (1, 7))}
+ _reject(_graph(Node("Concat", "cat", (), ("y",), {"axis": 1}), vals),
+ "at least one input")
+ _reject(_graph(Node("Concat", "cat", ("a", "gone"), ("y",), {"axis": 1}), vals),
+ "no known shape")
+ _reject(_graph(Node("Concat", "cat", ("a", "r3"), ("y",), {"axis": 1}),
+ {**vals, "r3": _t("r3", (1, 2, 3))}),
+ "same rank")
+ _reject(_graph(Node("Concat", "cat", ("a", "b"), ("y",), {"axis": 5}), vals),
+ "out of range")
+ _reject(_graph(Node("Concat", "cat", ("a", "wide"), ("y",), {"axis": 1}),
+ {**vals, "wide": _t("wide", (2, 4))}),
+ "axis|differ")
+
+
+# --- Add ------------------------------------------------------------------
+
+def test_add_rejections():
+ vals = {"x": _t("x", (1, 4)), "y": _t("y", (1, 4))}
+ c = _arr(4)
+ _reject(_graph(Node("Add", "a", ("x",), ("y",), {}), vals, {"c": c}),
+ "exactly 2 inputs")
+ _reject(_graph(Node("Add", "a", ("x", "z"), ("y",), {}),
+ {**vals, "z": _t("z", (1, 4))}, {}),
+ "exactly one runtime operand")
+ _reject(_graph(Node("Add", "a", ("x", "c"), ("gone",), {}), vals, {"c": c}),
+ "must have inferred shapes")
+ _reject(_graph(Node("Add", "a", ("x", "c"), ("y",), {}),
+ {"x": _t("x", (1, 2)), "y": _t("y", (1, 4))}, {"c": c}),
+ "only the constant operand may broadcast")
+ _reject(_graph(Node("Add", "a", ("x", "c"), ("y",), {}), vals, {"c": _arr(1, 1, 4)}),
+ "wider than the output")
+ _reject(_graph(Node("Add", "a", ("x", "c"), ("y",), {}), vals, {"c": _arr(3)}),
+ "neither\n?matches|neither matches")
+
+
+# --- LSTM -----------------------------------------------------------------
+
+def _lstm_vals(hidden=2, inp=3, seq=2):
+ return {"x": _t("x", (seq, 1, inp)), "y": _t("y", (seq, 1, 1, hidden))}
+
+
+def _lstm(attrs=None, inputs=("x", "W", "R"), outputs=("y",), extra_vals=None, inits=None):
+ vals = {**_lstm_vals(), **(extra_vals or {})}
+ base = {"W": _arr(1, 8, 3), "R": _arr(1, 8, 2)}
+ return _graph(Node("LSTM", "l", inputs, outputs, {"hidden_size": 2, **(attrs or {})}),
+ vals, {**base, **(inits or {})})
+
+
+def test_lstm_attribute_rejections():
+ _reject(_lstm({"direction": "bidirectional"}), "direction=")
+ _reject(_lstm({"activations": ("Relu",) * 3}), "custom activations")
+ for attr, val in (("clip", 1.0), ("input_forget", 1), ("layout", 1)):
+ _reject(_lstm({attr: val}), f"{attr}=")
+
+
+def test_lstm_input_rejections():
+ _reject(_lstm(inputs=("x", "W", "R", "", "lens")), "sequence_lens")
+ _reject(_lstm(inputs=("x", "W", "R", "", "", "", "", "P")), "peephole")
+ g = _graph(Node("LSTM", "l", ("x", "W", "R"), ("y",), {"hidden_size": 2}),
+ {"x": _t("x", (1, 4)), "y": _t("y", (1, 2))},
+ {"W": _arr(1, 8, 3), "R": _arr(1, 8, 2)})
+ _reject(g, "rank-3")
+ _reject(_lstm(inputs=("x", "rt", "R"), extra_vals={"rt": _t("rt", (1, 8, 3))},
+ inits={"W": _arr(1, 8, 3)}), "must be a constant initializer")
+ _reject(_lstm(inits={"W": _arr(8, 3)}), r"must have shape \(1, 4\*hidden")
+ _reject(_lstm(inputs=("x", "W", "R", "B"), inits={"B": _arr(3, 3)}),
+ r"B must be a constant of shape")
+
+
+def test_lstm_initial_state_rejections():
+ _reject(_lstm(inputs=("x", "W", "R", "", "", "h0"),
+ extra_vals={"h0": _t("h0", (1, 1, 2))}),
+ "supplied together")
+ _reject(_lstm(inputs=("x", "W", "R", "", "", "h0", "c0"),
+ extra_vals={"h0": _t("h0", (1, 2)), "c0": _t("c0", (1, 1, 2))}),
+ "must be a rank-3")
+
+
+# --- the global checks ----------------------------------------------------
+
+def test_rank_and_dtype_rejections():
+ _reject(_graph(Node("Relu", "r", ("x",), ("y",), {}),
+ {"x": _t("x", (1, 2, 3, 4, 5)), "y": _t("y", (1, 2, 3, 4, 5))}),
+ "this generator handles rank 1 to 4")
+ # An initializer no per-op rule claims: the per-op weight checks are
+ # tighter and fire before the global one, so this reaches the global check.
+ _reject(_graph(Node("Relu", "r", ("x",), ("y",), {}),
+ {"x": _t("x", (1, 3)), "y": _t("y", (1, 3))},
+ {"spare": np.ones((1, 2, 3, 4, 5), np.float32)}),
+ "initializer 'spare' has rank 5")
+ _reject(_graph(Node("Relu", "r", ("x",), ("y",), {}),
+ {"x": _t("x", (1, 3)), "y": _t("y", (1, 3))},
+ {"spare": np.ones((3, 2), np.int64)}),
+ "only floating-point")
+
+
+def test_an_unsupported_op_names_the_node_and_lists_what_is_supported():
+ with pytest.raises(UnsupportedModel) as e:
+ # Not an op on the roadmap: this assertion named Softmax until
+ # Softmax was implemented, and then failed for a coverage win.
+ validate(_graph(Node("Erf", "sm0", ("x",), ("y",), {}),
+ {"x": _t("x", (1, 3)), "y": _t("y", (1, 3))}))
+ assert "sm0" in str(e.value) and "Erf is not supported" in str(e.value)
+ assert "Gemm" in str(e.value), "the message should list what it does handle"
+
+
+# --- acceptance: a model that should NOT be refused -----------------------
+
def _model(tmp_path, nodes, inits, in_shape=(1, 2), out_shape=(1, 2)):
x = helper.make_tensor_value_info("x", TensorProto.FLOAT, list(in_shape))
y = helper.make_tensor_value_info("y", TensorProto.FLOAT, list(out_shape))
@@ -14,57 +277,11 @@ def _model(tmp_path, nodes, inits, in_shape=(1, 2), out_shape=(1, 2)):
onnx.save(m, p)
return load_graph(p)
+
def test_accepts_supported_dense_models(golden_model):
for name in ["gemm_small", "gemm_big", "gemm_nobias", "droplet", "batchnet"]:
validate(load_graph(golden_model(name)))
-def test_rejects_unsupported_op(tmp_path):
- n = helper.make_node("Softmax", ["x"], ["y"], name="soft1")
- g = _model(tmp_path, [n], [])
- with pytest.raises(UnsupportedModel, match="soft1.*Softmax"):
- validate(g)
-
-def test_rejects_gemm_transa(tmp_path):
- w = numpy_helper.from_array(np.zeros((2, 2), np.float32), "w")
- n = helper.make_node("Gemm", ["x", "w"], ["y"], name="g1", transA=1)
- with pytest.raises(UnsupportedModel, match="g1.*transA"):
- validate(_model(tmp_path, [n], [w]))
-
-def test_rejects_gemm_alpha(tmp_path):
- w = numpy_helper.from_array(np.zeros((2, 2), np.float32), "w")
- n = helper.make_node("Gemm", ["x", "w"], ["y"], name="g1", alpha=2.0)
- with pytest.raises(UnsupportedModel, match="g1.*alpha"):
- validate(_model(tmp_path, [n], [w]))
-
-def test_rejects_matmul_with_runtime_rhs(tmp_path):
- n1 = helper.make_node("Relu", ["x"], ["r"], name="r1")
- n2 = helper.make_node("MatMul", ["x", "r"], ["y"], name="mm1")
- with pytest.raises(UnsupportedModel, match="mm1"):
- validate(_model(tmp_path, [n1, n2], []))
-
-def test_rejects_gemm_weight_rank3(tmp_path):
- w = numpy_helper.from_array(np.zeros((2, 2, 2), np.float32), "w")
- n = helper.make_node("Gemm", ["x", "w"], ["y"], name="g1")
- with pytest.raises(UnsupportedModel, match="w.*rank"):
- validate(_model(tmp_path, [n], [w]))
-
-def test_rejects_matmul_rhs_rank3(tmp_path):
- w = numpy_helper.from_array(np.zeros((2, 2, 2), np.float32), "w")
- n = helper.make_node("MatMul", ["x", "w"], ["y"], name="mm1")
- with pytest.raises(UnsupportedModel, match="w.*rank"):
- validate(_model(tmp_path, [n], [w]))
-
-def test_rejects_gemm_short_inputs(tmp_path):
- n = helper.make_node("Gemm", ["x"], ["y"], name="g1")
- g = _model(tmp_path, [n], [])
- with pytest.raises(UnsupportedModel, match="g1"):
- validate(g)
-
-def test_rejects_matmul_short_inputs(tmp_path):
- n = helper.make_node("MatMul", ["x"], ["y"], name="mm1")
- g = _model(tmp_path, [n], [])
- with pytest.raises(UnsupportedModel, match="mm1"):
- validate(g)
def test_accepts_rank2_weight_with_nonunity_leading_dim(tmp_path):
w = numpy_helper.from_array(np.zeros((3, 2), np.float32), "w")
diff --git a/python/tests/test_weights_corruption.py b/python/tests/test_weights_corruption.py
new file mode 100644
index 0000000..6e64123
--- /dev/null
+++ b/python/tests/test_weights_corruption.py
@@ -0,0 +1,169 @@
+"""Every diagnostic `read_weights` promises for a bad `.rwt` file.
+
+The reader's error paths were the last uncovered block in the package (78%),
+and they are the same kind of promise `validate.py` makes: a file that is
+truncated, from another plan, or written by another version must be *named*,
+not silently half-read into weights that then compute plausible nonsense. The
+existing tests cover two truncations; these cover the rest.
+
+Each case takes a real file and damages exactly one thing, so the diagnostic
+under test is the one that fires rather than an earlier check catching it
+first. The offsets come from the format in weights.py:
+
+ 0 MAGIC (8)
+ 8 version, dtype_code, endian, count (4x4)
+ 24 plan hash (32)
+ 56 TOC length (4)
+ 60 TOC, then the body
+"""
+import struct
+
+import numpy as np
+import pytest
+from onnx import helper, numpy_helper, TensorProto
+
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+from rosenna.weights import MAGIC, VERSION, read_weights, write_weights
+
+
+@pytest.fixture
+def rwt(tmp_path):
+ """A valid two-weight file, plus its bytes."""
+ rng = np.random.default_rng(3)
+ w = numpy_helper.from_array(rng.uniform(-1, 1, (3, 2)).astype(np.float32), "w")
+ b = numpy_helper.from_array(rng.uniform(-1, 1, (2,)).astype(np.float32), "b")
+ graph = helper.make_graph(
+ [helper.make_node("Gemm", ["x", "w", "b"], ["y"], name="g0")], "wt",
+ [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3])],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 2])], [w, b])
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
+ model.ir_version = 8
+ onnx_path = tmp_path / "wt.onnx"
+ import onnx
+ onnx.save(model, str(onnx_path))
+ g = load_graph(onnx_path)
+ plan = build_plan(g, dtype="f32", embed=False)
+ path = tmp_path / "wt.rwt"
+ write_weights(plan, g, path)
+ return path, path.read_bytes()
+
+
+def _expect(path, blob, fragment):
+ path.write_bytes(blob)
+ with pytest.raises(ValueError, match=fragment):
+ read_weights(path)
+
+
+def test_a_valid_file_reads_back(rwt):
+ path, _ = rwt
+ tensors, header = read_weights(path)
+ assert header["version"] == VERSION and header["count"] == 2
+ assert set(tensors) == {"w", "b"}, "tensors is a dict keyed by name"
+
+
+def test_a_file_that_is_not_a_weights_file_is_named(rwt):
+ path, blob = rwt
+ _expect(path, b"NOTROSEN" + blob[8:], "not a roseNNa weights file")
+
+
+def test_a_header_shorter_than_24_bytes(rwt):
+ path, blob = rwt
+ _expect(path, blob[:20], "header requires 24 bytes")
+
+
+def test_a_version_from_another_release(rwt):
+ path, blob = rwt
+ _expect(path, blob[:8] + struct.pack("=2.5
onnx>=1.14
numpy>=1.24
-fypp>=3.1
onnxruntime>=1.15
pytest>=8.0
+pytest-cov
+pytest-xdist
diff --git a/test/Makefile b/test/Makefile
deleted file mode 100644
index c6ced11..0000000
--- a/test/Makefile
+++ /dev/null
@@ -1,75 +0,0 @@
-FC=gfortran -I../fLibrary/objFiles/ -J../fLibrary/objFiles/
-DIR = ../fLibrary
-MAIN = ../
-FFLAGS=-O3
-SRC=userTesting.fpp
-SRCBASE=$(DIR)/activation_funcs.f90 $(DIR)/derived_types.f90 $(DIR)/layers.f90 $(DIR)/reader.f90
-OBJ2=modelCreator.o ${SRC:.fpp=.o}
-COMP=${SRCBASE:.f90=.o}
-
-output: $(COMP) $(OBJ2)
- $(FC) $(FFLAGS) -o $@ $(COMP) $(OBJ2)
-
-.PRECIOUS : %.f90
-%.f90: %.fpp variables.fpp
- fypp -I. $< $*.f90
-
-modelCreator.f90: $(DIR)/modelCreator.fpp variables.fpp
- fypp -I. $(DIR)/modelCreator.fpp modelCreator.f90
-
-%.o: %.f90
- $(FC) $(FFLAGS) -o $@ -c $<
-
-$(DIR)/%.o: $(DIR)/%.f90
- $(FC) $(FFLAGS) -o $@ -c $<
-
-test: ex1 user output
- ./output 2> outputCase.txt
-
-user: $(OBJ2) variables.fpp
-
-testing: ex1 compile user output
- ./output 2> outputCase.txt
- python3 -Wi testChecker.py $(case)
-
-capi: $(COMP) modelCreator.o
- gcc -c ../examples/cAPI.c -o cAPI.o
- $(FC) $(FFLAGS) -o capi cAPI.o $(COMP) modelCreator.o
- ./capi
-
-ex1: $(DIR)/modelParserONNX.py
- rm -f modelCreator.f90 modelCreator.o userTesting.f90 userTesting.o output
- python3 $(MAIN)goldenFiles/$(case)/$(case).py
- python3 $(DIR)/modelParserONNX.py -f $(MAIN)goldenFiles/$(case)/$(case).onnx -i $(MAIN)goldenFiles/$(case)/$(case)_inferred.onnx
-
-
-graphs: output
- ./output 2> outputCase.txt
-
-
-
-compile: $(COMP)
-
-unit: $(COMP) unit_tests.o weights_format_tests.o
- $(FC) $(FFLAGS) -o unit_tests $(COMP) unit_tests.o
- ./unit_tests
- gcc -c c_default_paths.c -o c_default_paths.o
- $(FC) $(FFLAGS) -o c_default_paths c_default_paths.o $(COMP)
- rm -rf c_default_paths_run && mkdir c_default_paths_run
- cd c_default_paths_run && ! ../c_default_paths 2> err.txt && grep -q "cannot open model file 'onnxModel.txt'" err.txt
- rm -rf c_default_paths_run
- $(FC) $(FFLAGS) -o weights_format_tests $(COMP) weights_format_tests.o
- rm -rf weights_format_run && mkdir weights_format_run
- cd weights_format_run && ../weights_format_tests write
- cd weights_format_run && ../weights_format_tests bin
- cd weights_format_run && ../weights_format_tests txt
- cd weights_format_run && ! ../weights_format_tests extra 2> err_extra.txt && grep -q "do not match the model" err_extra.txt
- cd weights_format_run && rm -f onnxWeights.bin && ../weights_format_tests fallback 2> err_fallback.txt && grep -q "onnxWeights.bin not found, reading legacy onnxWeights.txt" err_fallback.txt
- rm -rf weights_format_run
-
-clean:
- rm -f *.o *.mod output unit_tests c_default_paths weights_format_tests capi cAPI.o
- rm -rf c_default_paths_run weights_format_run
- rm -f $(DIR)/*.o
- rm -f modelCreator.f90 userTesting.f90 variables.fpp inputs.fpp
- rm -f test.txt outputCase.txt onnxModel.txt onnxWeights.txt onnxWeights.bin
diff --git a/test/c_default_paths.c b/test/c_default_paths.c
deleted file mode 100644
index df202b2..0000000
--- a/test/c_default_paths.c
+++ /dev/null
@@ -1,9 +0,0 @@
-#include
-
-void initialize(const char * model_file, const char * weights_file);
-
-/* No arguments: default paths. Run in an empty directory, so it must fail on onnxModel.txt. */
-int main(void) {
- initialize(NULL, NULL);
- return 0;
-}
diff --git a/test/run.sh b/test/run.sh
deleted file mode 100755
index 9f11c00..0000000
--- a/test/run.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-npass=0
-nfail=0
-testnum=1
-skip="__pycache__"
-make compile
-make unit || { echo "FATAL: Fortran unit tests failed"; exit 1; }
-python3 test_parser.py || { echo "FATAL: parser tests failed"; exit 1; }
-for d in ../goldenFiles/*/ ; do
- name=$(basename "$d")
- if [[ "$name" != "$skip" ]] && [[ "$name" != "vgg16" ]] && [[ "$name" != "gemm_huge" ]] && [[ "$name" != "turbulentShear" ]]; then
- echo "---------------- TEST #$testnum $name -------------------"
- if make test case="$name"; then
- output=$(python3 -Wi testChecker.py "$name")
- if [[ $? -eq 0 ]]; then
- ((++npass))
- else
- ((++nfail))
- fi
- echo "$output"
- else
- # test.txt is stale; skip the checker
- ((++nfail))
- echo "Fail!! make test failed for $name (parser rejection or build failure); outputs not compared"
- fi
- echo -e "---------------- TEST #$testnum $name -------------------\n"
- ((++testnum))
- fi
-done
-echo "$npass out of $(($npass + $nfail)) test cases have passed!"
-exit $nfail
diff --git a/test/testChecker.py b/test/testChecker.py
deleted file mode 100644
index c1a8a94..0000000
--- a/test/testChecker.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import sys
-import os
-file = sys.argv[1]
-with open("test.txt") as f, open("../goldenFiles/"+file+"/"+file+".txt") as f2:
- try:
- fortran = f.readlines()
- py = f2.readlines()
- shapeF = list(map(int,fortran[0].strip().split()))
- outputF = list(map(float,fortran[1].strip().split()))
- shapeP = list(map(int,py[0].strip().split()))
- outputP = list(map(float,py[1].strip().split()))
- outShape = shapeP == shapeF
- outRes = True
- for p, f in zip(outputP,outputF):
- if abs(p-f) > 10**-5:
- outRes = False
- break
- except Exception as e:
- print(str(e))
- finally:
- outCompRun = True
- outputFailPath = "outputCase.txt"
- if os.path.exists(outputFailPath) and os.stat(outputFailPath).st_size != 0:
- outCompRun = False
- if outCompRun:
- if outRes and outShape:
- print("Outputs match! Pass!")
- else: #shapes do not match
- print("Fail!!")
- if not outShape:
- print("Output shapes do not match!!")
- print(f"Correct shape is {shapeP}. But, F90 outputted {shapeF}")
- if not outRes:
- print("Incorrect outputs.")
- sys.exit(1)
- else:
- print("Error occurred while executing! Failed! Here is the output: ")
- failed = open("outputCase.txt",'r')
- fail = failed.read()
- print(fail)
- sys.exit(1)
diff --git a/test/test_parser.py b/test/test_parser.py
deleted file mode 100644
index 132bfb0..0000000
--- a/test/test_parser.py
+++ /dev/null
@@ -1,220 +0,0 @@
-import sys, os
-import numpy as np
-
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "fLibrary"))
-import onnx_helpers as H
-
-failures = []
-
-def check(cond, name):
- if cond:
- print(f" ok {name}")
- else:
- print(f" FAIL {name}")
- failures.append(name)
-
-def test_stranspose_is_column_major():
- for shape in [(5,), (2, 3), (2, 3, 4), (2, 3, 4, 5)]:
- a = np.arange(int(np.prod(shape))).reshape(shape)
- expected = " ".join(str(x) for x in a.flatten(order="F").tolist())
- check(H.stranspose(a) == expected, f"stranspose column-major {shape}")
-
-def test_stringer():
- check(H.stringer([1, 2, 3]) == "1 2 3", "stringer joins with spaces")
- check(H.stringer([]) == "", "stringer handles empty")
-
-def test_reshape_parser_resolves_negative_one():
- check(H.reshapeParser([-1, 4], [2, 2, 4]) == [4, 4], "reshapeParser resolves -1")
- check(H.reshapeParser([2, 4], [2, 4]) == [2, 4], "reshapeParser passes through")
-
-def test_regate_lstm_reorders_iofc_to_ifgo():
- h = 2
- # ONNX gate order i, o, f, c
- onnx_w = np.concatenate([
- np.full((h, 3), 0.0), # i
- np.full((h, 3), 1.0), # o
- np.full((h, 3), 2.0), # f
- np.full((h, 3), 3.0), # c/g
- ], axis=0)
- got = H.regateLSTM(onnx_w, axis=0)
- check(np.all(got[0*h:1*h] == 0.0), "regateLSTM keeps i first")
- check(np.all(got[1*h:2*h] == 2.0), "regateLSTM moves f second")
- check(np.all(got[2*h:3*h] == 3.0), "regateLSTM moves g third")
- check(np.all(got[3*h:4*h] == 1.0), "regateLSTM moves o last")
-
-def test_regate_lstm_handles_direction_axis():
- h = 2
- onnx_w = np.arange(1 * 4 * h * 3, dtype=float).reshape(1, 4 * h, 3)
- got = H.regateLSTM(onnx_w, axis=1)
- check(got.shape == (1, 4 * h, 3), "regateLSTM preserves shape with direction axis")
- check(np.all(got[0, 1 * h:2 * h] == onnx_w[0, 2 * h:3 * h]), "regateLSTM remaps along axis 1")
-
-def test_four_d_transform_right_aligns():
- # (1,4,3,3) and (4,) do not broadcast
- try:
- H.fourDTransform([1,4,3,3], (4,))
- check(False, "fourDTransform rejects a non-broadcastable trailing vector")
- except ValueError:
- check(True, "fourDTransform rejects a non-broadcastable trailing vector")
- check(H.fourDTransform([1,3,3,3], (3,3)) == [1,1,3,3],
- "fourDTransform right-aligns a 2D add against a 4D target")
- check(H.fourDTransform([1,4,3,3], (3,3)) == [1,1,3,3],
- "fourDTransform right-aligns regardless of channel count")
- check(H.fourDTransform([1,4,3,3], (1,4,1,1)) == [1,4,1,1],
- "fourDTransform passes through an already-4D shape")
-
-def test_sanitize_produces_fortran_identifiers():
- import re
- ident = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,62}$")
- for raw in ["onnx::Gemm_0", "3", "/layer1/Gemm_output_0", "input", "a.b.c", "", "x" * 200]:
- got = H.sanitize(raw)
- check(bool(ident.match(got)), f"sanitize({raw[:30]!r}) -> {got!r} is a valid Fortran identifier")
- check(H.sanitize("input") == "v_input", "sanitize prefixes a valid lowercase name")
- check(H.sanitize("onnx::Gemm_0") != H.sanitize("onnx::Gemm_1"),
- "sanitize keeps distinct names distinct")
-
-def test_sanitize_avoids_fortran_collisions():
- # Fortran ignores case; names share scope with generated locals and calls
- check(H.sanitize("Input").lower() != H.sanitize("input").lower(),
- "sanitize separates names that differ only by case")
- reserved = {"i0", "o0", "output0", "t1", "t2", "conv", "lstm", "linear_layer", "max_pool",
- "avgpool", "reshape", "size", "transpose", "relu2d", "sigmoid2d", "tanhh2d"}
- for raw in ["i0", "o0", "output0", "T1", "t2", "conv", "reshape", "relu2d", "LSTM"]:
- check(H.sanitize(raw).lower() not in reserved,
- f"sanitize({raw!r}) avoids a generated or called name")
-
-def test_check_supported_rejects_unimplemented_attrs():
- def raises(fn):
- try:
- fn()
- except NotImplementedError:
- return True
- return False
-
- # attributes roseNNa parses but ignores
- check(raises(lambda: H.checkSupported("Conv", {"dilations": [2, 2], "kernel_shape": [3, 3], "pads": [0]*4, "strides": [1, 1]})),
- "rejects dilations != 1")
- check(raises(lambda: H.checkSupported("MaxPool", {"ceil_mode": 1, "kernel_shape": [2, 2], "pads": [0]*4, "strides": [1, 1]})),
- "rejects ceil_mode = 1")
- check(raises(lambda: H.checkSupported("Conv", {"kernel_shape": [3, 3], "pads": [1, 1, 2, 2], "strides": [1, 1]})),
- "rejects asymmetric pads")
- check(raises(lambda: H.checkSupported("Conv", {"kernel_shape": [3, 5], "pads": [0]*4, "strides": [1, 1]})),
- "rejects non-square kernels")
- check(not raises(lambda: H.checkSupported("Conv", {"dilations": [1, 1], "kernel_shape": [3, 3], "pads": [1, 1, 1, 1], "strides": [2, 2]})),
- "accepts a supported Conv")
-
-def test_check_supported_required_kernel_and_pooling_padding():
- def raises(fn):
- try:
- fn()
- except NotImplementedError:
- return True
- return False
-
- # kernel_shape is required for pooling, optional (inferred) for Conv
- check(raises(lambda: H.checkSupported("MaxPool", {"pads": [0]*4, "strides": [1, 1]})),
- "rejects MaxPool with no kernel_shape")
- check(raises(lambda: H.checkSupported("AveragePool", {"pads": [0]*4, "strides": [1, 1]})),
- "rejects AveragePool with no kernel_shape")
- check(not raises(lambda: H.checkSupported("Conv", {"kernel_shape": [3, 3], "pads": [0]*4})),
- "accepts Conv whose kernel_shape was inferred")
- # AveragePool divisor: roseNNa always divides by the full kernel area
- check(raises(lambda: H.checkSupported("AveragePool", {"kernel_shape": [3, 3], "pads": [1]*4, "count_include_pad": 0})),
- "rejects padded AveragePool with count_include_pad=0")
- check(raises(lambda: H.checkSupported("AveragePool", {"kernel_shape": [3, 3], "pads": [1]*4})),
- "rejects padded AveragePool with count_include_pad absent (ONNX default 0)")
- check(not raises(lambda: H.checkSupported("AveragePool", {"kernel_shape": [3, 3], "pads": [1]*4, "count_include_pad": 1})),
- "accepts padded AveragePool with count_include_pad=1")
- check(not raises(lambda: H.checkSupported("AveragePool", {"kernel_shape": [3, 3], "pads": [0]*4})),
- "accepts unpadded AveragePool regardless of count_include_pad")
- # AveragePool never computes SAME padding
- check(raises(lambda: H.checkSupported("AveragePool", {"kernel_shape": [3, 3], "auto_pad": "SAME_UPPER"})),
- "rejects AveragePool with auto_pad=SAME_UPPER")
- check(not raises(lambda: H.checkSupported("AveragePool", {"kernel_shape": [3, 3], "auto_pad": "VALID"})),
- "accepts AveragePool with auto_pad=VALID")
- # grouped convolution reads past the weight array in the Fortran conv
- check(raises(lambda: H.checkSupported("Conv", {"kernel_shape": [3, 3], "group": 2})),
- "rejects grouped Conv (group=2)")
- check(not raises(lambda: H.checkSupported("Conv", {"kernel_shape": [3, 3], "group": 1})),
- "accepts ungrouped Conv (group=1)")
-
-def test_pad_is_rejected_unless_identity():
- def raises(fn):
- try:
- fn()
- except NotImplementedError:
- return True
- return False
-
- check(raises(lambda: H.checkPadIsNoop([0, 0, 1, 1, 0, 0, 1, 1])),
- "rejects a Pad node with nonzero pads")
- check(not raises(lambda: H.checkPadIsNoop([0]*8)),
- "accepts an all-zero Pad node")
-
-def _raises(fn):
- try:
- fn()
- except NotImplementedError:
- return True
- return False
-
-def test_same_padding_requires_unit_stride():
- # SAME pads are kernel-1, right only for stride 1
- for op in ("Conv", "MaxPool"):
- for mode in ("SAME_UPPER", "SAME_LOWER"):
- check(_raises(lambda: H.checkSupported(op, {"kernel_shape": [3, 3], "pads": [1]*4, "strides": [2, 2], "auto_pad": mode})),
- f"rejects {op} auto_pad={mode} with strides [2, 2]")
- check(_raises(lambda: H.checkSupported(op, {"kernel_shape": [3, 3], "pads": [1]*4, "strides": [1, 2], "auto_pad": "SAME_UPPER"})),
- f"rejects {op} auto_pad=SAME_UPPER with strides [1, 2]")
- check(not _raises(lambda: H.checkSupported(op, {"kernel_shape": [3, 3], "pads": [1]*4, "strides": [1, 1], "auto_pad": "SAME_UPPER"})),
- f"accepts {op} auto_pad=SAME_UPPER with strides [1, 1]")
- check(not _raises(lambda: H.checkSupported(op, {"kernel_shape": [3, 3], "pads": [0]*4, "strides": [2, 2], "auto_pad": "NOTSET"})),
- f"accepts {op} explicit auto_pad=NOTSET with strides [2, 2]")
- check(not _raises(lambda: H.checkSupported(op, {"kernel_shape": [3, 3], "pads": [0]*4, "strides": [2, 2], "auto_pad": "VALID"})),
- f"accepts {op} auto_pad=VALID with strides [2, 2]")
-
-def test_lstm_rejects_unimplemented_attrs():
- check(_raises(lambda: H.checkLSTMSupported({"direction": "bidirectional"})),
- "rejects LSTM direction=bidirectional")
- check(_raises(lambda: H.checkLSTMSupported({"direction": "reverse"})),
- "rejects LSTM direction=reverse")
- check(_raises(lambda: H.checkLSTMSupported({"activations": ["Relu", "Tanh", "Tanh"]})),
- "rejects LSTM non-default activations")
- check(_raises(lambda: H.checkLSTMSupported({"clip": 3.0})),
- "rejects LSTM clip")
- check(_raises(lambda: H.checkLSTMSupported({"input_forget": 1})),
- "rejects LSTM input_forget=1")
- check(_raises(lambda: H.checkLSTMSupported({"layout": 1})),
- "rejects LSTM layout=1")
- check(not _raises(lambda: H.checkLSTMSupported({})),
- "accepts LSTM with every attribute absent (what PyTorch exports)")
- check(not _raises(lambda: H.checkLSTMSupported({"direction": "forward", "activations": ["Sigmoid", "Tanh", "Tanh"], "input_forget": 0, "layout": 0})),
- "accepts LSTM with the explicit defaults")
- check(not _raises(lambda: H.checkLSTMSupported({"activations": ["sigmoid", "TANH", "tanh"]})),
- "accepts LSTM default activations in any letter case")
-
-def test_gemm_bias_must_be_rank_one():
- check(_raises(lambda: H.checkGemmBias((1, 4))),
- "rejects a Gemm bias of shape (1, 4)")
- check(_raises(lambda: H.checkGemmBias(())),
- "rejects a scalar Gemm bias")
- check(not _raises(lambda: H.checkGemmBias((4,))),
- "accepts a rank-1 Gemm bias")
-
-if __name__ == "__main__":
- test_stranspose_is_column_major()
- test_stringer()
- test_reshape_parser_resolves_negative_one()
- test_regate_lstm_reorders_iofc_to_ifgo()
- test_regate_lstm_handles_direction_axis()
- test_four_d_transform_right_aligns()
- test_sanitize_produces_fortran_identifiers()
- test_sanitize_avoids_fortran_collisions()
- test_check_supported_rejects_unimplemented_attrs()
- test_check_supported_required_kernel_and_pooling_padding()
- test_pad_is_rejected_unless_identity()
- test_same_padding_requires_unit_stride()
- test_lstm_rejects_unimplemented_attrs()
- test_gemm_bias_must_be_rank_one()
- print(f"PARSER TESTS: {len(failures)} failure(s)")
- sys.exit(1 if failures else 0)
diff --git a/test/unit_tests.f90 b/test/unit_tests.f90
deleted file mode 100644
index ea5c4c5..0000000
--- a/test/unit_tests.f90
+++ /dev/null
@@ -1,106 +0,0 @@
-program unit_tests
- use iso_c_binding
- use activation_functions
- use derived_types
- use model_layers
- implicit none
-
- integer :: failures = 0
-
- call test_relu_basic()
- call test_sigmoid_midpoint()
- call test_maxpool_identity_nonsquare()
- call test_maxpool_preserves_batch()
- call test_tanh_saturates()
- call test_linear_layer_untransposed()
-
- if (failures > 0) then
- write(*,'(a,i0,a)') 'UNIT TESTS: ', failures, ' failure(s)'
- stop 1
- end if
- write(*,'(a)') 'UNIT TESTS: all passed'
-
-contains
-
- subroutine check(cond, name)
- logical, intent(in) :: cond
- character(*), intent(in) :: name
- if (cond) then
- write(*,'(a,a)') ' ok ', name
- else
- write(*,'(a,a)') ' FAIL ', name
- failures = failures + 1
- end if
- end subroutine
-
- subroutine test_relu_basic()
- real(c_double) :: x(3), y(3)
- x = [-1.0d0, 0.0d0, 2.0d0]
- y = relu(x)
- call check(all(abs(y - [0.0d0, 0.0d0, 2.0d0]) < 1.0d-12), 'relu clamps negatives')
- end subroutine
-
- subroutine test_sigmoid_midpoint()
- real(c_double) :: y(1)
- y = sigmoid([0.0d0])
- call check(abs(y(1) - 0.5d0) < 1.0d-12, 'sigmoid(0) == 0.5')
- end subroutine
-
- subroutine test_maxpool_identity_nonsquare()
- real(c_double), allocatable :: x(:,:,:,:)
- type(maxpoolLayer) :: mp
- integer :: r, c
- logical :: ok
- allocate(x(1,1,2,4))
- do r = 1, 2
- do c = 1, 4
- x(1,1,r,c) = 10.0d0*r + c
- end do
- end do
- mp%kernel_size = 1
- call max_pool(x, mp, 0, [0,0], [1,1])
- ok = all(shape(x) == [1,1,2,4])
- if (ok) ok = abs(x(1,1,1,3) - 13.0d0) < 1.0d-12 .and. &
- abs(x(1,1,2,4) - 24.0d0) < 1.0d-12
- call check(ok, 'max_pool k=1 s=1 is identity on non-square input')
- end subroutine
-
- subroutine test_maxpool_preserves_batch()
- real(c_double), allocatable :: x(:,:,:,:)
- type(maxpoolLayer) :: mp
- allocate(x(2,1,2,2))
- x = 1.0d0
- x(2,:,:,:) = 2.0d0
- mp%kernel_size = 2
- call max_pool(x, mp, 0, [0,0], [1,1])
- call check(all(shape(x) == [2,1,1,1]) .and. &
- abs(x(2,1,1,1) - 2.0d0) < 1.0d-12, &
- 'max_pool preserves the batch dimension')
- end subroutine
-
- subroutine test_tanh_saturates()
- real(c_double) :: y(3)
- y = tanhh([1.0d0, 720.0d0, -720.0d0])
- call check(abs(y(1) - 0.761594155955765d0) < 1.0d-12 .and. &
- abs(y(2) - 1.0d0) < 1.0d-12 .and. &
- abs(y(3) + 1.0d0) < 1.0d-12, &
- 'tanhh saturates instead of overflowing to NaN')
- end subroutine
-
- subroutine test_linear_layer_untransposed()
- real(c_double), allocatable :: a(:,:)
- type(linLayer) :: L
- logical :: ok
- allocate(a(1,2))
- a = reshape([1.0d0, 2.0d0], [1,2])
- allocate(L%weights(2,3))
- L%weights = reshape([1.0d0,2.0d0,3.0d0,4.0d0,5.0d0,6.0d0], [2,3])
- allocate(L%biases(3))
- L%biases = 0.0d0
- call linear_layer(a, L, 1)
- ok = all(shape(a) == [1,3])
- if (ok) ok = all(abs(reshape(a, [3]) - [5.0d0, 11.0d0, 17.0d0]) < 1.0d-12)
- call check(ok, 'linear_layer computes A*B when transB=0')
- end subroutine
-
-end program unit_tests
diff --git a/test/userTesting.fpp b/test/userTesting.fpp
deleted file mode 100644
index da14c4f..0000000
--- a/test/userTesting.fpp
+++ /dev/null
@@ -1,53 +0,0 @@
-program name
- #:def ranksuffix(RANK)
- $:'' if RANK == 0 else '(' + ':' + ',:' * (RANK - 1) + ')'
- #:enddef ranksuffix
- #:def genArray(arr)
- (/#{for index, x in enumerate(arr)}#${x}$#{if index < (len(arr)-1)}#, #{endif}##{endfor}#/)
- #:enddef genArray
- #:def gen(arr)
- #{for index, x in enumerate(arr)}#${x}$#{if index < (len(arr)-1)}#, #{endif}##{endfor}#
- #:enddef gen
-
- #:def rev(arr)
- [#{for x in range(len(arr),0,-1)}#${x}$ #{if x > 1}#, #{endif}##{endfor}#]
- #:enddef rev
- #:def revNum(num)
- [#{for x in range(num,0,-1)}#${x}$ #{if x > 1}#, #{endif}##{endfor}#]
- #:enddef revNum
- #:def genArrayNoParen(arr)
- #{for index, x in enumerate(arr)}#${x}$#{if index < (len(arr)-1)}#, #{endif}##{endfor}#
- #:enddef genArrayNoParen
- USE model
- USE reader
- implicit none
- #:mute
- #:include 'inputs.fpp'
- #:include 'variables.fpp'
- #:endmute
- #:for inp in inpShape
- REAL (c_double), DIMENSION(${genArrayNoParen(inpShape[inp])}$) :: ${inp}$
- #:endfor
- #:for o in outShape
- REAL (c_double), DIMENSION(${genArrayNoParen(o[1])}$) :: ${o[0]}$
- #:endfor
- #:for inp in arrs
- ${inp}$ = RESHAPE(${genArray(arrs[inp])}$,${genArray(inpShape[inp])}$, order = ${rev(inpShape[inp])}$)
- #:endfor
-
- CALL initialize()
- print *, "Model Reconstruction Success!"
- open(1, file = "test.txt")
- #:for inp in arrs
- ${inp}$ = RESHAPE(${genArray(arrs[inp])}$,${genArray(inpShape[inp])}$, order = ${rev(inpShape[inp])}$)
- #:endfor
- CALL use_model(#{for index,n in enumerate(inpShape)}#${n}$, #{endfor}##{for index,n in enumerate(outputs)}#${n}$#{if index < (len(outputs)-1)}#, #{endif}##{endfor}#)
- #:for x in outputs
- print *, ${x}$
- #:endfor
- #:for x in outShape
- #: set a = x[0]
- WRITE(1, *) SHAPE(${a}$)
- WRITE(1, *) PACK(RESHAPE(${x[0]}$,(/#{for num in range(len(x[1]),0,-1)}#SIZE(${x[0]}$, dim = ${num}$)#{if num > 1}#, #{endif}##{endfor}#/), order = [#{for x in range(len(x[1]),0,-1)}#${x}$#{if x > 1}#, #{endif}##{endfor}#]),.true.)
- #:endfor
-end program name
diff --git a/test/weights_format_tests.f90 b/test/weights_format_tests.f90
deleted file mode 100644
index 9de359e..0000000
--- a/test/weights_format_tests.f90
+++ /dev/null
@@ -1,88 +0,0 @@
-program weights_format_tests
- ! Weights-format tests on a one-layer Gemm model, one mode per process.
- ! write model, .bin, extra.bin, legacy.TXT, onnxWeights.txt
- ! bin explicit .bin path
- ! txt uppercase .TXT path with trailing blanks
- ! extra oversized .bin, must be rejected
- ! fallback no arguments, no .bin
- use iso_c_binding
- use reader
- implicit none
- character(len=16) :: mode
- real(c_double) :: v(8)
- integer :: k
- logical :: exists
-
- v = [(0.1d0*k - 0.35d0, k = 1, 8)]
- call get_command_argument(1, mode)
- select case (trim(mode))
- case ('write')
- call write_files()
- case ('bin')
- call initialize("onnxModel.txt"//c_null_char, "onnxWeights.bin"//c_null_char)
- call check_values('explicit .bin path loads the weights')
- case ('txt')
- call initialize("onnxModel.txt"//c_null_char, "legacy.TXT "//c_null_char)
- call check_values('uppercase .TXT path with trailing blanks loads the same weights as the .bin')
- case ('extra')
- call initialize("onnxModel.txt"//c_null_char, "extra.bin"//c_null_char)
- write(*,'(a)') ' FAIL a .bin with extra bytes was accepted'
- case ('fallback')
- inquire(file='onnxWeights.bin', exist=exists)
- if (exists) error stop 'fallback mode needs onnxWeights.bin to be absent'
- call initialize()
- call check_values('no arguments and no onnxWeights.bin falls back to onnxWeights.txt')
- case default
- error stop 'unknown mode'
- end select
-
-contains
-
- subroutine write_files()
- integer :: u
- open(newunit=u, file='onnxModel.txt', status='replace', action='write')
- write(u,'(a)') '1'
- write(u,'(a)') 'Gemm'
- write(u,'(a)') '2 3'
- write(u,'(a)') '2'
- close(u)
- open(newunit=u, file='onnxWeights.bin', status='replace', action='write', &
- access='stream', form='unformatted')
- write(u) v
- close(u)
- open(newunit=u, file='extra.bin', status='replace', action='write', &
- access='stream', form='unformatted')
- write(u) v
- write(u) 1.0d0
- close(u)
- call write_text('legacy.TXT')
- call write_text('onnxWeights.txt')
- end subroutine
-
- subroutine write_text(path)
- ! one value per line is valid legacy text for any model
- character(*), intent(in) :: path
- integer :: u
- open(newunit=u, file=path, status='replace', action='write')
- do k = 1, size(v)
- write(u,'(es26.17e3)') v(k)
- end do
- close(u)
- end subroutine
-
- subroutine check_values(name)
- character(*), intent(in) :: name
- logical :: ok
- ok = size(linLayers) == 1
- if (ok) ok = all(shape(linLayers(1)%weights) == [2, 3]) .and. size(linLayers(1)%biases) == 2
- if (ok) ok = all(linLayers(1)%weights == reshape(v(1:6), [2, 3])) .and. &
- all(linLayers(1)%biases == v(7:8))
- if (ok) then
- write(*,'(a,a)') ' ok ', name
- else
- write(*,'(a,a)') ' FAIL ', name
- error stop 2
- end if
- end subroutine
-
-end program
diff --git a/tools/measure_embed_threshold.py b/tools/measure_embed_threshold.py
new file mode 100644
index 0000000..e46f166
--- /dev/null
+++ b/tools/measure_embed_threshold.py
@@ -0,0 +1,88 @@
+"""Measure gcc -O2 compile time for a header embedding N weight scalars.
+
+Not a test: run manually --
+
+ cd python && python3 tests/measure_embed_threshold.py
+
+-- to reproduce the table cited in the comment next to EMBED_THRESHOLD in
+rosenna/plan.py (and pasted into the Task 2 report). Builds a single-Gemm
+ONNX model sized so its embedded weight count is close to each of
+1e3/1e4/1e5/3e5/1e6 parameters, emits it with `embed=True`, and times
+`gcc -O2 -c` compiling an otherwise-empty translation unit that only
+`#include`s the generated header (the header, not the source, holds the
+embedded ROSENNA_CONST arrays -- the source is nearly empty for an
+embedded plan).
+"""
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+import numpy as np
+import onnx
+from onnx import helper, numpy_helper, TensorProto
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from rosenna.emit_c import emit_c
+from rosenna.frontend import load_graph
+from rosenna.plan import build_plan
+
+SIZES = [1_000, 10_000, 100_000, 300_000, 1_000_000]
+
+
+def _cc() -> str:
+ for cand in ("gcc-15", "gcc-14", "gcc-13", "gcc"):
+ path = shutil.which(cand)
+ if path:
+ probe = subprocess.run([cand, "-fopenmp", "-x", "c", "-", "-o", "/dev/null"],
+ input="int main(void){return 0;}", capture_output=True, text=True)
+ if probe.returncode == 0:
+ return cand
+ raise SystemExit("no C compiler with -fopenmp found")
+
+
+def _make_model(path: Path, target_params: int) -> None:
+ """A single Gemm layer whose weight+bias element count is close to target_params."""
+ n = max(2, round((target_params) ** 0.5))
+ rng = np.random.default_rng(0)
+ w = numpy_helper.from_array(rng.uniform(-1, 1, (n, n)).astype(np.float32), "w")
+ b = numpy_helper.from_array(rng.uniform(-1, 1, (n,)).astype(np.float32), "b")
+ node = helper.make_node("Gemm", ["x", "w", "b"], ["y"], name="g0")
+ x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, n])
+ y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, n])
+ g = helper.make_graph([node], "measuretmp", [x], [y], initializer=[w, b])
+ m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 13)])
+ onnx.save(m, path)
+
+
+def main() -> None:
+ cc = _cc()
+ rows = []
+ with tempfile.TemporaryDirectory() as td:
+ td = Path(td)
+ for target in SIZES:
+ onnx_path = td / f"m{target}.onnx"
+ _make_model(onnx_path, target)
+ graph = load_graph(onnx_path)
+ plan = build_plan(graph, dtype="f64", embed=True)
+ _, header = emit_c(plan)
+ (td / f"m{target}.h").write_text(header)
+ tu = td / f"m{target}.c"
+ tu.write_text(f'#include "m{target}.h"\n')
+ start = time.perf_counter()
+ subprocess.run([cc, "-O2", "-c", str(tu), "-o", str(td / f"m{target}.o")],
+ check=True, capture_output=True, text=True)
+ elapsed = time.perf_counter() - start
+ rows.append((plan.n_params, elapsed))
+
+ print(f"compiler: {cc}")
+ print(f"{'params':>10} {'compile s':>10}")
+ for n_params, elapsed in rows:
+ print(f"{n_params:>10} {elapsed:>10.3f}")
+
+
+if __name__ == "__main__":
+ main()