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 @@ + + x2py performance relative to f2py + + Relative speed across 13 benchmarks. Values above one indicate x2py is faster. x2py is faster in 8 benchmarks. + + + + x2py relative performance + f2py time ÷ x2py time · farther right means faster x2py calls + + + + + + + + + + 0.8× + 1.0× + 1.2× + 1.4× + 1.5× + 1.0× equal + + + Empty call + + + 1.07× + Add scalars + + + 0.96× + Increment vector · n=1 + + + 1.38× + Increment vector · n=16 + + + 1.33× + Increment vector · n=1,024 + + + 0.93× + Increment vector · n=1,000,000 + + + 1.008× + Sum matrix · 4×4 + + + 1.12× + Sum matrix · 32×32 + + + 1.02× + Sum matrix · 256×256 + + + 1.003× + Sum matrix · 1,024×1,024 + + + 1.000× + Update matrix · 4×4 + + + 1.03× + Update matrix · 256×256 + + + 1.006× + Update matrix · 1,024×1,024 + + + 1.005× + + + + x2py faster + + f2py faster + + no significant difference + Geometric mean: x2py 1.06× faster + + + 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. + +
+
+ 1.06× + x2py geometric-mean speedup +
+
+ 8 of 13 + workloads faster with x2py +
+
+ 1.38× + best measured x2py speedup +
+
+ + +![Relative performance of x2py and f2py across 13 call, vector, and matrix workloads. Values above 1.0 mean x2py is faster.](assets/performance-comparison.svg) +{ .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, + "", + '
', + '
', + f" {geometric_value}", + f" {geometric_label}", + "
", + '
', + f" {len(snapshot.x2py_wins)} of {total}", + " workloads faster with x2py", + "
", + '
', + f" {best_value}", + f" {best_label}", + "
", + "
", + ] + ) + + +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'' + ), + ' x2py performance relative to f2py', + ' ', + ( + f" Relative speed across {len(snapshot.results)} benchmarks. Values above one indicate x2py is faster. " + f"x2py is faster in {len(snapshot.x2py_wins)} benchmarks." + ), + " ", + f' ', + ' ', + ' x2py relative performance', + ' f2py time ÷ x2py time · farther right means faster x2py calls', + ' ', + ] + for tick in _axis_ticks(lower, upper): + tick_x = x_position(tick) + lines.append(f' ') + lines.extend( + [ + " ", + ( + f' ' + ), + ' ', + ] + ) + for tick in _axis_ticks(lower, upper): + tick_x = x_position(tick) + lines.append(f' {tick:.1f}{TIMES}') + lines.append( + f' 1.0{TIMES} equal' + ) + lines.extend([" ", ' ']) + + colors = {"x2py": "#0f766e", "f2py": "#b45309", "parity": "#64748b"} + for index, result in enumerate(snapshot.results): + y = row_start + index * row_step + point = x_position(result.ratio) + color = colors[result.outcome] + anchor = "start" if point >= baseline else "end" + label_x = point + 14 if point >= baseline else point - 14 + lines.extend( + [ + f' {escape(result.chart_label)}', + ( + f' ' + ), + f' ', + ( + f' {_format_ratio(result.ratio)}' + ), + ] + ) + lines.extend( + [ + " ", + ' ', + f' ', + f' x2py faster', + f' ', + f' f2py faster', + f' ', + f' no significant difference', + ( + f' ' + f"{escape(_chart_geometric_label(snapshot))}" + ), + " ", + " ", + "", + "", + ] + ) + 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."""