diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 7a5604d53..6cfa4a2f2 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -3,24 +3,19 @@ name: Documentation
on:
push:
branches: [main]
- paths:
- - ".github/workflows/docs.yml"
- - "README.md"
- - "docs/**"
- - "docs_theme/**"
- - "mkdocs.yml"
- - "pyproject.toml"
- - "tests/shared/docs/**"
- - "tools/mkdocs_publication.py"
pull_request:
paths:
- ".github/workflows/docs.yml"
- "README.md"
+ - "benchmarks/**"
- "docs/**"
- "docs_theme/**"
- "mkdocs.yml"
- "pyproject.toml"
+ - "tests/shared/architecture/test_test_suite_layout.py"
- "tests/shared/docs/**"
+ - "tests/shared/tools/test_generate_performance_docs.py"
+ - "tools/generate_performance_docs.py"
- "tools/mkdocs_publication.py"
workflow_dispatch:
@@ -31,10 +26,86 @@ concurrency:
group: pages
cancel-in-progress: true
+env:
+ X2PY_GFORTRAN_BINARY: gfortran-13
+ X2PY_GFORTRAN_PACKAGE: gfortran-13
+
jobs:
+ benchmark:
+ name: Benchmark
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
+ runs-on: ${{ vars.X2PY_BENCHMARK_RUNNER || 'ubuntu-24.04' }}
+ timeout-minutes: 90
+ permissions:
+ contents: read
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install benchmark dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[docs]" \
+ "numpy==2.5.1" \
+ "meson==1.11.2" \
+ "ninja==1.13.0"
+
+ - name: Install pinned GFortran
+ shell: bash
+ run: |
+ if ! command -v "$X2PY_GFORTRAN_BINARY" >/dev/null 2>&1; then
+ sudo apt-get update
+ sudo apt-get install --yes "$X2PY_GFORTRAN_PACKAGE"
+ fi
+ compiler_dir="$RUNNER_TEMP/x2py-gfortran"
+ mkdir -p "$compiler_dir"
+ ln -sf "$(command -v "$X2PY_GFORTRAN_BINARY")" "$compiler_dir/gfortran"
+ echo "$compiler_dir" >> "$GITHUB_PATH"
+ "$compiler_dir/gfortran" --version
+
+ - name: Run correctness and rigorous performance suite
+ shell: bash
+ run: |
+ if (( GITHUB_RUN_NUMBER % 2 == 0 )); then
+ export X2PY_BENCHMARK_FIRST=f2py
+ else
+ export X2PY_BENCHMARK_FIRST=x2py
+ fi
+ bash benchmarks/run.sh
+
+ - name: Generate public Performance snapshot
+ run: |
+ python tools/generate_performance_docs.py \
+ --commit "$GITHUB_SHA"
+
+ - name: Report measurement stability
+ run: python -m pyperf check benchmarks/results/f2py.json benchmarks/results/x2py.json
+
+ - name: Upload Performance snapshot and raw results
+ uses: actions/upload-artifact@v4
+ with:
+ name: performance-snapshot
+ path: |
+ benchmarks/results/f2py.json
+ benchmarks/results/x2py.json
+ docs/user/performance.md
+ docs/user/assets/performance-comparison.svg
+ retention-days: 90
+
build:
name: Build
- runs-on: ubuntu-latest
+ needs: benchmark
+ if: always() && (needs.benchmark.result == 'success' || needs.benchmark.result == 'skipped')
+ runs-on: ubuntu-24.04
permissions:
contents: read
pages: write
@@ -52,6 +123,13 @@ jobs:
- name: Install documentation dependencies
run: python -m pip install -e ".[docs,qa]"
+ - name: Download generated Performance snapshot
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
+ uses: actions/download-artifact@v4
+ with:
+ name: performance-snapshot
+ path: .
+
- name: Run documentation tests
run: python -m pytest -q tests/shared/docs
diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore
new file mode 100644
index 000000000..268ef30c2
--- /dev/null
+++ b/benchmarks/.gitignore
@@ -0,0 +1,2 @@
+/build/f2py/
+/results/
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 000000000..0d58ebae5
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,62 @@
+# Binding Performance Benchmarks
+
+This suite compares the call and NumPy-array overhead of wrappers generated by
+x2py and NumPy's f2py on the same machine.
+
+The default x2py wrapper and the f2py wrappers measured here keep the GIL held,
+so the suite reports one like-for-like comparison of their normal generated
+interfaces.
+
+`X2PY_BENCHMARK_FIRST=x2py` is the default measurement order. Set it to
+`f2py` to reverse the order. The publication workflow alternates this setting
+between runs so one tool is not systematically measured first.
+
+Run the complete correctness check and rigorous benchmark with:
+
+```bash
+bash run.sh
+```
+
+The script rebuilds both extensions and applies
+`-O3 -march=native -mtune=native` to the native Fortran source, generated
+Fortran wrapper, and generated C binding. It retains f2py's generated sources
+under `build/f2py` for local inspection.
+
+To compare existing results without rebuilding:
+
+```bash
+python3 -m pyperf compare_to \
+ results/f2py.json \
+ results/x2py.json \
+ --table
+```
+
+Results are machine-specific. Compare files produced in the same run; CPU,
+compiler, Python, and NumPy differences can otherwise dominate small timings.
+The generated build directories, extensions, and result files are local
+artifacts rather than repository sources.
+
+## Publish a Documentation Snapshot
+
+After a completed run, refresh the generated sections of the public Performance
+page and its chart with:
+
+```bash
+python3 tools/generate_performance_docs.py
+```
+
+Run this command from the repository root. It reads the paired `pyperf` files,
+checks that they contain the same benchmarks and compatible platform metadata,
+records the host operating-system distribution and compiler, and updates only
+the marked result sections in `docs/user/performance.md` plus
+`docs/user/assets/performance-comparison.svg`. Explanatory prose and the
+reproduction instructions remain hand-maintained.
+
+The Documentation workflow performs the same generation after successful
+correctness checks and rigorous measurements on pushes to `main`. It keeps the
+raw `pyperf` files as a workflow artifact and overlays the generated snapshot
+only in the website build; it does not create a result commit.
+
+The publication environment pins Python 3.12, NumPy/f2py 2.5.1, pyperf 2.10.0,
+Meson 1.11.2, Ninja 1.13.0, and GNU Fortran 13. Update those versions through a
+reviewed change so published runs remain comparable.
diff --git a/benchmarks/build/f2py.sh b/benchmarks/build/f2py.sh
new file mode 100644
index 000000000..9228b553b
--- /dev/null
+++ b/benchmarks/build/f2py.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+benchmark_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$benchmark_dir"
+
+rm -rf bench_f2py.*.so build/f2py
+mkdir -p build/f2py
+
+CFLAGS="-O3 -march=native -mtune=native" \
+FFLAGS="-O3 -march=native -mtune=native" \
+F90FLAGS="-O3 -march=native -mtune=native" \
+python3 -m numpy.f2py \
+ -c \
+ -m bench_f2py \
+ sources/kernels.f90 \
+ --build-dir build/f2py \
+ --f90flags="-O3 -march=native -mtune=native" \
+ --opt="-O3 -march=native -mtune=native"
diff --git a/benchmarks/build/x2py.sh b/benchmarks/build/x2py.sh
new file mode 100644
index 000000000..123e8b07d
--- /dev/null
+++ b/benchmarks/build/x2py.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+benchmark_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$benchmark_dir"
+
+rm -rf bench_x2py.*.so __x2py__
+python3 -m x2py \
+ sources/kernels.f90 \
+ --out bench_x2py \
+ --native-compile-flags="-O3 -march=native -mtune=native" \
+ --wrapper-fortran-flags="-O3 -march=native -mtune=native" \
+ --wrapper-c-flags="-O3 -march=native -mtune=native" \
+ --verbose
diff --git a/benchmarks/correctness.py b/benchmarks/correctness.py
new file mode 100644
index 000000000..954253747
--- /dev/null
+++ b/benchmarks/correctness.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import importlib
+from typing import Any
+
+import numpy as np
+
+
+def load_api(module_name: str, nested_module: str | None = None) -> Any:
+ module = importlib.import_module(module_name)
+ return getattr(module, nested_module) if nested_module else module
+
+
+x2py = load_api("bench_x2py", "kernels")
+f2py = load_api("bench_f2py", "kernels")
+
+
+def check_implementation(api: Any) -> None:
+ assert api.add_scalars(np.float64(2.0), np.float64(3.0)) == np.float64(5.0)
+
+ vector = np.arange(32, dtype=np.float64)
+ expected = vector + 1.0
+ api.increment_vector(vector)
+ np.testing.assert_allclose(vector, expected)
+
+ matrix = np.asfortranarray(np.arange(128, dtype=np.float64).reshape((16, 8), order="F"))
+ expected_sum = np.sum(matrix)
+ actual_sum = api.sum_matrix(matrix)
+ np.testing.assert_allclose(actual_sum, expected_sum)
+
+ api.matrix_update(matrix, np.float64(2.0))
+ np.testing.assert_allclose(
+ matrix,
+ np.arange(128, dtype=np.float64).reshape((16, 8), order="F") + 2.0,
+ )
+
+
+check_implementation(x2py)
+check_implementation(f2py)
+
+print("All implementations passed correctness checks.")
diff --git a/benchmarks/run.sh b/benchmarks/run.sh
new file mode 100644
index 000000000..669f9a4e5
--- /dev/null
+++ b/benchmarks/run.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+benchmark_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+cd "$benchmark_dir"
+
+benchmark_first="${X2PY_BENCHMARK_FIRST:-x2py}"
+case "$benchmark_first" in
+ x2py)
+ binding_tools=(x2py f2py)
+ ;;
+ f2py)
+ binding_tools=(f2py x2py)
+ ;;
+ *)
+ echo "X2PY_BENCHMARK_FIRST must be 'x2py' or 'f2py'." >&2
+ exit 2
+ ;;
+esac
+
+rm -rf *.so __x2py__ results
+
+echo
+echo "========================================"
+echo " Building X2PY wrapper"
+echo "========================================"
+bash build/x2py.sh
+
+echo
+echo "========================================"
+echo " Building F2PY wrapper"
+echo "========================================"
+bash build/f2py.sh
+echo "========================================"
+echo "========================================"
+echo "========================================"
+
+echo
+echo "Check correctness of all shared libraries..."
+python3 correctness.py
+echo
+echo "========================================"
+echo "========================================"
+echo "========================================"
+
+mkdir -p results
+
+echo "Benchmark order: ${binding_tools[*]}"
+for binding_tool in "${binding_tools[@]}"; do
+ BINDING_TOOL="$binding_tool" \
+ OMP_NUM_THREADS=1 \
+ OPENBLAS_NUM_THREADS=1 \
+ MKL_NUM_THREADS=1 \
+ python3 runtime.py \
+ --rigorous \
+ --affinity=0 \
+ --inherit-environ=BINDING_TOOL,OMP_NUM_THREADS,OPENBLAS_NUM_THREADS,MKL_NUM_THREADS \
+ -o "results/$binding_tool.json"
+done
+
+python3 -m pyperf compare_to \
+ results/f2py.json \
+ results/x2py.json \
+ --table
diff --git a/benchmarks/runtime.py b/benchmarks/runtime.py
new file mode 100644
index 000000000..f5a2d6970
--- /dev/null
+++ b/benchmarks/runtime.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import importlib
+import os
+import platform
+import sys
+from collections.abc import Callable
+from typing import Any
+
+import numpy as np
+import pyperf
+
+
+def get_function(api: Any, name: str) -> Callable[..., Any]:
+ function = getattr(api, name)
+
+ if not callable(function):
+ raise TypeError(f"{name!r} is not callable")
+
+ return function
+
+
+tool = os.environ.get("BINDING_TOOL")
+
+if tool == "x2py":
+ extension = importlib.import_module("bench_x2py")
+elif tool == "f2py":
+ extension = importlib.import_module("bench_f2py")
+else:
+ raise RuntimeError("Set BINDING_TOOL to 'x2py' or 'f2py'.")
+
+api = extension.kernels
+noop = get_function(api, "noop")
+add_scalars = get_function(api, "add_scalars")
+increment_vector = get_function(api, "increment_vector")
+sum_matrix = get_function(api, "sum_matrix")
+matrix_update = get_function(api, "matrix_update")
+
+
+runner = pyperf.Runner(
+ metadata={
+ "binding_tool": tool,
+ "python_version": sys.version,
+ "numpy_version": np.__version__,
+ "platform_details": platform.platform(),
+ }
+)
+
+# Duplicate extremely small statements to reduce timing-loop overhead.
+runner.timeit(
+ "call.noop",
+ stmt="fn()",
+ globals={"fn": noop},
+ duplicate=100,
+)
+
+runner.timeit(
+ "call.add_scalars",
+ stmt="fn(np.float64(1.25), np.float64(2.75))",
+ globals={"fn": add_scalars, "np": np},
+ duplicate=50,
+)
+
+for size in (1, 16, 1024, 1000000):
+ vector = np.zeros(size, dtype=np.float64)
+
+ runner.timeit(
+ f"array.increment_vector.n={size}",
+ stmt="fn(a)",
+ globals={
+ "fn": increment_vector,
+ "a": vector,
+ },
+ )
+
+for shape in ((4, 4), (32, 32), (256, 256), (1024, 1024)):
+ rows, columns = shape
+
+ for order in ("F",):
+ matrix = np.ones(shape, dtype=np.float64, order=order)
+
+ runner.timeit(
+ f"matrix.sum.{rows}x{columns}.order={order}",
+ stmt="fn(a)",
+ globals={
+ "fn": sum_matrix,
+ "a": matrix,
+ },
+ )
+
+# Native in-place Fortran-contiguous path.
+for shape in ((4, 4), (256, 256), (1024, 1024)):
+ matrix = np.zeros(shape, dtype=np.float64, order="F")
+ rows, columns = shape
+
+ runner.timeit(
+ f"matrix.update.{rows}x{columns}.order=F",
+ stmt="fn(a, np.float64(1.0))",
+ globals={
+ "fn": matrix_update,
+ "a": matrix,
+ "np": np,
+ },
+ )
diff --git a/benchmarks/sources/kernels.f90 b/benchmarks/sources/kernels.f90
new file mode 100644
index 000000000..8222cd0b4
--- /dev/null
+++ b/benchmarks/sources/kernels.f90
@@ -0,0 +1,51 @@
+module kernels
+ implicit none
+
+contains
+
+ subroutine noop()
+ end subroutine noop
+
+ function add_scalars(a, b) result(c)
+ double precision, intent(in) :: a, b
+ double precision :: c
+
+ c = a + b
+ end function add_scalars
+
+ subroutine increment_vector(x)
+ double precision, intent(inout), contiguous :: x(:)
+ integer :: i
+
+ do i = 1, size(x)
+ x(i) = x(i) + 1.0d0
+ end do
+ end subroutine increment_vector
+
+ function sum_matrix(a) result(total)
+ double precision, intent(in) :: a(:, :)
+ double precision :: total
+ integer :: i, j
+
+ total = 0.0d0
+
+ do j = 1, size(a, 2)
+ do i = 1, size(a, 1)
+ total = total + a(i, j)
+ end do
+ end do
+ end function sum_matrix
+
+ subroutine matrix_update(a, value)
+ double precision, intent(inout), contiguous :: a(:, :)
+ double precision, intent(in) :: value
+ integer :: i, j
+
+ do j = 1, size(a, 2)
+ do i = 1, size(a, 1)
+ a(i, j) = a(i, j) + value
+ end do
+ end do
+ end subroutine matrix_update
+
+end module kernels
diff --git a/docs/developer/feature-to-code-map.md b/docs/developer/feature-to-code-map.md
index de99cb741..a9393a778 100644
--- a/docs/developer/feature-to-code-map.md
+++ b/docs/developer/feature-to-code-map.md
@@ -57,7 +57,7 @@ this routing page tied to the source hotspots and package README files.
| --- | --- | --- |
| Wrapping functions and subroutines | `x2py/semantics/fortran2ir.py`, policy completion, `x2py/wrapper_codegen/planner.py`, bridge and binding generators | Runtime tests compile, import, call, and verify return and failure behavior |
| Wrapping modules and module variables | parser module facts, semantic module conversion, naming policy, wrapper generators | Python-visible names, accessors, and unsupported module constructs are tested |
-| Arrays and allocatables | semantic array contracts, ownership policy, typed wrapper plans, bridge/binding array handlers | dtype, shape, rank, contiguity, mutation, returned arrays, and failure paths are tested |
+| Arrays and allocatables | semantic array contracts, ownership policy, typed wrapper plans, bridge/binding array handlers | dtype, shape, rank, contiguity, mutation, returned arrays, and failure paths are tested; ordinary NumPy array actuals validate and extract their buffer directly in the C binding, descriptor handles use the planned runtime-handle path, and strided contracts carry a dense-actual role for zero-copy fast-path selection |
| Pointer arguments | semantic metadata, ownership policy, bridge/binding pointer handlers | Owner, lifetime, association, and blocked cases are explicit and tested |
| Optional arguments | parser optional attributes, semantic arguments, binding argument parsing | Present/absent calls and unsupported combinations are tested |
| Generic interfaces | parser interface facts, semantic overload sets, `FunctionOverloadSet`, binding dispatch | Overload selection and ambiguity failures are tested at runtime |
diff --git a/docs/developer/repository-structure.md b/docs/developer/repository-structure.md
index ea2353574..6a6e946d1 100644
--- a/docs/developer/repository-structure.md
+++ b/docs/developer/repository-structure.md
@@ -29,6 +29,8 @@ artifacts used by tests. Navigate by ownership boundary first, then by file.
| `x2py/binding_support/` | Bundled header-only native support copied into generated wrapper builds. |
| `x2py/naming/` | Unified public-name and generated-symbol policy. |
| `x2py/utilities/` | Small shared Python utilities. |
+| `benchmarks/` | Local x2py/f2py correctness and performance comparison harness. Benchmark sources and scripts are maintained; native builds and result files are generated locally. |
+| `tools/generate_performance_docs.py` | Validates paired `pyperf` results and generates the bounded public Performance snapshot and chart. |
The major source packages have local README files under `x2py/` for
developers reading directly in the source tree. Those README files should link
@@ -85,6 +87,10 @@ Source navigation is considered maintained when these files agree:
- `__x2py__/` directories are wrapper build artifacts and should not be
hand-edited as source.
+- `benchmarks/build/f2py/` and `benchmarks/results/` contain generated
+ comparison artifacts and are not repository sources. CI retains paired
+ result files as workflow artifacts and generates the website snapshot from
+ them without committing the raw files.
- Parser and `.pyi` fixture files should be regenerated with the documented
fixture commands instead of edited loosely.
- `x2py.egg-info/`, caches, and benchmark output are generated local artifacts,
diff --git a/docs/index.md b/docs/index.md
index 213ebd4c6..44e049b92 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -3,7 +3,7 @@ title: x2py
description: Turn Fortran functions, modules, arrays, and derived types into natural Python APIs
audience: users
prerequisites: none
-related: user/getting-started/index.md, user/getting-started/installation.md
+related: user/getting-started/index.md, user/getting-started/installation.md, user/performance.md
status: maintained
publication: reviewed
---
@@ -99,6 +99,8 @@ No manual binding code or low-level boilerplate.
- Immediate Python callbacks and overloaded interfaces
- Editable `.pyi` contracts and readable generated docstrings
- Early, clear errors when a boundary cannot be wrapped
+- Low wrapper overhead measured against NumPy's
+ [f2py](user/performance.md) in a reproducible benchmark suite
---
diff --git a/docs/maintainer/ci-cd.md b/docs/maintainer/ci-cd.md
index d2b2be207..d3bc49905 100644
--- a/docs/maintainer/ci-cd.md
+++ b/docs/maintainer/ci-cd.md
@@ -33,6 +33,21 @@ strict production build without deploying. A push to `main` runs those checks
and deploys the reviewed site when GitHub Pages is configured to use GitHub
Actions.
+Every push to `main` first runs the x2py/f2py correctness and rigorous
+performance suite. The job extracts its platform and toolchain metadata,
+generates the result-dependent Performance page sections and SVG, and uploads
+the generated documentation together with the raw `pyperf` files. The website
+build overlays that artifact before testing and building MkDocs. Generated
+results are deployment inputs, not automated commits to `main`.
+
+Set the repository variable `X2PY_BENCHMARK_RUNNER` to the label of a dedicated
+Linux x86-64 runner for stable published measurements. Without that variable,
+the workflow uses the pinned Ubuntu 24.04 GitHub-hosted image and publishes its
+recorded platform details. Pull requests verify the generator against fixtures
+but do not replace the public snapshot. The workflow pins the benchmark
+toolchain and alternates whether x2py or f2py is measured first to avoid a
+systematic ordering advantage.
+
Enable the repository once through **Settings > Pages > Build and deployment >
Source > GitHub Actions**. Then open **Actions > Documentation > Run workflow**,
select `main`, and run it. Later documentation changes deploy automatically
diff --git a/docs/maintainer/documentation-architecture.md b/docs/maintainer/documentation-architecture.md
index c61614911..c44a3fd92 100644
--- a/docs/maintainer/documentation-architecture.md
+++ b/docs/maintainer/documentation-architecture.md
@@ -55,7 +55,7 @@ instead of competing with that first task.
| Lane | Primary reader | Publication | Content |
| --- | --- | --- | --- |
-| `user/` | People using x2py | Documentation website after review | Getting Started, guides, tutorials, examples, public reference, support status, FAQ, troubleshooting, changelog |
+| `user/` | People using x2py | Documentation website after review | Getting Started, guides, performance benchmarks, tutorials, examples, public reference, support status, FAQ, troubleshooting, changelog |
| `developer/` | People changing x2py | Documentation website after review | Source orientation, implementation maps, testing, coding standards, feature work, contribution workflow |
| `maintainer/` | People governing x2py | Documentation website after review | Documentation policy, design decisions, internal architecture, CI administration, releases, roadmaps |
@@ -153,6 +153,7 @@ docs/
index.md
user/
index.md
+ performance.md
getting-started/
guide/
tutorials/
@@ -187,6 +188,15 @@ Do not restore top-level topic directories or place maintainer rules beside the
website landing page. Historical `old_docs/` material is never eligible for
website publication.
+The Performance page keeps its explanatory text and reproduction workflow in
+reviewed Markdown. Result-dependent summary, table, and environment blocks are
+bounded by `x2py-performance-*` comments and are generated from paired `pyperf`
+files by `tools/generate_performance_docs.py`. The same tool owns the
+performance SVG. Generation must fail when a marker is missing or duplicated;
+it must not rewrite prose outside those blocks. The environment block records
+both the operating-system distribution and the lower-level platform string so
+published results identify the benchmark host clearly.
+
## Continuous Documentation Quality
- Require metadata for every active page.
diff --git a/docs/maintainer/internal-architecture/pipeline-map.md b/docs/maintainer/internal-architecture/pipeline-map.md
index 5de5ff2d7..40ff2f94d 100644
--- a/docs/maintainer/internal-architecture/pipeline-map.md
+++ b/docs/maintainer/internal-architecture/pipeline-map.md
@@ -145,7 +145,7 @@ Examples:
datatypes stay out of `x2py/semantics/models.py`.
diff --git a/docs/stylesheets/site.css b/docs/stylesheets/site.css
index e8c3e62d2..a6857d2d4 100644
--- a/docs/stylesheets/site.css
+++ b/docs/stylesheets/site.css
@@ -150,12 +150,63 @@
outline-offset: 2px;
}
+.x2py-performance-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1rem;
+ max-width: 56rem;
+ margin: 1.5rem 0;
+}
+
+.x2py-performance-metric {
+ display: flex;
+ min-height: 7rem;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ border: 1px solid #cfdde5;
+ border-radius: 0.6rem;
+ background: linear-gradient(145deg, #f5fbfa, #eef6f8);
+ box-shadow: 0 2px 7px rgb(23 107 100 / 12%);
+ text-align: center;
+}
+
+.x2py-performance-metric strong {
+ color: #176b64;
+ font-size: 2rem;
+ line-height: 1.1;
+}
+
+.x2py-performance-metric span {
+ margin-top: 0.4rem;
+ color: #43515d;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.x2py-performance-chart {
+ max-width: 56rem;
+ overflow-x: auto;
+ padding-bottom: 0.25rem;
+}
+
+.x2py-performance-chart img {
+ display: block;
+ min-width: 48rem;
+ margin: 0;
+}
+
@media screen and (max-width: 768px) {
.wy-breadcrumbs-aside {
display: block;
float: none;
margin-top: 0.75rem;
}
+
+ .x2py-performance-summary {
+ grid-template-columns: 1fr;
+ }
}
.rst-content pre {
diff --git a/docs/user/assets/performance-comparison.svg b/docs/user/assets/performance-comparison.svg
new file mode 100644
index 000000000..94eaa8210
--- /dev/null
+++ b/docs/user/assets/performance-comparison.svg
@@ -0,0 +1,90 @@
+
diff --git a/docs/user/guide/arrays.md b/docs/user/guide/arrays.md
index 29bab497e..9d2cc3c3c 100644
--- a/docs/user/guide/arrays.md
+++ b/docs/user/guide/arrays.md
@@ -386,7 +386,8 @@ This checks the final Python axis and flattens the leading axes.
## Strided Views
-Use `::` for an assumed-shape axis that supports positive strides:
+Use `::` for an assumed-shape axis that accepts F-contiguous arrays and
+positive-stride views without copying:
```python
from x2py.contracts import Float64, Returns
diff --git a/docs/user/index.md b/docs/user/index.md
index e815cc055..b31a27ca1 100644
--- a/docs/user/index.md
+++ b/docs/user/index.md
@@ -2,7 +2,7 @@
title: User Documentation
audience: users
prerequisites: none
-related: getting-started/index.md, guide/index.md
+related: getting-started/index.md, guide/index.md, performance.md
status: maintained
publication: reviewed
---
@@ -17,8 +17,10 @@ extensions.
1. [Getting Started](getting-started/index.md)
2. [User Guide](guide/index.md)
+3. [Performance](performance.md)
Getting Started covers installation, environment verification, the first
standalone wrapper, the first module wrapper, and the beginner edit-build-test
loop. The User Guide covers supported Fortran wrapper features, runtime
-behavior, packaging, and distribution.
+behavior, packaging, and distribution. Performance presents the reproducible
+x2py and f2py comparison.
diff --git a/docs/user/performance.md b/docs/user/performance.md
new file mode 100644
index 000000000..d736e8808
--- /dev/null
+++ b/docs/user/performance.md
@@ -0,0 +1,107 @@
+---
+title: Performance
+description: Reproducible x2py and f2py binding-performance results
+audience: users
+prerequisites: x2py repository checkout
+related: getting-started/index.md, guide/index.md
+status: maintained
+publication: reviewed
+---
+
+# Performance
+
+**Low-overhead Python calls for real Fortran workloads.**
+
+
+On the benchmark system, the normal x2py interface delivered a **1.06× geometric-mean
+speedup over NumPy's f2py**. Across 13 workloads, x2py was faster in 8 and f2py in 2; 3
+workloads showed no statistically significant difference.
+
+
+
+
+
+{ .x2py-performance-chart }
+
+The chart shows `f2py time ÷ x2py time`. Values to the right of `1.0×` favor
+x2py; values to the left favor f2py. Results close to `1.0×` are practical
+parity and may move slightly between machines or runs.
+
+## Detailed Results
+
+Lower times are better. Every row measures the same Fortran operation through
+the normal generated interface of each tool.
+
+
+| Workload | f2py | x2py | Relative result |
+| --- | ---: | ---: | ---: |
+| Empty function call | 44.5 ns | **41.7 ns** | x2py 1.07× faster |
+| Add two scalars | **416 ns** | 434 ns | f2py 1.04× faster |
+| Increment vector, 1 element | 129 ns | **93.8 ns** | x2py 1.38× faster |
+| Increment vector, 16 elements | 142 ns | **106 ns** | x2py 1.33× faster |
+| Increment vector, 1,024 elements | **276 ns** | 297 ns | f2py 1.08× faster |
+| Increment vector, 1,000,000 elements | 981 µs | 973 µs | No significant difference |
+| Sum 4×4 F-order matrix | 165 ns | **148 ns** | x2py 1.12× faster |
+| Sum 32×32 F-order matrix | 1.13 µs | **1.12 µs** | x2py 1.02× faster |
+| Sum 256×256 F-order matrix | 63.5 µs | **63.3 µs** | x2py 1.003× faster |
+| Sum 1,024×1,024 F-order matrix | 1.10 ms | 1.10 ms | No significant difference |
+| Update 4×4 F-order matrix | 342 ns | **331 ns** | x2py 1.03× faster |
+| Update 256×256 F-order matrix | 25.7 µs | **25.6 µs** | x2py 1.006× faster |
+| Update 1,024×1,024 F-order matrix | 1.14 ms | 1.14 ms | No significant difference |
+| **Geometric mean** | reference | — | **x2py 1.06× faster** |
+
+
+The smallest workloads expose wrapper overhead most clearly. As more time is
+spent inside Fortran, both tools approach the cost of the native operation and
+small differences matter less.
+
+## Fair, Like-for-Like Setup
+
+The suite wraps one set of Fortran kernels with the default x2py and f2py
+interfaces. It checks both extensions for the same results before measuring
+them. No benchmark-only wrapper mode is used.
+
+
+- Native and generated sources use `-O3 -march=native -mtune=native`.
+- Both interfaces keep the GIL held.
+- OpenMP, OpenBLAS, and MKL are limited to one thread.
+- `pyperf --rigorous` pins each benchmark to logical CPU `0`.
+- CPU: Intel(R) Core(TM) i7-4712MQ CPU @ 2.30GHz.
+- Operating system: Ubuntu 26.04 LTS.
+- Kernel/platform: `Linux-7.0.0-28-generic-x86_64-with-glibc2.43`.
+- Python: 3.14.4.
+- NumPy/f2py: 2.5.1.
+- Fortran compiler: GNU Fortran 15.2.0.
+- pyperf: 2.10.0.
+- x2py revision: `03d228acc6a8`.
+
+These results were recorded on August 1, 2026. Performance depends on the CPU,
+compiler, operating system, and background activity, so comparisons should use
+results produced together on the same machine.
+
+
+## Reproduce the Results
+
+The complete [benchmark suite](../../benchmarks/README.md) is included in the
+repository. From the repository root, reproduce the build, correctness checks,
+measurements, and comparison with one command:
+
+```bash
+bash benchmarks/run.sh
+```
+
+The command writes both `pyperf` result files under `benchmarks/results/` and
+prints the full comparison table.
diff --git a/docs/user/reference/fortran-wrapper.md b/docs/user/reference/fortran-wrapper.md
index db2de179c..53872d169 100644
--- a/docs/user/reference/fortran-wrapper.md
+++ b/docs/user/reference/fortran-wrapper.md
@@ -518,6 +518,10 @@ A wrapper feature is considered supported only when all applicable layers agree:
- the default wrapper build emits a precise error when a declaration is
unsupported or lacks policy;
- semantic lowering preserves the contract without reconstructing source text;
+- the source formatter wraps generated free-form Fortran at syntax-safe token
+ boundaries, including character-literal continuations that preserve their
+ exact value, so every bridge line stays within the standard 132-column limit;
+ generation fails before compilation when no safe continuation point exists;
- runtime behavior is covered by the project verification policy before it is
presented as supported; and
- fixed-form and free-form behavior are both considered when the source feature
@@ -2146,31 +2150,37 @@ process abort, or a callback failure crossing a native callback boundary.
### GIL Policy
Module-variable and class-property accessors, constructors, destructors, and
-callback-taking calls keep the GIL automatically. An edited `.pyi` can keep it
-for another procedure:
+other generated procedures keep the GIL automatically. An edited `.pyi` can
+explicitly release it around one native procedure call:
```python
-from x2py.contracts import Int32, hold_gil
+from x2py.contracts import Int32, nogil
-@hold_gil
-def update_shared_state(value: Int32) -> None: ...
+@nogil
+def update_disjoint_state(value: Int32) -> None: ...
```
-`@hold_gil` accepts no arguments. It serializes against ordinary Python threads
-in the same interpreter; it is not a lock against native threads, OpenMP
-workers, external libraries, or another interpreter.
+`@nogil` accepts no arguments. It releases the GIL only around the native
+bridge call; conversion, writeback, cleanup, and exception projection keep it.
+Use it only when the native call is safe while other Python threads execute.
+For callback-taking calls, the callback trampoline reacquires the GIL during
+Python execution.
+
+Keeping the GIL serializes against ordinary Python threads in the same
+interpreter; it is not a lock against native threads, OpenMP workers, external
+libraries, or another interpreter.
### OpenMP
-OpenMP is an explicit build/runtime choice. A callback-free OpenMP procedure
-uses the normal GIL-release policy. For GNU Fortran, pass OpenMP flags to both
-compile and link steps:
+OpenMP is an explicit build/runtime choice. Add `@nogil` when a callback-free
+OpenMP procedure should run concurrently with Python threads. For GNU Fortran,
+pass OpenMP flags to both compile and link steps:
```bash
python3 -m x2py generate --makefile parallel_api.f90 --out-dir build
@@ -2187,8 +2197,8 @@ print(parallel_sum(values)) # 528.0
x2py does not infer host-memory synchronization. Callers must protect arrays,
module variables, object state, and aliases touched by concurrent Python calls,
OpenMP workers, or external native code. Use native locks, Python locks around
-the whole call, disjoint storage, or `@hold_gil` where its limited serialization
-scope is sufficient.
+the whole call, disjoint storage, or the default held-GIL policy where its
+limited serialization scope is sufficient.
The verified compiler path includes GNU Fortran and debug/optimized ABI builds.
Other compilers and platforms require their own ABI validation; support is not
diff --git a/docs/user/reference/pyi-contracts/calls-and-results.md b/docs/user/reference/pyi-contracts/calls-and-results.md
index f12f2ab37..abf3dc58f 100644
--- a/docs/user/reference/pyi-contracts/calls-and-results.md
+++ b/docs/user/reference/pyi-contracts/calls-and-results.md
@@ -136,22 +136,27 @@ non-success status raises the generated exception before an ordinary result is
returned. See [Error Handling](../../guide/error-handling.md#status-projection-example)
for the Python behavior.
-## Keep the GIL When Required
+## Release the GIL for a Native Call
-Ordinary native calls release Python's Global Interpreter Lock (GIL) when
-their contract allows it. Use `@hold_gil` when the native call must invoke
-Python immediately, such as a synchronous callback:
+Native calls keep Python's Global Interpreter Lock (GIL) by default. Use
+`@nogil` only when the native call can safely run while other Python threads
+execute:
```python
-from x2py.contracts import hold_gil
+from x2py.contracts import nogil
-@hold_gil
-def run_engine() -> None: ...
+@nogil
+def run_parallel_engine() -> None: ...
```
-Remove `@hold_gil` to return to the normal GIL-releasing behavior when the call
-is safe without it. This changes call behavior, not the native procedure
-interface. It does not describe a callback signature; callback contracts are
+`@nogil` accepts no arguments and releases the GIL only around the native
+bridge call. Argument conversion, result conversion, writeback, cleanup, and
+exception projection still run with the GIL held. Remove `@nogil` to restore
+the default held-GIL behavior. This changes call behavior, not the native
+procedure interface.
+
+If a decorated native call invokes an x2py callback, the callback trampoline
+temporarily reacquires the GIL for Python execution. Callback contracts are
covered in the [Callbacks](../../guide/callbacks.md) guide.
## Next
diff --git a/docs/user/reference/pyi-contracts/index.md b/docs/user/reference/pyi-contracts/index.md
index eac287eb8..5430f4792 100644
--- a/docs/user/reference/pyi-contracts/index.md
+++ b/docs/user/reference/pyi-contracts/index.md
@@ -71,7 +71,7 @@ to compare and undo.
- [How do I return a replacement instead of mutating the original Python value?](calls-and-results.md#control-mutation)
- [How do I pass checked storage or a raw memory address?](../../guide/raw-addresses.md#checked-storage-or-raw-address)
- [How do I turn a native status into a Python exception?](calls-and-results.md#translate-status-results-into-exceptions)
-- [How do I keep Python's Global Interpreter Lock (GIL) during a call, or return to the normal releasing behavior?](calls-and-results.md#keep-the-gil-when-required)
+- [How do I release Python's Global Interpreter Lock (GIL) for a native call?](calls-and-results.md#release-the-gil-for-a-native-call)
- [How do I describe a callback signature in the contract?](../../guide/callbacks.md#choosing-the-prototype-spelling)
Most edits change the Python surface: names, visibility, grouping, or how
diff --git a/docs/user/reference/semantic-pyi-format.md b/docs/user/reference/semantic-pyi-format.md
index 2d6f13396..ec4263fd7 100644
--- a/docs/user/reference/semantic-pyi-format.md
+++ b/docs/user/reference/semantic-pyi-format.md
@@ -2405,7 +2405,7 @@ ambiguous, unsafe, or stale before wrapper lowering:
- unsupported decorators other than `@private`, `@bind`, `@external`,
`@native_call`, `@native_type`,
`@overload("specific")`, the class-operator `generic=` form, `@raises`,
- `@hold_gil`, and `@staticmethod`.
+ `@nogil`, and `@staticmethod`.
- bare `@overload` or `typing.overload`; overload links require one concrete
procedure name.
- `@overload(...)` combined with `@native_call(...)`; the linked concrete
diff --git a/mkdocs.yml b/mkdocs.yml
index 6e25b6edb..ecd935490 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -58,6 +58,7 @@ nav:
- Raw Addresses: user/guide/raw-addresses.md
- Error Handling & Diagnostics: user/guide/error-handling.md
- Building the Shared Library: user/guide/building-shared-library.md
+ - Performance: user/performance.md
- Tutorials:
- Overview: user/tutorials/index.md
- Large Fortran Codebase: user/tutorials/large-fortran-codebase.md
diff --git a/pyproject.toml b/pyproject.toml
index 575d8750a..43c56f81f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ pretty = [
docs = [
"mkdocs==1.6.1",
"mkdocs-material==9.7.6",
+ "pyperf==2.10.0",
]
qa = [
"bandit[toml]==1.9.4",
@@ -35,6 +36,7 @@ qa = [
"hypothesis>=6.100",
"pytest>=8.0",
"pytest-randomly>=3.15",
+ "pyperf==2.10.0",
"radon[toml]==6.0.1",
"ruff==0.15.17",
"vulture==2.16",
diff --git a/tests/fortran/CONTRACT_COVERAGE.md b/tests/fortran/CONTRACT_COVERAGE.md
index 1b7cde5b2..35ef3f4f0 100644
--- a/tests/fortran/CONTRACT_COVERAGE.md
+++ b/tests/fortran/CONTRACT_COVERAGE.md
@@ -177,7 +177,7 @@ Authoritative sources:
| [Error Handling: Status Projection Example](../../docs/user/guide/error-handling.md#status-projection-example) | Supported | edited `@native_call` hidden status/message projection; `@raises`; success value; `None` on success; exact `RuntimeError` message; repeated failure cleanup and recovery | `tests/fortran/error_handling/semantics/test_status_contract_semantics.py::test_runtime_policy_decorators_round_trip_through_pyi`
`tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_direct_bridge_lowering_projects_status_and_copies_fixed_message` | `tests/fortran/error_handling/end_to_end/test_status_projection.py::test_status_projection_consumes_outputs_raises_message_and_recovers` | — | canonical |
| [Error Handling: Common Python Exceptions](../../docs/user/guide/error-handling.md#common-python-exceptions) | Supported | boundary `TypeError`; contract/option and parse `ValueError`; projected native `RuntimeError`; native artifact import/load error taxonomy | `tests/fortran/error_handling/parsing/test_fortran_diagnostics.py::test_parse_error_is_subclass_of_value_error`
`tests/fortran/error_handling/semantics/test_status_contract_semantics.py::test_runtime_status_policy_rejects_invalid_output_contracts[@raises(status="status")\ndef solve(status: Int32) -> None: ...-status target must name a hidden output]` | `tests/fortran/raw_addresses/end_to_end/test_raw_native_addresses.py::test_primitive_array_and_fixed_string_raw_addresses_share_one_native_build`
`tests/fortran/error_handling/end_to_end/test_status_projection.py::test_status_projection_consumes_outputs_raises_message_and_recovers` | — | canonical |
| [Error Handling: Best Practices](../../docs/user/guide/error-handling.md#best-practices) | Supported | full diagnostic first; verbose command replay; debug traceback only on demand; edited-contract inspection; risky callback isolation | `tests/fortran/error_handling/parsing/test_fortran_diagnostics.py::test_parse_error_message_includes_filename_and_lineno`
`tests/fortran/error_handling/compiling/test_verbose_commands.py::test_run_command_verbose_prints_replayable_command` | `tests/fortran/error_handling/pipeline/test_debug_cli_tracebacks.py::test_cli_debug_flag_reraises_parse_errors`
`tests/fortran/callbacks/end_to_end/test_scalar_callbacks.py::test_immediate_scalar_dummy_procedure_calls_python_callback[source]` | — | canonical |
-| [Fortran Wrapper: Wrapper Errors And Fortran Errors](../../docs/user/reference/fortran-wrapper.md#wrapper-errors-and-fortran-errors) | Supported | ordinary wrapper exceptions; no inferred application convention; opt-in status/message projection; cleanup after failure; native termination remains unrecoverable | `tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_direct_binding_lowering_places_only_native_call_outside_the_gil`
`tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_fixed_message_bridge_copy_requires_its_completed_reason` | `tests/fortran/error_handling/end_to_end/test_status_projection.py::test_status_projection_consumes_outputs_raises_message_and_recovers` | `tests/fortran/callbacks/end_to_end/test_scalar_callbacks.py::test_immediate_scalar_dummy_procedure_calls_python_callback[source]` (`runtime`) | canonical |
+| [Fortran Wrapper: Wrapper Errors And Fortran Errors](../../docs/user/reference/fortran-wrapper.md#wrapper-errors-and-fortran-errors) | Supported | ordinary wrapper exceptions; no inferred application convention; opt-in status/message projection; cleanup after failure; native termination remains unrecoverable | `tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_direct_binding_lowering_places_only_opted_in_native_call_outside_the_gil`
`tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_fixed_message_bridge_copy_requires_its_completed_reason` | `tests/fortran/error_handling/end_to_end/test_status_projection.py::test_status_projection_consumes_outputs_raises_message_and_recovers` | `tests/fortran/callbacks/end_to_end/test_scalar_callbacks.py::test_immediate_scalar_dummy_procedure_calls_python_callback[source]` (`runtime`) | canonical |
| [Feature Matrix: Runtime Error Projection, GIL Policy, Recursion, OpenMP Path, And GNU ABI Checks](../../docs/user/language-support/feature-matrix.md#supported-runtime-features) | Supported | status error and message; completed GIL envelope; recursion/OpenMP/ABI remain separately owned; no caller synchronization inference | `tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_planner_records_editable_native_runtime_and_status_error_facts` | `tests/fortran/error_handling/end_to_end/test_status_projection.py::test_status_projection_consumes_outputs_raises_message_and_recovers` | — | canonical |
| [Building The Shared Library: Build](../../docs/user/guide/building-shared-library.md#build) | Supported | source input; default and explicit module names; build directory; generated sources; importable shared library | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_source_build_result_records_structured_native_plan`
`tests/fortran/building_shared_library/pipeline/test_source_generated_contracts.py::test_source_build_generated_pyi_contract_matches_fixture[fruntime_abi_f90]` | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_documented_homepage_points_example_builds_and_imports` | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_wrapper_build_rejects_empty_source_list` (`pipeline`)
`tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_wrapper_build_rejects_missing_source` (`pipeline`) | canonical |
| [Building The Shared Library: Import](../../docs/user/guide/building-shared-library.md#import) | Supported | ABI-suffixed artifact; stable module import name; explicit output name; root-function name collision avoidance | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_fortran_wrapper_out_dir_separates_abi_artifact_from_cli_alias` | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_fortran_wrapper_out_names_importable_shared_library`
`tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_fortran_wrapper_default_module_name_does_not_collide_with_root_function` | — | canonical |
@@ -228,7 +228,7 @@ Authoritative sources:
| [`.pyi` Calls And Results: Control Mutation](../../docs/user/reference/pyi-contracts/calls-and-results.md#control-mutation) | Supported | immutable scalar, fixed string, array, and derived replacement results; unchanged Python inputs; copy-in/copy-out and identity writeback paths | `tests/fortran/pyi_contracts/calls_and_results/policy/test_call_and_result_policy.py::test_immutable_replacement_policy_is_complete_before_ir_lowering`
`tests/fortran/pyi_contracts/calls_and_results/wrapper_codegen/test_call_and_result_lowering.py::test_replacement_writeback_dispatches_selected_scalar_result_behavior[copy_in_out]` | `tests/fortran/pyi_contracts/calls_and_results/end_to_end/test_edited_call_surfaces.py::test_immutable_values_return_replacements_without_mutating_inputs` | `tests/fortran/memory_management/policy/test_memory_ownership_policy.py::test_contradictory_ownership_contract_fails_before_lowering` (`policy`) | canonical |
| [`.pyi` Calls And Results: Edit Types Shapes Layout And Optionality](../../docs/user/reference/pyi-contracts/calls-and-results.md#edit-types-shapes-layout-and-optionality) | Supported | fixed/open shapes; exact dtype, rank, layout, writeability, byte order, alignment, and zero-size checks; Fortran-order default; supported nullable/defaulted native optionals | `tests/fortran/arrays/wrapper_codegen/test_dense_array_shape_lowering.py::test_dense_array_lowering_uses_planned_shape_checks_and_bridge_orientation`
`tests/fortran/optional_arguments/policy/test_optional_policy.py::test_optional_scalar_policy_completes_nullable_value_presence_before_planning` | `tests/fortran/arrays/end_to_end/test_array_contract_validation.py::test_remaining_array_contracts_are_validated_before_fortran_calls[source]`
`tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py::test_optional_arguments_drive_fortran_present_behavior[source]` | `tests/fortran/optional_arguments/policy/test_optional_policy.py::test_optional_passed_procedure_is_blocked_before_codegen` (`policy`) | canonical |
| [`.pyi` Calls And Results: Translate Status Results Into Exceptions](../../docs/user/reference/pyi-contracts/calls-and-results.md#translate-status-results-into-exceptions) | Supported | named hidden scalar integer status; optional hidden string message; configurable success value; consumed projected outputs | `tests/fortran/error_handling/semantics/test_status_contract_semantics.py::test_status_projection_accepts_an_optional_missing_message_target`
`tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_runtime_plan_edits_dispatch_to_named_lowering_and_validate_roles` | `tests/fortran/error_handling/end_to_end/test_status_projection.py::test_status_projection_consumes_outputs_raises_message_and_recovers` | `tests/fortran/error_handling/semantics/test_status_contract_semantics.py::test_runtime_status_policy_rejects_invalid_output_contracts[@raises(status="status", message="message")\ndef solve() -> tuple[Returns["status", Int32], Returns["message", Int32]]: ...-must be a scalar string hidden output]` (`policy`) | canonical |
-| [`.pyi` Calls And Results: Keep The GIL When Required](../../docs/user/reference/pyi-contracts/calls-and-results.md#keep-the-gil-when-required) | Supported | ordinary released call; explicit held call; status conversion after reacquisition; synchronous callback invocation | `tests/fortran/error_handling/semantics/test_status_contract_semantics.py::test_runtime_policy_decorators_round_trip_through_pyi`
`tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_direct_binding_lowering_places_only_native_call_outside_the_gil` | `tests/fortran/callbacks/end_to_end/test_supported_callback_shapes.py::test_immediate_callbacks_cover_all_supported_argument_shapes[source]` | — | canonical |
+| [`.pyi` Calls And Results: Release The GIL For A Native Call](../../docs/user/reference/pyi-contracts/calls-and-results.md#release-the-gil-for-a-native-call) | Supported | ordinary held call; explicit released call; status conversion after reacquisition; callback trampoline reacquisition | `tests/fortran/error_handling/semantics/test_status_contract_semantics.py::test_runtime_policy_decorators_round_trip_through_pyi`
`tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py::test_direct_binding_lowering_places_only_opted_in_native_call_outside_the_gil` | `tests/fortran/callbacks/end_to_end/test_supported_callback_shapes.py::test_immediate_callbacks_cover_all_supported_argument_shapes[source]` | — | canonical |
| [Feature Matrix: Caller-Ordered Multi-Source Builds, Makefiles, Verbose Mode, And Output Placement](../../docs/user/language-support/feature-matrix.md#supported-runtime-features) | Supported | caller order; direct and Makefile builds; replayable verbose commands; ABI artifact and stable alias placement | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_verbose_mode_prints_full_direct_build_commands` | `tests/fortran/building_shared_library/end_to_end/test_multi_source_builds.py::test_makefile_mode_reproduces_multi_source_build`
`tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_fortran_wrapper_out_dir_separates_abi_artifact_from_cli_alias` | — | canonical |
| [Feature Matrix: Fortran Source Wrapper Builds](../../docs/user/language-support/feature-matrix.md#supported-runtime-features) | Supported | ordered Fortran source inputs; generated contracts; structured native plan; ABI-compatible import | `tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py::test_source_build_result_records_structured_native_plan`
`tests/fortran/building_shared_library/pipeline/test_source_generated_contracts.py::test_source_build_generated_pyi_contract_matches_fixture[fdefault_output]` | `tests/fortran/building_shared_library/end_to_end/test_runtime_compatibility.py::test_debug_and_optimized_wrapper_builds_preserve_runtime_abi` | — | canonical |
| [Feature Matrix: Semantic `.pyi` Wrapper Builds From Explicit Native Artifacts](../../docs/user/language-support/feature-matrix.md#supported-inspection-features) | Partially supported | exactly one entry contract; explicit native input; source-free object build; ordered link items; current runtime subset | `tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py::test_pyi_python_api_accepts_exactly_one_entry_contract`
`tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py::test_generated_pyi_fixture_builds_from_native_object_without_source_reparse` | `tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py::test_scale_runtime_contract[generated-pyi]` | `tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py::test_pyi_python_api_rejects_a_missing_native_artifact` (`pipeline`) | canonical |
diff --git a/tests/fortran/_support/printer_models.py b/tests/fortran/_support/printer_models.py
index b9d165978..730f80ef5 100644
--- a/tests/fortran/_support/printer_models.py
+++ b/tests/fortran/_support/printer_models.py
@@ -26,7 +26,7 @@
from x2py.semantics.models import (
PROTOTYPE_REF_METADATA,
ProjectionMapping,
- RUNTIME_HOLD_GIL_METADATA,
+ RUNTIME_RELEASE_GIL_METADATA,
RUNTIME_STATUS_ERROR_METADATA,
SemanticArgument,
SemanticArrayContract,
@@ -87,7 +87,7 @@ def normalize(text: str) -> str:
__all__ = (
"OPERATOR_F90_SOURCE",
"PROTOTYPE_REF_METADATA",
- "RUNTIME_HOLD_GIL_METADATA",
+ "RUNTIME_RELEASE_GIL_METADATA",
"RUNTIME_STATUS_ERROR_METADATA",
"Path",
"ProjectionMapping",
diff --git a/tests/fortran/arrays/wrapper_codegen/test_array_buffer_lowering.py b/tests/fortran/arrays/wrapper_codegen/test_array_buffer_lowering.py
index 697e5730d..8ec67cf62 100644
--- a/tests/fortran/arrays/wrapper_codegen/test_array_buffer_lowering.py
+++ b/tests/fortran/arrays/wrapper_codegen/test_array_buffer_lowering.py
@@ -64,6 +64,7 @@ def test_required_array_buffer_has_one_printable_editable_handoff_plan():
assert argument.array.extent_roles == (f"{argument.owner_path}:extent:0",)
assert argument.array.upper_bound_roles == ()
assert argument.array.stride_roles == ()
+ assert argument.array.dense_actual_role is None
def test_required_array_buffer_dispatches_through_named_binding_and_bridge_methods():
@@ -79,12 +80,14 @@ def test_required_array_buffer_dispatches_through_named_binding_and_bridge_metho
) in c_source
assert "bound_values = PyLong_AsVoidPtr(PyTuple_GetItem(bound_values_packed, 0));" in c_source
assert "bound_values_extent_0 = (int64_t)PyLong_AsLongLong(PyTuple_GetItem(bound_values_packed, 1));" in c_source
- assert "PyArray_TYPE((PyArrayObject *)bound_values_obj)" not in c_source
+ assert "if (PyArray_Check(bound_values_obj)) {" in c_source
+ assert "PyArray_TYPE((PyArrayObject *)bound_values_obj) != NPY_FLOAT64" in c_source
+ assert "bound_values = PyArray_DATA((PyArrayObject *)bound_values_obj);" in c_source
assert "result = bind_c_sum_values(bound_values, bound_values_extent_0);" in c_source
assert "type(c_ptr), value :: bound_values" in bridge_source
assert "integer(c_int64_t), value :: values_extent_0" in bridge_source
- assert "real(c_double), pointer, dimension(:) :: values" in bridge_source
+ assert "real(c_double), pointer, contiguous, dimension(:) :: values" in bridge_source
assert "call c_f_pointer(bound_values, values, [values_extent_0])" in bridge_source
assert "result = native_sum_values(values)" in bridge_source
diff --git a/tests/fortran/arrays/wrapper_codegen/test_dense_array_shape_lowering.py b/tests/fortran/arrays/wrapper_codegen/test_dense_array_shape_lowering.py
index 365e67f4d..59131545d 100644
--- a/tests/fortran/arrays/wrapper_codegen/test_dense_array_shape_lowering.py
+++ b/tests/fortran/arrays/wrapper_codegen/test_dense_array_shape_lowering.py
@@ -178,7 +178,7 @@ def test_dense_array_lowering_uses_planned_shape_checks_and_bridge_orientation()
assert "subroutine bind_c_c_flat_rank2_runtime(" in bridge_source
assert "external :: flat_rank2_runtime" in bridge_source
assert "external :: c_flat_rank2_fixed" in bridge_source
- assert "real(c_double), pointer, dimension(:, :) :: values" in bridge_source
+ assert "real(c_double), pointer, contiguous, dimension(:, :) :: values" in bridge_source
def test_external_interface_declares_late_extent_before_dependent_array():
diff --git a/tests/fortran/arrays/wrapper_codegen/test_specialized_array_roles.py b/tests/fortran/arrays/wrapper_codegen/test_specialized_array_roles.py
index 8499116b6..8f23aef21 100644
--- a/tests/fortran/arrays/wrapper_codegen/test_specialized_array_roles.py
+++ b/tests/fortran/arrays/wrapper_codegen/test_specialized_array_roles.py
@@ -72,5 +72,5 @@ def test_optional_assumed_rank_and_character_lowering_follow_named_plan_fields()
assert "select case (values_rank)" in bridge_source
assert "case (1)" in bridge_source
assert "case (15)" in bridge_source
- assert "character(kind=c_char, len=8), pointer, dimension(:) :: values" in bridge_source
+ assert "character(kind=c_char, len=8), pointer, contiguous, dimension(:) :: values" in bridge_source
assert max(map(len, bridge_source.splitlines())) <= 132
diff --git a/tests/fortran/arrays/wrapper_codegen/test_strided_array_lowering.py b/tests/fortran/arrays/wrapper_codegen/test_strided_array_lowering.py
index fd7374a30..fd8c8128d 100644
--- a/tests/fortran/arrays/wrapper_codegen/test_strided_array_lowering.py
+++ b/tests/fortran/arrays/wrapper_codegen/test_strided_array_lowering.py
@@ -9,12 +9,13 @@
from x2py.wrapper_codegen import WrapperCodeGenerator, WrapperPlanner
-def _strided_plan():
+def _strided_plan(rank: int = 2):
+ dimensions = ", ".join("::" for _ in range(rank))
module = parse_pyi_text(
- """
+ f"""
from x2py.contracts import Float64
-def strided(values: Float64[::, ::]) -> None: ...
+def strided(values: Float64[{dimensions}]) -> None: ...
""",
module_name="strided_arrays",
)
@@ -38,6 +39,7 @@ def test_strided_array_plan_names_bounds_and_element_strides_explicitly():
f"{argument.owner_path}:stride:0",
f"{argument.owner_path}:stride:1",
)
+ assert array.dense_actual_role == f"{argument.owner_path}:dense-actual"
def test_strided_array_lowering_validates_and_passes_one_explicit_bridge_slice():
@@ -49,10 +51,31 @@ def test_strided_array_lowering_validates_and_passes_one_explicit_bridge_slice()
assert 'PyUnicode_FromString("F")' in c_source
assert "bound_values_upper_bound_0" in c_source
assert "bound_values_stride_1" in c_source
+ assert "int bound_values_dense_actual = 0;" in c_source
+ assert "bound_values_dense_actual = PyArray_IS_F_CONTIGUOUS" in c_source
+ assert "if (!bound_values_dense_actual) {" in c_source
+ assert (
+ "bind_c_strided(bound_values, bound_values_dense_actual, bound_values_extent_0, bound_values_extent_1,"
+ in c_source
+ )
+ assert "integer(c_int), value :: values_dense_actual" in bridge_source
assert "real(c_double), pointer, dimension(:, :) :: values_base" in bridge_source
+ assert "real(c_double), pointer, dimension(:, :) :: values" in bridge_source
+ assert "if (values_dense_actual /= 0_c_int) then" in bridge_source
+ assert "values => values_base" in bridge_source
assert (
- "values_base(1:values_upper_bound_0 + 1:values_stride_0, 1:values_upper_bound_1 + 1:values_stride_1)"
+ "values => values_base(1:values_upper_bound_0 + 1:values_stride_0, 1:values_upper_bound_1 + 1:values_stride_1)"
) in bridge_source
+ assert "call native_strided(values)" in bridge_source
+ assert max(map(len, bridge_source.splitlines())) <= 132
+
+
+def test_rank3_strided_array_pointer_sections_respect_free_form_line_limit():
+ artifacts = WrapperCodeGenerator().generate(_strided_plan(rank=3))
+ bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90")
+
+ assert "values => values_base(&" in bridge_source
+ assert "& 1:values_upper_bound_2 + 1:values_stride_2)" in bridge_source
assert max(map(len, bridge_source.splitlines())) <= 132
@@ -64,3 +87,13 @@ def test_strided_role_edit_fails_before_backend_lowering():
with pytest.raises(ValueError, match="invalid-array-stride-roles"):
WrapperCodeGenerator().generate(plan)
+
+
+def test_strided_dense_actual_role_edit_fails_before_backend_lowering():
+ plan = _strided_plan()
+ array = plan.namespaces[0].functions[0].arguments[0].array
+ assert array is not None
+ array.dense_actual_role = None
+
+ with pytest.raises(ValueError, match="invalid-array-dense-actual-role"):
+ WrapperCodeGenerator().generate(plan)
diff --git a/tests/fortran/callbacks/wrapper_codegen/test_callback_planning.py b/tests/fortran/callbacks/wrapper_codegen/test_callback_planning.py
index d5ed495de..837a669dc 100644
--- a/tests/fortran/callbacks/wrapper_codegen/test_callback_planning.py
+++ b/tests/fortran/callbacks/wrapper_codegen/test_callback_planning.py
@@ -4,7 +4,7 @@
import pytest
-from x2py.pipeline.pyi import pyi_file_to_semantic_module
+from x2py.pipeline.pyi import pyi_file_to_semantic_module, pyi_text_to_semantic_module
from x2py.semantics import models
from x2py.semantics.ownership import PythonBarrierAction
from x2py.semantics.policy_completion import complete_semantic_policies
@@ -104,7 +104,7 @@ def test_callback_plan_projects_one_explicit_site_and_stable_roles_per_argument(
)
)
assert all(
- _function(plan, function).binding.hold_gil
+ not _function(plan, function).binding.release_gil
for function in (
"apply_value_callback",
"apply_scalar_storage_callback",
@@ -175,6 +175,29 @@ def test_callback_artifacts_use_linear_context_adapter_and_trampoline_paths():
assert max(map(len, bridge.splitlines())) <= 132
+def test_nogil_callback_call_releases_outer_envelope_and_reacquires_in_trampoline():
+ source = CONTRACT.read_text(encoding="utf-8")
+ source = source.replace("native_call, prototype", "native_call, nogil, prototype", 1)
+ source = source.replace(
+ "@native_call([Arg(0), Addr(Arg(1))])\ndef apply_value_callback",
+ "@nogil\n@native_call([Arg(0), Addr(Arg(1))])\ndef apply_value_callback",
+ 1,
+ )
+ module = pyi_text_to_semantic_module(source, module_name="fcallback_all_f90")
+ complete_semantic_policies(module)
+ plan = WrapperPlanner().build(module)
+
+ assert _function(plan, "apply_value_callback").binding.release_gil is True
+ c_source, _ = _sources(plan)
+ function_start = c_source.index("static PyObject * wrap_apply_value_callback")
+ function_end = c_source.index("static PyObject * wrap_apply_scalar_storage_callback")
+ function_source = c_source[function_start:function_end]
+ assert "Py_BEGIN_ALLOW_THREADS" in function_source
+ assert "Py_END_ALLOW_THREADS" in function_source
+ assert "PyGILState_Ensure()" in c_source
+ assert "PyGILState_Release(" in c_source
+
+
def test_callback_declaration_uses_external_unless_prototype_requires_explicit_interface():
module = pyi_file_to_semantic_module(ARRAY_CONTRACT, module_name="fcallback_array_f90")
complete_semantic_policies(module)
diff --git a/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi
index c3e82b870..059bed04b 100644
--- a/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi
+++ b/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi
@@ -10,6 +10,7 @@ from x2py.contracts import (
Returns,
Value,
native_call,
+ nogil,
)
from matrix_left_types import item as left_item
from matrix_right_types import item as right_item
@@ -171,6 +172,7 @@ def mutate_duplicate(
) -> tuple[Returns["first", item], Returns["second", item]]: ...
+@nogil
@native_call([Allocatable(Arg(0)), Arg(1)])
def hold_allocatable(
value: item | None,
@@ -178,6 +180,7 @@ def hold_allocatable(
) -> Returns["value", item] | None: ...
+@nogil
@native_call([Arg(0), Arg(1)])
def hold_object(
value: item,
diff --git a/tests/fortran/error_handling/end_to_end/fixtures/edited_contract/fruntime_policy_f90.pyi b/tests/fortran/error_handling/end_to_end/fixtures/edited_contract/fruntime_policy_f90.pyi
index ee4917edf..7453e2153 100644
--- a/tests/fortran/error_handling/end_to_end/fixtures/edited_contract/fruntime_policy_f90.pyi
+++ b/tests/fortran/error_handling/end_to_end/fixtures/edited_contract/fruntime_policy_f90.pyi
@@ -1,12 +1,13 @@
# Intentional difference: exercise runtime policy decorators from an edited contract.
-from x2py.contracts import Addr, Arg, Int32, Return, String, hold_gil, native_call, raises
+from x2py.contracts import Addr, Arg, Int32, Return, String, native_call, nogil, raises
+@nogil
def pause_for_one_second() -> None: ...
-@hold_gil
def pause_with_gil() -> None: ...
@raises(status="status", message="message", success=0)
+@nogil
@native_call([Addr(Arg(0)), Return('status', 0), Return('message', 1)])
def solve(
value: Int32
diff --git a/tests/fortran/error_handling/end_to_end/test_openmp_runtime.py b/tests/fortran/error_handling/end_to_end/test_openmp_runtime.py
index 5e2283331..0330b62c7 100644
--- a/tests/fortran/error_handling/end_to_end/test_openmp_runtime.py
+++ b/tests/fortran/error_handling/end_to_end/test_openmp_runtime.py
@@ -1,4 +1,4 @@
-"""GNU OpenMP build flags, execution, and GIL-release tests."""
+"""GNU OpenMP build flags, execution, and default GIL-policy tests."""
import importlib
import json
@@ -59,8 +59,8 @@ def test_openmp_enabled_procedure_builds_with_explicit_gnu_flags(tmp_path: Path)
)
c_wrapper = (tmp_path / "fopenmp_runtime_f90_wrapper.c").read_text(encoding="utf-8")
- assert "Py_BEGIN_ALLOW_THREADS" in c_wrapper
- assert "Py_END_ALLOW_THREADS" in c_wrapper
+ assert "Py_BEGIN_ALLOW_THREADS" not in c_wrapper
+ assert "Py_END_ALLOW_THREADS" not in c_wrapper
sys.modules.pop("fopenmp_runtime_f90", None)
sys.path.insert(0, str(tmp_path))
diff --git a/tests/fortran/error_handling/end_to_end/test_status_projection.py b/tests/fortran/error_handling/end_to_end/test_status_projection.py
index 209e6b80b..aae251102 100644
--- a/tests/fortran/error_handling/end_to_end/test_status_projection.py
+++ b/tests/fortran/error_handling/end_to_end/test_status_projection.py
@@ -1,6 +1,8 @@
"""Runtime status projection through a reviewed edited semantic contract."""
from pathlib import Path
+import threading
+import time
import numpy as np
import pytest
@@ -19,6 +21,22 @@
pytestmark = pytest.mark.fortran_end_to_end
+def _python_thread_runs_before_native_return(native_call) -> bool:
+ observed: dict[str, float] = {}
+
+ def observe() -> None:
+ time.sleep(0.05)
+ observed["time"] = time.monotonic()
+
+ worker = threading.Thread(target=observe)
+ worker.start()
+ native_call()
+ returned = time.monotonic()
+ worker.join(timeout=2.0)
+ assert not worker.is_alive()
+ return observed["time"] < returned
+
+
def test_status_projection_consumes_outputs_raises_message_and_recovers(tmp_path: Path):
native_object = _compile_native_object(RUNTIME_POLICY_SOURCE, tmp_path / "native")
result = build_pyi_extension(
@@ -29,6 +47,7 @@ def test_status_projection_consumes_outputs_raises_message_and_recovers(tmp_path
)
module = _sole_native_module(_import_from_build_dir(result.module_name, result.output_dir))
+ assert _python_thread_runs_before_native_return(module.pause_for_one_second)
assert module.solve(np.int32(1)) is None
for _ in range(3):
with pytest.raises(RuntimeError, match="negative input"):
@@ -36,6 +55,11 @@ def test_status_projection_consumes_outputs_raises_message_and_recovers(tmp_path
assert module.solve(np.int32(2)) is None
binding = (result.output_dir / "fruntime_policy_f90_wrapper.c").read_text(encoding="utf-8")
+ held = binding[
+ binding.index("static PyObject * wrap_pause_with_gil") : binding.index("static PyObject * wrap_solve")
+ ]
+ assert "Py_BEGIN_ALLOW_THREADS" not in held
+ assert "Py_END_ALLOW_THREADS" not in held
solve = binding[binding.index("static PyObject * wrap_solve") : binding.index("PyMODINIT_FUNC")]
assert solve.index("Py_END_ALLOW_THREADS") < solve.index("status != 0")
assert solve.index("PyUnicode_FromString") < solve.index("free(message)") < solve.index("status != 0")
diff --git a/tests/fortran/error_handling/semantics/test_status_contract_semantics.py b/tests/fortran/error_handling/semantics/test_status_contract_semantics.py
index b9541a00f..603ccb4be 100644
--- a/tests/fortran/error_handling/semantics/test_status_contract_semantics.py
+++ b/tests/fortran/error_handling/semantics/test_status_contract_semantics.py
@@ -3,13 +3,13 @@
import pytest
from x2py.pipeline.pyi import pyi_text_to_semantic_module
-from x2py.semantics.models import RUNTIME_HOLD_GIL_METADATA, RUNTIME_STATUS_ERROR_METADATA
+from x2py.semantics.models import RUNTIME_RELEASE_GIL_METADATA, RUNTIME_STATUS_ERROR_METADATA
from x2py.semantics.policy_completion import complete_semantic_policies
from x2py.wrapper_codegen.printers import emit_module
_CONTRACT_IMPORTS = """from x2py.contracts import (
- Float64, Int32, Returns, String, hold_gil, raises
+ Float64, Int32, Returns, String, nogil, raises
)
"""
@@ -26,8 +26,8 @@ def solve(
x: Float64
) -> tuple[Float64, Returns["status", Int32], Returns["message", String]]: ...
-@hold_gil
-def serialized(x: Float64) -> Float64: ...
+@nogil
+def concurrent(x: Float64) -> Float64: ...
""",
module_name="runtime_policy",
)
@@ -38,11 +38,11 @@ def serialized(x: Float64) -> Float64: ...
"message": "message",
"success": 0,
}
- assert loaded.functions[1].metadata[RUNTIME_HOLD_GIL_METADATA] is True
+ assert loaded.functions[1].metadata[RUNTIME_RELEASE_GIL_METADATA] is True
code = emit_module(loaded)
assert '@raises(status="status", message="message", success=0)' in code
- assert "@hold_gil" in code
+ assert "@nogil" in code
assert emit_module(pyi_text_to_semantic_module(code, module_name="runtime_policy")) == code
diff --git a/tests/fortran/error_handling/wrapper_codegen/test_runtime_envelope_lowering.py b/tests/fortran/error_handling/wrapper_codegen/test_runtime_envelope_lowering.py
index 6fcf6706c..544796847 100644
--- a/tests/fortran/error_handling/wrapper_codegen/test_runtime_envelope_lowering.py
+++ b/tests/fortran/error_handling/wrapper_codegen/test_runtime_envelope_lowering.py
@@ -20,13 +20,13 @@ def _rendered_source(artifacts, suffix: str) -> str:
return next(source.text for source in artifacts.sources if source.path.name.endswith(suffix))
-def test_recursive_runtime_contract_keeps_release_policy_in_the_plan():
+def test_recursive_runtime_contract_keeps_the_gil_by_default():
module = pyi_file_to_semantic_module(RECURSION_CONTRACT, module_name="fruntime_recursion_f90")
complete_semantic_policies(module)
plan = WrapperPlanner().build(module)
assert plan.namespaces[0].functions
- assert all(function.binding.hold_gil is False for function in plan.namespaces[0].functions)
+ assert all(function.binding.release_gil is False for function in plan.namespaces[0].functions)
c_source = _rendered_source(WrapperCodeGenerator().generate(plan), ".c")
- assert c_source.count("Py_BEGIN_ALLOW_THREADS") == len(plan.namespaces[0].functions)
- assert c_source.count("Py_END_ALLOW_THREADS") == len(plan.namespaces[0].functions)
+ assert "Py_BEGIN_ALLOW_THREADS" not in c_source
+ assert "Py_END_ALLOW_THREADS" not in c_source
diff --git a/tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py b/tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py
index 461dc125d..116a39bfd 100644
--- a/tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py
+++ b/tests/fortran/error_handling/wrapper_codegen/test_status_error_lowering.py
@@ -48,9 +48,9 @@ def test_planner_records_editable_native_runtime_and_status_error_facts():
functions = {function.binding.python_name: function for function in plan.namespaces[0].functions}
solve = functions["solve"]
- assert functions["pause_for_one_second"].binding.hold_gil is False
- assert functions["pause_with_gil"].binding.hold_gil is True
- assert solve.binding.hold_gil is False
+ assert functions["pause_for_one_second"].binding.release_gil is True
+ assert functions["pause_with_gil"].binding.release_gil is False
+ assert solve.binding.release_gil is True
assert solve.binding.status_error is not None
assert solve.binding.status_error.success == 0
assert solve.binding.status_error.exception_kind is PythonExceptionKind.RUNTIME_ERROR
@@ -72,7 +72,7 @@ def test_planner_records_editable_native_runtime_and_status_error_facts():
)
-def test_direct_binding_lowering_places_only_native_call_outside_the_gil():
+def test_direct_binding_lowering_places_only_opted_in_native_call_outside_the_gil():
artifacts = WrapperCodeGenerator().generate(_runtime_plan())
c_source = _rendered_source(artifacts, ".c")
released = _function_source(c_source, "pause_for_one_second", "pause_with_gil")
@@ -127,7 +127,7 @@ def test_runtime_plan_edits_dispatch_to_named_lowering_and_validate_roles():
held = _edit_function(
plan,
"pause_for_one_second",
- lambda function: replace(function, binding=replace(function.binding, hold_gil=True)),
+ lambda function: replace(function, binding=replace(function.binding, release_gil=False)),
)
c_source = _rendered_source(WrapperCodeGenerator().generate(held), ".c")
released = _function_source(c_source, "pause_for_one_second", "pause_with_gil")
diff --git a/tests/fortran/functions/end_to_end/test_external_procedures.py b/tests/fortran/functions/end_to_end/test_external_procedures.py
index 9cdd8c2ad..7d9a95585 100644
--- a/tests/fortran/functions/end_to_end/test_external_procedures.py
+++ b/tests/fortran/functions/end_to_end/test_external_procedures.py
@@ -267,7 +267,7 @@ def test_handwritten_c_order_flat_contract_passes_rank_preserving_bridge_view(tm
np.testing.assert_allclose(result_values, [6.0, 60.0])
assert "external::row_sums_c" in compact_bridge
- assert "real(c_double),pointer,dimension(:,:)::values" in compact_bridge
+ assert "real(c_double),pointer,contiguous,dimension(:,:)::values" in compact_bridge
with pytest.raises(TypeError, match=r"expected ordering \(C\)"):
module.row_sums_c(np.int32(values.shape[0]), np.asfortranarray(values), result_values)
diff --git a/tests/fortran/infrastructure/policy/test_wrapper_policy.py b/tests/fortran/infrastructure/policy/test_wrapper_policy.py
index 4ce6a0041..64d1609b2 100644
--- a/tests/fortran/infrastructure/policy/test_wrapper_policy.py
+++ b/tests/fortran/infrastructure/policy/test_wrapper_policy.py
@@ -250,7 +250,7 @@ def test_fmath_scalar_policy_records_address_projected_call_slots():
def test_wrapper_policy_records_runtime_and_native_order_metadata():
module = parse_pyi_text(
"""
-@hold_gil
+@nogil
@bind("SWAP_ARGS")
@external
@native_call([Addr(Arg(1)), Addr(Arg(0))])
@@ -262,7 +262,7 @@ def swap_args(x: Float64, y: Float64) -> Float64: ...
policy = completed_function_wrapper_policy(module.functions[0])
- assert policy.hold_gil is True
+ assert policy.release_gil is True
assert policy.external is True
assert [argument.python_position for argument in policy.arguments] == [0, 1]
assert [argument.native_position for argument in policy.arguments] == [1, 0]
@@ -315,7 +315,7 @@ def add(x: Float64, y: Float64) -> Float64: ...
policy = completed_function_wrapper_policy(module.functions[0])
- assert policy.hold_gil is False
+ assert policy.release_gil is False
assert [argument.native_position for argument in policy.arguments] == [0, 1]
assert [(slot.source_kind, slot.native_position, slot.python_position) for slot in policy.native_call_slots] == [
("implicit", 0, 0),
diff --git a/tests/fortran/infrastructure/wrapper_codegen/test_backend_foundations.py b/tests/fortran/infrastructure/wrapper_codegen/test_backend_foundations.py
index fda2d5bcd..d2952b380 100644
--- a/tests/fortran/infrastructure/wrapper_codegen/test_backend_foundations.py
+++ b/tests/fortran/infrastructure/wrapper_codegen/test_backend_foundations.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import ast
+import re
from pathlib import Path
import pytest
@@ -26,8 +27,10 @@
FortranAssignment,
FortranCall,
FortranFunction,
+ FortranIf,
FortranModule,
FortranParameter,
+ FortranPointerAssignment,
FortranSourcePrinter,
FortranUse,
ModulePlan,
@@ -131,6 +134,79 @@ def test_fortran_source_printer_wraps_long_parenthesized_call_arguments():
assert max(map(len, source.splitlines())) <= 124
+def test_fortran_source_printer_wraps_long_pointer_array_sections():
+ slices = ", ".join(f"1:values_upper_bound_{axis} + 1:values_stride_{axis}" for axis in range(4))
+
+ source = FortranSourcePrinter().doprint(
+ FortranPointerAssignment("values", CodeExpression(f"values_base({slices})"))
+ )
+
+ assert source.startswith("values => values_base(&")
+ assert "& 1:values_upper_bound_3 + 1:values_stride_3)" in source
+ assert max(map(len, source.splitlines())) <= 132
+
+
+def test_fortran_source_printer_formats_unstructured_long_statements_automatically():
+ expression = " + ".join(f"value_{index}" for index in range(20))
+
+ source = FortranSourcePrinter().doprint(FortranAssignment("result", CodeExpression(expression)))
+
+ assert " &\n & " in source
+ assert max(map(len, source.splitlines())) <= 132
+
+
+def test_fortran_source_printer_formats_after_nested_indentation_is_complete():
+ expression = " + ".join(f"value_{index}" for index in range(20))
+ source = FortranSourcePrinter().doprint(
+ FortranModule(
+ "nested_lines",
+ procedures=(
+ FortranFunction(
+ "nested",
+ body=(
+ FortranIf(
+ CodeExpression("outer"),
+ body=(
+ FortranIf(
+ CodeExpression("inner"),
+ body=(FortranAssignment("result", CodeExpression(expression)),),
+ ),
+ ),
+ ),
+ ),
+ is_subroutine=True,
+ ),
+ ),
+ )
+ )
+
+ assert " & " in source
+ assert max(map(len, source.splitlines())) <= 132
+
+
+@pytest.mark.parametrize("quote", ("'", '"'))
+def test_fortran_source_printer_continues_long_literals_without_changing_their_value(quote: str):
+ encoded_value = f"{'x' * 110} literal whitespace {quote * 2}{'y' * 160}"
+ statement = f"result = {quote}{encoded_value}{quote}"
+
+ source = FortranSourcePrinter().doprint(
+ FortranAssignment("result", CodeExpression(f"{quote}{encoded_value}{quote}"))
+ )
+ reconstructed = source.replace("result = &\n & ", "result = ")
+ reconstructed = re.sub(r"&\n\s*&", "", reconstructed)
+
+ assert reconstructed == statement
+ assert max(map(len, source.splitlines())) <= 132
+ for current, following in zip(source.splitlines(), source.splitlines()[1:], strict=False):
+ following_content = following.lstrip().removeprefix("&")
+ assert not (current[-2:-1] == quote and following_content[:1] == quote)
+
+
+def test_fortran_source_printer_rejects_an_overlong_token_without_a_safe_break():
+ with pytest.raises(ValueError, match=r"free-form limit is 132"):
+ FortranSourcePrinter().doprint(FortranAssignment("result", CodeExpression("x" * 134)))
+
+
def test_source_printers_do_not_import_wrapper_plan_models():
path = REPO_ROOT / "x2py" / "wrapper_codegen" / "printers" / "source_printers.py"
imports = {
diff --git a/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_assembly.py b/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_assembly.py
index fdae1dd8b..4bda287f2 100644
--- a/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_assembly.py
+++ b/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_assembly.py
@@ -33,7 +33,7 @@ def _rendered_source(artifacts, suffix: str) -> str:
def test_public_generator_directly_returns_complete_rendered_artifacts():
plan = _plan(
"""
-@hold_gil
+@nogil
@bind("SWAP_ARGS")
@external
@native_call([Addr(Arg(1)), Addr(Arg(0))])
diff --git a/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_plan_validation.py b/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_plan_validation.py
index ee15e5c83..ec67c027e 100644
--- a/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_plan_validation.py
+++ b/tests/fortran/infrastructure/wrapper_codegen/test_wrapper_plan_validation.py
@@ -26,7 +26,7 @@ def _plan(source: str, *, module_name: str = "fmath"):
def _scalar_plan():
return _plan(
"""
-@hold_gil
+@nogil
@bind("SWAP_ARGS")
@external
@native_call([Addr(Arg(1)), Addr(Arg(0))])
diff --git a/tests/fortran/modules/end_to_end/test_module_variables_and_state.py b/tests/fortran/modules/end_to_end/test_module_variables_and_state.py
index 1b00c2953..6528bc396 100644
--- a/tests/fortran/modules/end_to_end/test_module_variables_and_state.py
+++ b/tests/fortran/modules/end_to_end/test_module_variables_and_state.py
@@ -90,8 +90,8 @@ def test_scalar_module_variables_use_attributes_and_parameters_have_no_native_se
getter_start = wrapper_source.index("static PyObject * module_get_counter")
setter_start = wrapper_source.index("static int module_set_counter")
next_getter_start = wrapper_source.index("static PyObject * module_get_scale")
- assert "Py_BEGIN_ALLOW_THREADS" in wrapper_source[summarize_start:scaled_start]
- assert "Py_END_ALLOW_THREADS" in wrapper_source[summarize_start:scaled_start]
+ assert "Py_BEGIN_ALLOW_THREADS" not in wrapper_source[summarize_start:scaled_start]
+ assert "Py_END_ALLOW_THREADS" not in wrapper_source[summarize_start:scaled_start]
assert "Py_BEGIN_ALLOW_THREADS" not in wrapper_source[getter_start:setter_start]
assert "Py_END_ALLOW_THREADS" not in wrapper_source[getter_start:setter_start]
assert "Py_BEGIN_ALLOW_THREADS" not in wrapper_source[setter_start:next_getter_start]
diff --git a/tests/fortran/pyi_contracts/calls_and_results/policy/test_call_and_result_policy.py b/tests/fortran/pyi_contracts/calls_and_results/policy/test_call_and_result_policy.py
index 3f7b2274f..c5ac9b2ea 100644
--- a/tests/fortran/pyi_contracts/calls_and_results/policy/test_call_and_result_policy.py
+++ b/tests/fortran/pyi_contracts/calls_and_results/policy/test_call_and_result_policy.py
@@ -28,12 +28,12 @@ def scalar_status(base: Int32[()], status: Int32[()]) -> None: ...
_, projected = _completed_policy(
"""
-@hold_gil
+@nogil
@native_call([Return("status", 0), Addr(Arg(0))])
def scalar_status(base: Int32) -> Returns["status", Int32]: ...
"""
)
- assert projected.hold_gil is True
+ assert projected.release_gil is True
assert [(slot.source_kind, slot.native_position) for slot in projected.native_call_slots] == [
("result", 0),
("projection", 1),
diff --git a/tests/fortran/pyi_contracts/calls_and_results/wrapper_codegen/test_call_and_result_lowering.py b/tests/fortran/pyi_contracts/calls_and_results/wrapper_codegen/test_call_and_result_lowering.py
index e49c2e5f2..625e736ed 100644
--- a/tests/fortran/pyi_contracts/calls_and_results/wrapper_codegen/test_call_and_result_lowering.py
+++ b/tests/fortran/pyi_contracts/calls_and_results/wrapper_codegen/test_call_and_result_lowering.py
@@ -26,7 +26,7 @@ def test_plan_records_reordered_arguments_gil_behavior_and_hidden_result_slots()
reordered = (
_plan(
"""
-@hold_gil
+@nogil
@bind("SWAP_ARGS")
@external
@native_call([Addr(Arg(1)), Addr(Arg(0))])
@@ -38,7 +38,7 @@ def swap_args(x: Float64, y: Float64) -> Float64: ...
.functions[0]
)
- assert reordered.binding.hold_gil is True
+ assert reordered.binding.release_gil is True
assert reordered.bridge.native_name == "SWAP_ARGS"
assert reordered.bridge.external is True
assert [argument.native_position for argument in reordered.arguments] == [1, 0]
diff --git a/tests/fortran/pyi_contracts/functions_and_classes/wrapper_codegen/test_constructor_lowering.py b/tests/fortran/pyi_contracts/functions_and_classes/wrapper_codegen/test_constructor_lowering.py
index b2071d554..18ff9adf5 100644
--- a/tests/fortran/pyi_contracts/functions_and_classes/wrapper_codegen/test_constructor_lowering.py
+++ b/tests/fortran/pyi_contracts/functions_and_classes/wrapper_codegen/test_constructor_lowering.py
@@ -33,5 +33,5 @@ def __init__(self, seed: Addr(Int32)) -> None: ...
assert "state__default_init_wrapper" not in sources[".c"]
assert 'static char * kwlist[] = {"self", "seed", NULL};' in sources[".c"]
assert 'PyArg_ParseTupleAndKeywords(args, kwargs, "OO", kwlist, &bound_self_obj, &bound_seed_obj)' in sources[".c"]
- assert "Py_BEGIN_ALLOW_THREADS" in sources[".c"]
- assert "Py_END_ALLOW_THREADS" in sources[".c"]
+ assert "Py_BEGIN_ALLOW_THREADS" not in sources[".c"]
+ assert "Py_END_ALLOW_THREADS" not in sources[".c"]
diff --git a/tests/shared/architecture/test_test_suite_layout.py b/tests/shared/architecture/test_test_suite_layout.py
index e59c4bf02..d01c321cf 100644
--- a/tests/shared/architecture/test_test_suite_layout.py
+++ b/tests/shared/architecture/test_test_suite_layout.py
@@ -71,6 +71,7 @@
"test_check_benchmark_regression.py",
"test_check_radon_policy.py",
"test_check_static_analysis_versions.py",
+ "test_generate_performance_docs.py",
"test_print_pytest_failures.py",
"test_run_fortran_toolchain_lane.py",
"test_warm_real_library_native_cache.py",
@@ -218,6 +219,7 @@ def test_active_github_action_jobs_use_purpose_first_display_names() -> None:
expected = {
DOCS_WORKFLOW: (
"name: Documentation",
+ " name: Benchmark",
" name: Build",
" name: Deploy",
),
@@ -252,3 +254,16 @@ def test_active_github_action_jobs_use_purpose_first_display_names() -> None:
text = workflow.read_text(encoding="utf-8")
for name in names:
assert name in text
+
+
+def test_documentation_workflow_generates_main_only_performance_snapshot() -> None:
+ workflow = DOCS_WORKFLOW.read_text(encoding="utf-8")
+
+ assert "runs-on: ${{ vars.X2PY_BENCHMARK_RUNNER || 'ubuntu-24.04' }}" in workflow
+ assert "if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'" in workflow
+ assert "bash benchmarks/run.sh" in workflow
+ assert "python tools/generate_performance_docs.py" in workflow
+ assert "name: performance-snapshot" in workflow
+ assert "benchmarks/results/f2py.json" in workflow
+ assert "benchmarks/results/x2py.json" in workflow
+ assert "uses: actions/download-artifact@v4" in workflow
diff --git a/tests/shared/tools/test_generate_performance_docs.py b/tests/shared/tools/test_generate_performance_docs.py
new file mode 100644
index 000000000..159e001ab
--- /dev/null
+++ b/tests/shared/tools/test_generate_performance_docs.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+from datetime import date
+from pathlib import Path
+from xml.etree import ElementTree
+
+import pyperf
+import pytest
+
+from tools.generate_performance_docs import (
+ _format_factor,
+ _format_ratio,
+ generate,
+ load_snapshot,
+ render_chart,
+ render_page,
+)
+
+
+COMMON_METADATA = {
+ "cpu_affinity": "2",
+ "cpu_model_name": "Benchmark CPU",
+ "hostname": "private-runner-name",
+ "numpy_version": "2.5.1",
+ "perf_version": "2.10.0",
+ "platform_details": "Linux-test-x86_64",
+ "python_version": "3.12.11 (test build)",
+ "unit": "second",
+}
+TEST_OS = "Test Linux 1.0"
+
+
+def _write_suite(
+ path: Path,
+ tool: str,
+ benchmarks: list[tuple[str, list[float]]],
+ *,
+ platform_details: str = "Linux-test-x86_64",
+) -> Path:
+ suite_benchmarks = []
+ for name, values in benchmarks:
+ metadata = {
+ **COMMON_METADATA,
+ "binding_tool": tool,
+ "date": "2026-08-01 12:00:00",
+ "name": name,
+ "platform_details": platform_details,
+ }
+ run = pyperf.Run(values, metadata=metadata, collect_metadata=False)
+ suite_benchmarks.append(pyperf.Benchmark([run]))
+ pyperf.BenchmarkSuite(suite_benchmarks).dump(str(path), replace=True)
+ return path
+
+
+def _paired_suites(tmp_path: Path) -> tuple[Path, Path]:
+ f2py = _write_suite(
+ tmp_path / "f2py.json",
+ "f2py",
+ [
+ ("call.noop", [2.0e-6, 2.1e-6, 1.9e-6, 2.05e-6, 1.95e-6]),
+ ("call.add_scalars", [0.8e-6, 0.82e-6, 0.78e-6, 0.81e-6, 0.79e-6]),
+ ("array.increment_vector.n=1", [1.0e-6, 1.02e-6, 0.98e-6, 1.01e-6, 0.99e-6]),
+ ],
+ )
+ x2py = _write_suite(
+ tmp_path / "x2py.json",
+ "x2py",
+ [
+ ("call.noop", [1.0e-6, 1.1e-6, 0.9e-6, 1.05e-6, 0.95e-6]),
+ ("call.add_scalars", [1.0e-6, 1.02e-6, 0.98e-6, 1.01e-6, 0.99e-6]),
+ ("array.increment_vector.n=1", [0.98e-6, 1.01e-6, 1.0e-6, 1.02e-6, 0.99e-6]),
+ ],
+ )
+ return f2py, x2py
+
+
+def _page_template() -> str:
+ return """before
+
+old summary
+
+between summary and table
+
+old table
+
+between table and environment
+
+old environment
+
+after
+"""
+
+
+def test_load_snapshot_classifies_results_and_formats_public_values(tmp_path: Path) -> None:
+ f2py, x2py = _paired_suites(tmp_path)
+
+ snapshot = load_snapshot(
+ f2py,
+ x2py,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0",
+ commit="1234567890abcdef",
+ )
+
+ assert [result.outcome for result in snapshot.results] == ["x2py", "f2py", "parity"]
+ assert snapshot.results[0].f2py_display == "2.00 µs"
+ assert snapshot.results[2].table_label == "Increment vector, 1 element"
+ assert snapshot.recorded_date == date(2026, 8, 1)
+ assert snapshot.compiler_version == "GNU Fortran 13.3.0"
+ assert snapshot.commit == "1234567890ab"
+ assert "hostname" not in snapshot.metadata
+
+
+def test_format_factor_keeps_small_significant_differences_visible() -> None:
+ assert _format_factor(1.004) == "1.004\N{MULTIPLICATION SIGN}"
+ assert _format_factor(1.04) == "1.04\N{MULTIPLICATION SIGN}"
+ assert _format_ratio(0.996) == "0.996\N{MULTIPLICATION SIGN}"
+
+
+def test_render_page_updates_only_marked_blocks(tmp_path: Path) -> None:
+ f2py, x2py = _paired_suites(tmp_path)
+ snapshot = load_snapshot(
+ f2py,
+ x2py,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran 13.3.0",
+ commit="1234567890abcdef",
+ )
+
+ rendered = render_page(_page_template(), snapshot)
+
+ assert rendered.startswith("before\n")
+ assert rendered.endswith("after\n")
+ assert "between summary and table" in rendered
+ assert "1 of 3" in rendered
+ assert "No significant difference" in rendered
+ assert "private-runner-name" not in rendered
+ assert "Operating system: Test Linux 1.0" in rendered
+ assert "GNU Fortran 13.3.0" in rendered
+ assert "`1234567890ab`" in rendered
+
+
+def test_render_page_rejects_missing_or_duplicate_markers(tmp_path: Path) -> None:
+ f2py, x2py = _paired_suites(tmp_path)
+ snapshot = load_snapshot(
+ f2py,
+ x2py,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran 13.3.0",
+ commit="1234567890abcdef",
+ )
+
+ with pytest.raises(ValueError, match="exactly one 'summary' marker pair"):
+ render_page(_page_template().replace("", ""), snapshot)
+
+
+def test_render_chart_is_valid_accessible_svg(tmp_path: Path) -> None:
+ f2py, x2py = _paired_suites(tmp_path)
+ snapshot = load_snapshot(
+ f2py,
+ x2py,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran 13.3.0",
+ commit="1234567890abcdef",
+ )
+
+ chart = render_chart(snapshot)
+ root = ElementTree.fromstring(chart)
+
+ assert root.attrib["role"] == "img"
+ assert root.attrib["aria-labelledby"] == "title description"
+ assert "no significant difference" in chart
+ assert "Geometric mean:" in chart
+
+
+def test_load_snapshot_rejects_incompatible_platforms(tmp_path: Path) -> None:
+ f2py, _x2py = _paired_suites(tmp_path)
+ x2py = _write_suite(
+ tmp_path / "other-x2py.json",
+ "x2py",
+ [
+ ("call.noop", [1.0e-6, 1.1e-6, 0.9e-6, 1.05e-6, 0.95e-6]),
+ ("call.add_scalars", [1.0e-6, 1.02e-6, 0.98e-6, 1.01e-6, 0.99e-6]),
+ ("array.increment_vector.n=1", [0.98e-6, 1.01e-6, 1.0e-6, 1.02e-6, 0.99e-6]),
+ ],
+ platform_details="different-platform",
+ )
+
+ with pytest.raises(ValueError, match="disagree on metadata 'platform_details'"):
+ load_snapshot(
+ f2py,
+ x2py,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran 13.3.0",
+ commit="12345678",
+ )
+
+
+def test_load_snapshot_rejects_swapped_tool_results(tmp_path: Path) -> None:
+ f2py, x2py = _paired_suites(tmp_path)
+
+ with pytest.raises(ValueError, match="expected 'f2py' results, found binding_tool='x2py'"):
+ load_snapshot(
+ x2py,
+ f2py,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran 13.3.0",
+ commit="12345678",
+ )
+
+
+def test_generate_writes_page_and_chart(tmp_path: Path) -> None:
+ f2py, x2py = _paired_suites(tmp_path)
+ page = tmp_path / "performance.md"
+ chart = tmp_path / "assets/performance.svg"
+ page.write_text(_page_template(), encoding="utf-8")
+
+ generate(
+ f2py,
+ x2py,
+ page,
+ chart,
+ operating_system=TEST_OS,
+ compiler_version="GNU Fortran 13.3.0",
+ commit="1234567890abcdef",
+ recorded_date=date(2026, 8, 2),
+ )
+
+ assert "August 2, 2026" in page.read_text(encoding="utf-8")
+ assert chart.is_file()
+ ElementTree.parse(chart)
+
+
+def test_current_performance_page_has_one_complete_marker_pair_per_generated_block() -> None:
+ page = Path("docs/user/performance.md").read_text(encoding="utf-8")
+
+ for name in ("summary", "table", "environment"):
+ assert page.count(f"") == 1
+ assert page.count(f"") == 1
+
+
+def test_pyperf_is_pinned_for_documentation_and_generator_tests() -> None:
+ pyproject = Path("pyproject.toml").read_text(encoding="utf-8")
+
+ assert pyproject.count('"pyperf==2.10.0"') == 2
diff --git a/tools/generate_performance_docs.py b/tools/generate_performance_docs.py
new file mode 100644
index 000000000..e76324fe7
--- /dev/null
+++ b/tools/generate_performance_docs.py
@@ -0,0 +1,610 @@
+#!/usr/bin/env python3
+"""Generate the public Performance snapshot from paired pyperf results."""
+
+from __future__ import annotations
+
+import argparse
+from dataclasses import dataclass
+from datetime import date
+from html import escape
+import math
+from pathlib import Path
+import platform
+import re
+import subprocess # nosec B404 - fixed argv commands collect local tool versions
+import sys
+import textwrap
+from typing import Literal
+
+import pyperf
+from pyperf._compare import is_significant_benchs
+
+
+REPOSITORY_ROOT = Path(__file__).parents[1]
+DEFAULT_F2PY_RESULTS = REPOSITORY_ROOT / "benchmarks/results/f2py.json"
+DEFAULT_X2PY_RESULTS = REPOSITORY_ROOT / "benchmarks/results/x2py.json"
+DEFAULT_PAGE = REPOSITORY_ROOT / "docs/user/performance.md"
+DEFAULT_CHART = REPOSITORY_ROOT / "docs/user/assets/performance-comparison.svg"
+COMPILE_FLAGS = "-O3 -march=native -mtune=native"
+TIMES = "\N{MULTIPLICATION SIGN}"
+MARKER_NAMES = ("summary", "table", "environment")
+SHARED_METADATA = (
+ "cpu_affinity",
+ "cpu_model_name",
+ "numpy_version",
+ "perf_version",
+ "platform_details",
+ "python_version",
+)
+Outcome = Literal["x2py", "f2py", "parity"]
+
+
+@dataclass(frozen=True)
+class BenchmarkResult:
+ name: str
+ table_label: str
+ chart_label: str
+ f2py_value: float
+ x2py_value: float
+ f2py_display: str
+ x2py_display: str
+ ratio: float
+ outcome: Outcome
+
+ @property
+ def factor(self) -> float:
+ return self.ratio if self.ratio >= 1.0 else 1.0 / self.ratio
+
+
+@dataclass(frozen=True)
+class PerformanceSnapshot:
+ results: tuple[BenchmarkResult, ...]
+ metadata: dict[str, object]
+ recorded_date: date
+ operating_system: str
+ compiler_version: str
+ commit: str
+
+ @property
+ def geometric_mean_ratio(self) -> float:
+ return math.exp(sum(math.log(result.ratio) for result in self.results) / len(self.results))
+
+ @property
+ def x2py_wins(self) -> tuple[BenchmarkResult, ...]:
+ return tuple(result for result in self.results if result.outcome == "x2py")
+
+ @property
+ def f2py_wins(self) -> tuple[BenchmarkResult, ...]:
+ return tuple(result for result in self.results if result.outcome == "f2py")
+
+ @property
+ def parity_results(self) -> tuple[BenchmarkResult, ...]:
+ return tuple(result for result in self.results if result.outcome == "parity")
+
+
+def _format_factor(factor: float) -> str:
+ precision = 3 if factor < 1.01 else 2
+ return f"{factor:.{precision}f}{TIMES}"
+
+
+def _format_ratio(ratio: float) -> str:
+ precision = 3 if abs(ratio - 1.0) < 0.01 else 2
+ return f"{ratio:.{precision}f}{TIMES}"
+
+
+def _compiler_display_name(value: str) -> str:
+ value = value.strip()
+ if value.startswith("GNU Fortran"):
+ version = re.search(r"(\d+(?:\.\d+){1,2})$", value)
+ if version:
+ return f"GNU Fortran {version.group(1)}"
+ return value
+
+
+def _procedure_labels(name: str) -> tuple[str, str]:
+ fixed = {
+ "call.noop": ("Empty function call", "Empty call"),
+ "call.add_scalars": ("Add two scalars", "Add scalars"),
+ }
+ if name in fixed:
+ return fixed[name]
+
+ vector_match = re.fullmatch(r"array\.increment_vector\.n=(\d+)", name)
+ if vector_match:
+ size = f"{int(vector_match.group(1)):,}"
+ return (f"Increment vector, {size} element{'s' if size != '1' else ''}", f"Increment vector · n={size}")
+
+ matrix_match = re.fullmatch(r"matrix\.(sum|update)\.(\d+)x(\d+)\.order=([A-Za-z])", name)
+ if matrix_match:
+ operation, rows, columns, order = matrix_match.groups()
+ title = operation.capitalize()
+ shape = f"{int(rows):,}{TIMES}{int(columns):,}"
+ return (f"{title} {shape} {order}-order matrix", f"{title} matrix · {shape}")
+
+ readable = name.replace("_", " ").replace(".", " · ")
+ return readable, readable
+
+
+def _outcome(f2py_benchmark: pyperf.Benchmark, x2py_benchmark: pyperf.Benchmark) -> Outcome:
+ significant, _score = is_significant_benchs(f2py_benchmark, x2py_benchmark)
+ if not significant:
+ return "parity"
+ return "x2py" if f2py_benchmark.mean() > x2py_benchmark.mean() else "f2py"
+
+
+def _format_benchmark_value(benchmark: pyperf.Benchmark, value: float) -> str:
+ return benchmark.format_value(value).replace(" us", " µs")
+
+
+def _compatible_metadata(
+ f2py_suite: pyperf.BenchmarkSuite,
+ x2py_suite: pyperf.BenchmarkSuite,
+) -> dict[str, object]:
+ f2py_metadata = f2py_suite.get_metadata()
+ x2py_metadata = x2py_suite.get_metadata()
+ shared: dict[str, object] = {}
+ for key in SHARED_METADATA:
+ f2py_value = f2py_metadata.get(key)
+ x2py_value = x2py_metadata.get(key)
+ if f2py_value is None or x2py_value is None:
+ raise ValueError(f"paired pyperf results are missing required metadata {key!r}")
+ if f2py_value != x2py_value:
+ raise ValueError(f"paired pyperf results disagree on metadata {key!r}")
+ shared[key] = f2py_value
+ return shared
+
+
+def _validate_suite_identity(suite: pyperf.BenchmarkSuite, expected: str) -> None:
+ actual = suite.get_metadata().get("binding_tool")
+ if actual != expected:
+ raise ValueError(f"expected {expected!r} results, found binding_tool={actual!r}")
+
+
+def load_snapshot(
+ f2py_path: Path,
+ x2py_path: Path,
+ *,
+ operating_system: str,
+ compiler_version: str,
+ commit: str,
+ recorded_date: date | None = None,
+) -> PerformanceSnapshot:
+ """Load and validate one paired benchmark snapshot."""
+ f2py_suite = pyperf.BenchmarkSuite.load(str(f2py_path))
+ x2py_suite = pyperf.BenchmarkSuite.load(str(x2py_path))
+ _validate_suite_identity(f2py_suite, "f2py")
+ _validate_suite_identity(x2py_suite, "x2py")
+ f2py_names = f2py_suite.get_benchmark_names()
+ x2py_names = x2py_suite.get_benchmark_names()
+ if f2py_names != x2py_names:
+ raise ValueError("paired pyperf results must contain the same benchmarks in the same order")
+ if not f2py_names:
+ raise ValueError("paired pyperf results contain no benchmarks")
+
+ results = []
+ for name in f2py_names:
+ f2py_benchmark = f2py_suite.get_benchmark(name)
+ x2py_benchmark = x2py_suite.get_benchmark(name)
+ f2py_value = f2py_benchmark.mean()
+ x2py_value = x2py_benchmark.mean()
+ table_label, chart_label = _procedure_labels(name)
+ results.append(
+ BenchmarkResult(
+ name=name,
+ table_label=table_label,
+ chart_label=chart_label,
+ f2py_value=f2py_value,
+ x2py_value=x2py_value,
+ f2py_display=_format_benchmark_value(f2py_benchmark, f2py_value),
+ x2py_display=_format_benchmark_value(x2py_benchmark, x2py_value),
+ ratio=f2py_value / x2py_value,
+ outcome=_outcome(f2py_benchmark, x2py_benchmark),
+ )
+ )
+
+ latest_date = max(f2py_suite.get_dates()[1], x2py_suite.get_dates()[1]).date()
+ return PerformanceSnapshot(
+ results=tuple(results),
+ metadata=_compatible_metadata(f2py_suite, x2py_suite),
+ recorded_date=recorded_date or latest_date,
+ operating_system=operating_system,
+ compiler_version=_compiler_display_name(compiler_version),
+ commit=commit[:12],
+ )
+
+
+def _geometric_result(snapshot: PerformanceSnapshot) -> tuple[str, str]:
+ ratio = snapshot.geometric_mean_ratio
+ if math.isclose(ratio, 1.0, rel_tol=0.005):
+ return f"1.00{TIMES}", "geometric-mean parity"
+ if ratio > 1.0:
+ return f"{ratio:.2f}{TIMES}", "x2py geometric-mean speedup"
+ return f"{1.0 / ratio:.2f}{TIMES}", "f2py geometric-mean speedup"
+
+
+def _geometric_sentence(snapshot: PerformanceSnapshot) -> str:
+ ratio = snapshot.geometric_mean_ratio
+ if math.isclose(ratio, 1.0, rel_tol=0.005):
+ return "the geometric-mean runtime of x2py and NumPy's f2py was at parity"
+ if ratio > 1.0:
+ return f"the normal x2py interface delivered a **{ratio:.2f}{TIMES} geometric-mean speedup over NumPy's f2py**"
+ return f"NumPy's f2py delivered a **{1.0 / ratio:.2f}{TIMES} geometric-mean speedup over x2py**"
+
+
+def _outcome_sentence(snapshot: PerformanceSnapshot) -> str:
+ total = len(snapshot.results)
+ x2py_count = len(snapshot.x2py_wins)
+ f2py_count = len(snapshot.f2py_wins)
+ parity_count = len(snapshot.parity_results)
+ comparison = f"Across {total} workloads, x2py was faster in {x2py_count} and f2py in {f2py_count}"
+ if parity_count:
+ noun = "workload" if parity_count == 1 else "workloads"
+ return f"{comparison}; {parity_count} {noun} showed no statistically significant difference."
+ return f"{comparison}; all comparisons were statistically significant."
+
+
+def _summary_markdown(snapshot: PerformanceSnapshot) -> str:
+ geometric_value, geometric_label = _geometric_result(snapshot)
+ best = max(snapshot.x2py_wins, key=lambda result: result.factor, default=None)
+ best_value = _format_factor(best.factor) if best else "—"
+ best_label = "best measured x2py speedup" if best else "no measured x2py speedup"
+ total = len(snapshot.results)
+ summary = textwrap.fill(
+ f"On the benchmark system, {_geometric_sentence(snapshot)}. {_outcome_sentence(snapshot)}",
+ width=88,
+ break_long_words=False,
+ break_on_hyphens=False,
+ )
+ return "\n".join(
+ [
+ summary,
+ "",
+ '",
+ ]
+ )
+
+
+def _relative_result(result: BenchmarkResult) -> str:
+ if result.outcome == "parity":
+ return "No significant difference"
+ winner = result.outcome
+ return f"{winner} {_format_factor(result.factor)} faster"
+
+
+def _table_value(value: str, *, winner: bool) -> str:
+ return f"**{value}**" if winner else value
+
+
+def _geometric_table_result(snapshot: PerformanceSnapshot) -> str:
+ ratio = snapshot.geometric_mean_ratio
+ if math.isclose(ratio, 1.0, rel_tol=0.005):
+ return "**At parity**"
+ if ratio > 1.0:
+ return f"**x2py {ratio:.2f}{TIMES} faster**"
+ return f"**f2py {1.0 / ratio:.2f}{TIMES} faster**"
+
+
+def _table_markdown(snapshot: PerformanceSnapshot) -> str:
+ rows = [
+ "| Workload | f2py | x2py | Relative result |",
+ "| --- | ---: | ---: | ---: |",
+ ]
+ for result in snapshot.results:
+ f2py_value = _table_value(result.f2py_display, winner=result.outcome == "f2py")
+ x2py_value = _table_value(result.x2py_display, winner=result.outcome == "x2py")
+ rows.append(f"| {result.table_label} | {f2py_value} | {x2py_value} | {_relative_result(result)} |")
+ rows.append(f"| **Geometric mean** | reference | — | {_geometric_table_result(snapshot)} |")
+ return "\n".join(rows)
+
+
+def _month_date(value: date) -> str:
+ months = (
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+ )
+ return f"{months[value.month - 1]} {value.day}, {value.year}"
+
+
+def _metadata_text(metadata: dict[str, object], key: str) -> str:
+ return str(metadata[key]).replace("`", "'")
+
+
+def _environment_markdown(snapshot: PerformanceSnapshot) -> str:
+ python_version = _metadata_text(snapshot.metadata, "python_version").split(maxsplit=1)[0]
+ affinity = _metadata_text(snapshot.metadata, "cpu_affinity")
+ operating_system = snapshot.operating_system.replace("`", "'")
+ compiler_version = snapshot.compiler_version.replace("`", "'")
+ lines = [
+ f"- Native and generated sources use `{COMPILE_FLAGS}`.",
+ "- Both interfaces keep the GIL held.",
+ "- OpenMP, OpenBLAS, and MKL are limited to one thread.",
+ f"- `pyperf --rigorous` pins each benchmark to logical CPU `{affinity}`.",
+ f"- CPU: {_metadata_text(snapshot.metadata, 'cpu_model_name')}.",
+ f"- Operating system: {operating_system}.",
+ f"- Kernel/platform: `{_metadata_text(snapshot.metadata, 'platform_details')}`.",
+ f"- Python: {python_version}.",
+ f"- NumPy/f2py: {_metadata_text(snapshot.metadata, 'numpy_version')}.",
+ f"- Fortran compiler: {compiler_version}.",
+ f"- pyperf: {_metadata_text(snapshot.metadata, 'perf_version')}.",
+ f"- x2py revision: `{snapshot.commit}`.",
+ "",
+ f"These results were recorded on {_month_date(snapshot.recorded_date)}. Performance depends on the CPU,",
+ "compiler, operating system, and background activity, so comparisons should use",
+ "results produced together on the same machine.",
+ ]
+ return "\n".join(lines)
+
+
+def _replace_block(markdown: str, name: str, replacement: str) -> str:
+ start = f""
+ end = f""
+ if markdown.count(start) != 1 or markdown.count(end) != 1:
+ raise ValueError(f"Performance page must contain exactly one {name!r} marker pair")
+ before, remainder = markdown.split(start, maxsplit=1)
+ _old, after = remainder.split(end, maxsplit=1)
+ return f"{before}{start}\n{replacement.rstrip()}\n{end}{after}"
+
+
+def render_page(markdown: str, snapshot: PerformanceSnapshot) -> str:
+ """Replace only the generated blocks in a Performance page."""
+ replacements = {
+ "summary": _summary_markdown(snapshot),
+ "table": _table_markdown(snapshot),
+ "environment": _environment_markdown(snapshot),
+ }
+ for name in MARKER_NAMES:
+ markdown = _replace_block(markdown, name, replacements[name])
+ return markdown
+
+
+def _axis_bounds(results: tuple[BenchmarkResult, ...]) -> tuple[float, float]:
+ ratios = [result.ratio for result in results]
+ lower = max(0.1, math.floor((min(ratios) - 0.03) * 10.0) / 10.0)
+ upper = math.ceil((max(ratios) + 0.03) * 10.0) / 10.0
+ if upper - lower < 0.2:
+ lower = max(0.1, lower - 0.1)
+ upper += 0.1
+ return lower, upper
+
+
+def _axis_ticks(lower: float, upper: float) -> list[float]:
+ span = upper - lower
+ step = 0.1 if span <= 0.5 else 0.2 if span <= 1.0 else 0.5
+ first = math.ceil(lower / step) * step
+ ticks = {lower, upper, 1.0}
+ value = first
+ while value <= upper + 1e-9:
+ ticks.add(round(value, 10))
+ value += step
+ return sorted(tick for tick in ticks if lower <= tick <= upper)
+
+
+def _chart_geometric_label(snapshot: PerformanceSnapshot) -> str:
+ ratio = snapshot.geometric_mean_ratio
+ if math.isclose(ratio, 1.0, rel_tol=0.005):
+ return "Geometric mean: parity"
+ if ratio > 1.0:
+ return f"Geometric mean: x2py {ratio:.2f}{TIMES} faster"
+ return f"Geometric mean: f2py {1.0 / ratio:.2f}{TIMES} faster"
+
+
+def render_chart(snapshot: PerformanceSnapshot) -> str:
+ """Render an accessible SVG lollipop chart for a snapshot."""
+ width = 1000
+ plot_left = 350
+ plot_right = 920
+ top = 115
+ row_start = 150
+ row_step = 38
+ final_row = row_start + (len(snapshot.results) - 1) * row_step
+ footer = final_row + 58
+ height = footer + 38
+ lower, upper = _axis_bounds(snapshot.results)
+
+ def x_position(value: float) -> float:
+ return plot_left + ((value - lower) / (upper - lower)) * (plot_right - plot_left)
+
+ baseline = x_position(1.0)
+ lines = [
+ (
+ f'",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _command_first_line(argv: list[str], *, description: str) -> str:
+ try:
+ result = subprocess.run(argv, check=True, capture_output=True, text=True) # nosec B603
+ except (OSError, subprocess.CalledProcessError) as exc:
+ raise ValueError(f"cannot determine {description}: {exc}") from exc
+ first_line = result.stdout.splitlines()[0].strip() if result.stdout.splitlines() else ""
+ if not first_line:
+ raise ValueError(f"cannot determine {description}: command produced no output")
+ return first_line
+
+
+def _operating_system_name() -> str:
+ try:
+ release = platform.freedesktop_os_release()
+ except OSError:
+ return platform.platform()
+ return release.get("PRETTY_NAME") or release.get("NAME") or platform.platform()
+
+
+def generate(
+ f2py_path: Path,
+ x2py_path: Path,
+ page_path: Path,
+ chart_path: Path,
+ *,
+ operating_system: str,
+ compiler_version: str,
+ commit: str,
+ recorded_date: date | None = None,
+) -> PerformanceSnapshot:
+ """Generate the marked page sections and SVG from paired results."""
+ snapshot = load_snapshot(
+ f2py_path,
+ x2py_path,
+ operating_system=operating_system,
+ compiler_version=compiler_version,
+ commit=commit,
+ recorded_date=recorded_date,
+ )
+ original_page = page_path.read_text(encoding="utf-8")
+ generated_page = render_page(original_page, snapshot)
+ page_path.write_text(generated_page, encoding="utf-8")
+ chart_path.parent.mkdir(parents=True, exist_ok=True)
+ chart_path.write_text(render_chart(snapshot), encoding="utf-8")
+ return snapshot
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--f2py-results", type=Path, default=DEFAULT_F2PY_RESULTS)
+ parser.add_argument("--x2py-results", type=Path, default=DEFAULT_X2PY_RESULTS)
+ parser.add_argument("--page", type=Path, default=DEFAULT_PAGE)
+ parser.add_argument("--chart", type=Path, default=DEFAULT_CHART)
+ parser.add_argument("--compiler", default="gfortran")
+ parser.add_argument("--compiler-version")
+ parser.add_argument("--operating-system")
+ parser.add_argument("--commit")
+ parser.add_argument("--recorded-date", type=date.fromisoformat)
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(list(argv or sys.argv[1:]))
+ try:
+ compiler_version = args.compiler_version or _command_first_line(
+ [args.compiler, "--version"],
+ description="Fortran compiler version",
+ )
+ operating_system = args.operating_system or _operating_system_name()
+ commit = args.commit or _command_first_line(
+ ["git", "rev-parse", "HEAD"],
+ description="x2py revision",
+ )
+ snapshot = generate(
+ args.f2py_results,
+ args.x2py_results,
+ args.page,
+ args.chart,
+ operating_system=operating_system,
+ compiler_version=compiler_version,
+ commit=commit,
+ recorded_date=args.recorded_date,
+ )
+ except (OSError, ValueError) as exc:
+ print(f"cannot generate Performance documentation: {exc}", file=sys.stderr)
+ return 2
+
+ print(
+ f"Generated Performance documentation from {len(snapshot.results)} benchmarks "
+ f"recorded on {snapshot.recorded_date.isoformat()}."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/x2py/contracts/__init__.py b/x2py/contracts/__init__.py
index 6bdacaa1d..e1f409cd2 100644
--- a/x2py/contracts/__init__.py
+++ b/x2py/contracts/__init__.py
@@ -203,7 +203,7 @@ def apply(target):
bind = _decorator
external = _decorator
-hold_gil = _decorator
+nogil = _decorator
native_call = _decorator
native_type = _decorator
overload = _decorator
@@ -293,7 +293,7 @@ def apply(target):
"WrappedType",
"bind",
"external",
- "hold_gil",
+ "nogil",
"native_call",
"native_type",
"overload",
diff --git a/x2py/semantics/models.py b/x2py/semantics/models.py
index 6f3579069..701ec9cb6 100644
--- a/x2py/semantics/models.py
+++ b/x2py/semantics/models.py
@@ -14,7 +14,7 @@
PYTHON_VALUE_MUTABILITY_METADATA = "python_value_mutability"
PYTHON_VALUE_IMMUTABLE = "immutable"
NATIVE_BY_VALUE_METADATA = "native_by_value"
-RUNTIME_HOLD_GIL_METADATA = "runtime_hold_gil"
+RUNTIME_RELEASE_GIL_METADATA = "runtime_release_gil"
RUNTIME_RETAIN_RESULT_OWNER_METADATA = "runtime_retain_result_owner"
RUNTIME_STATUS_ERROR_METADATA = "runtime_status_error"
RESOLVED_RUNTIME_STATUS_ERROR_POLICY_METADATA = "resolved_runtime_status_error_policy"
diff --git a/x2py/semantics/pyi2ir.py b/x2py/semantics/pyi2ir.py
index ba6b15339..b28af9373 100644
--- a/x2py/semantics/pyi2ir.py
+++ b/x2py/semantics/pyi2ir.py
@@ -36,7 +36,7 @@
PYTHON_VALUE_IMMUTABLE,
PYTHON_VALUE_MUTABILITY_METADATA,
PROTOTYPE_REF_METADATA,
- RUNTIME_HOLD_GIL_METADATA,
+ RUNTIME_RELEASE_GIL_METADATA,
RUNTIME_STATUS_ERROR_METADATA,
ProjectionMapping,
ProcedureOverloadSet,
@@ -105,7 +105,7 @@ class _Decorators:
native_type: dict[str, object] | None = None
external: bool = False
is_static: bool = False
- hold_gil: bool = False
+ release_gil: bool = False
error_status_policy: dict[str, object] | None = None
prototype: bool = False
@@ -284,7 +284,7 @@ def function_def(
native_name: str | None = None,
external: bool = False,
has_native_call: bool = False,
- hold_gil: bool = False,
+ release_gil: bool = False,
error_status_policy: dict[str, object] | None = None,
) -> SemanticFunction:
actual_projection = projection if projection is not None else []
@@ -296,8 +296,8 @@ def function_def(
metadata = {BIND_TARGET_METADATA: native_name} if native_name is not None else {}
if has_native_call:
metadata[NATIVE_PROJECTION_METADATA] = True
- if hold_gil:
- metadata[RUNTIME_HOLD_GIL_METADATA] = True
+ if release_gil:
+ metadata[RUNTIME_RELEASE_GIL_METADATA] = True
if error_status_policy is not None:
metadata[RUNTIME_STATUS_ERROR_METADATA] = dict(error_status_policy)
origin = self._origin(
@@ -365,7 +365,7 @@ def method_def(
class_name: str,
infer_passed_object: bool = True,
has_native_call: bool = False,
- hold_gil: bool = False,
+ release_gil: bool = False,
error_status_policy: dict[str, object] | None = None,
) -> SemanticMethod:
actual_projection = projection if projection is not None else []
@@ -402,8 +402,8 @@ def method_def(
),
)
self._restore_pass_projection(actual_projection, passed_object_position)
- if hold_gil:
- metadata[RUNTIME_HOLD_GIL_METADATA] = True
+ if release_gil:
+ metadata[RUNTIME_RELEASE_GIL_METADATA] = True
if error_status_policy is not None:
metadata[RUNTIME_STATUS_ERROR_METADATA] = dict(error_status_policy)
origin = self._origin(
@@ -485,7 +485,7 @@ def _apply_decorator(self, parsed: _Decorators, node: ast.expr, *, context: str)
"overload": self._apply_overload_decorator,
"bind": self._apply_bind_decorator,
"external": self._apply_external_decorator,
- "hold_gil": self._apply_hold_gil_decorator,
+ "nogil": self._apply_nogil_decorator,
"native_call": self._apply_native_call_decorator,
"native_type": self._apply_native_type_decorator,
"prototype": self._apply_prototype_decorator,
@@ -540,12 +540,12 @@ def _apply_bind_decorator(self, parsed: _Decorators, node: ast.expr, context: st
parsed.bind_target = self._required_string_decorator_argument(node, "bind")
@staticmethod
- def _apply_hold_gil_decorator(parsed: _Decorators, node: ast.expr, context: str) -> None:
+ def _apply_nogil_decorator(parsed: _Decorators, node: ast.expr, context: str) -> None:
if isinstance(node, ast.Call):
- raise ValueError("hold_gil does not accept arguments")
- if parsed.hold_gil:
- raise ValueError(f"Duplicate {context} hold_gil decorator")
- parsed.hold_gil = True
+ raise ValueError("nogil does not accept arguments")
+ if parsed.release_gil:
+ raise ValueError(f"Duplicate {context} nogil decorator")
+ parsed.release_gil = True
@staticmethod
def _apply_external_decorator(parsed: _Decorators, node: ast.expr, context: str) -> None:
@@ -715,7 +715,7 @@ def _validated_overload_candidate(
candidate = deepcopy(target)
candidate.visibility = declaration.visibility
candidate.metadata[OVERLOAD_TARGET_METADATA] = target.name
- for key in (RUNTIME_HOLD_GIL_METADATA, RUNTIME_STATUS_ERROR_METADATA):
+ for key in (RUNTIME_RELEASE_GIL_METADATA, RUNTIME_STATUS_ERROR_METADATA):
if key in declaration.metadata:
candidate.metadata[key] = deepcopy(declaration.metadata[key])
@@ -2654,7 +2654,7 @@ def _visit_FunctionDef(self, node: ast.FunctionDef) -> None:
class_name=self.class_name,
infer_passed_object=decorators.overload_target is None,
has_native_call=decorators.has_native_call,
- hold_gil=decorators.hold_gil,
+ release_gil=decorators.release_gil,
error_status_policy=decorators.error_status_policy,
)
if node.name == "__init__" and decorators.bind_target is not None and decorators.overload_target is None:
@@ -2698,7 +2698,7 @@ def _visit_ClassDef(self, node: ast.ClassDef) -> None:
if (
decorators.has_native_call
or decorators.bind_target is not None
- or decorators.hold_gil
+ or decorators.release_gil
or decorators.error_status_policy is not None
or decorators.external
):
@@ -2758,7 +2758,7 @@ def _visit_ClassDef(self, node: ast.ClassDef) -> None:
if (
decorators.has_native_call
or decorators.bind_target is not None
- or decorators.hold_gil
+ or decorators.release_gil
or decorators.error_status_policy is not None
or decorators.external
):
@@ -2795,7 +2795,7 @@ def _visit_FunctionDef(self, node: ast.FunctionDef) -> None:
native_name=decorators.bind_target,
external=decorators.external,
has_native_call=decorators.has_native_call,
- hold_gil=decorators.hold_gil,
+ release_gil=decorators.release_gil,
error_status_policy=decorators.error_status_policy,
)
if decorators.overload_target is not None:
diff --git a/x2py/semantics/wrapper_policy.py b/x2py/semantics/wrapper_policy.py
index eb72e18e0..9d92ddee2 100644
--- a/x2py/semantics/wrapper_policy.py
+++ b/x2py/semantics/wrapper_policy.py
@@ -1168,7 +1168,7 @@ class FunctionWrapperPolicy:
external_declaration: ExternalDeclarationMode
native_module: str | None
native_is_subroutine: bool
- hold_gil: bool
+ release_gil: bool
status_error: NativeStatusErrorPolicy | None
class_call: ClassMethodPolicy | None
module_export: bool
@@ -2409,8 +2409,7 @@ def build_function_wrapper_policy(
),
native_module=native_module,
native_is_subroutine=_native_is_subroutine(function),
- hold_gil=bool(function.metadata.get(models.RUNTIME_HOLD_GIL_METADATA))
- or any(argument.callback is not None for argument in arguments),
+ release_gil=bool(function.metadata.get(models.RUNTIME_RELEASE_GIL_METADATA)),
status_error=status_error,
class_call=class_call,
module_export=(
diff --git a/x2py/wrapper_codegen/c/binding.py b/x2py/wrapper_codegen/c/binding.py
index 961018e91..b5d071226 100644
--- a/x2py/wrapper_codegen/c/binding.py
+++ b/x2py/wrapper_codegen/c/binding.py
@@ -70,6 +70,7 @@
)
from x2py.wrapper_codegen.naming import NativeSymbolNames
from x2py.wrapper_codegen.plan import (
+ ArrayHandoffPlan,
ArgumentTransferPlan,
CallbackHandoffPlan,
CallbackTransferPlan,
@@ -106,6 +107,7 @@ class _CArgumentNames:
extent_names: tuple[str, ...]
upper_bound_names: tuple[str, ...]
stride_names: tuple[str, ...]
+ dense_actual_name: str
runtime_rank_name: str
itemsize_name: str
polymorphic_name: str
@@ -6421,7 +6423,7 @@ def _lower_argument_required_array_storage(
self,
plan: ArgumentTransferPlan,
context: _CFunctionContext,
- ) -> tuple[CDeclaration | CExpressionStatement, ...]:
+ ) -> tuple[CDeclaration | CExpressionStatement | CIf | CFor, ...]:
"""Validate and borrow one completed ordinary NumPy array buffer."""
if plan.native_array_actual is not None:
return self._lower_argument_required_array_actual(plan, context)
@@ -6466,56 +6468,61 @@ def _ordinary_array_argument_declarations(
declarations.extend(CDeclaration(name, "int64_t", CodeExpression("0")) for name in names.upper_bound_names)
if array.stride_roles:
declarations.extend(CDeclaration(name, "int64_t", CodeExpression("1")) for name in names.stride_names)
+ declarations.extend(self._array_dense_actual_declarations(array, names))
if array.runtime_rank_role is not None:
declarations.append(CDeclaration(names.runtime_rank_name, "int64_t", CodeExpression("0")))
if array.itemsize_role is not None:
declarations.append(CDeclaration(names.itemsize_name, "int64_t", CodeExpression("0")))
return tuple(declarations)
+ @staticmethod
+ def _array_dense_actual_declarations(
+ array: ArrayHandoffPlan,
+ names: _CArgumentNames,
+ ) -> tuple[CDeclaration, ...]:
+ """Declare the planned dense-actual selector when its role exists."""
+ if array.dense_actual_role is None:
+ return ()
+ return (CDeclaration(names.dense_actual_name, "int", CodeExpression("0")),)
+
# Native-handle actuals reuse the ordinary array-buffer ABI.
def _lower_argument_required_array_actual(
self,
plan: ArgumentTransferPlan,
context: _CFunctionContext,
- ) -> tuple[CDeclaration | CExpressionStatement, ...]:
- """Pack an ndarray or native handle through the shared runtime helper."""
+ ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]:
+ """Validate ndarrays directly and reserve runtime packing for native handles."""
actual = plan.native_array_actual
array = plan.array
if actual is None or array is None:
raise ValueError(f"Array actual {plan.owner_path!r} is missing its completed policy")
names = context.arguments[plan.owner_path]
prefix = names.value_name
- nodes: list[CDeclaration | CExpressionStatement] = [
- CDeclaration(names.object_name, "PyObject *"),
- CDeclaration(names.value_name, "void *", CodeExpression("NULL")),
- *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in names.extent_names),
- *(
- CDeclaration(name, "int64_t", CodeExpression("0"))
- for name in names.upper_bound_names[: len(array.upper_bound_roles)]
- ),
- *(
- CDeclaration(name, "int64_t", CodeExpression("1"))
- for name in names.stride_names[: len(array.stride_roles)]
- ),
- *(
- (CDeclaration(names.runtime_rank_name, "int64_t", CodeExpression("0")),)
- if array.runtime_rank_role is not None
- else ()
- ),
- *(
- (CDeclaration(names.itemsize_name, "int64_t", CodeExpression("0")),)
- if array.itemsize_role is not None
- else ()
- ),
+ array_object = f"(PyArrayObject *){names.object_name}"
+ direct_nodes = (
+ self._array_type_and_rank_check(plan, names, array_object),
+ *self._array_layout_checks(plan, array_object),
+ *self._array_access_checks(plan, array_object),
+ *self._array_shape_checks(plan, context, array_object),
+ *self._array_extraction_nodes(plan, names, array_object),
+ )
+ handle_nodes = (
CDeclaration(f"{prefix}_runtime", "PyObject *", CodeExpression("NULL")),
CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")),
CDeclaration(f"{prefix}_shape", "PyObject *", CodeExpression("NULL")),
CDeclaration(f"{prefix}_layout", "PyObject *", CodeExpression("NULL")),
CDeclaration(f"{prefix}_packed", "PyObject *", CodeExpression("NULL")),
- ]
- nodes.extend(self._native_array_actual_call_nodes(plan, context, names))
- nodes.extend(self._native_array_actual_unpack_nodes(plan, names))
- return tuple(nodes)
+ *self._native_array_actual_call_nodes(plan, context, names),
+ *self._native_array_actual_unpack_nodes(plan, names),
+ )
+ return (
+ *self._ordinary_array_argument_declarations(plan, names),
+ CIf(
+ CodeExpression(f"PyArray_Check({names.object_name})"),
+ body=direct_nodes,
+ else_body=handle_nodes,
+ ),
+ )
def _native_array_actual_call_nodes(
self,
@@ -6730,7 +6737,7 @@ def _array_type_and_rank_check(
CodeExpression(
f"if (!PyArray_Check({names.object_name}) || PyArray_TYPE({array}) != {numpy_type} || "
f'{rank_check}) {{ PyErr_Format(PyExc_TypeError, "Expected a compatible numpy.ndarray of '
- f"type {python_type} for argument {plan.binding.python_name}. Received \", "
+ f"dtype {python_type} for argument {plan.binding.python_name}. Received \", "
f"Py_TYPE({names.object_name})->tp_name); return NULL; }}"
)
)
@@ -6795,12 +6802,13 @@ def _array_layout_checks(
else:
condition = f"!(PyArray_IS_C_CONTIGUOUS({array}) || PyArray_IS_F_CONTIGUOUS({array}))"
expected_order = "C or F"
+ contiguous_requirement = "; array must be contiguous" if handoff.contiguous is True else ""
return (
CExpressionStatement(
CodeExpression(
f"if ({condition}) {{ PyErr_SetString(PyExc_TypeError, "
f'"Argument {plan.binding.python_name} has incompatible layout; expected ordering '
- f'({expected_order})"); return NULL; }}'
+ f'({expected_order}){contiguous_requirement}"); return NULL; }}'
)
),
)
@@ -6904,7 +6912,7 @@ def _array_extraction_nodes(
plan: ArgumentTransferPlan,
names: _CArgumentNames,
array: str,
- ) -> tuple[CExpressionStatement, ...]:
+ ) -> tuple[CExpressionStatement | CIf | CFor, ...]:
"""Extract only the ABI fields named by the editable handoff plan."""
handoff = plan.array
if handoff is None:
@@ -6930,6 +6938,7 @@ def _array_extraction_nodes(
if handoff.flatten_python_storage:
nodes.extend(self._flat_array_extraction_nodes(handoff, names, array))
return tuple(nodes)
+ nodes.extend(self._array_dense_actual_extraction_nodes(handoff, names, array))
active_rank = 15 if handoff.rank is None else handoff.rank
for axis in range(active_rank):
guard = f"if (PyArray_NDIM({array}) > {axis}) " if handoff.rank is None else ""
@@ -6938,10 +6947,34 @@ def _array_extraction_nodes(
CodeExpression(f"{guard}{names.extent_names[axis]} = (int64_t)PyArray_DIM({array}, {axis})")
)
)
- if handoff.contiguous is False:
- nodes.extend(self._strided_array_extraction_nodes(handoff.rank, names, array))
+ nodes.extend(self._array_strided_extraction_dispatch_nodes(handoff, names, array))
return tuple(nodes)
+ @staticmethod
+ def _array_dense_actual_extraction_nodes(
+ handoff: ArrayHandoffPlan,
+ names: _CArgumentNames,
+ array: str,
+ ) -> tuple[CExpressionStatement, ...]:
+ """Extract the runtime dense-actual selector named by the plan."""
+ if handoff.dense_actual_role is None:
+ return ()
+ return (CExpressionStatement(CodeExpression(f"{names.dense_actual_name} = PyArray_IS_F_CONTIGUOUS({array})")),)
+
+ def _array_strided_extraction_dispatch_nodes(
+ self,
+ handoff: ArrayHandoffPlan,
+ names: _CArgumentNames,
+ array: str,
+ ) -> tuple[CExpressionStatement | CIf, ...]:
+ """Dispatch general stride extraction only when the selected actual needs it."""
+ if handoff.contiguous is not False:
+ return ()
+ strided_nodes = self._strided_array_extraction_nodes(handoff.rank, names, array)
+ if handoff.dense_actual_role is None:
+ return strided_nodes
+ return (CIf(CodeExpression(f"!{names.dense_actual_name}"), body=strided_nodes),)
+
def _flat_array_extraction_nodes(
self,
handoff,
@@ -9028,9 +9061,9 @@ def _lower_native_call(
call: CExpressionStatement,
) -> tuple[CAllowThreadsBegin | CAllowThreadsEnd | CExpressionStatement, ...]:
"""Dispatch the completed GIL envelope to directly named methods."""
- if plan.binding.hold_gil:
- return self._lower_native_call_held(call)
- return self._lower_native_call_released(call)
+ if plan.binding.release_gil:
+ return self._lower_native_call_released(call)
+ return self._lower_native_call_held(call)
def _lower_native_call_held(self, call: CExpressionStatement) -> tuple[CExpressionStatement, ...]:
"""Emit one native bridge call while retaining the GIL."""
@@ -9396,6 +9429,7 @@ def _argument_context_names(self, argument: ArgumentTransferPlan) -> _CArgumentN
tuple(f"{local}_extent_{axis}" for axis in range(rank)),
tuple(f"{local}_upper_bound_{axis}" for axis in range(rank)),
tuple(f"{local}_stride_{axis}" for axis in range(rank)),
+ f"{local}_dense_actual",
f"{local}_rank",
f"{local}_itemsize",
f"{local}_polymorphic",
@@ -9944,6 +9978,8 @@ def _array_bridge_call_arguments(
arguments.append(names.runtime_rank_name)
if handoff.itemsize_role is not None:
arguments.append(names.itemsize_name)
+ if handoff.dense_actual_role is not None:
+ arguments.append(names.dense_actual_name)
arguments.extend(names.extent_names)
arguments.extend(self._selected_array_axis_names(names.upper_bound_names, handoff.upper_bound_roles))
arguments.extend(self._selected_array_axis_names(names.stride_names, handoff.stride_roles))
@@ -10173,6 +10209,8 @@ def _array_bridge_argument_parameters(
parameters.append(CParameter(f"{name}_rank", "int64_t"))
if handoff.itemsize_role is not None:
parameters.append(CParameter(f"{name}_itemsize", "int64_t"))
+ if handoff.dense_actual_role is not None:
+ parameters.append(CParameter(f"{name}_dense_actual", "int"))
parameters.extend(self._array_bridge_axis_parameters(name, "extent", len(handoff.extent_roles)))
parameters.extend(self._array_bridge_axis_parameters(name, "upper_bound", len(handoff.upper_bound_roles)))
parameters.extend(self._array_bridge_axis_parameters(name, "stride", len(handoff.stride_roles)))
diff --git a/x2py/wrapper_codegen/fortran/bridge.py b/x2py/wrapper_codegen/fortran/bridge.py
index 8e0c3dd2c..f45d2beeb 100644
--- a/x2py/wrapper_codegen/fortran/bridge.py
+++ b/x2py/wrapper_codegen/fortran/bridge.py
@@ -2688,6 +2688,11 @@ def _lower_argument_array_buffer(
if array.itemsize_role is not None
else ()
),
+ *(
+ (FortranParameter(f"{name}_dense_actual", "integer(c_int)", ("value",)),)
+ if array.dense_actual_role is not None
+ else ()
+ ),
*(
FortranParameter(f"{name}_extent_{axis}", "integer(c_int64_t)", ("value",))
for axis in range(len(array.extent_roles))
@@ -3275,7 +3280,7 @@ def _prepare_present_associated_view(
if plan.bridge.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR:
return ()
if plan.bridge.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER:
- return (self._array_pointer_initializer(plan),)
+ return self._array_pointer_initializer_nodes(plan)
if plan.bridge.optional_mode is OptionalMode.NULLABLE_VALUE:
return (
FortranCall(
@@ -3478,13 +3483,25 @@ def _array_declarations(self, plan: FunctionPlan) -> tuple[FortranDeclaration, .
if array.rank is None:
declarations.extend(self._assumed_rank_array_declarations(argument))
else:
+ attributes = ["pointer"]
+ if array.contiguous is True:
+ attributes.append("contiguous")
+ attributes.append(self._array_dimension_attribute(array.rank))
declarations.append(
FortranDeclaration(
self._array_pointer_name(argument),
self._array_element_fortran_type(argument),
- ("pointer", self._array_dimension_attribute(array.rank)),
+ tuple(attributes),
)
)
+ if array.dense_actual_role is not None:
+ declarations.append(
+ FortranDeclaration(
+ argument.bridge.native_name.lower(),
+ self._array_element_fortran_type(argument),
+ ("pointer", self._array_dimension_attribute(array.rank)),
+ )
+ )
if argument.array_writeback_abi is ArrayWritebackABI.LOGICAL_LOW_BIT_INT8:
declarations.append(
FortranDeclaration(
@@ -3564,7 +3581,7 @@ def _logical_array_writeback_for_rank(
def _logical_array_byte_pointer_name(argument: ArgumentTransferPlan) -> str:
return f"{argument.bridge.native_name.lower()}_logical_bytes"
- def _array_initializers(self, plan: FunctionPlan) -> tuple[FortranCall, ...]:
+ def _array_initializers(self, plan: FunctionPlan) -> tuple[FortranCall | FortranIf, ...]:
"""Associate each completed ordinary array data/extent handoff."""
initializers = []
for argument in plan.arguments:
@@ -3574,7 +3591,7 @@ def _array_initializers(self, plan: FunctionPlan) -> tuple[FortranCall, ...]:
continue
if argument.array is not None and argument.array.rank is None:
continue
- initializers.append(self._array_pointer_initializer(argument))
+ initializers.extend(self._array_pointer_initializer_nodes(argument))
return tuple(initializers)
def _raw_array_address_declarations(self, plan: FunctionPlan) -> tuple[FortranDeclaration, ...]:
@@ -3647,6 +3664,30 @@ def _array_pointer_initializer(self, argument: ArgumentTransferPlan) -> FortranC
),
)
+ def _array_pointer_initializer_nodes(
+ self,
+ argument: ArgumentTransferPlan,
+ ) -> tuple[FortranCall | FortranIf, ...]:
+ """Associate base storage and select the planned dense or strided view."""
+ association = self._array_pointer_initializer(argument)
+ array = argument.array
+ if array is None or array.dense_actual_role is None:
+ return (association,)
+ name = argument.bridge.native_name.lower()
+ return (
+ association,
+ FortranIf(
+ CodeExpression(f"{name}_dense_actual /= 0_c_int"),
+ body=(FortranPointerAssignment(name, CodeExpression(f"{name}_base")),),
+ else_body=(
+ FortranPointerAssignment(
+ name,
+ CodeExpression(self._strided_array_section_expression(argument)),
+ ),
+ ),
+ ),
+ )
+
def _assumed_rank_array_declarations(
self,
argument: ArgumentTransferPlan,
@@ -3654,11 +3695,12 @@ def _assumed_rank_array_declarations(
"""Declare one readable typed pointer local for every supported runtime rank."""
name = argument.bridge.native_name.lower()
element_type = self._array_element_fortran_type(argument)
+ attributes = ("pointer", "contiguous") if argument.array.contiguous is True else ("pointer",)
return tuple(
FortranDeclaration(
f"{name}_rank_{rank}",
element_type,
- ("pointer", self._array_dimension_attribute(rank)),
+ (*attributes, self._array_dimension_attribute(rank)),
)
for rank in range(1, 16)
)
@@ -3697,6 +3739,17 @@ def _array_native_argument_expression(self, argument: ArgumentTransferPlan) -> s
pointer_name = self._array_pointer_name(argument)
if array.contiguous is not False:
return pointer_name
+ if array.dense_actual_role is not None:
+ return name
+ return self._strided_array_section_expression(argument)
+
+ def _strided_array_section_expression(self, argument: ArgumentTransferPlan) -> str:
+ """Render one positive-stride section from completed layout roles."""
+ array = argument.array
+ if array is None or array.rank is None:
+ raise ValueError(f"Strided array argument {argument.owner_path!r} requires a concrete rank")
+ name = argument.bridge.native_name.lower()
+ pointer_name = self._array_pointer_name(argument)
slices = (f"1:{name}_upper_bound_{axis} + 1:{name}_stride_{axis}" for axis in range(array.rank))
return f"{pointer_name}({', '.join(slices)})"
diff --git a/x2py/wrapper_codegen/generator.py b/x2py/wrapper_codegen/generator.py
index e5cc3965e..9c85fd380 100644
--- a/x2py/wrapper_codegen/generator.py
+++ b/x2py/wrapper_codegen/generator.py
@@ -2280,6 +2280,7 @@ def _native_array_handle_buffer_role_diagnostics(
*array.extent_roles,
*array.upper_bound_roles,
*array.stride_roles,
+ array.dense_actual_role,
array.runtime_rank_role,
array.itemsize_role,
)
@@ -2663,6 +2664,7 @@ def _raw_array_buffer_role_diagnostics(
*array.extent_roles,
*array.upper_bound_roles,
*array.stride_roles,
+ array.dense_actual_role,
array.runtime_rank_role,
array.itemsize_role,
)
@@ -2765,8 +2767,22 @@ def _array_layout_role_diagnostics(
*self._array_order_diagnostics(plan),
*self._array_axis_mode_diagnostics(plan),
*self._array_stride_role_diagnostics(plan),
+ *self._array_dense_actual_role_diagnostics(plan),
)
+ def _array_dense_actual_role_diagnostics(
+ self,
+ plan: ArgumentTransferPlan,
+ ) -> tuple[WrapperPlanDiagnostic, ...]:
+ """Require the planned runtime selector exactly on concrete strided inputs."""
+ array = plan.array
+ if array is None:
+ return ()
+ expected = f"{plan.owner_path}:dense-actual" if array.contiguous is False and array.rank is not None else None
+ if array.dense_actual_role != expected:
+ return (self._diagnostic(plan.owner_path, "invalid-array-dense-actual-role", array.dense_actual_role),)
+ return ()
+
def _array_order_diagnostics(self, plan: ArgumentTransferPlan) -> tuple[WrapperPlanDiagnostic, ...]:
"""Validate the completed ordinary-array order marker."""
array = plan.array
diff --git a/x2py/wrapper_codegen/nodes.py b/x2py/wrapper_codegen/nodes.py
index 7f7938cd5..f431714ad 100644
--- a/x2py/wrapper_codegen/nodes.py
+++ b/x2py/wrapper_codegen/nodes.py
@@ -167,8 +167,8 @@ class CIf(StageRecord):
"""C conditional with recursively printable statement bodies."""
condition: CodeExpression
- body: tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...] = ()
- else_body: tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...] = ()
+ body: tuple[CDeclaration | CExpressionStatement | CIf | CFor | CReturn, ...] = ()
+ else_body: tuple[CDeclaration | CExpressionStatement | CIf | CFor | CReturn, ...] = ()
@dataclass
diff --git a/x2py/wrapper_codegen/plan.py b/x2py/wrapper_codegen/plan.py
index 459d1f4b6..0c0a38fa0 100644
--- a/x2py/wrapper_codegen/plan.py
+++ b/x2py/wrapper_codegen/plan.py
@@ -299,6 +299,7 @@ class ArrayHandoffPlan(StageRecord):
extent_reference_roles: tuple[tuple[str, ...], ...] = ()
upper_bound_roles: tuple[str, ...] = ()
stride_roles: tuple[str, ...] = ()
+ dense_actual_role: str | None = None
runtime_rank_role: str | None = None
itemsize_role: str | None = None
@@ -475,7 +476,7 @@ class BindingFunctionPlan(StageRecord):
python_name: str
docstring: str
- hold_gil: bool
+ release_gil: bool
status_error: BindingStatusErrorPlan | None
public: bool = True
diff --git a/x2py/wrapper_codegen/planner.py b/x2py/wrapper_codegen/planner.py
index eee953b0d..a99d566a8 100644
--- a/x2py/wrapper_codegen/planner.py
+++ b/x2py/wrapper_codegen/planner.py
@@ -941,7 +941,7 @@ def _function_plan(
results,
status_error=status_error,
),
- hold_gil=policy.hold_gil,
+ release_gil=policy.release_gil,
status_error=status_error,
public=public,
),
@@ -1346,10 +1346,12 @@ def _result_scalar_descriptor_plan(
def _native_slot_plan(self, slot: NativeCallSlotPolicy, role: str) -> NativeCallSlotPlan:
"""Return one shared ABI slot without selecting backend behavior."""
+ include_buffer_roles = slot.native_barrier_action is NativeBarrierAction.PASS_ARRAY_BUFFER
array = self._array_plan(
slot.array,
slot.owner_path,
- include_buffer_roles=slot.native_barrier_action is NativeBarrierAction.PASS_ARRAY_BUFFER,
+ include_buffer_roles=include_buffer_roles,
+ include_dense_actual_role=include_buffer_roles and slot.python_position is not None,
)
return NativeCallSlotPlan(
owner_path=slot.owner_path,
@@ -1609,6 +1611,7 @@ def _array_plan(
owner_path: str,
*,
include_buffer_roles: bool = True,
+ include_dense_actual_role: bool = False,
) -> ArrayHandoffPlan | None:
"""Mechanically add only the ABI roles selected by completed transport."""
if policy is None:
@@ -1634,10 +1637,26 @@ def _array_plan(
extent_reference_roles=self._array_extent_reference_roles(owner_path, policy.extent_references),
upper_bound_roles=self._array_layout_roles(owner_path, abi_rank, policy.contiguous, "upper-bound"),
stride_roles=self._array_layout_roles(owner_path, abi_rank, policy.contiguous, "stride"),
+ dense_actual_role=self._array_dense_actual_role(
+ policy,
+ owner_path,
+ include_dense_actual_role,
+ ),
runtime_rank_role=runtime_rank_role,
itemsize_role=itemsize_role,
)
+ @staticmethod
+ def _array_dense_actual_role(
+ policy: ArrayHandoffPolicy,
+ owner_path: str,
+ enabled: bool,
+ ) -> str | None:
+ """Name the dense-view selector only for concrete strided inputs."""
+ if not enabled or policy.rank is None or policy.contiguous is not False:
+ return None
+ return f"{owner_path}:dense-actual"
+
def _array_transport_roles(
self,
policy: ArrayHandoffPolicy,
diff --git a/x2py/wrapper_codegen/printers/pyi_printer.py b/x2py/wrapper_codegen/printers/pyi_printer.py
index 360d56d67..15e324cbf 100644
--- a/x2py/wrapper_codegen/printers/pyi_printer.py
+++ b/x2py/wrapper_codegen/printers/pyi_printer.py
@@ -34,7 +34,7 @@
PYTHON_VALUE_IMMUTABLE,
PYTHON_VALUE_MUTABILITY_METADATA,
PROTOTYPE_REF_METADATA,
- RUNTIME_HOLD_GIL_METADATA,
+ RUNTIME_RELEASE_GIL_METADATA,
RUNTIME_STATUS_ERROR_METADATA,
ProjectionMapping,
ProcedureOverloadSet,
@@ -1653,8 +1653,8 @@ def _decorators(self, func: SemanticFunction, *, indent: str = "", emitted_name:
)
if isinstance(policy := func.metadata.get(RUNTIME_STATUS_ERROR_METADATA), dict):
decorators.append(f"{indent}{self._raises(policy)}")
- if func.metadata.get(RUNTIME_HOLD_GIL_METADATA):
- decorators.append(f"{indent}@{self._contract('hold_gil')}")
+ if func.metadata.get(RUNTIME_RELEASE_GIL_METADATA):
+ decorators.append(f"{indent}@{self._contract('nogil')}")
if not decorators:
return ""
return "\n".join(decorators) + "\n"
diff --git a/x2py/wrapper_codegen/printers/source_printers.py b/x2py/wrapper_codegen/printers/source_printers.py
index 2e1298b66..79c72efdd 100644
--- a/x2py/wrapper_codegen/printers/source_printers.py
+++ b/x2py/wrapper_codegen/printers/source_printers.py
@@ -304,12 +304,142 @@ class FortranSourcePrinter(ClassVisitor):
"""Print isolated Fortran source nodes."""
_LINE_LIMIT = 112
+ _MAX_LINE_LENGTH = 132
def doprint(self, node: object) -> str:
"""Render one isolated Fortran backend node."""
if isinstance(node, StageRecord):
node.freeze()
- return self.visit(node)
+ source = self._format_line_lengths(self.visit(node))
+ self._validate_line_lengths(source)
+ return source
+
+ def _format_line_lengths(self, source: str) -> str:
+ """Wrap overlong free-form lines at syntax-safe token boundaries."""
+ lines = []
+ for line in source.splitlines():
+ lines.extend(self._wrap_rendered_line(line))
+ return "\n".join(lines)
+
+ def _wrap_rendered_line(self, line: str) -> tuple[str, ...]:
+ """Add free-form continuations without splitting tokens or literals."""
+ if len(line) <= self._MAX_LINE_LENGTH:
+ return (line,)
+ indentation = line[: len(line) - len(line.lstrip())]
+ continuation_prefix = f"{indentation} & "
+ prefix = indentation
+ remaining = line[len(indentation) :]
+ continued_quote = None
+ wrapped = []
+ while len(prefix) + len(remaining) > self._MAX_LINE_LENGTH:
+ budget = self._MAX_LINE_LENGTH - len(prefix) - len(" &")
+ split = self._safe_fortran_break(remaining, budget, initial_quote=continued_quote)
+ if split is None:
+ return (*wrapped, f"{prefix}{remaining}")
+ position, continued_quote = split
+ if continued_quote is None:
+ piece = remaining[:position].rstrip()
+ remaining = remaining[position:].lstrip()
+ else:
+ piece = remaining[:position]
+ remaining = remaining[position:]
+ if not piece or not remaining:
+ return (*wrapped, f"{prefix}{piece}{remaining}")
+ trailing = "&" if continued_quote is not None else " &"
+ wrapped.append(f"{prefix}{piece}{trailing}")
+ prefix = f"{indentation}&" if continued_quote is not None else continuation_prefix
+ wrapped.append(f"{prefix}{remaining}")
+ return tuple(wrapped)
+
+ @staticmethod
+ def _safe_fortran_break(
+ text: str,
+ budget: int,
+ *,
+ initial_quote: str | None = None,
+ ) -> tuple[int, str | None] | None:
+ """Find the preferred safe code or character-literal continuation."""
+ literal_quotes = FortranSourcePrinter._fortran_literal_quotes(text, initial_quote=initial_quote)
+ literal_positions = set(literal_quotes)
+ window = text[: budget + 1]
+ if FortranSourcePrinter._has_fortran_comment(text, literal_positions):
+ return None
+ candidates = FortranSourcePrinter._fortran_break_candidates(window, literal_positions)
+ position = max((candidate for candidate in candidates if 0 < candidate <= budget), default=None)
+ if position is not None:
+ return position, None
+ return FortranSourcePrinter._fortran_literal_break(text, budget, literal_quotes)
+
+ @staticmethod
+ def _fortran_literal_quotes(text: str, *, initial_quote: str | None = None) -> dict[int, str]:
+ """Map character offsets protected by Fortran literals to their quote."""
+ positions = {}
+ quote = initial_quote
+ index = 0
+ while index < len(text):
+ character = text[index]
+ if quote is None:
+ if character in {"'", '"'}:
+ quote = character
+ positions[index] = quote
+ index += 1
+ continue
+ positions[index] = quote
+ if character == quote and index + 1 < len(text) and text[index + 1] == quote:
+ positions[index + 1] = quote
+ index += 2
+ continue
+ if character == quote:
+ quote = None
+ index += 1
+ return positions
+
+ @staticmethod
+ def _fortran_literal_break(
+ text: str,
+ budget: int,
+ literal_quotes: dict[int, str],
+ ) -> tuple[int, str] | None:
+ """Find a character-literal continuation that preserves its exact value."""
+ candidates = []
+ for position in range(1, min(len(text), budget + 1)):
+ quote = literal_quotes.get(position)
+ if quote is None or literal_quotes.get(position - 1) != quote:
+ continue
+ if text[position - 1 : position + 1] == quote * 2:
+ continue
+ candidates.append(position)
+ if not candidates:
+ return None
+ position = max(candidates)
+ return position, literal_quotes[position]
+
+ @staticmethod
+ def _has_fortran_comment(text: str, literal_positions: set[int]) -> bool:
+ """Identify a comment marker that is not protected by a literal."""
+ return any(character == "!" and index not in literal_positions for index, character in enumerate(text))
+
+ @staticmethod
+ def _fortran_break_candidates(text: str, literal_positions: set[int]) -> tuple[int, ...]:
+ """Collect token boundaries that preserve free-form statement syntax."""
+ candidates = []
+ for index, character in enumerate(text):
+ if index in literal_positions:
+ continue
+ if character == ",":
+ candidates.append(index + 1)
+ elif character.isspace():
+ candidates.append(index)
+ return tuple(candidates)
+
+ def _validate_line_lengths(self, source: str) -> None:
+ """Reject generated free-form source that a standard compiler truncates."""
+ for line_number, line in enumerate(source.splitlines(), start=1):
+ if len(line) > self._MAX_LINE_LENGTH:
+ raise ValueError(
+ f"Generated Fortran line {line_number} has {len(line)} columns; "
+ f"the free-form limit is {self._MAX_LINE_LENGTH}: {line}"
+ )
def _visit_FortranModule(self, node: FortranModule) -> str:
"""Render a complete Fortran module."""
@@ -386,18 +516,22 @@ def _visit_FortranTypeDefinition(self, node: FortranTypeDefinition) -> str:
def _visit_FortranAssignment(self, node: FortranAssignment) -> str:
"""Render one Fortran assignment."""
- rendered = f"{node.target} = {node.expression.text}"
+ return self._continued_assignment(node.target, "=", node.expression.text)
+
+ def _visit_FortranPointerAssignment(self, node: FortranPointerAssignment) -> str:
+ """Render one Fortran pointer association."""
+ return self._continued_assignment(node.target, "=>", node.expression.text)
+
+ def _continued_assignment(self, target: str, operator: str, expression: str) -> str:
+ """Wrap a long assignment whose expression has parenthesized items."""
+ rendered = f"{target} {operator} {expression}"
if len(rendered) <= self._LINE_LIMIT:
return rendered
- call = self._parenthesized_items(node.expression.text, minimum_items=1)
+ call = self._parenthesized_items(expression, minimum_items=1)
if call is None:
return rendered
function_name, arguments = call
- return self._continued_call(f"{node.target} = {function_name}(", arguments)
-
- def _visit_FortranPointerAssignment(self, node: FortranPointerAssignment) -> str:
- """Render one Fortran pointer association."""
- return f"{node.target} => {node.expression.text}"
+ return self._continued_call(f"{target} {operator} {function_name}(", arguments)
def _visit_FortranNullify(self, node: FortranNullify) -> str:
"""Render one pointer nullification statement."""