From f7e65e5e285d64845b3f3f31f4dcebdce3c03791 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 00:23:29 +0100 Subject: [PATCH 01/47] Expose non-target module arrays as views and interoperable logicals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed-shape module arrays without `target`, and array fields of derived-type module variables, are now live NumPy views like their `target` counterparts. The address is captured in C through a new `prik_capture_address` in prik_binding.h, which does the job of `c_loc` where the standard forbids it. An earlier attempt captured through a generated Fortran `target` dummy; that is invalid under F2018 15.5.2.4, where such a pointer becomes undefined on return. Target and non-target allocatable module arrays now share the one Fortran descriptor path. Logical arrays wider than one byte report the integer dtype matching their element width instead of being rejected, so `logical :: flags(3)` and its allocatable, pointer and derived-field forms are live writable views. `logical(c_bool)` stays `numpy.bool_` — one byte holding zero or one is exactly that dtype — and scalars remain Python `bool` in every kind. With the widths agreeing, no conversion remains on any path. PRIK now also requests the option that makes a Fortran logical interoperable with C: `-standard-semantics` on Intel, `-Munixlogical` on PGI/NVIDIA. Without it those compilers store all bits set for `.true.`, so a `logical(c_bool)` reaching C holds 255 where `_Bool` is defined to hold 1. It is on by default and can be turned off with `--no-standard-logicals` for the one case that needs it: linking prebuilt Intel objects compiled without it, which cannot be linked against objects compiled with it because the option also changes Intel module symbol mangling. A `character` array handle is accepted wherever a numeric one is. Verified on gfortran 11 and ifx 2026.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 118 +++++++ docs/user/guide/building-shared-library.md | 7 + docs/user/guide/data-types.md | 86 ++++- docs/user/guide/wrapping-modules.md | 49 +++ docs/user/reference/cli-commands.md | 1 + docs/user/reference/pyi-format.md | 2 +- prik/cli.py | 13 + prik/codegen/c/binding.py | 196 +++++------ prik/codegen/docstrings.py | 34 +- prik/codegen/fortran/bridge.py | 303 ++++++++++-------- prik/codegen/nodes.py | 29 ++ prik/codegen/primitive_scalar_types.py | 52 ++- prik/compiler/compiler_profiles.py | 11 + prik/compiler/compilers.py | 14 + prik/pipeline/build.py | 34 ++ prik/pipeline/wrapper.py | 13 +- prik/planning/entrypoints.py | 18 +- prik/planning/models.py | 5 + prik/planning/planner.py | 1 + prik/policy/completion.py | 8 +- prik/policy/construction.py | 73 +++-- prik/policy/models.py | 19 ++ prik/runtime/handles.py | 34 +- prik/runtime/native_support/prik_binding.h | 22 ++ tests/fortran/_support/wrapper_build.py | 14 +- .../end_to_end/test_allocatable_handles.py | 102 ++++++ .../policy/test_allocatable_handle_policy.py | 13 +- .../codegen/test_array_output_identity.py | 36 ++- .../test_array_direct_entrypoint_routing.py | 2 +- .../test_logical_kind_array_conversions.py | 81 +++-- .../test_primitive_scalar_result_lowering.py | 3 +- .../test_derived_array_field_lowering.py | 89 +++++ .../end_to_end/test_module_derived_aliases.py | 70 +++- .../compiling/test_logical_interop_flags.py | 45 +++ .../test_logical_interop_option_routing.py | 82 +++++ .../cli/pipeline/test_argument_contract.py | 46 +++ .../runtime/test_native_support.py | 16 + .../codegen/test_native_handle_planning.py | 15 +- .../test_module_array_view_lowering.py | 163 ++++++++++ .../native/fmodule_array_forms_f90.f90 | 97 ++++++ .../end_to_end/test_logical_array_views.py | 110 +++++++ .../test_module_array_storage_forms.py | 187 +++++++++++ .../test_module_variables_and_state.py | 86 +++++ .../policy/test_module_variable_policy.py | 48 ++- .../policy/test_pointer_ownership_policy.py | 7 +- 45 files changed, 2113 insertions(+), 341 deletions(-) create mode 100644 tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py create mode 100644 tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py create mode 100644 tests/fortran/infrastructure/building/pipeline/test_logical_interop_option_routing.py create mode 100644 tests/fortran/modules/codegen/test_module_array_view_lowering.py create mode 100644 tests/fortran/modules/end_to_end/fixtures/native/fmodule_array_forms_f90.f90 create mode 100644 tests/fortran/modules/end_to_end/test_logical_array_views.py create mode 100644 tests/fortran/modules/end_to_end/test_module_array_storage_forms.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccb2696c..ccd1b73c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,124 @@ release tags add a leading `v` to the package version. ## Unreleased +- A `character` array handle is now accepted wherever a numeric one is. An + `AllocatableArray` of characters was refused at an ordinary character dummy + and had to be passed as `handle.to_numpy()`, while every other element type + converted directly. The handle machinery already supported it; the completed + policy simply excluded `String` from handle-as-actual acceptance. A character + actual is matched on its declared width as well as its kind, so a handle whose + elements are a different length is still refused. + +- PRIK now requests the compiler option that makes a Fortran `logical` + interoperable with C: `-standard-semantics` for Intel `ifx`/`ifort` and + `-Munixlogical` for PGI/NVIDIA. gfortran, Cray and IBM XL already use the + interoperable form. Without it those compilers store all bits set for + `.true.`, so a `logical(c_bool)` reaching C holds `255` where `_Bool` is + defined to hold `1`; C then miscounts it, and a four-element array of `.true.` + counted as `765` through Intel's own C compiler. If you override PRIK's + compiler flags, keep this one. + + The option is on by default and can be turned off with the new + `--no-standard-logicals` flag, or `standard_logicals=False` on + `build_fortran_extension`, `build_pyi_extension` and `build_c_extension`. + Turning it off is needed only when linking prebuilt Intel objects that were + themselves compiled without `-standard-semantics`: that option also changes + Intel module symbol mangling (`lib_MP_name_` rather than `lib_mp_name_`), so + objects built with and without it cannot be linked together, and mixing them + fails with an undefined reference rather than with anything about logicals. + Rebuilding the dependency with the option is the better fix. + +- **Breaking:** a Fortran `logical` array wider than one byte now reports the + integer dtype matching its element width — `int16`, `int32` or `int64` — + instead of being rejected. NumPy has no Boolean larger than a byte, so those + kinds could not be described at all before: `logical :: flags(3)` and every + allocatable, pointer and derived-field form of it were unsupported. They are + now live, writable views, read back with `.astype(bool)`. + + `logical(c_bool)` is unchanged and stays `numpy.bool_`: one byte holding zero + or one is exactly what that dtype describes. Logical scalars are unchanged + too, in every kind — they cross by value and remain Python `bool`. + + With the widths agreeing, no conversion remains on any path. A logical array + argument is passed as the caller's own buffer rather than widened into a + native-kind temporary and narrowed back, and the post-call byte normalization + is gone: the representation is now correct at the source rather than repaired + at each boundary. + +- Fixed a module allocatable array with `target` reporting the wrong descriptor + facts. `target` let the bridge take the variable's address with `c_loc`, after + which the binding had to reconstruct the rest of the descriptor from + assumptions — a hardcoded lower bound of zero, unit stride, `sizeof` element + length. Fortran's default lower bound is one, so the reported bound was wrong + for every such array, not only for a declared bound: `allocate(a(4))` reported + zero instead of one, and `allocate(a(5:8))` reported zero instead of five. + + An allocatable or pointer dummy adopts the bounds of the descriptor it is + given, so this reached native code rather than staying a reported fact. A + procedure taking `real(real64), allocatable, intent(in) :: x(:)` saw + `lbound(x, 1) == 0` for an array allocated `(5:8)`, and `x(5)` read past the + end of four elements and returned whatever was there. Both declarations now + read the real descriptor, so the callee sees the bounds the array actually has + and indexes it correctly, and the element length of a `character` allocatable + is measured rather than assumed. The two paths are now one, which also removed the + hand-written descriptor reconstruction from generated C and made `Aliased` stop + selecting a different NumPy exposure for module allocatables. Python-visible + views are unchanged: NumPy indexing stays zero-based either way. + + `character` module allocatables are included. Their descriptor dummy is now + declared `allocatable` rather than assumed-shape, which is what carries the + declared bounds across — an assumed-shape dummy renumbers them from zero — and + their element length is read from the array instead of assumed from the + declaration. + + Deferred-length `character` previously failed to build at all: GCC 11 raised + an internal compiler error on the generated descriptor call. The cause was + that a module allocatable planned two byte-identical bridge procedures, one + for its descriptor and one for its data address, and GCC could not compile + both. The address operation now shares the descriptor procedure and passes a + callback that keeps only the address, so the duplicate is gone and the form + builds and reports its real bounds and element length. + +- Fixed a silent correctness bug in live views over Fortran `logical` arrays. A + borrowed view aliases native storage element for element, but every `logical` + kind was represented as NumPy's one-byte bool, so a view over a wider kind — + including the default `logical` on every toolchain PRIK tests — read the wrong + elements and reported wrong values with no error anywhere. Such a module array + or derived-type field is now refused with a diagnostic naming the width; + `logical(c_bool)` is unaffected and is still borrowed as a live view. This was + present for `target` arrays too, so it predates the borrowing change below. + +- Fixed derived-type array fields are now exposed through their base address and + extents rather than a C consumer callback receiving a Fortran descriptor. A + fixed component has a fixed rank and contiguous storage, so the descriptor + carried nothing the extents did not already give, and the callback round-trip + per attribute access is gone. Where the owner is reached as a pointer its + components are already addressable and `c_loc` names them directly; a member of + a module object declared without `target` uses `prik_capture_address`, the same + route a non-addressable module array takes. Python-visible behavior is + unchanged. + +- Fixed-shape module arrays are now exposed as live NumPy views whether or not + the Fortran declaration carries `target`. `target` is what lets `c_loc` name + a variable; it is not what gives a module array its address. For an ordinary + declaration the generated bridge now captures the base address on the C side, + the way f2py does: the whole array is handed to `prik_capture_address`, a + `bind(C)` primitive in the bundled support header whose assumed-type + assumed-size dummy receives the bare base address and hands it back. The + Fortran side forms no pointer and claims no target, and one interface covers + every element type and rank. The Python-facing + behavior is identical to the `target` form: one borrowed view over the real + module storage, writable in both directions, with whole-array replacement + still rejected. Previously such a variable was reported unsupported with + "ordinary module array requires addressable Aliased target storage". + + The Fortran standard does not require a module variable to keep one address + for the life of the program, so this borrow rests on how compilers lay out + module storage in practice rather than on a guarantee. It holds on the + toolchains PRIK tests; a future implementation that relocates module storage + (device offload, for example) could invalidate a view held across the move. + Declare `target` where you want the language to carry that weight. + ## 0.4.3 — 2026-08-31 - Republishes 0.4.2. That tag carried the previous package version, so the diff --git a/docs/user/guide/building-shared-library.md b/docs/user/guide/building-shared-library.md index 78d22f7db..697cb475b 100644 --- a/docs/user/guide/building-shared-library.md +++ b/docs/user/guide/building-shared-library.md @@ -59,6 +59,13 @@ GNU, IFX, and Flang are tested on Linux. See [Compiler Toolchains](../getting-started/installation.md#compiler-toolchains) for versions and other recognized options. +On Intel PRIK adds `-standard-semantics` (and `-Munixlogical` on PGI/NVIDIA) so +a Fortran `logical` has the representation C expects. Pass +`--no-standard-logicals` to leave it out — needed only when you link prebuilt +Intel objects that were themselves compiled without it, since the option also +changes module symbol mangling. See +[Compiler options for interoperable logicals](data-types.md#compiler-options-for-interoperable-logicals). + ## Build a primitive C API directly PRIK supports C source as well. Start with the [C User Guide](c/index.md) for diff --git a/docs/user/guide/data-types.md b/docs/user/guide/data-types.md index 1be2a3851..f4e5d393b 100644 --- a/docs/user/guide/data-types.md +++ b/docs/user/guide/data-types.md @@ -195,7 +195,7 @@ table below explains the target-mantissa rule. | `complex(4)` | `Complex64` | `np.complex64` | `np.complex64` | | `complex(8)` | `Complex128` | `np.complex128` | `np.complex128` | | `complex(c_long_double_complex)` — `complex(10)` on x86-64 | `Complex256` | `np.clongdouble` | `np.clongdouble` | -| `logical` | `Bool8`-`Bool64` | `bool` or `np.bool_` | `bool` | +| `logical` | `Bool8`-`Bool64` | `bool` | `bool` | | `character` | `String` / `String[n]` | Depends on the string boundary | Depends on the string boundary | | Derived Type | Generated Class | Instance of that class | Instance of that class | @@ -206,19 +206,87 @@ double` is IEEE quad, `real(16)` maps to it instead. PRIK decides from the mantissa width the compiler reports, never from storage size — see [Unsupported Widths And Forms](#unsupported-widths-and-forms). -Boolean contract names describe native storage, not different Python dtypes: +Boolean contract names describe native storage. A scalar crosses by value and is +converted, so it stays a Python `bool`. An **array is aliased**, element for +element, and NumPy has no Boolean wider than one byte — so a logical array +reports the integer dtype of matching width: | Semantic Contract | Native Logical Storage Represented | Scalar Input | Direct Result | Array Storage | | --- | --- | --- | --- | --- | -| `Bool` | 8 bits; portable default, equivalent to `Bool8` | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | -| `Bool8` | 8 bits | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | -| `Bool16` | 16 bits | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | -| `Bool32` | 32 bits | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | -| `Bool64` | 64 bits | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | +| `Bool` | 8 bits; portable default, equivalent to `Bool8` | `bool` | `bool` | `dtype=np.bool_` | +| `Bool8` | 8 bits | `bool` | `bool` | `dtype=np.bool_` | +| `Bool16` | 16 bits | `bool` | `bool` | `dtype=np.int16` | +| `Bool32` | 32 bits | `bool` | `bool` | `dtype=np.int32` | +| `Bool64` | 64 bits | `bool` | `bool` | `dtype=np.int64` | Generated contracts select a numbered name after probing the chosen compiler. -Callers never pass integer arrays for wider logical storage: the wrapper adapts -the one-byte NumPy Boolean representation at the native boundary. + +A `logical(c_bool)` array is one byte per element holding zero or one, which is +exactly `numpy.bool_`, so it is exposed as a NumPy Boolean and needs no +conversion. A wider kind has no NumPy Boolean to be — there is none larger than +a byte — so it reports the integer of matching width and is read with +`.astype(bool)`: + +```python +flags = mod.flags # dtype bool for logical(c_bool) +wide = mod.wide # dtype int32 for a default `logical` +wide.astype(bool) # array([True, False, True]) +wide[0] = 0 # visible to Fortran +``` + +Write only `0` or `1` into an integer-typed logical array. Any other value is +undefined in Fortran itself, not just through PRIK: writing raw `2` into a +`logical` array and asking `count()` gives `4` on gfortran and `0` on ifx, and +each compiler then contradicts itself about whether a single element is true. + +### Compiler options for interoperable logicals + +A Fortran `logical` has no fixed representation, and several compilers default +to one their own C compiler cannot read. PRIK requests the option that selects +the interoperable form, so this is handled for you: + +| Compiler | Option PRIK passes | +| --- | --- | +| gfortran, Cray, IBM XL | none needed | +| Intel `ifx` / `ifort` | `-standard-semantics` | +| PGI / NVIDIA | `-Munixlogical` | + +Without it, Intel stores all bits set for `.true.`, so a `logical(c_bool)` array +handed to C contains `255` where `_Bool` is defined to hold `1` — and C then +miscounts it. If you override PRIK's compiler flags, keep this one. + +#### Turning it off for prebuilt Intel objects + +On Intel, `-standard-semantics` also changes how module symbols are mangled. +A variable `flag` in module `logtest` is emitted as `logtest_MP_flag_` with the +option and as `logtest_mp_flag_` without it. Objects compiled with the option +and objects compiled without it therefore cannot be linked together, and mixing +them fails with `undefined reference to lib_MP_name_` rather than with anything +about logicals. + +So if you link PRIK against a **prebuilt** Intel library or object file that +was compiled without `-standard-semantics`, either rebuild that library with +the option — the better fix — or tell PRIK to leave the option out: + +```bash +python3 -m prik lib.f90 --compiler ifx --no-standard-logicals +``` + +```python +build_fortran_extension( + "lib.f90", + preprocessing=PreprocessingConfig(compiler="ifx"), + standard_logicals=False, +) +``` + +That restores link compatibility at the cost of the guarantee above: `.true.` +is stored as all bits set again, so a `logical(c_bool)` array reaching NumPy +holds `255` for true. Comparisons against `True` and `.astype(bool)` still read +it correctly, because every non-zero value is true — but `numpy.bool_` values +that are neither `0` nor `1` are outside what NumPy documents, and +`tobytes()`, buffer sharing, and anything reading the raw byte will see `255`. +Prefer rebuilding the dependency. --- diff --git a/docs/user/guide/wrapping-modules.md b/docs/user/guide/wrapping-modules.md index e82449295..4af1900f3 100644 --- a/docs/user/guide/wrapping-modules.md +++ b/docs/user/guide/wrapping-modules.md @@ -114,12 +114,61 @@ print(mod.nmax) # 12 (read-only parameter) ## Module Arrays & Saved State +A fixed-shape module array is exposed as a live NumPy view over the real +Fortran storage. Reads see whatever the native code last wrote, and writes +through the view are visible to Fortran: + +```fortran +module state + use iso_fortran_env, only: real64 + implicit none + real(real64) :: grid(2, 3) +end module state +``` + +```python +grid = mod.grid # a view, not a copy +grid[0, 0] = 10.0 # Fortran sees this +mod.bump() # and this is visible through `grid` +``` + +The whole variable cannot be reassigned (`mod.grid = ...` raises +`AttributeError`); its shape belongs to the Fortran declaration. Write into the +view instead, with `mod.grid[:] = ...`. + +- The `target` attribute is not required. It is what lets `c_loc` name a + variable in Fortran, not what gives a module array its address, so for an + ordinary declaration PRIK takes the address on the C side instead: the whole + array is passed to `prik_capture_address`, a `bind(C)` primitive in PRIK's + bundled support header whose assumed-size dummy receives the bare base + address. Both forms produce the same live view. +- Derived-type array fields never needed `target` either, and are borrowed the + same way. An object reached through its address makes its components + addressable, so `c_loc` names them directly; a member of a module object + declared without `target` takes the same C-side route as a module array. A + plain `real(real64) :: grid(2, 3)` component is a live view whether the type, + the field, or the containing module variable declares the attribute. +- A Fortran `logical` array is borrowed only when its kind is one byte wide + (`logical(c_bool)`). A wider kind — including the default `logical` on common + compilers — cannot be aliased by NumPy's one-byte bool, so it is reported + unsupported rather than exposed as a view that would read the wrong elements. + Return it from a procedure instead, which converts each element. - Allocatable module arrays use the `Allocatable[T[...]]` API. - Allocation, lifetime, NumPy views, and mutation rules are covered in the storage and objects section. - `save` attributes (including procedure-local `save` variables) persist across calls. - Multiple Python imports of the same extension share the same native module state. +!!! warning "A borrowed view assumes module storage stays put" + + The Fortran standard does not require a module variable to occupy one + address for the life of the program, so a view held across native code that + could relocate module storage — device offload, for instance — is your + responsibility rather than something the language guarantees. This holds on + the toolchains PRIK tests, and declaring `target` puts the language behind + it. If you would rather not hold a view at all, copy what you need: + `np.array(mod.grid)`. + --- ## Shape the Module API With the Contract diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 1aa466746..2de9be866 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -90,6 +90,7 @@ least one explicit native input: `--native-fortran-sources`, `--native-c-sources | `--strict-wrapper-names` | Rejects Python names that would need escaping or a collision suffix. | | `--assume-intent-in-scalars` | Treats a primitive or non-descriptor character scalar dummy that declares no `intent` as `intent(in)`, so its value is not returned. A declared `intent` always wins; arrays, derived-type objects, and descriptor character scalars are unaffected. Also accepted by `generate --pyi`, where it removes the same results from the generated contract, and by `semantics`. | | `--no-compile-input-sources` | Treats positional sources as semantic inputs only. Requires an explicit native input. | +| `--no-standard-logicals` | Omits the compiler option that gives a Fortran `logical` the representation C expects (`-standard-semantics` on Intel, `-Munixlogical` on PGI/NVIDIA). Use only to link prebuilt Intel objects compiled without it, since that option also changes module symbol mangling. | | `--native-fortran-sources PATH ...` | Compiles extra native sources without exposing them as public API. | | `--native-c-sources PATH ...` | Compiles extra C sources without exposing them as public API. | | `--native-compile-flags FLAG ...` | Flags for native implementation compilation. | diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 956acc089..fb189603b 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -684,7 +684,7 @@ contract is generated. | Family | Available names | Normal Python boundary | | --- | --- | --- | -| Boolean | `Bool`, `Bool8`, `Bool16`, `Bool32`, `Bool64` | `bool` or `numpy.bool_`; arrays use `numpy.bool_`. | +| Boolean | `Bool`, `Bool8`, `Bool16`, `Bool32`, `Bool64` | Scalars are `bool`; arrays are aliased and use the integer dtype of the same width (`numpy.uint8`, `int16`, `int32`, `int64`). Read with `.astype(bool)`. | | Signed integer | `Int`, `Int8`, `Int16`, `Int32`, `Int64` | Matching NumPy integer scalar or array dtype. `Int` retains target-dependent C `int` identity. | | Unsigned integer | `UInt`, `UInt8`, `UInt16`, `UInt32`, `UInt64`, `SizeT` | Matching NumPy unsigned scalar or array dtype; `UInt` and `SizeT` are target-dependent. | | Real | `Float16`, `Float32`, `Float64`, `Float128` | Matching NumPy real dtype when the selected target supports it. | diff --git a/prik/cli.py b/prik/cli.py index beb99cf3a..b93d03b17 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -1468,6 +1468,7 @@ def record_total_build_time(elapsed: float) -> None: makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), jobs=getattr(args, "jobs", None), + standard_logicals=getattr(args, "standard_logicals", True), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), wrapper_fortran_flags=_with_link_time_optimization( @@ -1517,6 +1518,7 @@ def record_total_build_time(elapsed: float) -> None: wrapper_c_flags=_with_link_time_optimization( _cli_wrapper_c_flags(getattr(args, "wrapper_c_flags", None)), args ), + standard_logicals=getattr(args, "standard_logicals", True), _on_total_build_time=total_build_time_reporter, ) return _copy_wrapper_shared_library_alias(args, result) @@ -1532,6 +1534,7 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), assume_intent_in_scalars=getattr(args, "assume_intent_in_scalars", False), compile_input_sources=not getattr(args, "no_compile_input_sources", False), + standard_logicals=getattr(args, "standard_logicals", True), native_fortran_sources=getattr(args, "native_fortran_sources", None), native_fortran_flags=_with_link_time_optimization( _cli_native_compile_flags(getattr(args, "native_compile_flags", None)), args @@ -2225,6 +2228,16 @@ def _add_native_compilation_options(group: argparse._ArgumentGroup) -> None: action="store_true", help="Read positional sources without compiling them; require an explicit native implementation", ) + group.add_argument( + "--no-standard-logicals", + dest="standard_logicals", + action="store_false", + help=( + "Omit the compiler option that makes a Fortran logical interoperable with C " + "(-standard-semantics on Intel, -Munixlogical on PGI/NVIDIA); use only to match " + "prebuilt objects already compiled without it" + ), + ) group.add_argument( "--native-fortran-sources", dest="native_fortran_sources", diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 55c01ac5f..d8cd0a528 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -35,6 +35,7 @@ DerivedWriteback, DirectResultABI, ModuleObjectAccessMechanism, + ModuleArrayAddressMechanism, ModuleGetterAction, NativeArrayDescriptorKind, NativeArrayDescriptorInterop, @@ -640,8 +641,30 @@ def _module_defines(self, plan: ModulePlan, needs_native_support: bool) -> tuple for argument in function.arguments ): definitions.append(CMacroDefinition("PRIK_BINDING_NATIVE_ARRAY_ACTUAL", "1")) + # The bundled address-capture primitive needs external linkage for the + # Fortran bridge to call it, so the header defines it only where this + # macro opts in. Selecting it here keeps it in one translation unit. + if self._requires_address_capture(plan): + definitions.append(CMacroDefinition("PRIK_BINDING_CAPTURE_ADDRESS", "1")) return tuple(definitions) + def _requires_address_capture(self, plan: ModulePlan) -> bool: + """Report whether any borrowed view in this module takes its address in C. + + Both cases name their storage directly rather than reaching it through a + pointer, so neither has a Fortran route to its own address: a module + array whose declaration withheld ``target``, and an array member of a + plain module object, which is likewise not a target. + """ + return any( + variable.array_address is ModuleArrayAddressMechanism.CAPTURED_ADDRESS for variable in self._variables(plan) + ) or any( + member.field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR + for variable in self._variables(plan) + if variable.derived is not None and variable.derived.access is ModuleObjectAccessMechanism.MEMBER_PROXY + for member in variable.derived.member_paths + ) + def _module_includes( self, plan: ModulePlan, @@ -2701,12 +2724,7 @@ def _direct_handle_field_functions(self, derived, field) -> tuple[CFunction, ... def _direct_array_field_functions(self, derived, field) -> tuple[CFunction, ...]: """Build direct array field functions from the supplied completed binding records; emitted nodes only project completed binding actions.""" - callback = self._ordinary_array_field_descriptor_callback( - field, - self._derived_field_descriptor_callback_name(derived, field), - ) return ( - callback, self._direct_ordinary_array_field_getter(derived, field), *self._present_field_function(self._direct_ordinary_array_field_setter(derived, field)), ) @@ -2738,12 +2756,7 @@ def _module_handle_member_functions(self, variable, member) -> tuple[CFunction, def _module_array_member_functions(self, variable, member) -> tuple[CFunction, ...]: """Build module array member functions from the supplied completed binding records; emitted nodes only project completed binding actions.""" - callback = self._ordinary_array_field_descriptor_callback( - member.field, - self._module_member_descriptor_callback_name(variable, member), - ) return ( - callback, self._module_ordinary_array_member_getter(variable, member), *self._present_field_function(self._module_ordinary_array_member_setter(variable, member)), ) @@ -2780,17 +2793,70 @@ def _direct_ordinary_array_field_getter( """Create a live NumPy view over one fixed address-backed field.""" body = ( *self._derived_owner_address_nodes(derived), - CDeclaration("field_view", "PyObject *", CodeExpression("NULL")), - CExpressionStatement( - CodeExpression( - f"{self._derived_field_bridge_name(derived, field, 'get')}(owner_address, " - f"{self._derived_field_descriptor_callback_name(derived, field)}, &field_view)" - ) + *self._borrowed_array_view_nodes( + field, + self._derived_field_bridge_name(derived, field, "get"), + owner="owner_obj", + leading_arguments=("owner_address",), ), - *self._ordinary_array_field_owner_nodes("field_view", "owner_obj"), ) return self._derived_private_method(self._derived_field_method_name(derived, field, "get"), body) + def _borrowed_array_view_nodes( + self, + field: DerivedFieldPlan, + bridge_name: str, + *, + owner: str, + leading_arguments: tuple[str, ...] = (), + ) -> tuple: + """Build one live Fortran-ordered NumPy alias from a base pointer and extents. + + Both field families share this construction: the bridge reports where + the member lives plus one extent per axis, and the view is formed here. + ``leading_arguments`` carries the owner address where one is passed. + """ + array = field.array + if array is None or array.rank is None: + raise ValueError(f"Ordinary array field {field.owner_path!r} has no fixed rank") + scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) + extents = tuple(f"extent_{axis}" for axis in range(array.rank)) + arguments = ", ".join((*leading_arguments, *(f"&{name}" for name in extents))) + return ( + *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in extents), + CDeclaration("field_data", "void *", CodeExpression(f"{bridge_name}({arguments})")), + CIf( + CodeExpression("field_data == NULL"), + body=( + CExpressionStatement( + CodeExpression('PyErr_SetString(PyExc_ReferenceError, "array field storage is unavailable")') + ), + CReturn(CodeExpression("NULL")), + ), + ), + CDeclaration( + f"dimensions[{array.rank}]", + "npy_intp", + CodeExpression("{" + ", ".join(extents) + "}"), + ), + CDeclaration(f"strides[{array.rank}]", "npy_intp"), + CExpressionStatement(CodeExpression(f"strides[0] = (npy_intp)sizeof({scalar.array_c_spelling})")), + *( + CExpressionStatement(CodeExpression(f"strides[{axis}] = strides[{axis - 1}] * dimensions[{axis - 1}]")) + for axis in range(1, array.rank) + ), + CDeclaration( + "field_view", + "PyObject *", + CodeExpression( + f"PyArray_New(&PyArray_Type, {array.rank}, dimensions, {scalar.array_numpy_type}, " + "strides, field_data, 0, NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_ALIGNED | " + "NPY_ARRAY_WRITEABLE, NULL)" + ), + ), + *self._ordinary_array_field_owner_nodes("field_view", owner), + ) + def _direct_native_handle_field_getter( self, derived: DerivedTypePlan, @@ -2870,14 +2936,11 @@ def _module_ordinary_array_member_getter( body = ( CDeclaration("owner_obj", "PyObject *"), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), - CDeclaration("field_view", "PyObject *", CodeExpression("NULL")), - CExpressionStatement( - CodeExpression( - f"{self._module_member_bridge_name(variable, member, 'get')}(" - f"{self._module_member_descriptor_callback_name(variable, member)}, &field_view)" - ) + *self._borrowed_array_view_nodes( + member.field, + self._module_member_bridge_name(variable, member, "get"), + owner="owner_obj", ), - *self._ordinary_array_field_owner_nodes("field_view", "owner_obj"), ) return self._derived_private_method(self._module_member_method_name(variable, member, "get"), body) @@ -3100,56 +3163,6 @@ def _module_ordinary_array_member_setter( ) return self._derived_private_method(self._module_member_method_name(variable, member, "set"), body) - def _ordinary_array_field_descriptor_callback( - self, - field: DerivedFieldPlan, - callback_name: str, - ) -> CFunction: - """Construct a NumPy view from one standard field descriptor.""" - array = field.array - if array is None or array.rank is None or not array.shape: - raise ValueError(f"Ordinary array field {field.owner_path!r} has no fixed shape") - scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - dims = ", ".join(f"(npy_intp)descriptor->dim[{axis}].extent" for axis in range(array.rank)) - strides = ", ".join(f"(npy_intp)descriptor->dim[{axis}].sm" for axis in range(array.rank)) - return CFunction( - callback_name, - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), - CIf( - CodeExpression("descriptor == NULL || descriptor->base_addr == NULL"), - body=( - CExpressionStatement( - CodeExpression( - 'PyErr_SetString(PyExc_ReferenceError, "array field descriptor is unavailable")' - ) - ), - CReturn(), - ), - ), - CDeclaration( - f"field_dims[{array.rank}]", - "npy_intp", - CodeExpression("{" + dims + "}"), - ), - CDeclaration( - f"field_strides[{array.rank}]", - "npy_intp", - CodeExpression("{" + strides + "}"), - ), - CExpressionStatement( - CodeExpression( - f"*(PyObject **)context = PyArray_New(&PyArray_Type, {array.rank}, field_dims, " - f"{scalar.numpy_type_macro}, field_strides, descriptor->base_addr, 0, " - "NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" - ) - ), - ), - ) - @staticmethod def _ordinary_array_field_owner_nodes(field_view: str, owner_name: str) -> tuple: """Retain the live parent as the NumPy view base after descriptor use.""" @@ -4276,12 +4289,16 @@ def _module_allocatable_array_actual_body( self, variable: ModuleVariablePlan, ) -> tuple[CDeclaration | CExpressionStatement | CReturn, ...]: - """Request the current standard descriptor and expose only its data address.""" + """Request the current standard descriptor and expose only its data address. + + This shares the descriptor operation rather than declaring one of its + own: the two would be the same procedure, and only the callback differs. + """ return ( CDeclaration("base_addr", "void *", CodeExpression("NULL")), CExpressionStatement( CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.ARRAY_ACTUAL)}(" + f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR)}(" f"{self._module_array_actual_callback_name(variable)}, &base_addr)" ) ), @@ -5584,8 +5601,8 @@ def _lower_module_getter_borrowed_array_view(self, plan: ModuleVariablePlan) -> numpy_itemsize = "(int)itemsize" else: scalar = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) - element_size = f"sizeof({scalar.c_spelling})" - numpy_type = str(scalar.numpy_type_macro) + element_size = f"sizeof({scalar.array_c_spelling})" + numpy_type = str(scalar.array_numpy_type) numpy_itemsize = "0" owner = self._module_native_array_owner_name(plan) width = ("itemsize",) if character else () @@ -7136,9 +7153,9 @@ def _numeric_array_dtype_selectors(plan: ArgumentTransferPlan) -> tuple[str, str ) return native.numpy_type_macro, native.python_type_name scalar_type = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) - if scalar_type.numpy_type_macro is None or scalar_type.python_type_name is None: + if scalar_type.array_numpy_type is None or scalar_type.array_dtype_name is None: raise ValueError(f"Unsupported array element type {plan.semantic_type_name!r}") - return scalar_type.numpy_type_macro, scalar_type.python_type_name + return scalar_type.array_numpy_type, scalar_type.array_dtype_name @staticmethod def _array_rank_bounds(handoff: ArrayHandoffPlan) -> tuple[int, int]: @@ -8050,6 +8067,7 @@ def _native_array_dtype_for_semantic_type( scalar_type = PrimitiveScalarTypeRegistry.type_for(semantic_type_name) return { "NPY_BOOL": "bool", + "NPY_UINT8": "uint8", "NPY_INT8": "int8", "NPY_INT16": "int16", "NPY_INT32": "int32", @@ -8058,7 +8076,7 @@ def _native_array_dtype_for_semantic_type( "NPY_FLOAT64": "float64", "NPY_COMPLEX64": "complex64", "NPY_COMPLEX128": "complex128", - }[scalar_type.numpy_type_macro] + }[scalar_type.array_numpy_type] def _native_array_cfi_type(self, plan: ArgumentTransferPlan | ResultPlan) -> str | None: """Return the standard-descriptor element type after array-family dispatch.""" @@ -11612,14 +11630,6 @@ def _pointer_holder_ops_name(type_name: str) -> str: """Return the binding-local pointer holder ops name derived from the supplied local lowering values; this helper preserves completed policy.""" return CBindingNames.pointer_holder_ops(type_name) - def _derived_field_descriptor_callback_name( - self, - derived: DerivedTypePlan, - field: DerivedFieldPlan, - ) -> str: - """Return the binding-local derived field descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" - return f"prik_field_{self._derived_field_symbol(derived, field)}_descriptor" - def _derived_handle_operation_name( self, derived: DerivedTypePlan, @@ -11682,14 +11692,6 @@ def _module_member_bridge_name( ".".join((variable.owner_path, *member.path)), f"field:module:{action}" ).symbol_name - def _module_member_descriptor_callback_name( - self, - variable: ModuleVariablePlan, - member: DerivedMemberPathPlan, - ) -> str: - """Return the binding-local module member descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" - return f"prik_module_field_{self._module_member_symbol(variable, member)}_descriptor" - def _module_member_handle_operation_name( self, variable: ModuleVariablePlan, diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 654b71632..af7e3edb5 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -53,6 +53,20 @@ "String": "str", } +# An aliased array reports the width its Fortran elements really occupy. NumPy +# has no Boolean wider than one byte, and no Fortran kind promises the byte +# values zero and one that its `bool_` requires, so a logical array is described +# by the integer of matching width and read back with `.astype(bool)`. +_ARRAY_ELEMENT_TYPES = { + "Bool": "uint8", + "Bool8": "uint8", + "Bool16": "int16", + "Bool32": "int32", + "Bool64": "int64", +} + +_LOGICAL_ARRAY_NOTE = "Fortran logical elements; compare with .astype(bool) rather than to 1." + _UNKNOWN_EXTENTS = frozenset({"", ":", "::", "*", ".."}) @@ -457,6 +471,7 @@ def module_variable(self, variable: ModuleVariablePlan) -> str: nullable = variable.binding.getter_action is ModuleGetterAction.NULLABLE_SNAPSHOT lines = [f"{name} : {self._type(variable, nullable=nullable, signature=False)}"] lines.extend(self._array_lines(variable.array)) + lines.extend(self._logical_array_lines(variable)) if variable.binding.getter_action in { ModuleGetterAction.CONSTANT_VALUE, ModuleGetterAction.NATIVE_CONSTANT_VALUE, @@ -473,6 +488,20 @@ def module_variable(self, variable: ModuleVariablePlan) -> str: lines.append(" Replacement assignment is not supported.") return "\n".join(lines) + @staticmethod + def _logical_array_lines(transfer) -> tuple[str, ...]: + """Explain the dtype a Fortran logical array reports, where it has one. + + The integer width is the storage the elements actually occupy, so the + note says how to read it rather than leaving the caller to guess that + the values are Booleans. + """ + if getattr(transfer, "semantic_type_name", None) not in _ARRAY_ELEMENT_TYPES: + return () + if getattr(transfer, "array", None) is None and getattr(transfer, "native_array_handle", None) is None: + return () + return (f" {_LOGICAL_ARRAY_NOTE}",) + def field(self, field: DerivedFieldPlan) -> str: """Render one generated class-property docstring from its field plan. @@ -896,6 +925,7 @@ def _base_type(self, transfer) -> str: if getattr(transfer, "datatype_family", None) is DatatypeFamily.DERIVED: return transfer.semantic_type_name scalar = _SCALAR_TYPES.get(transfer.semantic_type_name, transfer.semantic_type_name) + array_element = _ARRAY_ELEMENT_TYPES.get(transfer.semantic_type_name, scalar) handle = getattr(transfer, "native_array_handle", None) if handle is not None: prefix = ( @@ -903,9 +933,9 @@ def _base_type(self, transfer) -> str: if handle.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE else "PointerArray" ) - return f"{prefix}[{scalar}]" + return f"{prefix}[{array_element}]" if getattr(transfer, "array", None) is not None: - element = "bytes" if transfer.semantic_type_name == "String" else scalar + element = "bytes" if transfer.semantic_type_name == "String" else array_element return f"ndarray[{self._exact_array_element_label(transfer, element)}]" return scalar diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index f1c9130c1..21c0814ec 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -40,6 +40,7 @@ DeclarationCallableAction, DirectResultABI, ExternalDeclarationMode, + ModuleArrayAddressMechanism, ModuleGetterAction, ModuleObjectAccessMechanism, CharacterLocalRelease, @@ -109,6 +110,10 @@ from prik.codegen.visitor import ClassVisitor +# The C identity function that reports a non-target module array's base +# address. The binding defines it; the bridge declares and calls it. +_MODULE_ARRAY_CAPTURE_NAME = "prik_capture_address" + _MODULE_GETTER_SUMMARIES = { ModuleGetterAction.CONSTANT_VALUE: "The value is a compile-time constant materialized by the binding.", ModuleGetterAction.NATIVE_CONSTANT_VALUE: "Returns the compiler-evaluated constant by value.", @@ -324,6 +329,7 @@ def _visit_ModulePlan(self, plan: ModulePlan) -> FortranModule: *self._external_interfaces(plan), *self._module_descriptor_callback_interfaces(plan), *self._derived_array_callback_interfaces(plan), + *self._module_array_capture_interfaces(plan), *self._allocator_interfaces(plan), ), declarations=self._prototype_entity_declarations(plan), @@ -2572,12 +2578,11 @@ def _module_native_array_state_operation(self, plan: ModuleVariablePlan, operati ) def _module_native_array_actual_operation(self, plan: ModuleVariablePlan) -> FortranFunction: - """Return current module-array data storage without changing ownership.""" - if self._uses_module_allocatable_descriptor(plan): - return self._module_allocatable_descriptor_callback_operation( - plan, - NativeArrayOperation.ARRAY_ACTUAL, - ) + """Return current module-array data storage without changing ownership. + + A descriptor-reading module allocatable plans no such operation, so only + the address route reaches this. + """ name = self._module_native_array_operation_name(plan, NativeArrayOperation.ARRAY_ACTUAL) native = self._native_variable_name(plan) return FortranFunction( @@ -2810,7 +2815,7 @@ def _module_native_array_element_type(self, plan: ModuleVariablePlan) -> str: if plan.datatype_family is DatatypeFamily.STRING: length = ":" if plan.character_length is None else str(plan.character_length) return f"character(kind=c_char, len={length})" - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).fortran_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _module_pointer_dummy_element_type(self, plan: ModuleVariablePlan) -> str: """Return the element type of one module pointer dummy. @@ -2821,7 +2826,7 @@ def _module_pointer_dummy_element_type(self, plan: ModuleVariablePlan) -> str: """ if plan.datatype_family is DatatypeFamily.STRING: return "character(kind=c_char, len=:)" - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).fortran_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _module_descriptor_consumer_value_declaration( self, @@ -2830,18 +2835,21 @@ def _module_descriptor_consumer_value_declaration( ) -> tuple[str, tuple[str, ...]]: """Return the type and attributes of one descriptor-consumer value dummy. - A ``bind(C)`` allocatable character dummy has to declare deferred - length, while argument association requires the actual to be deferred - exactly when the dummy is. A module array that declares its own width - satisfies neither together, so it travels as an assumed-length - assumed-shape dummy whose descriptor still carries the element length. - The runtime never reaches this operation while the array is - unallocated: ``AllocatableArray.to_numpy`` and ``shape`` both return - early on ``allocated``. + The dummy is always ``allocatable`` so that the descriptor it receives + describes the module variable itself. An assumed-shape dummy would + renumber the bounds from zero, losing a declared lower bound, and GCC + rejects an assumed-shape character one outright. + + Argument association requires the actual to declare deferred length + exactly when the dummy does, so a character array that declares its own + width takes assumed length rather than deferred. The runtime never + reaches this operation while the array is unallocated: + ``AllocatableArray.to_numpy`` and ``shape`` both return early on + ``allocated``. """ dimension = self._array_dimension_attribute(rank) if plan.datatype_family is DatatypeFamily.STRING and plan.character_length is not None: - return "character(kind=c_char, len=*)", (dimension, "intent(in)") + return "character(kind=c_char, len=*)", ("allocatable", dimension, "intent(in)") return self._module_native_array_element_type(plan), ("allocatable", dimension, "intent(in)") def _module_native_array_operation_name(self, plan: ModuleVariablePlan, operation) -> str: @@ -2996,7 +3004,7 @@ def _lower_module_getter_borrowed_array_view( self, plan: ModuleVariablePlan, ) -> tuple[FortranFunction, ...]: - """Expose one addressable fixed module array through pointer and extents.""" + """Expose one fixed module array through its base pointer and extents.""" array = plan.array if array is None or array.rank is None: raise ValueError(f"Module array view {plan.owner_path!r} has no fixed rank") @@ -3007,6 +3015,7 @@ def _lower_module_getter_borrowed_array_view( # the Fortran variable, not to anything the binding can restate. width = ("itemsize",) if plan.datatype_family is DatatypeFamily.STRING else () extents = tuple(f"extent_{axis}" for axis in range(array.rank)) + address = self._module_array_address(plan, native) return ( FortranFunction( name=name, @@ -3026,11 +3035,65 @@ def _lower_module_getter_borrowed_array_view( ) for axis, extent in enumerate(extents) ), - FortranAssignment("result", CodeExpression(f"c_loc({native})")), + FortranAssignment("result", CodeExpression(address)), ), ), ) + @staticmethod + def _module_array_address(plan: ModuleVariablePlan, native: str) -> str: + """Return the address expression selected by the completed mechanism.""" + mechanism = plan.array_address + if mechanism is ModuleArrayAddressMechanism.TARGET_ADDRESS: + return f"c_loc({native})" + if mechanism is ModuleArrayAddressMechanism.CAPTURED_ADDRESS: + return f"{_MODULE_ARRAY_CAPTURE_NAME}({native})" + raise ValueError(f"Module array view {plan.owner_path!r} has no completed address mechanism: {mechanism!r}") + + def _requires_address_capture(self, plan: ModulePlan) -> bool: + """Report whether any borrowed view must take its address on the C side. + + Both cases name their storage directly rather than reaching it through a + pointer, so neither has a Fortran route to its own address: a module + array whose declaration withheld ``target``, and an array member of a + plain module object, which is likewise not a target. + """ + return any( + variable.array_address is ModuleArrayAddressMechanism.CAPTURED_ADDRESS for variable in self._variables(plan) + ) or any( + member.field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR + for variable in self._derived_member_proxy_variables(plan) + for member in variable.derived.member_paths + ) + + def _module_array_capture_interfaces(self, plan: ModulePlan) -> tuple[FortranInterface, ...]: + """Declare the C capture helper wherever a borrowed view needs it. + + ``c_loc`` requires the variable it names to be a target, so a module + array whose declaration withheld the attribute has no Fortran route to + its own address. Handing the whole array to a `bind(C)` procedure does + have one: an assumed-type assumed-size dummy is passed as the bare base + address, so C receives where the module variable lives and hands it + straight back. Nothing here claims a target or forms a Fortran pointer. + """ + if not self._requires_address_capture(plan): + return () + return ( + FortranInterface( + ( + FortranInterfaceProcedure( + name=_MODULE_ARRAY_CAPTURE_NAME, + imports=("c_ptr",), + parameters=(FortranParameter("base", "type(*)", ("dimension(*)",)),), + result_name="address", + result_type="type(c_ptr)", + bind_name=_MODULE_ARRAY_CAPTURE_NAME, + bind_c=True, + ), + ) + ), + ) + def _lower_module_getter_nullable_snapshot( self, plan: ModuleVariablePlan, @@ -4373,7 +4436,7 @@ def _array_declarations(self, plan: FunctionPlan) -> tuple[FortranDeclaration, . declarations.append( FortranDeclaration( self._logical_array_byte_pointer_name(argument), - "integer(c_int8_t)", + self._logical_array_integer_type(argument.semantic_type_name), ("pointer", "dimension(:)"), ) ) @@ -4499,10 +4562,49 @@ def _logical_array_writeback_for_rank( ), FortranAssignment( byte_pointer, - CodeExpression(f"iand({byte_pointer}, 1_c_int8_t)"), + CodeExpression(self._logical_array_canonical_expression(argument.semantic_type_name, byte_pointer)), ), ) + @staticmethod + def _logical_array_integer_type(semantic_type_name: str) -> str: + """Return the integer type covering one Boolean element's own width. + + The mask reinterprets the caller's buffer, so it has to step by the + element width rather than by bytes: a `logical(4)` array is four-byte + integers, not four times as many one-byte ones. + """ + return { + "Bool": "integer(c_int8_t)", + "Bool8": "integer(c_int8_t)", + "Bool16": "integer(c_int16_t)", + "Bool32": "integer(c_int32_t)", + "Bool64": "integer(c_int64_t)", + }[semantic_type_name] + + @staticmethod + def _logical_array_kind_suffix(semantic_type_name: str) -> str: + """Return the integer kind suffix matching one Boolean element's width.""" + return { + "Bool": "c_int8_t", + "Bool8": "c_int8_t", + "Bool16": "c_int16_t", + "Bool32": "c_int32_t", + "Bool64": "c_int64_t", + }[semantic_type_name] + + def _logical_array_canonical_expression(self, semantic_type_name: str, target: str) -> str: + """Return the expression reducing Boolean storage to zero and one. + + The rule is C's: any non-zero value is true, which is what converting to + ``_Bool`` produces and what NumPy, Python and C all read back. It is not + a low-bit test -- that would call ``2`` false, disagreeing with every one + of them -- and it maps both representations compilers emit, ``1`` and + ``-1``, onto the single value the interoperable type is defined to hold. + """ + kind = self._logical_array_kind_suffix(semantic_type_name) + return f"merge(1_{kind}, 0_{kind}, {target} /= 0_{kind})" + @staticmethod def _logical_array_byte_pointer_name(argument: ArgumentTransferPlan) -> str: """Return the bridge-local byte-pointer name for one logical-array rank conversion.""" @@ -4698,7 +4800,7 @@ def _array_element_fortran_type(self, argument: ArgumentTransferPlan) -> str: if array.itemsize <= 0: raise ValueError(f"Character array {argument.owner_path!r} has a non-positive itemsize") return f"character(kind=c_char, len={array.itemsize})" - return PrimitiveScalarTypeRegistry.type_for(argument.semantic_type_name).fortran_spelling + return PrimitiveScalarTypeRegistry.type_for(argument.semantic_type_name).array_fortran_type def _array_dimension_attribute(self, rank: int) -> str: """Spell one explicit-rank deferred-shape pointer attribute.""" @@ -5563,7 +5665,7 @@ def _direct_scalar_result_finalizers( return ( FortranAssignment( "result", - CodeExpression("iand(transfer(c_result, 0_c_int8_t), 1_c_int8_t)"), + CodeExpression("merge(1_c_int8_t, 0_c_int8_t, transfer(c_result, 0_c_int8_t) /= 0_c_int8_t)"), ), ) case DirectResultABI.NATIVE_SCALAR: @@ -6179,7 +6281,7 @@ def _array_result_element_type( return f"character(kind=c_char, len={itemsize})" if plan.semantic_type_name is None: raise ValueError(f"Array result {plan.owner_path!r} has no element type") - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).fortran_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _array_result_itemsize( self, @@ -7122,33 +7224,53 @@ def _direct_ordinary_array_field_getter( derived: DerivedTypePlan, field: DerivedFieldPlan, ) -> FortranFunction: - """Pass one fixed field through a standard descriptor callback.""" + """Expose one fixed field through its base pointer and extents. + + The owner arrives as an address and is reached through a Fortran + pointer, so its components are subobjects of a pointer target and + ``c_loc`` can name them directly however the field itself was declared. + """ name = self._derived_field_bridge_name(derived, field, "get") - interface = self._derived_field_callback_interface_name(derived, field) + member = f"owner%{field.native_name}" return FortranFunction( name=name, parameters=( FortranParameter("owner_address", "type(c_ptr)", ("value",)), - FortranParameter("callback_address", "type(c_funptr)", ("value",)), - FortranParameter("context", "type(c_ptr)", ("value",)), + *self._ordinary_array_field_extent_parameters(field), ), + result_name="result", + result_type="type(c_ptr)", bind_name=name, - declarations=( - self._derived_owner_declaration(derived), - FortranDeclaration("callback", f"procedure({interface})", ("pointer",)), - ), + declarations=(self._derived_owner_declaration(derived),), body=( self._derived_owner_association(), - FortranCall( - "c_f_procpointer", - (CodeExpression("callback_address"), CodeExpression("callback")), - ), - FortranCall( - "callback", - (CodeExpression(f"owner%{field.native_name}"), CodeExpression("context")), - ), + *self._ordinary_array_field_extent_assignments(field, member), + FortranAssignment("result", CodeExpression(f"c_loc({member})")), ), - is_subroutine=True, + ) + + @staticmethod + def _ordinary_array_field_extent_parameters(field: DerivedFieldPlan) -> tuple[FortranParameter, ...]: + """Return the reported extent outputs for one fixed array field.""" + array = field.array + if array is None or array.rank is None: + raise ValueError(f"Ordinary array field {field.owner_path!r} has no fixed rank") + return tuple( + FortranParameter(f"extent_{axis}", "integer(c_int64_t)", ("intent(out)",)) for axis in range(array.rank) + ) + + @staticmethod + def _ordinary_array_field_extent_assignments(field: DerivedFieldPlan, member: str) -> tuple[FortranAssignment, ...]: + """Report each axis from the native field rather than restating its declaration.""" + array = field.array + if array is None or array.rank is None: + raise ValueError(f"Ordinary array field {field.owner_path!r} has no fixed rank") + return tuple( + FortranAssignment( + f"extent_{axis}", + CodeExpression(f"int(size({member}, {axis + 1}), c_int64_t)"), + ) + for axis in range(array.rank) ) def _direct_ordinary_array_field_setter( @@ -7184,31 +7306,25 @@ def _module_ordinary_array_member_getter( variable: ModuleVariablePlan, member: DerivedMemberPathPlan, ) -> FortranFunction: - """Pass a fixed module member through a standard descriptor callback.""" + """Expose one fixed module member through its base pointer and extents. + + A plain module object is named directly rather than reached through a + pointer, so nothing here is a target and ``c_loc`` cannot name the + member. The address is taken on the C side instead, exactly as a + non-addressable module array's is. + """ name = self._module_member_bridge_name(variable, member, "get") - interface = self._module_member_callback_interface_name(variable, member) + expression = self._module_member_expression(variable, member) return FortranFunction( name=name, - parameters=( - FortranParameter("callback_address", "type(c_funptr)", ("value",)), - FortranParameter("context", "type(c_ptr)", ("value",)), - ), + parameters=self._ordinary_array_field_extent_parameters(member.field), + result_name="result", + result_type="type(c_ptr)", bind_name=name, - declarations=(FortranDeclaration("callback", f"procedure({interface})", ("pointer",)),), body=( - FortranCall( - "c_f_procpointer", - (CodeExpression("callback_address"), CodeExpression("callback")), - ), - FortranCall( - "callback", - ( - CodeExpression(self._module_member_expression(variable, member)), - CodeExpression("context"), - ), - ), + *self._ordinary_array_field_extent_assignments(member.field, expression), + FortranAssignment("result", CodeExpression(f"{_MODULE_ARRAY_CAPTURE_NAME}({expression})")), ), - is_subroutine=True, ) def _module_ordinary_array_member_setter( @@ -7465,14 +7581,6 @@ def _pointer_holder_field_bridge_name( f"{derived.owner_path}.{field.name}", f"field:pointer:{action}" ).symbol_name - def _derived_field_callback_interface_name( - self, - derived: DerivedTypePlan, - field: DerivedFieldPlan, - ) -> str: - """Return the consumer-interface name associated with one direct derived field.""" - return f"prik_field_{self._derived_field_symbol(derived, field)}_consumer" - def _derived_handle_bridge_name( self, derived: DerivedTypePlan, @@ -7509,14 +7617,6 @@ def _module_member_bridge_name( ".".join((variable.owner_path, *member.path)), f"field:module:{action}" ).symbol_name - def _module_member_callback_interface_name( - self, - variable: ModuleVariablePlan, - member: DerivedMemberPathPlan, - ) -> str: - """Return the consumer-interface name associated with one module member.""" - return f"prik_module_field_{self._module_member_symbol(variable, member)}_consumer" - def _module_member_handle_bridge_name( self, variable: ModuleVariablePlan, @@ -8047,37 +8147,11 @@ def _module_descriptor_callback_interfaces(self, plan: ModulePlan) -> tuple[Fort def _derived_array_callback_interfaces(self, plan: ModulePlan) -> tuple[FortranInterface, ...]: """Declare standard-descriptor callbacks for live ordinary array fields.""" procedures = ( - *self._direct_ordinary_array_callback_interfaces(plan), - *self._module_ordinary_array_callback_interfaces(plan), *self._direct_handle_callback_interfaces(plan), *self._module_handle_callback_interfaces(plan), ) return (FortranInterface(procedures),) if procedures else () - def _direct_ordinary_array_callback_interfaces(self, plan: ModulePlan) -> tuple: - """Return callback interfaces required by direct ordinary-array field procedures.""" - return tuple( - self._ordinary_array_callback_interface( - field, - self._derived_field_callback_interface_name(derived, field), - ) - for derived in self._derived_types(plan) - for field in derived.fields - if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR - ) - - def _module_ordinary_array_callback_interfaces(self, plan: ModulePlan) -> tuple: - """Return callback interfaces required by module ordinary-array member procedures.""" - return tuple( - self._ordinary_array_callback_interface( - member.field, - self._module_member_callback_interface_name(variable, member), - ) - for variable in self._derived_member_proxy_variables(plan) - for member in variable.derived.member_paths - if member.field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR - ) - def _direct_handle_callback_interfaces(self, plan: ModulePlan) -> tuple: """Return callback interfaces required by direct native-array-handle field procedures.""" return tuple( @@ -8102,31 +8176,6 @@ def _module_handle_callback_interfaces(self, plan: ModulePlan) -> tuple: if member.field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE ) - def _ordinary_array_callback_interface( - self, - field: DerivedFieldPlan, - name: str, - ) -> FortranInterfaceProcedure: - """Return one element- and rank-typed descriptor consumer interface.""" - array = field.array - if array is None or array.rank is None: - raise ValueError(f"Ordinary array field {field.owner_path!r} has no callback rank") - scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - return FortranInterfaceProcedure( - name=name, - imports=(self._iso_symbol(field.semantic_type_name), "c_ptr"), - parameters=( - FortranParameter( - "value", - scalar.fortran_spelling, - (self._array_dimension_attribute(array.rank), "intent(in)"), - ), - FortranParameter("context", "type(c_ptr)", ("value",)), - ), - is_subroutine=True, - bind_name=name, - ) - def _native_handle_callback_interface( self, field: DerivedFieldPlan, diff --git a/prik/codegen/nodes.py b/prik/codegen/nodes.py index 54fd96dc2..5dce9d367 100644 --- a/prik/codegen/nodes.py +++ b/prik/codegen/nodes.py @@ -31,6 +31,35 @@ class BackendScalarType(StageRecord): python_type_name: str | None = None python_module_result_kind: str | None = None cfi_type_spelling: str | None = None + # Array facts, where an element aliased in a NumPy buffer is described + # differently from the same type crossing as a scalar value. A Fortran + # logical is the only such type: a scalar converts to a Python bool, while + # an array is aliased and must report the width its elements really have. + # Every other type leaves these unset and reuses its scalar spellings. + array_numpy_type_macro: str | None = None + array_element_c_spelling: str | None = None + array_python_type_name: str | None = None + array_fortran_spelling: str | None = None + + @property + def array_numpy_type(self) -> str | None: + """Return the NumPy type macro describing one aliased array element.""" + return self.array_numpy_type_macro or self.numpy_type_macro + + @property + def array_c_spelling(self) -> str: + """Return the C type whose width one aliased array element occupies.""" + return self.array_element_c_spelling or self.c_spelling + + @property + def array_dtype_name(self) -> str | None: + """Return the NumPy dtype expression one aliased array reports.""" + return self.array_python_type_name or self.python_type_name + + @property + def array_fortran_type(self) -> str: + """Return the Fortran type declaring one aliased array's elements.""" + return self.array_fortran_spelling or self.fortran_spelling @dataclass diff --git a/prik/codegen/primitive_scalar_types.py b/prik/codegen/primitive_scalar_types.py index fb11ece46..1a892cc49 100644 --- a/prik/codegen/primitive_scalar_types.py +++ b/prik/codegen/primitive_scalar_types.py @@ -142,11 +142,61 @@ def expression_for(cls, semantic_dtype: str | None) -> str: ) +# A Fortran logical array is aliased element for element, so its NumPy dtype has +# to occupy the same width. `logical(c_bool)` is one byte holding zero or one -- +# the compiler profiles request the flag that guarantees it -- which is exactly +# what `numpy.bool_` describes, so it is exposed as one. No wider kind can be: +# NumPy has no Boolean larger than a byte, and a one-byte dtype cannot describe +# four-byte elements, so those report the integer of matching width instead and +# are read back with `.astype(bool)`. +# Only an array element takes the declared width. A logical value crosses the C +# boundary as one byte whatever its Fortran kind, and the bridge converts it, +# so the scalar spelling stays `logical(c_bool)` for every name. +_BOOLEAN_FORTRAN_SPELLINGS = { + "Bool": "logical(c_bool)", + "Bool8": "logical(c_bool)", + "Bool16": "logical(2)", + "Bool32": "logical(4)", + "Bool64": "logical(8)", +} +_BOOLEAN_ARRAY_NUMPY_MACROS = { + "Bool": "NPY_BOOL", + "Bool8": "NPY_BOOL", + "Bool16": "NPY_INT16", + "Bool32": "NPY_INT32", + "Bool64": "NPY_INT64", +} +_BOOLEAN_ARRAY_C_SPELLINGS = { + "Bool": "bool", + "Bool8": "bool", + "Bool16": "int16_t", + "Bool32": "int32_t", + "Bool64": "int64_t", +} +_BOOLEAN_ARRAY_DTYPE_NAMES = { + "Bool": "numpy.bool_", + "Bool8": "numpy.bool_", + "Bool16": "numpy.int16", + "Bool32": "numpy.int32", + "Bool64": "numpy.int64", +} + + class PrimitiveScalarTypeRegistry: """Return first-lane scalar facts without coupling binding and bridge emitters.""" TYPES: ClassVar[dict[str, BackendScalarType]] = { - **{name: replace(_BOOL_BACKEND_TYPE, semantic_name=name) for name in BOOLEAN_SEMANTIC_TYPE_NAMES}, + **{ + name: replace( + _BOOL_BACKEND_TYPE, + semantic_name=name, + array_fortran_spelling=_BOOLEAN_FORTRAN_SPELLINGS[name], + array_numpy_type_macro=_BOOLEAN_ARRAY_NUMPY_MACROS[name], + array_element_c_spelling=_BOOLEAN_ARRAY_C_SPELLINGS[name], + array_python_type_name=_BOOLEAN_ARRAY_DTYPE_NAMES[name], + ) + for name in BOOLEAN_SEMANTIC_TYPE_NAMES + }, "Int8": BackendScalarType( semantic_name="Int8", c_spelling="int8_t", diff --git a/prik/compiler/compiler_profiles.py b/prik/compiler/compiler_profiles.py index 58fdef46f..c38015af6 100644 --- a/prik/compiler/compiler_profiles.py +++ b/prik/compiler/compiler_profiles.py @@ -100,6 +100,7 @@ def _language( release_flags: tuple[str, ...], general_flags: tuple[str, ...], optional_general_flags: tuple[str, ...] = (), + logical_interop_flags: tuple[str, ...] = (), standard_flags: tuple[str, ...], module_output_flag: str | None = None, openmp: dict[str, tuple[str, ...]] | None = None, @@ -113,6 +114,7 @@ def _language( "release_flags": release_flags, "general_flags": general_flags, "optional_general_flags": optional_general_flags, + "logical_interop_flags": logical_interop_flags, "standard_flags": standard_flags, "mpi": {}, "openmp": openmp or {}, @@ -143,6 +145,12 @@ def _language( openmp={"flags": ("-fopenmp",), "libs": ("gomp",)}, openacc={"flags": ("-ta=multicore", "-Minfo=accel")}, ) +# A Fortran `logical` has no fixed representation, and several compilers default +# to one their own C compiler cannot read: `logical(c_bool)` is interoperable +# with `_Bool`, which holds zero or one, and Intel and PGI otherwise store all +# bits set for `.true.`. Each offers a flag selecting the interoperable form, so +# the profiles below request it wherever one is needed; gfortran, Cray and IBM XL +# already use it. See https://www.fortran90.org/src/gotchas.html for the survey. _GNU_FORTRAN = _language( "gfortran", "mpif90", @@ -182,6 +190,7 @@ def _language( debug_flags=("-check", "bounds", "-g", "-O0"), release_flags=("-O3", "-DNDEBUG"), general_flags=("-fPIC", "-fpp"), + logical_interop_flags=("-standard-semantics",), standard_flags=("-std=f2003",), module_output_flag="-module", openmp={"flags": ("-qopenmp", "-nostandard-realloc-lhs"), "libs": ("iomp5",)}, @@ -204,6 +213,7 @@ def _language( debug_flags=("-Mbounds", "-g", "-O0"), release_flags=("-O3", "-DNDEBUG"), general_flags=("-fPIC", "-cpp"), + logical_interop_flags=("-Munixlogical",), standard_flags=("-Mstandard",), module_output_flag="-module", openmp={"flags": ("-mp",)}, @@ -236,6 +246,7 @@ def _language( debug_flags=("-Mbounds", "-g", "-O0"), release_flags=("-O3", "-DNDEBUG"), general_flags=("-fPIC", "-cpp"), + logical_interop_flags=("-Munixlogical",), standard_flags=("-Mstandard",), module_output_flag="-module", openmp={"flags": ("-mp",)}, diff --git a/prik/compiler/compilers.py b/prik/compiler/compilers.py index 1ddf5b549..46c5bc5e4 100644 --- a/prik/compiler/compilers.py +++ b/prik/compiler/compilers.py @@ -60,12 +60,17 @@ def from_fortran_executable( debug: bool = False, execute_commands: bool = True, search_path: str | None = None, + standard_logicals: bool = True, ) -> Compiler: """Create a mixed toolchain whose final link uses one Fortran driver. When ``c_executable`` is omitted, the Fortran driver's matching C compiler is selected. An explicit C executable keeps C probing and C compilation on the same driver while Fortran still owns the link. + + ``standard_logicals`` requests the option that makes a Fortran + ``logical`` interoperable with C. Decline it only to match objects + already built without it, whose stored values C cannot read. """ resolved_fortran = shutil.which(executable, path=search_path) if resolved_fortran is None: @@ -105,6 +110,7 @@ def from_fortran_executable( execute_commands=execute_commands, search_path=search_path, executables={"fortran": resolved_fortran, "c": resolved_c}, + standard_logicals=standard_logicals, ) @classmethod @@ -172,9 +178,11 @@ def __init__( execute_commands: bool = True, search_path: str | None = None, executables: Mapping[str, str] | None = None, + standard_logicals: bool = True, ) -> None: self._toolchain = self._load_toolchain(vendor) self._debug = debug + self._standard_logicals = standard_logicals self._execute_commands = execute_commands self._search_path = search_path self._executables = {str(language): str(command) for language, command in (executables or {}).items()} @@ -352,6 +360,12 @@ def _flags( profile = "debug_flags" if self._debug else "release_flags" values = [*self._strings(language.get(profile, ())), *self._strings(language.get("general_flags", ()))] values.extend(self._supported_optional_flags(executable, language.get("optional_general_flags", ()))) + # A Fortran `logical` has no fixed representation, and some compilers + # default to one their own C compiler cannot read. The profile names the + # option selecting the interoperable form; a caller can decline it when + # linking objects already built the other way. + if self._standard_logicals: + values.extend(self._strings(language.get("logical_interop_flags", ()))) for tool in sorted(set(tools)): if tool != "python": values.extend(self._strings(self._tool_mapping(language, tool).get("flags", ()))) diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index abb5f43b1..b8becc7a3 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -619,6 +619,7 @@ def _new_compiler( input_compiler: str | None = None, input_c_compiler: str | None = None, requires_fortran: bool = True, + standard_logicals: bool = True, ) -> Compiler: """Create the compiler configured for generated wrapper code. @@ -626,6 +627,10 @@ def _new_compiler( paired C compiler. A C-only plan uses ``input_c_compiler`` directly, so it neither discovers nor requires a Fortran compiler. The choice comes from explicit build-language records, never source suffixes. + + ``standard_logicals`` requests the option that makes a Fortran ``logical`` + interoperable with C, which some compilers do not select by default. Decline + it only to match objects already built the other way. """ search_path = get_condaless_search_path("verbose") if requires_fortran: @@ -635,6 +640,7 @@ def _new_compiler( debug=debug, execute_commands=execute_commands, search_path=search_path, + standard_logicals=standard_logicals, ) return Compiler.from_c_executable( input_c_compiler or "cc", @@ -652,6 +658,7 @@ def _c_build_compiler_and_preprocessing( requires_fortran: bool, execute_commands: bool, debug: bool, + standard_logicals: bool, ) -> tuple[Compiler | None, PreprocessingConfig]: """Select coherent C preprocessing and an optional early mixed compiler. @@ -666,6 +673,7 @@ def _c_build_compiler_and_preprocessing( compiler = _new_compiler( execute_commands=execute_commands, debug=debug, + standard_logicals=standard_logicals, input_compiler=input_compiler, requires_fortran=True, ) @@ -3284,6 +3292,7 @@ def build_fortran_extension( wrapper_compiler_debug: bool = False, wrapper_fortran_flags: Iterable[str] | None = None, wrapper_c_flags: Iterable[str] | None = None, + standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3351,6 +3360,15 @@ def build_fortran_extension( verbose, wrapper_compiler_debug, wrapper_fortran_flags, wrapper_c_flags Build progress output, generated-wrapper debug mode, and additional flags for generated bridge and binding compilation. + standard_logicals + Pass the compiler option that gives a Fortran ``logical`` the + representation C expects: ``-standard-semantics`` on Intel and + ``-Munixlogical`` on PGI/NVIDIA. On by default, because without it + those compilers store all bits set for ``.true.`` and a + ``logical(c_bool)`` reaching C holds ``255`` where ``_Bool`` is defined + to hold ``1``. Set false only to link prebuilt Intel objects compiled + without the option, which is also required for link compatibility + because it changes Intel module symbol mangling. Returns ------- @@ -3427,6 +3445,7 @@ def build_fortran_extension( compiler = _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, + standard_logicals=standard_logicals, input_compiler=preprocessing.compiler if preprocessing.uses_compiler else None, ) native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) @@ -3496,6 +3515,7 @@ def build_c_extension( wrapper_compiler_debug: bool = False, wrapper_fortran_flags: Iterable[str] | None = None, wrapper_c_flags: Iterable[str] | None = None, + standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3512,6 +3532,11 @@ def build_c_extension( C functions and can explicitly select declarations from included headers. ``native_c_sources`` adds separately compiled C inputs, while explicit Fortran inputs are supported only as ordinary link dependencies. + ``standard_logicals`` controls whether those Fortran inputs are compiled + with the option that gives a ``logical`` the representation C expects + (``-standard-semantics`` on Intel, ``-Munixlogical`` on PGI/NVIDIA); it is + on by default and should be turned off only to link prebuilt Intel objects + compiled without it. ``preprocessing`` supplies the C preprocessing configuration used to expand ``sources`` before parsing; the default runs the selected C compiler. @@ -3549,6 +3574,7 @@ def build_c_extension( requires_fortran=requires_fortran, execute_commands=not generation_only, debug=wrapper_compiler_debug, + standard_logicals=standard_logicals, ) parsed_sources = tuple(_parse_c_wrapper_source(path, preprocessing) for path in source_paths) # Fail forms that are intrinsically outside the primitive lane before the @@ -3564,6 +3590,7 @@ def build_c_extension( compiler = compiler or _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, + standard_logicals=standard_logicals, input_compiler=input_compiler, input_c_compiler=input_c_compiler, requires_fortran=requires_fortran, @@ -3662,6 +3689,7 @@ def build_pyi_extension( wrapper_compiler_debug: bool = False, wrapper_fortran_flags: Iterable[str] | None = None, wrapper_c_flags: Iterable[str] | None = None, + standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3713,6 +3741,11 @@ def build_pyi_extension( verbose, wrapper_compiler_debug, wrapper_fortran_flags, wrapper_c_flags Progress, generated-wrapper debug mode, and generated bridge/binding compiler flags. + standard_logicals + Pass the compiler option that gives a Fortran ``logical`` the + representation C expects (``-standard-semantics`` on Intel, + ``-Munixlogical`` on PGI/NVIDIA). On by default; set false only to + link prebuilt Intel objects compiled without it. Returns ------- @@ -3797,6 +3830,7 @@ def build_pyi_extension( compiler = _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, + standard_logicals=standard_logicals, input_compiler=input_compiler, input_c_compiler=selected_input_c_compiler, requires_fortran=_native_inputs_require_fortran(native_inputs) or native_language == "fortran", diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 6a5f29611..89a254d9b 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -1257,6 +1257,10 @@ def _module_borrowed_array_view_diagnostics( diagnostics.append(self._diagnostic(plan.owner_path, "module-array-view-has-unrelated-facet", None)) if plan.entrypoint.getter_role is None: diagnostics.append(self._diagnostic(plan.owner_path, "missing-module-array-getter-role", None)) + # The route to the array's base address is a policy decision. Bridge + # lowering reads it; it must never fall back to one when it is absent. + if plan.array_address is None: + diagnostics.append(self._diagnostic(plan.owner_path, "missing-module-array-address-mechanism", None)) if plan.bridge.native_assignment is not AssignmentMode.NONE: diagnostics.append( self._diagnostic( @@ -1948,12 +1952,9 @@ def _array_writeback_abi_diagnostics( if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER and ( plan.mutates_native or self._publishes_array_replacement(plan) ): - if plan.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY: - expected = ArrayWritebackABI.NOT_APPLICABLE - elif plan.datatype_family is DatatypeFamily.BOOL: - expected = ArrayWritebackABI.LOGICAL_LOW_BIT_INT8 - else: - expected = ArrayWritebackABI.NATIVE_ARRAY + # Every element type is written back the same way: a Boolean one + # already holds the zero or one its interoperable form requires. + expected = ArrayWritebackABI.NATIVE_ARRAY if plan.array_writeback_abi is expected: return () return ( diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 43df37715..17d54923d 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -700,17 +700,19 @@ def _string_field_operations(self, owner, field, route, owner_path, owner_parame def _ordinary_array_field_operations(self, owner, field, route, owner_path, owner_parameter): owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () - callback = self._descriptor_callback_parameter( - semantic_type_name=field.semantic_type_name, - rank=field.array.rank, - descriptor_kind=None, + # A fixed field reports its base address and extents, the same shape a + # fixed module array uses. Its rank is fixed and its storage contiguous, + # so a descriptor would carry nothing the extents do not already give. + extents = tuple( + self._int64_parameter(f"extent_{axis}", reference=True, intent="out") for axis in range(field.array.rank) ) operations = [ self._operation( owner_path, f"field:{route}:get", self._field_symbol(owner, field, route, "get"), - (*owner_values, callback, self._opaque_parameter("context")), + (*owner_values, *extents), + self._opaque_result(), ) ] if field.setter_action is SetterAction.WRITE_THROUGH: @@ -1019,8 +1021,12 @@ def _module_native_array_signature(self, variable, handle, operation): if operation is NativeArrayOperation.ELEMENT_LENGTH: return NativeEntrypointSignaturePlan((), self._int64_result()) if operation is NativeArrayOperation.ARRAY_ACTUAL: + # Reading the descriptor already hands the consumer everything an + # actual needs, so this operation would repeat that procedure + # exactly. It is left unplanned and the binding calls the + # descriptor symbol with a callback that keeps only the address. if self._uses_module_allocatable_descriptor(variable): - return self._module_descriptor_callback_signature(variable, handle) + return None return NativeEntrypointSignaturePlan((), self._opaque_result()) if operation is NativeArrayOperation.SHAPE: extents = tuple( diff --git a/prik/planning/models.py b/prik/planning/models.py index b0d5cb33b..145d7ce71 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -65,6 +65,7 @@ DirectResultABI, DeclarationCallableAction, ExternalDeclarationMode, + ModuleArrayAddressMechanism, ModuleGetterAction, ModuleObjectAccessMechanism, NativeArrayDescriptorInterop, @@ -783,6 +784,10 @@ class ModuleVariablePlan(StageRecord): derived: DerivedModuleObjectPlan | None = None character_length: int | None = None docstring: str | None = None + # Present only for a borrowed fixed-array view. Both backends read it: the + # bridge to reach the address, the binding to define the C helper that one + # of the two mechanisms calls. It is therefore a shared fact, not a facet. + array_address: ModuleArrayAddressMechanism | None = None @dataclass diff --git a/prik/planning/planner.py b/prik/planning/planner.py index b681ef79f..c71f91d10 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -1153,6 +1153,7 @@ def _module_variable_plan( native_assignment=policy.native_assignment, ), character_length=policy.character_length, + array_address=policy.array_address, array=self._array_plan(policy.array, policy.owner_path), native_array_handle=self._native_array_handle_plan(policy.native_array_handle, policy.owner_path), derived=( diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 0c397b22d..b54752334 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -1622,7 +1622,7 @@ def _native_array_to_numpy_policy( return _native_array_pointer_to_numpy_policy(semantic_type) if decision.is_blocked: return "unsupported" - if handle_kind == "borrowed_module_descriptor" and not semantic_type.metadata.get("aliased"): + if handle_kind == "borrowed_module_descriptor": return "descriptor_view" return "borrowed_view" @@ -1697,11 +1697,7 @@ def _native_array_descriptor_interop_requirement( """Return the C-descriptor interop mechanism required by a supported handle.""" if descriptor_kind == "allocatable" and handle_kind == "owned_result_descriptor": return "owned_allocatable_c_descriptor" - if ( - descriptor_kind == "allocatable" - and handle_kind == "borrowed_module_descriptor" - and not semantic_type.metadata.get("aliased") - ): + if descriptor_kind == "allocatable" and handle_kind == "borrowed_module_descriptor": return "module_allocatable_c_descriptor" if descriptor_kind == "pointer" and handle_kind != "unsupported": return "pointer_c_descriptor" diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 86657f67d..73978f6ec 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -93,6 +93,7 @@ CallbackThreadAction, CallbackGILAction, CallbackFatalAction, + ModuleArrayAddressMechanism, ModuleGetterAction, ModuleObjectAccessMechanism, DerivedFieldAccessMechanism, @@ -1134,6 +1135,7 @@ def _ordinary_array_module_variable_policy( array: ArrayHandoffPolicy, ) -> ModuleVariablePolicy: """Build one borrowed ordinary module-array view policy.""" + address = _ordinary_array_module_address_mechanism(variable) blockers = _ordinary_array_module_variable_blockers(variable, getter, setter, array) return ModuleVariablePolicy( **_module_variable_policy_base(variable, module_name, owner_path), @@ -1148,9 +1150,25 @@ def _ordinary_array_module_variable_policy( supported=not blockers, blockers=tuple(blockers), array=array, + array_address=address, ) +def _ordinary_array_module_address_mechanism( + variable: models.SemanticVariable, +) -> ModuleArrayAddressMechanism: + """Select how the bridge obtains one fixed module array's base address. + + Addressable storage names itself directly. An ordinary declaration cannot, + so its whole array is handed to C through an assumed-size dummy, which + receives the bare base address. Both mechanisms borrow the same live + storage; only the route to its address differs. + """ + if variable.semantic_type.metadata.get("aliased"): + return ModuleArrayAddressMechanism.TARGET_ADDRESS + return ModuleArrayAddressMechanism.CAPTURED_ADDRESS + + def _constant_array_module_variable_policy( variable: models.SemanticVariable, module_name: str, @@ -1265,8 +1283,6 @@ def _ordinary_array_module_variable_blockers( blockers.append("ordinary module array requires one concrete fixed rank") if variable.semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: blockers.append("ordinary module array requires a primitive numeric element type") - if not variable.semantic_type.metadata.get("aliased"): - blockers.append("ordinary module array requires addressable Aliased target storage") expected_getter = ( ("owner", getter.owner, OwnershipOwner.NATIVE), ("transfer", getter.transfer, TransferMode.BORROWED_VIEW), @@ -6495,6 +6511,19 @@ def _native_array_assignment(value: str, owner_path: str) -> AssignmentMode: return _native_array_enum(AssignmentMode, value, owner_path, "native setter") +def _native_array_actual_dtype(argument: models.SemanticArgument) -> str | None: + """Return the NumPy dtype a handle must carry to stand in for this actual. + + A character actual is matched on its declared width as well as its kind, + because a handle whose elements are a different length describes different + storage. A width the declaration does not fix cannot be matched at all. + """ + if argument.semantic_type.name == "String": + length = _character_length(argument.semantic_type) + return None if length is None else f"S{length}" + return _NUMPY_DTYPE_NAMES.get(argument.semantic_type.name) + + def _native_array_actual_policy( argument: models.SemanticArgument, decision: OwnershipDecision, @@ -6508,15 +6537,13 @@ def _native_array_actual_policy( or array.native_order != array.order or array.rank is None or argument.optional - or argument.semantic_type.name == "String" or decision.transfer is TransferMode.COPY_RETURN or decision.python_barrier_action is not PythonBarrierAction.ARRAY_STORAGE or decision.native_barrier_action is not NativeBarrierAction.PASS_ARRAY_BUFFER ): return None - try: - dtype = _NUMPY_DTYPE_NAMES[argument.semantic_type.name] - except KeyError: + dtype = _native_array_actual_dtype(argument) + if dtype is None: return None return NativeArrayActualPolicy( accepted_sources=( @@ -6547,6 +6574,8 @@ def _native_array_module_variable_blockers( blockers = [] if variable.visibility != "public": blockers.append("native array module variable is not public") + # A descriptor handle hands out the same element-for-element view a fixed + # array does, so it carries the same width requirement. if handle.handle_kind is not NativeArrayHandleKind.BORROWED_MODULE_DESCRIPTOR: blockers.append(f"native array module handle kind {handle.handle_kind.value!r} is unsupported") if getter is None or getter.is_blocked or getter.kind is not ObjectKind.NUMPY_ARRAY: @@ -7162,17 +7191,12 @@ def _array_logical_argument_abi( semantic_type = argument.semantic_type if not is_boolean_semantic_type_name(semantic_type.name) or int(semantic_type.rank or 0) <= 0: return ArrayLogicalABI.NOT_APPLICABLE, None, False, False - source_type = _fortran_logical_native_type(argument) - if source_type is None: - if semantic_type.name in {"Bool", "Bool8"}: - return ArrayLogicalABI.C_BOOL_VIEW, "logical(c_bool)", False, False - copy_in = bool(getattr(argument, "_source_reads_argument", True)) - return ArrayLogicalABI.NATIVE_KIND_COPY, None, copy_in, decision.mutates_native - if "".join(source_type.casefold().split()) == "logical(kind=c_bool)": - return ArrayLogicalABI.C_BOOL_VIEW, "logical(c_bool)", False, False - copy_in = bool(getattr(argument, "_source_reads_argument", True)) - copy_out = decision.mutates_native - return ArrayLogicalABI.NATIVE_KIND_COPY, source_type, copy_in, copy_out + # The buffer is a NumPy integer of the element's own width, so the native + # pointer describes the caller's storage exactly and no directional copy is + # required for any logical kind. + # A spelling the source did not record is left unset; backend lowering then + # resolves the width from the semantic type itself. + return ArrayLogicalABI.C_BOOL_VIEW, _fortran_logical_native_type(argument), False, False def _logical_argument_bridge_action( @@ -7342,17 +7366,16 @@ def _array_writeback_abi( ) -> ArrayWritebackABI: """Complete mutable ordinary-array byte normalization before planning. - Exact-kind logical copies canonicalize bytes while copying out, so only a - direct ``c_bool`` view needs the separate low-bit normalization pass. + A Boolean array needs no more than any other kind. Its elements already + hold the zero or one a C ``_Bool`` is defined to hold, because the compiler + profiles request the option that guarantees it, so there is nothing left to + reduce. Reducing anyway could not help a translation unit built without + that option either: such a compiler represents false as the complement of + true, which no test applied here could tell from a true value. """ + del logical_abi if array is None or handoff_mode is not ArgumentHandoffMode.ARRAY_BUFFER or not decision.mutates_native: return ArrayWritebackABI.NOT_APPLICABLE - if is_boolean_semantic_type_name(semantic_type.name): - return ( - ArrayWritebackABI.LOGICAL_LOW_BIT_INT8 - if logical_abi is ArrayLogicalABI.C_BOOL_VIEW - else ArrayWritebackABI.NOT_APPLICABLE - ) return ArrayWritebackABI.NATIVE_ARRAY diff --git a/prik/policy/models.py b/prik/policy/models.py index 345682357..4d718375f 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -311,6 +311,24 @@ class ModuleGetterAction(str, Enum): DERIVED_OBJECT = "derived_object" +class ModuleArrayAddressMechanism(str, Enum): + """Completed native mechanism that yields a fixed module array's base address. + + ``TARGET_ADDRESS`` applies to storage the declaration made addressable, where + ``c_loc`` names the array directly. ``CAPTURED_ADDRESS`` applies to an + ordinary array without that attribute: ``c_loc`` cannot name it, so the whole + array is handed to ``prik_capture_address``, a ``bind(C)`` primitive whose + assumed-type assumed-size dummy receives the bare base address. The + Fortran side forms no pointer and claims no target. The captured address is + valid for as long as the module variable keeps its storage, which the Fortran + standard does not guarantee across the program's lifetime; see the module + variable guide for the responsibility that carries. + """ + + TARGET_ADDRESS = "target_address" + CAPTURED_ADDRESS = "captured_address" + + class ModuleObjectAccessMechanism(str, Enum): """Completed native access path for one derived module value.""" @@ -936,6 +954,7 @@ class ModuleVariablePolicy: blockers: tuple[str, ...] = () character_length: int | None = None array: ArrayHandoffPolicy | None = None + array_address: ModuleArrayAddressMechanism | None = None native_array_handle: NativeArrayHandleWrapperPolicy | None = None derived: DerivedModuleObjectPolicy | None = None diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 458ec150e..459688772 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -16,6 +16,11 @@ _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT = ctypes.c_int(1) _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT_ADDRESS = ctypes.addressof(_PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT) +# The base a C-built descriptor starts from when no Fortran bound is available. +# Only a handle that reports its own descriptor can carry a declared lower +# bound; one reduced to a bare address has none to report. +_UNKNOWN_DESCRIPTOR_LOWER_BOUND = 0 + class _OwnerRetainedNDArray(np.ndarray): """Internal ndarray view carrying a strong reference to native owner state.""" @@ -736,7 +741,7 @@ def _descriptor_for_binding( return descriptor if _is_pointer_descriptor_record(descriptor): if _pointer_descriptor_base_addr(descriptor) == 0: - return self._contiguous_descriptor_record(0, None) + return self._absent_descriptor_record() _validate_pointer_descriptor_itemsize(descriptor, np.dtype(self.dtype)) descriptor_shape, _ = _pointer_descriptor_shape_and_strides(descriptor) if len(descriptor_shape) != self.rank: @@ -764,8 +769,31 @@ def _descriptor_record_for_binding(self) -> Any: ) return descriptor + def _absent_descriptor_record(self) -> dict[str, Any]: + """Return descriptor fields for storage that is not there. + + Every axis is empty, so the bounds describe nothing and no value is + being asserted about an array that does not exist. + """ + dtype = np.dtype(self.dtype) + return { + "base_addr": 0, + "elem_len": dtype.itemsize, + "rank": self.rank, + "dim": [{"lower_bound": 0, "extent": 0, "sm": dtype.itemsize} for _axis in range(self.rank)], + } + def _contiguous_descriptor_record(self, address: int, shape: tuple[int, ...] | None) -> dict[str, Any]: - """Build standard descriptor fields for a contiguous native array actual.""" + """Build standard descriptor fields for a contiguous native array actual. + + A bare address carries no bounds, so every axis is described from the + zero base a C-built descriptor starts at rather than from a Fortran + bound this cannot know. A handle that can name its bounds -- any that + reports its own descriptor -- must do so through its descriptor + operation, which is where a declared lower bound survives; generated + module handles all take that route, so nothing prik emits relies on the + base chosen here. + """ dtype = np.dtype(self.dtype) extents = (0,) * self.rank if shape is None else shape strides = [] @@ -778,7 +806,7 @@ def _contiguous_descriptor_record(self, address: int, shape: tuple[int, ...] | N "elem_len": dtype.itemsize, "rank": self.rank, "dim": [ - {"lower_bound": 0, "extent": int(extent), "sm": int(axis_stride)} + {"lower_bound": _UNKNOWN_DESCRIPTOR_LOWER_BOUND, "extent": int(extent), "sm": int(axis_stride)} for extent, axis_stride in zip(extents, strides, strict=True) ], } diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index b4f96011f..e1b3e87ec 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -37,6 +37,28 @@ #define PRIK_NO_INLINE #endif +#ifdef PRIK_BINDING_CAPTURE_ADDRESS +/* + * Report the address a caller already passed by reference. + * + * Fortran's `c_loc` can only name a variable that is a target or a pointer, so + * an ordinary declaration -- a module array without `target`, say -- has no way + * to state its own address. Handing the whole object to a `bind(C)` procedure + * does: an assumed-type assumed-size dummy is passed as the bare base address, + * so the parameter below already is where that object lives and only has to be + * handed back. It does the job of `c_loc` exactly where `c_loc` is not + * available, for any type and any rank, because it never inspects what it is + * given. Nothing on the Fortran side forms a pointer or claims a target. + * + * This needs external linkage for the generated bridge to call it, so it is + * defined only in the translation unit that opts in with this macro. + */ +void *prik_capture_address(void *base) +{ + return base; +} +#endif + typedef void (*prik_native_array_release_fn)(void *descriptor); /* diff --git a/tests/fortran/_support/wrapper_build.py b/tests/fortran/_support/wrapper_build.py index 584e11f27..3fe6c0834 100644 --- a/tests/fortran/_support/wrapper_build.py +++ b/tests/fortran/_support/wrapper_build.py @@ -452,8 +452,18 @@ def _result_dtype(expected): return np.asarray(expected).dtype +def _array_element_dtype(expected): + """Return the dtype one Fortran array element reports to Python. + + A `logical(c_bool)` element is one byte holding zero or one, which is what + `numpy.bool_` describes, so it needs no adjustment. Only kinds wider than a + byte report an integer instead, and no fixture here exercises one. + """ + return _result_dtype(expected) + + def _array_argument(value, size: int, *, strided: bool): - dtype = np.asarray(value).dtype + dtype = _array_element_dtype(value) if strided: storage = np.zeros(2 * size, dtype=dtype) array = storage[::2] @@ -464,7 +474,7 @@ def _array_argument(value, size: int, *, strided: bool): def _array_result(expected, size: int, *, strided: bool): - dtype = _result_dtype(expected) + dtype = _array_element_dtype(expected) if strided: storage = np.zeros(2 * size, dtype=dtype) return storage[1::2] diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 656d9d96e..b79a1a04d 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -1,6 +1,7 @@ """Allocatable result, module-array, and component-view ownership tests.""" import gc +import os import subprocess import sys from pathlib import Path @@ -24,6 +25,18 @@ ALLOCATABLE_VIEW_F90_SOURCE = FIXTURES / "native" / "fallocatable_views_f90.f90" CONTRACT_FIXTURES = FIXTURES / "contracts" pytestmark = pytest.mark.fortran_end_to_end + + +def _allocatable_dummy_handoff_supported() -> bool: + """Report whether this compiler accepts a handle at an allocatable dummy. + + ifx rejects the established descriptor for that argument form regardless of + the bounds it carries, so the round-trip below is checked where it works. + The descriptor facts themselves are asserted on every compiler. + """ + return "ifx" not in os.environ.get("PRIK_TEST_FORTRAN_COMPILER", "gfortran") + + PLAIN_ALLOCATABLE_MODULE_SOURCE = """\ module fallocatable_plain_f90 implicit none @@ -359,3 +372,92 @@ def test_plain_allocatable_module_array_exposes_current_live_view( assert handle.allocated is False assert handle.shape is None assert handle.to_numpy() is None + + +LOWER_BOUND_SOURCE = """ +module falloc_lower_bounds_f90 + use iso_fortran_env, only: int32, real64 + implicit none + real(real64), allocatable :: plain_a(:) + real(real64), allocatable, target :: tgt_a(:) + real(real64), allocatable, target :: defaulted(:) + character(len=5), allocatable, target :: fixed_words(:) + character(len=:), allocatable, target :: deferred_words(:) +contains + function lower_bound_of(x) result(bound) + real(real64), allocatable, intent(in) :: x(:) + integer(int32) :: bound + bound = lbound(x, 1) + end function lower_bound_of + + function element_at(x, index) result(value) + real(real64), allocatable, intent(in) :: x(:) + integer(int32), intent(in) :: index + real(real64) :: value + value = x(index) + end function element_at + + subroutine setup() + allocate(plain_a(5:8)) + plain_a = 1.0d0 + allocate(tgt_a(5:8)) + tgt_a = 2.0d0 + allocate(defaulted(4)) + defaulted = 3.0d0 + allocate(character(len=5) :: fixed_words(5:8)) + fixed_words = 'aaaaa' + allocate(character(len=6) :: deferred_words(5:8)) + deferred_words = 'bbbbbb' + end subroutine setup +end module falloc_lower_bounds_f90 +""" + + +def test_module_allocatable_reports_its_real_lower_bound_with_or_without_target(tmp_path: Path): + """A module allocatable reports the bounds it actually has, either way. + + `target` allows `c_loc` on the variable, but that yields only a base address: + the bounds, strides and element length then have to come from somewhere else. + Reconstructing them hardcoded a lower bound of zero, which is wrong for every + Fortran array — the default is one — and further wrong for a declared `(5:8)`. + Both declarations read the descriptor, so both report 5. + """ + module = _build_text_and_import( + LOWER_BOUND_SOURCE, + "falloc_lower_bounds_f90.f90", + tmp_path, + { + "bind_c_falloc_lower_bounds_f90_wrapper.f90", + "falloc_lower_bounds_f90_wrapper.c", + "falloc_lower_bounds_f90_wrapper.h", + }, + ) + module.setup() + + for name in ("plain_a", "tgt_a", "defaulted"): + handle = getattr(module, name) + record = handle._descriptor_record_for_binding() + # A defaulted allocation still starts at one, which the reconstruction + # also got wrong by reporting zero. + assert record["dim"][0]["lower_bound"] == (1 if name == "defaulted" else 5), name + assert record["dim"][0]["extent"] == 4, name + assert record["elem_len"] == 8, name + # The Python view is unaffected: NumPy indexing stays zero-based. + assert handle.to_numpy().shape == (4,) + + # A character allocatable reads the same descriptor, and its element length + # comes from the array rather than from a width the binding assumed. + for name, width in (("fixed_words", 5), ("deferred_words", 6)): + record = getattr(module, name)._descriptor_record_for_binding() + assert record["dim"][0]["lower_bound"] == 5, name + assert record["elem_len"] == width, name + + # The bound is not a reported fact but part of the value: an allocatable + # dummy adopts the bounds of the descriptor it is given, so a wrong one + # makes the callee index the wrong elements. + if not _allocatable_dummy_handoff_supported(): + return + for name in ("plain_a", "tgt_a"): + handle = getattr(module, name) + assert module.lower_bound_of(handle) == np.int32(5), name + assert module.element_at(handle, np.int32(5)) == handle.to_numpy()[0], name diff --git a/tests/fortran/allocatables/policy/test_allocatable_handle_policy.py b/tests/fortran/allocatables/policy/test_allocatable_handle_policy.py index 4184c58c1..e22ea634d 100644 --- a/tests/fortran/allocatables/policy/test_allocatable_handle_policy.py +++ b/tests/fortran/allocatables/policy/test_allocatable_handle_policy.py @@ -87,6 +87,13 @@ def replace_values( def test_aliased_does_not_change_allocatable_live_view_semantics(): + """A module allocatable is reached through its descriptor either way. + + `Aliased` would allow `c_loc` on the variable, but that yields only a base + address: lower bounds, strides and element length would then have to be + assumed rather than read, and a non-default lower bound makes the assumption + wrong. Both declarations therefore complete to the same descriptor policy. + """ module = parse_pyi_text( """ values: Allocatable[Float64[:]] @@ -100,12 +107,10 @@ def test_aliased_does_not_change_allocatable_live_view_semantics(): values = module.variables[0].metadata[RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA] shared_values = module.variables[1].metadata[RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA] - assert values.to_numpy == "descriptor_view" - assert shared_values.to_numpy == "borrowed_view" + assert values.to_numpy == shared_values.to_numpy == "descriptor_view" + assert values.descriptor_interop == shared_values.descriptor_interop == "module_allocatable_c_descriptor" assert values.owner == shared_values.owner == "native" assert values.borrowed is shared_values.borrowed is True - assert values.descriptor_interop == "module_allocatable_c_descriptor" - assert shared_values.descriptor_interop == "none" def test_owned_allocatable_result_records_local_standard_c_descriptor_build_requirement(): diff --git a/tests/fortran/arrays/codegen/test_array_output_identity.py b/tests/fortran/arrays/codegen/test_array_output_identity.py index 9080447a5..ff2085316 100644 --- a/tests/fortran/arrays/codegen/test_array_output_identity.py +++ b/tests/fortran/arrays/codegen/test_array_output_identity.py @@ -86,35 +86,45 @@ def test_projected_array_lowering_increfs_original_objects_and_reuses_tuple_aggr assert "PyTuple_SET_ITEM(result_obj, 1, result_1_obj)" in c_source -def test_mutable_bool_array_writeback_normalizes_the_aliased_numpy_buffer_in_place(): +def test_mutable_bool_array_writeback_needs_no_normalization(): + """A Boolean array is written back like any other element type. + + Its elements already hold the zero or one a C `_Bool` is defined to hold, + because the compiler profiles request the option that guarantees it, so the + callee leaves nothing behind that has to be reduced afterwards. + """ plan = _logical_output_plan() - arguments = plan.namespaces[0].functions[0].arguments - values, out = arguments[1:] + values, out = plan.namespaces[0].functions[0].arguments[1:] - assert values.array_writeback_abi is ArrayWritebackABI.LOGICAL_LOW_BIT_INT8 - assert out.array_writeback_abi is ArrayWritebackABI.LOGICAL_LOW_BIT_INT8 + assert values.array_writeback_abi is ArrayWritebackABI.NATIVE_ARRAY + assert out.array_writeback_abi is ArrayWritebackABI.NATIVE_ARRAY artifacts = WrapperGenerator().generate(plan) bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") - assert "integer(c_int8_t), pointer, dimension(:) :: out_logical_bytes" in bridge_source assert "call native_invert_flags(n, values, out)" in bridge_source - assert "call c_f_pointer(bound_out, out_logical_bytes, [out_extent_0])" in bridge_source - assert "out_logical_bytes = iand(out_logical_bytes, 1_c_int8_t)" in bridge_source + assert "_logical_bytes" not in bridge_source + assert "iand(" not in bridge_source + +def test_high_rank_bool_array_bridge_stays_inside_the_fortran_line_limit(): + """Free-form Fortran caps a line at 132 columns, whatever the rank. -def test_high_rank_bool_array_writeback_wraps_the_flattened_shape_product(): + A rank-15 array names one extent per axis, so its generated declarations and + calls are the longest prik emits and are where continuation would first be + missed. + """ artifacts = WrapperGenerator().generate(_high_rank_logical_output_plan()) bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") - assert "values_extent_0 * &" in bridge_source - assert "& values_extent_14])" in bridge_source + assert "values_extent_14" in bridge_source assert max(map(len, bridge_source.splitlines())) <= 132 -def test_generator_rejects_a_non_normalized_mutable_bool_array_writeback_abi(): +def test_generator_rejects_a_normalized_mutable_bool_array_writeback_abi(): + """An edited plan cannot reintroduce a normalization pass that is not needed.""" plan = _logical_output_plan() - plan.namespaces[0].functions[0].arguments[-1].array_writeback_abi = ArrayWritebackABI.NATIVE_ARRAY + plan.namespaces[0].functions[0].arguments[-1].array_writeback_abi = ArrayWritebackABI.LOGICAL_LOW_BIT_INT8 with pytest.raises(ValueError, match="invalid-array-writeback-abi"): WrapperGenerator().generate(plan) diff --git a/tests/fortran/arrays/end_to_end/test_array_direct_entrypoint_routing.py b/tests/fortran/arrays/end_to_end/test_array_direct_entrypoint_routing.py index 094963b9b..afec1416a 100644 --- a/tests/fortran/arrays/end_to_end/test_array_direct_entrypoint_routing.py +++ b/tests/fortran/arrays/end_to_end/test_array_direct_entrypoint_routing.py @@ -37,7 +37,7 @@ def test_arrays_all_direct_route_preserves_dtype_values_and_mutation( flags = np.array([True, True, False], dtype=np.bool_, order="F") assert bool(module.all_flags(np.int32(flags.size), flags)) is False inverted = module.invert_flags(np.int32(flags.size), flags) - np.testing.assert_array_equal(flags, np.array([False, False, True], dtype=np.bool_)) + np.testing.assert_array_equal(flags, np.array([False, False, True])) if inverted is not None: np.testing.assert_array_equal(inverted, flags) diff --git a/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py b/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py index c306cbeec..0c336f80a 100644 --- a/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py +++ b/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py @@ -68,7 +68,23 @@ """ -def test_boolean_arrays_copy_only_in_required_directions_for_every_supported_width(tmp_path: Path): +_LOGICAL_KIND_DTYPES = { + "c_bool": np.bool_, + "8": np.bool_, + "16": np.int16, + "32": np.int32, + "64": np.int64, +} + + +def test_boolean_arrays_are_aliased_at_their_own_width_without_any_copy(tmp_path: Path): + """Every logical kind is passed as the caller's own buffer, whatever its width. + + NumPy has no Boolean wider than one byte, so a logical array is described by + the integer of matching width. The widths then agree by construction and the + buffer is aliased directly: no native-kind temporary is declared, nothing is + widened on the way in, and nothing is narrowed on the way out. + """ module = _build_text_and_import( _LOGICAL_KIND_ARRAY_SOURCE, "logical_kind_arrays.f90", @@ -80,19 +96,17 @@ def test_boolean_arrays_copy_only_in_required_directions_for_every_supported_wid }, ) bridge_source = (tmp_path / "bind_c_logical_kind_arrays_wrapper.f90").read_text(encoding="utf-8") - assert bridge_source.count("input_values_native = input_values") == 4 - assert bridge_source.count("inout_values_native = inout_values") == 4 - assert "output_values_native = output_values" not in bridge_source - assert bridge_source.count("output_values = merge(.true._c_bool, .false._c_bool, output_values_native)") == 4 - assert bridge_source.count("inout_values = merge(.true._c_bool, .false._c_bool, inout_values_native)") == 4 + assert "_native = " not in bridge_source + assert "merge(.true._c_bool, .false._c_bool," not in bridge_source assert "call native_exercise_c_bool(n, input_values, output_values, inout_values)" in bridge_source - input_values = np.array([True, False, True, False], dtype=np.bool_) - initial_inout = np.array([False, False, True, True], dtype=np.bool_) - expected_output = np.logical_not(input_values) - expected_inout = np.logical_xor(input_values, initial_inout) - - for suffix in ("c_bool", "8", "16", "32", "64"): - output_values = np.empty(input_values.shape, dtype=np.bool_) + # Written arrays are normalized at the element's own width, not copied. + assert "_logical_bytes" not in bridge_source + assert "iand(" not in bridge_source + + for suffix, dtype in _LOGICAL_KIND_DTYPES.items(): + input_values = np.array([1, 0, 1, 0], dtype=dtype) + initial_inout = np.array([0, 0, 1, 1], dtype=dtype) + output_values = np.empty(input_values.shape, dtype=dtype) inout_values = initial_inout.copy() result = getattr(module, f"exercise_{suffix}")( @@ -103,9 +117,17 @@ def test_boolean_arrays_copy_only_in_required_directions_for_every_supported_wid ) assert result is None - assert input_values.dtype == output_values.dtype == inout_values.dtype == np.dtype(np.bool_) - np.testing.assert_array_equal(output_values, expected_output) - np.testing.assert_array_equal(inout_values, expected_inout) + assert output_values.dtype == inout_values.dtype == np.dtype(dtype), suffix + # The values are Fortran logicals, so they are read back as truth + # rather than compared against any one integer the compiler chose. + np.testing.assert_array_equal( + output_values.astype(bool), np.logical_not(input_values.astype(bool)), err_msg=suffix + ) + np.testing.assert_array_equal( + inout_values.astype(bool), + np.logical_xor(input_values.astype(bool), initial_inout.astype(bool)), + err_msg=suffix, + ) def test_numbered_boolean_pyi_contracts_probe_and_call_every_supported_width(tmp_path: Path): @@ -155,14 +177,15 @@ def exercise_64( ) bridge_source = next(path for path in result.generated_sources if path.suffix == ".f90").read_text(encoding="utf-8") assert "call native_exercise_8(n, input_values, output_values, inout_values)" in bridge_source - assert "logical(kind=2), dimension(input_values_extent_0) :: input_values_native" in bridge_source - assert "logical(kind=4), dimension(input_values_extent_0) :: input_values_native" in bridge_source - assert "logical(kind=8), dimension(input_values_extent_0) :: input_values_native" in bridge_source - - input_values = np.array([True, False, True, False], dtype=np.bool_) - initial_inout = np.array([False, False, True, True], dtype=np.bool_) - for suffix in ("c_bool", "8", "16", "32", "64"): - output_values = np.empty(input_values.shape, dtype=np.bool_) + # Each numbered contract width aliases a buffer of its own size. + for kind in ("logical(c_bool)", "logical(2)", "logical(4)", "logical(8)"): + assert f"{kind}, pointer, contiguous, dimension(:) :: input_values" in bridge_source, kind + assert "_native = " not in bridge_source + + for suffix, dtype in _LOGICAL_KIND_DTYPES.items(): + input_values = np.array([1, 0, 1, 0], dtype=dtype) + initial_inout = np.array([0, 0, 1, 1], dtype=dtype) + output_values = np.empty(input_values.shape, dtype=dtype) inout_values = initial_inout.copy() getattr(module, f"exercise_{suffix}")( @@ -172,5 +195,11 @@ def exercise_64( inout_values, ) - np.testing.assert_array_equal(output_values, np.logical_not(input_values)) - np.testing.assert_array_equal(inout_values, np.logical_xor(input_values, initial_inout)) + np.testing.assert_array_equal( + output_values.astype(bool), np.logical_not(input_values.astype(bool)), err_msg=suffix + ) + np.testing.assert_array_equal( + inout_values.astype(bool), + np.logical_xor(input_values.astype(bool), initial_inout.astype(bool)), + err_msg=suffix, + ) diff --git a/tests/fortran/data_types/codegen/test_primitive_scalar_result_lowering.py b/tests/fortran/data_types/codegen/test_primitive_scalar_result_lowering.py index a4affb595..13e86542d 100644 --- a/tests/fortran/data_types/codegen/test_primitive_scalar_result_lowering.py +++ b/tests/fortran/data_types/codegen/test_primitive_scalar_result_lowering.py @@ -60,7 +60,8 @@ def test_direct_bool_result_normalizes_the_fortran_truth_bit_before_c_conversion assert "integer(c_int8_t) :: result" in fortran_source assert "logical(c_bool) :: c_result" in fortran_source assert "c_result = native_not_flag(value)" in fortran_source - assert "result = iand(transfer(c_result, 0_c_int8_t), 1_c_int8_t)" in fortran_source + # Reduced the way C converts to `_Bool`: any non-zero value is true. + assert "result = merge(1_c_int8_t, 0_c_int8_t, transfer(c_result, 0_c_int8_t) /= 0_c_int8_t)" in fortran_source def test_generator_rejects_a_non_normalized_direct_bool_result_abi(): diff --git a/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py b/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py new file mode 100644 index 000000000..7db8373b4 --- /dev/null +++ b/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py @@ -0,0 +1,89 @@ +"""Bridge lowering for live views over fixed derived-type array fields.""" + +from __future__ import annotations + +from prik.codegen.fortran.bridge import FortranBridgeGenerator +from prik.parsers.fortran.parser import parse_fortran_project +from prik.pipeline.build import _apply_source_python_exports, _merge_wrapper_modules +from prik.planning import WrapperPlanner +from prik.policy.completion import complete_semantic_policies +from prik.printers.fortran import FortranSourcePrinter +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules + + +ARRAY_FIELD_SOURCE = """ +module field_state + use iso_fortran_env, only: real64 + implicit none + + type :: box + real(real64) :: grid(2, 3) + end type box + + type(box) :: plain_box + type(box), target :: tgt_box +end module field_state +""" + + +def _bridge_source(): + parsed = parse_fortran_project({"field_state.f90": ARRAY_FIELD_SOURCE}) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="field_state") + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + return FortranSourcePrinter().visit(FortranBridgeGenerator().visit(plan)) + + +def test_owned_array_field_takes_its_address_through_the_owner_pointer(): + """An owner reached as a pointer makes its components addressable. + + The owner arrives as an address and is associated with a Fortran pointer, so + its components are subobjects of a pointer target. `c_loc` may name them + whatever the field's own declaration said, and no capture is needed. + """ + source = _bridge_source() + + assert "result = c_loc(owner%grid)" in source + assert "extent_0 = int(size(owner%grid, 1), c_int64_t)" in source + + +def test_plain_module_object_field_captures_its_address_in_c(): + """A plain module object is named directly, so nothing about it is a target. + + `c_loc` cannot name a member of a module object that was declared without + `target`, so the address is taken on the C side, exactly as a non-addressable + module array's is. + """ + source = _bridge_source() + + assert "result = prik_capture_address(native_plain_box%grid)" in source + assert "c_loc(native_plain_box%grid)" not in source + + +def _procedure(source: str, name: str) -> str: + """Return the text of one generated procedure, by name.""" + start = source.index(f"function {name}(") + return source[start : source.index(f"end function {name}", start)] + + +def test_array_field_getters_report_extents_instead_of_passing_a_descriptor(): + """A fixed field's rank and contiguity are known, so extents carry everything. + + The earlier lowering handed the component to a C consumer as a descriptor + purely to reach its base address. A fixed component is contiguous and its + rank is fixed, so the base pointer plus one extent per axis says the same + thing without a callback round-trip, and the getter returns an address + rather than driving a consumer. + """ + source = _bridge_source() + + for name in ("bind_c_prik_field_box_grid_get", "bind_c_prik_module_field_plain_box_grid_get"): + getter = _procedure(source, name) + assert "type(c_ptr) :: result" in getter + assert "c_funptr" not in getter + assert "c_f_procpointer" not in getter + + # No descriptor-consumer interface is declared for an ordinary array field. + assert "_grid_consumer" not in source diff --git a/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py b/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py index 350d2e436..3b7c282a1 100644 --- a/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py +++ b/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py @@ -6,7 +6,10 @@ import numpy as np import pytest from prik.runtime.handles import AllocatableArray -from tests.fortran._support.wrapper_build import _build_source_or_generated_pyi_and_import +from tests.fortran._support.wrapper_build import ( + _build_source_or_generated_pyi_and_import, + _build_text_and_import, +) FIXTURES = Path(__file__).parent / "fixtures" DERIVED_ALIAS_F90_SOURCE = FIXTURES / "native" / "fmodule_derived_alias_f90.f90" @@ -76,3 +79,68 @@ def test_aliased_derived_module_object_borrows_native_state( assert isinstance(current_values, AllocatableArray) assert current_values.allocated is False assert current_values.to_numpy() is None + + +PLAIN_DERIVED_ARRAY_FIELD_SOURCE = """ +module fplain_derived_fields_f90 + use iso_fortran_env, only: int32, real64 + implicit none + + type :: box + real(real64) :: grid(2, 3) + integer(int32) :: n + end type box + + type(box) :: plain_box + type(box), target :: tgt_box + +contains + function make_box(seed) result(value) + real(real64), intent(in) :: seed + type(box) :: value + value%grid = seed + value%n = 3 + end function make_box + + subroutine touch_plain() + plain_box%grid(1, 1) = plain_box%grid(1, 1) + 1.0d0 + end subroutine touch_plain +end module fplain_derived_fields_f90 +""" + + +def test_derived_array_fields_are_live_views_without_a_target_declaration(tmp_path: Path): + """An ordinary array component is borrowed live wherever its owner comes from. + + A derived field's address never comes from `c_loc` on the field, so `target` + on the component, the type, or the containing module variable changes + nothing. The owner is reached as a Fortran pointer and its component is + handed to a C consumer during the call, which is what makes the borrow work + for a wrapper-owned instance and for a plain module object alike. + """ + module = _build_text_and_import( + PLAIN_DERIVED_ARRAY_FIELD_SOURCE, + "fplain_derived_fields_f90.f90", + tmp_path, + { + "bind_c_fplain_derived_fields_f90_wrapper.f90", + "fplain_derived_fields_f90_wrapper.c", + "fplain_derived_fields_f90_wrapper.h", + }, + ) + + # A wrapper-owned instance borrows its own component storage. + instance = module.make_box(np.float64(2.0)) + assert instance.grid.shape == (2, 3) + assert instance.grid.flags["F_CONTIGUOUS"] is True + instance.grid[0, 0] = np.float64(9.0) + assert instance.grid[0, 0] == np.float64(9.0) + + # A module object without `target` borrows the same way, in both directions. + module.plain_box.grid[0, 0] = np.float64(5.0) + module.touch_plain() + assert module.plain_box.grid[0, 0] == np.float64(6.0) + + # The addressable module object is no different. + module.tgt_box.grid[1, 2] = np.float64(4.5) + assert module.tgt_box.grid[1, 2] == np.float64(4.5) diff --git a/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py b/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py new file mode 100644 index 000000000..e7c14e310 --- /dev/null +++ b/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import pytest + +from prik.compiler.compilers import Compiler +from prik.compiler.objects import ObjectFile + + +def _fortran_compile_command(vendor: str, *, standard_logicals: bool, tmp_path: Path) -> tuple[str, ...]: + compiler = Compiler(vendor, execute_commands=False, standard_logicals=standard_logicals) + compiler._executable = lambda _language, _tools: "fc" + compiler.compile_object( + ObjectFile( + source=tmp_path / "module.f90", + object_path=tmp_path / "module.o", + language="fortran", + ) + ) + return compiler.command_log[0] + + +@pytest.mark.parametrize( + ("vendor", "option"), + [("intel", "-standard-semantics"), ("PGI", "-Munixlogical"), ("nvidia", "-Munixlogical")], +) +def test_fortran_compilation_requests_the_interoperable_logical_by_default(vendor: str, option: str, tmp_path: Path): + """A logical must reach C as 0 or 1, so the vendor option is on without being asked for.""" + assert option in _fortran_compile_command(vendor, standard_logicals=True, tmp_path=tmp_path) + + +@pytest.mark.parametrize( + ("vendor", "option"), + [("intel", "-standard-semantics"), ("PGI", "-Munixlogical"), ("nvidia", "-Munixlogical")], +) +def test_standard_logicals_can_be_turned_off_for_prebuilt_objects(vendor: str, option: str, tmp_path: Path): + """Opting out is the only way to link objects built without the option, whose mangling differs.""" + assert option not in _fortran_compile_command(vendor, standard_logicals=False, tmp_path=tmp_path) + + +@pytest.mark.parametrize("vendor", ["GNU", "LLVM"]) +def test_compilers_that_already_interoperate_add_no_logical_option(vendor: str, tmp_path: Path): + """gfortran and Flang already store .true. as 1, so they must stay flag-free.""" + command = _fortran_compile_command(vendor, standard_logicals=True, tmp_path=tmp_path) + assert "-standard-semantics" not in command + assert "-Munixlogical" not in command diff --git a/tests/fortran/infrastructure/building/pipeline/test_logical_interop_option_routing.py b/tests/fortran/infrastructure/building/pipeline/test_logical_interop_option_routing.py new file mode 100644 index 000000000..e961cb779 --- /dev/null +++ b/tests/fortran/infrastructure/building/pipeline/test_logical_interop_option_routing.py @@ -0,0 +1,82 @@ +"""Every build entrypoint that compiles Fortran must carry the logical-interop choice.""" + +import pytest + +from prik.pipeline import build as pipeline_build + + +@pytest.fixture +def recorded_compiler_options(monkeypatch): + """Capture the ``standard_logicals`` each entrypoint hands its compiler.""" + recorded: list[bool | None] = [] + real_new_compiler = pipeline_build._new_compiler + + def _record(*args, **kwargs): + recorded.append(kwargs.get("standard_logicals")) + raise _StopBuild + + monkeypatch.setattr(pipeline_build, "_new_compiler", _record) + assert real_new_compiler is not _record + return recorded + + +class _StopBuild(Exception): + """Raised in place of constructing a compiler, so no build work follows.""" + + +@pytest.mark.parametrize("standard_logicals", [True, False]) +def test_fortran_source_build_hands_the_choice_to_its_compiler( + tmp_path, recorded_compiler_options, standard_logicals: bool +): + source = tmp_path / "flags.f90" + source.write_text("module flags\n logical :: on = .true.\nend module flags\n") + + with pytest.raises(_StopBuild): + pipeline_build.build_fortran_extension( + [source], output_dir=tmp_path / "out", standard_logicals=standard_logicals + ) + + assert recorded_compiler_options == [standard_logicals] + + +@pytest.mark.parametrize("standard_logicals", [True, False]) +def test_contract_build_hands_the_choice_to_its_compiler(tmp_path, recorded_compiler_options, standard_logicals: bool): + """A contract build compiles the native Fortran too, so it must not drop the choice.""" + package = tmp_path / "contract" + package.mkdir() + (package / "__init__.pyi").write_text("from . import flags\n") + (package / "flags.pyi").write_text("from prik.contracts import Bool8\n\non: Bool8\n") + native = tmp_path / "flags.f90" + native.write_text("module flags\n logical :: on = .true.\nend module flags\n") + + with pytest.raises(_StopBuild): + pipeline_build.build_pyi_extension( + package / "__init__.pyi", + output_dir=tmp_path / "out", + native_fortran_sources=[native], + standard_logicals=standard_logicals, + ) + + assert recorded_compiler_options == [standard_logicals] + + +@pytest.mark.parametrize("standard_logicals", [True, False]) +def test_c_build_with_fortran_dependencies_hands_the_choice_to_its_compiler( + tmp_path, recorded_compiler_options, standard_logicals: bool +): + """A C build that links Fortran compiles it with the same compiler, so the choice applies.""" + source = tmp_path / "api.c" + source.write_text("int answer(void) { return 1; }\n") + native = tmp_path / "flags.f90" + native.write_text("module flags\n logical :: on = .true.\nend module flags\n") + + with pytest.raises(_StopBuild): + pipeline_build.build_c_extension( + source, + output_dir=tmp_path / "out", + output_name="c_logical_option", + native_fortran_sources=[native], + standard_logicals=standard_logicals, + ) + + assert recorded_compiler_options == [standard_logicals] diff --git a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py index 4c87bdee1..ff7a91d81 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py @@ -321,6 +321,29 @@ def test_source_build_routes_disabled_input_compilation_to_the_pipeline(monkeypa assert calls[0][1]["native_objects"] == ["libnative.so"] +@pytest.mark.parametrize( + ("cli_arguments", "forwarded"), + [({}, True), ({"standard_logicals": False}, False)], +) +def test_source_build_routes_the_logical_interop_choice_to_the_pipeline( + monkeypatch, cli_arguments: dict[str, bool], forwarded: bool +): + """--no-standard-logicals is the only opt-out, so it must reach the build unchanged.""" + from prik.pipeline import build as pipeline_build + + calls = [] + result = types.SimpleNamespace(compiled=False) + monkeypatch.setattr( + pipeline_build, + "build_fortran_extension", + lambda *args, **kwargs: calls.append((args, kwargs)) or result, + ) + args = _main_args(paths=[str(TEST_FILE)], **cli_arguments) + + assert prik_cli._run_wrap_build(args, types.SimpleNamespace(compiler="gfortran")) is result + assert calls[0][1]["standard_logicals"] is forwarded + + def test_fortran_pyi_build_defers_c_driver_selection_to_the_compiler_pair(monkeypatch): from prik.pipeline import build as pipeline_build @@ -342,6 +365,29 @@ def test_fortran_pyi_build_defers_c_driver_selection_to_the_compiler_pair(monkey assert calls[0][1]["input_c_compiler"] is None +@pytest.mark.parametrize( + ("cli_arguments", "forwarded"), + [({}, True), ({"standard_logicals": False}, False)], +) +def test_pyi_contract_build_routes_the_logical_interop_choice_to_the_pipeline( + monkeypatch, cli_arguments: dict[str, bool], forwarded: bool +): + """A contract build compiles the same native Fortran, so it must carry the same choice.""" + from prik.pipeline import build as pipeline_build + + calls = [] + result = types.SimpleNamespace(compiled=False) + monkeypatch.setattr( + pipeline_build, + "build_pyi_extension", + lambda *args, **kwargs: calls.append((args, kwargs)) or result, + ) + args = _main_args(paths=["contract.pyi"], language="fortran", **cli_arguments) + + assert prik_cli._run_wrap_build(args, types.SimpleNamespace(compiler="gfortran")) is result + assert calls[0][1]["standard_logicals"] is forwarded + + @pytest.mark.parametrize( ("native_language", "expected_compilers"), [ diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index ce00489fc..e97b67346 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -23,6 +23,7 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): "prik_array_actual_unpack", "prik_array_validate", "prik_release_owned_memory", + "prik_capture_address", ) for name in expected_api: assert name in header @@ -50,3 +51,18 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): assert f"prik_{suffix}_unpack" in header assert f"prik_{suffix}_to_python" in header assert f"prik_{suffix}_to_numpy" in header + + +def test_address_capture_primitive_has_external_linkage_behind_one_opt_in(): + """The one support symbol the generated Fortran bridge links against. + + Every other helper here is `static`, which the bridge could not call. This + one stands in for `c_loc` where Fortran cannot form it, so it must be + externally visible -- and therefore defined in exactly one translation unit, + which the opt-in macro is what enforces. + """ + header = SUPPORT_HEADER.read_text(encoding="utf-8") + + assert "#ifdef PRIK_BINDING_CAPTURE_ADDRESS" in header + assert "void *prik_capture_address(void *base)" in header + assert "static inline void *prik_capture_address" not in header diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 10d823028..8363029a1 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -320,7 +320,11 @@ def test_module_variables_use_borrowed_handle_plans_and_operation_sets(): assert NativeArrayOperation.ELEMENT_LENGTH in names.operations assert NativeArrayOperation.RESIZE not in names.operations assert NativeArrayOperation.DESTROY not in pointer.operations - assert allocatable.required_headers == () + # A module allocatable reads its own descriptor whether or not it is a + # target, so `Aliased` selects the same interop and headers as a plain one. + assert allocatable.extraction_action.value == "descriptor_view" + assert allocatable.descriptor_interop is NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR + assert allocatable.required_headers == ("ISO_Fortran_binding.h",) assert plain.extraction_action.value == "descriptor_view" assert plain.descriptor_interop is NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR assert plain.required_headers == ("ISO_Fortran_binding.h",) @@ -329,12 +333,19 @@ def test_module_variables_use_borrowed_handle_plans_and_operation_sets(): def test_deferred_character_module_handles_use_runtime_element_length(): + """A deferred length is reported at runtime, and the descriptor supplies it. + + The width is not in the declaration, so it can only come from the array + itself. It reaches the descriptor record from the descriptor now rather than + through a second call, while the standalone query remains for the callers + that ask for the length on its own. + """ artifacts = WrapperGenerator().generate(_module_handle_plan()) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") + assert '"elem_len", (unsigned long long)descriptor->elem_len' in c_source assert "bind_c_module_names_element_length()" in c_source - assert '"elem_len", (unsigned long long)(bind_c_module_names_element_length())' in c_source assert "function bind_c_module_names_element_length() result(result)" in bridge_source assert "result = len(native_module_names, kind=c_int64_t)" in bridge_source diff --git a/tests/fortran/modules/codegen/test_module_array_view_lowering.py b/tests/fortran/modules/codegen/test_module_array_view_lowering.py new file mode 100644 index 000000000..1eaa5600a --- /dev/null +++ b/tests/fortran/modules/codegen/test_module_array_view_lowering.py @@ -0,0 +1,163 @@ +"""Bridge lowering for the two fixed module-array address mechanisms.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from prik.codegen.c.binding import CBindingGenerator +from prik.codegen.fortran.bridge import FortranBridgeGenerator +from prik.parsers.fortran.parser import parse_fortran_project +from prik.pipeline.build import _apply_source_python_exports, _merge_wrapper_modules +from prik.pipeline.wrapper import WrapperGenerator +from prik.planning import WrapperPlanner +from prik.policy.completion import complete_semantic_policies +from prik.printers.fortran import FortranSourcePrinter +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules +from tests.fortran._support.ownership_policy import parse_pyi_text + + +MODULE_ARRAY_SOURCE = """ +module array_state + use iso_fortran_env, only: int32, real64 + implicit none + real(real64) :: plain(2, 3) + integer(int32) :: counts(3) + character(len=5) :: labels(2) + real(real64), target :: addressable(4) +end module array_state +""" + + +def _plan(): + parsed = parse_fortran_project({"array_state.f90": MODULE_ARRAY_SOURCE}) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="array_state") + complete_semantic_policies(module) + return WrapperPlanner().build(module) + + +def _bridge_module(): + return FortranBridgeGenerator().visit(_plan()) + + +def _lowered_getters(): + plan = _plan() + bridge = FortranBridgeGenerator() + bridge.visit(plan) + printer = FortranSourcePrinter() + return { + variable.binding.python_names[0]: printer.visit(bridge.visit(variable)[0]) + for namespace in plan.namespaces + for variable in namespace.variables + } + + +def test_addressable_module_array_takes_its_address_directly(): + """A `target` declaration lets `c_loc` name the array, so nothing else is emitted.""" + getter = _lowered_getters()["addressable"] + + assert "c_loc(native_addressable)" in getter + assert "capture_array_address" not in getter + + +@pytest.mark.parametrize("python_name", ["plain", "counts", "labels"]) +def test_ordinary_module_array_captures_its_address_in_c(python_name): + """Without `target`, the address is taken on the C side, never by `c_loc`. + + `c_loc` requires the variable it names to be a target, so an ordinary + declaration has no Fortran route to its own address. The getter hands the + whole array to a `bind(C)` procedure instead: an assumed-type assumed-size + dummy is passed as the bare base address, so C receives where the module + variable lives. The bridge forms no pointer and claims no target, and one + interface covers every element type including character. + """ + getter = _lowered_getters()[python_name] + + assert f"prik_capture_address(native_{python_name})" in getter + assert "c_loc" not in getter + assert "target" not in getter + + +def test_captured_address_declares_one_typeless_c_interface(): + """One assumed-type interface serves every captured element type.""" + module = _bridge_module() + + interfaces = [procedure for interface in module.interfaces for procedure in interface.procedures] + captures = [procedure for procedure in interfaces if procedure.name == "prik_capture_address"] + assert len(captures) == 1 + assert captures[0].bind_name == "prik_capture_address" + assert captures[0].parameters[0].type_name == "type(*)" + assert captures[0].parameters[0].attributes == ("dimension(*)",) + + +def test_capture_helper_is_declared_only_where_an_array_needs_it(): + """A module whose arrays are all addressable declares no capture interface.""" + module = parse_pyi_text( + "addressable: Annotated[Float64[4], Aliased]\n", + module_name="array_state", + ) + complete_semantic_policies(module) + bridge = FortranBridgeGenerator() + emitted = bridge.visit(WrapperPlanner().build(module)) + + names = [procedure.name for interface in emitted.interfaces for procedure in interface.procedures] + assert "prik_capture_address" not in names + + +def test_binding_opts_into_the_bundled_capture_primitive(): + """The C side selects the runtime definition of the symbol the bridge calls. + + The helper lives in the bundled support header rather than in emitted code, + because it is a fixed ABI primitive rather than something a plan describes. + It needs external linkage for the bridge to call it, so the binding opts in + once per extension and the header defines it in that translation unit alone. + """ + binding = CBindingGenerator().binding_module(_plan()) + + assert any(define.name == "PRIK_BINDING_CAPTURE_ADDRESS" for define in binding.defines) + assert not any(function.name == "prik_capture_address" for function in binding.functions) + + +def test_binding_omits_the_capture_primitive_when_no_array_needs_it(): + """An extension whose arrays are all addressable pulls in no capture symbol.""" + module = parse_pyi_text( + "addressable: Annotated[Float64[4], Aliased]\n", + module_name="array_state", + ) + complete_semantic_policies(module) + binding = CBindingGenerator().binding_module(WrapperPlanner().build(module)) + + assert not any(define.name == "PRIK_BINDING_CAPTURE_ADDRESS" for define in binding.defines) + + +def _undecided_plan(): + """Return a module-array plan whose address mechanism policy never selected.""" + module = parse_pyi_text("plain: Float64[3]\n", module_name="array_state") + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + variable = plan.namespaces[0].variables[0] + return plan, variable, replace(variable, array_address=None) + + +def test_module_array_view_plan_rejects_a_missing_address_mechanism(): + """The plan boundary reports the gap rather than letting lowering guess.""" + plan, _variable, undecided = _undecided_plan() + namespace = plan.namespaces[0] + namespace.variables = (undecided,) + + diagnostics = WrapperGenerator()._plan_diagnostics(plan) + + assert any(diagnostic.code == "missing-module-array-address-mechanism" for diagnostic in diagnostics) + + +def test_module_array_view_lowering_requires_a_completed_address_mechanism(): + """Lowering refuses to invent an address route policy did not select.""" + plan, _variable, undecided = _undecided_plan() + bridge = FortranBridgeGenerator() + bridge.visit(plan) + + with pytest.raises(ValueError, match="no completed address mechanism"): + bridge.visit(undecided) diff --git a/tests/fortran/modules/end_to_end/fixtures/native/fmodule_array_forms_f90.f90 b/tests/fortran/modules/end_to_end/fixtures/native/fmodule_array_forms_f90.f90 new file mode 100644 index 000000000..445fb52a2 --- /dev/null +++ b/tests/fortran/modules/end_to_end/fixtures/native/fmodule_array_forms_f90.f90 @@ -0,0 +1,97 @@ +module fmodule_array_forms_f90 + use iso_c_binding, only: c_bool + use iso_fortran_env, only: int32, real64 + implicit none + + type :: sample + real(real64) :: grid(2, 3) + integer(int32) :: n + end type sample + + ! Fixed shape: the declaration holds the shape; only the address is unknown. + real(real64) :: fixed_plain(4) = [1.0d0, 2.0d0, 3.0d0, 4.0d0] + real(real64), target :: fixed_target(4) = [5.0d0, 6.0d0, 7.0d0, 8.0d0] + real(real64) :: fixed_matrix(2, 3) + real(real64) :: fixed_shifted(5:8) = [9.0d0, 10.0d0, 11.0d0, 12.0d0] + integer(int32) :: fixed_counts(3) = [7, 8, 9] + logical(c_bool) :: fixed_flags(3) = [.true., .false., .true.] + + ! Allocatable: bounds, strides and element length are runtime facts. + real(real64), allocatable :: alloc_plain(:) + real(real64), allocatable, target :: alloc_target(:) + real(real64), allocatable :: alloc_matrix(:, :) + real(real64), allocatable :: alloc_shifted(:) + + ! Pointer: association is runtime state. + real(real64), pointer :: ptr_link(:) => null() + + ! Character, in every storage form its element length can take. + character(len=5) :: char_fixed(2) = ['alpha', 'bravo'] + character(len=5), target :: char_target(2) = ['gamma', 'delta'] + character(len=5), allocatable :: char_alloc(:) + character(len=:), allocatable :: char_deferred(:) + + ! Derived objects whose array field is reached through the owner. + type(sample) :: obj_plain + type(sample), target :: obj_target + +contains + + subroutine setup() + fixed_matrix = reshape([1.0d0, 2.0d0, 3.0d0, 4.0d0, 5.0d0, 6.0d0], [2, 3]) + allocate(alloc_plain(4)); alloc_plain = [1.0d0, 2.0d0, 3.0d0, 4.0d0] + allocate(alloc_target(3)); alloc_target = [9.0d0, 8.0d0, 7.0d0] + allocate(alloc_matrix(2, 3)); alloc_matrix = 2.0d0 + allocate(alloc_shifted(5:8)); alloc_shifted = [1.0d0, 2.0d0, 3.0d0, 4.0d0] + allocate(character(len=5) :: char_alloc(2)); char_alloc = ['epsil', 'zetaa'] + allocate(character(len=6) :: char_deferred(2)); char_deferred = ['etaaaa', 'thetaa'] + obj_plain%grid = 1.0d0; obj_plain%n = 3 + obj_target%grid = 2.0d0; obj_target%n = 4 + ptr_link => alloc_target + end subroutine setup + + ! One ordinary array dummy every numeric form above can be passed to. + function total(values) result(sum_of) + real(real64), intent(in) :: values(:) + real(real64) :: sum_of + sum_of = sum(values) + end function total + + function total_2d(values) result(sum_of) + real(real64), intent(in) :: values(:, :) + real(real64) :: sum_of + sum_of = sum(values) + end function total_2d + + function count_set(values) result(how_many) + logical(c_bool), intent(in) :: values(:) + integer(int32) :: how_many + how_many = count(values) + end function count_set + + function total_counts(values) result(sum_of) + integer(int32), intent(in) :: values(:) + integer(int32) :: sum_of + sum_of = sum(values) + end function total_counts + + function first_word(values) result(word) + character(len=5), intent(in) :: values(:) + character(len=5) :: word + word = values(1) + end function first_word + + ! An allocatable dummy adopts the descriptor's bounds, unlike the ones above. + function lower_bound_of(values) result(bound) + real(real64), allocatable, intent(in) :: values(:) + integer(int32) :: bound + bound = lbound(values, 1) + end function lower_bound_of + + subroutine bump_all() + fixed_plain(1) = fixed_plain(1) + 100.0d0 + alloc_plain(1) = alloc_plain(1) + 100.0d0 + obj_plain%grid(1, 1) = obj_plain%grid(1, 1) + 100.0d0 + end subroutine bump_all + +end module fmodule_array_forms_f90 diff --git a/tests/fortran/modules/end_to_end/test_logical_array_views.py b/tests/fortran/modules/end_to_end/test_logical_array_views.py new file mode 100644 index 000000000..86b053d2d --- /dev/null +++ b/tests/fortran/modules/end_to_end/test_logical_array_views.py @@ -0,0 +1,110 @@ +"""How a Fortran logical array reaches Python, and at what width. + +A logical has no fixed representation in Fortran, and some compilers default to +one their own C compiler cannot read. prik requests the option that selects the +interoperable form, so `.true.` is the same byte everywhere and a one-byte +logical is exactly what `numpy.bool_` describes. Kinds wider than a byte have no +NumPy Boolean to be, so they report the integer of matching width instead. +""" + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_text_and_import + +pytestmark = pytest.mark.fortran_end_to_end + +LOGICAL_VIEW_SOURCE = """ +module flogical_view_f90 + use iso_c_binding, only: c_bool + implicit none + + logical(c_bool) :: narrow(4) + logical :: wide(4) + logical(c_bool), allocatable :: narrow_alloc(:) + +contains + + subroutine setup() + narrow = [.true., .false., .true., .false.] + wide = [.true., .false., .true., .false.] + if (.not. allocated(narrow_alloc)) allocate(narrow_alloc(4)) + narrow_alloc = [.true., .true., .false., .false.] + end subroutine setup + + subroutine negate() + narrow = .not. narrow + wide = .not. wide + end subroutine negate + + function count_narrow() result(total) + integer :: total + total = count(narrow) + end function count_narrow + + function count_wide() result(total) + integer :: total + total = count(wide) + end function count_wide +end module flogical_view_f90 +""" + + +@pytest.fixture(scope="module") +def logical_view(tmp_path_factory): + module = _build_text_and_import( + LOGICAL_VIEW_SOURCE, + "flogical_view_f90.f90", + tmp_path_factory.mktemp("logical_view"), + { + "bind_c_flogical_view_f90_wrapper.f90", + "flogical_view_f90_wrapper.c", + "flogical_view_f90_wrapper.h", + }, + ) + module.setup() + return module + + +def test_a_one_byte_logical_is_a_numpy_boolean(logical_view): + """`logical(c_bool)` holds zero or one in one byte, which is `numpy.bool_`.""" + assert logical_view.narrow.dtype == np.dtype(np.bool_) + assert logical_view.narrow_alloc.to_numpy().dtype == np.dtype(np.bool_) + assert logical_view.narrow.tolist() == [True, False, True, False] + + +def test_a_wider_logical_reports_the_width_its_elements_occupy(logical_view): + """NumPy has no Boolean larger than a byte, so the width is stated instead.""" + wide = logical_view.wide + + assert wide.dtype == np.dtype(np.int32) + assert wide.astype(bool).tolist() == [True, False, True, False] + + +def test_a_held_view_keeps_agreeing_with_fortran_across_native_writes(logical_view): + """The view aliases the storage, and both sides read the same bytes. + + The interoperable representation is what makes this hold on every compiler: + without it one of them writes all bits set for `.true.`, which C and NumPy + would read as true where Fortran's own complement of it means false. + """ + narrow, wide = logical_view.narrow, logical_view.wide + try: + logical_view.negate() + + assert narrow.tolist() == [False, True, False, True] + assert int(narrow.sum()) == logical_view.count_narrow() + assert wide.astype(bool).tolist() == [False, True, False, True] + assert int(wide.astype(bool).sum()) == logical_view.count_wide() + finally: + logical_view.negate() + + +def test_reading_a_logical_view_does_not_disturb_native_storage(logical_view): + """Reading is a borrow: nothing is rewritten on the way out.""" + before = logical_view.count_narrow() + + for _ in range(3): + logical_view.narrow # noqa: B018 - the read itself is what is under test + + assert logical_view.count_narrow() == before diff --git a/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py b/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py new file mode 100644 index 000000000..0d1e07e2d --- /dev/null +++ b/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py @@ -0,0 +1,187 @@ +"""Every storage form a module array can take, exercised through one module. + +The forms differ in what the declaration already fixes and what only exists at +runtime, and prik reaches them by three different mechanisms because of that. +This module holds them side by side so the differences are visible in one place: +what each is exposed as, whether it stays live, and what happens when it is +handed back to an ordinary Fortran array dummy. +""" + +import os +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_and_import + +FIXTURES = Path(__file__).parent / "fixtures" +ARRAY_FORMS_F90_SOURCE = FIXTURES / "native" / "fmodule_array_forms_f90.f90" +pytestmark = pytest.mark.fortran_end_to_end + + +def _allocatable_dummy_handoff_supported() -> bool: + """Report whether this compiler accepts a handle at an allocatable dummy. + + ifx rejects the established descriptor for that argument form regardless of + the bounds it carries, so the round-trip below is checked where it works. + The descriptor facts themselves are asserted on every compiler. + """ + return "ifx" not in os.environ.get("PRIK_TEST_FORTRAN_COMPILER", "gfortran") + + +@pytest.fixture(scope="module") +def array_forms(tmp_path_factory): + module = _build_and_import( + ARRAY_FORMS_F90_SOURCE, + tmp_path_factory.mktemp("array_forms"), + { + "bind_c_fmodule_array_forms_f90_wrapper.f90", + "fmodule_array_forms_f90_wrapper.c", + "fmodule_array_forms_f90_wrapper.h", + }, + ) + module.setup() + return module + + +# A fixed shape is entirely in the declaration, so these are plain views; an +# allocatable or pointer carries runtime state, so those are handles. +@pytest.mark.parametrize( + ("name", "exposed_as", "dtype"), + [ + ("fixed_plain", np.ndarray, "float64"), + ("fixed_target", np.ndarray, "float64"), + ("fixed_matrix", np.ndarray, "float64"), + ("fixed_shifted", np.ndarray, "float64"), + ("fixed_counts", np.ndarray, "int32"), + ("fixed_flags", np.ndarray, "bool"), + ("char_fixed", np.ndarray, "S5"), + ("char_target", np.ndarray, "S5"), + ], +) +def test_fixed_shape_module_arrays_are_plain_views(array_forms, name, exposed_as, dtype): + """A declared shape needs no handle: the value is the storage itself.""" + value = getattr(array_forms, name) + + assert isinstance(value, exposed_as) + assert value.dtype == np.dtype(dtype) + + +@pytest.mark.parametrize( + ("name", "dtype"), + [ + ("alloc_plain", "float64"), + ("alloc_target", "float64"), + ("alloc_matrix", "float64"), + ("alloc_shifted", "float64"), + ("char_alloc", "S5"), + ("char_deferred", "S6"), + ], +) +def test_allocatable_module_arrays_are_handles(array_forms, name, dtype): + """Allocation state is not in the declaration, so these carry it explicitly.""" + handle = getattr(array_forms, name) + + assert handle.allocated is True + assert handle.to_numpy().dtype == np.dtype(dtype) + + +def test_a_bare_pointer_is_a_handle_that_declines_to_hand_out_a_view(array_forms): + """A pointer says what it is associated with, not that a view is safe. + + Contiguity and target lifetime are not in a plain `pointer` declaration, so + no view can be justified from it alone; that needs an explicit + `PointerPolicy`. The association state is knowable and is reported. + """ + handle = array_forms.ptr_link + + assert handle.associated is True + assert handle.shape == (3,) + with pytest.raises(NotImplementedError, match="unsupported by completed policy"): + handle.to_numpy() + + +def test_derived_array_fields_are_views_through_either_owner(array_forms): + """An array component is reached through its owner, addressable or not.""" + assert isinstance(array_forms.obj_plain.grid, np.ndarray) + assert isinstance(array_forms.obj_target.grid, np.ndarray) + assert array_forms.obj_plain.grid.shape == (2, 3) + + +@pytest.mark.parametrize( + "name", + ["fixed_plain", "fixed_target", "fixed_shifted", "obj_plain", "obj_target"], +) +def test_views_stay_live_across_native_writes(array_forms, name): + """Every borrowed view names the storage native code writes, not a copy.""" + + def current(): + owner = getattr(array_forms, name) + return owner.grid if name.startswith("obj") else owner + + view = current() + before = float(view.flat[0]) + try: + view.flat[0] = before + 1.0 + assert float(current().flat[0]) == before + 1.0 + finally: + # The storage is shared with every other test in this module, so the + # write is undone rather than left for whatever runs next. + view.flat[0] = before + + +def test_every_numeric_form_reaches_one_ordinary_array_dummy(array_forms): + """`total(values(:))` accepts each form, however its storage is reached. + + A fixed array arrives as a view, an allocatable and a pointer as handles, + and a derived component through its owner. The dummy is an ordinary array + either way, so the conversion has to erase the difference. + """ + assert array_forms.total(array_forms.fixed_plain) == np.float64(10.0) + assert array_forms.total(array_forms.fixed_target) == np.float64(26.0) + assert array_forms.total(array_forms.fixed_shifted) == np.float64(42.0) + assert array_forms.total(array_forms.alloc_plain) == np.float64(10.0) + assert array_forms.total(array_forms.alloc_target) == np.float64(24.0) + assert array_forms.total_2d(array_forms.fixed_matrix) == np.float64(21.0) + assert array_forms.total_2d(array_forms.alloc_matrix) == np.float64(12.0) + assert array_forms.total_2d(array_forms.obj_plain.grid) == np.float64(6.0) + assert array_forms.total_counts(array_forms.fixed_counts) == np.int32(24) + assert array_forms.count_set(array_forms.fixed_flags) == np.int32(2) + assert array_forms.fixed_flags.tolist() == [True, False, True] + assert array_forms.first_word(array_forms.char_fixed) == "alpha" + + +def test_only_an_allocatable_dummy_carries_the_declared_lower_bound(array_forms): + """The two dummy forms disagree about bounds, and both are right. + + An ordinary array dummy declares its own bounds, so a declared lower bound + is discarded by the language and cannot be observed. An allocatable dummy is + the actual's descriptor, so it adopts them -- which is why a module + allocatable has to report the bounds it really has. + """ + assert array_forms.alloc_shifted._descriptor_record_for_binding()["dim"][0]["lower_bound"] == 5 + assert array_forms.alloc_plain._descriptor_record_for_binding()["dim"][0]["lower_bound"] == 1 + if _allocatable_dummy_handoff_supported(): + assert array_forms.lower_bound_of(array_forms.alloc_shifted) == np.int32(5) + assert array_forms.lower_bound_of(array_forms.alloc_plain) == np.int32(1) + + # `fixed_shifted` is declared (5:8) and sums the same as any other four + # elements: nothing downstream can tell where it started. + assert array_forms.total(array_forms.fixed_shifted) == np.float64(42.0) + + +def test_a_character_handle_reaches_a_character_dummy_like_any_other(array_forms): + """A handle stands in for an array actual whatever its element type. + + A character actual is matched on its declared width as well as its kind, so + a handle whose elements are a different length is refused: it describes + storage the dummy cannot accept. + """ + assert array_forms.first_word(array_forms.char_alloc) == "epsil" + assert array_forms.first_word(array_forms.char_alloc.to_numpy()) == "epsil" + assert array_forms.first_word(array_forms.char_fixed) == "alpha" + + # `char_deferred` holds six-character elements; the dummy declares five. + with pytest.raises(TypeError, match="does not match expected dtype"): + array_forms.first_word(array_forms.char_deferred) 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 8ac369201..91f2d75ec 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 @@ -126,6 +126,92 @@ def test_scalar_module_variables_use_attributes_and_parameters_have_no_native_se assert second_module.black.r == np.int32(0) +PLAIN_MODULE_ARRAY_SOURCE = """ +module fplain_module_arrays_f90 + use iso_fortran_env, only: int32, real64 + implicit none + real(real64) :: grid(2, 3) + integer(int32) :: counts(3) = [7, 8, 9] + character(len=5) :: labels(2) = ['alpha', 'bravo'] + real(real64), target :: addressable(2) = [1.0d0, 2.0d0] +contains + subroutine bump() + grid(1, 1) = grid(1, 1) + 1.0d0 + end subroutine bump + + function read_grid(row, column) result(value) + integer(int32), intent(in) :: row, column + real(real64) :: value + value = grid(row, column) + end function read_grid + + function read_count(index) result(value) + integer(int32), intent(in) :: index + integer(int32) :: value + value = counts(index) + end function read_count + + function read_label(index) result(value) + integer(int32), intent(in) :: index + character(len=5) :: value + value = labels(index) + end function read_label +end module fplain_module_arrays_f90 +""" + + +def test_fixed_module_arrays_without_target_expose_the_same_live_view(tmp_path: Path): + """A fixed module array is borrowed live whether or not it declares `target`. + + `target` is what lets `c_loc` name the array, not what gives the array a + stable address, so withholding it changes the route to the base address and + nothing the caller can observe. The claim here is that both declarations + produce one live view over the real module storage: Fortran writes appear + without re-reading the attribute, Python writes are visible to Fortran, and + neither form accepts whole-array replacement. + """ + module = _build_text_and_import( + PLAIN_MODULE_ARRAY_SOURCE, + "fplain_module_arrays_f90.f90", + tmp_path, + { + "bind_c_fplain_module_arrays_f90_wrapper.f90", + "fplain_module_arrays_f90_wrapper.c", + "fplain_module_arrays_f90_wrapper.h", + }, + ) + + assert module.grid.shape == (2, 3) + assert module.grid.dtype == np.dtype(np.float64) + assert module.grid.flags["F_CONTIGUOUS"] is True + np.testing.assert_array_equal(module.counts, np.array([7, 8, 9], dtype=np.int32)) + np.testing.assert_array_equal(module.labels, np.array([b"alpha", b"bravo"], dtype="S5")) + + # A view handed out earlier still names the storage Fortran writes. + view = module.grid + view[0, 0] = np.float64(10.0) + module.bump() + assert view[0, 0] == np.float64(11.0) + assert module.grid[0, 0] == np.float64(11.0) + + # A Python write reaches the storage Fortran reads, for every element type. + module.grid[1, 2] = np.float64(4.5) + assert module.read_grid(np.int32(2), np.int32(3)) == np.float64(4.5) + module.counts[1] = np.int32(99) + assert module.read_count(np.int32(2)) == np.int32(99) + module.labels[0] = b"omega" + assert module.read_label(np.int32(1)) == "omega" + + # The addressable declaration borrows identically. + module.addressable[0] = np.float64(6.0) + assert module.addressable[0] == np.float64(6.0) + + # Neither form hands the whole variable back to be reassigned. + for name in ("grid", "counts", "addressable"): + with pytest.raises(AttributeError, match="read-only"): + setattr(module, name, np.zeros(2)) + + CHARACTER_MODULE_ARRAY_SOURCE = """ module fchar_module_arrays_f90 implicit none diff --git a/tests/fortran/modules/policy/test_module_variable_policy.py b/tests/fortran/modules/policy/test_module_variable_policy.py index a69b76a0b..4145e2fd4 100644 --- a/tests/fortran/modules/policy/test_module_variable_policy.py +++ b/tests/fortran/modules/policy/test_module_variable_policy.py @@ -9,7 +9,7 @@ from prik.semantics.fortran2ir import fortran_project_to_semantic_modules from prik.semantics.models import RESOLVED_MODULE_VARIABLE_POLICY_METADATA from prik.policy.ownership import AssignmentMode -from prik.policy.models import ModuleGetterAction, ModuleVariablePolicy +from prik.policy.models import ModuleArrayAddressMechanism, ModuleGetterAction, ModuleVariablePolicy def test_scalar_module_variable_policy_completes_access_and_storage_before_planning(): @@ -120,17 +120,53 @@ def test_parameter_arrays_complete_as_immutable_native_snapshots(): assert "dpmpar: Final[Float64[3]]" in PyiPrinter().emit(module) -def test_fixed_module_array_requires_explicit_addressable_alias_storage(): +def test_fixed_module_array_address_mechanism_follows_declared_addressability(): + """A fixed module array is borrowed either way; only its address route differs.""" module = parse_pyi_text( """ -from prik.contracts import Float64 +from prik.contracts import Aliased, Annotated, Float64 values: Float64[4] +addressable: Annotated[Float64[4], Aliased] """, module_name="plain_array_state", ) complete_semantic_policies(module) - policy = module.variables[0].metadata[RESOLVED_MODULE_VARIABLE_POLICY_METADATA] - assert policy.supported is False - assert "ordinary module array requires addressable Aliased target storage" in policy.blockers + policies = { + variable.name: variable.metadata[RESOLVED_MODULE_VARIABLE_POLICY_METADATA] for variable in module.variables + } + assert [policy.supported for policy in policies.values()] == [True, True] + assert policies["values"].getter_action is ModuleGetterAction.BORROWED_ARRAY_VIEW + assert policies["values"].array_address is ModuleArrayAddressMechanism.CAPTURED_ADDRESS + assert policies["addressable"].array_address is ModuleArrayAddressMechanism.TARGET_ADDRESS + # Neither route hands Python the whole variable back to reassign. + assert policies["values"].setter_action is SetterAction.REJECT_REPLACEMENT + assert policies["addressable"].setter_action is SetterAction.REJECT_REPLACEMENT + + +def test_logical_module_arrays_are_borrowed_at_every_width(): + """A live view aliases element for element, so the dtype reports the width. + + NumPy has no Boolean wider than one byte, so a logical array is described by + the integer of matching width rather than narrowed to `bool`. The widths + then agree for every Fortran kind and each is borrowed as a live view. + """ + module = parse_pyi_text( + """ +from prik.contracts import Bool, Bool32 + +narrow: Bool[3] +wide: Bool32[3] +""", + module_name="logical_state", + ) + complete_semantic_policies(module) + + policies = { + variable.name: variable.metadata[RESOLVED_MODULE_VARIABLE_POLICY_METADATA] for variable in module.variables + } + for name in ("narrow", "wide"): + assert policies[name].supported is True, name + assert policies[name].getter_action is ModuleGetterAction.BORROWED_ARRAY_VIEW, name + assert policies[name].blockers == (), name diff --git a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py index c17eea2f6..a4379c2a2 100644 --- a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py +++ b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py @@ -600,8 +600,11 @@ def make_target() -> Pointer[Float64[:]]: ... assert target_values.handle_kind == "borrowed_module_descriptor" assert target_values.owner_retention == "native_module" assert target_values.target_lifetime == "module" - assert target_values.to_numpy == "borrowed_view" - assert target_values.descriptor_interop == "none" + # A module allocatable reports its own descriptor whether or not it is a + # target, so `Aliased` selects neither a different NumPy exposure nor a + # different interop mechanism. + assert target_values.to_numpy == "descriptor_view" + assert target_values.descriptor_interop == "module_allocatable_c_descriptor" assert target_values.requires_pointer_c_descriptor_interop is False assert field_values.handle_kind == "borrowed_field_descriptor" From bbee8915145018a041a57560678b63c2c4ca6e62 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 14:43:56 +0100 Subject: [PATCH 02/47] Borrow the runtime descriptor for an allocatable actual An allocatable actual argument cannot be built from C. F2018 18.5.5.6 requires a null `base_addr` when the attribute is `CFI_attribute_allocatable`, because an allocatable established from C must start unallocated; pairing that attribute with a real address describes an already-allocated allocatable, which the standard does not permit C to construct. PRIK did exactly that, so the same wrapper worked on gfortran -- which does not enforce the constraint -- and failed on ifx with `CFI_ERROR_BASE_ADDR_NOT_NULL`, surfacing as `Unable to establish native descriptor for argument a: 2`. The binding now keeps the descriptor the Fortran runtime already hands to its callback instead of rebuilding one from facts. Only the descriptor record is copied, not the array data, so the cost does not grow with array size, and the copy is remade on every call because reallocating the native entity invalidates the previous one. Module variables, derived-type fields and returned results all reach a read-only allocatable dummy on every compiler now, including the bounds a shifted allocatable carries. That also removes a per-call Python facts mapping, which makes such a call about twice as fast. A borrowed copy cannot stand in where the callee changes the allocation: an `intent(inout)` allocatable may deallocate and reallocate its dummy, and through a copy that lands in the copy and leaves the caller's entity pointing at released storage. The descriptor handoff therefore records whether it owns its descriptor, and a projected argument refuses a borrowed one rather than accepting it. Optional allocatable dummies keep the fact-packed form, whose absent branch establishes the unallocated placeholder that pairs with the present flag. Verified on gfortran 11 and ifx 2026.1 across module, derived-field, result and pointer handles, allocated and unallocated, against read-only and writable dummies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 17 ++ prik/codegen/c/binding.py | 179 ++++++++++++++++-- prik/pipeline/wrapper.py | 35 +++- prik/policy/construction.py | 15 +- prik/runtime/handles.py | 84 +++++++- prik/runtime/native_support/prik_binding.h | 11 ++ .../codegen/test_allocatable_lowering.py | 54 ++++++ .../end_to_end/test_allocatable_handles.py | 116 ++++++++++++ .../codegen/test_native_handle_planning.py | 18 +- 9 files changed, 491 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd1b73c5..45f113758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ release tags add a leading `v` to the package version. ## Unreleased +- **Fixed:** passing an allocatable handle to a read-only `allocatable` dummy no + longer builds the descriptor in C. F2018 18.5.5.6 requires a null `base_addr` + when the attribute is `CFI_attribute_allocatable`, because an allocatable + established from C must start unallocated; PRIK paired that attribute with a + real address, which describes an already-allocated allocatable. Intel rejected + it with `CFI_ERROR_BASE_ADDR_NOT_NULL`, so the same wrapper worked on gfortran + and raised `Unable to establish native descriptor for argument ...: 2` on ifx. + + The binding now borrows the descriptor the Fortran runtime already hands to + its callback, copying the descriptor record — not the array data, so cost does + not grow with array size — for the duration of the call. The copy is remade on + every call, because reallocating the native entity invalidates the previous + one. Module variables, derived-type fields and returned results all reach such + a dummy on every compiler now, including the bounds a shifted allocatable + carries. Optional allocatable dummies are unchanged: their absent branch still + establishes the unallocated placeholder that pairs with the present flag. + - A `character` array handle is now accepted wherever a numeric one is. An `AllocatableArray` of characters was refused at an ordinary character dummy and had to be passed as `handle.to_numpy()`, while every other element type diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index d8cd0a528..20a39ac0c 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -38,6 +38,7 @@ ModuleArrayAddressMechanism, ModuleGetterAction, NativeArrayDescriptorKind, + NativeArrayOutputProjection, NativeArrayDescriptorInterop, NativeArrayDefaultConstruction, NativeArrayOperation, @@ -3117,6 +3118,7 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name ops=ops, owner=owner_name, descriptor_ownership="borrowed", + descriptor_handoff=self._borrowed_descriptor_handoff(handle), extraction_action=handle.extraction_action.value, ) ) @@ -3665,7 +3667,7 @@ def _field_handle_descriptor_callbacks( field: DerivedFieldPlan, descriptor_name: str, actual_name: str, - ) -> tuple[CFunction, CFunction]: + ) -> tuple[CFunction, ...]: """Decode one current field descriptor without copying its payload.""" handle = field.native_array_handle if handle is None or handle.array.rank is None: @@ -3684,6 +3686,27 @@ def _field_handle_descriptor_callbacks( ), ), ) + borrowed = ( + ( + CFunction( + f"{descriptor_name}_capsule", + "void", + parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), + storage="static", + body=( + CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), + *self._borrowed_descriptor_capsule_nodes( + handle.array.rank, + "descriptor", + self._native_array_handle_kind_constant(handle), + return_target="*(PyObject **)context", + ), + ), + ), + ) + if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + else () + ) actual = CFunction( actual_name, "void", @@ -3694,7 +3717,7 @@ def _field_handle_descriptor_callbacks( CReturn(), ), ) - return descriptor, actual + return (descriptor, *borrowed, actual) def _field_handle_operation_function( self, @@ -3724,7 +3747,11 @@ def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation prefix = self._field_handle_owner_nodes(owner) owner_args = self._field_handle_owner_arguments(owner) if operation in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY}: - callback = self._field_handle_descriptor_callback(owner, field) + callback = self._field_handle_descriptor_callback( + owner, + field, + borrows=operation is NativeArrayOperation.DESCRIPTOR, + ) descriptor_bridge = self._field_handle_bridge_name( owner, field, @@ -3804,12 +3831,21 @@ def _field_handle_bridge_name( variable, member = owner return self._module_member_handle_bridge_name(variable, member, operation) - def _field_handle_descriptor_callback(self, owner, field: DerivedFieldPlan) -> str: + def _field_handle_descriptor_callback(self, owner, field: DerivedFieldPlan, *, borrows: bool = False) -> str: """Build field handle descriptor callback from the supplied completed binding records; emitted nodes only project completed binding actions.""" if isinstance(owner, DerivedTypePlan): - return self._derived_handle_descriptor_callback_name(owner, field) - variable, member = owner - return self._module_member_handle_descriptor_callback_name(variable, member) + name = self._derived_handle_descriptor_callback_name(owner, field) + else: + variable, member = owner + name = self._module_member_handle_descriptor_callback_name(variable, member) + handle = field.native_array_handle + if ( + borrows + and handle is not None + and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ): + return f"{name}_capsule" + return name def _field_handle_actual_callback(self, owner, field: DerivedFieldPlan) -> str: """Build field handle actual callback from the supplied completed binding records; emitted nodes only project completed binding actions.""" @@ -4187,9 +4223,9 @@ def _module_native_array_data_operation_body( if operation is NativeArrayOperation.SHAPE: return self._module_native_array_shape_body(variable) if operation is NativeArrayOperation.TO_NUMPY: - return self._module_native_array_descriptor_body(variable) + return self._module_native_array_descriptor_body(variable, borrows_descriptor=False) if operation is NativeArrayOperation.DESCRIPTOR: - return self._module_native_array_descriptor_body(variable) + return self._module_native_array_descriptor_body(variable, borrows_descriptor=True) if operation is NativeArrayOperation.ASSOCIATE: return ( CDeclaration("source_packed", "PyObject *"), @@ -4249,13 +4285,20 @@ def _module_native_array_shape_body( def _module_native_array_descriptor_body( self, variable: ModuleVariablePlan, + *, + borrows_descriptor: bool, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Return standard descriptor facts for module extraction and handoff.""" + """Return standard descriptor facts for module extraction and handoff. + + ``borrows_descriptor`` selects the handoff form: the descriptor + operation may hand back a borrowed copy of the runtime's descriptor, + while extraction always reads decoded facts. + """ handle = variable.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Module handle {variable.owner_path!r} has no descriptor rank") if self._uses_module_allocatable_descriptor(variable): - return self._module_allocatable_descriptor_body(variable) + return self._module_allocatable_descriptor_body(variable, borrows_descriptor=borrows_descriptor) if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: return self._module_pointer_descriptor_body(variable) return self._module_contiguous_descriptor_body(variable) @@ -4272,14 +4315,27 @@ def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: def _module_allocatable_descriptor_body( self, variable: ModuleVariablePlan, + *, + borrows_descriptor: bool, ) -> tuple[CDeclaration | CExpressionStatement | CReturn, ...]: - """Request the current standard descriptor and return its decoded facts.""" + """Request the current standard descriptor as a borrowed copy or decoded facts.""" + handle = variable.native_array_handle + borrows = ( + borrows_descriptor + and handle is not None + and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ) + callback = ( + self._module_descriptor_capsule_callback_name(variable) + if borrows + else self._module_descriptor_callback_name(variable) + ) return ( CDeclaration("descriptor_record", "PyObject *", CodeExpression("NULL")), CExpressionStatement( CodeExpression( f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR)}(" - f"{self._module_descriptor_callback_name(variable)}, &descriptor_record)" + f"{callback}, &descriptor_record)" ) ), CReturn(CodeExpression("descriptor_record")), @@ -4329,6 +4385,27 @@ def _module_allocatable_descriptor_callbacks( ), ), ) + capsule_callbacks = ( + ( + CFunction( + self._module_descriptor_capsule_callback_name(variable), + "void", + parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), + storage="static", + body=( + CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), + *self._borrowed_descriptor_capsule_nodes( + handle.array.rank, + "descriptor", + self._native_array_handle_kind_constant(handle), + return_target="*(PyObject **)context", + ), + ), + ), + ) + if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + else () + ) array_actual_callback = CFunction( self._module_array_actual_callback_name(variable), "void", @@ -4339,7 +4416,11 @@ def _module_allocatable_descriptor_callbacks( CReturn(), ), ) - return descriptor_callback, array_actual_callback + return (descriptor_callback, *capsule_callbacks, array_actual_callback) + + def _module_descriptor_capsule_callback_name(self, variable: ModuleVariablePlan) -> str: + """Return the callback name that copies a borrowed module descriptor.""" + return f"{self._module_descriptor_callback_name(variable)}_capsule" def _module_descriptor_callback_name(self, variable: ModuleVariablePlan) -> str: """Return the binding-local module descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" @@ -5104,6 +5185,46 @@ def _pointer_association_cfi_type( return "CFI_type_char" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling + def _borrowed_descriptor_capsule_nodes( + self, + rank: int, + descriptor_name: str, + kind_constant: str, + *, + return_target: str, + ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: + """Copy one runtime-made descriptor into a capsule for a borrowed handle. + + An allocatable actual cannot be established from C, so the binding + keeps the descriptor the Fortran runtime built for this call instead of + rebuilding one from facts. Only the descriptor record is copied; its + ``base_addr`` still refers to the module or parent storage, which this + extension never allocates or releases. The copy is made fresh on every + call because reallocating the native entity invalidates the previous one. + """ + storage = f"{descriptor_name}_copy" + size = f"sizeof(CFI_CDESC_T({rank}))" + return ( + CDeclaration(storage, "CFI_cdesc_t *", CodeExpression(f"(CFI_cdesc_t *)calloc(1, {size})")), + CIf( + CodeExpression(f"{storage} == NULL"), + body=(CExpressionStatement(CodeExpression("PyErr_NoMemory()")), CReturn()), + ), + CExpressionStatement(CodeExpression(f"memcpy({storage}, {descriptor_name}, {size})")), + CExpressionStatement( + CodeExpression( + f"{return_target} = prik_native_array_handle_capsule_new(" + f"{kind_constant}, {rank}, {storage}->type, {storage}->elem_len, {size}, " + f"{storage}, prik_release_borrowed_native_descriptor)" + ) + ), + CIf( + CodeExpression(f"{return_target} == NULL"), + body=(CExpressionStatement(CodeExpression(f"free({storage})")),), + ), + CReturn(), + ) + def _native_array_descriptor_record_nodes( self, rank: int, @@ -5737,6 +5858,7 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> ops=f"{prefix}_ops", owner=f"{owner} != NULL ? {owner} : Py_None", descriptor_ownership="borrowed", + descriptor_handoff=self._borrowed_descriptor_handoff(handle), extraction_action=handle.extraction_action.value, ) ) @@ -7663,7 +7785,11 @@ def _lower_argument_native_array_direct( plan, context, names, - "_native_array_descriptor_handoff_for_binding_positional", + ( + "_native_array_descriptor_handoff_for_binding_positional" + if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE + else "_native_array_borrowed_descriptor_for_binding_positional" + ), default_binder_definition=binder_definition, ) ) @@ -8096,6 +8222,17 @@ def _module_native_array_elem_size(self, plan: ModuleVariablePlan) -> str: return f"{self._module_native_array_bridge_operation_name(plan, NativeArrayOperation.ELEMENT_LENGTH)}()" return f"sizeof({PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).c_spelling})" + @staticmethod + def _borrowed_descriptor_handoff(handle: NativeArrayHandlePlan) -> str: + """Name the descriptor form this handle's operation hands back. + + A handle whose completed plan borrows the native descriptor returns one + copied capsule per call; every other handle returns decoded facts. + """ + if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + return "borrowed_descriptor" + return "facts" + def _native_array_handle_factory_call( self, *, @@ -8108,18 +8245,21 @@ def _native_array_handle_factory_call( ops: str, owner: str, descriptor_ownership: str, + descriptor_handoff: str, extraction_action: str, ) -> str: """Call the runtime factory with a fixed dtype or deferred character dtype.""" dtype = self._native_array_dtype_for_semantic_type(semantic_type_name, datatype_family) if dtype is None: return ( - f'{target} = PyObject_CallFunction({helper}, "sOiOOssO", "{descriptor_kind}", Py_None, ' - f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", Py_None)' + f'{target} = PyObject_CallFunction({helper}, "sOiOOsssO", "{descriptor_kind}", Py_None, ' + f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' + f'"{descriptor_handoff}", Py_None)' ) return ( - f'{target} = PyObject_CallFunction({helper}, "ssiOOssO", "{descriptor_kind}", "{dtype}", ' - f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", Py_None)' + f'{target} = PyObject_CallFunction({helper}, "ssiOOsssO", "{descriptor_kind}", "{dtype}", ' + f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' + f'"{descriptor_handoff}", Py_None)' ) def _lower_argument_nullable_value( @@ -8507,6 +8647,7 @@ def _lower_result_owned_native_array_handle( ops=f"{prefix}_ops", owner=f"{prefix}_owner", descriptor_ownership="owned", + descriptor_handoff="facts", extraction_action=handle.extraction_action.value, ) ) diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 89a254d9b..175c72532 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -2822,7 +2822,11 @@ def _uses_typed_derived_value(plan: ArgumentTransferPlan) -> bool: def _expected_native_descriptor_data_action(self, plan: ArgumentTransferPlan) -> BridgeDataAction: """Distinguish call-local facts from persistent projected descriptors.""" handle = plan.native_array_handle - if handle is not None and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + if ( + handle is not None + and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + and handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE + ): return BridgeDataAction.DIRECT_TRANSFER return BridgeDataAction.ASSOCIATE_VIEW @@ -2930,7 +2934,10 @@ def _native_array_handle_argument_action_diagnostics( for name, actual, required in expected if actual is not required ) - projected = handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + projected = ( + handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + and handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE + ) expected_codegen = CodegenAction.IN_PLACE_ARGUMENT if projected else CodegenAction.CALL_LOCAL_INPUT if plan.binding.codegen_action is not expected_codegen: diagnostics.append( @@ -2953,7 +2960,10 @@ def _native_array_handle_argument_ownership_diagnostics( ) expected_destruction = ( DestructionPolicy.CALLER - if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + if ( + handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + and handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE + ) else DestructionPolicy.NONE ) if plan.destruction_policy is not expected_destruction: @@ -3076,11 +3086,15 @@ def _native_array_default_handle_storage_diagnostics( owner_path, "inconsistent-default-handle-owner-storage-role", default.owner_storage_role ) ) - expected_abi = { - NativeArrayDefaultConstruction.FACT_PACKED_EMPTY: NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL, - NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR: NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR, - }[default.construction] - if handle.handoff.abi is not expected_abi: + # A default handle that owns a lazily created descriptor requires the + # direct handoff. The converse does not hold: a borrowed allocatable + # descriptor crosses directly while its default handle is still built + # from facts, because the borrowed descriptor never comes from the + # default handle. + if ( + default.construction is NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR + and handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ): diagnostics.append( self._diagnostic(owner_path, "inconsistent-default-handle-descriptor-abi", handle.handoff.abi) ) @@ -3371,7 +3385,10 @@ def _direct_descriptor_diagnostics( diagnostics = [] if handle.handoff.descriptor_pointer_role is None or any(expected_counts): diagnostics.append(self._diagnostic(owner_path, "invalid-direct-native-descriptor-roles", None)) - if handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE: + if ( + handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE + and handle.descriptor_kind is not NativeArrayDescriptorKind.ALLOCATABLE + ): diagnostics.append(self._diagnostic(owner_path, "direct-descriptor-without-projection", None)) return tuple(diagnostics) diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 73978f6ec..a2824dc97 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6185,7 +6185,7 @@ def _native_array_handle_wrapper_policy( ) handle_kind = _native_array_enum(NativeArrayHandleKind, completed.handle_kind, owner_path, "handle kind") handoff = NativeDescriptorHandoffPolicy( - abi=_native_descriptor_handoff_abi(handle_kind, output_projection), + abi=_native_descriptor_handoff_abi(handle_kind, output_projection, descriptor, completed.optional_absent), rank=int(semantic_type.rank or 0), optional_presence=completed.optional_absent, ) @@ -6359,12 +6359,23 @@ def _native_array_default_handle_policy( def _native_descriptor_handoff_abi( handle_kind: NativeArrayHandleKind, output_projection: NativeArrayOutputProjection, + descriptor_kind: str, + optional_absent: bool, ) -> NativeDescriptorHandoffABI: - """Select one descriptor ABI from completed handle/result policy.""" + """Select one descriptor ABI from completed handle/result policy. + + An allocatable actual cannot be established from C: the standard reserves + that descriptor for the Fortran runtime, so the binding must borrow the + descriptor the handle already owns rather than build one. An optional + allocatable keeps the fact-packed form, whose absent branch establishes the + unallocated placeholder the present flag pairs with. + """ if handle_kind is NativeArrayHandleKind.OWNED_RESULT_DESCRIPTOR: return NativeDescriptorHandoffABI.OWNED_RESULT_STORAGE if output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + if descriptor_kind == NativeArrayDescriptorKind.ALLOCATABLE.value and not optional_absent: + return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR return NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 459688772..ce24e1302 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -49,9 +49,16 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class _NativeArrayDescriptorHandoff: - """Internal opaque handoff for one versioned native-handle capsule.""" + """Internal opaque handoff for one versioned native-handle capsule. + + ``borrowed`` marks a per-call copy of a descriptor the Fortran runtime + owns. Such a copy describes live storage and is correct to read, but a + callee that reallocates through it changes only the copy, so it may not + stand in for an argument whose allocation changes must reach the caller. + """ capsule: Any + borrowed: bool = False def __post_init__(self) -> None: if self.capsule is None: @@ -96,16 +103,20 @@ def _native_array_handle_from_generated_ops( owner: Any = None, descriptor_ownership: str = "borrowed", to_numpy_policy: str = "borrowed_view", + descriptor_handoff: str = "facts", generation: int | None = None, ) -> NativeArrayHandleBase: """Build a runtime handle from generated operation callables.""" owned = descriptor_ownership == "owned" + borrows_descriptor = descriptor_handoff == "borrowed_descriptor" normalized_ops = {} for name, operation in ops.items(): if name == "array_actual": normalized = _generated_handoff_operation(operation, owner=owner, pass_owner=owned) elif name == "descriptor" and owned: normalized = _generated_owned_descriptor_operation(operation, owner) + elif name == "descriptor" and borrows_descriptor: + normalized = _generated_borrowed_descriptor_operation(operation) elif name in {"shape", "to_numpy"} and owned: normalized = _generated_owned_descriptor_record_operation(operation, owner) elif name == "associate": @@ -345,6 +356,33 @@ def call(_handle: NativeArrayHandleBase, *args: Any) -> _NativeArrayDescriptorHa return call +def _generated_borrowed_descriptor_operation(operation: HandleOperation) -> HandleOperation: + """Adapt a borrowed descriptor copy into a typed handoff. + + Selected only for a handle whose completed plan borrows the native + descriptor. Its generated operation returns one capsule per call, copied + from the descriptor the Fortran runtime built for that call. + """ + + def call(handle: NativeArrayHandleBase, *args: Any) -> Any: + value = operation(*args) + if value is None or isinstance(value, _NativeArrayDescriptorHandoff): + return value + handoff = _NativeArrayDescriptorHandoff(value, borrowed=True) + # The binding reads the descriptor out of this capsule and releases its + # own reference before calling the native entrypoint, exactly as it does + # for an owned handle. An owned handle survives that because it holds + # the capsule itself, so a borrowed copy is held here for the same + # reason: the descriptor must outlive the call that uses it. The next + # descriptor request replaces it, which is safe because a generated + # binding runs no Python between reading this capsule and returning + # from the native call, so no other thread can replace it in between. + handle._borrowed_descriptor = handoff + return handoff + + return call + + def _generated_owned_descriptor_record_operation(operation: HandleOperation, owner: Any) -> HandleOperation: """Adapt owned descriptor facts and normalize compiler zero-extent sentinels.""" @@ -589,6 +627,9 @@ def __init__( self._descriptor_ownership = descriptor_ownership self._to_numpy_policy = to_numpy_policy self._generation = generation + # Holds the most recent borrowed descriptor copy so it outlives the call + # that reads it; see _generated_borrowed_descriptor_operation. + self._borrowed_descriptor: Any = None self._contract_default = False self._validate_required_ops() self._closed = False @@ -733,6 +774,9 @@ def _descriptor_for_binding( raise TypeError( f"{self.descriptor_kind} handle dtype {self.dtype!r} does not match expected dtype {expected_dtype!r}" ) + # Reading the shape is also the gate that rejects nonsense extents + # before any descriptor reaches native code, so it is not conditional + # on the dummy constraining a shape. shape = self.shape if shape is not None: self._validate_expected_shape(shape, expected_shape) @@ -1476,8 +1520,15 @@ def _native_array_descriptor_handoff_for_binding( expected_shape: Sequence[int | None] | int | None = None, optional_absent: bool = False, bind_default: HandleOperation | None = None, + allow_borrowed: bool = False, ) -> tuple[Any | None, ...]: - """Pack a versioned native-handle capsule for projected descriptor mutation.""" + """Pack a versioned native-handle capsule for a descriptor argument. + + ``allow_borrowed`` admits a per-call copy of a descriptor the Fortran + runtime owns. It is set only for read-only arguments: a callee that + reallocates through a copy would change the copy alone, leaving the + caller's entity pointing at released storage. + """ if isinstance(value, NativeArrayHandleBase) and value._contract_default: if bind_default is None: raise TypeError( @@ -1502,7 +1553,7 @@ def _native_array_descriptor_handoff_for_binding( ) if descriptor is None: return (None, None) if optional_absent else (None,) - if not isinstance(descriptor, _NativeArrayDescriptorHandoff): + if not isinstance(descriptor, _NativeArrayDescriptorHandoff) or (descriptor.borrowed and not allow_borrowed): raise TypeError( f"writable {descriptor_kind} descriptor argument requires a generated direct descriptor handoff" ) @@ -1511,6 +1562,33 @@ def _native_array_descriptor_handoff_for_binding( return (descriptor.capsule,) +def _native_array_borrowed_descriptor_for_binding_positional( + value: Any, + descriptor_kind: str, + expected_dtype: Any = None, + expected_rank: int | None = None, + expected_shape: Sequence[int | None] | int | None = None, + optional_absent: bool = False, + bind_default: HandleOperation | None = None, +) -> tuple[Any | None, ...]: + """Positional wrapper used by read-only descriptor CPython binding code. + + Unlike the projected form, this accepts a borrowed per-call copy: the callee + cannot change the allocation of a read-only descriptor argument, so nothing + has to travel back to the caller's entity. + """ + return _native_array_descriptor_handoff_for_binding( + value, + descriptor_kind=str(descriptor_kind), + expected_dtype=None if expected_dtype is None else np.dtype(expected_dtype), + expected_rank=None if expected_rank is None else int(expected_rank), + expected_shape=expected_shape, + optional_absent=bool(optional_absent), + bind_default=bind_default, + allow_borrowed=True, + ) + + def _native_array_descriptor_handoff_for_binding_positional( value: Any, descriptor_kind: str, diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index e1b3e87ec..0027729b5 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -117,6 +117,17 @@ static inline PyObject *prik_status_message_text(const char *bytes, Py_ssize_t c } +/* + * Release for a descriptor this extension copied but does not own. The copy + * itself is freed by prik_native_array_handle_release; the Fortran allocation + * it describes belongs to the module or parent object that declared it and + * must never be deallocated here. + */ +static inline void prik_release_borrowed_native_descriptor(void *descriptor) +{ + (void)descriptor; +} + static inline void prik_native_array_handle_release(prik_native_array_handle *handle) { void *descriptor; diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index 883b0ad5e..9eabc8dee 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -6,6 +6,7 @@ from prik.policy.completion import complete_semantic_policies from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner +from prik.policy.models import NativeArrayDescriptorKind, NativeDescriptorHandoffABI def _allocatable_plan(): @@ -117,3 +118,56 @@ def invalid_argument(values: Annotated[Allocatable[Float64[:]], MaybeUnallocated with pytest.raises(ValueError, match="MaybeUnallocated metadata"): complete_semantic_policies(module) + + +def _allocatable_argument_plan(): + module = parse_pyi_text( + """ +from prik.contracts import Allocatable, Float64, native_call + +@native_call([]) +def total(values: Allocatable[Float64[:]]) -> Float64: ... + +plain_allocatable: Allocatable[Float64[:]] +""", + module_name="allocatable_actuals", + ) + complete_semantic_policies(module) + return WrapperPlanner().build(module) + + +def test_no_generated_binding_establishes_an_allocated_allocatable_descriptor(): + """CFI_establish reserves the allocatable descriptor for the Fortran runtime. + + F2018 18.5.5.6 requires a null ``base_addr`` when the attribute is + ``CFI_attribute_allocatable``: an allocatable established from C must start + unallocated. Pairing that attribute with a real address describes an + already-allocated allocatable, which ifx rejects with + ``CFI_ERROR_BASE_ADDR_NOT_NULL`` while gfortran silently accepts it. + """ + artifacts = WrapperGenerator().generate(_allocatable_argument_plan()) + c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") + + forged = [ + line.strip() + for line in c_source.splitlines() + if "CFI_establish(" in line and "CFI_attribute_allocatable" in line and ", NULL," not in line + ] + assert forged == [] + + +def test_allocatable_argument_borrows_the_runtime_descriptor(): + """The binding copies the descriptor Fortran built instead of rebuilding one.""" + plan = _allocatable_argument_plan() + functions = {function.binding.python_name: function for function in plan.namespaces[0].functions} + argument = functions["total"].arguments[0] + handle = argument.native_array_handle + + assert handle is not None + assert handle.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE + assert handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + + artifacts = WrapperGenerator().generate(plan) + c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") + assert "prik_release_borrowed_native_descriptor" in c_source + assert "memcpy(" in c_source diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index b79a1a04d..3fe66fe3a 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -461,3 +461,119 @@ def test_module_allocatable_reports_its_real_lower_bound_with_or_without_target( handle = getattr(module, name) assert module.lower_bound_of(handle) == np.int32(5), name assert module.element_at(handle, np.int32(5)) == handle.to_numpy()[0], name + + +BORROWED_DESCRIPTOR_SOURCE = """\ +module fallocatable_borrowed_f90 + implicit none + type :: box + real(8), allocatable :: field(:) + end type box + real(8), allocatable :: modvar(:) + type(box) :: thebox +contains + subroutine grow(values) + real(8), allocatable, intent(inout) :: values(:) + + if (allocated(values)) deallocate(values) + allocate(values(6)) + values = 9.0_8 + end subroutine grow + + function total(values) result(sum_out) + real(8), allocatable, intent(in) :: values(:) + real(8) :: sum_out + + sum_out = sum(values) + end function total + + function make(n) result(values) + integer(4), intent(in) :: n + real(8), allocatable :: values(:) + integer(4) :: i + + allocate(values(n)) + values = [(1.0_8 * i, i = 1, n)] + end function make +end module fallocatable_borrowed_f90 +""" + + +def test_every_allocatable_handle_kind_reaches_a_read_only_allocatable_dummy(tmp_path: Path): + """An allocatable actual borrows the descriptor the Fortran runtime built. + + A read-only allocatable dummy requires an allocatable actual, and C may not + establish one: F2018 18.5.5.6 reserves that descriptor for the runtime. The + binding therefore copies the descriptor handed to its callback rather than + rebuilding one from facts, so module, derived-field and result handles all + reach the dummy on every compiler instead of only where an invalid + descriptor happens to be tolerated. + """ + workdir = tmp_path / "borrowed" + workdir.mkdir(parents=True) + module = _build_text_and_import( + BORROWED_DESCRIPTOR_SOURCE, + "fallocatable_borrowed_f90.f90", + workdir, + { + "bind_c_fallocatable_borrowed_f90_wrapper.f90", + "fallocatable_borrowed_f90_wrapper.c", + "fallocatable_borrowed_f90_wrapper.h", + }, + ) + namespace = _sole_native_module(module) + + namespace.modvar.resize(4) + namespace.modvar.to_numpy()[:] = [1.0, 2.0, 3.0, 4.0] + namespace.thebox.field.resize(4) + namespace.thebox.field.to_numpy()[:] = [1.0, 2.0, 3.0, 4.0] + + assert namespace.total(namespace.modvar) == np.float64(10.0) + assert namespace.total(namespace.thebox.field) == np.float64(10.0) + assert namespace.total(namespace.make(np.int32(4))) == np.float64(10.0) + + # The copy is remade per call, so reallocating the native entity between + # calls cannot leave the previous descriptor behind. + namespace.modvar.resize(3) + namespace.modvar.to_numpy()[:] = [100.0, 200.0, 300.0] + assert namespace.total(namespace.modvar) == np.float64(600.0) + + +def test_a_writable_allocatable_dummy_refuses_a_borrowed_descriptor(tmp_path: Path): + """A borrowed descriptor copy may not stand in where the callee reallocates. + + A read-only allocatable actual can be a per-call copy of the runtime's + descriptor, because the callee cannot change its allocation. An + ``intent(inout)`` allocatable can: the callee may deallocate and reallocate + it, and through a copy that would land in the copy and leave the caller's + entity pointing at released storage. Such an argument is refused instead. + """ + workdir = tmp_path / "writable" + workdir.mkdir(parents=True) + module = _build_text_and_import( + BORROWED_DESCRIPTOR_SOURCE, + "fallocatable_borrowed_f90.f90", + workdir, + { + "bind_c_fallocatable_borrowed_f90_wrapper.f90", + "fallocatable_borrowed_f90_wrapper.c", + "fallocatable_borrowed_f90_wrapper.h", + }, + ) + namespace = _sole_native_module(module) + + namespace.modvar.resize(2) + namespace.modvar.to_numpy()[:] = [1.0, 2.0] + with pytest.raises(TypeError, match="requires a generated direct descriptor handoff"): + namespace.grow(namespace.modvar) + + # The module variable is untouched by the refusal. + assert namespace.modvar.shape == (2,) + assert namespace.modvar.to_numpy().tolist() == [1.0, 2.0] + + # A handle that owns its descriptor still works, and the callee's + # reallocation reaches it. + owned = namespace.make(np.int32(2)) + namespace.grow(owned) + assert owned.shape == (6,) + assert owned.to_numpy().tolist() == [9.0] * 6 diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 8363029a1..f4ac2ab23 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -189,20 +189,28 @@ def test_native_handle_plans_keep_datatype_specific_state(): alloc = functions["alloc"].arguments[0] pointer = functions["pointer"].arguments[0] - for argument, descriptor_kind in ( - (alloc, NativeArrayDescriptorKind.ALLOCATABLE), - (pointer, NativeArrayDescriptorKind.POINTER), + # An allocatable actual cannot be established from C, so it borrows the + # descriptor the Fortran runtime made. A pointer actual can be established + # with a real base address, so it keeps the fact-packed call-local form. + for argument, descriptor_kind, abi in ( + (alloc, NativeArrayDescriptorKind.ALLOCATABLE, NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR), + (pointer, NativeArrayDescriptorKind.POINTER, NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL), ): handle = argument.native_array_handle assert handle is not None assert handle is argument.projected_call_slot.native_array_handle assert handle.descriptor_kind is descriptor_kind - assert handle.handoff.abi is NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL + assert handle.handoff.abi is abi assert handle.default_handle.construction is NativeArrayDefaultConstruction.FACT_PACKED_EMPTY assert handle.default_handle.descriptor_ownership is NativeArrayDescriptorOwnership.OWNED assert handle.default_handle.owner_storage_role is None assert NativeArrayOperation.DESTROY in handle.default_handle.operations - assert len(handle.handoff.extent_roles) == handle.array.rank == 1 + # Fact roles exist only for the fact-packed form; a borrowed descriptor + # carries its own extents, so none are named. + if abi is NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL: + assert len(handle.handoff.extent_roles) == handle.array.rank == 1 + else: + assert handle.handoff.extent_roles == () assert argument.binding.python_action is PythonBarrierAction.WRAPPER_INSTANCE assert argument.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR From 92af0d2f4e5ab34d4bd287d0d53e608b92ac8347 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 18:25:49 +0100 Subject: [PATCH 03/47] Publish a native entry-point table for module array handles Reaching the entity behind an array handle costs a Python operation lookup today: the binding imports the runtime module, fetches a helper, calls it, and that helper dispatches through a PyCFunction wrapper to reach a generated C function that finally calls the bridge. The work at the end of that chain is a single indirect call; measured against a generated extension, the descriptor round trip is 0.06 us out of roughly 7 us for passing one handle argument. A module array handle now publishes `prik_native_array_ops`, a versioned cross-extension record naming the bridge entry points for its entity. It carries the identity a consumer must agree on -- descriptor kind, rank, CFI type, element size -- and `owner`, the address the entity needs, which is resolved once when the handle is built rather than looked up per operation. `scoped_descriptor` invokes a consumer while the runtime's descriptor is valid, so the consumer decides whether to copy the record out or make its call in place. The descriptor stays a compiler-owned representation in this record, as everywhere else in the header, so it does not depend on the Fortran interop header and remains usable from a C-only extension. A generated forwarder bridges the bridge's own consumer signature to the table's, which avoids casting between function pointer types. Nothing consumes the table yet, so this changes no behavior. Reaching it from C and calling through it runs the same call about 28 times faster than the current path, which is what the following change will use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 151 ++++++++++++++++++++- prik/runtime/handles.py | 15 +- prik/runtime/native_support/prik_binding.h | 92 +++++++++++++ 3 files changed, 251 insertions(+), 7 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 20a39ac0c..cafcbae1e 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -3119,6 +3119,7 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name owner=owner_name, descriptor_ownership="borrowed", descriptor_handoff=self._borrowed_descriptor_handoff(handle), + native_ops="Py_None", extraction_action=handle.extraction_action.value, ) ) @@ -4057,6 +4058,14 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction self._native_array_capsule_release_function(argument) for _function, argument in self._default_native_array_arguments(plan) ), + *( + (self._native_array_forward_descriptor_function(),) + if any( + self._uses_module_allocatable_descriptor(variable) + for variable in self._module_native_array_variables(plan) + ) + else () + ), *( callback for variable in self._module_native_array_variables(plan) @@ -4416,7 +4425,134 @@ def _module_allocatable_descriptor_callbacks( CReturn(), ), ) - return (descriptor_callback, *capsule_callbacks, array_actual_callback) + return ( + descriptor_callback, + *capsule_callbacks, + array_actual_callback, + *self._module_native_array_ops_nodes(variable, handle), + ) + + def _module_native_array_ops_nodes( + self, + variable: ModuleVariablePlan, + handle: NativeArrayHandlePlan, + ) -> tuple[CFunction | CDeclaration, ...]: + """Emit the native entry-point table one module array handle publishes. + + The table names the bridge symbols for this variable so a consumer can + reach it with one indirect call instead of a Python operation lookup. + A module variable needs no owner, so the record is a file-scope + constant rather than per-handle storage. + """ + cfi_type = self._module_native_array_cfi_type(variable) + if cfi_type is None: + return () + bridge = self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR) + forward = self._module_scoped_descriptor_name(variable) + element_size = ( + "0" + if variable.datatype_family is DatatypeFamily.STRING + else f"sizeof({PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name).c_spelling})" + ) + return ( + CFunction( + forward, + "void", + parameters=( + CParameter("owner", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("context", "void *"), + ), + storage="static", + body=( + CDeclaration( + "forwarded", + "prik_native_array_descriptor_forward", + CodeExpression("{consumer, context}"), + ), + CExpressionStatement(CodeExpression("(void)owner")), + CExpressionStatement(CodeExpression(f"{bridge}(prik_native_array_forward_descriptor, &forwarded)")), + CReturn(), + ), + ), + CDeclaration( + self._module_native_array_ops_name(variable), + "static prik_native_array_ops", + CodeExpression( + "{PRIK_NATIVE_ARRAY_OPS_MAGIC, PRIK_NATIVE_ARRAY_OPS_ABI_VERSION, " + "(uint32_t)sizeof(prik_native_array_ops), " + f"{self._native_array_handle_kind_constant(handle)}, " + f"{handle.array.rank}, {cfi_type}, {element_size}, NULL, {forward}}}" + ), + ), + ) + + @staticmethod + def _native_array_forward_descriptor_function() -> CFunction: + """Emit the consumer that hands a runtime descriptor to a table consumer.""" + return CFunction( + "prik_native_array_forward_descriptor", + "void", + parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), + storage="static", + body=( + CDeclaration( + "forwarded", + "prik_native_array_descriptor_forward *", + CodeExpression("(prik_native_array_descriptor_forward *)context"), + ), + CExpressionStatement(CodeExpression("forwarded->consumer(descriptor, forwarded->context)")), + CReturn(), + ), + ) + + def _module_native_array_ops_capsule_name(self, variable: ModuleVariablePlan, prefix: str) -> str: + """Return the local holding this variable's published entry-point table.""" + if not self._uses_module_allocatable_descriptor(variable): + return "Py_None" + return f"{prefix}_native_ops" + + def _module_native_array_ops_declaration_nodes( + self, + variable: ModuleVariablePlan, + prefix: str, + ) -> tuple[CDeclaration, ...]: + """Declare and build the capsule publishing one variable's entry-point table.""" + if not self._uses_module_allocatable_descriptor(variable): + return () + return ( + CDeclaration( + f"{prefix}_native_ops", + "PyObject *", + CodeExpression(self._module_native_array_ops_capsule(variable)), + ), + ) + + def _module_native_array_ops_release_nodes( + self, + variable: ModuleVariablePlan, + prefix: str, + ) -> tuple[CExpressionStatement, ...]: + """Release the reference the published table capsule was created with.""" + if not self._uses_module_allocatable_descriptor(variable): + return () + return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_ops)")),) + + def _module_native_array_ops_capsule(self, variable: ModuleVariablePlan) -> str: + """Return the expression publishing this variable's native entry-point table.""" + if not self._uses_module_allocatable_descriptor(variable): + return "Py_None" + return ( + f"PyCapsule_New(&{self._module_native_array_ops_name(variable)}, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME, NULL)" + ) + + def _module_scoped_descriptor_name(self, variable: ModuleVariablePlan) -> str: + """Return the forwarder name that drives this variable's descriptor bridge.""" + return f"{self._module_descriptor_callback_name(variable)}_scoped" + + def _module_native_array_ops_name(self, variable: ModuleVariablePlan) -> str: + """Return the file-scope native entry-point table name for one module array.""" + return f"{self._module_descriptor_callback_name(variable)}_ops" def _module_descriptor_capsule_callback_name(self, variable: ModuleVariablePlan) -> str: """Return the callback name that copies a borrowed module descriptor.""" @@ -5791,6 +5927,7 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> CDeclaration(f"{prefix}_operation", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_runtime", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")), + *self._module_native_array_ops_declaration_nodes(plan, prefix), CIf(CodeExpression(f"{prefix}_ops == NULL"), body=(CReturn(CodeExpression("NULL")),)), ] for operation in handle.operations: @@ -5859,12 +5996,14 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> owner=f"{owner} != NULL ? {owner} : Py_None", descriptor_ownership="borrowed", descriptor_handoff=self._borrowed_descriptor_handoff(handle), + native_ops=self._module_native_array_ops_capsule_name(plan, prefix), extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_helper)")), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + *self._module_native_array_ops_release_nodes(plan, prefix), CIf(CodeExpression(f"{cache} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement(CodeExpression(f"Py_INCREF({cache})")), CReturn(CodeExpression(cache)), @@ -8246,20 +8385,21 @@ def _native_array_handle_factory_call( owner: str, descriptor_ownership: str, descriptor_handoff: str, + native_ops: str, extraction_action: str, ) -> str: """Call the runtime factory with a fixed dtype or deferred character dtype.""" dtype = self._native_array_dtype_for_semantic_type(semantic_type_name, datatype_family) if dtype is None: return ( - f'{target} = PyObject_CallFunction({helper}, "sOiOOsssO", "{descriptor_kind}", Py_None, ' + f'{target} = PyObject_CallFunction({helper}, "sOiOOsssOO", "{descriptor_kind}", Py_None, ' f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' - f'"{descriptor_handoff}", Py_None)' + f'"{descriptor_handoff}", {native_ops}, Py_None)' ) return ( - f'{target} = PyObject_CallFunction({helper}, "ssiOOsssO", "{descriptor_kind}", "{dtype}", ' + f'{target} = PyObject_CallFunction({helper}, "ssiOOsssOO", "{descriptor_kind}", "{dtype}", ' f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' - f'"{descriptor_handoff}", Py_None)' + f'"{descriptor_handoff}", {native_ops}, Py_None)' ) def _lower_argument_nullable_value( @@ -8648,6 +8788,7 @@ def _lower_result_owned_native_array_handle( owner=f"{prefix}_owner", descriptor_ownership="owned", descriptor_handoff="facts", + native_ops="Py_None", extraction_action=handle.extraction_action.value, ) ) diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index ce24e1302..51f15c51a 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -104,9 +104,16 @@ def _native_array_handle_from_generated_ops( descriptor_ownership: str = "borrowed", to_numpy_policy: str = "borrowed_view", descriptor_handoff: str = "facts", + native_ops: Any = None, generation: int | None = None, ) -> NativeArrayHandleBase: - """Build a runtime handle from generated operation callables.""" + """Build a runtime handle from generated operation callables. + + ``native_ops`` is an optional capsule publishing the entity's native entry + points, so a consumer can reach it with one indirect call rather than a + Python operation lookup. It is carried, not required: a handle without one + keeps working through its operation mapping. + """ owned = descriptor_ownership == "owned" borrows_descriptor = descriptor_handoff == "borrowed_descriptor" normalized_ops = {} @@ -139,7 +146,7 @@ def _native_array_handle_from_generated_ops( except KeyError: raise ValueError("generated native array handle kind must be 'allocatable' or 'pointer'") from None try: - return handle_cls( + handle = handle_cls( dtype=dtype, rank=rank, ops=normalized_ops, @@ -148,6 +155,8 @@ def _native_array_handle_from_generated_ops( to_numpy_policy=to_numpy_policy, generation=generation, ) + handle._native_ops = native_ops + return handle except BaseException: if owned and "destroy" in normalized_ops: with suppress(Exception): @@ -630,6 +639,8 @@ def __init__( # Holds the most recent borrowed descriptor copy so it outlives the call # that reads it; see _generated_borrowed_descriptor_operation. self._borrowed_descriptor: Any = None + # Optional capsule publishing this entity's native entry points. + self._native_ops: Any = None self._contract_default = False self._validate_required_ops() self._closed = False diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 0027729b5..aa7807814 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -59,6 +59,98 @@ void *prik_capture_address(void *base) } #endif +#define PRIK_NATIVE_ARRAY_OPS_ABI_VERSION 1u +#define PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME "prik.native_array_ops.v1" +#define PRIK_NATIVE_ARRAY_OPS_MAGIC UINT64_C(0x583250594e414f50) + +/* + * Consumer for one descriptor the Fortran runtime builds for a single call. + * The descriptor stays a compiler-owned representation here, as everywhere + * else in this header, so this record does not depend on the Fortran interop + * header and stays usable from a C-only extension. + */ +typedef void (*prik_native_array_descriptor_fn)(void *descriptor, void *context); + +/* + * Bridges a generated descriptor bridge, whose consumer takes the compiler's + * descriptor type, to a table consumer that takes it as void *. Forwarding + * through this record avoids casting between function pointer types. + */ +typedef struct { + prik_native_array_descriptor_fn consumer; + void *context; +} prik_native_array_descriptor_forward; + +/* Destination for prik_native_array_copy_descriptor. */ +typedef struct { + void *destination; + size_t size; +} prik_native_array_descriptor_copy; + +/* + * Versioned cross-extension table of native entry points for one array + * handle. The pointers are the generated bridge symbols for the entity the + * handle stands for, and `owner` is the address that entity needs -- the + * parent object for a derived-type field, NULL for a module variable. It is + * resolved once when the handle is built, so reaching the entity costs one + * indirect call instead of a Python attribute lookup per operation. + * + * `scoped_descriptor` invokes a consumer while the runtime's descriptor is + * valid. The consumer decides what to do with it: copy the record out, or + * make the native call in place while it is still live. + */ +typedef struct { + uint64_t magic; + uint32_t abi_version; + uint32_t struct_size; + uint32_t descriptor_kind; + uint32_t rank; + int32_t cfi_type; + size_t element_size; + void *owner; + void (*scoped_descriptor)(void *owner, prik_native_array_descriptor_fn consumer, void *context); +} prik_native_array_ops; + +/* Copy one runtime descriptor record into the caller's buffer. */ +static inline void prik_native_array_copy_descriptor(void *descriptor, void *context) +{ + prik_native_array_descriptor_copy *target = (prik_native_array_descriptor_copy *)context; + + memcpy(target->destination, descriptor, target->size); +} + +/* Decode one ops capsule, rejecting a record this extension cannot read. */ +static inline prik_native_array_ops *prik_native_array_ops_from_capsule( + PyObject *capsule, + uint32_t expected_descriptor_kind, + uint32_t expected_rank, + int expected_cfi_type, + size_t expected_element_size) +{ + prik_native_array_ops *ops; + + ops = (prik_native_array_ops *)PyCapsule_GetPointer(capsule, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME); + if (ops == NULL) { + return NULL; + } + if (ops->magic != PRIK_NATIVE_ARRAY_OPS_MAGIC + || ops->abi_version != PRIK_NATIVE_ARRAY_OPS_ABI_VERSION + || ops->struct_size != (uint32_t)sizeof(*ops)) { + PyErr_SetString(PyExc_TypeError, "incompatible prik native array ops record"); + return NULL; + } + if (ops->scoped_descriptor == NULL) { + PyErr_SetString(PyExc_TypeError, "prik native array ops record has no descriptor entry point"); + return NULL; + } + if (ops->descriptor_kind != expected_descriptor_kind || ops->rank != expected_rank + || ops->cfi_type != expected_cfi_type || ops->element_size != expected_element_size) { + PyErr_SetString(PyExc_TypeError, "native array handle does not match the declared dummy argument"); + return NULL; + } + return ops; +} + typedef void (*prik_native_array_release_fn)(void *descriptor); /* From c3b272df0c263607a91cba62b7a219d18cbfb0fa Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 18:46:49 +0100 Subject: [PATCH 04/47] Take the published entry-point table when placing a descriptor argument A read-only allocatable argument no longer reaches its entity through the Python runtime. When the handle publishes a native entry-point table the binding validates it in C -- descriptor kind, rank, CFI type and element size must agree with the dummy -- fills a call-local descriptor through `scoped_descriptor`, and checks the extents before use. Passing a module allocatable to such a dummy costs 0.31 us rather than 7.26 us, close to the 0.21 us an ordinary array argument costs. The table is taken only where a copy of the runtime's descriptor is a valid actual. A projected argument keeps the general path, because a callee that reallocates through a copy would change the copy alone; that is the same rule the borrowed handoff follows, enforced here while the code is generated rather than checked as it runs. An optional argument keeps it too, since the table carries no presence flag, and so does a dummy that fixes an extent, since the table declares no shape. A handle that publishes no table falls back unchanged, which is every derived-type field and result today. The negative-extent gate that guards the general path is kept, now against the filled descriptor in C rather than a separately fetched shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 97 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index cafcbae1e..63021af94 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -7889,6 +7889,90 @@ def _lower_argument_native_array_facts( nodes.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) return tuple(nodes) + _UNCONSTRAINED_ARRAY_EXTENTS = frozenset({":", "::Strided", "Flat"}) + + def _uses_native_array_ops_fast_path( + self, + plan: ArgumentTransferPlan, + handle: NativeArrayHandlePlan, + ) -> bool: + """Report whether this argument may take a handle's published entry-point table. + + The table hands back a copy of the descriptor the Fortran runtime + built, which stands in only where the callee cannot change the + allocation -- the same rule the borrowed handoff follows. It carries + no presence flag and no declared extents either, so an optional or + shape-constrained argument keeps the general path. + """ + return ( + handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + and handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE + and plan.binding.optional_mode is OptionalMode.REQUIRED + and handle.array.rank is not None + and all(extent in self._UNCONSTRAINED_ARRAY_EXTENTS for extent in handle.array.shape) + ) + + def _native_array_ops_fast_path_nodes( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + handle: NativeArrayHandlePlan, + fallback: tuple[CDeclaration | CExpressionStatement | CIf, ...], + ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: + """Take the handle's entry-point table when it publishes one, else fall back.""" + prefix = names.value_name + rank = handle.array.rank + cfi_type = self._native_array_cfi_type(plan) + capsule = f"{prefix}_ops_capsule" + table = f"{prefix}_native_ops" + storage = f"{prefix}_ops_storage" + target = f"{prefix}_ops_copy" + gate = " || ".join(f"{prefix}->dim[{axis}].extent < 0" for axis in range(rank)) + return ( + CDeclaration(storage, f"CFI_CDESC_T({rank})"), + CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CDeclaration( + target, + "prik_native_array_descriptor_copy", + CodeExpression(f"{{&{storage}, sizeof({storage})}}"), + ), + CExpressionStatement( + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + ), + CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), + CIf( + CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), + body=( + CExpressionStatement( + CodeExpression( + f"{table} = prik_native_array_ops_from_capsule({capsule}, " + f"{self._native_array_handle_kind_constant(handle)}, {rank}, {cfi_type}, " + f"{self._native_array_expected_element_size(plan)})" + ) + ), + CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), + CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CExpressionStatement( + CodeExpression( + f"{table}->scoped_descriptor({table}->owner, prik_native_array_copy_descriptor, &{target})" + ) + ), + CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{storage}")), + CExpressionStatement( + CodeExpression( + f"if ({gate}) {{ PyErr_SetString(PyExc_ValueError, " + f'"{plan.binding.python_name} reports a negative extent"); return NULL; }}' + ) + ), + ), + else_body=( + CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), + *fallback, + ), + ), + ) + def _lower_argument_native_array_direct( self, plan: ArgumentTransferPlan, @@ -7919,7 +8003,8 @@ def _lower_argument_native_array_direct( ), *(self._native_descriptor_presence_declarations(plan, names)), ] - nodes.extend( + general: list[CDeclaration | CExpressionStatement | CIf] = [] + general.extend( self._native_descriptor_helper_call_nodes( plan, context, @@ -7932,9 +8017,13 @@ def _lower_argument_native_array_direct( default_binder_definition=binder_definition, ) ) - nodes.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 1)) - nodes.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) - nodes.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) + general.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 1)) + general.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) + general.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) + if self._uses_native_array_ops_fast_path(plan, handle): + nodes.extend(self._native_array_ops_fast_path_nodes(plan, names, handle, tuple(general))) + else: + nodes.extend(general) return tuple(nodes) def _native_descriptor_object_declaration( From bde71204484a8f725d8ec20a0d0afd31a5b0be4d Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 19:48:48 +0100 Subject: [PATCH 05/47] Publish entry-point tables for field and owned result handles Both remaining allocatable handle kinds now publish the record module arrays already did, so a read-only descriptor argument reaches them without a Python operation lookup: a derived-type field costs 0.32 us rather than 7.64, and an owned result 0.27 us rather than about 7.3. The two kinds differ from a module array, and from each other. A field reaches its entity through the parent's address, so its record cannot be a file-scope constant: it is built when the handle is, released by the capsule that carries it, and its owner is resolved once instead of on every operation, which is where a field previously spent a capsule attribute lookup per call. Whether the bridge takes that address at all depends on the field: a derived-type field passes it, a module member does not. An owned result needs no bridge call for its descriptor. It allocated that descriptor and the callee filled it in place, so the current state is already local and the forwarder hands it straight to the consumer. That record is published before ownership moves into the handle capsule, while the pointer is still named. Both forwarders are declared ahead of the code that publishes them, because a handle getter is emitted before the forwarder it names. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 245 ++++++++++++++++++++- prik/runtime/native_support/prik_binding.h | 54 +++++ 2 files changed, 289 insertions(+), 10 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 63021af94..39aa15b38 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -3038,6 +3038,56 @@ def _fixed_string_field_input_nodes(self, field: DerivedFieldPlan, object_name: ), ) + def _field_handle_ops_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: + """Release the reference the published table capsule was created with.""" + if self._field_handle_ops_capsule_name(field, prefix) == "Py_None": + return () + return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_ops)")),) + + def _field_handle_ops_capsule_name(self, field: DerivedFieldPlan, prefix: str) -> str: + """Return the local holding this field handle's published entry-point table.""" + handle = field.native_array_handle + if handle is None or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + return "Py_None" + return f"{prefix}_native_ops" + + def _field_handle_ops_capsule_nodes(self, owner, field: DerivedFieldPlan, prefix: str, owner_name: str) -> tuple: + """Build the entry-point table this field handle publishes. + + A derived-type field reaches its entity through the parent's address, + so that address is resolved once here rather than on every operation. + A module member needs none. + """ + handle = field.native_array_handle + if handle is None or handle.array.rank is None: + return () + if handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + return () + cfi_type = self._field_native_array_cfi_type(field) + if cfi_type is None: + return () + parent = f"{prefix}_parent" + address = f"{parent}_address" if isinstance(owner, DerivedTypePlan) else "NULL" + forward = self._field_handle_scoped_descriptor_name(self._field_handle_descriptor_callback(owner, field)) + capsule = f"{prefix}_native_ops" + return ( + *( + self._derived_address_from_object_nodes(owner.backend_symbol, owner_name, parent) + if isinstance(owner, DerivedTypePlan) + else () + ), + CDeclaration( + capsule, + "PyObject *", + CodeExpression( + "prik_native_array_ops_capsule_new(" + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " + f"{self._field_native_array_element_size(field)}, {address}, {forward})" + ), + ), + CIf(CodeExpression(f"{capsule} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + ) + def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name: str) -> tuple: """Build a fresh borrowed handle whose operations are bound to its parent.""" handle = field.native_array_handle @@ -3049,7 +3099,10 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name runtime = f"{prefix}_runtime" helper = f"{prefix}_helper" result = f"{prefix}_handle" + # The entry-point table is built before anything that would need + # releasing, so its failure paths can return without cleanup. nodes = [ + *self._field_handle_ops_capsule_nodes(owner, field, prefix, owner_name), CDeclaration(ops, "PyObject *", CodeExpression("PyDict_New()")), CDeclaration(operation_object, "PyObject *", CodeExpression("NULL")), CDeclaration(runtime, "PyObject *", CodeExpression("NULL")), @@ -3119,13 +3172,14 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name owner=owner_name, descriptor_ownership="borrowed", descriptor_handoff=self._borrowed_descriptor_handoff(handle), - native_ops="Py_None", + native_ops=self._field_handle_ops_capsule_name(field, prefix), extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({helper})")), CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), + *self._field_handle_ops_release_nodes(field, prefix), CReturn(CodeExpression(result)), ) ) @@ -3566,6 +3620,28 @@ def _derived_field_c_type(self, field: DerivedFieldPlan) -> str: return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).c_spelling # Derived native-array-handle fields reuse the Phase 7 runtime protocol. + def _field_handle_scoped_descriptor_prototypes( + self, + field: DerivedFieldPlan, + descriptor_callback: str, + ) -> tuple[CFunctionPrototype, ...]: + """Declare the forwarder driving one field's descriptor bridge.""" + handle = field.native_array_handle + if handle is None or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + return () + return ( + CFunctionPrototype( + self._field_handle_scoped_descriptor_name(descriptor_callback), + "void", + ( + CParameter("owner", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("context", "void *"), + ), + storage="static", + ), + ) + def _derived_handle_operation_declarations( self, plan: ModulePlan, @@ -3574,6 +3650,9 @@ def _derived_handle_operation_declarations( declarations = [] for _owner, field, operation_name, callback_names in self._derived_handle_targets(plan): descriptor_callback, actual_callback = callback_names + # The getter that publishes this field's table is emitted before the + # forwarder it names, so the forwarder is declared here. + declarations.extend(self._field_handle_scoped_descriptor_prototypes(field, descriptor_callback)) declarations.extend( ( CFunctionPrototype( @@ -3615,9 +3694,28 @@ def _derived_handle_operation_declarations( def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: """Lower descriptor callbacks and parent-bound runtime operations.""" functions = [] + if self._emits_native_array_ops(plan): + # The shared consumer is defined once with the module-array + # section, which follows these forwarders in the emitted file. + functions.append( + CFunctionPrototype( + "prik_native_array_forward_descriptor", + "void", + (CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), + storage="static", + ) + ) for owner, field, operation_name, callback_names in self._derived_handle_targets(plan): descriptor_callback, actual_callback = callback_names functions.extend(self._field_handle_descriptor_callbacks(field, descriptor_callback, actual_callback)) + functions.extend( + self._field_handle_ops_nodes( + field, + self._field_handle_bridge_name(owner, field, NativeArrayOperation.DESCRIPTOR), + self._field_handle_scoped_descriptor_name(descriptor_callback), + takes_owner=isinstance(owner, DerivedTypePlan), + ) + ) handle = field.native_array_handle if handle is None: continue @@ -3663,6 +3761,59 @@ def _derived_handle_targets(self, plan: ModulePlan) -> tuple[tuple, ...]: ) return tuple(targets) + @staticmethod + def _field_handle_scoped_descriptor_name(descriptor_callback: str) -> str: + """Return the forwarder name that drives one field's descriptor bridge.""" + return f"{descriptor_callback}_scoped" + + def _field_handle_ops_nodes( + self, + field: DerivedFieldPlan, + descriptor_bridge: str, + forward_name: str, + *, + takes_owner: bool, + ) -> tuple[CFunction, ...]: + """Emit the forwarder driving one field's descriptor bridge. + + A field reaches its entity through its parent's address, so unlike a + module variable the table cannot be a file-scope constant: the owner + differs per handle and is filled in when the handle is built. + """ + handle = field.native_array_handle + if handle is None or handle.array.rank is None: + return () + if handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + return () + return ( + CFunction( + forward_name, + "void", + parameters=( + CParameter("owner", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("context", "void *"), + ), + storage="static", + body=( + CDeclaration( + "forwarded", + "prik_native_array_descriptor_forward", + CodeExpression("{consumer, context}"), + ), + # A module member reaches its field without a parent address. + *(() if takes_owner else (CExpressionStatement(CodeExpression("(void)owner")),)), + CExpressionStatement( + CodeExpression( + f"{descriptor_bridge}({'owner, ' if takes_owner else ''}" + "prik_native_array_forward_descriptor, &forwarded)" + ) + ), + CReturn(), + ), + ), + ) + def _field_handle_descriptor_callbacks( self, field: DerivedFieldPlan, @@ -4055,17 +4206,15 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction for _function, result in self._owned_native_array_results(plan) ), *( - self._native_array_capsule_release_function(argument) - for _function, argument in self._default_native_array_arguments(plan) + node + for _function, result in self._owned_native_array_results(plan) + for node in self._owned_result_ops_nodes(result) ), *( - (self._native_array_forward_descriptor_function(),) - if any( - self._uses_module_allocatable_descriptor(variable) - for variable in self._module_native_array_variables(plan) - ) - else () + self._native_array_capsule_release_function(argument) + for _function, argument in self._default_native_array_arguments(plan) ), + *((self._native_array_forward_descriptor_function(),) if self._emits_native_array_ops(plan) else ()), *( callback for variable in self._module_native_array_variables(plan) @@ -4487,6 +4636,18 @@ def _module_native_array_ops_nodes( ), ) + def _emits_native_array_ops(self, plan: ModulePlan) -> bool: + """Report whether any handle in this module publishes an entry-point table.""" + if any( + self._uses_module_allocatable_descriptor(variable) for variable in self._module_native_array_variables(plan) + ): + return True + return any( + field.native_array_handle is not None + and field.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + for _owner, field, _operation_name, _callbacks in self._derived_handle_targets(plan) + ) + @staticmethod def _native_array_forward_descriptor_function() -> CFunction: """Emit the consumer that hands a runtime descriptor to a table consumer.""" @@ -8438,6 +8599,18 @@ def _native_array_cfi_type(self, plan: ArgumentTransferPlan | ResultPlan) -> str return "CFI_type_char" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling + def _field_native_array_cfi_type(self, field: DerivedFieldPlan) -> str | None: + """Return one field handle's standard-descriptor element type.""" + if field.string_element: + return "CFI_type_char" + return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).cfi_type_spelling + + def _field_native_array_element_size(self, field: DerivedFieldPlan) -> str: + """Return one field handle's fixed element size, or zero for a runtime width.""" + if field.string_element: + return "0" + return f"sizeof({PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).c_spelling})" + def _module_native_array_cfi_type(self, plan: ModuleVariablePlan) -> str | None: """Return one module handle's standard-descriptor element type.""" if plan.datatype_family is DatatypeFamily.STRING: @@ -8765,6 +8938,54 @@ def _lower_result_scalar_descriptor( ) # Owned native-array-handle result lowering. + def _owned_result_ops_capsule_nodes(self, plan: ResultPlan, prefix: str, descriptor_name: str) -> tuple: + """Build the entry-point table an owned result handle publishes.""" + handle = plan.native_array_handle + cfi_type = self._native_array_cfi_type(plan) + if handle is None or handle.array.rank is None or cfi_type is None: + return () + capsule = f"{prefix}_native_ops" + return ( + CExpressionStatement( + CodeExpression( + f"{capsule} = prik_native_array_ops_capsule_new(" + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " + f"{self._native_array_expected_element_size(plan)}, {descriptor_name}, " + f"{self._owned_result_scoped_descriptor_name(plan)})" + ) + ), + CIf(CodeExpression(f"{capsule} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + ) + + def _owned_result_ops_nodes(self, plan: ResultPlan) -> tuple[CFunction, ...]: + """Emit the forwarder handing over an owned result's descriptor. + + Unlike a module variable or a field, this handle allocated the + descriptor itself and the callee filled it in place, so the current + state is already here: the forwarder passes it straight to the consumer + without asking Fortran for it. + """ + handle = plan.native_array_handle + if handle is None or handle.array.rank is None: + return () + return ( + CFunction( + self._owned_result_scoped_descriptor_name(plan), + "void", + parameters=( + CParameter("owner", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("context", "void *"), + ), + storage="static", + body=(CExpressionStatement(CodeExpression("consumer(owner, context)")), CReturn()), + ), + ) + + def _owned_result_scoped_descriptor_name(self, plan: ResultPlan) -> str: + """Return the forwarder name handing over one owned result descriptor.""" + return f"{self._native_array_capsule_release_name(plan)}_scoped" + def _lower_result_owned_native_array_handle( self, plan: ResultPlan, @@ -8785,6 +9006,7 @@ def _lower_result_owned_native_array_handle( CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_ops", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_owner", "PyObject *", CodeExpression("NULL")), + CDeclaration(f"{prefix}_native_ops", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_operation", "PyObject *", CodeExpression("NULL")), CDeclaration(python_name, "PyObject *", CodeExpression("NULL")), *self._owned_pointer_result_normalization_nodes( @@ -8831,6 +9053,9 @@ def _lower_result_owned_native_array_handle( CReturn(CodeExpression("NULL")), ), ), + # Published before ownership of the descriptor moves into the + # handle capsule, while the pointer is still named here. + *self._owned_result_ops_capsule_nodes(plan, prefix, descriptor_name), CExpressionStatement(CodeExpression(f"{descriptor_name} = NULL")), CExpressionStatement( CodeExpression(f'{prefix}_runtime = PyImport_ImportModule("prik.runtime.handles")') @@ -8877,7 +9102,7 @@ def _lower_result_owned_native_array_handle( owner=f"{prefix}_owner", descriptor_ownership="owned", descriptor_handoff="facts", - native_ops="Py_None", + native_ops=f"{prefix}_native_ops", extraction_action=handle.extraction_action.value, ) ) diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index aa7807814..223a0ff69 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -119,6 +119,60 @@ static inline void prik_native_array_copy_descriptor(void *descriptor, void *con memcpy(target->destination, descriptor, target->size); } +/* Free the per-handle entry-point table a capsule owns. */ +static inline void prik_native_array_ops_capsule_destructor(PyObject *capsule) +{ + void *ops; + + ops = PyCapsule_GetPointer(capsule, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME); + if (ops == NULL) { + PyErr_Clear(); + return; + } + free(ops); +} + +/* + * Publish a per-handle entry-point table. A handle whose entity needs an + * owner address cannot share one file-scope record, so its table is built + * when the handle is and released with the capsule that carries it. + */ +static inline PyObject *prik_native_array_ops_capsule_new( + uint32_t descriptor_kind, + uint32_t rank, + int cfi_type, + size_t element_size, + void *owner, + void (*scoped_descriptor)(void *owner, prik_native_array_descriptor_fn consumer, void *context)) +{ + prik_native_array_ops *ops; + PyObject *capsule; + + if (scoped_descriptor == NULL) { + PyErr_SetString(PyExc_ValueError, "prik native array ops needs a descriptor entry point"); + return NULL; + } + ops = (prik_native_array_ops *)calloc(1, sizeof(*ops)); + if (ops == NULL) { + PyErr_NoMemory(); + return NULL; + } + ops->magic = PRIK_NATIVE_ARRAY_OPS_MAGIC; + ops->abi_version = PRIK_NATIVE_ARRAY_OPS_ABI_VERSION; + ops->struct_size = (uint32_t)sizeof(*ops); + ops->descriptor_kind = descriptor_kind; + ops->rank = rank; + ops->cfi_type = (int32_t)cfi_type; + ops->element_size = element_size; + ops->owner = owner; + ops->scoped_descriptor = scoped_descriptor; + capsule = PyCapsule_New(ops, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME, prik_native_array_ops_capsule_destructor); + if (capsule == NULL) { + free(ops); + } + return capsule; +} + /* Decode one ops capsule, rejecting a record this extension cannot read. */ static inline prik_native_array_ops *prik_native_array_ops_from_capsule( PyObject *capsule, From 3cc23e47b196f052c16adc2409bb93fbd73d9bda Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 23:09:50 +0100 Subject: [PATCH 06/47] Detach a borrowed descriptor from the capsule that carried it A read-only descriptor argument receives a per-call copy of the descriptor the Fortran runtime built, carried by a capsule the handle keeps alive so it survives the binding releasing its own reference. That capsule is replaced on the handle's next descriptor request, which is safe only while nothing else can make one mid-call. A function marked `@nogil` releases the GIL around its native call, so another thread can reach the same handle while the first is still using the descriptor: it replaces the capsule, the copy is freed, and the descriptor in flight is left pointing at released storage. The window is narrow and needs the same handle passed concurrently, but nothing in the binding prevented it. The binding now copies the record into the call's own storage before releasing its reference, so what it passes to the entrypoint belongs to that call alone. This is what the entry-point table path already did by filling call-local storage; the two paths now agree. A projected argument needs no copy, since its descriptor is the persistent one its handle owns rather than a per-call capsule. The comment describing the earlier reasoning claimed a generated binding runs no Python between reading the capsule and returning from the native call. That holds only while the GIL is held, and is corrected here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 70 ++++++++++++++++++++++++++++++++++++--- prik/runtime/handles.py | 8 ++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 39aa15b38..942edaf37 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -8098,42 +8098,102 @@ def _native_array_ops_fast_path_nodes( "prik_native_array_descriptor_copy", CodeExpression(f"{{&{storage}, sizeof({storage})}}"), ), + CComment("A handle publishes a table of native entry points when its entity can be"), + CComment("reached directly; one that does not takes the general path below."), CExpressionStatement( - CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")'), ), CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), CIf( CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), body=( + CComment("The table names the entity it stands for: refuse a handle whose kind,"), + CComment("rank, element type or element size disagrees with this dummy."), CExpressionStatement( CodeExpression( f"{table} = prik_native_array_ops_from_capsule({capsule}, " f"{self._native_array_handle_kind_constant(handle)}, {rank}, {cfi_type}, " - f"{self._native_array_expected_element_size(plan)})" + f"{self._native_array_expected_element_size(plan)})", ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CComment("Fortran builds the descriptor for this call and hands it to the consumer,"), + CComment("which copies the record into our storage. Only the record is copied;"), + CComment("base_addr still refers to the entity's own data. It is rebuilt every call"), + CComment("because reallocating the entity leaves an earlier descriptor describing"), + CComment("storage that has been released."), CExpressionStatement( CodeExpression( - f"{table}->scoped_descriptor({table}->owner, prik_native_array_copy_descriptor, &{target})" + f"{table}->scoped_descriptor({table}->owner, prik_native_array_copy_descriptor, &{target})", ) ), CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{storage}")), + CComment("A negative extent would index outside the array, so it is refused here"), + CComment("rather than reaching the native call."), CExpressionStatement( CodeExpression( f"if ({gate}) {{ PyErr_SetString(PyExc_ValueError, " - f'"{plan.binding.python_name} reports a negative extent"); return NULL; }}' + f'"{plan.binding.python_name} reports a negative extent"); return NULL; }}', ) ), ), else_body=( + CComment("No table: fetch this handle's descriptor through the Python runtime."), CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), *fallback, ), ), ) + def _borrowed_descriptor_detach_declarations( + self, + names: _CArgumentNames, + handle: NativeArrayHandlePlan, + ) -> tuple[CDeclaration, ...]: + """Declare this call's own storage for a detached borrowed descriptor.""" + if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: + return () + if handle.array.rank is None: + return () + return (CDeclaration(f"{names.value_name}_detached", f"CFI_CDESC_T({handle.array.rank})"),) + + def _borrowed_descriptor_detach_nodes( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + handle: NativeArrayHandlePlan, + ) -> tuple[CComment | CDeclaration | CExpressionStatement, ...]: + """Detach a borrowed descriptor from the capsule that carried it. + + A read-only argument receives a per-call copy owned by a capsule the + handle keeps alive, and that capsule is replaced on the handle's next + descriptor request. Releasing the GIL around the native call lets + another thread make that request while this one is still using the + descriptor, so the record is copied into this call's own storage and + the capsule stops mattering. A projected argument needs no copy: its + descriptor is the persistent one its handle owns. + """ + if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: + return () + if handle.array.rank is None: + return () + prefix = names.value_name + storage = f"{prefix}_detached" + size = f"sizeof(CFI_CDESC_T({handle.array.rank}))" + return ( + CComment("The descriptor above belongs to a capsule the handle replaces on its"), + CComment("next request, which another thread may make while the GIL is released"), + CComment("for the native call. Copy the record so this call owns what it uses."), + CIf( + CodeExpression(f"{prefix} != NULL"), + body=( + CExpressionStatement(CodeExpression(f"memcpy(&{storage}, {prefix}, {size})")), + CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{storage}")), + ), + ), + ) + def _lower_argument_native_array_direct( self, plan: ArgumentTransferPlan, @@ -8158,6 +8218,7 @@ def _lower_argument_native_array_direct( "prik_native_array_handle *", CodeExpression("NULL"), ), + *self._borrowed_descriptor_detach_declarations(names, handle), *self._native_descriptor_helper_declarations( prefix, include_default_binder=binder_definition is not None, @@ -8180,6 +8241,7 @@ def _lower_argument_native_array_direct( ) general.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 1)) general.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) + general.extend(self._borrowed_descriptor_detach_nodes(plan, names, handle)) general.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) if self._uses_native_array_ops_fast_path(plan, handle): nodes.extend(self._native_array_ops_fast_path_nodes(plan, names, handle, tuple(general))) diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 51f15c51a..ddf0046fa 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -382,10 +382,10 @@ def call(handle: NativeArrayHandleBase, *args: Any) -> Any: # own reference before calling the native entrypoint, exactly as it does # for an owned handle. An owned handle survives that because it holds # the capsule itself, so a borrowed copy is held here for the same - # reason: the descriptor must outlive the call that uses it. The next - # descriptor request replaces it, which is safe because a generated - # binding runs no Python between reading this capsule and returning - # from the native call, so no other thread can replace it in between. + # reason: the descriptor must outlive the call that reads it. A binding + # copies the record into its own storage before releasing its reference, + # so replacing this one on the next request cannot disturb a call that + # is already under way, including one that has released the GIL. handle._borrowed_descriptor = handoff return handoff From ab2b6b7bc338db9c6e9598184923f7cfe57eff5f Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 23:10:13 +0100 Subject: [PATCH 07/47] Refuse a PROTECTED module array before generating its accessors Every generated accessor for a module array either allocates, deallocates, or passes the variable to a dummy the callee may define. PROTECTED forbids all three outside the declaring module, so the bridge could not compile: the build failed with three Fortran diagnostics about ALLOCATE, DEALLOCATE and INTENT(INOUT) on the variable, none of which named the attribute responsible. The parser now records the attribute and completed policy refuses the variable, naming the reason once: Semantic variable 'm.guarded' has unsupported module-variable policy: module variable 'guarded' is PROTECTED, so a generated accessor cannot define it outside its module The attribute travels as internal declaration metadata rather than a serialized parser field, alongside the other facts that matter to generation without describing the parse tree, so recorded fixture output is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/parsers/fortran/parser.py | 4 ++++ prik/policy/construction.py | 7 +++++++ prik/semantics/fortran2ir.py | 2 ++ 3 files changed, 13 insertions(+) diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index cf9024d22..9362a07be 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -292,6 +292,7 @@ class EnumUnit(SourceUnit): "contiguous": "contiguous", "external": "external", "parameter": "parameter", + "protected": "protected", } ) _EMPTY_COMPILE_TIME_SYMBOLS: Mapping[str, str] = MappingProxyType({}) @@ -356,6 +357,7 @@ class _Declaration: allocatable: bool = False pointer: bool = False target: bool = False + protected: bool = False contiguous: bool = False external: bool = False parameter: bool = False @@ -4324,6 +4326,8 @@ def _apply_internal_type_metadata(arg: FortranVariable, declaration: _Declaratio arg._declared_storage_bits = declaration.declared_storage_bits if declaration.polymorphic: arg._fortran_polymorphic = True + if declaration.protected: + arg._fortran_protected = True @staticmethod def _split_dim_bounds(dim: str) -> tuple[str | None, str | None]: diff --git a/prik/policy/construction.py b/prik/policy/construction.py index a2824dc97..08d220a37 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -5380,6 +5380,13 @@ def _runtime_semantic_validation_blockers( blockers.append(f"{label} has no runtime validators for semantic constraints {constraints}") if coercions: blockers.append(f"{label} has no wrapper conversion actions for semantic coercions {coercions}") + if semantic_type.metadata.get("fortran_protected"): + # Every generated accessor for a module array either allocates, + # deallocates or passes the variable to a dummy the callee may define. + # PROTECTED forbids all three outside the declaring module, so the + # bridge is refused here rather than emitted and left to fail in the + # Fortran compiler. + blockers.append(f"{label} is PROTECTED, so a generated accessor cannot define it outside its module") return tuple(blockers) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index f6fe7327a..8d269ccfc 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -468,6 +468,8 @@ def _convert_variable_type( if getattr(var, "target", False): metadata["aliased"] = True metadata["fortran_target"] = True + if getattr(var, "_fortran_protected", False): + metadata["fortran_protected"] = True if getattr(var, "pointer", False): metadata["fortran_pointer"] = True metadata["fortran_pointer_association"] = "runtime" From 24a01f953479031ed43e5913f9bc6519e2c84a17 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 3 Sep 2026 23:10:39 +0100 Subject: [PATCH 08/47] Let a descriptor consumer define the module array it receives The consumer a module array hands its descriptor to declared the dummy `intent(in)`, so a callee reached through that descriptor could read the array but nothing it changed about the allocation could travel back: with `intent(in)` the compiler has no obligation to copy the descriptor back into the module variable when the bridge returns. Declaring it `intent(inout)` restores that obligation. A reader is unaffected, because copying back an unchanged descriptor changes nothing, and reading paths are byte-identical on gfortran and ifx. What it enables is the case PRIK has always refused: a procedure that reallocates its allocatable dummy, reached through the descriptor the runtime builds for that call, now updates the module variable rather than a copy that is discarded. Nothing uses that yet -- the binding still hands the callee a copy, and a writable descriptor argument is still refused at the boundary. This is the bridge half of that support. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/fortran/bridge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 21c0814ec..651dbaa06 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -2849,8 +2849,8 @@ def _module_descriptor_consumer_value_declaration( """ dimension = self._array_dimension_attribute(rank) if plan.datatype_family is DatatypeFamily.STRING and plan.character_length is not None: - return "character(kind=c_char, len=*)", ("allocatable", dimension, "intent(in)") - return self._module_native_array_element_type(plan), ("allocatable", dimension, "intent(in)") + return "character(kind=c_char, len=*)", ("allocatable", dimension, "intent(inout)") + return self._module_native_array_element_type(plan), ("allocatable", dimension, "intent(inout)") def _module_native_array_operation_name(self, plan: ModuleVariablePlan, operation) -> str: """Return one planner-owned module native-array operation symbol.""" From 248ff1e6aaf605fa6f71da82eb1e062d0644444a Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 01:07:28 +0100 Subject: [PATCH 09/47] Call inside the descriptor consumer for a writable allocatable dummy Passing a module array or a derived-type field to a procedure that reallocates its `intent(inout)` allocatable dummy was refused. The descriptor those entities hand out exists only while the consumer holding it runs, so a callee given a copy would replace the allocation in the copy, and the caller's entity would be left naming released storage. Refusing was the honest response to that. The call is now made inside the consumer, where the descriptor the runtime built is still live, so what the callee writes into it is what Fortran copies back to the entity when the bridge returns. A module array and a field both reach size 6 from size 2 through such a callee on gfortran and ifx, where before the argument was rejected at the boundary. The consumer serves both routes to a descriptor. A handle publishing native entry points builds one inside it; a handle that owns a descriptor already -- a result, or a contract default with no native entity behind it -- hands that over directly. The call appears once either way. Field consumers take `intent(inout)` as module ones now do. They previously took `intent(in)`, under which the copy-back is unspecified; both compilers tested happened to perform it, which is not something to depend on. The inversion covers the case where the descriptor is the entrypoint's only parameter. A value result or a further argument would have to travel out of the consumer, which needs a context record this does not build, so those keep the general path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 147 +++++++++++++++++- prik/codegen/fortran/bridge.py | 7 +- .../end_to_end/test_allocatable_handles.py | 40 +++-- 3 files changed, 174 insertions(+), 20 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 942edaf37..198586315 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -157,6 +157,7 @@ class _CFunctionContext: python_result_name: str | None python_results: dict[str, str] role_values: dict[str, str] + inverted_descriptor: str | None = None @dataclass(frozen=True) @@ -4215,6 +4216,7 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction for _function, argument in self._default_native_array_arguments(plan) ), *((self._native_array_forward_descriptor_function(),) if self._emits_native_array_ops(plan) else ()), + *self._inverted_descriptor_consumer_functions(plan), *( callback for variable in self._module_native_array_variables(plan) @@ -8146,6 +8148,57 @@ def _native_array_ops_fast_path_nodes( ), ) + def _inverted_descriptor_table_nodes( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + handle: NativeArrayHandlePlan, + fallback: tuple, + ) -> tuple: + """Reach this argument's descriptor by whichever route its handle offers. + + A handle standing for a module array or a field publishes a table, and + the descriptor it names is built inside the consumer that makes the + call -- the only place a callee can change the allocation and have that + reach the caller's entity. A handle that owns its descriptor publishes + no table and needs none: the descriptor it already holds is handed to + the same consumer directly. + """ + prefix = names.value_name + capsule = f"{prefix}_ops_capsule" + table = f"{prefix}_native_ops" + return ( + CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CComment(f"'{plan.binding.python_name}' may have its allocation changed by the callee."), + CComment("A handle that publishes native entry points builds its descriptor inside"), + CComment("the consumer; one that owns a descriptor already hands that over instead."), + CExpressionStatement( + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + ), + CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), + CIf( + CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), + body=( + CExpressionStatement( + CodeExpression( + f"{table} = prik_native_array_ops_from_capsule({capsule}, " + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"{self._native_array_cfi_type(plan)}, " + f"{self._native_array_expected_element_size(plan)})" + ) + ), + CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), + CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + ), + else_body=( + CComment("No table: this handle owns the descriptor it will hand over."), + CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), + *fallback, + ), + ), + ) + def _borrowed_descriptor_detach_declarations( self, names: _CArgumentNames, @@ -8225,6 +8278,7 @@ def _lower_argument_native_array_direct( ), *(self._native_descriptor_presence_declarations(plan, names)), ] + inverted = context.inverted_descriptor == plan.owner_path general: list[CDeclaration | CExpressionStatement | CIf] = [] general.extend( self._native_descriptor_helper_call_nodes( @@ -8243,7 +8297,9 @@ def _lower_argument_native_array_direct( general.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) general.extend(self._borrowed_descriptor_detach_nodes(plan, names, handle)) general.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) - if self._uses_native_array_ops_fast_path(plan, handle): + if inverted: + nodes.extend(self._inverted_descriptor_table_nodes(plan, names, handle, tuple(general))) + elif self._uses_native_array_ops_fast_path(plan, handle): nodes.extend(self._native_array_ops_fast_path_nodes(plan, names, handle, tuple(general))) else: nodes.extend(general) @@ -9794,6 +9850,91 @@ def _result_native_name(self, plan: ResultPlan, context: _CFunctionContext) -> s except KeyError: raise ValueError(f"Hidden result {plan.owner_path!r} has no C output storage") from None + def _inverted_descriptor_argument(self, plan: FunctionPlan) -> ArgumentTransferPlan | None: + """Return the argument whose descriptor must stay live across the call. + + A callee may change the allocation of a writable allocatable dummy. The + descriptor the runtime builds for a module array or field exists only + while the consumer it was handed to is running, so handing the callee a + copy would lose that change. The call is made inside the consumer + instead. Only the case where that descriptor is the entrypoint's sole + parameter is inverted today; every other shape keeps the general path. + """ + candidates = [ + argument + for argument in plan.arguments + if argument.native_array_handle is not None + and argument.native_array_handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE + and argument.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + and argument.binding.optional_mode is OptionalMode.REQUIRED + and argument.native_array_handle.array.rank is not None + ] + if len(candidates) != 1 or len(plan.entrypoint.parameters) != 1: + return None + # A projected handle is written back to Python after the call, which + # needs nothing from the descriptor, so that action is compatible. A + # value result or any other output is not yet, because reading it would + # have to travel out of the consumer. + if self._direct_result(plan) is not None or plan.results: + return None + return candidates[0] + + def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) -> tuple: + """Emit the native call, inside a descriptor consumer where one is required.""" + if context.inverted_descriptor is None: + return self._lower_native_call(plan, self._entrypoint_call_statement(plan, context)) + names = context.arguments[context.inverted_descriptor] + table = f"{names.value_name}_native_ops" + consumer = self._inverted_consumer_name(plan) + return ( + CComment("The call is made inside the consumer, where the descriptor is live, so"), + CComment("what the callee writes into it is what Fortran copies back to the"), + CComment("caller's entity when the bridge returns."), + CIf( + CodeExpression(f"{table} != NULL"), + body=( + CExpressionStatement( + CodeExpression(f"{table}->scoped_descriptor({table}->owner, {consumer}, NULL)") + ), + ), + else_body=( + CComment("This handle owns its descriptor, so hand it to the same consumer."), + CExpressionStatement(CodeExpression(f"{consumer}({names.value_name}, NULL)")), + ), + ), + ) + + def _inverted_descriptor_consumer_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: + """Emit one consumer per entrypoint whose call must run inside it.""" + return tuple( + CFunction( + self._inverted_consumer_name(function), + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + doc=( + f"Call {self._entrypoint_function_name(function)} on a live descriptor.", + "The callee may reallocate the array it receives. Making the call here," + " while the descriptor the Fortran runtime built for it is still valid," + " means the callee writes into the descriptor Fortran copies back to the" + " caller's entity, so a new allocation reaches it.", + ), + body=( + CExpressionStatement(CodeExpression("(void)context")), + CExpressionStatement( + CodeExpression(f"{self._entrypoint_function_name(function)}((CFI_cdesc_t *)descriptor)") + ), + CReturn(), + ), + ) + for function in self._functions(plan) + if self._inverted_descriptor_argument(function) is not None + ) + + def _inverted_consumer_name(self, plan: FunctionPlan) -> str: + """Return the consumer that performs one inverted entrypoint call.""" + return f"{self._binding_function_name(plan)}_call_with_descriptor" + def _output_nodes( self, plan: FunctionPlan, @@ -9802,7 +9943,7 @@ def _output_nodes( """Return the native envelope, status projection, and Python result.""" nodes = [ *self._callback_context_push_nodes(plan, context), - *self._lower_native_call(plan, self._entrypoint_call_statement(plan, context)), + *self._lower_entrypoint_call(plan, context), *self._callback_context_pop_nodes(plan), *self._derived_call_failure_nodes(plan, context), *self._derived_after_native_failure_nodes(plan, context), @@ -10777,6 +10918,7 @@ def _function_context(self, plan: FunctionPlan) -> _CFunctionContext: python_result = self._python_result_name(plan) native_result = self._native_result_name(plan) role_values = self._argument_role_values(plan, arguments) + inverted = self._inverted_descriptor_argument(plan) return _CFunctionContext( arguments, native_outputs, @@ -10784,6 +10926,7 @@ def _function_context(self, plan: FunctionPlan) -> _CFunctionContext: python_result, python_results, role_values, + inverted.owner_path if inverted is not None else None, ) def _argument_contexts(self, plan: FunctionPlan) -> dict[str, _CArgumentNames]: diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 651dbaa06..b38173f22 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -8199,7 +8199,12 @@ def _native_handle_callback_interface( FortranParameter( "value", element_type, - (attribute, self._array_dimension_attribute(handle.array.rank), "intent(in)"), + # intent(inout), so a callee reached through this descriptor + # can change the field's allocation and have the compiler + # copy that back. intent(in) leaves the copy-back + # unspecified, which happens to work on the compilers tested + # but is not something the standard obliges them to do. + (attribute, self._array_dimension_attribute(handle.array.rank), "intent(inout)"), ), FortranParameter("context", "type(c_ptr)", ("value",)), ), diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 3fe66fe3a..7a43c0f5f 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -539,14 +539,15 @@ def test_every_allocatable_handle_kind_reaches_a_read_only_allocatable_dummy(tmp assert namespace.total(namespace.modvar) == np.float64(600.0) -def test_a_writable_allocatable_dummy_refuses_a_borrowed_descriptor(tmp_path: Path): - """A borrowed descriptor copy may not stand in where the callee reallocates. - - A read-only allocatable actual can be a per-call copy of the runtime's - descriptor, because the callee cannot change its allocation. An - ``intent(inout)`` allocatable can: the callee may deallocate and reallocate - it, and through a copy that would land in the copy and leave the caller's - entity pointing at released storage. Such an argument is refused instead. +def test_a_writable_allocatable_dummy_reaches_the_callers_entity(tmp_path: Path): + """A callee that reallocates an ``intent(inout)`` dummy updates the caller. + + The descriptor a module array or field hands out exists only while the + consumer holding it runs, so a callee handed a copy would reallocate the + copy and leave the caller's entity naming released storage. The call is + made inside that consumer instead, which is what lets the new allocation + reach the entity. A handle owning its descriptor hands that over directly + and needs no such arrangement. """ workdir = tmp_path / "writable" workdir.mkdir(parents=True) @@ -562,17 +563,22 @@ def test_a_writable_allocatable_dummy_refuses_a_borrowed_descriptor(tmp_path: Pa ) namespace = _sole_native_module(module) + # A module array: the callee replaces the allocation, and the module + # variable names the new one afterwards. namespace.modvar.resize(2) namespace.modvar.to_numpy()[:] = [1.0, 2.0] - with pytest.raises(TypeError, match="requires a generated direct descriptor handoff"): - namespace.grow(namespace.modvar) - - # The module variable is untouched by the refusal. - assert namespace.modvar.shape == (2,) - assert namespace.modvar.to_numpy().tolist() == [1.0, 2.0] - - # A handle that owns its descriptor still works, and the callee's - # reallocation reaches it. + namespace.grow(namespace.modvar) + assert namespace.modvar.shape == (6,) + assert namespace.modvar.to_numpy().tolist() == [9.0] * 6 + + # A derived-type field reaches its entity the same way, through its parent. + namespace.thebox.field.resize(2) + namespace.thebox.field.to_numpy()[:] = [1.0, 2.0] + namespace.grow(namespace.thebox.field) + assert namespace.thebox.field.shape == (6,) + assert namespace.thebox.field.to_numpy().tolist() == [9.0] * 6 + + # A handle owning its descriptor hands that over directly. owned = namespace.make(np.int32(2)) namespace.grow(owned) assert owned.shape == (6,) From d742fec0fad34846d9f64593d19c84359a76fd0b Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 01:47:14 +0100 Subject: [PATCH 10/47] Place every descriptor argument inside its consumer A read-only descriptor argument was placed by copying the record the Fortran runtime built into call-local storage and passing the copy. That worked, but it meant C writing a C descriptor, which the standard reserves for its own descriptor functions, and it left two ways of placing the same kind of argument depending on whether the callee could change the allocation. Both now go the same way: the call is made inside the consumer holding the runtime's descriptor, and C passes on the pointer it was given rather than a record it assembled. Nothing copies a descriptor, and the generated binding contains no `memcpy` of one. Carrying the other call values through a context record, added with the writable case, is what makes this possible for arguments that are not the entrypoint's only parameter or that return a value. The copy machinery goes with it: the separate placement path, the condition selecting between the two, the detachment that protected a copied record from the capsule carrying it, and a gate for shapes a descriptor dummy cannot have -- Fortran requires deferred shape there, so it could never fire. Reaching a handle's entity no longer uses any of its Python operations: placing an argument went from three such calls, to one, to none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 313 +++++++++++++++----------------------- 1 file changed, 126 insertions(+), 187 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 198586315..96899c9c7 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -8052,102 +8052,6 @@ def _lower_argument_native_array_facts( nodes.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) return tuple(nodes) - _UNCONSTRAINED_ARRAY_EXTENTS = frozenset({":", "::Strided", "Flat"}) - - def _uses_native_array_ops_fast_path( - self, - plan: ArgumentTransferPlan, - handle: NativeArrayHandlePlan, - ) -> bool: - """Report whether this argument may take a handle's published entry-point table. - - The table hands back a copy of the descriptor the Fortran runtime - built, which stands in only where the callee cannot change the - allocation -- the same rule the borrowed handoff follows. It carries - no presence flag and no declared extents either, so an optional or - shape-constrained argument keeps the general path. - """ - return ( - handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - and handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE - and plan.binding.optional_mode is OptionalMode.REQUIRED - and handle.array.rank is not None - and all(extent in self._UNCONSTRAINED_ARRAY_EXTENTS for extent in handle.array.shape) - ) - - def _native_array_ops_fast_path_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - handle: NativeArrayHandlePlan, - fallback: tuple[CDeclaration | CExpressionStatement | CIf, ...], - ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: - """Take the handle's entry-point table when it publishes one, else fall back.""" - prefix = names.value_name - rank = handle.array.rank - cfi_type = self._native_array_cfi_type(plan) - capsule = f"{prefix}_ops_capsule" - table = f"{prefix}_native_ops" - storage = f"{prefix}_ops_storage" - target = f"{prefix}_ops_copy" - gate = " || ".join(f"{prefix}->dim[{axis}].extent < 0" for axis in range(rank)) - return ( - CDeclaration(storage, f"CFI_CDESC_T({rank})"), - CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), - CDeclaration( - target, - "prik_native_array_descriptor_copy", - CodeExpression(f"{{&{storage}, sizeof({storage})}}"), - ), - CComment("A handle publishes a table of native entry points when its entity can be"), - CComment("reached directly; one that does not takes the general path below."), - CExpressionStatement( - CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")'), - ), - CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), - CIf( - CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), - body=( - CComment("The table names the entity it stands for: refuse a handle whose kind,"), - CComment("rank, element type or element size disagrees with this dummy."), - CExpressionStatement( - CodeExpression( - f"{table} = prik_native_array_ops_from_capsule({capsule}, " - f"{self._native_array_handle_kind_constant(handle)}, {rank}, {cfi_type}, " - f"{self._native_array_expected_element_size(plan)})", - ) - ), - CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), - CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), - CComment("Fortran builds the descriptor for this call and hands it to the consumer,"), - CComment("which copies the record into our storage. Only the record is copied;"), - CComment("base_addr still refers to the entity's own data. It is rebuilt every call"), - CComment("because reallocating the entity leaves an earlier descriptor describing"), - CComment("storage that has been released."), - CExpressionStatement( - CodeExpression( - f"{table}->scoped_descriptor({table}->owner, prik_native_array_copy_descriptor, &{target})", - ) - ), - CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{storage}")), - CComment("A negative extent would index outside the array, so it is refused here"), - CComment("rather than reaching the native call."), - CExpressionStatement( - CodeExpression( - f"if ({gate}) {{ PyErr_SetString(PyExc_ValueError, " - f'"{plan.binding.python_name} reports a negative extent"); return NULL; }}', - ) - ), - ), - else_body=( - CComment("No table: fetch this handle's descriptor through the Python runtime."), - CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), - *fallback, - ), - ), - ) - def _inverted_descriptor_table_nodes( self, plan: ArgumentTransferPlan, @@ -8199,54 +8103,6 @@ def _inverted_descriptor_table_nodes( ), ) - def _borrowed_descriptor_detach_declarations( - self, - names: _CArgumentNames, - handle: NativeArrayHandlePlan, - ) -> tuple[CDeclaration, ...]: - """Declare this call's own storage for a detached borrowed descriptor.""" - if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: - return () - if handle.array.rank is None: - return () - return (CDeclaration(f"{names.value_name}_detached", f"CFI_CDESC_T({handle.array.rank})"),) - - def _borrowed_descriptor_detach_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - handle: NativeArrayHandlePlan, - ) -> tuple[CComment | CDeclaration | CExpressionStatement, ...]: - """Detach a borrowed descriptor from the capsule that carried it. - - A read-only argument receives a per-call copy owned by a capsule the - handle keeps alive, and that capsule is replaced on the handle's next - descriptor request. Releasing the GIL around the native call lets - another thread make that request while this one is still using the - descriptor, so the record is copied into this call's own storage and - the capsule stops mattering. A projected argument needs no copy: its - descriptor is the persistent one its handle owns. - """ - if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: - return () - if handle.array.rank is None: - return () - prefix = names.value_name - storage = f"{prefix}_detached" - size = f"sizeof(CFI_CDESC_T({handle.array.rank}))" - return ( - CComment("The descriptor above belongs to a capsule the handle replaces on its"), - CComment("next request, which another thread may make while the GIL is released"), - CComment("for the native call. Copy the record so this call owns what it uses."), - CIf( - CodeExpression(f"{prefix} != NULL"), - body=( - CExpressionStatement(CodeExpression(f"memcpy(&{storage}, {prefix}, {size})")), - CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{storage}")), - ), - ), - ) - def _lower_argument_native_array_direct( self, plan: ArgumentTransferPlan, @@ -8271,7 +8127,6 @@ def _lower_argument_native_array_direct( "prik_native_array_handle *", CodeExpression("NULL"), ), - *self._borrowed_descriptor_detach_declarations(names, handle), *self._native_descriptor_helper_declarations( prefix, include_default_binder=binder_definition is not None, @@ -8295,12 +8150,9 @@ def _lower_argument_native_array_direct( ) general.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 1)) general.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) - general.extend(self._borrowed_descriptor_detach_nodes(plan, names, handle)) general.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) if inverted: nodes.extend(self._inverted_descriptor_table_nodes(plan, names, handle, tuple(general))) - elif self._uses_native_array_ops_fast_path(plan, handle): - nodes.extend(self._native_array_ops_fast_path_nodes(plan, names, handle, tuple(general))) else: nodes.extend(general) return tuple(nodes) @@ -9853,29 +9705,26 @@ def _result_native_name(self, plan: ResultPlan, context: _CFunctionContext) -> s def _inverted_descriptor_argument(self, plan: FunctionPlan) -> ArgumentTransferPlan | None: """Return the argument whose descriptor must stay live across the call. - A callee may change the allocation of a writable allocatable dummy. The - descriptor the runtime builds for a module array or field exists only - while the consumer it was handed to is running, so handing the callee a - copy would lose that change. The call is made inside the consumer - instead. Only the case where that descriptor is the entrypoint's sole - parameter is inverted today; every other shape keeps the general path. + The descriptor the runtime builds for a module array or a field exists + only while the consumer it was handed to is running. Making the call + inside that consumer is what lets a callee change the allocation of a + writable dummy and have it reach the caller's entity, and it means no + descriptor is ever copied: a read-only argument is placed the same way, + so C only ever passes on a descriptor Fortran made. """ candidates = [ argument for argument in plan.arguments if argument.native_array_handle is not None - and argument.native_array_handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE and argument.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR and argument.binding.optional_mode is OptionalMode.REQUIRED and argument.native_array_handle.array.rank is not None ] - if len(candidates) != 1 or len(plan.entrypoint.parameters) != 1: + if len(candidates) != 1: return None - # A projected handle is written back to Python after the call, which - # needs nothing from the descriptor, so that action is compatible. A - # value result or any other output is not yet, because reading it would - # have to travel out of the consumer. - if self._direct_result(plan) is not None or plan.results: + # Hidden outputs and status projections read native storage the consumer + # does not carry, so those keep the general path. + if plan.results and any(result.source_kind != "direct_return" for result in plan.results): return None return candidates[0] @@ -9886,7 +9735,16 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) names = context.arguments[context.inverted_descriptor] table = f"{names.value_name}_native_ops" consumer = self._inverted_consumer_name(plan) + record = self._inverted_context_name(plan) + fields = self._inverted_context_fields(plan, context) + result = self._direct_result(plan) + initializer = ", ".join(value for _declaration, value in fields) + if result is not None: + initializer = f"{initializer}, 0" if initializer else "0" return ( + CComment("Everything the call needs apart from the descriptor is gathered here,"), + CComment("because the consumer runs outside this frame."), + CDeclaration("call_context", record, CodeExpression(f"{{{initializer}}}")), CComment("The call is made inside the consumer, where the descriptor is live, so"), CComment("what the callee writes into it is what Fortran copies back to the"), CComment("caller's entity when the bridge returns."), @@ -9894,42 +9752,123 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) CodeExpression(f"{table} != NULL"), body=( CExpressionStatement( - CodeExpression(f"{table}->scoped_descriptor({table}->owner, {consumer}, NULL)") + CodeExpression(f"{table}->scoped_descriptor({table}->owner, {consumer}, &call_context)") ), ), else_body=( CComment("This handle owns its descriptor, so hand it to the same consumer."), - CExpressionStatement(CodeExpression(f"{consumer}({names.value_name}, NULL)")), + CExpressionStatement(CodeExpression(f"{consumer}({names.value_name}, &call_context)")), ), ), + *( + (CExpressionStatement(CodeExpression(f"{context.result_name} = call_context.result")),) + if result is not None and context.result_name is not None + else () + ), ) - def _inverted_descriptor_consumer_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: - """Emit one consumer per entrypoint whose call must run inside it.""" - return tuple( - CFunction( - self._inverted_consumer_name(function), - "void", - parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), - storage="static", - doc=( - f"Call {self._entrypoint_function_name(function)} on a live descriptor.", - "The callee may reallocate the array it receives. Making the call here," - " while the descriptor the Fortran runtime built for it is still valid," - " means the callee writes into the descriptor Fortran copies back to the" - " caller's entity, so a new allocation reaches it.", - ), - body=( - CExpressionStatement(CodeExpression("(void)context")), - CExpressionStatement( - CodeExpression(f"{self._entrypoint_function_name(function)}((CFI_cdesc_t *)descriptor)") + def _inverted_descriptor_consumer_functions(self, plan: ModulePlan) -> tuple: + """Emit the record and consumer for each entrypoint called inside one.""" + nodes: list = [] + for function in self._functions(plan): + if self._inverted_descriptor_argument(function) is None: + continue + context = self._function_context(function) + fields = self._inverted_context_fields(function, context) + record = self._inverted_context_name(function) + result = self._direct_result(function) + result_field = ( + (CParameter("result", self._inverted_result_type(function, result)),) if result is not None else () + ) + nodes.append( + CStructDefinition( + record, + tuple(declaration for declaration, _value in fields) + result_field, + ) + ) + call = self._inverted_consumer_call(function, context, fields) + nodes.append( + CFunction( + self._inverted_consumer_name(function), + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + doc=( + f"Call {self._entrypoint_function_name(function)} on a live descriptor.", + "The callee may change the allocation of the array it receives. Making" + " the call here, while the descriptor the Fortran runtime built for it" + " is still valid, means the callee writes into the descriptor Fortran" + " copies back to the caller's entity, so a new allocation reaches it.", + "Every other value the call needs arrives through the context record," + " because this runs outside the frame that computed them.", ), - CReturn(), - ), + body=( + CDeclaration("call", f"{record} *", CodeExpression(f"({record} *)context")), + # The record is empty when the descriptor is the only value the + # call needs, and an unused local would warn. + *( + () + if fields or result is not None + else (CExpressionStatement(CodeExpression("(void)call")),) + ), + CExpressionStatement(CodeExpression(call)), + CReturn(), + ), + ) ) - for function in self._functions(plan) - if self._inverted_descriptor_argument(function) is not None - ) + return tuple(nodes) + + def _inverted_result_type(self, plan: FunctionPlan, result) -> str: + """Return the C storage a carried direct result is written into.""" + direct_c_abi = plan.entrypoint.direct_c_abi + if direct_c_abi is not None and direct_c_abi.result is not None: + return direct_c_abi.result.c_spelling + return PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name).c_spelling + + def _inverted_consumer_call( + self, + plan: FunctionPlan, + context: _CFunctionContext, + fields: tuple[tuple[CParameter, str], ...], + ) -> str: + """Assemble the entrypoint call as the consumer makes it.""" + carried = {value: f"call->{declaration.name}" for declaration, value in fields} + arguments = [] + for group in sorted(plan.entrypoint.parameters, key=lambda item: item.position): + values = self._entrypoint_parameter_values(plan, group, context) + if group.owner_path == context.inverted_descriptor: + arguments.extend("(CFI_cdesc_t *)descriptor" for _value in values) + continue + arguments.extend(carried[value] for value in values) + call = f"{self._entrypoint_function_name(plan)}({', '.join(arguments)})" + return f"call->result = {call}" if self._direct_result(plan) is not None else call + + def _inverted_context_name(self, plan: FunctionPlan) -> str: + """Return the record carrying one inverted call's other values.""" + return f"{self._binding_function_name(plan)}_call_context" + + def _inverted_context_fields( + self, + plan: FunctionPlan, + context: _CFunctionContext, + ) -> tuple[tuple[CParameter, str], ...]: + """Pair every entrypoint value the consumer needs with its declaration. + + The inverted argument is excluded: the consumer receives that + descriptor directly. Everything else the call needs is carried into + the consumer through the context record, because the consumer runs + outside the frame that computed it. + """ + pairs: list[tuple[CParameter, str]] = [] + for group in sorted(plan.entrypoint.parameters, key=lambda item: item.position): + if group.owner_path == context.inverted_descriptor: + continue + declarations = self._entrypoint_parameter_declarations(plan, group) + values = self._entrypoint_parameter_values(plan, group, context) + if len(declarations) != len(values): + raise ValueError(f"Entrypoint parameter {group.owner_path!r} has mismatched declarations and values") + pairs.extend(zip(declarations, values, strict=True)) + return tuple(pairs) def _inverted_consumer_name(self, plan: FunctionPlan) -> str: """Return the consumer that performs one inverted entrypoint call.""" From 941b9016ed16f45ec21cc2f81fb0d58d5d7080a6 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 02:26:51 +0100 Subject: [PATCH 11/47] Remove the machinery for handing a copied descriptor to a binding Placing a descriptor argument no longer copies a descriptor, so the parts that existed to produce such a copy and to keep it apart from a real one have nothing left to do. A module array and a field handed their descriptor operation a consumer that copied the record into a capsule; both return decoded facts again, as they did before that consumer existed. The runtime adapter wrapping those capsules, the binding helper that accepted them, and the flag on the handoff recording whether the descriptor was owned or copied are gone with it. That flag existed only to stop a copy standing in where a callee could change the allocation, and nothing copies now. The form a handle's descriptor operation returns no longer has to be announced from generated code through the runtime factory, and the header helpers for copying a descriptor record are unreferenced. Reaching an entity through its published entry points does not depend on whether the argument is projected back to Python, so generated code no longer consults output projection at all when placing one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 177 ++---------------- prik/runtime/handles.py | 83 +------- prik/runtime/native_support/prik_binding.h | 14 -- .../codegen/test_allocatable_lowering.py | 19 +- 4 files changed, 40 insertions(+), 253 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 96899c9c7..ecf360359 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -38,7 +38,6 @@ ModuleArrayAddressMechanism, ModuleGetterAction, NativeArrayDescriptorKind, - NativeArrayOutputProjection, NativeArrayDescriptorInterop, NativeArrayDefaultConstruction, NativeArrayOperation, @@ -3172,7 +3171,6 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name ops=ops, owner=owner_name, descriptor_ownership="borrowed", - descriptor_handoff=self._borrowed_descriptor_handoff(handle), native_ops=self._field_handle_ops_capsule_name(field, prefix), extraction_action=handle.extraction_action.value, ) @@ -3839,27 +3837,6 @@ def _field_handle_descriptor_callbacks( ), ), ) - borrowed = ( - ( - CFunction( - f"{descriptor_name}_capsule", - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), - *self._borrowed_descriptor_capsule_nodes( - handle.array.rank, - "descriptor", - self._native_array_handle_kind_constant(handle), - return_target="*(PyObject **)context", - ), - ), - ), - ) - if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - else () - ) actual = CFunction( actual_name, "void", @@ -3870,7 +3847,7 @@ def _field_handle_descriptor_callbacks( CReturn(), ), ) - return (descriptor, *borrowed, actual) + return (descriptor, actual) def _field_handle_operation_function( self, @@ -3900,11 +3877,7 @@ def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation prefix = self._field_handle_owner_nodes(owner) owner_args = self._field_handle_owner_arguments(owner) if operation in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY}: - callback = self._field_handle_descriptor_callback( - owner, - field, - borrows=operation is NativeArrayOperation.DESCRIPTOR, - ) + callback = self._field_handle_descriptor_callback(owner, field) descriptor_bridge = self._field_handle_bridge_name( owner, field, @@ -3984,21 +3957,12 @@ def _field_handle_bridge_name( variable, member = owner return self._module_member_handle_bridge_name(variable, member, operation) - def _field_handle_descriptor_callback(self, owner, field: DerivedFieldPlan, *, borrows: bool = False) -> str: - """Build field handle descriptor callback from the supplied completed binding records; emitted nodes only project completed binding actions.""" + def _field_handle_descriptor_callback(self, owner, field: DerivedFieldPlan) -> str: + """Return the consumer decoding one field descriptor into facts.""" if isinstance(owner, DerivedTypePlan): - name = self._derived_handle_descriptor_callback_name(owner, field) - else: - variable, member = owner - name = self._module_member_handle_descriptor_callback_name(variable, member) - handle = field.native_array_handle - if ( - borrows - and handle is not None - and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - ): - return f"{name}_capsule" - return name + return self._derived_handle_descriptor_callback_name(owner, field) + variable, member = owner + return self._module_member_handle_descriptor_callback_name(variable, member) def _field_handle_actual_callback(self, owner, field: DerivedFieldPlan) -> str: """Build field handle actual callback from the supplied completed binding records; emitted nodes only project completed binding actions.""" @@ -4383,9 +4347,9 @@ def _module_native_array_data_operation_body( if operation is NativeArrayOperation.SHAPE: return self._module_native_array_shape_body(variable) if operation is NativeArrayOperation.TO_NUMPY: - return self._module_native_array_descriptor_body(variable, borrows_descriptor=False) + return self._module_native_array_descriptor_body(variable) if operation is NativeArrayOperation.DESCRIPTOR: - return self._module_native_array_descriptor_body(variable, borrows_descriptor=True) + return self._module_native_array_descriptor_body(variable) if operation is NativeArrayOperation.ASSOCIATE: return ( CDeclaration("source_packed", "PyObject *"), @@ -4445,20 +4409,13 @@ def _module_native_array_shape_body( def _module_native_array_descriptor_body( self, variable: ModuleVariablePlan, - *, - borrows_descriptor: bool, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Return standard descriptor facts for module extraction and handoff. - - ``borrows_descriptor`` selects the handoff form: the descriptor - operation may hand back a borrowed copy of the runtime's descriptor, - while extraction always reads decoded facts. - """ + """Return standard descriptor facts for module extraction and handoff.""" handle = variable.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Module handle {variable.owner_path!r} has no descriptor rank") if self._uses_module_allocatable_descriptor(variable): - return self._module_allocatable_descriptor_body(variable, borrows_descriptor=borrows_descriptor) + return self._module_allocatable_descriptor_body(variable) if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: return self._module_pointer_descriptor_body(variable) return self._module_contiguous_descriptor_body(variable) @@ -4475,21 +4432,9 @@ def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: def _module_allocatable_descriptor_body( self, variable: ModuleVariablePlan, - *, - borrows_descriptor: bool, ) -> tuple[CDeclaration | CExpressionStatement | CReturn, ...]: - """Request the current standard descriptor as a borrowed copy or decoded facts.""" - handle = variable.native_array_handle - borrows = ( - borrows_descriptor - and handle is not None - and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - ) - callback = ( - self._module_descriptor_capsule_callback_name(variable) - if borrows - else self._module_descriptor_callback_name(variable) - ) + """Request the current standard descriptor and return its decoded facts.""" + callback = self._module_descriptor_callback_name(variable) return ( CDeclaration("descriptor_record", "PyObject *", CodeExpression("NULL")), CExpressionStatement( @@ -4545,27 +4490,6 @@ def _module_allocatable_descriptor_callbacks( ), ), ) - capsule_callbacks = ( - ( - CFunction( - self._module_descriptor_capsule_callback_name(variable), - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), - *self._borrowed_descriptor_capsule_nodes( - handle.array.rank, - "descriptor", - self._native_array_handle_kind_constant(handle), - return_target="*(PyObject **)context", - ), - ), - ), - ) - if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - else () - ) array_actual_callback = CFunction( self._module_array_actual_callback_name(variable), "void", @@ -4578,7 +4502,6 @@ def _module_allocatable_descriptor_callbacks( ) return ( descriptor_callback, - *capsule_callbacks, array_actual_callback, *self._module_native_array_ops_nodes(variable, handle), ) @@ -4717,10 +4640,6 @@ def _module_native_array_ops_name(self, variable: ModuleVariablePlan) -> str: """Return the file-scope native entry-point table name for one module array.""" return f"{self._module_descriptor_callback_name(variable)}_ops" - def _module_descriptor_capsule_callback_name(self, variable: ModuleVariablePlan) -> str: - """Return the callback name that copies a borrowed module descriptor.""" - return f"{self._module_descriptor_callback_name(variable)}_capsule" - def _module_descriptor_callback_name(self, variable: ModuleVariablePlan) -> str: """Return the binding-local module descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" owner = re.sub(r"\W", "_", variable.owner_path).casefold() @@ -5484,46 +5403,6 @@ def _pointer_association_cfi_type( return "CFI_type_char" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling - def _borrowed_descriptor_capsule_nodes( - self, - rank: int, - descriptor_name: str, - kind_constant: str, - *, - return_target: str, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Copy one runtime-made descriptor into a capsule for a borrowed handle. - - An allocatable actual cannot be established from C, so the binding - keeps the descriptor the Fortran runtime built for this call instead of - rebuilding one from facts. Only the descriptor record is copied; its - ``base_addr`` still refers to the module or parent storage, which this - extension never allocates or releases. The copy is made fresh on every - call because reallocating the native entity invalidates the previous one. - """ - storage = f"{descriptor_name}_copy" - size = f"sizeof(CFI_CDESC_T({rank}))" - return ( - CDeclaration(storage, "CFI_cdesc_t *", CodeExpression(f"(CFI_cdesc_t *)calloc(1, {size})")), - CIf( - CodeExpression(f"{storage} == NULL"), - body=(CExpressionStatement(CodeExpression("PyErr_NoMemory()")), CReturn()), - ), - CExpressionStatement(CodeExpression(f"memcpy({storage}, {descriptor_name}, {size})")), - CExpressionStatement( - CodeExpression( - f"{return_target} = prik_native_array_handle_capsule_new(" - f"{kind_constant}, {rank}, {storage}->type, {storage}->elem_len, {size}, " - f"{storage}, prik_release_borrowed_native_descriptor)" - ) - ), - CIf( - CodeExpression(f"{return_target} == NULL"), - body=(CExpressionStatement(CodeExpression(f"free({storage})")),), - ), - CReturn(), - ) - def _native_array_descriptor_record_nodes( self, rank: int, @@ -6158,7 +6037,6 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> ops=f"{prefix}_ops", owner=f"{owner} != NULL ? {owner} : Py_None", descriptor_ownership="borrowed", - descriptor_handoff=self._borrowed_descriptor_handoff(handle), native_ops=self._module_native_array_ops_capsule_name(plan, prefix), extraction_action=handle.extraction_action.value, ) @@ -8140,11 +8018,9 @@ def _lower_argument_native_array_direct( plan, context, names, - ( - "_native_array_descriptor_handoff_for_binding_positional" - if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE - else "_native_array_borrowed_descriptor_for_binding_positional" - ), + # Only a handle owning its descriptor reaches this path now: one + # that publishes native entry points is placed through them. + "_native_array_descriptor_handoff_for_binding_positional", default_binder_definition=binder_definition, ) ) @@ -8593,17 +8469,6 @@ def _module_native_array_elem_size(self, plan: ModuleVariablePlan) -> str: return f"{self._module_native_array_bridge_operation_name(plan, NativeArrayOperation.ELEMENT_LENGTH)}()" return f"sizeof({PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).c_spelling})" - @staticmethod - def _borrowed_descriptor_handoff(handle: NativeArrayHandlePlan) -> str: - """Name the descriptor form this handle's operation hands back. - - A handle whose completed plan borrows the native descriptor returns one - copied capsule per call; every other handle returns decoded facts. - """ - if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: - return "borrowed_descriptor" - return "facts" - def _native_array_handle_factory_call( self, *, @@ -8616,7 +8481,6 @@ def _native_array_handle_factory_call( ops: str, owner: str, descriptor_ownership: str, - descriptor_handoff: str, native_ops: str, extraction_action: str, ) -> str: @@ -8624,14 +8488,14 @@ def _native_array_handle_factory_call( dtype = self._native_array_dtype_for_semantic_type(semantic_type_name, datatype_family) if dtype is None: return ( - f'{target} = PyObject_CallFunction({helper}, "sOiOOsssOO", "{descriptor_kind}", Py_None, ' + f'{target} = PyObject_CallFunction({helper}, "sOiOOssOO", "{descriptor_kind}", Py_None, ' f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' - f'"{descriptor_handoff}", {native_ops}, Py_None)' + f"{native_ops}, Py_None)" ) return ( - f'{target} = PyObject_CallFunction({helper}, "ssiOOsssOO", "{descriptor_kind}", "{dtype}", ' + f'{target} = PyObject_CallFunction({helper}, "ssiOOssOO", "{descriptor_kind}", "{dtype}", ' f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' - f'"{descriptor_handoff}", {native_ops}, Py_None)' + f"{native_ops}, Py_None)" ) def _lower_argument_nullable_value( @@ -9071,7 +8935,6 @@ def _lower_result_owned_native_array_handle( ops=f"{prefix}_ops", owner=f"{prefix}_owner", descriptor_ownership="owned", - descriptor_handoff="facts", native_ops=f"{prefix}_native_ops", extraction_action=handle.extraction_action.value, ) diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index ddf0046fa..1bca9f896 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -49,16 +49,9 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class _NativeArrayDescriptorHandoff: - """Internal opaque handoff for one versioned native-handle capsule. - - ``borrowed`` marks a per-call copy of a descriptor the Fortran runtime - owns. Such a copy describes live storage and is correct to read, but a - callee that reallocates through it changes only the copy, so it may not - stand in for an argument whose allocation changes must reach the caller. - """ + """Internal opaque handoff for one versioned native-handle capsule.""" capsule: Any - borrowed: bool = False def __post_init__(self) -> None: if self.capsule is None: @@ -103,27 +96,22 @@ def _native_array_handle_from_generated_ops( owner: Any = None, descriptor_ownership: str = "borrowed", to_numpy_policy: str = "borrowed_view", - descriptor_handoff: str = "facts", native_ops: Any = None, generation: int | None = None, ) -> NativeArrayHandleBase: """Build a runtime handle from generated operation callables. ``native_ops`` is an optional capsule publishing the entity's native entry - points, so a consumer can reach it with one indirect call rather than a - Python operation lookup. It is carried, not required: a handle without one - keeps working through its operation mapping. + points, so a binding can reach it with one indirect call. It is carried, + not required: a handle without one is placed through its operations. """ owned = descriptor_ownership == "owned" - borrows_descriptor = descriptor_handoff == "borrowed_descriptor" normalized_ops = {} for name, operation in ops.items(): if name == "array_actual": normalized = _generated_handoff_operation(operation, owner=owner, pass_owner=owned) elif name == "descriptor" and owned: normalized = _generated_owned_descriptor_operation(operation, owner) - elif name == "descriptor" and borrows_descriptor: - normalized = _generated_borrowed_descriptor_operation(operation) elif name in {"shape", "to_numpy"} and owned: normalized = _generated_owned_descriptor_record_operation(operation, owner) elif name == "associate": @@ -365,33 +353,6 @@ def call(_handle: NativeArrayHandleBase, *args: Any) -> _NativeArrayDescriptorHa return call -def _generated_borrowed_descriptor_operation(operation: HandleOperation) -> HandleOperation: - """Adapt a borrowed descriptor copy into a typed handoff. - - Selected only for a handle whose completed plan borrows the native - descriptor. Its generated operation returns one capsule per call, copied - from the descriptor the Fortran runtime built for that call. - """ - - def call(handle: NativeArrayHandleBase, *args: Any) -> Any: - value = operation(*args) - if value is None or isinstance(value, _NativeArrayDescriptorHandoff): - return value - handoff = _NativeArrayDescriptorHandoff(value, borrowed=True) - # The binding reads the descriptor out of this capsule and releases its - # own reference before calling the native entrypoint, exactly as it does - # for an owned handle. An owned handle survives that because it holds - # the capsule itself, so a borrowed copy is held here for the same - # reason: the descriptor must outlive the call that reads it. A binding - # copies the record into its own storage before releasing its reference, - # so replacing this one on the next request cannot disturb a call that - # is already under way, including one that has released the GIL. - handle._borrowed_descriptor = handoff - return handoff - - return call - - def _generated_owned_descriptor_record_operation(operation: HandleOperation, owner: Any) -> HandleOperation: """Adapt owned descriptor facts and normalize compiler zero-extent sentinels.""" @@ -1531,15 +1492,8 @@ def _native_array_descriptor_handoff_for_binding( expected_shape: Sequence[int | None] | int | None = None, optional_absent: bool = False, bind_default: HandleOperation | None = None, - allow_borrowed: bool = False, ) -> tuple[Any | None, ...]: - """Pack a versioned native-handle capsule for a descriptor argument. - - ``allow_borrowed`` admits a per-call copy of a descriptor the Fortran - runtime owns. It is set only for read-only arguments: a callee that - reallocates through a copy would change the copy alone, leaving the - caller's entity pointing at released storage. - """ + """Pack a versioned native-handle capsule for a descriptor argument.""" if isinstance(value, NativeArrayHandleBase) and value._contract_default: if bind_default is None: raise TypeError( @@ -1564,7 +1518,7 @@ def _native_array_descriptor_handoff_for_binding( ) if descriptor is None: return (None, None) if optional_absent else (None,) - if not isinstance(descriptor, _NativeArrayDescriptorHandoff) or (descriptor.borrowed and not allow_borrowed): + if not isinstance(descriptor, _NativeArrayDescriptorHandoff): raise TypeError( f"writable {descriptor_kind} descriptor argument requires a generated direct descriptor handoff" ) @@ -1573,33 +1527,6 @@ def _native_array_descriptor_handoff_for_binding( return (descriptor.capsule,) -def _native_array_borrowed_descriptor_for_binding_positional( - value: Any, - descriptor_kind: str, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - optional_absent: bool = False, - bind_default: HandleOperation | None = None, -) -> tuple[Any | None, ...]: - """Positional wrapper used by read-only descriptor CPython binding code. - - Unlike the projected form, this accepts a borrowed per-call copy: the callee - cannot change the allocation of a read-only descriptor argument, so nothing - has to travel back to the caller's entity. - """ - return _native_array_descriptor_handoff_for_binding( - value, - descriptor_kind=str(descriptor_kind), - expected_dtype=None if expected_dtype is None else np.dtype(expected_dtype), - expected_rank=None if expected_rank is None else int(expected_rank), - expected_shape=expected_shape, - optional_absent=bool(optional_absent), - bind_default=bind_default, - allow_borrowed=True, - ) - - def _native_array_descriptor_handoff_for_binding_positional( value: Any, descriptor_kind: str, diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 223a0ff69..a4f9ec4c7 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -81,12 +81,6 @@ typedef struct { void *context; } prik_native_array_descriptor_forward; -/* Destination for prik_native_array_copy_descriptor. */ -typedef struct { - void *destination; - size_t size; -} prik_native_array_descriptor_copy; - /* * Versioned cross-extension table of native entry points for one array * handle. The pointers are the generated bridge symbols for the entity the @@ -111,14 +105,6 @@ typedef struct { void (*scoped_descriptor)(void *owner, prik_native_array_descriptor_fn consumer, void *context); } prik_native_array_ops; -/* Copy one runtime descriptor record into the caller's buffer. */ -static inline void prik_native_array_copy_descriptor(void *descriptor, void *context) -{ - prik_native_array_descriptor_copy *target = (prik_native_array_descriptor_copy *)context; - - memcpy(target->destination, descriptor, target->size); -} - /* Free the per-handle entry-point table a capsule owns. */ static inline void prik_native_array_ops_capsule_destructor(PyObject *capsule) { diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index 9eabc8dee..ae2b28342 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -156,8 +156,14 @@ def test_no_generated_binding_establishes_an_allocated_allocatable_descriptor(): assert forged == [] -def test_allocatable_argument_borrows_the_runtime_descriptor(): - """The binding copies the descriptor Fortran built instead of rebuilding one.""" +def test_allocatable_argument_uses_the_descriptor_the_runtime_built(): + """The binding passes on the runtime's descriptor rather than a record of its own. + + A C descriptor is the Fortran runtime's to build, so the binding neither + establishes one for an allocatable actual nor copies the one it is handed: + the call is made inside the consumer holding it, and only that pointer + crosses. + """ plan = _allocatable_argument_plan() functions = {function.binding.python_name: function for function in plan.namespaces[0].functions} argument = functions["total"].arguments[0] @@ -169,5 +175,10 @@ def test_allocatable_argument_borrows_the_runtime_descriptor(): artifacts = WrapperGenerator().generate(plan) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") - assert "prik_release_borrowed_native_descriptor" in c_source - assert "memcpy(" in c_source + assert "scoped_descriptor(" in c_source + copied = [ + line.strip() + for line in c_source.splitlines() + if "memcpy(" in line and "CFI_CDESC_T" in line + ] + assert copied == [] From c15013f7235ecee0c5661ae46e3269806a8031d1 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 03:32:56 +0100 Subject: [PATCH 12/47] Reach a pointer dummy through its consumer, as an allocatable one is A callee that re-associated a `pointer, intent(inout)` dummy was silently ignored. The binding packed the descriptor's fields in Python and rebuilt a descriptor in C for the call, so `v => target` in the callee re-pointed that rebuilt copy and nothing else: the handle passed in came back still unassociated, with `associated` False and no shape, and no error was raised. Pointers now take the route allocatables already take. The bridge hands the native entity to a C callback and the call is made inside it, on the descriptor the compiler built, so what the callee does to the entity is what the caller's entity sees. Pointer and allocatable dummies are one mechanism rather than two: the three predicates that named only the allocatable interop kind now name both, and the plan validator accepts either descriptor kind on the direct ABI. Because a direct descriptor is the runtime's to build, a caller-created handle needs storage of its own to hand over, so default construction is now lazy owned storage for every non-optional descriptor argument rather than only result-projecting ones. An argument that does not project a result supplies that storage without also naming what the handle exposes, so a read-only argument no longer stamps its own to_numpy policy onto a handle the caller will go on using elsewhere. Verified on gfortran and ifx: the descriptor matrix is identical on both, and a callee's reallocation and re-association both reach the caller's entity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 27 ++++-- prik/codegen/c/binding.py | 45 ++++++++-- prik/codegen/fortran/bridge.py | 48 +++++------ prik/pipeline/wrapper.py | 5 +- prik/planning/entrypoints.py | 12 ++- prik/policy/completion.py | 15 +++- prik/policy/construction.py | 16 ++-- prik/runtime/handles.py | 12 ++- .../codegen/test_allocatable_lowering.py | 6 +- .../codegen/test_native_handle_planning.py | 31 +++---- .../end_to_end/test_pointer_handles.py | 83 +++++++++++++++++++ .../policy/test_pointer_ownership_policy.py | 4 +- 12 files changed, 226 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f113758..3a979476c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,25 @@ release tags add a leading `v` to the package version. it with `CFI_ERROR_BASE_ADDR_NOT_NULL`, so the same wrapper worked on gfortran and raised `Unable to establish native descriptor for argument ...: 2` on ifx. - The binding now borrows the descriptor the Fortran runtime already hands to - its callback, copying the descriptor record — not the array data, so cost does - not grow with array size — for the duration of the call. The copy is remade on - every call, because reallocating the native entity invalidates the previous - one. Module variables, derived-type fields and returned results all reach such - a dummy on every compiler now, including the bounds a shifted allocatable - carries. Optional allocatable dummies are unchanged: their absent branch still - establishes the unallocated placeholder that pairs with the present flag. + The binding no longer builds or copies a descriptor for these arguments at + all. It hands the call itself to Fortran instead: generated bridge code passes + the native entity to a C callback, and the call is made inside that callback, + where the descriptor the compiler built is live. Whatever the callee does to + the entity — including changing its allocation or, for a pointer, its + association — is therefore what the caller's entity sees when the callback + returns. Module variables, derived-type fields and returned results all reach + such a dummy on every compiler now, including the bounds a shifted allocatable + carries. Optional dummies are unchanged: their absent branch still establishes + the unallocated placeholder that pairs with the present flag. + +- **Fixed:** a `pointer` dummy now takes the same route, and a callee that + re-associates one is no longer silently ignored. PRIK packed the descriptor's + fields in Python and rebuilt a descriptor in C for the call, so `v => big` in + the callee re-pointed that rebuilt copy and nothing else: the handle passed in + came back still unassociated, with `associated` `False` and no shape, and no + error was reported. The handle now follows the callee's association — the + bounds and target it ends up with are the ones the call produced. Pointer and + allocatable dummies are one mechanism rather than two. - A `character` array handle is now accepted wherever a numeric one is. An `AllocatableArray` of characters was refused at an ordinary character dummy diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index ecf360359..00a852597 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -41,6 +41,7 @@ NativeArrayDescriptorInterop, NativeArrayDefaultConstruction, NativeArrayOperation, + NativeArrayOutputProjection, NativeDescriptorHandoffABI, EntrypointProjectionAction, EntrypointPassingConvention, @@ -119,6 +120,13 @@ from prik.codegen.visitor import ClassVisitor +def _descriptor_binding_noun(handle: NativeArrayHandlePlan) -> str: + """Name what a callee can change about this descriptor's entity.""" + if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: + return "association" + return "allocation" + + @dataclass class _CArgumentNames: """Binding-private C local names for one planned Python argument. @@ -4422,11 +4430,20 @@ def _module_native_array_descriptor_body( @staticmethod def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: - """Return whether completed policy selected callback-based descriptor access.""" + """Return whether a handle reaches its descriptor through a consumer. + + A module array hands its variable to a consumer rather than filling a + record supplied from C, so the descriptor that crosses is always one + this compiler built. Both allocatable and pointer variables do this. + """ handle = variable.native_array_handle return bool( handle is not None - and handle.descriptor_interop is NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR + and handle.descriptor_interop + in { + NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR, + NativeArrayDescriptorInterop.POINTER_C_DESCRIPTOR, + } ) def _module_allocatable_descriptor_body( @@ -4896,9 +4913,20 @@ def _default_native_array_binder_function( function: FunctionPlan, argument: ArgumentTransferPlan, ) -> CFunction: - """Attach one compiler-compatible owned descriptor to a fresh handle.""" + """Attach one compiler-compatible owned descriptor to a fresh handle. + + An argument that projects a result decides what the handle exposes + afterwards, because the handle stands for what the call produced. One + that does not is only borrowing the handle for a descriptor, so it + passes NULL and leaves the handle's own exposure alone. + """ handle = argument.native_array_handle default = handle.default_handle + exposure = ( + f'"{handle.extraction_action.value}"' + if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE + else "NULL" + ) dtype = self._native_array_dtype_for_semantic_type( argument.semantic_type_name, argument.datatype_family, @@ -5021,9 +5049,9 @@ def _default_native_array_binder_function( ), CExpressionStatement( CodeExpression( - f'result = PyObject_CallFunction(helper, "OssiOOssO", handle_obj, ' + f'result = PyObject_CallFunction(helper, "OssiOOszO", handle_obj, ' f'"{handle.descriptor_kind.value}", "{dtype}", {handle.array.rank}, ops, owner_obj, ' - f'"{default.descriptor_ownership.value}", "{handle.extraction_action.value}", Py_None)' + f'"{default.descriptor_ownership.value}", {exposure}, Py_None)' ) ), CExpressionStatement(CodeExpression("Py_DECREF(helper)")), @@ -7941,7 +7969,8 @@ def _inverted_descriptor_table_nodes( A handle standing for a module array or a field publishes a table, and the descriptor it names is built inside the consumer that makes the - call -- the only place a callee can change the allocation and have that + call -- the only place a callee can change what the dummy stands for + (an allocatable's allocation, a pointer's association) and have that reach the caller's entity. A handle that owns its descriptor publishes no table and needs none: the descriptor it already holds is handed to the same consumer directly. @@ -7952,7 +7981,9 @@ def _inverted_descriptor_table_nodes( return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), - CComment(f"'{plan.binding.python_name}' may have its allocation changed by the callee."), + CComment( + f"'{plan.binding.python_name}' may have its {_descriptor_binding_noun(handle)} changed by the callee." + ), CComment("A handle that publishes native entry points builds its descriptor inside"), CComment("the consumer; one that owns a descriptor already hands that over instead."), CExpressionStatement( diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index b38173f22..33bb6c498 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -2660,35 +2660,27 @@ def _module_native_array_descriptor_operation(self, plan: ModuleVariablePlan) -> return None if handle.array.rank is None: raise ValueError(f"Pointer module handle {plan.owner_path!r} has no descriptor rank") - name = self._module_native_array_operation_name(plan, NativeArrayOperation.DESCRIPTOR) - native = self._native_variable_name(plan) - return FortranFunction( - name=name, - parameters=( - FortranParameter( - "descriptor", - self._module_pointer_dummy_element_type(plan), - ("pointer", self._array_dimension_attribute(handle.array.rank), "intent(out)"), - ), - ), - bind_name=name, - body=( - FortranIf( - CodeExpression(f"associated({native})"), - body=(FortranPointerAssignment("descriptor", CodeExpression(native)),), - else_body=(FortranPointerAssignment("descriptor", CodeExpression("null()")),), - ), - ), - is_subroutine=True, - ) + # The variable is handed to a consumer, as an allocatable one is, so the + # descriptor that crosses is the one this compiler builds for the call + # rather than a record C established and this filled in. + return self._module_allocatable_descriptor_callback_operation(plan, NativeArrayOperation.DESCRIPTOR) @staticmethod def _uses_module_allocatable_descriptor(plan: ModuleVariablePlan) -> bool: - """Return whether completed policy selected callback-based descriptor access.""" + """Return whether a handle reaches its descriptor through a consumer. + + A module array hands its variable to a consumer rather than filling a + record supplied from C, so the descriptor that crosses is always one + this compiler built. Both allocatable and pointer variables do this. + """ handle = plan.native_array_handle return bool( handle is not None - and handle.descriptor_interop is NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR + and handle.descriptor_interop + in { + NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR, + NativeArrayDescriptorInterop.POINTER_C_DESCRIPTOR, + } ) def _module_allocatable_descriptor_callback_operation( @@ -2848,9 +2840,15 @@ def _module_descriptor_consumer_value_declaration( ``allocated``. """ dimension = self._array_dimension_attribute(rank) + handle = plan.native_array_handle + attribute = ( + "pointer" + if handle is not None and handle.descriptor_kind is NativeArrayDescriptorKind.POINTER + else "allocatable" + ) if plan.datatype_family is DatatypeFamily.STRING and plan.character_length is not None: - return "character(kind=c_char, len=*)", ("allocatable", dimension, "intent(inout)") - return self._module_native_array_element_type(plan), ("allocatable", dimension, "intent(inout)") + return "character(kind=c_char, len=*)", (attribute, dimension, "intent(inout)") + return self._module_native_array_element_type(plan), (attribute, dimension, "intent(inout)") def _module_native_array_operation_name(self, plan: ModuleVariablePlan, operation) -> str: """Return one planner-owned module native-array operation symbol.""" diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 175c72532..45163d8b2 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -3385,9 +3385,8 @@ def _direct_descriptor_diagnostics( diagnostics = [] if handle.handoff.descriptor_pointer_role is None or any(expected_counts): diagnostics.append(self._diagnostic(owner_path, "invalid-direct-native-descriptor-roles", None)) - if ( - handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE - and handle.descriptor_kind is not NativeArrayDescriptorKind.ALLOCATABLE + if handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE and ( + handle.descriptor_kind not in {NativeArrayDescriptorKind.ALLOCATABLE, NativeArrayDescriptorKind.POINTER} ): diagnostics.append(self._diagnostic(owner_path, "direct-descriptor-without-projection", None)) return tuple(diagnostics) diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 17d54923d..226c13541 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -1061,10 +1061,20 @@ def _module_descriptor_callback_signature(self, variable, handle): @staticmethod def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: + """Report whether a module array reaches its descriptor through a consumer. + + The variable is handed to a consumer rather than filling a record + supplied from C, so the descriptor that crosses is one the compiler + built. Allocatable and pointer variables both do this. + """ handle = variable.native_array_handle return bool( handle is not None - and handle.descriptor_interop is NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR + and handle.descriptor_interop + in { + NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR, + NativeArrayDescriptorInterop.POINTER_C_DESCRIPTOR, + } ) @staticmethod diff --git a/prik/policy/completion.py b/prik/policy/completion.py index b54752334..5fdeb1397 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -1440,16 +1440,23 @@ def _native_array_default_construction( context: OwnershipContext, semantic_type: models.SemanticType, ) -> str: - """Complete how a runtime-constructed descriptor reaches one argument.""" + """Complete how a runtime-constructed descriptor reaches one argument. + + This pairs with the handoff ABI: an argument whose descriptor crosses + directly is handed a descriptor the Fortran runtime built, so a caller who + supplies a contract-default handle needs storage of its own to hand over. + An optional argument keeps the fact-packed form, whose absent branch has an + empty descriptor to establish instead. + """ if ( semantic_type.name == "String" or handle_kind not in {"argument_descriptor", "optional_absent_handle"} or not context.is_argument ): return "none" - if context.projects_result: - return "lazy_owned_descriptor" - return "fact_packed_empty" + if handle_kind == "optional_absent_handle" and not context.projects_result: + return "fact_packed_empty" + return "lazy_owned_descriptor" def _native_array_handle_origin(context: OwnershipContext) -> str: diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 08d220a37..3a8e0dbeb 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6371,17 +6371,21 @@ def _native_descriptor_handoff_abi( ) -> NativeDescriptorHandoffABI: """Select one descriptor ABI from completed handle/result policy. - An allocatable actual cannot be established from C: the standard reserves - that descriptor for the Fortran runtime, so the binding must borrow the - descriptor the handle already owns rather than build one. An optional - allocatable keeps the fact-packed form, whose absent branch establishes the - unallocated placeholder the present flag pairs with. + A descriptor actual is the Fortran runtime's to build: the binding is + handed one for the call rather than establishing or filling a record of its + own. That holds for a pointer as much as an allocatable, and it is what + lets a callee change an allocation or an association and have the caller's + entity see it. An optional argument keeps the fact-packed form, whose + absent branch establishes the placeholder the present flag pairs with. """ if handle_kind is NativeArrayHandleKind.OWNED_RESULT_DESCRIPTOR: return NativeDescriptorHandoffABI.OWNED_RESULT_STORAGE if output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - if descriptor_kind == NativeArrayDescriptorKind.ALLOCATABLE.value and not optional_absent: + if not optional_absent and descriptor_kind in { + NativeArrayDescriptorKind.ALLOCATABLE.value, + NativeArrayDescriptorKind.POINTER.value, + }: return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR return NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 1bca9f896..8fff0c64b 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -287,10 +287,16 @@ def _bind_contract_native_array_handle( ops: Mapping[str, HandleOperation], owner: Any, descriptor_ownership: str, - to_numpy_policy: str, + to_numpy_policy: str | None, generation: int | None = None, ) -> None: - """Attach generated persistent descriptor storage to a contract handle.""" + """Attach generated persistent descriptor storage to a contract handle. + + ``to_numpy_policy`` is ``None`` when the argument that supplied the storage + does not project a result. Such an argument gives the handle a descriptor + to hand over, but it does not define what the handle exposes, so the + handle keeps the exposure it was created with. + """ if not isinstance(handle, NativeArrayHandleBase) or not handle._contract_default: raise TypeError("generated descriptor storage can attach only to a fresh contract handle") if handle.closed: @@ -311,7 +317,7 @@ def _bind_contract_native_array_handle( ops, owner=owner, descriptor_ownership=descriptor_ownership, - to_numpy_policy=to_numpy_policy, + to_numpy_policy=handle._to_numpy_policy if to_numpy_policy is None else to_numpy_policy, generation=generation, ) handle._ops = generated._ops diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index ae2b28342..a50bff02d 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -176,9 +176,5 @@ def test_allocatable_argument_uses_the_descriptor_the_runtime_built(): artifacts = WrapperGenerator().generate(plan) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") assert "scoped_descriptor(" in c_source - copied = [ - line.strip() - for line in c_source.splitlines() - if "memcpy(" in line and "CFI_CDESC_T" in line - ] + copied = [line.strip() for line in c_source.splitlines() if "memcpy(" in line and "CFI_CDESC_T" in line] assert copied == [] diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index f4ac2ab23..82667a86d 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -189,28 +189,29 @@ def test_native_handle_plans_keep_datatype_specific_state(): alloc = functions["alloc"].arguments[0] pointer = functions["pointer"].arguments[0] - # An allocatable actual cannot be established from C, so it borrows the - # descriptor the Fortran runtime made. A pointer actual can be established - # with a real base address, so it keeps the fact-packed call-local form. - for argument, descriptor_kind, abi in ( - (alloc, NativeArrayDescriptorKind.ALLOCATABLE, NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR), - (pointer, NativeArrayDescriptorKind.POINTER, NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL), + # Neither kind is established from C. An allocatable cannot be: the + # standard requires a null base address for that attribute. A pointer + # could be, but a descriptor C built is not the caller's entity, so a + # callee that re-associates the dummy would change only that copy. Both + # therefore take the descriptor the Fortran runtime made. + for argument, descriptor_kind in ( + (alloc, NativeArrayDescriptorKind.ALLOCATABLE), + (pointer, NativeArrayDescriptorKind.POINTER), ): handle = argument.native_array_handle assert handle is not None assert handle is argument.projected_call_slot.native_array_handle assert handle.descriptor_kind is descriptor_kind - assert handle.handoff.abi is abi - assert handle.default_handle.construction is NativeArrayDefaultConstruction.FACT_PACKED_EMPTY + assert handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + assert handle.default_handle.construction is NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR assert handle.default_handle.descriptor_ownership is NativeArrayDescriptorOwnership.OWNED - assert handle.default_handle.owner_storage_role is None + # Lazily attached storage is the wrapper's, so the plan names the slot + # the generated binder allocates and the handle's finalizer releases. + assert handle.default_handle.owner_storage_role == f"{argument.owner_path}:default-owner-storage" assert NativeArrayOperation.DESTROY in handle.default_handle.operations - # Fact roles exist only for the fact-packed form; a borrowed descriptor - # carries its own extents, so none are named. - if abi is NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL: - assert len(handle.handoff.extent_roles) == handle.array.rank == 1 - else: - assert handle.handoff.extent_roles == () + # Fact roles exist only for the fact-packed form; a descriptor the + # runtime built carries its own extents, so none are named. + assert handle.handoff.extent_roles == () assert argument.binding.python_action is PythonBarrierAction.WRAPPER_INSTANCE assert argument.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR diff --git a/tests/fortran/pointers/end_to_end/test_pointer_handles.py b/tests/fortran/pointers/end_to_end/test_pointer_handles.py index f92f7ec25..54f929e5a 100644 --- a/tests/fortran/pointers/end_to_end/test_pointer_handles.py +++ b/tests/fortran/pointers/end_to_end/test_pointer_handles.py @@ -680,3 +680,86 @@ def peak_kib() -> int: # A borrowed target is module storage the library keeps; releasing is the # caller's decision there too, so only the untouched path is asserted. assert module.borrow(np.int32(4)).associated is True + + +POINTER_REASSOCIATION_SOURCE = """\ +module fpointer_reassociate_f90 + implicit none + + real(8), target :: small_target(3) = [1.0_8, 2.0_8, 3.0_8] + real(8), target :: large_target(5) = [10.0_8, 20.0_8, 30.0_8, 40.0_8, 50.0_8] + +contains + + subroutine repoint(values) + real(8), pointer, intent(inout) :: values(:) + values => large_target + end subroutine repoint + + function total(values) result(sum_values) + real(8), pointer, intent(in) :: values(:) + real(8) :: sum_values + sum_values = 0.0_8 + if (associated(values)) sum_values = sum(values) + end function total + +end module fpointer_reassociate_f90 +""" + + +def test_callee_reassociation_of_an_inout_pointer_dummy_reaches_the_caller_handle(tmp_path: Path): + """A callee's ``values => target`` must reach the handle that was passed in. + + ``intent(inout)`` carries no output projection, so nothing re-reads the + descriptor after the call. The descriptor the callee re-points therefore + has to be the caller's own, not one the wrapper rebuilt for the call. + """ + source = tmp_path / "native" / "fpointer_reassociate_f90.f90" + source.parent.mkdir() + source.write_text(POINTER_REASSOCIATION_SOURCE, encoding="utf-8") + native_object = _compile_native_object(source, tmp_path / "native_build") + contract = tmp_path / "contracts" / "fpointer_reassociate_f90.pyi" + contract.parent.mkdir() + contract.write_text( + """from prik.contracts import Annotated, Float64, Pointer, PointerAssociation, PointerPolicy + +def repoint( + values: Annotated[ + Pointer[Float64[:]], + PointerAssociation("runtime"), + PointerPolicy( + nullable=True, + transfer="call_local", + target_owner="module", + lifetime="module", + deallocation="never", + shape_source="pointer_bounds", + contiguity="contiguous", + reassociation="native", + aliasing="borrowed", + mutability="view", + ), + ], +) -> None: ... + +def total(values: Pointer[Float64[:]]) -> Float64: ... +""", + encoding="utf-8", + ) + result = build_pyi_extension( + contract, + native_objects=[native_object], + native_include_dirs=[native_object.parent], + output_dir=tmp_path / "build", + ) + module = _sole_native_module(_import_from_build_dir(result.module_name, result.output_dir)) + + handle = Pointer[Float64[:]]() + assert handle.associated is False + assert module.total(handle) == np.float64(0.0) + + module.repoint(handle) + + assert handle.associated is True + assert handle.shape == (5,) + assert module.total(handle) == np.float64(150.0) diff --git a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py index a4379c2a2..ada844885 100644 --- a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py +++ b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py @@ -635,7 +635,9 @@ def make_target() -> Pointer[Float64[:]]: ... assert argument_values.descriptor_interop == "none" assert argument_values.requires_pointer_c_descriptor_interop is False assert set(argument_values.operations) == {"allocated", "to_numpy"} - assert argument_values.default_construction == "fact_packed_empty" + # A non-optional descriptor argument is handed a descriptor the Fortran + # runtime built, so a caller-created handle needs storage of its own. + assert argument_values.default_construction == "lazy_owned_descriptor" assert argument_values.default_descriptor_ownership == "owned" assert argument_values.default_release == "wrapper_dealloc" assert argument_values.default_destroy_behavior == "handle_finalizer" From ff8d32d65e2ee3b36ed7d345bf44c163f8c77991 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 04:17:00 +0100 Subject: [PATCH 13/47] Place a present optional descriptor argument like any other Passing a handle to an optional allocatable or pointer dummy raised `Unable to establish native descriptor for argument ...: 2` on ifx while working on gfortran. The fix for non-optional dummies did not reach these: routing was decided from `optional_absent`, a property of the declaration rather than of the call, so marking a dummy optional sent both its runtime cases down the old route. The present one then rebuilt its descriptor in C and made exactly the `CFI_establish` call F2018 18.5.5.6 forbids. A present optional argument is an ordinary descriptor argument, so it now takes the direct ABI and the consumer inversion, and a callee that reallocates or re-associates one reaches the caller's entity. Three places had to stop assuming the inverted argument was never optional: the inversion candidate filter excluded it; the consumer call substituted the descriptor for every value in its parameter group, so the present flag was passed the descriptor; and presence was decided only in the packing branch, so a handle publishing a table read as absent. Absence is routed by what the entrypoint can express, not by the Python signature. A generated bridge takes its descriptor dummy unconditionally and reads a separate flag, so the absent branch establishes the unallocated placeholder that pairs with it -- legal precisely because absence is when there is nothing to point at. A direct `bind(c)` entrypoint has no such flag, since PRIK cannot add a parameter to a signature the user wrote, so there absence stays a null descriptor pointer. Establishing a placeholder there would have made an absent argument indistinguishable from a present but unallocated one. A handle whose `descriptor` operation only reported base address, element length and bounds is no longer accepted: rebuilding a descriptor from those fields is the unsound step being removed. The optional fixture now allocates and associates through real entrypoints, which also proves the callee's work reaches the handle. Verified on gfortran and ifx: absent, present-unallocated and present-allocated stay distinct on both routes, and a reallocating callee grows the caller's module variable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 26 ++++- prik/codegen/c/binding.py | 108 ++++++++++++++++-- prik/policy/completion.py | 4 - prik/policy/construction.py | 8 +- .../codegen/test_native_handle_planning.py | 11 +- .../optional_array_descriptors.pyi | 29 ++++- .../native/optional_array_descriptors.f90 | 16 +++ .../end_to_end/test_optional_runtime.py | 41 ++----- .../policy/test_pointer_ownership_policy.py | 4 +- 9 files changed, 195 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a979476c..e795f0a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,30 @@ release tags add a leading `v` to the package version. association — is therefore what the caller's entity sees when the callback returns. Module variables, derived-type fields and returned results all reach such a dummy on every compiler now, including the bounds a shifted allocatable - carries. Optional dummies are unchanged: their absent branch still establishes - the unallocated placeholder that pairs with the present flag. + carries. + +- **Fixed:** an `optional` `allocatable` or `pointer` dummy no longer fails on + Intel `ifx`. Passing a handle to one raised `Unable to establish native + descriptor for argument ...: 2` there while working on gfortran: a present + optional argument still rebuilt its descriptor in C, so it made exactly the + `CFI_establish` call F2018 18.5.5.6 forbids. A present optional argument is an + ordinary descriptor argument and now takes the same route as any other, which + also means a callee that reallocates or re-associates one reaches the caller's + entity instead of a copy. + + An absent argument is unchanged where a generated bridge is involved: it still + hands over the unallocated placeholder that pairs with the bridge's present + flag, which is legal precisely because absence is when there is nothing to + point at. A direct `bind(c)` entrypoint has no such flag — PRIK cannot add a + parameter to a signature you wrote — so there an absent argument stays a null + descriptor pointer, keeping it distinct from a present but unallocated one. + + A caller-supplied handle must now be backed by a real descriptor. A handle + whose `descriptor` operation only reported base address, element length and + bounds is no longer accepted for these arguments: rebuilding a descriptor from + those fields is the unsound step this release removes. Handles obtained from a + module variable, a field, a result, or created from a contract type and filled + by a native call are unaffected. - **Fixed:** a `pointer` dummy now takes the same route, and a callee that re-associates one is no longer silently ignored. PRIK packed the descriptor's diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 00a852597..4426f8590 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -7978,6 +7978,17 @@ def _inverted_descriptor_table_nodes( prefix = names.value_name capsule = f"{prefix}_ops_capsule" table = f"{prefix}_native_ops" + # Presence is otherwise decided by the packing helper, which only the + # other branch calls. A handle that published a table was supplied, so + # an optional argument reaching this branch is present. + present = ( + ( + CComment("This handle was supplied, so an optional argument is present."), + CExpressionStatement(CodeExpression(f"{names.present_name} = {table}")), + ) + if plan.entrypoint.pass_descriptor_presence + else () + ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), @@ -8003,6 +8014,7 @@ def _inverted_descriptor_table_nodes( ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + *present, ), else_body=( CComment("No table: this handle owns the descriptor it will hand over."), @@ -8041,6 +8053,14 @@ def _lower_argument_native_array_direct( include_default_binder=binder_definition is not None, ), *(self._native_descriptor_presence_declarations(plan, names)), + *( + ( + CDeclaration(f"{prefix}_storage", f"CFI_CDESC_T({handle.array.rank})"), + CDeclaration(f"{prefix}_establish_status", "int", CodeExpression("CFI_SUCCESS")), + ) + if plan.entrypoint.pass_descriptor_presence + else () + ), ] inverted = context.inverted_descriptor == plan.owner_path general: list[CDeclaration | CExpressionStatement | CIf] = [] @@ -8235,6 +8255,11 @@ def _native_descriptor_pointer_unpack_nodes( cfi_type = self._native_array_cfi_type(plan) prefix = names.value_name condition = "1" if plan.binding.optional_mode is OptionalMode.REQUIRED else f"{names.present_name} != NULL" + absent = ( + self._absent_descriptor_placeholder_nodes(plan, names, handle) + if plan.entrypoint.pass_descriptor_presence + else () + ) return ( CExpressionStatement(CodeExpression(f"{prefix}_item = PyTuple_GetItem({prefix}_packed, 0)")), CExpressionStatement( @@ -8262,7 +8287,57 @@ def _native_descriptor_pointer_unpack_nodes( CodeExpression(f"{names.value_name} = (CFI_cdesc_t *){prefix}_native_handle->descriptor") ), ), + else_body=absent, + ), + ) + + def _absent_descriptor_placeholder_nodes( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + handle: NativeArrayHandlePlan, + ) -> tuple[CComment | CExpressionStatement | CIf, ...]: + """Establish the unallocated descriptor an absent optional hands over. + + A generated bridge takes its descriptor dummy unconditionally and reads + the separate present flag, so something valid has to cross even when the + argument is absent. A null base address is the one form the standard + allows C to establish for this attribute, and absence is exactly when + there is nothing to point at. + + A direct ``bind(c)`` entrypoint has no such flag -- PRIK cannot add a + parameter to a signature the user wrote -- so there absence is a null + descriptor pointer and no placeholder is built. That is also what keeps + an absent argument distinct from a present but unallocated one. + """ + prefix = names.value_name + rank = handle.array.rank + cfi_type = self._native_array_cfi_type(plan) + elem_len = self._native_array_expected_element_size(plan) + status = f"{prefix}_establish_status" + return ( + CComment("Absent: hand over an unallocated placeholder, not a descriptor of"), + CComment("someone else's storage. The present flag tells the bridge to ignore it."), + CExpressionStatement( + CodeExpression( + f"{status} = CFI_establish((CFI_cdesc_t *)&{prefix}_storage, NULL, " + f"{self._owned_native_array_cfi_attribute(handle)}, {cfi_type}, {elem_len}, {rank}, NULL)" + ) + ), + CIf( + CodeExpression(f"{status} != CFI_SUCCESS"), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_RuntimeError, "Unable to establish absent native descriptor ' + f'for argument {plan.binding.python_name}: %d", {status})' + ) + ), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)")), + CReturn(CodeExpression("NULL")), + ), ), + CExpressionStatement(CodeExpression(f"{names.value_name} = (CFI_cdesc_t *)&{prefix}_storage")), ) def _native_descriptor_fact_unpack_nodes( @@ -9605,13 +9680,16 @@ def _inverted_descriptor_argument(self, plan: FunctionPlan) -> ArgumentTransferP writable dummy and have it reach the caller's entity, and it means no descriptor is ever copied: a read-only argument is placed the same way, so C only ever passes on a descriptor Fortran made. + + An optional argument is placed the same way when it is present. When + it is absent there is no handle and so no consumer to enter, and the + unallocated placeholder is handed to the same call site directly. """ candidates = [ argument for argument in plan.arguments if argument.native_array_handle is not None and argument.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - and argument.binding.optional_mode is OptionalMode.REQUIRED and argument.native_array_handle.array.rank is not None ] if len(candidates) != 1: @@ -9727,11 +9805,17 @@ def _inverted_consumer_call( ) -> str: """Assemble the entrypoint call as the consumer makes it.""" carried = {value: f"call->{declaration.name}" for declaration, value in fields} + descriptor_value = context.arguments[context.inverted_descriptor].value_name arguments = [] for group in sorted(plan.entrypoint.parameters, key=lambda item: item.position): values = self._entrypoint_parameter_values(plan, group, context) if group.owner_path == context.inverted_descriptor: - arguments.extend("(CFI_cdesc_t *)descriptor" for _value in values) + # Only the descriptor itself is the consumer's argument. An + # optional one is planned alongside its present flag, and that + # flag is an ordinary carried value like any other. + arguments.extend( + "(CFI_cdesc_t *)descriptor" if value == descriptor_value else carried[value] for value in values + ) continue arguments.extend(carried[value] for value in values) call = f"{self._entrypoint_function_name(plan)}({', '.join(arguments)})" @@ -9748,20 +9832,26 @@ def _inverted_context_fields( ) -> tuple[tuple[CParameter, str], ...]: """Pair every entrypoint value the consumer needs with its declaration. - The inverted argument is excluded: the consumer receives that - descriptor directly. Everything else the call needs is carried into - the consumer through the context record, because the consumer runs - outside the frame that computed it. + The inverted descriptor is excluded: the consumer receives that + directly. Everything else the call needs is carried into the consumer + through the context record, because the consumer runs outside the frame + that computed it -- including the present flag planned beside an + optional descriptor, which is a value like any other. """ + descriptor_value = ( + None if context.inverted_descriptor is None else context.arguments[context.inverted_descriptor].value_name + ) pairs: list[tuple[CParameter, str]] = [] for group in sorted(plan.entrypoint.parameters, key=lambda item: item.position): - if group.owner_path == context.inverted_descriptor: - continue declarations = self._entrypoint_parameter_declarations(plan, group) values = self._entrypoint_parameter_values(plan, group, context) if len(declarations) != len(values): raise ValueError(f"Entrypoint parameter {group.owner_path!r} has mismatched declarations and values") - pairs.extend(zip(declarations, values, strict=True)) + pairs.extend( + (declaration, value) + for declaration, value in zip(declarations, values, strict=True) + if not (group.owner_path == context.inverted_descriptor and value == descriptor_value) + ) return tuple(pairs) def _inverted_consumer_name(self, plan: FunctionPlan) -> str: diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 5fdeb1397..487edc382 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -1445,8 +1445,6 @@ def _native_array_default_construction( This pairs with the handoff ABI: an argument whose descriptor crosses directly is handed a descriptor the Fortran runtime built, so a caller who supplies a contract-default handle needs storage of its own to hand over. - An optional argument keeps the fact-packed form, whose absent branch has an - empty descriptor to establish instead. """ if ( semantic_type.name == "String" @@ -1454,8 +1452,6 @@ def _native_array_default_construction( or not context.is_argument ): return "none" - if handle_kind == "optional_absent_handle" and not context.projects_result: - return "fact_packed_empty" return "lazy_owned_descriptor" diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 3a8e0dbeb..6fc663489 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6375,14 +6375,16 @@ def _native_descriptor_handoff_abi( handed one for the call rather than establishing or filling a record of its own. That holds for a pointer as much as an allocatable, and it is what lets a callee change an allocation or an association and have the caller's - entity see it. An optional argument keeps the fact-packed form, whose - absent branch establishes the placeholder the present flag pairs with. + entity see it. An optional argument is no different when it is present; + its absent branch establishes the placeholder the present flag pairs with, + which is the one descriptor C may legally establish for this attribute + because it has a null base address. """ if handle_kind is NativeArrayHandleKind.OWNED_RESULT_DESCRIPTOR: return NativeDescriptorHandoffABI.OWNED_RESULT_STORAGE if output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - if not optional_absent and descriptor_kind in { + if descriptor_kind in { NativeArrayDescriptorKind.ALLOCATABLE.value, NativeArrayDescriptorKind.POINTER.value, }: diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 82667a86d..448ba850f 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -366,7 +366,9 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert artifacts.required_headers == ("ISO_Fortran_binding.h",) assert "prik_bind_array(" in c_source - assert '"_native_array_descriptor_argument_for_binding_positional"' in c_source + # Descriptor arguments reach the runtime through one packer. The + # fact-reporting one is gone: nothing rebuilds a descriptor in C. + assert '"_native_array_descriptor_argument_for_binding_positional"' not in c_source assert '"_native_array_descriptor_handoff_for_binding_positional"' in c_source assert '"_native_array_handle_from_generated_ops"' in c_source assert '"_bind_contract_native_array_handle"' in c_source @@ -394,8 +396,11 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): optional_c_end = c_source.index("static PyObject * wrap_replace(", optional_c_start) optional_binding = c_source[optional_c_start:optional_c_end] assert "} else {" in optional_binding - assert "bound_values_elem_len = sizeof(double);" in optional_binding - assert "bound_values_descriptor_rank = 1;" in optional_binding + # The absent branch hands the bridge an unallocated placeholder to pair + # with its present flag. A null base address is the only form the standard + # lets C establish for this attribute, and absence is when there is nothing + # to point at. + assert "CFI_establish((CFI_cdesc_t *)&bound_values_storage, NULL, CFI_attribute_allocatable" in optional_binding assert "bound_values = (CFI_cdesc_t *)&bound_values_storage;" in optional_binding assert "result_value = native_make(n)" in bridge_source assert "result_value = native_make_matrix(n, m)" in bridge_source diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/edited_contracts/optional_array_descriptors/optional_array_descriptors.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/edited_contracts/optional_array_descriptors/optional_array_descriptors.pyi index b9083e1d5..eb1c0f406 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/edited_contracts/optional_array_descriptors/optional_array_descriptors.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/edited_contracts/optional_array_descriptors/optional_array_descriptors.pyi @@ -1,4 +1,31 @@ -from prik.contracts import Allocatable, Float64, Int32, Pointer +from prik.contracts import ( + Allocatable, + Annotated, + Float64, + Int32, + Pointer, + PointerAssociation, + PointerPolicy, +) def alloc_state(values: Allocatable[Float64[:]] | None = ...) -> Int32: ... def pointer_state(values: Pointer[Float64[:]] | None = ...) -> Int32: ... +def alloc_fill(values: Allocatable[Float64[:]]) -> None: ... +def pointer_bind( + values: Annotated[ + Pointer[Float64[:]], + PointerAssociation("runtime"), + PointerPolicy( + nullable=True, + transfer="call_local", + target_owner="module", + lifetime="module", + deallocation="never", + shape_source="pointer_bounds", + contiguity="contiguous", + reassociation="native", + aliasing="borrowed", + mutability="view", + ), + ], +) -> None: ... diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/native/optional_array_descriptors.f90 b/tests/fortran/optional_arguments/end_to_end/fixtures/native/optional_array_descriptors.f90 index 2bd1f154f..6e5a8cfd0 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/native/optional_array_descriptors.f90 +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/native/optional_array_descriptors.f90 @@ -1,5 +1,8 @@ module optional_array_descriptors implicit none + + real(8), target :: pointer_target(3) = [1.0d0, 2.0d0, 3.0d0] + contains integer(4) function alloc_state(values) result(state) real(8), allocatable, optional, intent(in) :: values(:) @@ -24,4 +27,17 @@ integer(4) function pointer_state(values) result(state) state = int(sum(values), kind=4) end if end function pointer_state + subroutine alloc_fill(values) + real(8), allocatable, intent(inout) :: values(:) + + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = [1.0d0, 2.0d0, 3.0d0] + end subroutine alloc_fill + + subroutine pointer_bind(values) + real(8), pointer, intent(inout) :: values(:) + + values => pointer_target + end subroutine pointer_bind end module optional_array_descriptors diff --git a/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py b/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py index 8aba150d3..1a4a43aee 100644 --- a/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py +++ b/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py @@ -12,6 +12,7 @@ _import_from_build_dir, _sole_native_module, ) +from prik.contracts import Allocatable, Float64, Pointer from prik.runtime.handles import _NativeArrayHandoff, AllocatableArray, PointerArray FIXTURES = Path(__file__).parent / "fixtures" @@ -51,31 +52,6 @@ def _unassociated_handle_for_rejected_optional_array(): ) -def _optional_descriptor_handle(value: np.ndarray | None, *, pointer: bool): - descriptor = { - "base_addr": 0 if value is None else value.ctypes.data, - "elem_len": np.dtype(np.float64).itemsize, - "rank": 1, - "dim": [ - { - "lower_bound": 0, - "extent": 0 if value is None else value.size, - "sm": np.dtype(np.float64).itemsize, - } - ], - } - operations = { - "array_actual": lambda _handle: _NativeArrayHandoff(value.ctypes.data), - "descriptor": lambda _handle: descriptor, - "shape": lambda _handle: None if value is None else value.shape, - "to_numpy": lambda _handle: value, - "associated" if pointer else "allocated": lambda _handle: value is not None, - "nullify" if pointer else "deallocate": lambda _handle: None, - } - handle_type = PointerArray if pointer else AllocatableArray - return handle_type(dtype=np.dtype(np.float64), rank=1, ops=operations) - - def test_optional_scalar_descriptors_distinguish_omitted_none_and_value(tmp_path: Path): source = FIXTURES / "native" / "optional_scalar_descriptors.f90" native_object = _compile_native_object(source, tmp_path / "native") @@ -122,16 +98,23 @@ def test_optional_array_descriptors_preserve_presence_and_storage_state(tmp_path ) module = _sole_native_module(_import_from_build_dir(result.module_name, result.output_dir)) - values = np.array([1.0, 2.0, 3.0], dtype=np.float64) for function_name in ("alloc_state", "pointer_state"): function = getattr(module, function_name) assert function() == np.int32(0) assert function(None) == np.int32(0) - for function_name, pointer in (("alloc_state", False), ("pointer_state", True)): + # A handle that is present but empty must stay distinguishable from an + # absent argument, and the descriptor the callee fills has to be the + # handle's own for the third state to be reachable at all. + for function_name, contract, fill_name in ( + ("alloc_state", Allocatable[Float64[:]], "alloc_fill"), + ("pointer_state", Pointer[Float64[:]], "pointer_bind"), + ): function = getattr(module, function_name) - assert function(_optional_descriptor_handle(None, pointer=pointer)) == np.int32(1) - assert function(_optional_descriptor_handle(values, pointer=pointer)) == np.int32(6) + handle = contract() + assert function(handle) == np.int32(1) + getattr(module, fill_name)(handle) + assert function(handle) == np.int32(6) def test_optional_arguments_drive_fortran_present_behavior( diff --git a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py index ada844885..2a5f70ac0 100644 --- a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py +++ b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py @@ -654,7 +654,9 @@ def make_target() -> Pointer[Float64[:]]: ... assert optional_target.descriptor_interop == "pointer_c_descriptor" assert optional_target.requires_pointer_c_descriptor_interop is True assert set(optional_target.operations) == {"associate", "associated", "nullify", "to_numpy"} - assert optional_target.default_construction == "fact_packed_empty" + # An optional argument is a descriptor argument like any other when it is + # present, so a caller-created handle needs storage of its own to hand over. + assert optional_target.default_construction == "lazy_owned_descriptor" assert "destroy" in optional_target.default_operations assert "allocate" not in optional_target.operations assert "deallocate" not in optional_target.operations From a73b08befcf805abeef8fbef97e7e92dba2d962c Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 04:46:49 +0100 Subject: [PATCH 14/47] Delete the machinery for rebuilding a descriptor in C With pointers and optional arguments placed like every other descriptor argument, nothing selects the fact-packed route. It was unreachable rather than merely unused: an array handle's descriptor kind is only ever allocatable or pointer, and both now take the direct ABI, so the selector's final branch could not be reached. Removed: the two fact-packed policy selectors; the six descriptor fact roles on the plan and the planner helpers that named them; the role-count validation chain those roles existed to check; the C lowering that established call-local storage from packed fields and its unpacker; and the two runtime packers that reported a descriptor as base address, element length and bounds. The ABI selector now states the rule it actually applies -- every descriptor a call receives is the runtime's to build, except a result, where the wrapper owns storage the callee allocates into. Three of its four parameters were dead: the descriptor-kind test listed both members of a two-member enum, and the projection test was subsumed by it. Five tests went with it. Four drove the deleted packers through handles that only reported fields; of their invariants, optional presence mapping is kept against the surviving helper, because a present but unassociated pointer must stay distinct from an absent one, while field packing no longer exists and the kind and dtype validation is already asserted against the shared validator in the same file. The fifth pinned a plan edit naming a role that is gone. A fresh contract handle now has a test saying it owns no descriptor until a generated binder attaches one, which is the guard that replaces the empty descriptor the old path would have invented. Verified on gfortran and ifx: the descriptor matrix and the optional cases are unchanged on both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 8 +- prik/codegen/c/binding.py | 63 --------- prik/pipeline/wrapper.py | 51 +------ prik/planning/models.py | 6 - prik/planning/planner.py | 26 ---- prik/policy/construction.py | 33 ++--- prik/policy/models.py | 2 - prik/runtime/handles.py | 63 --------- .../test_allocatable_contract_handles.py | 23 ++-- .../codegen/test_native_handle_planning.py | 9 +- .../runtime/test_pointer_descriptor_abi.py | 127 +----------------- 11 files changed, 43 insertions(+), 368 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e795f0a61..9e6c19d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,9 +44,11 @@ release tags add a leading `v` to the package version. A caller-supplied handle must now be backed by a real descriptor. A handle whose `descriptor` operation only reported base address, element length and bounds is no longer accepted for these arguments: rebuilding a descriptor from - those fields is the unsound step this release removes. Handles obtained from a - module variable, a field, a result, or created from a contract type and filled - by a native call are unaffected. + those fields is the unsound step this release removes, and with every + descriptor argument now placed the same way, the machinery that did it is + gone rather than merely unused. Handles obtained from a module variable, a + field, a result, or created from a contract type and filled by a native call + are unaffected. - **Fixed:** a `pointer` dummy now takes the same route, and a callee that re-associates one is no longer silently ignored. PRIK packed the descriptor's diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 4426f8590..511953760 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -7910,54 +7910,10 @@ def _lower_argument_native_array_handle( handle = plan.native_array_handle if handle is None: return () - if handle.handoff.abi is NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL: - return self._lower_argument_native_array_facts(plan, context) if handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: return self._lower_argument_native_array_direct(plan, context) raise ValueError(f"Unsupported C native descriptor ABI for {plan.owner_path!r}: {handle.handoff.abi!r}") - def _lower_argument_native_array_facts( - self, - plan: ArgumentTransferPlan, - context: _CFunctionContext, - ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: - """Establish call-local CFI storage from validated descriptor facts.""" - handle = plan.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Native descriptor {plan.owner_path!r} has no concrete rank") - names = context.arguments[plan.owner_path] - rank = handle.array.rank - prefix = names.value_name - nodes: list[CDeclaration | CExpressionStatement | CIf] = [ - self._native_descriptor_object_declaration(plan, names), - CDeclaration(f"{prefix}_storage", f"CFI_CDESC_T({rank})"), - CDeclaration(names.value_name, "CFI_cdesc_t *", CodeExpression("NULL")), - CDeclaration(f"{prefix}_base_addr", "void *", CodeExpression("NULL")), - CDeclaration(f"{prefix}_elem_len", "size_t", CodeExpression("0")), - CDeclaration(f"{prefix}_descriptor_rank", "CFI_rank_t", CodeExpression("0")), - CDeclaration(f"{prefix}_cfi_extents[{rank}]", "CFI_index_t"), - *( - CDeclaration(f"{prefix}_{label}_{axis}", "CFI_index_t", CodeExpression("0")) - for axis in range(rank) - for label in ("lower_bound", "descriptor_extent", "stride_multiplier") - ), - CDeclaration(f"{prefix}_establish_status", "int", CodeExpression("CFI_SUCCESS")), - *self._native_descriptor_helper_declarations(prefix), - *(self._native_descriptor_presence_declarations(plan, names)), - ] - nodes.extend( - self._native_descriptor_helper_call_nodes( - plan, - context, - names, - "_native_array_descriptor_argument_for_binding_positional", - ) - ) - nodes.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 3 + 3 * rank)) - nodes.extend(self._native_descriptor_fact_unpack_nodes(plan, names)) - nodes.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) - return tuple(nodes) - def _inverted_descriptor_table_nodes( self, plan: ArgumentTransferPlan, @@ -8340,25 +8296,6 @@ def _absent_descriptor_placeholder_nodes( CExpressionStatement(CodeExpression(f"{names.value_name} = (CFI_cdesc_t *)&{prefix}_storage")), ) - def _native_descriptor_fact_unpack_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - ) -> tuple[CExpressionStatement | CIf, ...]: - """Decode facts and establish a call-local standard descriptor.""" - handle = plan.native_array_handle - if handle is None or handle.array.rank is None: - return () - if plan.binding.optional_mode is OptionalMode.REQUIRED: - return self._native_descriptor_fact_present_nodes(plan, names) - return ( - CIf( - CodeExpression(f"{names.present_name} != NULL"), - body=self._native_descriptor_fact_present_nodes(plan, names), - else_body=self._native_descriptor_fact_absent_nodes(plan, names), - ), - ) - def _native_descriptor_fact_present_nodes( self, plan: ArgumentTransferPlan, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 45163d8b2..c9cdc8b51 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -3075,7 +3075,6 @@ def _native_array_default_handle_storage_diagnostics( """Match persistent owner storage and descriptor ABI to construction.""" default = handle.default_handle expected_owner_role = { - NativeArrayDefaultConstruction.FACT_PACKED_EMPTY: None, NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR: True, }[default.construction] owner_role = True if default.owner_storage_role is not None else None @@ -3087,10 +3086,8 @@ def _native_array_default_handle_storage_diagnostics( ) ) # A default handle that owns a lazily created descriptor requires the - # direct handoff. The converse does not hold: a borrowed allocatable - # descriptor crosses directly while its default handle is still built - # from facts, because the borrowed descriptor never comes from the - # default handle. + # direct handoff: the storage it attaches is what crosses. A result + # keeps its own owned storage instead and never attaches one. if ( default.construction is NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR and handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR @@ -3317,17 +3314,11 @@ def _native_descriptor_handoff_diagnostics( argument: ArgumentTransferPlan | None, ) -> tuple[WrapperPlanDiagnostic, ...]: """Validate descriptor ABI roles without reconstructing its policy.""" - handoff = handle.handoff rank = handle.array.rank diagnostics = [] if rank is None: return (self._diagnostic(owner_path, "missing-native-descriptor-rank", None),) - expected_counts = ( - len(handoff.lower_bound_roles), - len(handoff.extent_roles), - len(handoff.stride_multiplier_roles), - ) - diagnostics.extend(self._native_descriptor_abi_diagnostics(owner_path, handle, expected_counts)) + diagnostics.extend(self._native_descriptor_abi_diagnostics(owner_path, handle)) diagnostics.extend(self._native_descriptor_presence_diagnostics(owner_path, handle, argument)) diagnostics.extend(self._native_array_operation_diagnostics(owner_path, handle)) return tuple(diagnostics) @@ -3336,11 +3327,9 @@ def _native_descriptor_abi_diagnostics( self, owner_path: str, handle: NativeArrayHandlePlan, - expected_counts: tuple[int, int, int], ) -> tuple[WrapperPlanDiagnostic, ...]: """Dispatch exact role validation by typed descriptor ABI.""" handlers = { - NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL: self._fact_packed_descriptor_diagnostics, NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: self._direct_descriptor_diagnostics, NativeDescriptorHandoffABI.OWNED_RESULT_STORAGE: self._owned_descriptor_diagnostics, } @@ -3348,42 +3337,16 @@ def _native_descriptor_abi_diagnostics( handler = handlers[handle.handoff.abi] except KeyError: return (self._diagnostic(owner_path, "unknown-native-descriptor-handoff", handle.handoff.abi),) - return handler(owner_path, handle, expected_counts) - - def _fact_packed_descriptor_diagnostics( - self, - owner_path: str, - handle: NativeArrayHandlePlan, - expected_counts: tuple[int, int, int], - ) -> tuple[WrapperPlanDiagnostic, ...]: - """Validate every call-local descriptor fact role.""" - handoff = handle.handoff - diagnostics = [] - expected_rank = handle.array.rank - if expected_counts != (expected_rank, expected_rank, expected_rank): - diagnostics.append( - self._diagnostic(owner_path, "inconsistent-native-descriptor-axis-roles", expected_counts) - ) - if None in { - handoff.descriptor_pointer_role, - handoff.base_addr_role, - handoff.elem_len_role, - handoff.rank_role, - }: - diagnostics.append(self._diagnostic(owner_path, "missing-native-descriptor-fact-role", None)) - if handoff.owner_storage_role is not None: - diagnostics.append(self._diagnostic(owner_path, "fact-packed-has-owner-storage", None)) - return tuple(diagnostics) + return handler(owner_path, handle) def _direct_descriptor_diagnostics( self, owner_path: str, handle: NativeArrayHandlePlan, - expected_counts: tuple[int, int, int], ) -> tuple[WrapperPlanDiagnostic, ...]: """Validate one persistent projected standard-descriptor pointer.""" diagnostics = [] - if handle.handoff.descriptor_pointer_role is None or any(expected_counts): + if handle.handoff.descriptor_pointer_role is None: diagnostics.append(self._diagnostic(owner_path, "invalid-direct-native-descriptor-roles", None)) if handle.output_projection is not NativeArrayOutputProjection.PROJECTED_HANDLE and ( handle.descriptor_kind not in {NativeArrayDescriptorKind.ALLOCATABLE, NativeArrayDescriptorKind.POINTER} @@ -3395,12 +3358,10 @@ def _owned_descriptor_diagnostics( self, owner_path: str, handle: NativeArrayHandlePlan, - expected_counts: tuple[int, int, int], ) -> tuple[WrapperPlanDiagnostic, ...]: """Validate persistent wrapper-owned result descriptor storage roles.""" handoff = handle.handoff - invalid = handoff.owner_storage_role is None or handoff.descriptor_pointer_role is not None - if invalid or any(expected_counts): + if handoff.owner_storage_role is None or handoff.descriptor_pointer_role is not None: return (self._diagnostic(owner_path, "invalid-owned-native-descriptor-roles", None),) return () diff --git a/prik/planning/models.py b/prik/planning/models.py index 145d7ce71..00ec5b969 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -531,12 +531,6 @@ class NativeDescriptorHandoffPlan(StageRecord): abi: NativeDescriptorHandoffABI descriptor_pointer_role: str | None - base_addr_role: str | None - elem_len_role: str | None - rank_role: str | None - lower_bound_roles: tuple[str, ...] - extent_roles: tuple[str, ...] - stride_multiplier_roles: tuple[str, ...] presence_role: str | None owner_storage_role: str | None operation_roles: tuple[tuple[NativeArrayOperation, str], ...] diff --git a/prik/planning/planner.py b/prik/planning/planner.py index c71f91d10..d8872be29 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -2292,18 +2292,9 @@ def _native_descriptor_handoff_plan( operations, ) -> NativeDescriptorHandoffPlan: """Name descriptor facts once for binding, bridge, and lifecycle consumers.""" - fact_packed = policy.abi is NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL return NativeDescriptorHandoffPlan( abi=policy.abi, descriptor_pointer_role=self._native_descriptor_pointer_role(policy, owner_path), - base_addr_role=self._native_descriptor_fact_role(owner_path, "base-addr", fact_packed), - elem_len_role=self._native_descriptor_fact_role(owner_path, "elem-len", fact_packed), - rank_role=self._native_descriptor_fact_role(owner_path, "descriptor-rank", fact_packed), - lower_bound_roles=self._native_descriptor_axis_roles(owner_path, policy.rank, "lower-bound", fact_packed), - extent_roles=self._native_descriptor_axis_roles(owner_path, policy.rank, "descriptor-extent", fact_packed), - stride_multiplier_roles=self._native_descriptor_axis_roles( - owner_path, policy.rank, "stride-multiplier", fact_packed - ), presence_role=self._native_descriptor_presence_role(policy, owner_path), owner_storage_role=self._native_descriptor_owner_role(policy, owner_path), operation_roles=tuple((operation, f"{owner_path}:operation:{operation.value}") for operation in operations), @@ -2319,10 +2310,6 @@ def _native_descriptor_pointer_role( return None return f"{owner_path}:descriptor" - def _native_descriptor_fact_role(self, owner_path: str, label: str, enabled: bool) -> str | None: - """Name one fact-packed scalar descriptor field.""" - return f"{owner_path}:{label}" if enabled else None - def _native_descriptor_presence_role( self, policy: NativeDescriptorHandoffPolicy, @@ -2341,19 +2328,6 @@ def _native_descriptor_owner_role( return f"{owner_path}:owner-storage" return None - def _native_descriptor_axis_roles( - self, - owner_path: str, - rank: int, - label: str, - enabled: bool, - ) -> tuple[str, ...]: - """Name one standard-descriptor field role per declared axis.""" - if not enabled: - return () - return tuple(f"{owner_path}:{label}:{axis}" for axis in range(rank)) - - # Ordinary-array buffer and raw-address planning. def _array_plan( self, policy: ArrayHandoffPolicy | None, diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 6fc663489..063e6529c 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6192,7 +6192,7 @@ def _native_array_handle_wrapper_policy( ) handle_kind = _native_array_enum(NativeArrayHandleKind, completed.handle_kind, owner_path, "handle kind") handoff = NativeDescriptorHandoffPolicy( - abi=_native_descriptor_handoff_abi(handle_kind, output_projection, descriptor, completed.optional_absent), + abi=_native_descriptor_handoff_abi(handle_kind), rank=int(semantic_type.rank or 0), optional_presence=completed.optional_absent, ) @@ -6363,33 +6363,20 @@ def _native_array_default_handle_policy( ) -def _native_descriptor_handoff_abi( - handle_kind: NativeArrayHandleKind, - output_projection: NativeArrayOutputProjection, - descriptor_kind: str, - optional_absent: bool, -) -> NativeDescriptorHandoffABI: +def _native_descriptor_handoff_abi(handle_kind: NativeArrayHandleKind) -> NativeDescriptorHandoffABI: """Select one descriptor ABI from completed handle/result policy. - A descriptor actual is the Fortran runtime's to build: the binding is - handed one for the call rather than establishing or filling a record of its - own. That holds for a pointer as much as an allocatable, and it is what - lets a callee change an allocation or an association and have the caller's - entity see it. An optional argument is no different when it is present; - its absent branch establishes the placeholder the present flag pairs with, - which is the one descriptor C may legally establish for this attribute - because it has a null base address. + Every descriptor a call receives is the Fortran runtime's to build: the + binding is handed one rather than establishing or filling a record of its + own. That holds for a pointer as much as an allocatable, and for an + optional argument as much as a required one, and it is what lets a callee + change an allocation or an association and have the caller's entity see it. + A result is the one exception, because there is no caller entity yet: the + wrapper owns storage the callee allocates into. """ if handle_kind is NativeArrayHandleKind.OWNED_RESULT_DESCRIPTOR: return NativeDescriptorHandoffABI.OWNED_RESULT_STORAGE - if output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE: - return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - if descriptor_kind in { - NativeArrayDescriptorKind.ALLOCATABLE.value, - NativeArrayDescriptorKind.POINTER.value, - }: - return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - return NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL + return NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR def _native_array_enum(enum_type, value: object, owner_path: str, label: str): diff --git a/prik/policy/models.py b/prik/policy/models.py index 4d718375f..d704a91fb 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -769,7 +769,6 @@ class NativeArrayHandleKind(str, Enum): class NativeDescriptorHandoffABI(str, Enum): """Binding-to-bridge descriptor representation.""" - FACT_PACKED_CALL_LOCAL = "fact_packed_call_local" DIRECT_STANDARD_DESCRIPTOR = "direct_standard_descriptor" OWNED_RESULT_STORAGE = "owned_result_storage" @@ -778,7 +777,6 @@ class NativeArrayDefaultConstruction(str, Enum): """Completed storage path for a runtime-constructed empty descriptor.""" NONE = "none" - FACT_PACKED_EMPTY = "fact_packed_empty" LAZY_OWNED_DESCRIPTOR = "lazy_owned_descriptor" diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 8fff0c64b..aeb391ff9 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -1426,69 +1426,6 @@ def _native_array_descriptor_for_binding( ) -def _native_array_descriptor_argument_for_binding( - value: Any, - *, - descriptor_kind: str, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - optional_absent: bool = False, -) -> tuple[Any, ...]: - """Pack standard descriptor fields for generated CPython binding code.""" - descriptor = _native_array_descriptor_for_binding( - value, - descriptor_kind=descriptor_kind, - expected_dtype=expected_dtype, - expected_rank=expected_rank, - expected_shape=expected_shape, - optional=optional_absent, - ) - if descriptor is None: - if expected_rank is None: - raise ValueError("optional absent native array descriptor arguments require an expected rank") - fields = (None,) * (3 + 3 * int(expected_rank)) - return (*fields, None) - if isinstance(descriptor, _NativeArrayDescriptorHandoff): - descriptor = value._descriptor_record_for_binding() - dimensions = _pointer_descriptor_dimensions(descriptor) - fields = [ - _required_descriptor_int(descriptor, "base_addr"), - _required_descriptor_int(descriptor, "elem_len"), - _required_descriptor_int(descriptor, "rank"), - ] - for index, dimension in enumerate(dimensions): - fields.extend( - [ - _required_descriptor_int(dimension, "lower_bound", field_owner=f"dim[{index}]"), - _required_descriptor_int(dimension, "extent", field_owner=f"dim[{index}]"), - _required_descriptor_int(dimension, "sm", field_owner=f"dim[{index}]"), - ] - ) - if optional_absent: - fields.append(_PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT_ADDRESS) - return tuple(fields) - - -def _native_array_descriptor_argument_for_binding_positional( - value: Any, - descriptor_kind: str, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - optional_absent: bool = False, -) -> tuple[Any, ...]: - """Positional wrapper used by generated CPython binding code.""" - return _native_array_descriptor_argument_for_binding( - value, - descriptor_kind=str(descriptor_kind), - expected_dtype=None if expected_dtype is None else np.dtype(expected_dtype), - expected_rank=None if expected_rank is None else int(expected_rank), - expected_shape=expected_shape, - optional_absent=bool(optional_absent), - ) - - def _native_array_descriptor_handoff_for_binding( value: Any, *, diff --git a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py index b20032092..49c992c12 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py @@ -7,20 +7,27 @@ from prik.runtime.handles import ( AllocatableArray, _bind_contract_native_array_handle, - _native_array_descriptor_argument_for_binding, _native_array_descriptor_handoff_for_binding, ) -def test_fresh_contract_handle_supplies_present_empty_read_only_descriptor_facts(): +def test_fresh_contract_handle_has_no_descriptor_to_hand_over_on_its_own(): + """A handle the caller made owns no descriptor until the wrapper gives it one. + + Nothing rebuilds a descriptor from reported fields any more, so the only + thing such a handle can supply is storage a generated binder attached to + it. Reaching the call without that is a wrapper bug, not a caller error, + so it is refused rather than papered over with an empty descriptor. + """ handle = contracts.Allocatable[contracts.Float64[:]]() - assert _native_array_descriptor_argument_for_binding( - handle, - descriptor_kind="allocatable", - expected_dtype=np.float64, - expected_rank=1, - ) == (0, 8, 1, 0, 0, 8) + with pytest.raises(TypeError, match="requires generated persistent descriptor storage"): + _native_array_descriptor_handoff_for_binding( + handle, + descriptor_kind="allocatable", + expected_dtype=np.float64, + expected_rank=1, + ) def test_contract_default_allocatable_constructor_preserves_dtype_rank_and_empty_state(): diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 448ba850f..6d6e3bfbd 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -209,9 +209,6 @@ def test_native_handle_plans_keep_datatype_specific_state(): # the generated binder allocates and the handle's finalizer releases. assert handle.default_handle.owner_storage_role == f"{argument.owner_path}:default-owner-storage" assert NativeArrayOperation.DESTROY in handle.default_handle.operations - # Fact roles exist only for the fact-packed form; a descriptor the - # runtime built carries its own extents, so none are named. - assert handle.handoff.extent_roles == () assert argument.binding.python_action is PythonBarrierAction.WRAPPER_INSTANCE assert argument.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR @@ -226,7 +223,6 @@ def test_native_handle_plans_keep_datatype_specific_state(): assert replacement.native_array_handle is not None assert replacement.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR assert replacement.native_array_handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE - assert replacement.native_array_handle.handoff.extent_roles == () assert ( replacement.native_array_handle.default_handle.construction is NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR @@ -445,7 +441,6 @@ def test_constant_owned_handle_operations_do_not_emit_unused_descriptor_locals() ("edit", "diagnostic"), [ ("required_presence", "inconsistent-native-descriptor-presence"), - ("projected_facts", "invalid-direct-native-descriptor-roles"), ("owned_storage", "invalid-owned-native-descriptor-roles"), ("default_storage", "inconsistent-default-handle-owner-storage-role"), ("disabled_default", "invalid-disabled-default-handle-policy"), @@ -463,8 +458,6 @@ def test_native_handle_plan_edits_fail_central_validation(edit: str, diagnostic: functions = _functions(plan) if edit == "required_presence": functions["alloc"].arguments[0].native_array_handle.handoff.presence_role = "edited:present" - elif edit == "projected_facts": - functions["replace"].arguments[0].native_array_handle.handoff.extent_roles = ("edited:extent",) elif edit == "owned_storage": functions["make"].results[0].native_array_handle.handoff.owner_storage_role = None elif edit == "default_storage": @@ -488,7 +481,7 @@ def test_native_handle_plan_edits_fail_central_validation(edit: str, diagnostic: elif edit == "default_abi": functions["replace"].arguments[ 0 - ].native_array_handle.handoff.abi = NativeDescriptorHandoffABI.FACT_PACKED_CALL_LOCAL + ].native_array_handle.handoff.abi = NativeDescriptorHandoffABI.OWNED_RESULT_STORAGE elif edit == "operation": functions["pointer"].arguments[0].native_array_handle.operations = () else: diff --git a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py index 182b34371..844e84000 100644 --- a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py +++ b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py @@ -6,8 +6,6 @@ AllocatableArray, PointerArray, _NativeArrayDescriptorHandoff, - _native_array_descriptor_argument_for_binding, - _native_array_descriptor_argument_for_binding_positional, _native_array_descriptor_for_binding, _native_array_descriptor_handoff_for_binding, _native_array_descriptor_handoff_for_binding_positional, @@ -155,115 +153,6 @@ def test_descriptor_binding_helper_rejects_plain_arrays_none_and_wrong_kind(): _native_array_descriptor_for_binding(None, descriptor_kind="coarray", optional=True) -def test_descriptor_argument_abi_packer_returns_required_descriptor_fields(): - descriptor = _handoff(239) - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: descriptor, - }, - to_numpy_policy="unsupported", - ) - - assert _native_array_descriptor_argument_for_binding( - handle, - descriptor_kind="allocatable", - expected_dtype=np.float64, - expected_rank=1, - expected_shape=(2,), - ) == (descriptor.address, 8, 1, 0, 2, 8) - - -def test_descriptor_argument_abi_packer_positional_helper_matches_generated_call_shape(): - descriptor = _handoff(242) - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: descriptor, - }, - to_numpy_policy="unsupported", - ) - - assert _native_array_descriptor_argument_for_binding_positional( - handle, - "allocatable", - "float64", - 1, - None, - False, - ) == (descriptor.address, 8, 1, 0, 2, 8) - assert _native_array_descriptor_argument_for_binding_positional( - None, - "allocatable", - "float64", - 1, - None, - True, - ) == (None, None, None, None, None, None, None) - - -def test_descriptor_argument_abi_packer_maps_optional_presence_and_absence(): - descriptor = _handoff(240) - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - "descriptor": lambda _handle: descriptor, - }, - to_numpy_policy="unsupported", - ) - - *descriptor_fields, presence_token = _native_array_descriptor_argument_for_binding( - handle, - descriptor_kind="pointer", - expected_dtype=np.float64, - expected_rank=1, - optional_absent=True, - ) - assert descriptor_fields == [descriptor.address, 8, 1, 0, 0, 8] - assert presence_token is not None - assert presence_token != descriptor.address - assert _native_array_descriptor_argument_for_binding( - None, - descriptor_kind="pointer", - expected_rank=1, - optional_absent=True, - ) == (None, None, None, None, None, None, None) - - -def test_descriptor_argument_abi_packer_rejects_wrong_kind_and_unsupported_descriptor_kind(): - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: _handoff(241), - }, - to_numpy_policy="unsupported", - ) - - with pytest.raises(TypeError, match="expected pointer native array handle"): - _native_array_descriptor_argument_for_binding(alloc_handle, descriptor_kind="pointer") - with pytest.raises(ValueError, match="unsupported native array descriptor kind"): - _native_array_descriptor_argument_for_binding(None, descriptor_kind="coarray", optional_absent=True) - with pytest.raises(TypeError, match="received ndarray"): - _native_array_descriptor_argument_for_binding(np.ones(1), descriptor_kind="allocatable") - - def test_projected_descriptor_handoff_requires_persistent_standard_descriptor_storage(): owner = object() direct = _NativeArrayDescriptorHandoff(owner) @@ -296,7 +185,8 @@ def test_projected_descriptor_handoff_requires_persistent_standard_descriptor_st ) == (owner,) -def test_owned_standard_descriptor_can_supply_fact_packed_read_only_handoff(): +def test_owned_standard_descriptor_supplies_the_only_read_only_handoff(): + """A handle hands over the descriptor it owns, and nothing else will do.""" owner = object() direct = _NativeArrayDescriptorHandoff(owner) record = { @@ -321,13 +211,6 @@ def test_owned_standard_descriptor_can_supply_fact_packed_read_only_handoff(): to_numpy_policy="unsupported", ) - assert _native_array_descriptor_argument_for_binding( - handle, - descriptor_kind="pointer", - expected_dtype=np.float64, - expected_rank=1, - expected_shape=(2,), - ) == (0x5678, 8, 1, 0, 2, 8) assert _native_array_descriptor_handoff_for_binding( handle, descriptor_kind="pointer", @@ -344,7 +227,9 @@ def test_owned_standard_descriptor_can_supply_fact_packed_read_only_handoff(): True, ) == (None, None) - fact_packed = AllocatableArray( + # A handle that only reports descriptor fields is refused: rebuilding a + # descriptor from them is what C is not allowed to do for an allocatable. + reports_facts_only = AllocatableArray( dtype=np.dtype(np.float64), rank=1, ops={ @@ -356,7 +241,7 @@ def test_owned_standard_descriptor_can_supply_fact_packed_read_only_handoff(): to_numpy_policy="unsupported", ) with pytest.raises(TypeError, match="requires a generated direct descriptor handoff"): - _native_array_descriptor_handoff_for_binding(fact_packed, descriptor_kind="allocatable") + _native_array_descriptor_handoff_for_binding(reports_facts_only, descriptor_kind="allocatable") def test_pointer_c_descriptor_helper_builds_strided_numpy_view_from_decoded_fields(): From 97ff88ff302081f6c543a40f203ecd3d17d3dc93 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 05:58:00 +0100 Subject: [PATCH 15/47] Publish an entry-point table for wrapper-owned descriptor storage A handle the caller creates is given wrapper-owned descriptor storage the first time it is passed to a descriptor argument. That storage lives as long as the handle, so there was no reason for later calls to keep going back to Python: they can read it from C the way a module array's is read. The binder now publishes the storage it attaches as a native entry-point table, and `_bind_contract_native_array_handle` records it on the handle. Reaching it needs no call-scoped window, since nothing else owns it, so the table's entry point is one shared forwarder that hands the descriptor straight to the consumer. Measured on this fixture, passing such a handle to a `real(8), allocatable` dummy went from about 8.8us to about 0.26us per call -- below the 0.46us a plain NumPy array costs, which still has a buffer to validate. The Python packer is now called once per handle for its lifetime rather than once per call; module, field and result handles are unchanged. The table has to name the storage rather than the local that allocated it: the binder clears `owner_descriptor` once its ownership moves to the handle capsule, so a separate non-owning `owner_storage` is captured right after the descriptor is established. Publishing the cleared local instead stored a null owner, which read as an unallocated array and, on a writable dummy, allocated into a null descriptor. Verified on gfortran and ifx: the descriptor matrix and the optional cases are unchanged, a callee's allocation still reaches such a handle, and a handle bound by one entrypoint works through another. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 9 +++++++ prik/codegen/c/binding.py | 31 ++++++++++++++++++++-- prik/runtime/handles.py | 8 ++++++ prik/runtime/native_support/prik_binding.h | 18 +++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e6c19d4f..ef4b3a3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ release tags add a leading `v` to the package version. such a dummy on every compiler now, including the bounds a shifted allocatable carries. +- A handle you create yourself now reaches a native call as fast as one that + came from a module variable. Such a handle is given wrapper-owned descriptor + storage the first time it is passed, and it now publishes that storage as an + entry-point table, so every later call reads it from C instead of packing the + argument through Python. Passing one to a `real(8), allocatable` dummy cost + about 8.8us per call and now costs about 0.26us -- faster than passing a + plain NumPy array, which still has a buffer to check. The first call is + unchanged: it still binds the storage through Python, once per handle. + - **Fixed:** an `optional` `allocatable` or `pointer` dummy no longer fails on Intel `ifx`. Passing a handle to one raised `Unable to establish native descriptor for argument ...: 2` there while working on gfortran: a present diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 511953760..e3360618b 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -4936,12 +4936,17 @@ def _default_native_array_binder_function( nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ CDeclaration("handle_obj", "PyObject *"), CDeclaration("owner_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")), + # `owner_descriptor` is cleared once its ownership moves to the + # handle capsule, so the table's non-owning reference to the same + # storage is kept separately. + CDeclaration("owner_storage", "void *", CodeExpression("NULL")), CDeclaration("owner_status", "int", CodeExpression("CFI_SUCCESS")), CDeclaration("ops", "PyObject *", CodeExpression("NULL")), CDeclaration("operation", "PyObject *", CodeExpression("NULL")), CDeclaration("owner_obj", "PyObject *", CodeExpression("NULL")), CDeclaration("runtime", "PyObject *", CodeExpression("NULL")), CDeclaration("helper", "PyObject *", CodeExpression("NULL")), + CDeclaration("native_ops", "PyObject *", CodeExpression("NULL")), CDeclaration("result", "PyObject *", CodeExpression("NULL")), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &handle_obj)) return NULL')), CExpressionStatement( @@ -4974,6 +4979,7 @@ def _default_native_array_binder_function( CReturn(CodeExpression("NULL")), ), ), + CExpressionStatement(CodeExpression("owner_storage = (void *)owner_descriptor")), CExpressionStatement(CodeExpression("ops = PyDict_New()")), CIf( CodeExpression("ops == NULL"), @@ -5047,13 +5053,34 @@ def _default_native_array_binder_function( CReturn(CodeExpression("NULL")), ), ), + # The attached storage is the wrapper's own, so it can be + # published as an entry-point table. Every later call then + # reaches the descriptor from C instead of coming back here. CExpressionStatement( CodeExpression( - f'result = PyObject_CallFunction(helper, "OssiOOszO", handle_obj, ' + f"native_ops = prik_native_array_ops_capsule_new(" + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"{cfi_type}, {elem_len}, owner_storage, " + f"prik_native_array_owned_scoped_descriptor)" + ) + ), + CIf( + CodeExpression("native_ops == NULL"), + body=( + CExpressionStatement(CodeExpression("Py_DECREF(helper)")), + CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), + CExpressionStatement(CodeExpression("Py_DECREF(ops)")), + CReturn(CodeExpression("NULL")), + ), + ), + CExpressionStatement( + CodeExpression( + f'result = PyObject_CallFunction(helper, "OssiOOszOO", handle_obj, ' f'"{handle.descriptor_kind.value}", "{dtype}", {handle.array.rank}, ops, owner_obj, ' - f'"{default.descriptor_ownership.value}", {exposure}, Py_None)' + f'"{default.descriptor_ownership.value}", {exposure}, Py_None, native_ops)' ) ), + CExpressionStatement(CodeExpression("Py_DECREF(native_ops)")), CExpressionStatement(CodeExpression("Py_DECREF(helper)")), CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), CExpressionStatement(CodeExpression("Py_DECREF(ops)")), diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index aeb391ff9..805172325 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -289,6 +289,7 @@ def _bind_contract_native_array_handle( descriptor_ownership: str, to_numpy_policy: str | None, generation: int | None = None, + native_ops: Any = None, ) -> None: """Attach generated persistent descriptor storage to a contract handle. @@ -296,6 +297,9 @@ def _bind_contract_native_array_handle( does not project a result. Such an argument gives the handle a descriptor to hand over, but it does not define what the handle exposes, so the handle keeps the exposure it was created with. + + ``native_ops`` is the entry-point table for the attached storage, which + subsequent calls read directly from C. """ if not isinstance(handle, NativeArrayHandleBase) or not handle._contract_default: raise TypeError("generated descriptor storage can attach only to a fresh contract handle") @@ -325,6 +329,10 @@ def _bind_contract_native_array_handle( handle._descriptor_ownership = generated._descriptor_ownership handle._to_numpy_policy = generated._to_numpy_policy handle._generation = generated._generation + # The storage just attached is the wrapper's own and lives as long as the + # handle, so the handle can publish it the way a module array publishes + # its entity. Later calls then reach it from C without coming back here. + handle._native_ops = native_ops handle._contract_default = False generated._closed = True if pending_pointer_descriptor is not None: diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index a4f9ec4c7..2bde0f280 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -105,6 +105,24 @@ typedef struct { void (*scoped_descriptor)(void *owner, prik_native_array_descriptor_fn consumer, void *context); } prik_native_array_ops; +/* + * Hand over a descriptor the wrapper itself owns. + * + * A handle the caller created has no native entity behind it: the binding + * allocated its descriptor when the handle was first bound and keeps it for + * the handle's lifetime. There is no call-scoped window to stay inside, so + * the consumer runs on that storage directly. Publishing it through the same + * table lets such a handle reach a call the way a module array does, without + * a Python round trip per call. + */ +static inline void prik_native_array_owned_scoped_descriptor( + void *owner, + prik_native_array_descriptor_fn consumer, + void *context) +{ + consumer(owner, context); +} + /* Free the per-handle entry-point table a capsule owns. */ static inline void prik_native_array_ops_capsule_destructor(PyObject *capsule) { From f6d29bff5c0aeb13c0b8e94c1e9418b14f5830bc Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 06:40:02 +0100 Subject: [PATCH 16/47] Build an allocatable handle's NumPy view where its descriptor is Reading a handle went through the descriptor twice. The generated extraction had the descriptor in hand and reported it as base address, element length and per-axis bounds; the runtime then decoded those fields back into a view, on every call. The view is now built in the generated operation, from the descriptor it already holds. Measured on this fixture, `to_numpy()` on an allocatable handle went from about 12.9us to about 2.7us per call. The view keeps its handle alive rather than only the record describing its storage: the handle releases that storage when it is finalized, so a view that outlived it would otherwise read freed memory. Retention is applied exactly when the view borrows what the owner record holds, which leaves an operation returning an array of its own handed back untouched. Two forms keep reporting fields. A pointer describes its target to another pointer through them, and a view cannot carry the Fortran lower bounds an association preserves; a character element width is only known at runtime. An allocatable never associates, so its extraction returns the view. Verified on gfortran and ifx: values, strides and write-through are unchanged, a view outlives the handle it came from, and the descriptor matrix and optional cases are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 10 +++++ prik/codegen/c/binding.py | 79 ++++++++++++++++++++++++++++++++++++++- prik/runtime/handles.py | 22 +++++++---- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4b3a3af..d52f32f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,16 @@ release tags add a leading `v` to the package version. such a dummy on every compiler now, including the bounds a shifted allocatable carries. +- Reading an allocatable handle with `to_numpy()` no longer round-trips its + descriptor through Python. The generated extraction reported the descriptor + as base address, element length and per-axis bounds, and the runtime decoded + those fields back into a NumPy view on every call; it now builds the view + where the descriptor already is. Reading a handle cost about 12.9us per call + and now costs about 2.7us. The view still keeps its handle alive, so it stays + valid after the handle is dropped. A `pointer` handle is unchanged: it + describes its target to another pointer through those same fields, and a view + cannot carry the Fortran lower bounds an association preserves. + - A handle you create yourself now reaches a native call as fast as one that came from a module variable. Such a handle is given wrapper-owned descriptor storage the first time it is passed, and it now publishes that storage as an diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index e3360618b..244937a3a 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -5130,7 +5130,7 @@ def _owned_native_array_operation_handler(self, operation: NativeArrayOperation) """Return one directly named operation lowerer.""" handlers = { NativeArrayOperation.SHAPE: self._owned_native_array_shape_body, - NativeArrayOperation.TO_NUMPY: self._owned_native_array_descriptor_record_body, + NativeArrayOperation.TO_NUMPY: self._owned_native_array_to_numpy_body, NativeArrayOperation.ELEMENT_LENGTH: self._owned_native_array_element_length_body, NativeArrayOperation.ARRAY_ACTUAL: self._owned_native_array_actual_body, NativeArrayOperation.DESCRIPTOR: self._owned_native_array_descriptor_body, @@ -5167,6 +5167,83 @@ def _owned_native_array_associate_body( CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) + def _owned_native_array_to_numpy_body( + self, + result: ResultPlan, + ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: + """Build the NumPy view over one owned descriptor's storage. + + The descriptor is already here, so the view is built from it directly + rather than reported as fields for the runtime to decode back. The + capsule owning the descriptor becomes the array's base, so the storage + outlives any view taken of it. + """ + handle = result.native_array_handle + if handle is None or handle.array.rank is None: + raise ValueError(f"Owned result {result.owner_path!r} has no descriptor rank") + # A pointer describes its target to another pointer through these + # fields, and a view cannot carry the Fortran lower bounds an + # association preserves. A character element width is only known at + # runtime. Both keep reporting fields; an allocatable never associates, + # so its extraction hands back the view itself. + if ( + result.datatype_family is DatatypeFamily.STRING + or handle.descriptor_kind is not NativeArrayDescriptorKind.ALLOCATABLE + ): + return self._native_array_descriptor_record_nodes(handle.array.rank, "owner_descriptor") + rank = handle.array.rank + scalar = PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name) + nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ + CDeclaration(f"view_dimensions[{rank}]", "npy_intp"), + CDeclaration(f"view_strides[{rank}]", "npy_intp"), + CDeclaration("view", "PyObject *", CodeExpression("NULL")), + CComment("An unallocated or disassociated descriptor exposes no storage."), + CIf( + CodeExpression("owner_descriptor->base_addr == NULL"), + body=(CReturn(CodeExpression("Py_NewRef(Py_None)")),), + ), + ] + for axis in range(rank): + nodes.extend( + ( + # A compiler may report an empty dimension as extent -1. + CExpressionStatement( + CodeExpression( + f"view_dimensions[{axis}] = (npy_intp)(owner_descriptor->dim[{axis}].extent == -1 " + f"? 0 : owner_descriptor->dim[{axis}].extent)" + ) + ), + CExpressionStatement( + CodeExpression(f"view_strides[{axis}] = (npy_intp)owner_descriptor->dim[{axis}].sm") + ), + ) + ) + nodes.extend( + ( + CExpressionStatement( + CodeExpression( + f"view = PyArray_New(&PyArray_Type, {rank}, view_dimensions, " + f"{scalar.numpy_type_macro}, view_strides, owner_descriptor->base_addr, 0, " + f"NPY_ARRAY_WRITEABLE, NULL)" + ) + ), + CIf(CodeExpression("view == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CComment("The view borrows the descriptor's storage, so it keeps the capsule"), + CComment("that owns the descriptor alive for as long as the view exists."), + CExpressionStatement(CodeExpression("Py_INCREF(owner_obj)")), + CIf( + CodeExpression("PyArray_SetBaseObject((PyArrayObject *)view, owner_obj) < 0"), + body=( + CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), + CExpressionStatement(CodeExpression("Py_DECREF(view)")), + CReturn(CodeExpression("NULL")), + ), + ), + CReturn(CodeExpression("view")), + ) + ) + return tuple(nodes) + def _owned_native_array_descriptor_record_body( self, result: ResultPlan, diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 805172325..e713dd993 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -845,30 +845,36 @@ def to_numpy(self) -> Any: """Return a live view of current native storage, or ``None``.""" if self._to_numpy_absent_state(): return None - if self.to_numpy_policy == "unsupported": + policy = self._to_numpy_policy + if policy == "unsupported": raise NotImplementedError( f"{self.descriptor_kind} handle to_numpy extraction is unsupported by completed policy" ) - if ( - self.to_numpy_policy == "contiguous_view" - and "contiguous" in self._ops - and not bool(self._call_op("contiguous")) - ): + if policy == "contiguous_view" and "contiguous" in self._ops and not bool(self._call_op("contiguous")): raise ValueError(f"{self.descriptor_kind} handle to_numpy target must be contiguous") value = self._call_op("to_numpy") if value is None: raise TypeError( f"{self.descriptor_kind} handle to_numpy operation returned None for present descriptor state" ) - if _is_pointer_descriptor_record(value): + # A generated operation builds the view itself; only an operation that + # reports descriptor fields needs decoding here. + if not isinstance(value, np.ndarray) and _is_pointer_descriptor_record(value): value = _numpy_view_from_pointer_c_descriptor(value, dtype=self.dtype, expected_rank=self.rank) if value is None: raise TypeError( f"{self.descriptor_kind} handle extraction returned a null descriptor for present descriptor state" ) value = _retain_numpy_owner(value, self) + elif isinstance(value, np.ndarray) and value.base is not None and value.base is self._owner: + # A generated operation builds its view over the storage the owner + # record holds, and this handle releases that storage when it is + # finalized, so the view has to keep the handle alive too. An + # operation returning an array of its own owns its memory already + # and is handed back untouched. + value = _retain_numpy_owner(value, self) self._validate_numpy_result(value) - if self.to_numpy_policy == "contiguous_view": + if policy == "contiguous_view": self._validate_contiguous_numpy_result(value) return value From eb48fda510bbf293fb3fc328adb961c34d01f843 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 07:31:42 +0100 Subject: [PATCH 17/47] Read an array handle's storage from its table for an ordinary dummy An ordinary array dummy takes an address and extents. When a handle was passed to one, the binding asked the runtime for those facts an operation at a time -- nine round trips per call, the shape among them computed twice -- even though the handle's table already names the storage. The binding now reads the descriptor directly when a handle publishes a table: a generated reader copies the base address, the extents and a contiguity flag out while the runtime holds the descriptor open, and the call proceeds on the address it names. An ordinary call cannot change the allocation behind the dummy, so reading it once before the call is enough. Measured on this fixture, passing a handle to a `real(8) :: v(n)` dummy went from about 17.5us to about 0.27us per call, below the 0.55us a NumPy array costs, which still has a buffer to validate. The kind is deliberately not compared when reading the table: an allocatable and a pointer are equally acceptable at an ordinary dummy. Everything that decides whether the storage fits -- rank, element type, element size, fixed extents and contiguity -- still is, and each diagnostic reuses the runtime's own wording so a handle reads the same whether or not it publishes a table. The reader dereferences a descriptor, so it is emitted only where the module already includes the Fortran interop header. A C-only wrapper never includes it and keeps the shared binder, as do flattened storage and the runtime rank, stride and itemsize shapes. Verified on gfortran and ifx: values, the descriptor matrix, contiguity rejection for a strided pointer target, and the unallocated, unassociated and shape-mismatch diagnostics are all unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 12 + prik/codegen/c/binding.py | 257 ++++++++++++++++++++- prik/runtime/native_support/prik_binding.h | 35 +++ 3 files changed, 299 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52f32f2b..bd1035cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,18 @@ release tags add a leading `v` to the package version. such a dummy on every compiler now, including the bounds a shifted allocatable carries. +- Passing an array handle to an ordinary array dummy no longer goes through + Python on every call. Such a dummy takes an address and extents, and the + runtime was asked for them one operation at a time -- nine round trips per + call, with the shape computed twice. When the handle publishes native entry + points, the address and extents are now read from its descriptor in the + binding. Passing a module array to a `real(8) :: v(n)` dummy cost about + 17.5us per call and now costs about 0.27us. Handles that report state + through supplied operations, flattened storage, and runtime rank, stride or + itemsize shapes keep the previous route, and every diagnostic is unchanged: + an unallocated handle, an unassociated pointer, a noncontiguous pointer + target and a shape mismatch report exactly as before. + - Reading an allocatable handle with `to_numpy()` no longer round-trips its descriptor through Python. The generated extraction reported the descriptor as base address, element length and per-axis bounds, and the runtime decoded diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 244937a3a..e785ab781 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -42,6 +42,7 @@ NativeArrayDefaultConstruction, NativeArrayOperation, NativeArrayOutputProjection, + NativeArraySourceKind, NativeDescriptorHandoffABI, EntrypointProjectionAction, EntrypointPassingConvention, @@ -118,6 +119,7 @@ ) from prik.codegen.primitive_scalar_types import NativeCArrayStorageRegistry, PrimitiveScalarTypeRegistry from prik.codegen.visitor import ClassVisitor +from prik.policy.native_array_handles import NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER def _descriptor_binding_noun(handle: NativeArrayHandlePlan) -> str: @@ -165,6 +167,9 @@ class _CFunctionContext: python_results: dict[str, str] role_values: dict[str, str] inverted_descriptor: str | None = None + # The function whose lowering this context serves, so an argument's nodes + # can name helpers emitted once per function at module scope. + function: FunctionPlan | None = None @dataclass(frozen=True) @@ -267,6 +272,10 @@ def _require_argument_supported(self, argument: ArgumentTransferPlan) -> None: return self._require_backend_type_supported(argument.semantic_type_name, argument.datatype_family) + # Reading a descriptor needs the Fortran interop header, which a module + # only includes when its plan calls for one. A C-only wrapper never does. + _reads_native_descriptors = False + def _visit_ModulePlan(self, plan: ModulePlan) -> tuple[CModule, CHeader]: """Build the matching C implementation module and public header. @@ -282,6 +291,9 @@ def binding_module(self, plan: ModulePlan) -> CModule: then assembles module support, runtime helpers, wrappers, and module initialization in emitted dependency order. """ + # Reading a descriptor needs the Fortran interop header, and a module + # only includes it when its plan calls for one; a C-only wrapper does not. + self._reads_native_descriptors = NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER in plan.required_headers # Stage 1: index planner-owned cross-language operations before any lowering. self._generated_support_procedure_entrypoints = { (procedure.owner_path, procedure.role): procedure for procedure in plan.entrypoint.support_procedures @@ -4188,6 +4200,7 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction for _function, argument in self._default_native_array_arguments(plan) ), *((self._native_array_forward_descriptor_function(),) if self._emits_native_array_ops(plan) else ()), + *self._array_actual_reader_functions(plan), *self._inverted_descriptor_consumer_functions(plan), *( callback @@ -7318,11 +7331,16 @@ def _outlined_array_bind_nodes( CExpressionStatement(CodeExpression(f"{prefix}_bind_fixed[{axis}] = {value}")) for axis, value in enumerate(fixed) ), - CExpressionStatement( - CodeExpression( - f"if (prik_bind_array({names.object_name}, {selectors}, " - f"{prefix}_bind_fixed, &{names.value_name}, {prefix}_bind_extents) < 0) return NULL" - ) + *self._array_actual_table_nodes( + plan, + context, + names, + CExpressionStatement( + CodeExpression( + f"if (prik_bind_array({names.object_name}, {selectors}, " + f"{prefix}_bind_fixed, &{names.value_name}, {prefix}_bind_extents) < 0) return NULL" + ) + ), ), *( CExpressionStatement(CodeExpression(f"{names.extent_names[axis]} = {prefix}_bind_extents[{axis}]")) @@ -7330,6 +7348,145 @@ def _outlined_array_bind_nodes( ), ) + def _array_actual_table_nodes( + self, + plan: ArgumentTransferPlan, + context: _CFunctionContext, + names: _CArgumentNames, + fallback: CExpressionStatement, + ) -> tuple: + """Take an array handle's storage from its table when it publishes one. + + A handle standing for native storage names it through its table, so the + address and extents an ordinary dummy needs are read here rather than + asked for through the runtime one operation at a time. Anything else -- + an ndarray, or a handle with no table -- takes the shared binder. + """ + function = context.function + if not self._reads_native_descriptors or function is None or not any( + argument.owner_path == plan.owner_path + for _function, argument in self._array_actual_handle_arguments_for(function, context) + ): + return (fallback,) + prefix = names.value_name + rank = plan.array.rank + record = self._array_actual_reader_record_name(function, plan) + reader = self._array_actual_reader_name(function, plan) + capsule = f"{prefix}_actual_capsule" + table = f"{prefix}_actual_ops" + found = f"{prefix}_actual_found" + actual = plan.native_array_actual + contiguous_check: tuple = () + if actual is not None and (actual.require_contiguous or plan.array.contiguous is True): + contiguous_check = ( + CIf( + CodeExpression(f"!{found}.contiguous"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_SetString(PyExc_ValueError, "pointer handle target is noncontiguous ' + 'and cannot use the pointer/shape array-actual handoff")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + ) + checks: list = [ + CComment("The same condition the runtime reports, worded the same way,"), + CComment("so a handle reads alike whether or not it publishes a table."), + CIf( + CodeExpression(f"!{found}.present"), + body=( + CExpressionStatement( + CodeExpression( + f"PyErr_SetString(PyExc_ValueError, " + f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + f'? "pointer handle is unassociated and cannot be passed as an array actual" ' + f': "allocatable handle is unallocated and cannot be passed as an array actual")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + *contiguous_check, + ] + for axis in range(rank): + checks.append( + CIf( + CodeExpression( + f"{prefix}_bind_fixed[{axis}] >= 0 && {found}.extents[{axis}] " + f"!= (int64_t){prefix}_bind_fixed[{axis}]" + ), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "Argument {plan.binding.python_name} ' + f'has incompatible shape at axis %d", {axis})' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ) + ) + checks.extend( + ( + CExpressionStatement(CodeExpression(f"{names.value_name} = {found}.data")), + *( + CExpressionStatement(CodeExpression(f"{prefix}_bind_extents[{axis}] = {found}.extents[{axis}]")) + for axis in range(rank) + ), + ) + ) + return ( + CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CDeclaration(found, record), + CExpressionStatement( + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + ), + CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), + CIf( + CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), + body=( + CExpressionStatement( + CodeExpression( + f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, {rank}, " + f"{self._native_array_cfi_type(plan)}, " + f"{self._native_array_expected_element_size(plan)})" + ) + ), + CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), + CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CExpressionStatement( + CodeExpression(f"{table}->scoped_descriptor({table}->owner, {reader}, &{found})") + ), + *checks, + ), + else_body=( + CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), + fallback, + ), + ), + ) + + def _array_actual_handle_arguments_for(self, function: FunctionPlan, context: _CFunctionContext): + """Return this function's array arguments an array handle may be passed to.""" + for argument in function.arguments: + actual = argument.native_array_actual + if actual is None or argument.array is None or argument.array.rank is None: + continue + if not { + NativeArraySourceKind.ALLOCATABLE_HANDLE, + NativeArraySourceKind.POINTER_HANDLE, + }.intersection(actual.accepted_sources): + continue + if self._outlined_array_bind_fixed_extents(argument, context) is None: + continue + if actual.flatten_storage or argument.array.flatten_python_storage: + continue + yield function, argument + def _outlined_array_bind_fixed_extents( self, plan: ArgumentTransferPlan, @@ -9780,6 +9937,95 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) ), ) + def _array_actual_reader_name(self, function: FunctionPlan, argument: ArgumentTransferPlan) -> str: + """Return the reader that copies one handle's storage out of its descriptor.""" + owner = re.sub(r"\W", "_", argument.owner_path).casefold() + return f"prik_read_array_actual_{owner}" + + def _array_actual_reader_record_name(self, function: FunctionPlan, argument: ArgumentTransferPlan) -> str: + """Return the record one array-actual reader fills.""" + return f"{self._array_actual_reader_name(function, argument)}_result" + + def _array_actual_handle_arguments(self, plan: ModulePlan): + """Return ordinary array arguments an array handle may be passed to.""" + for function in self._functions(plan): + yield from self._array_actual_handle_arguments_for(function, self._function_context(function)) + + def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: + """Emit the record and reader for each array dummy a handle may reach. + + An ordinary array dummy takes an address and extents, not a descriptor, + and an ordinary call cannot change the allocation behind them. The + storage is therefore read out of the descriptor once, while the runtime + holds it open, and the call proceeds on the address it names. + """ + nodes: list = [] + seen: set[str] = set() + if not self._reads_native_descriptors: + return () + for function, argument in self._array_actual_handle_arguments(plan): + record = self._array_actual_reader_record_name(function, argument) + if record in seen: + continue + seen.add(record) + rank = argument.array.rank + nodes.append( + CStructDefinition( + record, + ( + CParameter("data", "void *"), + CParameter(f"extents[{rank}]", "int64_t"), + CParameter("contiguous", "int"), + CParameter("present", "int"), + ), + ) + ) + body: list = [ + CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), + CDeclaration("out", f"{record} *", CodeExpression(f"({record} *)context")), + CDeclaration("expected", "CFI_index_t", CodeExpression("0")), + CExpressionStatement(CodeExpression("out->present = 0")), + CExpressionStatement(CodeExpression("out->contiguous = 1")), + CComment("Unallocated or disassociated storage has no address to pass."), + CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("expected = (CFI_index_t)source->elem_len")), + ] + for axis in range(rank): + body.extend( + ( + CExpressionStatement( + CodeExpression(f"out->extents[{axis}] = (int64_t)source->dim[{axis}].extent") + ), + CIf( + CodeExpression(f"source->dim[{axis}].sm != expected"), + body=(CExpressionStatement(CodeExpression("out->contiguous = 0")),), + ), + CExpressionStatement( + CodeExpression(f"expected *= (CFI_index_t)source->dim[{axis}].extent") + ), + ) + ) + body.extend( + ( + CExpressionStatement(CodeExpression("out->data = source->base_addr")), + CExpressionStatement(CodeExpression("out->present = 1")), + ) + ) + nodes.append( + CFunction( + self._array_actual_reader_name(function, argument), + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + body=tuple(body), + doc=( + "Copy one handle's storage out of the descriptor the runtime opened.", + "An ordinary array dummy receives that address and its extents.", + ), + ) + ) + return tuple(nodes) + def _inverted_descriptor_consumer_functions(self, plan: ModulePlan) -> tuple: """Emit the record and consumer for each entrypoint called inside one.""" nodes: list = [] @@ -10891,6 +11137,7 @@ def _function_context(self, plan: FunctionPlan) -> _CFunctionContext: python_results, role_values, inverted.owner_path if inverted is not None else None, + plan, ) def _argument_contexts(self, plan: FunctionPlan) -> dict[str, _CArgumentNames]: diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 2bde0f280..c8320ab12 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -178,6 +178,41 @@ static inline PyObject *prik_native_array_ops_capsule_new( } /* Decode one ops capsule, rejecting a record this extension cannot read. */ +/* + * Read a table for an ordinary array actual. + * + * An ordinary array dummy takes the storage behind a handle, not the handle's + * descriptor kind: an allocatable and a pointer are equally acceptable there, + * so the kind is not compared. Everything that decides whether the storage + * matches the dummy -- rank, element type and element size -- still is. + */ +static inline prik_native_array_ops *prik_native_array_ops_actual_from_capsule( + PyObject *capsule, + uint32_t expected_rank, + int expected_cfi_type, + size_t expected_element_size) +{ + prik_native_array_ops *ops; + + ops = (prik_native_array_ops *)PyCapsule_GetPointer(capsule, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME); + if (ops == NULL) { + return NULL; + } + if (ops->magic != PRIK_NATIVE_ARRAY_OPS_MAGIC + || ops->abi_version != PRIK_NATIVE_ARRAY_OPS_ABI_VERSION + || ops->struct_size != (uint32_t)sizeof(*ops) + || ops->scoped_descriptor == NULL) { + PyErr_SetString(PyExc_TypeError, "incompatible prik native array ops record"); + return NULL; + } + if (ops->rank != expected_rank || ops->cfi_type != expected_cfi_type + || ops->element_size != expected_element_size) { + PyErr_SetString(PyExc_TypeError, "native array handle does not match the declared dummy argument"); + return NULL; + } + return ops; +} + static inline prik_native_array_ops *prik_native_array_ops_from_capsule( PyObject *capsule, uint32_t expected_descriptor_kind, From 7ec432ee7195c3e9fa02b9cc7239e6eb24139a4f Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 13:27:44 +0100 Subject: [PATCH 18/47] Normalize an empty dimension when a handle reaches an ordinary dummy An allocated array of size zero stopped reaching an ordinary array dummy: it was rejected as a shape mismatch where it previously passed. gfortran describes an empty dimension with an extent of -1 and ifx with 0, and the reader added for the array fast path passed the raw value through as the extent the dummy receives. Every other path already normalizes that sentinel -- the runtime does when it decodes descriptor fields, and the generated NumPy view does -- so the reader now does too, and the contiguity walk multiplies the normalized extent rather than the raw one. The suite passed with the regression present because nothing passed a zero-sized handle to an ordinary array dummy. A test now does, and it fails without this change. Verified on gfortran and ifx: a zero-sized handle sums to zero through an ordinary dummy on both, and a three-element one is unaffected. Confirmed separately on both compilers that a null base address agrees with Fortran's own allocated and associated inquiries in every state, empty included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 8 ++- .../end_to_end/test_allocatable_handles.py | 65 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index e785ab781..c2cdc8ea2 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -9993,15 +9993,19 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: for axis in range(rank): body.extend( ( + # A compiler may report an empty dimension as extent -1. CExpressionStatement( - CodeExpression(f"out->extents[{axis}] = (int64_t)source->dim[{axis}].extent") + CodeExpression( + f"out->extents[{axis}] = (int64_t)(source->dim[{axis}].extent == -1 " + f"? 0 : source->dim[{axis}].extent)" + ) ), CIf( CodeExpression(f"source->dim[{axis}].sm != expected"), body=(CExpressionStatement(CodeExpression("out->contiguous = 0")),), ), CExpressionStatement( - CodeExpression(f"expected *= (CFI_index_t)source->dim[{axis}].extent") + CodeExpression(f"expected *= (CFI_index_t)out->extents[{axis}]") ), ) ) diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 7a43c0f5f..e8a31bf79 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -583,3 +583,68 @@ def test_a_writable_allocatable_dummy_reaches_the_callers_entity(tmp_path: Path) namespace.grow(owned) assert owned.shape == (6,) assert owned.to_numpy().tolist() == [9.0] * 6 + + +EMPTY_ACTUAL_SOURCE = """\ +module fallocatable_empty_actual_f90 + implicit none + +contains + + subroutine fill_empty(values) + real(8), allocatable, intent(inout) :: values(:) + + if (allocated(values)) deallocate(values) + allocate(values(0)) + end subroutine fill_empty + + subroutine fill_three(values) + real(8), allocatable, intent(inout) :: values(:) + + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = [1.0_8, 2.0_8, 3.0_8] + end subroutine fill_three + + ! An ordinary explicit-shape dummy: it receives an address and an extent. + function total(values, n) result(sum_values) + integer, intent(in) :: n + real(8), intent(in) :: values(n) + real(8) :: sum_values + + sum_values = sum(values) + end function total + +end module fallocatable_empty_actual_f90 +""" + + +def test_zero_sized_allocatable_handle_reaches_an_ordinary_array_dummy(tmp_path: Path): + """An allocated but empty array is present, and its extent is zero. + + A compiler may describe an empty dimension with an extent of -1, so the + extent an ordinary dummy receives has to be normalised. Passing the raw + value makes a zero-sized actual look like a shape mismatch. + """ + build_dir = tmp_path / "build" + build_dir.mkdir(parents=True) + module = _build_text_and_import( + EMPTY_ACTUAL_SOURCE, + "fallocatable_empty_actual_f90.f90", + build_dir, + { + "bind_c_fallocatable_empty_actual_f90_wrapper.f90", + "fallocatable_empty_actual_f90_wrapper.c", + "fallocatable_empty_actual_f90_wrapper.h", + }, + ) + + handle = Allocatable[Float64[:]]() + module.fill_empty(handle) + assert handle.allocated is True + assert handle.shape == (0,) + assert module.total(handle, np.int32(0)) == np.float64(0.0) + + module.fill_three(handle) + assert handle.shape == (3,) + assert module.total(handle, np.int32(3)) == np.float64(6.0) From 00e9bf63d237d41ed249b2003ada1bd5755b672a Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 14:19:14 +0100 Subject: [PATCH 19/47] Answer an owned handle's inquiries from the descriptor it holds Reading a handle asked two questions where the answer to both was in front of it. `to_numpy()` and `shape` each queried allocation state through one generated operation and then the value through another, and then re-checked what the generated code had already built from the declared type. A generated inquiry over storage the handle owns reads its descriptor directly, so it can report an absent state as None rather than being asked first. The shape inquiry now does; the extraction already did. On such a handle `to_numpy()` went from about 2.7us to about 1.3us and `shape` from about 2.9us to about 1.3us. The route is taken only when the handle both publishes native entry points and owns its descriptor. Publishing alone is not enough: a module or field handle publishes one too, but reaches its entity through the compiler's own inquiries, which say nothing about whether the entity is there. Gating on publication alone made a disassociated pointer report a shape of zero instead of none. `_call_op` is unchanged and still dispatches every operation, including all mutation, for every handle. This route sits beside it. Verified on gfortran and ifx: an unbound handle, an allocated empty one, an allocated one, a deallocated one and a module handle all report the same shape and extraction as before, and a view still outlives the handle it came from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 10 +++++++ prik/codegen/c/binding.py | 20 +++++++++----- prik/runtime/handles.py | 57 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1035cbc..c95b0a247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,16 @@ release tags add a leading `v` to the package version. such a dummy on every compiler now, including the bounds a shifted allocatable carries. +- Reading a handle you created is faster again. Its generated inquiries read + the descriptor they are given, so they now report absent storage themselves + instead of the runtime asking a separate question first, and a result the + generated code built from the declared type is no longer re-checked. On such + a handle `to_numpy()` went from about 2.7us to about 1.3us and `shape` from + about 2.9us to about 1.3us. A handle standing for a module variable or a + derived-type field reaches its entity through the compiler's own inquiries, + which say nothing about whether the entity is there, so it keeps asking and + is unchanged. + - Passing an array handle to an ordinary array dummy no longer goes through Python on every call. Such a dummy takes an address and extents, and the runtime was asked for them one operation at a time -- nine round trips per diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index c2cdc8ea2..966093dfc 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -5280,6 +5280,12 @@ def _owned_native_array_shape_body( dimensions = tuple(f"extent_{axis}" for axis in range(handle.array.rank)) return ( *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in dimensions), + CComment("Storage that is not there has no shape, and the compiler's"), + CComment("inquiry has nothing to answer about, so report it here."), + CIf( + CodeExpression("owner_descriptor->base_addr == NULL"), + body=(CReturn(CodeExpression("Py_NewRef(Py_None)")),), + ), CExpressionStatement( CodeExpression( f"{self._owned_native_array_bridge_operation_name(result, NativeArrayOperation.SHAPE)}" @@ -7363,9 +7369,13 @@ def _array_actual_table_nodes( an ndarray, or a handle with no table -- takes the shared binder. """ function = context.function - if not self._reads_native_descriptors or function is None or not any( - argument.owner_path == plan.owner_path - for _function, argument in self._array_actual_handle_arguments_for(function, context) + if ( + not self._reads_native_descriptors + or function is None + or not any( + argument.owner_path == plan.owner_path + for _function, argument in self._array_actual_handle_arguments_for(function, context) + ) ): return (fallback,) prefix = names.value_name @@ -10004,9 +10014,7 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: CodeExpression(f"source->dim[{axis}].sm != expected"), body=(CExpressionStatement(CodeExpression("out->contiguous = 0")),), ), - CExpressionStatement( - CodeExpression(f"expected *= (CFI_index_t)out->extents[{axis}]") - ), + CExpressionStatement(CodeExpression(f"expected *= (CFI_index_t)out->extents[{axis}]")), ) ) body.extend( diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index e713dd993..d8bd36da8 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -20,6 +20,9 @@ # Only a handle that reports its own descriptor can carry a declared lower # bound; one reduced to a bare address has none to report. _UNKNOWN_DESCRIPTOR_LOWER_BOUND = 0 +# Returned when an extraction reports descriptor fields rather than a view, so +# the caller falls back to decoding them. +_EXTRACTION_UNAVAILABLE = object() class _OwnerRetainedNDArray(np.ndarray): @@ -643,12 +646,38 @@ def _deferred_character_dtype(self) -> np.dtype: return np.dtype(f"S{length}") raise TypeError("deferred character handle cannot resolve its runtime element length") + @property + def _reads_its_own_descriptor(self) -> bool: + """Report whether this handle's inquiries read a descriptor it owns. + + A generated handle over wrapper-owned storage answers from the + descriptor in front of it, absence included. A borrowed one reaches + its entity through the compiler's own inquiries, which say nothing + about whether the entity is there, and a handle built from supplied + operations makes no promise at all. + """ + return self._native_ops is not None and self._descriptor_ownership == "owned" + @property def rank(self) -> int: return self._rank @property def shape(self) -> tuple[int, ...] | None: + if self._reads_its_own_descriptor: + # The generated inquiry reads the descriptor, so it reports absent + # storage as None instead of being asked about it first, and the + # extents it returns are the compiler's own. + if self.closed: + raise ReferenceError(f"{self.descriptor_kind} handle is closed") + extents = self._ops["shape"](self) + if extents is None: + return None + if _is_pointer_descriptor_record(extents): + # A deferred character inquiry reports fields, not extents. + shape, _strides = _pointer_descriptor_shape_and_strides(extents) + return self._normalize_shape(shape) + return extents if self._to_numpy_absent_state(): return None shape = self._call_op("shape") @@ -843,9 +872,13 @@ def _contiguous_descriptor_record(self, address: int, shape: tuple[int, ...] | N def to_numpy(self) -> Any: """Return a live view of current native storage, or ``None``.""" + policy = self._to_numpy_policy + if self._reads_its_own_descriptor and policy != "unsupported": + extracted = self._own_descriptor_view(policy) + if extracted is not _EXTRACTION_UNAVAILABLE: + return extracted if self._to_numpy_absent_state(): return None - policy = self._to_numpy_policy if policy == "unsupported": raise NotImplementedError( f"{self.descriptor_kind} handle to_numpy extraction is unsupported by completed policy" @@ -878,6 +911,28 @@ def to_numpy(self) -> Any: self._validate_contiguous_numpy_result(value) return value + def _own_descriptor_view(self, policy: str) -> Any: + """Return the view a generated extraction builds over owned storage. + + The extraction reads the descriptor itself, so it reports an absent + state as None rather than needing to be asked first, and what it + returns was built from the declared type, so a second check of the + result adds nothing. An extraction that reports fields instead is not + one of these, and says so by returning the unavailable sentinel. + """ + if self.closed: + raise ReferenceError(f"{self.descriptor_kind} handle is closed") + value = self._ops["to_numpy"](self) + if value is None: + return None + if not isinstance(value, np.ndarray): + return _EXTRACTION_UNAVAILABLE + if value.base is not None and value.base is self._owner: + value = _retain_numpy_owner(value, self) + if policy == "contiguous_view": + self._validate_contiguous_numpy_result(value) + return value + def _call_op(self, name: str, *args: Any) -> Any: if self.closed: raise ReferenceError(f"{self.descriptor_kind} handle is closed") From 16d92b062086ba3cbc65229ff41a3fce1ba3b961 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 17:09:09 +0100 Subject: [PATCH 20/47] Reach an array handle's storage from C for more dummy forms Passing a handle to an ordinary array dummy read its descriptor directly only when the module already included the Fortran interop header, and a module includes that when it uses descriptors somewhere else. Whether an argument was placed from C therefore depended on an unrelated variable in the same module rather than on the argument. The header now follows the argument: a module whose ordinary array dummies accept a handle needs it. A module with no Fortran behind it has no descriptors to read and keeps the runtime route. Two further dummy forms are covered. An assumed-shape `values(:)` carries strides and bounds beyond an address and extents, so its reader fills the same record the runtime fills and everything downstream is untouched; a handle reaches such a dummy contiguously, as the runtime already required, so the strides are unit and each upper bound is its extent's last index. An assumed-size `values(*)` collapses an actual of any rank into one axis, so its reader walks the rank the descriptor reports and folds the collapsed axes into a product. Neither states an expected rank when the dummy takes any, the way a character dummy already states no element size. A character dummy is matched on its declared width as well as its kind. The entry-point table carries no width for character storage, so it can neither match a declared one nor be trusted to skip the check, and such a dummy keeps the runtime route where the comparison is made on the dtype. Verified on gfortran and ifx: an assumed-shape dummy, an assumed-size dummy taking rank-1 and rank-2 handles, explicit and multidimensional dummies, and a noncontiguous pointer target refused with the runtime's own wording. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 12 + prik/codegen/c/binding.py | 261 ++++++++++++++++++++- prik/pipeline/wrapper.py | 22 +- prik/planning/planner.py | 30 ++- prik/runtime/native_support/prik_binding.h | 7 +- 5 files changed, 323 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c95b0a247..7a35326a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,18 @@ release tags add a leading `v` to the package version. such a dummy on every compiler now, including the bounds a shifted allocatable carries. +- More array dummies take an array handle without going through Python. The + storage a handle names is reached through its descriptor, so the binding only + read it directly when the module happened to use descriptors elsewhere -- + whether an argument was fast depended on an unrelated variable in the same + module. The interop header now follows the argument instead. Two more dummy + forms are covered as well: an assumed-shape `values(:)`, whose bridge carries + strides and bounds beyond an address and extents, and an assumed-size + `values(*)`, which collapses an actual of any rank into one axis. A + `character` dummy is matched on its declared width, which the entry-point + table does not carry, so it still takes the runtime route, as do modules with + no Fortran behind them. + - Reading a handle you created is faster again. Its generated inquiries read the descriptor they are given, so they now report absent storage themselves instead of the runtime asking a separate question first, and a result the diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 966093dfc..f84105ddc 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -7461,7 +7461,9 @@ def _array_actual_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, {rank}, " + f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, " + # A flattened dummy takes an actual of any rank. + f"{0 if self._flattened_reader_axis(plan) is not None else rank}, " f"{self._native_array_cfi_type(plan)}, " f"{self._native_array_expected_element_size(plan)})" ) @@ -7480,6 +7482,47 @@ def _array_actual_table_nodes( ), ) + def _inline_array_actual_handle_arguments(self, plan: ModulePlan): + """Return array arguments that take a handle through the shared record. + + These are the ones the outlined binder cannot serve -- an assumed-shape + dummy carries strides and bounds beyond a pointer and extents -- so + they fill the record instead. + """ + for function in self._functions(plan): + context = self._function_context(function) + for argument in function.arguments: + if self._inline_array_actual_fast_path(argument) and ( + self._outlined_array_bind_fixed_extents(argument, context) is None + ): + yield argument + + @staticmethod + def _takes_array_handle(argument: ArgumentTransferPlan) -> bool: + """Return whether an ordinary array argument accepts an array handle.""" + actual = argument.native_array_actual + if actual is None or argument.array is None or argument.array.rank is None: + return False + return bool( + { + NativeArraySourceKind.ALLOCATABLE_HANDLE, + NativeArraySourceKind.POINTER_HANDLE, + }.intersection(actual.accepted_sources) + ) + + def _inline_array_actual_fast_path(self, plan: ArgumentTransferPlan) -> bool: + """Report whether this argument can fill its record from a descriptor.""" + if not self._reads_native_descriptors or not self._takes_array_handle(plan): + return False + actual = plan.native_array_actual + # A character dummy is matched on its declared width, which the table + # does not carry, so it keeps the runtime route. + return not ( + actual.flatten_storage + or plan.array.flatten_python_storage + or plan.datatype_family is DatatypeFamily.STRING + ) + def _array_actual_handle_arguments_for(self, function: FunctionPlan, context: _CFunctionContext): """Return this function's array arguments an array handle may be passed to.""" for argument in function.arguments: @@ -7493,7 +7536,10 @@ def _array_actual_handle_arguments_for(self, function: FunctionPlan, context: _C continue if self._outlined_array_bind_fixed_extents(argument, context) is None: continue - if actual.flatten_storage or argument.array.flatten_python_storage: + if argument.datatype_family is DatatypeFamily.STRING: + continue + flattens = actual.flatten_storage or argument.array.flatten_python_storage + if flattens and self._flattened_reader_axis(argument) is None: continue yield function, argument @@ -7599,6 +7645,7 @@ def _native_array_actual_call_nodes( return () prefix = names.value_name layout = "NULL" if actual.order is None else f'"{actual.order}"' + table_nodes = self._native_array_actual_table_nodes(plan, names) nodes = [ *self._native_array_actual_shape_object_nodes(plan, names), *self._native_array_actual_shape_nodes(plan, context, names), @@ -7616,7 +7663,62 @@ def _native_array_actual_call_nodes( ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_shape)")), ] - return tuple(nodes) + if not table_nodes: + return tuple(nodes) + # The handle already named its storage, so the shared path runs only + # when nothing filled the record. + return ( + *table_nodes, + CIf(CodeExpression(f"{prefix}_actual.data == NULL"), body=tuple(nodes)), + ) + + def _native_array_actual_table_nodes( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + ) -> tuple: + """Fill the array-actual record from a handle's table when it has one. + + A handle standing for native storage names it through its table, so the + record is filled here rather than assembled by asking the runtime one + operation at a time. Anything else leaves it empty and takes the + shared path. + """ + if not self._inline_array_actual_fast_path(plan): + return () + prefix = names.value_name + capsule = f"{prefix}_table_capsule" + table = f"{prefix}_table" + return ( + CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CExpressionStatement(CodeExpression(f"{prefix}_actual.data = NULL")), + CExpressionStatement( + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + ), + CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), + CIf( + CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), + body=( + CExpressionStatement( + CodeExpression( + f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, " + f"{plan.array.rank}, {self._native_array_cfi_type(plan)}, " + f"{self._native_array_expected_element_size(plan)})" + ) + ), + CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), + CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CExpressionStatement( + CodeExpression( + f"{table}->scoped_descriptor({table}->owner, " + f"{self._array_actual_struct_reader_name(plan)}, &{prefix}_actual)" + ) + ), + ), + else_body=(CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")),), + ), + ) def _native_array_actual_shape_object_nodes( self, @@ -9961,6 +10063,149 @@ def _array_actual_handle_arguments(self, plan: ModulePlan): for function in self._functions(plan): yield from self._array_actual_handle_arguments_for(function, self._function_context(function)) + @staticmethod + def _flattened_reader_axis(argument: ArgumentTransferPlan) -> int | None: + """Return the contract axis a flattened dummy collapses into, if any.""" + actual = argument.native_array_actual + if actual is None or not actual.flatten_storage: + return None + rank = argument.array.rank + axis = 0 if actual.flat_axis is None or int(actual.flat_axis) < 0 else int(actual.flat_axis) + return axis if axis in {0, rank - 1} else None + + def _flattened_array_actual_reader( + self, + function: FunctionPlan, + argument: ArgumentTransferPlan, + record: str, + rank: int, + flat_axis: int, + ) -> CFunction: + """Read a handle's storage for a dummy that flattens it. + + Such a dummy takes an actual of any rank and collapses it into one + contract axis, so this walks the rank the descriptor reports rather + than the dummy's own, and folds every collapsed axis into a product. + Kept extents come from the axes the contract still names. + """ + body: list = [ + CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), + CDeclaration("out", f"{record} *", CodeExpression(f"({record} *)context")), + CDeclaration("expected", "CFI_index_t", CodeExpression("0")), + CDeclaration("collapsed", "int64_t", CodeExpression("1")), + CDeclaration("extent", "int64_t", CodeExpression("0")), + CDeclaration("axis", "int", CodeExpression("0")), + CDeclaration("kept", "int", CodeExpression(str(rank - 1))), + CExpressionStatement(CodeExpression("out->present = 0")), + CExpressionStatement(CodeExpression("out->contiguous = 1")), + CComment("Unallocated or disassociated storage has no address to pass."), + CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("expected = (CFI_index_t)source->elem_len")), + CComment("Every axis is walked once: contiguity is a property of them all,"), + CComment("and the collapsed extent is the product of the ones not kept."), + CFor( + "axis = 0", + CodeExpression("axis < (int)source->rank"), + CodeExpression("axis += 1"), + body=( + CExpressionStatement( + CodeExpression( + "extent = (int64_t)(source->dim[axis].extent == -1 ? 0 : source->dim[axis].extent)" + ) + ), + CIf( + CodeExpression("source->dim[axis].sm != expected"), + body=(CExpressionStatement(CodeExpression("out->contiguous = 0")),), + ), + CExpressionStatement(CodeExpression("expected *= (CFI_index_t)extent")), + CIf( + CodeExpression( + "axis < kept" if flat_axis == rank - 1 else "axis >= (int)source->rank - kept" + ), + body=( + CExpressionStatement( + CodeExpression( + f"out->extents[{'axis' if flat_axis == rank - 1 else 'axis - ((int)source->rank - kept) + 1'}] = extent" + ) + ), + ), + else_body=(CExpressionStatement(CodeExpression("collapsed *= extent")),), + ), + ), + ), + CExpressionStatement( + CodeExpression(f"out->extents[{rank - 1 if flat_axis == rank - 1 else 0}] = collapsed") + ), + CExpressionStatement(CodeExpression("out->data = source->base_addr")), + CExpressionStatement(CodeExpression("out->present = 1")), + ] + return CFunction( + self._array_actual_reader_name(function, argument), + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + body=tuple(body), + doc=("Read one handle's storage for a dummy that flattens it into one axis.",), + ) + + def _array_actual_struct_reader_name(self, argument: ArgumentTransferPlan) -> str: + """Return the reader that fills one array actual from its descriptor.""" + owner = re.sub(r"\W", "_", argument.owner_path).casefold() + return f"prik_fill_array_actual_{owner}" + + def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) -> CFunction: + """Fill the shared array-actual record from a handle's descriptor. + + The record is the same one the runtime fills, so what reads it is + unchanged. A handle's storage reaches an ordinary dummy contiguously + -- a noncontiguous one is refused -- so the strides are unit and each + upper bound is its extent's last index, exactly as the runtime reports + them for a handle. Storage that is not contiguous leaves the record + empty, and the shared path reports it. + """ + rank = argument.array.rank + body: list = [ + CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), + CDeclaration("out", "prik_array_actual *", CodeExpression("(prik_array_actual *)context")), + CDeclaration("extent", "int64_t", CodeExpression("0")), + CDeclaration("packed", "CFI_index_t", CodeExpression("0")), + CExpressionStatement(CodeExpression("out->data = NULL")), + CExpressionStatement(CodeExpression(f"out->rank = {rank}")), + CComment("Storage that is not there leaves the record empty."), + CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("out->itemsize = (int64_t)source->elem_len")), + CExpressionStatement(CodeExpression("packed = (CFI_index_t)source->elem_len")), + ] + for axis in range(rank): + body.extend( + ( + # A compiler may report an empty dimension as extent -1. + CExpressionStatement( + CodeExpression( + f"extent = (int64_t)(source->dim[{axis}].extent == -1 " + f"? 0 : source->dim[{axis}].extent)" + ) + ), + CComment("Extents alone cannot describe noncontiguous storage."), + CIf(CodeExpression(f"source->dim[{axis}].sm != packed"), body=(CReturn(),)), + CExpressionStatement(CodeExpression(f"out->extents[{axis}] = extent")), + CExpressionStatement( + CodeExpression(f"out->upper_bounds[{axis}] = extent == 0 ? -1 : extent - 1") + ), + CExpressionStatement(CodeExpression(f"out->strides[{axis}] = 1")), + CExpressionStatement(CodeExpression("packed *= (CFI_index_t)extent")), + ) + ) + body.append(CExpressionStatement(CodeExpression("out->data = source->base_addr"))) + return CFunction( + self._array_actual_struct_reader_name(argument), + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + body=tuple(body), + doc=("Fill one array actual record from the descriptor the runtime opened.",), + ) + def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: """Emit the record and reader for each array dummy a handle may reach. @@ -9973,12 +10218,19 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: seen: set[str] = set() if not self._reads_native_descriptors: return () + for argument in self._inline_array_actual_handle_arguments(plan): + name = self._array_actual_struct_reader_name(argument) + if name in seen: + continue + seen.add(name) + nodes.append(self._array_actual_struct_reader_function(argument)) for function, argument in self._array_actual_handle_arguments(plan): record = self._array_actual_reader_record_name(function, argument) if record in seen: continue seen.add(record) rank = argument.array.rank + flat_axis = self._flattened_reader_axis(argument) nodes.append( CStructDefinition( record, @@ -9990,6 +10242,9 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: ), ) ) + if flat_axis is not None: + nodes.append(self._flattened_array_actual_reader(function, argument, record, rank, flat_axis)) + continue body: list = [ CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), CDeclaration("out", f"{record} *", CodeExpression(f"({record} *)context")), diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index c9cdc8b51..502385eeb 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -530,13 +530,33 @@ def _required_header_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDia for namespace in plan.namespaces for derived in namespace.derived_types for field in derived.fields - ): + ) or self._accepts_array_handle_actual(plan): expected_headers.append(NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER) expected = tuple(dict.fromkeys(expected_headers)) if plan.required_headers == expected: return () return (self._diagnostic(plan.owner_path, "inconsistent-required-headers", plan.required_headers),) + @staticmethod + def _accepts_array_handle_actual(plan: ModulePlan) -> bool: + """Return whether an ordinary array argument accepts an array handle. + + The storage such a handle names is reached through its descriptor, so + the module needs the interop header even when nothing else in it does. + A module with no Fortran behind it has no descriptors to read and keeps + the runtime route instead. + """ + if "fortran" not in plan.entrypoint.native_languages: + return False + accepts = {NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE} + return any( + argument.native_array_actual is not None + and accepts.intersection(argument.native_array_actual.accepted_sources) + for namespace in plan.namespaces + for function in namespace.functions + for argument in function.arguments + ) + def _namespace_native_array_handles( self, namespace: NamespacePlan, diff --git a/prik/planning/planner.py b/prik/planning/planner.py index d8872be29..62d0981c4 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -37,6 +37,7 @@ ModuleGetterAction, ModuleObjectAccessMechanism, ModuleVariablePolicy, + NativeArraySourceKind, OverloadPolicy, OptionalMode, ArgumentPolicy, @@ -412,7 +413,7 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: ), namespaces=namespaces, native_generated_code_groups=generated_code_groups, - required_headers=self._required_headers(namespaces), + required_headers=self._required_headers(namespaces, module.origin.source_language), ) @staticmethod @@ -2552,7 +2553,11 @@ def _declaration_callable_roles( """Return bridge-resolved declaration-callable symbol roles.""" return tuple(item.symbolic_role for item in declaration_callables) - def _required_headers(self, namespaces: tuple[NamespacePlan, ...]) -> tuple[str, ...]: + def _required_headers( + self, + namespaces: tuple[NamespacePlan, ...], + source_language: str | None = None, + ) -> tuple[str, ...]: """Return the union of headers selected by completed handle plans.""" handles = tuple( handle @@ -2561,10 +2566,29 @@ def _required_headers(self, namespaces: tuple[NamespacePlan, ...]) -> tuple[str, if handle is not None ) headers = list(self._native_array_headers(handles)) - if self._requires_derived_descriptor_header(namespaces): + if self._requires_derived_descriptor_header(namespaces) or ( + source_language == "fortran" and self._accepts_array_handle_actual(namespaces) + ): headers.append(NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER) return tuple(dict.fromkeys(headers)) + @staticmethod + def _accepts_array_handle_actual(namespaces: tuple[NamespacePlan, ...]) -> bool: + """Return whether an ordinary array argument accepts an array handle. + + The storage such a handle names is reached through its descriptor, so a + module whose ordinary array dummies accept one needs the interop header + even when nothing else about the module does. + """ + accepts = {NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE} + return any( + argument.native_array_actual is not None + and accepts.intersection(argument.native_array_actual.accepted_sources) + for namespace in namespaces + for function in namespace.functions + for argument in function.arguments + ) + @staticmethod def _requires_derived_descriptor_header(namespaces: tuple[NamespacePlan, ...]) -> bool: """Return whether one derived field uses a standard C descriptor callback.""" diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index c8320ab12..658affc42 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -205,8 +205,11 @@ static inline prik_native_array_ops *prik_native_array_ops_actual_from_capsule( PyErr_SetString(PyExc_TypeError, "incompatible prik native array ops record"); return NULL; } - if (ops->rank != expected_rank || ops->cfi_type != expected_cfi_type - || ops->element_size != expected_element_size) { + /* Zero means the handle states the fact rather than matching one: a + character dummy takes its width from the actual, and a dummy whose + storage is flattened takes an actual of any rank. */ + if ((expected_rank != 0 && ops->rank != expected_rank) || ops->cfi_type != expected_cfi_type + || (expected_element_size != 0 && ops->element_size != expected_element_size)) { PyErr_SetString(PyExc_TypeError, "native array handle does not match the declared dummy argument"); return NULL; } From c19ade5b86737b32cfca8f5ee6056c49f4312f6b Mon Sep 17 00:00:00 2001 From: said Date: Fri, 4 Sep 2026 19:25:38 +0100 Subject: [PATCH 21/47] Report a refused array handle from the binding, and cover characters A handle that does not fit an ordinary array dummy was reported in two places. The binding refused a mismatched rank, element type or element size with one message while the runtime, reached when the binding declined, described the same conditions in its own words. Passing a `real(4)` handle to a `real(8)` dummy therefore changed what it said depending on which route ran, and the branch had already changed that message without meaning to. Every refusal is now made where the storage is inspected. A handle of the wrong shape names the rank and element width it carries and the dtype the dummy expects; storage that is not there, a noncontiguous pointer target and an element width the dummy did not declare each report the condition found. The wording is not the runtime's word for word; the conditions are the same ones, and there is one description of each rather than two that can drift. A character dummy is covered by the same route now. Its width is compared against the width the descriptor states rather than one the entry-point table would have to carry, so a handle whose elements are a different length is refused as before while a matching one is placed from C. Handle acceptance is also gated on the source language. A C module's array parameter never accepted a handle -- its binding takes an ndarray -- but completed policy said it did, so the plan disagreed with the code it produced, and that disagreement is what let generated readers dereference a descriptor in a wrapper with no Fortran behind it. Verified on gfortran and ifx: a matching handle is placed without entering Python on any path, and a wrong dtype, a wrong character width, an unallocated handle and a noncontiguous pointer target are each refused with the condition named. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 14 ++ prik/codegen/c/binding.py | 150 +++++++++++++++++++-- prik/pipeline/wrapper.py | 6 +- prik/planning/planner.py | 15 +-- prik/policy/construction.py | 26 +++- prik/runtime/native_support/prik_binding.h | 18 ++- 6 files changed, 191 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a35326a8..624755118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,20 @@ release tags add a leading `v` to the package version. such a dummy on every compiler now, including the bounds a shifted allocatable carries. +- A `character` array handle now reaches an ordinary character dummy without + going through Python, and a handle that does not fit an ordinary array dummy + is reported from the binding rather than by the runtime. The dummy's declared + width is compared against the width the descriptor states, so a handle whose + elements are a different length is still refused, and refusals name what the + handle carries and what the dummy expects. The wording differs from the + runtime's in places; the conditions reported are the same. + +- A C module's array parameter no longer accepts a Fortran array handle. It + never did in practice -- the generated binding takes an ndarray -- but + completed policy said otherwise, which made the plan disagree with the code + it produced. A handle passed there is now an object of the wrong type, like + any other value that is not an array. + - More array dummies take an array handle without going through Python. The storage a handle names is reached through its descriptor, so the binding only read it directly when the module happened to use descriptors elsewhere -- diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index f84105ddc..bfcacb59a 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -7403,8 +7403,23 @@ def _array_actual_table_nodes( ), ) checks: list = [ - CComment("The same condition the runtime reports, worded the same way,"), + CComment("Each condition is reported the way the runtime reports it,"), CComment("so a handle reads alike whether or not it publishes a table."), + CIf( + CodeExpression(f"{found}.refused == 2"), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "%s handle dtype dtype(\'S%zu\') does not ' + f'match expected dtype dtype(\'S%d\')", ' + f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + f'? "pointer" : "allocatable", {found}.width, ' + f"{self._declared_character_width(plan)})" + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), CIf( CodeExpression(f"!{found}.present"), body=( @@ -7465,7 +7480,8 @@ def _array_actual_table_nodes( # A flattened dummy takes an actual of any rank. f"{0 if self._flattened_reader_axis(plan) is not None else rank}, " f"{self._native_array_cfi_type(plan)}, " - f"{self._native_array_expected_element_size(plan)})" + f"{self._native_array_expected_element_size(plan)}, " + f'"{plan.native_array_actual.dtype}", "{plan.binding.python_name}")' ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), @@ -7515,13 +7531,7 @@ def _inline_array_actual_fast_path(self, plan: ArgumentTransferPlan) -> bool: if not self._reads_native_descriptors or not self._takes_array_handle(plan): return False actual = plan.native_array_actual - # A character dummy is matched on its declared width, which the table - # does not carry, so it keeps the runtime route. - return not ( - actual.flatten_storage - or plan.array.flatten_python_storage - or plan.datatype_family is DatatypeFamily.STRING - ) + return not (actual.flatten_storage or plan.array.flatten_python_storage) def _array_actual_handle_arguments_for(self, function: FunctionPlan, context: _CFunctionContext): """Return this function's array arguments an array handle may be passed to.""" @@ -7536,8 +7546,6 @@ def _array_actual_handle_arguments_for(self, function: FunctionPlan, context: _C continue if self._outlined_array_bind_fixed_extents(argument, context) is None: continue - if argument.datatype_family is DatatypeFamily.STRING: - continue flattens = actual.flatten_storage or argument.array.flatten_python_storage if flattens and self._flattened_reader_axis(argument) is None: continue @@ -7689,9 +7697,58 @@ def _native_array_actual_table_nodes( prefix = names.value_name capsule = f"{prefix}_table_capsule" table = f"{prefix}_table" + record = self._array_actual_struct_reader_record_name(plan) + found = f"{prefix}_table_result" + width = self._declared_character_width(plan) + refusals = ( + CIf( + CodeExpression(f"{found}.refused == 1"), + body=( + CExpressionStatement( + CodeExpression( + f"PyErr_SetString(PyExc_ValueError, " + f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + f'? "pointer handle is unassociated and cannot be passed as an array actual" ' + f': "allocatable handle is unallocated and cannot be passed as an array actual")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + CIf( + CodeExpression(f"{found}.refused == 2"), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "handle with %zu-byte elements does not match ' + f'expected dtype {plan.native_array_actual.dtype} for argument ' + f'{plan.binding.python_name}", {found}.width)' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + CIf( + CodeExpression(f"{found}.refused == 3"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_SetString(PyExc_ValueError, "pointer handle target is noncontiguous ' + 'and cannot use the pointer/shape array-actual handoff")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CDeclaration(found, record), + CExpressionStatement(CodeExpression(f"{found}.actual = &{prefix}_actual")), + CExpressionStatement(CodeExpression(f"{found}.refused = 0")), + CExpressionStatement(CodeExpression(f"{found}.width = 0")), + CExpressionStatement(CodeExpression(f"(void){width}")), CExpressionStatement(CodeExpression(f"{prefix}_actual.data = NULL")), CExpressionStatement( CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') @@ -7704,7 +7761,8 @@ def _native_array_actual_table_nodes( CodeExpression( f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, " f"{plan.array.rank}, {self._native_array_cfi_type(plan)}, " - f"{self._native_array_expected_element_size(plan)})" + f"{self._native_array_expected_element_size(plan)}, " + f'"{plan.native_array_actual.dtype}", "{plan.binding.python_name}")' ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), @@ -7712,9 +7770,10 @@ def _native_array_actual_table_nodes( CExpressionStatement( CodeExpression( f"{table}->scoped_descriptor({table}->owner, " - f"{self._array_actual_struct_reader_name(plan)}, &{prefix}_actual)" + f"{self._array_actual_struct_reader_name(plan)}, &{found})" ) ), + *refusals, ), else_body=(CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")),), ), @@ -10063,6 +10122,35 @@ def _array_actual_handle_arguments(self, plan: ModulePlan): for function in self._functions(plan): yield from self._array_actual_handle_arguments_for(function, self._function_context(function)) + @staticmethod + def _declared_character_width(argument: ArgumentTransferPlan) -> int: + """Return the element width a character dummy declares, or zero.""" + actual = argument.native_array_actual + declared = re.search(r"S(\d+)$", "" if actual is None else str(actual.dtype)) + return int(declared.group(1)) if declared else 0 + + @staticmethod + def _declared_character_width_guard(argument: ArgumentTransferPlan) -> tuple: + """Decline storage whose element width is not the one the dummy declares. + + A character dummy is matched on its width as well as its kind, and the + descriptor states the width the storage actually has. Declining leaves + the record empty, so the runtime reports the dtype it expected. + """ + if argument.datatype_family is not DatatypeFamily.STRING: + return () + actual = argument.native_array_actual + declared = re.search(r"S(\d+)$", "" if actual is None else str(actual.dtype)) + if declared is None: + return () + return ( + CComment("A character dummy is matched on its declared width."), + CIf( + CodeExpression(f"source->elem_len != (size_t){declared.group(1)}"), + body=(CReturn(),), + ), + ) + @staticmethod def _flattened_reader_axis(argument: ArgumentTransferPlan) -> int | None: """Return the contract axis a flattened dummy collapses into, if any.""" @@ -10101,6 +10189,7 @@ def _flattened_array_actual_reader( CComment("Unallocated or disassociated storage has no address to pass."), CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), CExpressionStatement(CodeExpression("expected = (CFI_index_t)source->elem_len")), + *self._declared_character_width_guard(argument), CComment("Every axis is walked once: contiguity is a property of them all,"), CComment("and the collapsed extent is the product of the ones not kept."), CFor( @@ -10153,6 +10242,22 @@ def _array_actual_struct_reader_name(self, argument: ArgumentTransferPlan) -> st owner = re.sub(r"\W", "_", argument.owner_path).casefold() return f"prik_fill_array_actual_{owner}" + def _array_actual_struct_reader_record_name(self, argument: ArgumentTransferPlan) -> str: + """Return the record one array-actual reader fills alongside its reason.""" + return f"{self._array_actual_struct_reader_name(argument)}_result" + + def _array_actual_struct_reader_record(self, argument: ArgumentTransferPlan) -> CStructDefinition: + """Define the record carrying one filled array actual and why it was refused.""" + return CStructDefinition( + self._array_actual_struct_reader_record_name(argument), + ( + CParameter("actual", "prik_array_actual *"), + # 0 accepted, 1 no storage, 2 element width, 3 noncontiguous + CParameter("refused", "int"), + CParameter("width", "size_t"), + ), + ) + def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) -> CFunction: """Fill the shared array-actual record from a handle's descriptor. @@ -10164,17 +10269,23 @@ def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) - empty, and the shared path reports it. """ rank = argument.array.rank + record = self._array_actual_struct_reader_record_name(argument) body: list = [ CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), - CDeclaration("out", "prik_array_actual *", CodeExpression("(prik_array_actual *)context")), + CDeclaration("wrap", f"{record} *", CodeExpression(f"({record} *)context")), + CDeclaration("out", "prik_array_actual *", CodeExpression("wrap->actual")), CDeclaration("extent", "int64_t", CodeExpression("0")), CDeclaration("packed", "CFI_index_t", CodeExpression("0")), CExpressionStatement(CodeExpression("out->data = NULL")), CExpressionStatement(CodeExpression(f"out->rank = {rank}")), + CExpressionStatement(CodeExpression("wrap->refused = 1")), CComment("Storage that is not there leaves the record empty."), CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("wrap->refused = 2")), + CExpressionStatement(CodeExpression("wrap->width = source->elem_len")), CExpressionStatement(CodeExpression("out->itemsize = (int64_t)source->elem_len")), CExpressionStatement(CodeExpression("packed = (CFI_index_t)source->elem_len")), + *self._declared_character_width_guard(argument), ] for axis in range(rank): body.extend( @@ -10187,6 +10298,7 @@ def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) - ) ), CComment("Extents alone cannot describe noncontiguous storage."), + CExpressionStatement(CodeExpression("wrap->refused = 3")), CIf(CodeExpression(f"source->dim[{axis}].sm != packed"), body=(CReturn(),)), CExpressionStatement(CodeExpression(f"out->extents[{axis}] = extent")), CExpressionStatement( @@ -10196,6 +10308,7 @@ def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) - CExpressionStatement(CodeExpression("packed *= (CFI_index_t)extent")), ) ) + body.append(CExpressionStatement(CodeExpression("wrap->refused = 0"))) body.append(CExpressionStatement(CodeExpression("out->data = source->base_addr"))) return CFunction( self._array_actual_struct_reader_name(argument), @@ -10223,6 +10336,7 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: if name in seen: continue seen.add(name) + nodes.append(self._array_actual_struct_reader_record(argument)) nodes.append(self._array_actual_struct_reader_function(argument)) for function, argument in self._array_actual_handle_arguments(plan): record = self._array_actual_reader_record_name(function, argument) @@ -10239,6 +10353,9 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: CParameter(f"extents[{rank}]", "int64_t"), CParameter("contiguous", "int"), CParameter("present", "int"), + # 0 accepted, 1 no storage, 2 element width + CParameter("refused", "int"), + CParameter("width", "size_t"), ), ) ) @@ -10251,9 +10368,14 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: CDeclaration("expected", "CFI_index_t", CodeExpression("0")), CExpressionStatement(CodeExpression("out->present = 0")), CExpressionStatement(CodeExpression("out->contiguous = 1")), + CExpressionStatement(CodeExpression("out->refused = 1")), CComment("Unallocated or disassociated storage has no address to pass."), CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("out->refused = 2")), + CExpressionStatement(CodeExpression("out->width = source->elem_len")), CExpressionStatement(CodeExpression("expected = (CFI_index_t)source->elem_len")), + *self._declared_character_width_guard(argument), + CExpressionStatement(CodeExpression("out->refused = 0")), ] for axis in range(rank): body.extend( diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 502385eeb..8fcaa6ed7 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -543,11 +543,9 @@ def _accepts_array_handle_actual(plan: ModulePlan) -> bool: The storage such a handle names is reached through its descriptor, so the module needs the interop header even when nothing else in it does. - A module with no Fortran behind it has no descriptors to read and keeps - the runtime route instead. + Only a Fortran argument accepts a handle, so the accepted sources are + the whole test. """ - if "fortran" not in plan.entrypoint.native_languages: - return False accepts = {NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE} return any( argument.native_array_actual is not None diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 62d0981c4..d83617293 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -413,7 +413,7 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: ), namespaces=namespaces, native_generated_code_groups=generated_code_groups, - required_headers=self._required_headers(namespaces, module.origin.source_language), + required_headers=self._required_headers(namespaces), ) @staticmethod @@ -2553,11 +2553,7 @@ def _declaration_callable_roles( """Return bridge-resolved declaration-callable symbol roles.""" return tuple(item.symbolic_role for item in declaration_callables) - def _required_headers( - self, - namespaces: tuple[NamespacePlan, ...], - source_language: str | None = None, - ) -> tuple[str, ...]: + def _required_headers(self, namespaces: tuple[NamespacePlan, ...]) -> tuple[str, ...]: """Return the union of headers selected by completed handle plans.""" handles = tuple( handle @@ -2566,9 +2562,7 @@ def _required_headers( if handle is not None ) headers = list(self._native_array_headers(handles)) - if self._requires_derived_descriptor_header(namespaces) or ( - source_language == "fortran" and self._accepts_array_handle_actual(namespaces) - ): + if self._requires_derived_descriptor_header(namespaces) or self._accepts_array_handle_actual(namespaces): headers.append(NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER) return tuple(dict.fromkeys(headers)) @@ -2578,7 +2572,8 @@ def _accepts_array_handle_actual(namespaces: tuple[NamespacePlan, ...]) -> bool: The storage such a handle names is reached through its descriptor, so a module whose ordinary array dummies accept one needs the interop header - even when nothing else about the module does. + even when nothing else about the module does. Only a Fortran argument + accepts one, so no separate language test is needed here. """ accepts = {NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE} return any( diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 063e6529c..91f368b5a 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -3098,7 +3098,12 @@ def _argument_policy( character_length=_character_length(argument.semantic_type), character_local=_character_local_policy(argument.semantic_type, decision), array=array_policy, - native_array_actual=_native_array_actual_policy(argument, decision, array_policy), + native_array_actual=_native_array_actual_policy( + argument, + decision, + array_policy, + function.origin.source_language, + ), native_array_handle=_native_array_handle_wrapper_policy( argument.semantic_type, argument.metadata.get(models.RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA), @@ -6539,8 +6544,14 @@ def _native_array_actual_policy( argument: models.SemanticArgument, decision: OwnershipDecision, array: ArrayHandoffPolicy | None, + source_language: str | None = None, ) -> NativeArrayActualPolicy | None: - """Complete handle-as-array-actual acceptance for the Phase 6 buffer ABI.""" + """Complete handle-as-array-actual acceptance for the Phase 6 buffer ABI. + + A handle stands for storage a Fortran runtime owns, so a C dummy does not + accept one: there it is an object of the wrong type, like any other value + that is not an array. + """ if native_array_descriptor_kind(argument.semantic_type) is not None: return None if ( @@ -6556,12 +6567,13 @@ def _native_array_actual_policy( dtype = _native_array_actual_dtype(argument) if dtype is None: return None + handle_sources = ( + () + if source_language == "c" + else (NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE) + ) return NativeArrayActualPolicy( - accepted_sources=( - NativeArraySourceKind.NDARRAY, - NativeArraySourceKind.ALLOCATABLE_HANDLE, - NativeArraySourceKind.POINTER_HANDLE, - ), + accepted_sources=(NativeArraySourceKind.NDARRAY, *handle_sources), dtype=dtype, rank=array.rank, shape=array.shape, diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 658affc42..604bb71d5 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -190,7 +190,9 @@ static inline prik_native_array_ops *prik_native_array_ops_actual_from_capsule( PyObject *capsule, uint32_t expected_rank, int expected_cfi_type, - size_t expected_element_size) + size_t expected_element_size, + const char *dtype_name, + const char *argument_name) { prik_native_array_ops *ops; @@ -207,10 +209,20 @@ static inline prik_native_array_ops *prik_native_array_ops_actual_from_capsule( } /* Zero means the handle states the fact rather than matching one: a character dummy takes its width from the actual, and a dummy whose - storage is flattened takes an actual of any rank. */ + storage is flattened takes an actual of any rank. + + A handle describing different storage is reported here, naming what the + dummy expects and what the handle carries. */ if ((expected_rank != 0 && ops->rank != expected_rank) || ops->cfi_type != expected_cfi_type || (expected_element_size != 0 && ops->element_size != expected_element_size)) { - PyErr_SetString(PyExc_TypeError, "native array handle does not match the declared dummy argument"); + PyErr_Format( + PyExc_TypeError, + "%s handle of rank %u with %zu-byte elements does not match expected dtype %s for argument %s", + ops->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER ? "pointer" : "allocatable", + (unsigned)ops->rank, + ops->element_size, + dtype_name, + argument_name); return NULL; } return ops; From 79e7652e67af8ae7810682eaf67a5ad663378e7c Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 04:36:14 +0100 Subject: [PATCH 22/47] Publish one versioned backend for every array handle The runtime carried two cross-extension records for the same thing. A borrowed handle published an entry-point table naming its bridge symbols; an owned handle published that table *and* a second capsule holding the descriptor storage it had allocated, with its own magic word, version and release callback. A reader had to know which of the two it was looking at. There is only one question either record answers: how do I reach a live descriptor for this handle? So there is now one record that answers it. `with_descriptor(context, consumer, consumer_context)` enters the entity and runs the consumer while its descriptor is valid -- into Fortran for a module variable or a field, straight onto persistent storage for an owned handle -- and `release` is non-NULL exactly when that context is storage this extension must free. An owned handle's `owner` and its published backend are now the same capsule. The version moves into the capsule name. PyCapsule_GetPointer already refuses a capsule created under any other name, so the two magic words and the two ABI version fields were re-checking what the name had settled; `struct_size` stays as the one tag that catches a layout change made without renaming. `descriptor_size` stays because nothing in a descriptor can be read to establish the producer's CFI_CDESC_T layout, and the kind, rank, element type and width stay so a mismatched actual is refused before any Fortran is entered rather than after. Also fixes the CLI argument tests, which called main() with no argv and so read the xdist worker's empty sys.argv and printed help instead of dispatching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 267 +---- docs/developer/packages/runtime.md | 14 +- docs/user/guide/allocatables.md | 5 + docs/user/guide/arrays.md | 9 + docs/user/guide/pointers.md | 10 +- prik/codegen/c/binding.py | 935 ++++++++---------- prik/codegen/fortran/bridge.py | 33 +- prik/pipeline/wrapper.py | 19 +- prik/planning/entrypoints.py | 29 +- prik/planning/models.py | 2 +- prik/policy/construction.py | 26 +- prik/policy/models.py | 7 +- prik/runtime/handles.py | 447 +-------- prik/runtime/native_support/prik_binding.h | 727 +++++--------- tests/c/_support/cli.py | 4 + .../test_runtime_rank_pointer_lowering.py | 4 +- .../test_exact_native_scalar_lowering.py | 2 +- .../fortran/_support/native_array_handles.py | 5 +- .../codegen/test_allocatable_lowering.py | 2 +- .../end_to_end/test_allocatable_handles.py | 13 - .../test_allocatable_array_actual_abi.py | 72 -- .../test_allocatable_contract_handles.py | 2 - .../test_allocatable_descriptor_abi.py | 1 - .../codegen/test_array_buffer_lowering.py | 7 +- .../test_dense_array_shape_lowering.py | 4 +- .../codegen/test_specialized_array_roles.py | 36 +- .../codegen/test_strided_array_lowering.py | 11 +- .../test_array_contract_validation.py | 108 -- .../end_to_end/test_array_wrapper_parity.py | 27 - .../end_to_end/test_assumed_rank_arrays.py | 42 - .../test_native_handle_array_forms.py | 140 +++ .../infrastructure/cli/pipeline/_support.py | 4 + .../runtime/test_native_support.py | 83 +- .../codegen/test_native_handle_planning.py | 19 +- .../runtime/test_handle_lifecycle.py | 62 +- .../test_native_array_actual_handoff.py | 598 ----------- .../end_to_end/test_logical_array_views.py | 21 + .../test_module_array_storage_forms.py | 16 +- .../end_to_end/test_optional_runtime.py | 42 +- .../end_to_end/test_pointer_handles.py | 10 +- .../runtime/test_pointer_array_actual_abi.py | 68 -- .../runtime/test_pointer_contract_handles.py | 5 - .../runtime/test_pointer_descriptor_abi.py | 8 - .../runtime/test_pointer_handle_protocol.py | 23 +- 44 files changed, 1036 insertions(+), 2933 deletions(-) delete mode 100644 tests/fortran/allocatables/runtime/test_allocatable_array_actual_abi.py create mode 100644 tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py delete mode 100644 tests/fortran/memory_management/runtime/test_native_array_actual_handoff.py delete mode 100644 tests/fortran/pointers/runtime/test_pointer_array_actual_abi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 624755118..61528e509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,243 +7,36 @@ release tags add a leading `v` to the package version. ## Unreleased -- **Fixed:** passing an allocatable handle to a read-only `allocatable` dummy no - longer builds the descriptor in C. F2018 18.5.5.6 requires a null `base_addr` - when the attribute is `CFI_attribute_allocatable`, because an allocatable - established from C must start unallocated; PRIK paired that attribute with a - real address, which describes an already-allocated allocatable. Intel rejected - it with `CFI_ERROR_BASE_ADDR_NOT_NULL`, so the same wrapper worked on gfortran - and raised `Unable to establish native descriptor for argument ...: 2` on ifx. - - The binding no longer builds or copies a descriptor for these arguments at - all. It hands the call itself to Fortran instead: generated bridge code passes - the native entity to a C callback, and the call is made inside that callback, - where the descriptor the compiler built is live. Whatever the callee does to - the entity — including changing its allocation or, for a pointer, its - association — is therefore what the caller's entity sees when the callback - returns. Module variables, derived-type fields and returned results all reach - such a dummy on every compiler now, including the bounds a shifted allocatable - carries. - -- A `character` array handle now reaches an ordinary character dummy without - going through Python, and a handle that does not fit an ordinary array dummy - is reported from the binding rather than by the runtime. The dummy's declared - width is compared against the width the descriptor states, so a handle whose - elements are a different length is still refused, and refusals name what the - handle carries and what the dummy expects. The wording differs from the - runtime's in places; the conditions reported are the same. - -- A C module's array parameter no longer accepts a Fortran array handle. It - never did in practice -- the generated binding takes an ndarray -- but - completed policy said otherwise, which made the plan disagree with the code - it produced. A handle passed there is now an object of the wrong type, like - any other value that is not an array. - -- More array dummies take an array handle without going through Python. The - storage a handle names is reached through its descriptor, so the binding only - read it directly when the module happened to use descriptors elsewhere -- - whether an argument was fast depended on an unrelated variable in the same - module. The interop header now follows the argument instead. Two more dummy - forms are covered as well: an assumed-shape `values(:)`, whose bridge carries - strides and bounds beyond an address and extents, and an assumed-size - `values(*)`, which collapses an actual of any rank into one axis. A - `character` dummy is matched on its declared width, which the entry-point - table does not carry, so it still takes the runtime route, as do modules with - no Fortran behind them. - -- Reading a handle you created is faster again. Its generated inquiries read - the descriptor they are given, so they now report absent storage themselves - instead of the runtime asking a separate question first, and a result the - generated code built from the declared type is no longer re-checked. On such - a handle `to_numpy()` went from about 2.7us to about 1.3us and `shape` from - about 2.9us to about 1.3us. A handle standing for a module variable or a - derived-type field reaches its entity through the compiler's own inquiries, - which say nothing about whether the entity is there, so it keeps asking and - is unchanged. - -- Passing an array handle to an ordinary array dummy no longer goes through - Python on every call. Such a dummy takes an address and extents, and the - runtime was asked for them one operation at a time -- nine round trips per - call, with the shape computed twice. When the handle publishes native entry - points, the address and extents are now read from its descriptor in the - binding. Passing a module array to a `real(8) :: v(n)` dummy cost about - 17.5us per call and now costs about 0.27us. Handles that report state - through supplied operations, flattened storage, and runtime rank, stride or - itemsize shapes keep the previous route, and every diagnostic is unchanged: - an unallocated handle, an unassociated pointer, a noncontiguous pointer - target and a shape mismatch report exactly as before. - -- Reading an allocatable handle with `to_numpy()` no longer round-trips its - descriptor through Python. The generated extraction reported the descriptor - as base address, element length and per-axis bounds, and the runtime decoded - those fields back into a NumPy view on every call; it now builds the view - where the descriptor already is. Reading a handle cost about 12.9us per call - and now costs about 2.7us. The view still keeps its handle alive, so it stays - valid after the handle is dropped. A `pointer` handle is unchanged: it - describes its target to another pointer through those same fields, and a view - cannot carry the Fortran lower bounds an association preserves. - -- A handle you create yourself now reaches a native call as fast as one that - came from a module variable. Such a handle is given wrapper-owned descriptor - storage the first time it is passed, and it now publishes that storage as an - entry-point table, so every later call reads it from C instead of packing the - argument through Python. Passing one to a `real(8), allocatable` dummy cost - about 8.8us per call and now costs about 0.26us -- faster than passing a - plain NumPy array, which still has a buffer to check. The first call is - unchanged: it still binds the storage through Python, once per handle. - -- **Fixed:** an `optional` `allocatable` or `pointer` dummy no longer fails on - Intel `ifx`. Passing a handle to one raised `Unable to establish native - descriptor for argument ...: 2` there while working on gfortran: a present - optional argument still rebuilt its descriptor in C, so it made exactly the - `CFI_establish` call F2018 18.5.5.6 forbids. A present optional argument is an - ordinary descriptor argument and now takes the same route as any other, which - also means a callee that reallocates or re-associates one reaches the caller's - entity instead of a copy. - - An absent argument is unchanged where a generated bridge is involved: it still - hands over the unallocated placeholder that pairs with the bridge's present - flag, which is legal precisely because absence is when there is nothing to - point at. A direct `bind(c)` entrypoint has no such flag — PRIK cannot add a - parameter to a signature you wrote — so there an absent argument stays a null - descriptor pointer, keeping it distinct from a present but unallocated one. - - A caller-supplied handle must now be backed by a real descriptor. A handle - whose `descriptor` operation only reported base address, element length and - bounds is no longer accepted for these arguments: rebuilding a descriptor from - those fields is the unsound step this release removes, and with every - descriptor argument now placed the same way, the machinery that did it is - gone rather than merely unused. Handles obtained from a module variable, a - field, a result, or created from a contract type and filled by a native call - are unaffected. - -- **Fixed:** a `pointer` dummy now takes the same route, and a callee that - re-associates one is no longer silently ignored. PRIK packed the descriptor's - fields in Python and rebuilt a descriptor in C for the call, so `v => big` in - the callee re-pointed that rebuilt copy and nothing else: the handle passed in - came back still unassociated, with `associated` `False` and no shape, and no - error was reported. The handle now follows the callee's association — the - bounds and target it ends up with are the ones the call produced. Pointer and - allocatable dummies are one mechanism rather than two. - -- A `character` array handle is now accepted wherever a numeric one is. An - `AllocatableArray` of characters was refused at an ordinary character dummy - and had to be passed as `handle.to_numpy()`, while every other element type - converted directly. The handle machinery already supported it; the completed - policy simply excluded `String` from handle-as-actual acceptance. A character - actual is matched on its declared width as well as its kind, so a handle whose - elements are a different length is still refused. - -- PRIK now requests the compiler option that makes a Fortran `logical` - interoperable with C: `-standard-semantics` for Intel `ifx`/`ifort` and - `-Munixlogical` for PGI/NVIDIA. gfortran, Cray and IBM XL already use the - interoperable form. Without it those compilers store all bits set for - `.true.`, so a `logical(c_bool)` reaching C holds `255` where `_Bool` is - defined to hold `1`; C then miscounts it, and a four-element array of `.true.` - counted as `765` through Intel's own C compiler. If you override PRIK's - compiler flags, keep this one. - - The option is on by default and can be turned off with the new - `--no-standard-logicals` flag, or `standard_logicals=False` on - `build_fortran_extension`, `build_pyi_extension` and `build_c_extension`. - Turning it off is needed only when linking prebuilt Intel objects that were - themselves compiled without `-standard-semantics`: that option also changes - Intel module symbol mangling (`lib_MP_name_` rather than `lib_mp_name_`), so - objects built with and without it cannot be linked together, and mixing them - fails with an undefined reference rather than with anything about logicals. - Rebuilding the dependency with the option is the better fix. - -- **Breaking:** a Fortran `logical` array wider than one byte now reports the - integer dtype matching its element width — `int16`, `int32` or `int64` — - instead of being rejected. NumPy has no Boolean larger than a byte, so those - kinds could not be described at all before: `logical :: flags(3)` and every - allocatable, pointer and derived-field form of it were unsupported. They are - now live, writable views, read back with `.astype(bool)`. - - `logical(c_bool)` is unchanged and stays `numpy.bool_`: one byte holding zero - or one is exactly what that dtype describes. Logical scalars are unchanged - too, in every kind — they cross by value and remain Python `bool`. - - With the widths agreeing, no conversion remains on any path. A logical array - argument is passed as the caller's own buffer rather than widened into a - native-kind temporary and narrowed back, and the post-call byte normalization - is gone: the representation is now correct at the source rather than repaired - at each boundary. - -- Fixed a module allocatable array with `target` reporting the wrong descriptor - facts. `target` let the bridge take the variable's address with `c_loc`, after - which the binding had to reconstruct the rest of the descriptor from - assumptions — a hardcoded lower bound of zero, unit stride, `sizeof` element - length. Fortran's default lower bound is one, so the reported bound was wrong - for every such array, not only for a declared bound: `allocate(a(4))` reported - zero instead of one, and `allocate(a(5:8))` reported zero instead of five. - - An allocatable or pointer dummy adopts the bounds of the descriptor it is - given, so this reached native code rather than staying a reported fact. A - procedure taking `real(real64), allocatable, intent(in) :: x(:)` saw - `lbound(x, 1) == 0` for an array allocated `(5:8)`, and `x(5)` read past the - end of four elements and returned whatever was there. Both declarations now - read the real descriptor, so the callee sees the bounds the array actually has - and indexes it correctly, and the element length of a `character` allocatable - is measured rather than assumed. The two paths are now one, which also removed the - hand-written descriptor reconstruction from generated C and made `Aliased` stop - selecting a different NumPy exposure for module allocatables. Python-visible - views are unchanged: NumPy indexing stays zero-based either way. - - `character` module allocatables are included. Their descriptor dummy is now - declared `allocatable` rather than assumed-shape, which is what carries the - declared bounds across — an assumed-shape dummy renumbers them from zero — and - their element length is read from the array instead of assumed from the - declaration. - - Deferred-length `character` previously failed to build at all: GCC 11 raised - an internal compiler error on the generated descriptor call. The cause was - that a module allocatable planned two byte-identical bridge procedures, one - for its descriptor and one for its data address, and GCC could not compile - both. The address operation now shares the descriptor procedure and passes a - callback that keeps only the address, so the duplicate is gone and the form - builds and reports its real bounds and element length. - -- Fixed a silent correctness bug in live views over Fortran `logical` arrays. A - borrowed view aliases native storage element for element, but every `logical` - kind was represented as NumPy's one-byte bool, so a view over a wider kind — - including the default `logical` on every toolchain PRIK tests — read the wrong - elements and reported wrong values with no error anywhere. Such a module array - or derived-type field is now refused with a diagnostic naming the width; - `logical(c_bool)` is unaffected and is still borrowed as a live view. This was - present for `target` arrays too, so it predates the borrowing change below. - -- Fixed derived-type array fields are now exposed through their base address and - extents rather than a C consumer callback receiving a Fortran descriptor. A - fixed component has a fixed rank and contiguous storage, so the descriptor - carried nothing the extents did not already give, and the callback round-trip - per attribute access is gone. Where the owner is reached as a pointer its - components are already addressable and `c_loc` names them directly; a member of - a module object declared without `target` uses `prik_capture_address`, the same - route a non-addressable module array takes. Python-visible behavior is - unchanged. - -- Fixed-shape module arrays are now exposed as live NumPy views whether or not - the Fortran declaration carries `target`. `target` is what lets `c_loc` name - a variable; it is not what gives a module array its address. For an ordinary - declaration the generated bridge now captures the base address on the C side, - the way f2py does: the whole array is handed to `prik_capture_address`, a - `bind(C)` primitive in the bundled support header whose assumed-type - assumed-size dummy receives the bare base address and hands it back. The - Fortran side forms no pointer and claims no target, and one interface covers - every element type and rank. The Python-facing - behavior is identical to the `target` form: one borrowed view over the real - module storage, writable in both directions, with whole-array replacement - still rejected. Previously such a variable was reported unsupported with - "ordinary module array requires addressable Aliased target storage". - - The Fortran standard does not require a module variable to keep one address - for the life of the program, so this borrow rests on how compilers lay out - module storage in practice rather than on a guarantee. It holds on the - toolchains PRIK tests; a future implementation that relocates module storage - (device offload, for example) could invalidate a view held across the move. - Declare `target` where you want the language to carry that weight. - +- Fixed Fortran allocatable and pointer descriptor arguments to use the + compiler's live descriptor. This works on Intel ifx as well as GNU Fortran, + preserves lower bounds, allocation and association changes, and covers + required and optional dummies reached from module variables, fields, results, + and caller-created handles. + +- Generated Fortran allocatable and pointer handles can satisfy matching + ordinary array arguments without conversion through NumPy. Supported forms + include explicit and assumed shape, positive strides, assumed size, assumed + rank 1 through 15, optional arrays, and fixed- or assumed-width character + arrays. C array arguments continue to accept NumPy arrays only. + +- Added a versioned native descriptor-operation table for generated array + handles. Ordinary argument handoff, shape queries, and NumPy views can use the + descriptor directly without serializing descriptor fields through Python. + +- Fixed writable module and derived-field allocatable arrays, and reject + PROTECTED module arrays during policy completion when writable access would be + required. + +- Fixed module array views and descriptor facts across fixed, target, + allocatable, pointer, shifted-bound, character, and logical storage. Ordinary + fixed-shape module arrays no longer require TARGET solely to expose a live + NumPy view. + +- PRIK now selects interoperable Fortran logical storage on Intel and + PGI/NVIDIA compilers. Use `--no-standard-logicals` or + `standard_logicals=False` only when linking Intel objects compiled without + that option. Wider logical arrays are exposed with their matching integer + dtype; `logical(c_bool)` remains `numpy.bool_`. ## 0.4.3 — 2026-08-31 - Republishes 0.4.2. That tag carried the previous package version, so the diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index bb6269727..77ff4706c 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -23,16 +23,19 @@ select a different view behavior from local descriptor facts. ## A Native Array Handle At Runtime ```text -generated operation dictionary + dtype, rank, ownership, and view policy +generated operation dictionary + native descriptor table + + dtype, rank, ownership, and view policy -> NativeArrayHandleBase validation and owner retention -> AllocatableArray or PointerArray -> state, lifecycle, association, and to_numpy() operations ``` The operation dictionary is the boundary between generated extension code and -the stable Python handle API. An operation exists only when the completed plan -allows the generator to expose it. Missing operations fail explicitly rather -than being inferred from `allocatable` or `pointer` alone. +the stable Python handle API. The versioned native table is the cross-extension +C boundary used to inspect a live descriptor without serializing it through +Python. An operation exists only when the completed plan allows the generator +to expose it. Missing operations fail explicitly rather than being inferred +from `allocatable` or `pointer` alone. ## Local Structure @@ -75,7 +78,8 @@ Resized shape: (4,) Generated resize received NumPy extents: True ``` -The example supplies the same operation-dictionary shape as generated code. +The example supplies the same Python operation-dictionary shape as generated +code. It creates an allocatable handle, reads its live NumPy view, and routes a resize through the adapter. The native header has no standalone Python route; the compiler installs it into a generated `binding_support/` directory. diff --git a/docs/user/guide/allocatables.md b/docs/user/guide/allocatables.md index f9dbeb843..1d75af20d 100644 --- a/docs/user/guide/allocatables.md +++ b/docs/user/guide/allocatables.md @@ -50,6 +50,11 @@ Use ordinary `T[...]` when the callable needs only array data: def sum_values(values: Float64[:]) -> Float64: ... ``` +An allocated handle may be passed to that ordinary Fortran array argument; +PRIK reads the live storage from its descriptor and applies the ordinary array +contract. This also works for optional, strided, flattened, and assumed-rank +ordinary arguments when their declared constraints match the handle. + A plain NumPy array cannot satisfy an `Allocatable[T[...]]` parameter because it does not carry native allocation state. Use `to_numpy()` when Python needs the current array data held by an allocatable handle. diff --git a/docs/user/guide/arrays.md b/docs/user/guide/arrays.md index cca59e2fc..d4e2929cf 100644 --- a/docs/user/guide/arrays.md +++ b/docs/user/guide/arrays.md @@ -550,6 +550,15 @@ Use this list when reading or editing a generated `.pyi` contract: leading axes flattened - `T[...]`: assumed-rank, currently rank 1-15 +An allocated `Allocatable[T[...]]` handle or associated `Pointer[T[...]]` +handle can also satisfy a matching ordinary Fortran array argument. The same +element type, rank, shape, layout, contiguity, and writeability requirements +apply as for a NumPy array. This includes explicit-shape, assumed-shape, +positive-strided, assumed-size/`Flat`, and assumed-rank arguments, plus +fixed-width and assumed-width character arrays. An absent handle is rejected; +pass `None` only when the ordinary argument itself is optional. C array +arguments accept NumPy arrays, not Fortran descriptor handles. + Generated contracts may describe a shape with visible arguments, such as `T[rows, columns]`. Most users should keep those generated relationships unchanged. If you need to edit a complex shape or use a native function to diff --git a/docs/user/guide/pointers.md b/docs/user/guide/pointers.md index 91c5b7d24..b9fe64301 100644 --- a/docs/user/guide/pointers.md +++ b/docs/user/guide/pointers.md @@ -49,10 +49,12 @@ Use ordinary `T[...]` when the callable needs only array data: def sum_values(values: Float64[:]) -> Float64: ... ``` -An associated pointer handle may satisfy an ordinary array parameter when its -dtype, rank, shape, layout, and contiguity meet that parameter's contract. A -plain NumPy array cannot satisfy a `Pointer[T[...]]` parameter because it does -not carry a native pointer descriptor. +An associated pointer handle may satisfy an ordinary Fortran array parameter +when its dtype, rank, shape, layout, contiguity, and writeability meet that +parameter's contract. Positive-strided targets are accepted by matching +strided arguments; optional, flattened, and assumed-rank ordinary arguments +use the same rules. A plain NumPy array cannot satisfy a `Pointer[T[...]]` +parameter because it does not carry a native pointer descriptor. --- diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index bfcacb59a..28b2c45a4 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -104,7 +104,6 @@ ModulePlan, ModuleVariablePlan, NamespacePlan, - NativeArrayActualPlan, NativeArrayHandlePlan, NativeEntrypointABIValueKind, NativeEntrypointABIValuePlan, @@ -3058,20 +3057,20 @@ def _fixed_string_field_input_nodes(self, field: DerivedFieldPlan, object_name: ), ) - def _field_handle_ops_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: + def _field_handle_backend_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: """Release the reference the published table capsule was created with.""" - if self._field_handle_ops_capsule_name(field, prefix) == "Py_None": + if self._field_handle_backend_capsule_name(field, prefix) == "Py_None": return () return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_ops)")),) - def _field_handle_ops_capsule_name(self, field: DerivedFieldPlan, prefix: str) -> str: + def _field_handle_backend_capsule_name(self, field: DerivedFieldPlan, prefix: str) -> str: """Return the local holding this field handle's published entry-point table.""" handle = field.native_array_handle if handle is None or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: return "Py_None" return f"{prefix}_native_ops" - def _field_handle_ops_capsule_nodes(self, owner, field: DerivedFieldPlan, prefix: str, owner_name: str) -> tuple: + def _field_handle_backend_capsule_nodes(self, owner, field: DerivedFieldPlan, prefix: str, owner_name: str) -> tuple: """Build the entry-point table this field handle publishes. A derived-type field reaches its entity through the parent's address, @@ -3088,7 +3087,7 @@ def _field_handle_ops_capsule_nodes(self, owner, field: DerivedFieldPlan, prefix return () parent = f"{prefix}_parent" address = f"{parent}_address" if isinstance(owner, DerivedTypePlan) else "NULL" - forward = self._field_handle_scoped_descriptor_name(self._field_handle_descriptor_callback(owner, field)) + forward = self._field_handle_with_descriptor_name(self._field_handle_descriptor_callback(owner, field)) capsule = f"{prefix}_native_ops" return ( *( @@ -3100,9 +3099,10 @@ def _field_handle_ops_capsule_nodes(self, owner, field: DerivedFieldPlan, prefix capsule, "PyObject *", CodeExpression( - "prik_native_array_ops_capsule_new(" - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " - f"{self._field_native_array_element_size(field)}, {address}, {forward})" + "prik_native_array_backend_capsule_new(" + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, " + f"{self._field_native_array_element_size(field)}, {address}, {forward}, NULL)" ), ), CIf(CodeExpression(f"{capsule} == NULL"), body=(CReturn(CodeExpression("NULL")),)), @@ -3122,7 +3122,7 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name # The entry-point table is built before anything that would need # releasing, so its failure paths can return without cleanup. nodes = [ - *self._field_handle_ops_capsule_nodes(owner, field, prefix, owner_name), + *self._field_handle_backend_capsule_nodes(owner, field, prefix, owner_name), CDeclaration(ops, "PyObject *", CodeExpression("PyDict_New()")), CDeclaration(operation_object, "PyObject *", CodeExpression("NULL")), CDeclaration(runtime, "PyObject *", CodeExpression("NULL")), @@ -3191,14 +3191,14 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name ops=ops, owner=owner_name, descriptor_ownership="borrowed", - native_ops=self._field_handle_ops_capsule_name(field, prefix), + native_ops=self._field_handle_backend_capsule_name(field, prefix), extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({helper})")), CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), - *self._field_handle_ops_release_nodes(field, prefix), + *self._field_handle_backend_release_nodes(field, prefix), CReturn(CodeExpression(result)), ) ) @@ -3639,7 +3639,7 @@ def _derived_field_c_type(self, field: DerivedFieldPlan) -> str: return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).c_spelling # Derived native-array-handle fields reuse the Phase 7 runtime protocol. - def _field_handle_scoped_descriptor_prototypes( + def _field_handle_with_descriptor_prototypes( self, field: DerivedFieldPlan, descriptor_callback: str, @@ -3650,12 +3650,12 @@ def _field_handle_scoped_descriptor_prototypes( return () return ( CFunctionPrototype( - self._field_handle_scoped_descriptor_name(descriptor_callback), + self._field_handle_with_descriptor_name(descriptor_callback), "void", ( - CParameter("owner", "void *"), - CParameter("consumer", "prik_native_array_descriptor_fn"), CParameter("context", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("consumer_context", "void *"), ), storage="static", ), @@ -3667,25 +3667,16 @@ def _derived_handle_operation_declarations( ) -> tuple[CFunctionPrototype | CDeclaration, ...]: """Declare every parent-bound field-handle callable and method record.""" declarations = [] - for _owner, field, operation_name, callback_names in self._derived_handle_targets(plan): - descriptor_callback, actual_callback = callback_names + for _owner, field, operation_name, descriptor_callback in self._derived_handle_targets(plan): # The getter that publishes this field's table is emitted before the # forwarder it names, so the forwarder is declared here. - declarations.extend(self._field_handle_scoped_descriptor_prototypes(field, descriptor_callback)) - declarations.extend( - ( - CFunctionPrototype( - descriptor_callback, - "void", - (CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - ), - CFunctionPrototype( - actual_callback, - "void", - (CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - ), + declarations.extend(self._field_handle_with_descriptor_prototypes(field, descriptor_callback)) + declarations.append( + CFunctionPrototype( + descriptor_callback, + "void", + (CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), + storage="static", ) ) handle = field.native_array_handle @@ -3713,7 +3704,7 @@ def _derived_handle_operation_declarations( def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: """Lower descriptor callbacks and parent-bound runtime operations.""" functions = [] - if self._emits_native_array_ops(plan): + if self._emits_native_array_backend(plan): # The shared consumer is defined once with the module-array # section, which follows these forwarders in the emitted file. functions.append( @@ -3724,14 +3715,13 @@ def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFuncti storage="static", ) ) - for owner, field, operation_name, callback_names in self._derived_handle_targets(plan): - descriptor_callback, actual_callback = callback_names - functions.extend(self._field_handle_descriptor_callbacks(field, descriptor_callback, actual_callback)) + for owner, field, operation_name, descriptor_callback in self._derived_handle_targets(plan): + functions.extend(self._field_handle_descriptor_callbacks(field, descriptor_callback)) functions.extend( - self._field_handle_ops_nodes( + self._field_handle_backend_nodes( field, self._field_handle_bridge_name(owner, field, NativeArrayOperation.DESCRIPTOR), - self._field_handle_scoped_descriptor_name(descriptor_callback), + self._field_handle_with_descriptor_name(descriptor_callback), takes_owner=isinstance(owner, DerivedTypePlan), ) ) @@ -3753,10 +3743,7 @@ def _derived_handle_targets(self, plan: ModulePlan) -> tuple[tuple, ...]: lambda operation, derived=derived, field=field: self._derived_handle_operation_name( derived, field, operation ), - ( - self._derived_handle_descriptor_callback_name(derived, field), - self._derived_handle_actual_callback_name(derived, field), - ), + self._derived_handle_descriptor_callback_name(derived, field), ) for derived in self._derived_types(plan) for field in derived.fields @@ -3769,10 +3756,7 @@ def _derived_handle_targets(self, plan: ModulePlan) -> tuple[tuple, ...]: lambda operation, variable=variable, member=member: self._module_member_handle_operation_name( variable, member, operation ), - ( - self._module_member_handle_descriptor_callback_name(variable, member), - self._module_member_handle_actual_callback_name(variable, member), - ), + self._module_member_handle_descriptor_callback_name(variable, member), ) for variable in self._derived_member_proxy_variables(plan) for member in variable.derived.member_paths @@ -3781,11 +3765,11 @@ def _derived_handle_targets(self, plan: ModulePlan) -> tuple[tuple, ...]: return tuple(targets) @staticmethod - def _field_handle_scoped_descriptor_name(descriptor_callback: str) -> str: + def _field_handle_with_descriptor_name(descriptor_callback: str) -> str: """Return the forwarder name that drives one field's descriptor bridge.""" - return f"{descriptor_callback}_scoped" + return f"{descriptor_callback}_with_descriptor" - def _field_handle_ops_nodes( + def _field_handle_backend_nodes( self, field: DerivedFieldPlan, descriptor_bridge: str, @@ -3809,22 +3793,22 @@ def _field_handle_ops_nodes( forward_name, "void", parameters=( - CParameter("owner", "void *"), - CParameter("consumer", "prik_native_array_descriptor_fn"), CParameter("context", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("consumer_context", "void *"), ), storage="static", body=( CDeclaration( "forwarded", "prik_native_array_descriptor_forward", - CodeExpression("{consumer, context}"), + CodeExpression("{consumer, consumer_context}"), ), # A module member reaches its field without a parent address. - *(() if takes_owner else (CExpressionStatement(CodeExpression("(void)owner")),)), + *(() if takes_owner else (CExpressionStatement(CodeExpression("(void)context")),)), CExpressionStatement( CodeExpression( - f"{descriptor_bridge}({'owner, ' if takes_owner else ''}" + f"{descriptor_bridge}({'context, ' if takes_owner else ''}" "prik_native_array_forward_descriptor, &forwarded)" ) ), @@ -3837,7 +3821,6 @@ def _field_handle_descriptor_callbacks( self, field: DerivedFieldPlan, descriptor_name: str, - actual_name: str, ) -> tuple[CFunction, ...]: """Decode one current field descriptor without copying its payload.""" handle = field.native_array_handle @@ -3857,17 +3840,7 @@ def _field_handle_descriptor_callbacks( ), ), ) - actual = CFunction( - actual_name, - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(void **)context = descriptor->base_addr")), - CReturn(), - ), - ) - return (descriptor, actual) + return (descriptor,) def _field_handle_operation_function( self, @@ -3887,13 +3860,6 @@ def _field_handle_operation_function( def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation: NativeArrayOperation) -> tuple: """Dispatch one operation without inferring descriptor ownership.""" - if operation in { - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, - }: - return self._module_native_array_metadata_body(operation) prefix = self._field_handle_owner_nodes(owner) owner_args = self._field_handle_owner_arguments(owner) if operation in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY}: @@ -3904,14 +3870,6 @@ def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation NativeArrayOperation.DESCRIPTOR, ) return (*prefix, *self._field_handle_descriptor_nodes(descriptor_bridge, owner_args, callback)) - if operation is NativeArrayOperation.ARRAY_ACTUAL: - callback = self._field_handle_actual_callback(owner, field) - descriptor_bridge = self._field_handle_bridge_name( - owner, - field, - NativeArrayOperation.DESCRIPTOR, - ) - return (*prefix, *self._field_handle_actual_nodes(descriptor_bridge, owner_args, callback)) bridge = self._field_handle_bridge_name(owner, field, operation) if operation in { NativeArrayOperation.ALLOCATED, @@ -3984,13 +3942,6 @@ def _field_handle_descriptor_callback(self, owner, field: DerivedFieldPlan) -> s variable, member = owner return self._module_member_handle_descriptor_callback_name(variable, member) - def _field_handle_actual_callback(self, owner, field: DerivedFieldPlan) -> str: - """Build field handle actual callback from the supplied completed binding records; emitted nodes only project completed binding actions.""" - if isinstance(owner, DerivedTypePlan): - return self._derived_handle_actual_callback_name(owner, field) - variable, member = owner - return self._module_member_handle_actual_callback_name(variable, member) - def _field_handle_shape_nodes(self, field: DerivedFieldPlan, bridge: str, owner_args: str) -> tuple: """Build field handle shape nodes from the supplied completed binding records; emitted nodes only project completed binding actions.""" handle = field.native_array_handle @@ -4026,16 +3977,6 @@ def _field_handle_descriptor_nodes(bridge: str, owner_args: str, callback: str) CReturn(CodeExpression("descriptor_record")), ) - @staticmethod - def _field_handle_actual_nodes(bridge: str, owner_args: str, callback: str) -> tuple: - """Build field handle actual nodes from the supplied local lowering values; emitted nodes only project completed binding actions.""" - arguments = ", ".join((*((owner_args,) if owner_args else ()), callback, "&base_addr")) - return ( - CDeclaration("base_addr", "void *", CodeExpression("NULL")), - CExpressionStatement(CodeExpression(f"{bridge}({arguments})")), - CReturn(CodeExpression("PyLong_FromVoidPtr(base_addr)")), - ) - def _field_handle_shape_mutation_nodes(self, field: DerivedFieldPlan, bridge: str, owner_args: str) -> tuple: """Build field handle shape mutation nodes from the supplied completed binding records; emitted nodes only project completed binding actions.""" handle = field.native_array_handle @@ -4190,16 +4131,11 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction self._native_array_capsule_release_function(result) for _function, result in self._owned_native_array_results(plan) ), - *( - node - for _function, result in self._owned_native_array_results(plan) - for node in self._owned_result_ops_nodes(result) - ), *( self._native_array_capsule_release_function(argument) for _function, argument in self._default_native_array_arguments(plan) ), - *((self._native_array_forward_descriptor_function(),) if self._emits_native_array_ops(plan) else ()), + *((self._native_array_forward_descriptor_function(),) if self._emits_native_array_backend(plan) else ()), *self._array_actual_reader_functions(plan), *self._inverted_descriptor_consumer_functions(plan), *( @@ -4228,7 +4164,7 @@ def _native_array_capsule_release_function( """Release descriptor payload through the module that created its record.""" descriptor = "owner_descriptor" body: tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...] = ( - CDeclaration(descriptor, "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)storage")), + CDeclaration(descriptor, "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)context")), CIf(CodeExpression(f"{descriptor} == NULL"), body=(CReturn(),)), ) handle = plan.native_array_handle @@ -4256,7 +4192,7 @@ def _native_array_capsule_release_function( return CFunction( self._native_array_capsule_release_name(plan), "void", - parameters=(CParameter("storage", "void *"),), + parameters=(CParameter("context", "void *"),), storage="static", body=body, ) @@ -4312,40 +4248,21 @@ def _module_native_array_operation_body( operation: NativeArrayOperation, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: """Dispatch one module operation without rediscovering semantic policy.""" - if operation in { - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, - }: - return self._module_native_array_metadata_body(operation) if operation in { NativeArrayOperation.ALLOCATED, NativeArrayOperation.ASSOCIATED, NativeArrayOperation.CONTIGUOUS, NativeArrayOperation.ELEMENT_LENGTH, - NativeArrayOperation.ARRAY_ACTUAL, }: return self._module_native_array_query_body(variable, operation) return self._module_native_array_data_operation_body(variable, operation) - @staticmethod - def _module_native_array_metadata_body( - operation: NativeArrayOperation, - ) -> tuple[CReturn, ...]: - """Return binding-known metadata that requires no bridge call.""" - if operation is NativeArrayOperation.LAYOUT: - return (CReturn(CodeExpression('PyUnicode_FromString("F")')),) - return (CReturn(CodeExpression("PyBool_FromLong(1)")),) - def _module_native_array_query_body( self, variable: ModuleVariablePlan, operation: NativeArrayOperation, ) -> tuple[CReturn, ...]: """Return one scalar fact queried from the native bridge.""" - if operation is NativeArrayOperation.ARRAY_ACTUAL and self._uses_module_allocatable_descriptor(variable): - return self._module_allocatable_array_actual_body(variable) call = f"{self._module_native_array_bridge_operation_name(variable, operation)}()" if operation in { NativeArrayOperation.ALLOCATED, @@ -4356,7 +4273,7 @@ def _module_native_array_query_body( elif operation is NativeArrayOperation.ELEMENT_LENGTH: expression = f"PyLong_FromLongLong((long long){call})" else: - expression = f"PyLong_FromVoidPtr({call})" + raise ValueError(f"Module handle query {operation!r} has no scalar lowering") return (CReturn(CodeExpression(expression)),) def _module_native_array_data_operation_body( @@ -4439,7 +4356,7 @@ def _module_native_array_descriptor_body( return self._module_allocatable_descriptor_body(variable) if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: return self._module_pointer_descriptor_body(variable) - return self._module_contiguous_descriptor_body(variable) + raise ValueError(f"Module handle {variable.owner_path!r} has no planned descriptor handoff") @staticmethod def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: @@ -4476,31 +4393,11 @@ def _module_allocatable_descriptor_body( CReturn(CodeExpression("descriptor_record")), ) - def _module_allocatable_array_actual_body( - self, - variable: ModuleVariablePlan, - ) -> tuple[CDeclaration | CExpressionStatement | CReturn, ...]: - """Request the current standard descriptor and expose only its data address. - - This shares the descriptor operation rather than declaring one of its - own: the two would be the same procedure, and only the callback differs. - """ - return ( - CDeclaration("base_addr", "void *", CodeExpression("NULL")), - CExpressionStatement( - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR)}(" - f"{self._module_array_actual_callback_name(variable)}, &base_addr)" - ) - ), - CReturn(CodeExpression("PyLong_FromVoidPtr(base_addr)")), - ) - def _module_allocatable_descriptor_callbacks( self, variable: ModuleVariablePlan, ) -> tuple[CFunction, ...]: - """Return C consumers for descriptor-record and data-address operations.""" + """Return the C consumer for descriptor-record operations.""" if not self._uses_module_allocatable_descriptor(variable): return () handle = variable.native_array_handle @@ -4520,23 +4417,12 @@ def _module_allocatable_descriptor_callbacks( ), ), ) - array_actual_callback = CFunction( - self._module_array_actual_callback_name(variable), - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(void **)context = descriptor->base_addr")), - CReturn(), - ), - ) return ( descriptor_callback, - array_actual_callback, - *self._module_native_array_ops_nodes(variable, handle), + *self._module_native_array_backend_nodes(variable, handle), ) - def _module_native_array_ops_nodes( + def _module_native_array_backend_nodes( self, variable: ModuleVariablePlan, handle: NativeArrayHandlePlan, @@ -4552,7 +4438,7 @@ def _module_native_array_ops_nodes( if cfi_type is None: return () bridge = self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR) - forward = self._module_scoped_descriptor_name(variable) + forward = self._module_with_descriptor_name(variable) element_size = ( "0" if variable.datatype_family is DatatypeFamily.STRING @@ -4563,35 +4449,35 @@ def _module_native_array_ops_nodes( forward, "void", parameters=( - CParameter("owner", "void *"), - CParameter("consumer", "prik_native_array_descriptor_fn"), CParameter("context", "void *"), + CParameter("consumer", "prik_native_array_descriptor_fn"), + CParameter("consumer_context", "void *"), ), storage="static", body=( CDeclaration( "forwarded", "prik_native_array_descriptor_forward", - CodeExpression("{consumer, context}"), + CodeExpression("{consumer, consumer_context}"), ), - CExpressionStatement(CodeExpression("(void)owner")), + CExpressionStatement(CodeExpression("(void)context")), CExpressionStatement(CodeExpression(f"{bridge}(prik_native_array_forward_descriptor, &forwarded)")), CReturn(), ), ), CDeclaration( - self._module_native_array_ops_name(variable), - "static prik_native_array_ops", + self._module_native_array_backend_name(variable), + "static prik_native_array_backend", CodeExpression( - "{PRIK_NATIVE_ARRAY_OPS_MAGIC, PRIK_NATIVE_ARRAY_OPS_ABI_VERSION, " - "(uint32_t)sizeof(prik_native_array_ops), " - f"{self._native_array_handle_kind_constant(handle)}, " - f"{handle.array.rank}, {cfi_type}, {element_size}, NULL, {forward}}}" + "{(uint32_t)sizeof(prik_native_array_backend), " + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, {element_size}, " + f"NULL, {forward}, NULL}}" ), ), ) - def _emits_native_array_ops(self, plan: ModulePlan) -> bool: + def _emits_native_array_backend(self, plan: ModulePlan) -> bool: """Report whether any handle in this module publishes an entry-point table.""" if any( self._uses_module_allocatable_descriptor(variable) for variable in self._module_native_array_variables(plan) @@ -4622,13 +4508,13 @@ def _native_array_forward_descriptor_function() -> CFunction: ), ) - def _module_native_array_ops_capsule_name(self, variable: ModuleVariablePlan, prefix: str) -> str: + def _module_native_array_backend_capsule_name(self, variable: ModuleVariablePlan, prefix: str) -> str: """Return the local holding this variable's published entry-point table.""" if not self._uses_module_allocatable_descriptor(variable): return "Py_None" return f"{prefix}_native_ops" - def _module_native_array_ops_declaration_nodes( + def _module_native_array_backend_declaration_nodes( self, variable: ModuleVariablePlan, prefix: str, @@ -4640,11 +4526,11 @@ def _module_native_array_ops_declaration_nodes( CDeclaration( f"{prefix}_native_ops", "PyObject *", - CodeExpression(self._module_native_array_ops_capsule(variable)), + CodeExpression(self._module_native_array_backend_capsule(variable)), ), ) - def _module_native_array_ops_release_nodes( + def _module_native_array_backend_release_nodes( self, variable: ModuleVariablePlan, prefix: str, @@ -4654,19 +4540,19 @@ def _module_native_array_ops_release_nodes( return () return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_ops)")),) - def _module_native_array_ops_capsule(self, variable: ModuleVariablePlan) -> str: + def _module_native_array_backend_capsule(self, variable: ModuleVariablePlan) -> str: """Return the expression publishing this variable's native entry-point table.""" if not self._uses_module_allocatable_descriptor(variable): return "Py_None" return ( - f"PyCapsule_New(&{self._module_native_array_ops_name(variable)}, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME, NULL)" + f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, NULL)" ) - def _module_scoped_descriptor_name(self, variable: ModuleVariablePlan) -> str: + def _module_with_descriptor_name(self, variable: ModuleVariablePlan) -> str: """Return the forwarder name that drives this variable's descriptor bridge.""" - return f"{self._module_descriptor_callback_name(variable)}_scoped" + return f"{self._module_descriptor_callback_name(variable)}_with_descriptor" - def _module_native_array_ops_name(self, variable: ModuleVariablePlan) -> str: + def _module_native_array_backend_name(self, variable: ModuleVariablePlan) -> str: """Return the file-scope native entry-point table name for one module array.""" return f"{self._module_descriptor_callback_name(variable)}_ops" @@ -4675,75 +4561,6 @@ def _module_descriptor_callback_name(self, variable: ModuleVariablePlan) -> str: owner = re.sub(r"\W", "_", variable.owner_path).casefold() return f"prik_module_{owner}_descriptor_callback" - def _module_array_actual_callback_name(self, variable: ModuleVariablePlan) -> str: - """Return the binding-local module array actual callback name derived from the supplied completed binding records; this helper preserves completed policy.""" - owner = re.sub(r"\W", "_", variable.owner_path).casefold() - return f"prik_module_{owner}_array_actual_callback" - - def _module_contiguous_descriptor_body( - self, - variable: ModuleVariablePlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Build allocatable descriptor facts from native data and extents.""" - handle = variable.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Module handle {variable.owner_path!r} has no descriptor rank") - rank = handle.array.rank - scalar_type = ( - None - if variable.datatype_family is DatatypeFamily.STRING - else PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name) - ) - extents = tuple(f"extent_{axis}" for axis in range(rank)) - elem_len = ( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.ELEMENT_LENGTH)}()" - if variable.datatype_family is DatatypeFamily.STRING - else f"sizeof({scalar_type.c_spelling})" - ) - nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ - CDeclaration( - "base_addr", - "void *", - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.ARRAY_ACTUAL)}()" - ), - ), - *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in extents), - CExpressionStatement( - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.SHAPE)}(" - f"{', '.join(f'&{name}' for name in extents)})" - ) - ), - CDeclaration("dimensions", "PyObject *", CodeExpression(f"PyList_New({rank})")), - CIf(CodeExpression("dimensions == NULL"), body=(CReturn(CodeExpression("NULL")),)), - CDeclaration("stride", "int64_t", CodeExpression(elem_len)), - ] - for axis, extent in enumerate(extents): - nodes.extend( - ( - CDeclaration( - f"dimension_{axis}", - "PyObject *", - CodeExpression( - f'Py_BuildValue("{{sL,sL,sL}}", "lower_bound", (long long)0, ' - f'"extent", (long long){extent}, "sm", (long long)stride)' - ), - ), - CIf( - CodeExpression(f"dimension_{axis} == NULL"), - body=( - CExpressionStatement(CodeExpression("Py_DECREF(dimensions)")), - CReturn(CodeExpression("NULL")), - ), - ), - CExpressionStatement(CodeExpression(f"PyList_SET_ITEM(dimensions, {axis}, dimension_{axis})")), - CExpressionStatement(CodeExpression(f"stride *= ({extent} > 0 ? {extent} : 1)")), - ) - ) - nodes.extend(self._descriptor_record_return_nodes("base_addr", elem_len, rank)) - return tuple(nodes) - def _module_pointer_descriptor_body( self, variable: ModuleVariablePlan, @@ -4949,17 +4766,12 @@ def _default_native_array_binder_function( nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ CDeclaration("handle_obj", "PyObject *"), CDeclaration("owner_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")), - # `owner_descriptor` is cleared once its ownership moves to the - # handle capsule, so the table's non-owning reference to the same - # storage is kept separately. - CDeclaration("owner_storage", "void *", CodeExpression("NULL")), CDeclaration("owner_status", "int", CodeExpression("CFI_SUCCESS")), CDeclaration("ops", "PyObject *", CodeExpression("NULL")), CDeclaration("operation", "PyObject *", CodeExpression("NULL")), CDeclaration("owner_obj", "PyObject *", CodeExpression("NULL")), CDeclaration("runtime", "PyObject *", CodeExpression("NULL")), CDeclaration("helper", "PyObject *", CodeExpression("NULL")), - CDeclaration("native_ops", "PyObject *", CodeExpression("NULL")), CDeclaration("result", "PyObject *", CodeExpression("NULL")), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &handle_obj)) return NULL')), CExpressionStatement( @@ -4992,7 +4804,6 @@ def _default_native_array_binder_function( CReturn(CodeExpression("NULL")), ), ), - CExpressionStatement(CodeExpression("owner_storage = (void *)owner_descriptor")), CExpressionStatement(CodeExpression("ops = PyDict_New()")), CIf( CodeExpression("ops == NULL"), @@ -5066,34 +4877,16 @@ def _default_native_array_binder_function( CReturn(CodeExpression("NULL")), ), ), - # The attached storage is the wrapper's own, so it can be - # published as an entry-point table. Every later call then - # reaches the descriptor from C instead of coming back here. - CExpressionStatement( - CodeExpression( - f"native_ops = prik_native_array_ops_capsule_new(" - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " - f"{cfi_type}, {elem_len}, owner_storage, " - f"prik_native_array_owned_scoped_descriptor)" - ) - ), - CIf( - CodeExpression("native_ops == NULL"), - body=( - CExpressionStatement(CodeExpression("Py_DECREF(helper)")), - CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), - CReturn(CodeExpression("NULL")), - ), - ), + # The backend over the attached storage is what the handle + # carries as its owner and what a later call reads directly, so + # it is published once and handed over under both names. CExpressionStatement( CodeExpression( f'result = PyObject_CallFunction(helper, "OssiOOszOO", handle_obj, ' f'"{handle.descriptor_kind.value}", "{dtype}", {handle.array.rank}, ops, owner_obj, ' - f'"{default.descriptor_ownership.value}", {exposure}, Py_None, native_ops)' + f'"{default.descriptor_ownership.value}", {exposure}, Py_None, owner_obj)' ) ), - CExpressionStatement(CodeExpression("Py_DECREF(native_ops)")), CExpressionStatement(CodeExpression("Py_DECREF(helper)")), CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), CExpressionStatement(CodeExpression("Py_DECREF(ops)")), @@ -5123,10 +4916,6 @@ def _owned_native_array_operation_body( ) handler = self._owned_native_array_operation_handler(operation) materialize_descriptor = operation not in { - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.DESTROY, } @@ -5145,14 +4934,9 @@ def _owned_native_array_operation_handler(self, operation: NativeArrayOperation) NativeArrayOperation.SHAPE: self._owned_native_array_shape_body, NativeArrayOperation.TO_NUMPY: self._owned_native_array_to_numpy_body, NativeArrayOperation.ELEMENT_LENGTH: self._owned_native_array_element_length_body, - NativeArrayOperation.ARRAY_ACTUAL: self._owned_native_array_actual_body, NativeArrayOperation.DESCRIPTOR: self._owned_native_array_descriptor_body, NativeArrayOperation.ALLOCATED: self._owned_native_array_allocated_body, NativeArrayOperation.ASSOCIATED: self._owned_native_array_associated_body, - NativeArrayOperation.NATIVE_BYTE_ORDER: self._owned_native_array_true_body, - NativeArrayOperation.ALIGNED: self._owned_native_array_true_body, - NativeArrayOperation.WRITEABLE: self._owned_native_array_true_body, - NativeArrayOperation.LAYOUT: self._owned_native_array_layout_body, NativeArrayOperation.CONTIGUOUS: self._owned_native_array_contiguous_body, NativeArrayOperation.DEALLOCATE: self._owned_native_array_deallocate_body, NativeArrayOperation.NULLIFY: self._owned_native_array_nullify_body, @@ -5295,10 +5079,6 @@ def _owned_native_array_shape_body( CReturn(CodeExpression(f'Py_BuildValue("({",".join("L" for _ in dimensions)})", {", ".join(dimensions)})')), ) - def _owned_native_array_actual_body(self, _result: ResultPlan) -> tuple[CReturn, ...]: - """Expose the current owned allocation data address.""" - return (CReturn(CodeExpression("PyLong_FromVoidPtr(owner_descriptor->base_addr)")),) - def _owned_native_array_element_length_body(self, _result: ResultPlan) -> tuple[CReturn, ...]: """Expose the current deferred character element width.""" return (CReturn(CodeExpression("PyLong_FromSize_t(owner_descriptor->elem_len)")),) @@ -5349,14 +5129,6 @@ def _owned_native_array_bridge_state_body( ), ) - def _owned_native_array_true_body(self, _result: ResultPlan) -> tuple[CReturn, ...]: - """Return one invariant true array capability.""" - return (CReturn(CodeExpression("PyBool_FromLong(1)")),) - - def _owned_native_array_layout_body(self, _result: ResultPlan) -> tuple[CReturn, ...]: - """Return the planned Fortran layout marker.""" - return (CReturn(CodeExpression('PyUnicode_FromString("F")')),) - def _owned_native_array_deallocate_body( self, result: ResultPlan, @@ -5377,7 +5149,7 @@ def _owned_native_array_destroy_body( ) -> tuple[CExpressionStatement, ...]: """Destroy payload and persistent owner storage.""" return ( - CExpressionStatement(CodeExpression("prik_native_array_handle_release(owner_handle)")), + CExpressionStatement(CodeExpression("prik_native_array_backend_release(owner_backend)")), CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) @@ -5395,7 +5167,7 @@ def _owned_native_array_owner_nodes( return ( CDeclaration(f"{prefix}_obj", "PyObject *"), *(CDeclaration(name, "PyObject *") for name in trailing_objects), - CDeclaration(f"{prefix}_handle", "prik_native_array_handle *", CodeExpression("NULL")), + CDeclaration(f"{prefix}_backend", "prik_native_array_backend *", CodeExpression("NULL")), *( (CDeclaration(f"{prefix}_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")),) if materialize_descriptor @@ -5410,17 +5182,17 @@ def _owned_native_array_owner_nodes( ), CExpressionStatement( CodeExpression( - f"{prefix}_handle = prik_native_array_handle_from_capsule({prefix}_obj, " - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " - f"{self._native_array_expected_element_size(plan)}, " - f"sizeof(CFI_CDESC_T({handle.array.rank})))" + f"{prefix}_backend = prik_native_array_backend_for_descriptor({prefix}_obj, " + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, " + f"{self._native_array_expected_element_size(plan)})" ) ), - CExpressionStatement(CodeExpression(f"if ({prefix}_handle == NULL) return NULL")), + CExpressionStatement(CodeExpression(f"if ({prefix}_backend == NULL) return NULL")), *( ( CExpressionStatement( - CodeExpression(f"{prefix}_descriptor = (CFI_cdesc_t *){prefix}_handle->descriptor") + CodeExpression(f"{prefix}_descriptor = (CFI_cdesc_t *){prefix}_backend->context") ), ) if materialize_descriptor @@ -5679,7 +5451,7 @@ def _owned_native_array_shape_mutation_body( nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ CDeclaration("owner_obj", "PyObject *"), *(CDeclaration(name, "PyObject *") for name in extent_objects), - CDeclaration("owner_handle", "prik_native_array_handle *", CodeExpression("NULL")), + CDeclaration("owner_backend", "prik_native_array_backend *", CodeExpression("NULL")), CDeclaration("owner_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")), CDeclaration(f"lower_bounds[{rank}]", "CFI_index_t"), CDeclaration(f"upper_bounds[{rank}]", "CFI_index_t"), @@ -5689,13 +5461,14 @@ def _owned_native_array_shape_mutation_body( ), CExpressionStatement( CodeExpression( - "owner_handle = prik_native_array_handle_from_capsule(owner_obj, " - f"{self._native_array_handle_kind_constant(handle)}, {rank}, {cfi_type}, " - f"{self._native_array_expected_element_size(result)}, sizeof(CFI_CDESC_T({rank})))" + "owner_backend = prik_native_array_backend_for_descriptor(owner_obj, " + f"{self._native_array_handle_kind_constant(handle)}, {rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({rank})), {cfi_type}, " + f"{self._native_array_expected_element_size(result)})" ) ), - CExpressionStatement(CodeExpression("if (owner_handle == NULL) return NULL")), - CExpressionStatement(CodeExpression("owner_descriptor = (CFI_cdesc_t *)owner_handle->descriptor")), + CExpressionStatement(CodeExpression("if (owner_backend == NULL) return NULL")), + CExpressionStatement(CodeExpression("owner_descriptor = (CFI_cdesc_t *)owner_backend->context")), ] for axis, item in enumerate(extent_objects): nodes.extend( @@ -6120,7 +5893,7 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> CDeclaration(f"{prefix}_operation", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_runtime", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")), - *self._module_native_array_ops_declaration_nodes(plan, prefix), + *self._module_native_array_backend_declaration_nodes(plan, prefix), CIf(CodeExpression(f"{prefix}_ops == NULL"), body=(CReturn(CodeExpression("NULL")),)), ] for operation in handle.operations: @@ -6188,14 +5961,14 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> ops=f"{prefix}_ops", owner=f"{owner} != NULL ? {owner} : Py_None", descriptor_ownership="borrowed", - native_ops=self._module_native_array_ops_capsule_name(plan, prefix), + native_ops=self._module_native_array_backend_capsule_name(plan, prefix), extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_helper)")), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), - *self._module_native_array_ops_release_nodes(plan, prefix), + *self._module_native_array_backend_release_nodes(plan, prefix), CIf(CodeExpression(f"{cache} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement(CodeExpression(f"Py_INCREF({cache})")), CReturn(CodeExpression(cache)), @@ -7257,6 +7030,16 @@ def _lower_argument_required_array_actual( names = context.arguments[plan.owner_path] prefix = names.value_name array_object = f"(PyArrayObject *){names.object_name}" + if not self._takes_array_handle(plan): + outlined = self._outlined_array_bind_nodes(plan, context, names) + if outlined is not None: + return (*self._ordinary_array_argument_declarations(plan, names), *outlined) + return ( + *self._ordinary_array_argument_declarations(plan, names), + self._array_validation_statement(plan, names), + *self._array_shape_checks(plan, context, array_object), + *self._array_extraction_nodes(plan, names, array_object), + ) direct_nodes = ( self._array_validation_statement(plan, names, object_kind_checked=True), *self._array_shape_checks(plan, context, array_object), @@ -7267,7 +7050,6 @@ def _lower_argument_required_array_actual( if outlined is not None: return (*self._ordinary_array_argument_declarations(plan, names), *outlined) handle_nodes = ( - CDeclaration(f"{prefix}_shape", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_actual", "prik_array_actual"), *self._native_array_actual_call_nodes(plan, context, names), *self._native_array_actual_unpack_nodes(plan, names), @@ -7297,7 +7079,6 @@ def _outlined_array_bind_nodes( if fixed is None: return None array = plan.array - actual = plan.native_array_actual rank = array.rank prefix = names.value_name numpy_type, python_type = self._array_dtype_selectors(plan, array) @@ -7313,19 +7094,8 @@ def _outlined_array_bind_nodes( int(array.contiguous is True), int(plan.binding.writable), f'"{python_type}"', - f'"{actual.dtype}"', f'"{plan.binding.python_name}"', - "NULL" if actual.order is None else f'"{actual.order}"', array.flat_axis if array.flatten_python_storage else rank - 1, - int(actual.writable), - int(actual.require_native_byte_order), - int(actual.require_aligned), - 0, - 0, - 0, - int(actual.require_contiguous), - int(actual.flatten_storage), - self._native_array_actual_flat_axis(actual), ) ) # Declarations hoist above argument parsing, so a required extent that @@ -7410,8 +7180,8 @@ def _array_actual_table_nodes( body=( CExpressionStatement( CodeExpression( - f'PyErr_Format(PyExc_TypeError, "%s handle dtype dtype(\'S%zu\') does not ' - f'match expected dtype dtype(\'S%d\')", ' + f"PyErr_Format(PyExc_TypeError, \"%s handle dtype dtype('S%zu') does not " + f"match expected dtype dtype('S%d')\", " f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " f'? "pointer" : "allocatable", {found}.width, ' f"{self._declared_character_width(plan)})" @@ -7465,8 +7235,12 @@ def _array_actual_table_nodes( ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_backend *", CodeExpression("NULL")), CDeclaration(found, record), + CExpressionStatement(CodeExpression(f"{found}.present = 0")), + CExpressionStatement(CodeExpression(f"{found}.contiguous = 0")), + CExpressionStatement(CodeExpression(f"{found}.refused = 1")), + CExpressionStatement(CodeExpression(f"{found}.width = 0")), CExpressionStatement( CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') ), @@ -7476,9 +7250,8 @@ def _array_actual_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, " - # A flattened dummy takes an actual of any rank. - f"{0 if self._flattened_reader_axis(plan) is not None else rank}, " + f"{table} = prik_native_array_backend_for_actual({capsule}, " + f"{plan.array.minimum_rank}, {plan.array.maximum_rank}, " f"{self._native_array_cfi_type(plan)}, " f"{self._native_array_expected_element_size(plan)}, " f'"{plan.native_array_actual.dtype}", "{plan.binding.python_name}")' @@ -7487,17 +7260,35 @@ def _array_actual_table_nodes( CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement( - CodeExpression(f"{table}->scoped_descriptor({table}->owner, {reader}, &{found})") + CodeExpression(f"{table}->with_descriptor({table}->context, {reader}, &{found})") ), *checks, ), else_body=( CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), + CIf( + CodeExpression(f"!PyArray_Check({names.object_name})"), + body=(self._native_array_actual_type_refusal(plan, names),), + ), fallback, ), ), ) + @staticmethod + def _native_array_actual_type_refusal( + plan: ArgumentTransferPlan, + names: _CArgumentNames, + ) -> CExpressionStatement: + """Report a value that is neither an ndarray nor an accepted handle.""" + return CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "Expected a compatible {plan.native_array_actual.dtype} array or ' + f'native array handle for argument {plan.binding.python_name}. Received %s", ' + f"Py_TYPE({names.object_name})->tp_name); return NULL" + ) + ) + def _inline_array_actual_handle_arguments(self, plan: ModulePlan): """Return array arguments that take a handle through the shared record. @@ -7517,7 +7308,7 @@ def _inline_array_actual_handle_arguments(self, plan: ModulePlan): def _takes_array_handle(argument: ArgumentTransferPlan) -> bool: """Return whether an ordinary array argument accepts an array handle.""" actual = argument.native_array_actual - if actual is None or argument.array is None or argument.array.rank is None: + if actual is None or argument.array is None: return False return bool( { @@ -7647,50 +7438,31 @@ def _native_array_actual_call_nodes( context: _CFunctionContext, names: _CArgumentNames, ) -> tuple[CExpressionStatement, ...]: - """Call the shared normal-array native-handle slow path.""" + """Read a normal-array native handle through its descriptor table.""" actual = plan.native_array_actual if actual is None: return () prefix = names.value_name - layout = "NULL" if actual.order is None else f'"{actual.order}"' - table_nodes = self._native_array_actual_table_nodes(plan, names) - nodes = [ - *self._native_array_actual_shape_object_nodes(plan, names), - *self._native_array_actual_shape_nodes(plan, context, names), - CExpressionStatement( - CodeExpression( - f'if (prik_array_actual_unpack({names.object_name}, "{actual.dtype}", ' - f"{self._native_array_actual_expected_rank(actual)}, {prefix}_shape, {layout}, " - f"{int(actual.writable)}, {int(actual.require_native_byte_order)}, {int(actual.require_aligned)}, " - f"{int(plan.array.runtime_rank_role is not None)}, " - f"{int(plan.array.itemsize_role is not None)}, {int(bool(plan.array.stride_roles))}, " - f"{int(actual.require_contiguous)}, {int(actual.flatten_storage)}, " - f"{self._native_array_actual_flat_axis(actual)}, &{prefix}_actual) < 0) {{ " - f"Py_DECREF({prefix}_shape); return NULL; }}" - ) - ), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_shape)")), - ] + table_nodes = self._native_array_actual_table_nodes(plan, context, names) + refuse = self._native_array_actual_type_refusal(plan, names) if not table_nodes: - return tuple(nodes) - # The handle already named its storage, so the shared path runs only - # when nothing filled the record. + return (refuse,) return ( *table_nodes, - CIf(CodeExpression(f"{prefix}_actual.data == NULL"), body=tuple(nodes)), + CIf(CodeExpression(f"{prefix}_actual.data == NULL"), body=(refuse,)), ) def _native_array_actual_table_nodes( self, plan: ArgumentTransferPlan, + context: _CFunctionContext, names: _CArgumentNames, ) -> tuple: """Fill the array-actual record from a handle's table when it has one. A handle standing for native storage names it through its table, so the record is filled here rather than assembled by asking the runtime one - operation at a time. Anything else leaves it empty and takes the - shared path. + operation at a time. Anything without a compatible table is refused. """ if not self._inline_array_actual_fast_path(plan): return () @@ -7699,7 +7471,6 @@ def _native_array_actual_table_nodes( table = f"{prefix}_table" record = self._array_actual_struct_reader_record_name(plan) found = f"{prefix}_table_result" - width = self._declared_character_width(plan) refusals = ( CIf( CodeExpression(f"{found}.refused == 1"), @@ -7721,7 +7492,7 @@ def _native_array_actual_table_nodes( CExpressionStatement( CodeExpression( f'PyErr_Format(PyExc_TypeError, "handle with %zu-byte elements does not match ' - f'expected dtype {plan.native_array_actual.dtype} for argument ' + f"expected dtype {plan.native_array_actual.dtype} for argument " f'{plan.binding.python_name}", {found}.width)' ) ), @@ -7740,15 +7511,27 @@ def _native_array_actual_table_nodes( CReturn(CodeExpression("NULL")), ), ), + CIf( + CodeExpression(f"{found}.refused == 4"), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "native array handle descriptor rank %d is outside ' + f'the supported range {plan.array.minimum_rank}..{plan.array.maximum_rank}", ' + f"{found}.rank)" + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_backend *", CodeExpression("NULL")), CDeclaration(found, record), CExpressionStatement(CodeExpression(f"{found}.actual = &{prefix}_actual")), - CExpressionStatement(CodeExpression(f"{found}.refused = 0")), + CExpressionStatement(CodeExpression(f"{found}.refused = 1")), CExpressionStatement(CodeExpression(f"{found}.width = 0")), - CExpressionStatement(CodeExpression(f"(void){width}")), CExpressionStatement(CodeExpression(f"{prefix}_actual.data = NULL")), CExpressionStatement( CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') @@ -7759,8 +7542,9 @@ def _native_array_actual_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_ops_actual_from_capsule({capsule}, " - f"{plan.array.rank}, {self._native_array_cfi_type(plan)}, " + f"{table} = prik_native_array_backend_for_actual({capsule}, " + f"{plan.array.minimum_rank}, {plan.array.maximum_rank}, " + f"{self._native_array_cfi_type(plan)}, " f"{self._native_array_expected_element_size(plan)}, " f'"{plan.native_array_actual.dtype}", "{plan.binding.python_name}")' ) @@ -7769,53 +7553,39 @@ def _native_array_actual_table_nodes( CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement( CodeExpression( - f"{table}->scoped_descriptor({table}->owner, " + f"{table}->with_descriptor({table}->context, " f"{self._array_actual_struct_reader_name(plan)}, &{found})" ) ), *refusals, + *self._native_array_actual_shape_checks(plan, context, names, found), + ), + else_body=( + CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "Expected a compatible numpy.ndarray or native ' + f"array handle for argument {plan.binding.python_name}. Received \", " + f"Py_TYPE({names.object_name})->tp_name)" + ) + ), + CReturn(CodeExpression("NULL")), ), - else_body=(CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")),), ), ) - def _native_array_actual_shape_object_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - ) -> tuple[CExpressionStatement, ...]: - """Create the expected-shape object consumed by the runtime helper.""" - actual = plan.native_array_actual - if actual is None: - return () - prefix = names.value_name - return ( - CExpressionStatement(CodeExpression(f"{prefix}_shape = PyTuple_New({actual.rank})")), - CExpressionStatement(CodeExpression(f"if ({prefix}_shape == NULL) return NULL")), - ) - - @staticmethod - def _native_array_actual_expected_rank(actual: NativeArrayActualPlan) -> int: - """Return the runtime-helper rank selector selected by completed policy.""" - return actual.rank - - @staticmethod - def _native_array_actual_flat_axis(actual: NativeArrayActualPlan) -> int: - """Return the flattened contract axis marker consumed by the runtime helper.""" - return -1 if actual.flat_axis is None else actual.flat_axis - - def _native_array_actual_shape_nodes( + def _native_array_actual_shape_checks( self, plan: ArgumentTransferPlan, context: _CFunctionContext, names: _CArgumentNames, - ) -> tuple[CExpressionStatement, ...]: - """Build the planned expected-shape tuple for runtime validation.""" + record: str, + ) -> tuple[CIf, ...]: + """Validate a handle descriptor against every binding-known extent.""" actual = plan.native_array_actual array = plan.array - if actual is None or array is None: + if actual is None or array is None or array.rank is None: return () - prefix = names.value_name nodes = [] for axis, expression in enumerate(actual.shape): if ( @@ -7823,18 +7593,20 @@ def _native_array_actual_shape_nodes( or (actual.flatten_storage and axis == actual.flat_axis) or array.extent_evaluation[axis] == "bridge" ): - nodes.append(CExpressionStatement(CodeExpression("Py_INCREF(Py_None)"))) - item = "Py_None" - else: - expected = self._array_extent_expression(array, axis, expression, context) - item = f"PyLong_FromLongLong((long long)({expected}))" - nodes.append(CExpressionStatement(CodeExpression(f"PyTuple_SET_ITEM({prefix}_shape, {axis}, {item})"))) + continue + expected = self._array_extent_expression(array, axis, expression, context) nodes.append( - CExpressionStatement( - CodeExpression( - f"if (PyTuple_GET_ITEM({prefix}_shape, {axis}) == NULL) {{ " - f"Py_DECREF({prefix}_shape); return NULL; }}" - ) + CIf( + CodeExpression(f"{record}.logical_extents[{axis}] != (int64_t)({expected})"), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_Format(PyExc_TypeError, "Argument {plan.binding.python_name} ' + f'has incompatible shape at axis %d", {axis})' + ) + ), + CReturn(CodeExpression("NULL")), + ), ) ) return tuple(nodes) @@ -8379,7 +8151,7 @@ def _inverted_descriptor_table_nodes( ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_ops *", CodeExpression("NULL")), + CDeclaration(table, "prik_native_array_backend *", CodeExpression("NULL")), CComment( f"'{plan.binding.python_name}' may have its {_descriptor_binding_noun(handle)} changed by the callee." ), @@ -8394,8 +8166,9 @@ def _inverted_descriptor_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_ops_from_capsule({capsule}, " + f"{table} = prik_native_array_backend_for_descriptor({capsule}, " f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), " f"{self._native_array_cfi_type(plan)}, " f"{self._native_array_expected_element_size(plan)})" ) @@ -8432,8 +8205,8 @@ def _lower_argument_native_array_direct( self._native_descriptor_object_declaration(plan, names), CDeclaration(names.value_name, "CFI_cdesc_t *", CodeExpression("NULL")), CDeclaration( - f"{names.value_name}_native_handle", - "prik_native_array_handle *", + f"{names.value_name}_native_backend", + "prik_native_array_backend *", CodeExpression("NULL"), ), *self._native_descriptor_helper_declarations( @@ -8658,21 +8431,21 @@ def _native_descriptor_pointer_unpack_nodes( body=( CExpressionStatement( CodeExpression( - f"{prefix}_native_handle = prik_native_array_handle_from_capsule({prefix}_item, " - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " - f"{self._native_array_expected_element_size(plan)}, " - f"sizeof(CFI_CDESC_T({handle.array.rank})))" + f"{prefix}_native_backend = prik_native_array_backend_for_descriptor({prefix}_item, " + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, " + f"{self._native_array_expected_element_size(plan)})" ) ), CIf( - CodeExpression(f"{prefix}_native_handle == NULL"), + CodeExpression(f"{prefix}_native_backend == NULL"), body=( CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)")), CReturn(CodeExpression("NULL")), ), ), CExpressionStatement( - CodeExpression(f"{names.value_name} = (CFI_cdesc_t *){prefix}_native_handle->descriptor") + CodeExpression(f"{names.value_name} = (CFI_cdesc_t *){prefix}_native_backend->context") ), ), else_body=absent, @@ -9247,54 +9020,6 @@ def _lower_result_scalar_descriptor( ) # Owned native-array-handle result lowering. - def _owned_result_ops_capsule_nodes(self, plan: ResultPlan, prefix: str, descriptor_name: str) -> tuple: - """Build the entry-point table an owned result handle publishes.""" - handle = plan.native_array_handle - cfi_type = self._native_array_cfi_type(plan) - if handle is None or handle.array.rank is None or cfi_type is None: - return () - capsule = f"{prefix}_native_ops" - return ( - CExpressionStatement( - CodeExpression( - f"{capsule} = prik_native_array_ops_capsule_new(" - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " - f"{self._native_array_expected_element_size(plan)}, {descriptor_name}, " - f"{self._owned_result_scoped_descriptor_name(plan)})" - ) - ), - CIf(CodeExpression(f"{capsule} == NULL"), body=(CReturn(CodeExpression("NULL")),)), - ) - - def _owned_result_ops_nodes(self, plan: ResultPlan) -> tuple[CFunction, ...]: - """Emit the forwarder handing over an owned result's descriptor. - - Unlike a module variable or a field, this handle allocated the - descriptor itself and the callee filled it in place, so the current - state is already here: the forwarder passes it straight to the consumer - without asking Fortran for it. - """ - handle = plan.native_array_handle - if handle is None or handle.array.rank is None: - return () - return ( - CFunction( - self._owned_result_scoped_descriptor_name(plan), - "void", - parameters=( - CParameter("owner", "void *"), - CParameter("consumer", "prik_native_array_descriptor_fn"), - CParameter("context", "void *"), - ), - storage="static", - body=(CExpressionStatement(CodeExpression("consumer(owner, context)")), CReturn()), - ), - ) - - def _owned_result_scoped_descriptor_name(self, plan: ResultPlan) -> str: - """Return the forwarder name handing over one owned result descriptor.""" - return f"{self._native_array_capsule_release_name(plan)}_scoped" - def _lower_result_owned_native_array_handle( self, plan: ResultPlan, @@ -9315,7 +9040,6 @@ def _lower_result_owned_native_array_handle( CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_ops", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_owner", "PyObject *", CodeExpression("NULL")), - CDeclaration(f"{prefix}_native_ops", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_operation", "PyObject *", CodeExpression("NULL")), CDeclaration(python_name, "PyObject *", CodeExpression("NULL")), *self._owned_pointer_result_normalization_nodes( @@ -9362,9 +9086,6 @@ def _lower_result_owned_native_array_handle( CReturn(CodeExpression("NULL")), ), ), - # Published before ownership of the descriptor moves into the - # handle capsule, while the pointer is still named here. - *self._owned_result_ops_capsule_nodes(plan, prefix, descriptor_name), CExpressionStatement(CodeExpression(f"{descriptor_name} = NULL")), CExpressionStatement( CodeExpression(f'{prefix}_runtime = PyImport_ImportModule("prik.runtime.handles")') @@ -9410,7 +9131,7 @@ def _lower_result_owned_native_array_handle( ops=f"{prefix}_ops", owner=f"{prefix}_owner", descriptor_ownership="owned", - native_ops=f"{prefix}_native_ops", + native_ops=f"{prefix}_owner", extraction_action=handle.extraction_action.value, ) ) @@ -10093,7 +9814,7 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) CodeExpression(f"{table} != NULL"), body=( CExpressionStatement( - CodeExpression(f"{table}->scoped_descriptor({table}->owner, {consumer}, &call_context)") + CodeExpression(f"{table}->with_descriptor({table}->context, {consumer}, &call_context)") ), ), else_body=( @@ -10186,10 +9907,14 @@ def _flattened_array_actual_reader( CDeclaration("kept", "int", CodeExpression(str(rank - 1))), CExpressionStatement(CodeExpression("out->present = 0")), CExpressionStatement(CodeExpression("out->contiguous = 1")), + CExpressionStatement(CodeExpression("out->refused = 1")), CComment("Unallocated or disassociated storage has no address to pass."), CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("out->refused = 2")), + CExpressionStatement(CodeExpression("out->width = source->elem_len")), CExpressionStatement(CodeExpression("expected = (CFI_index_t)source->elem_len")), *self._declared_character_width_guard(argument), + CExpressionStatement(CodeExpression("out->refused = 0")), CComment("Every axis is walked once: contiguity is a property of them all,"), CComment("and the collapsed extent is the product of the ones not kept."), CFor( @@ -10208,9 +9933,7 @@ def _flattened_array_actual_reader( ), CExpressionStatement(CodeExpression("expected *= (CFI_index_t)extent")), CIf( - CodeExpression( - "axis < kept" if flat_axis == rank - 1 else "axis >= (int)source->rank - kept" - ), + CodeExpression("axis < kept" if flat_axis == rank - 1 else "axis >= (int)source->rank - kept"), body=( CExpressionStatement( CodeExpression( @@ -10248,68 +9971,177 @@ def _array_actual_struct_reader_record_name(self, argument: ArgumentTransferPlan def _array_actual_struct_reader_record(self, argument: ArgumentTransferPlan) -> CStructDefinition: """Define the record carrying one filled array actual and why it was refused.""" + rank = argument.array.rank + extent_count = "PRIK_MAX_ARRAY_RANK" if rank is None else str(rank) return CStructDefinition( self._array_actual_struct_reader_record_name(argument), ( CParameter("actual", "prik_array_actual *"), - # 0 accepted, 1 no storage, 2 element width, 3 noncontiguous + CParameter(f"logical_extents[{extent_count}]", "int64_t"), + # 0 accepted, 1 no storage, 2 element width, 3 layout, 4 rank CParameter("refused", "int"), CParameter("width", "size_t"), + CParameter("rank", "int"), ), ) def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) -> CFunction: - """Fill the shared array-actual record from a handle's descriptor. - - The record is the same one the runtime fills, so what reads it is - unchanged. A handle's storage reaches an ordinary dummy contiguously - -- a noncontiguous one is refused -- so the strides are unit and each - upper bound is its extent's last index, exactly as the runtime reports - them for a handle. Storage that is not contiguous leaves the record - empty, and the shared path reports it. - """ + """Fill one ordinary-array ABI record from a live handle descriptor.""" rank = argument.array.rank + active_rank = "source->rank" if rank is None else str(rank) record = self._array_actual_struct_reader_record_name(argument) body: list = [ CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), CDeclaration("wrap", f"{record} *", CodeExpression(f"({record} *)context")), CDeclaration("out", "prik_array_actual *", CodeExpression("wrap->actual")), CDeclaration("extent", "int64_t", CodeExpression("0")), - CDeclaration("packed", "CFI_index_t", CodeExpression("0")), + CDeclaration("empty", "int", CodeExpression("0")), CExpressionStatement(CodeExpression("out->data = NULL")), - CExpressionStatement(CodeExpression(f"out->rank = {rank}")), + CExpressionStatement(CodeExpression(f"out->rank = {active_rank}")), CExpressionStatement(CodeExpression("wrap->refused = 1")), CComment("Storage that is not there leaves the record empty."), CIf(CodeExpression("source->base_addr == NULL"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("wrap->rank = (int)source->rank")), + CExpressionStatement(CodeExpression("wrap->refused = 4")), + CIf( + CodeExpression( + f"source->rank < {argument.array.minimum_rank} || source->rank > {argument.array.maximum_rank}" + ), + body=(CReturn(),), + ), CExpressionStatement(CodeExpression("wrap->refused = 2")), CExpressionStatement(CodeExpression("wrap->width = source->elem_len")), CExpressionStatement(CodeExpression("out->itemsize = (int64_t)source->elem_len")), - CExpressionStatement(CodeExpression("packed = (CFI_index_t)source->elem_len")), *self._declared_character_width_guard(argument), ] - for axis in range(rank): + if rank is None: body.extend( ( - # A compiler may report an empty dimension as extent -1. - CExpressionStatement( - CodeExpression( - f"extent = (int64_t)(source->dim[{axis}].extent == -1 " - f"? 0 : source->dim[{axis}].extent)" - ) + CFor( + "int axis = 0", + CodeExpression("axis < PRIK_MAX_ARRAY_RANK"), + CodeExpression("++axis"), + body=( + CExpressionStatement(CodeExpression("wrap->logical_extents[axis] = 0")), + CExpressionStatement(CodeExpression("out->extents[axis] = 0")), + CExpressionStatement(CodeExpression("out->upper_bounds[axis] = -1")), + CExpressionStatement(CodeExpression("out->strides[axis] = 1")), + ), ), - CComment("Extents alone cannot describe noncontiguous storage."), - CExpressionStatement(CodeExpression("wrap->refused = 3")), - CIf(CodeExpression(f"source->dim[{axis}].sm != packed"), body=(CReturn(),)), - CExpressionStatement(CodeExpression(f"out->extents[{axis}] = extent")), - CExpressionStatement( - CodeExpression(f"out->upper_bounds[{axis}] = extent == 0 ? -1 : extent - 1") + CFor( + "int axis = 0", + CodeExpression("axis < (int)source->rank"), + CodeExpression("++axis"), + body=( + CExpressionStatement( + CodeExpression( + "extent = (int64_t)(source->dim[axis].extent == -1 ? 0 : source->dim[axis].extent)" + ) + ), + CExpressionStatement(CodeExpression("wrap->logical_extents[axis] = extent")), + CExpressionStatement(CodeExpression("if (extent == 0) empty = 1")), + ), + ), + ) + ) + else: + for axis in range(rank): + body.extend( + ( + CExpressionStatement( + CodeExpression( + f"extent = (int64_t)(source->dim[{axis}].extent == -1 ? 0 : source->dim[{axis}].extent)" + ) + ), + CExpressionStatement(CodeExpression(f"wrap->logical_extents[{axis}] = extent")), + CExpressionStatement(CodeExpression("if (extent == 0) empty = 1")), + ) + ) + empty_body: list = [] + if rank is None: + empty_body.append( + CFor( + "int axis = 0", + CodeExpression("axis < (int)source->rank"), + CodeExpression("++axis"), + body=( + CExpressionStatement(CodeExpression("out->extents[axis] = wrap->logical_extents[axis]")), + CExpressionStatement( + CodeExpression( + "out->upper_bounds[axis] = wrap->logical_extents[axis] == 0 " + "? -1 : wrap->logical_extents[axis] - 1" + ) + ), + CExpressionStatement(CodeExpression("out->strides[axis] = 1")), + ), + ) + ) + else: + for axis in range(rank): + empty_body.extend( + ( + CExpressionStatement(CodeExpression(f"out->extents[{axis}] = wrap->logical_extents[{axis}]")), + CExpressionStatement( + CodeExpression( + f"out->upper_bounds[{axis}] = wrap->logical_extents[{axis}] == 0 " + f"? -1 : wrap->logical_extents[{axis}] - 1" + ) + ), + CExpressionStatement(CodeExpression(f"out->strides[{axis}] = 1")), + ) + ) + empty_body.extend( + ( + CExpressionStatement(CodeExpression("wrap->refused = 0")), + CExpressionStatement(CodeExpression("out->data = source->base_addr")), + CReturn(), + ) + ) + body.append(CIf(CodeExpression("empty"), body=tuple(empty_body))) + body.append(CExpressionStatement(CodeExpression("wrap->refused = 3"))) + if argument.array.stride_roles: + body.extend(self._strided_native_array_actual_reader_nodes(argument)) + elif rank is None: + body.extend( + ( + CDeclaration("packed", "CFI_index_t", CodeExpression("0")), + CExpressionStatement(CodeExpression("packed = (CFI_index_t)source->elem_len")), + CFor( + "int axis = 0", + CodeExpression("axis < (int)source->rank"), + CodeExpression("++axis"), + body=( + CIf(CodeExpression("source->dim[axis].sm != packed"), body=(CReturn(),)), + CExpressionStatement(CodeExpression("out->extents[axis] = wrap->logical_extents[axis]")), + CExpressionStatement( + CodeExpression("out->upper_bounds[axis] = wrap->logical_extents[axis] - 1") + ), + CExpressionStatement(CodeExpression("out->strides[axis] = 1")), + CExpressionStatement(CodeExpression("packed *= (CFI_index_t)wrap->logical_extents[axis]")), + ), ), - CExpressionStatement(CodeExpression(f"out->strides[{axis}] = 1")), - CExpressionStatement(CodeExpression("packed *= (CFI_index_t)extent")), ) ) - body.append(CExpressionStatement(CodeExpression("wrap->refused = 0"))) - body.append(CExpressionStatement(CodeExpression("out->data = source->base_addr"))) + else: + body.append(CDeclaration("packed", "CFI_index_t", CodeExpression("source->elem_len"))) + for axis in range(rank): + body.extend( + ( + CIf(CodeExpression(f"source->dim[{axis}].sm != packed"), body=(CReturn(),)), + CExpressionStatement(CodeExpression(f"out->extents[{axis}] = wrap->logical_extents[{axis}]")), + CExpressionStatement( + CodeExpression(f"out->upper_bounds[{axis}] = wrap->logical_extents[{axis}] - 1") + ), + CExpressionStatement(CodeExpression(f"out->strides[{axis}] = 1")), + CExpressionStatement(CodeExpression(f"packed *= (CFI_index_t)wrap->logical_extents[{axis}]")), + ) + ) + body.extend( + ( + CExpressionStatement(CodeExpression("wrap->refused = 0")), + CExpressionStatement(CodeExpression("out->data = source->base_addr")), + ) + ) return CFunction( self._array_actual_struct_reader_name(argument), "void", @@ -10319,6 +10151,54 @@ def _array_actual_struct_reader_function(self, argument: ArgumentTransferPlan) - doc=("Fill one array actual record from the descriptor the runtime opened.",), ) + @staticmethod + def _strided_native_array_actual_reader_nodes(argument: ArgumentTransferPlan) -> tuple: + """Preserve one positive-strided Fortran descriptor in bridge slice roles.""" + rank = argument.array.rank + if rank is None: + raise ValueError(f"Strided native array actual {argument.owner_path!r} requires a fixed rank") + nodes: list = [ + CDeclaration("base_bytes", "CFI_index_t", CodeExpression("(CFI_index_t)source->elem_len")), + CDeclaration("relative_stride", "int64_t", CodeExpression("0")), + CDeclaration("upper_bound", "int64_t", CodeExpression("0")), + ] + for axis in range(rank): + nodes.extend( + ( + CIf( + CodeExpression(f"source->dim[{axis}].sm <= 0 || source->dim[{axis}].sm % base_bytes != 0"), + body=(CReturn(),), + ), + CExpressionStatement( + CodeExpression(f"relative_stride = (int64_t)(source->dim[{axis}].sm / base_bytes)") + ), + CExpressionStatement( + CodeExpression(f"upper_bound = (wrap->logical_extents[{axis}] - 1) * relative_stride") + ), + CExpressionStatement(CodeExpression(f"out->strides[{axis}] = relative_stride")), + CExpressionStatement(CodeExpression(f"out->upper_bounds[{axis}] = upper_bound")), + ) + ) + if axis + 1 < rank: + nodes.extend( + ( + CIf( + CodeExpression( + f"source->dim[{axis + 1}].sm <= 0 || source->dim[{axis + 1}].sm % base_bytes != 0" + ), + body=(CReturn(),), + ), + CExpressionStatement( + CodeExpression(f"out->extents[{axis}] = (int64_t)(source->dim[{axis + 1}].sm / base_bytes)") + ), + CIf(CodeExpression(f"out->extents[{axis}] <= upper_bound"), body=(CReturn(),)), + ) + ) + else: + nodes.append(CExpressionStatement(CodeExpression(f"out->extents[{axis}] = upper_bound + 1"))) + nodes.append(CExpressionStatement(CodeExpression(f"base_bytes *= (CFI_index_t)out->extents[{axis}]"))) + return tuple(nodes) + def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: """Emit the record and reader for each array dummy a handle may reach. @@ -11941,7 +11821,12 @@ def _native_array_capsule_new_expression( plan: ArgumentTransferPlan | ResultPlan, descriptor: str, ) -> str: - """Create one versioned capsule around established descriptor storage.""" + """Publish one backend over descriptor storage this wrapper owns. + + There is no native entity to enter, so the entry point hands the + persistent storage straight to the consumer, and the release callback + marks that storage as this extension's to free. + """ handle = plan.native_array_handle cfi_type = self._native_array_cfi_type(plan) element_size = ( @@ -11950,10 +11835,10 @@ def _native_array_capsule_new_expression( else self._native_array_expected_element_size(plan) ) return ( - "prik_native_array_handle_capsule_new(" - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, {cfi_type}, " - f"{element_size}, sizeof(CFI_CDESC_T({handle.array.rank})), {descriptor}, " - f"{self._native_array_capsule_release_name(plan)})" + "prik_native_array_backend_capsule_new(" + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, {element_size}, {descriptor}, " + f"prik_native_array_owned_with_descriptor, {self._native_array_capsule_release_name(plan)})" ) # Binding-owned representation transformations. @@ -13060,14 +12945,6 @@ def _derived_handle_descriptor_callback_name( """Return the binding-local derived handle descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" return f"prik_field_handle_{self._derived_field_symbol(derived, field)}_descriptor_callback" - def _derived_handle_actual_callback_name( - self, - derived: DerivedTypePlan, - field: DerivedFieldPlan, - ) -> str: - """Return the binding-local derived handle actual callback name derived from the supplied completed binding records; this helper preserves completed policy.""" - return f"prik_field_handle_{self._derived_field_symbol(derived, field)}_actual_callback" - @staticmethod def _module_member_symbol(variable: ModuleVariablePlan, member: DerivedMemberPathPlan) -> str: """Return the binding-local module member symbol derived from the supplied completed binding records; this helper preserves completed policy.""" @@ -13122,14 +12999,6 @@ def _module_member_handle_descriptor_callback_name( """Return the binding-local module member handle descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" return f"prik_module_field_handle_{self._module_member_symbol(variable, member)}_descriptor_callback" - def _module_member_handle_actual_callback_name( - self, - variable: ModuleVariablePlan, - member: DerivedMemberPathPlan, - ) -> str: - """Return the binding-local module member handle actual callback name derived from the supplied completed binding records; this helper preserves completed policy.""" - return f"prik_module_field_handle_{self._module_member_symbol(variable, member)}_actual_callback" - @staticmethod def _module_member_ops_name(variable: ModuleVariablePlan, prefix: tuple[str, ...]) -> str: """Return the binding-local module member ops name derived from the supplied completed binding records; this helper preserves completed policy.""" diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 33bb6c498..798f96396 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -2542,8 +2542,6 @@ def _lower_module_native_array_bridge_operation( return self._module_native_array_state_operation(plan, operation) if operation is NativeArrayOperation.ELEMENT_LENGTH: return self._module_native_array_element_length_operation(plan) - if operation is NativeArrayOperation.ARRAY_ACTUAL: - return self._module_native_array_actual_operation(plan) if operation is NativeArrayOperation.SHAPE: return self._module_native_array_shape_operation(plan) if operation is NativeArrayOperation.DESCRIPTOR: @@ -2577,28 +2575,6 @@ def _module_native_array_state_operation(self, plan: ModuleVariablePlan, operati body=(FortranAssignment("result", CodeExpression(expression)),), ) - def _module_native_array_actual_operation(self, plan: ModuleVariablePlan) -> FortranFunction: - """Return current module-array data storage without changing ownership. - - A descriptor-reading module allocatable plans no such operation, so only - the address route reaches this. - """ - name = self._module_native_array_operation_name(plan, NativeArrayOperation.ARRAY_ACTUAL) - native = self._native_variable_name(plan) - return FortranFunction( - name=name, - result_name="result", - result_type="type(c_ptr)", - bind_name=name, - body=( - FortranIf( - CodeExpression(self._module_native_array_presence_expression(plan)), - body=(FortranAssignment("result", CodeExpression(f"c_loc({native})")),), - else_body=(FortranAssignment("result", CodeExpression("c_null_ptr")),), - ), - ), - ) - def _module_native_array_element_length_operation(self, plan: ModuleVariablePlan) -> FortranFunction: """Return the runtime character element width or zero when absent.""" name = self._module_native_array_operation_name(plan, NativeArrayOperation.ELEMENT_LENGTH) @@ -6922,14 +6898,7 @@ def _native_handle_field_procedures(self, owner, field: DerivedFieldPlan) -> tup raise ValueError(f"Native handle field {field.owner_path!r} has no operation plan") procedures = [] for operation in handle.operations: - if operation in { - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, - NativeArrayOperation.TO_NUMPY, - NativeArrayOperation.ARRAY_ACTUAL, - }: + if operation is NativeArrayOperation.TO_NUMPY: continue procedures.append(self._native_handle_field_procedure(owner, field, operation)) return tuple(procedures) diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 8fcaa6ed7..cd7845bd9 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -3069,7 +3069,6 @@ def _native_array_default_handle_operation_diagnostics( roles = handle.default_handle.operation_roles required = { NativeArrayOperation.SHAPE, - NativeArrayOperation.ARRAY_ACTUAL, NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.DESTROY, } @@ -3160,11 +3159,17 @@ def _native_array_actual_diagnostics( if actual is None: return () array = plan.array - expected_sources = ( - NativeArraySourceKind.NDARRAY, + expected_sources = (NativeArraySourceKind.NDARRAY,) + handle_sources = { NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE, - ) + } + if handle_sources.intersection(actual.accepted_sources): + expected_sources = ( + *expected_sources, + NativeArraySourceKind.ALLOCATABLE_HANDLE, + NativeArraySourceKind.POINTER_HANDLE, + ) diagnostics = [ *self._native_array_actual_source_diagnostics(plan, expected_sources), *self._native_array_actual_shape_diagnostics(plan), @@ -3427,11 +3432,7 @@ def _required_native_array_operations( handle: NativeArrayHandlePlan, ) -> set[NativeArrayOperation]: """Return common operations required by the completed descriptor kind.""" - required = { - NativeArrayOperation.SHAPE, - NativeArrayOperation.ARRAY_ACTUAL, - NativeArrayOperation.DESCRIPTOR, - } + required = {NativeArrayOperation.SHAPE, NativeArrayOperation.DESCRIPTOR} if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: required.add(NativeArrayOperation.ASSOCIATE) return required diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 226c13541..2584a46a2 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -50,25 +50,8 @@ ) -_FIELD_HANDLE_LOCAL_OPERATIONS = frozenset( - { - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, - NativeArrayOperation.TO_NUMPY, - NativeArrayOperation.ARRAY_ACTUAL, - } -) -_MODULE_HANDLE_LOCAL_OPERATIONS = frozenset( - { - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, - NativeArrayOperation.TO_NUMPY, - } -) +_FIELD_HANDLE_LOCAL_OPERATIONS = frozenset({NativeArrayOperation.TO_NUMPY}) +_MODULE_HANDLE_LOCAL_OPERATIONS = frozenset({NativeArrayOperation.TO_NUMPY}) _OWNED_HANDLE_ENTRYPOINT_OPERATIONS = frozenset( { NativeArrayOperation.ALLOCATED, @@ -1020,14 +1003,6 @@ def _module_native_array_signature(self, variable, handle, operation): return NativeEntrypointSignaturePlan((), self._bool_result()) if operation is NativeArrayOperation.ELEMENT_LENGTH: return NativeEntrypointSignaturePlan((), self._int64_result()) - if operation is NativeArrayOperation.ARRAY_ACTUAL: - # Reading the descriptor already hands the consumer everything an - # actual needs, so this operation would repeat that procedure - # exactly. It is left unplanned and the binding calls the - # descriptor symbol with a callback that keeps only the address. - if self._uses_module_allocatable_descriptor(variable): - return None - return NativeEntrypointSignaturePlan((), self._opaque_result()) if operation is NativeArrayOperation.SHAPE: extents = tuple( self._int64_parameter(f"extent_{axis}", reference=True, intent="out") diff --git a/prik/planning/models.py b/prik/planning/models.py index 00ec5b969..c0d8d66b4 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -510,7 +510,7 @@ class NativeArrayActualPlan(StageRecord): accepted_sources: tuple[NativeArraySourceKind, ...] dtype: str - rank: int + rank: int | None shape: tuple[str, ...] order: str | None writable: bool diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 91f368b5a..db30ee2da 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6210,17 +6210,10 @@ def _native_array_handle_wrapper_policy( operations = { _native_array_enum(NativeArrayOperation, item, owner_path, "operation") for item in completed.operations } - operations.update( - { - NativeArrayOperation.SHAPE, - NativeArrayOperation.ARRAY_ACTUAL, - NativeArrayOperation.DESCRIPTOR, - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, - } - ) + # Shape and the descriptor are what a handle is asked for. The storage + # facts an ordinary dummy needs are read from the descriptor in the + # binding, so no operation reports them. + operations.update({NativeArrayOperation.SHAPE, NativeArrayOperation.DESCRIPTOR}) if semantic_type.name == "String": operations.add(NativeArrayOperation.ELEMENT_LENGTH) if semantic_type.metadata.get("fortran_character_length") == ":": @@ -6340,12 +6333,7 @@ def _native_array_default_handle_policy( if operation in { NativeArrayOperation.SHAPE, - NativeArrayOperation.ARRAY_ACTUAL, NativeArrayOperation.DESCRIPTOR, - NativeArrayOperation.NATIVE_BYTE_ORDER, - NativeArrayOperation.ALIGNED, - NativeArrayOperation.WRITEABLE, - NativeArrayOperation.LAYOUT, NativeArrayOperation.CONTIGUOUS, } ) @@ -6532,11 +6520,11 @@ def _native_array_actual_dtype(argument: models.SemanticArgument) -> str | None: A character actual is matched on its declared width as well as its kind, because a handle whose elements are a different length describes different - storage. A width the declaration does not fix cannot be matched at all. + storage. An assumed width is read from the live descriptor. """ if argument.semantic_type.name == "String": length = _character_length(argument.semantic_type) - return None if length is None else f"S{length}" + return "S" if length is None else f"S{length}" return _NUMPY_DTYPE_NAMES.get(argument.semantic_type.name) @@ -6557,8 +6545,6 @@ def _native_array_actual_policy( if ( array is None or array.native_order != array.order - or array.rank is None - or argument.optional or decision.transfer is TransferMode.COPY_RETURN or decision.python_barrier_action is not PythonBarrierAction.ARRAY_STORAGE or decision.native_barrier_action is not NativeBarrierAction.PASS_ARRAY_BUFFER diff --git a/prik/policy/models.py b/prik/policy/models.py index d704a91fb..950c8f55e 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -880,13 +880,8 @@ class NativeArrayOperation(str, Enum): ASSOCIATED = "associated" SHAPE = "shape" ELEMENT_LENGTH = "element_length" - ARRAY_ACTUAL = "array_actual" DESCRIPTOR = "descriptor" TO_NUMPY = "to_numpy" - NATIVE_BYTE_ORDER = "native_byte_order" - ALIGNED = "aligned" - WRITEABLE = "writeable" - LAYOUT = "layout" CONTIGUOUS = "contiguous" ALLOCATE = "allocate" DEALLOCATE = "deallocate" @@ -1074,7 +1069,7 @@ class NativeArrayActualPolicy: accepted_sources: tuple[NativeArraySourceKind, ...] dtype: str - rank: int + rank: int | None shape: tuple[str, ...] order: str | None writable: bool diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index d8bd36da8..418d9e1d4 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -111,9 +111,7 @@ def _native_array_handle_from_generated_ops( owned = descriptor_ownership == "owned" normalized_ops = {} for name, operation in ops.items(): - if name == "array_actual": - normalized = _generated_handoff_operation(operation, owner=owner, pass_owner=owned) - elif name == "descriptor" and owned: + if name == "descriptor" and owned: normalized = _generated_owned_descriptor_operation(operation, owner) elif name in {"shape", "to_numpy"} and owned: normalized = _generated_owned_descriptor_record_operation(operation, owner) @@ -173,10 +171,6 @@ def current_shape(_handle: NativeArrayHandleBase) -> tuple[int, ...] | None: shape, _strides = _pointer_descriptor_shape_and_strides(record) return shape - def current_array_actual(_handle: NativeArrayHandleBase) -> _NativeArrayHandoff | None: - address = _pointer_descriptor_base_addr(descriptor_state["record"]) - return _NativeArrayHandoff(address, owner=descriptor_state["owner"]) - def descriptor(_handle: NativeArrayHandleBase) -> Mapping[str, Any]: return descriptor_state["record"] @@ -204,7 +198,6 @@ def associate_record( common_ops = { "shape": current_shape, - "array_actual": current_array_actual, "descriptor": descriptor, "to_numpy": current_view, "destroy": clear, @@ -417,32 +410,6 @@ def call(_handle: NativeArrayHandleBase, shape: Sequence[int]) -> Any: return call -def _generated_handoff_operation( - operation: HandleOperation, - *, - owner: Any, - pass_owner: bool = False, -) -> HandleOperation: - """Adapt a generated pointer-address operation to the runtime handoff protocol.""" - - def call(_handle: NativeArrayHandleBase, *args: Any) -> _NativeArrayHandoff: - value = operation(owner, *args) if pass_owner else operation(*args) - return _native_array_handoff_from_generated_result(value, owner=owner) - - return call - - -def _native_array_handoff_from_generated_result(value: Any, *, owner: Any = None) -> _NativeArrayHandoff: - """Normalize a generated pointer operation result into a native handoff.""" - if isinstance(value, _NativeArrayHandoff): - return value - if isinstance(value, ctypes.c_void_p): - value = value.value - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"generated native array handoff address must be an integer; received {type(value).__name__}") - return _NativeArrayHandoff(value, owner=owner) - - def _native_array_descriptor_handoff_from_generated_result( value: Any, *, @@ -740,39 +707,6 @@ def __del__(self) -> None: with suppress(Exception): self.close() - def _array_actual_for_binding( - self, - *, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - expected_layout: str | None = None, - require_writeable: bool = False, - require_native_byte_order: bool = False, - require_aligned: bool = False, - require_contiguous: bool = False, - ) -> Any: - """Return the generated native array actual after validating handle state.""" - self._validate_array_actual_state() - if expected_rank is not None and self.rank != int(expected_rank): - raise ValueError( - f"{self.descriptor_kind} handle rank {self.rank} does not match expected rank {int(expected_rank)}" - ) - if expected_dtype is not None and not self._dtype_matches(expected_dtype): - raise TypeError( - f"{self.descriptor_kind} handle dtype {self.dtype!r} does not match expected dtype {expected_dtype!r}" - ) - shape = self.shape - if shape is None: - raise ValueError(f"{self.descriptor_kind} handle has no valid array actual") - self._validate_expected_shape(shape, expected_shape) - self._validate_expected_layout(expected_layout) - self._validate_writeable(require_writeable) - self._validate_native_byte_order(require_native_byte_order) - self._validate_aligned(require_aligned) - self._validate_contiguous(require_contiguous) - return self._required_handoff_result("array_actual", self._call_op("array_actual")) - def _descriptor_for_binding( self, *, @@ -942,18 +876,6 @@ def _call_op(self, name: str, *args: Any) -> Any: raise NotImplementedError(f"{self.descriptor_kind} handle operation {name!r} is not available") from None return operation(self, *args) - def _required_handoff_result(self, operation: str, value: Any) -> Any: - if not isinstance(value, _NativeArrayHandoff): - raise TypeError( - f"{self.descriptor_kind} handle {operation} operation must return a native handoff object; " - f"received {type(value).__name__}" - ) - return value - - def _validate_array_actual_state(self) -> None: - """Validate descriptor-specific presence before native array-actual handoff.""" - raise NotImplementedError(f"{self.descriptor_kind} handle array-actual validation is not available") - def _to_numpy_absent_state(self) -> bool: """Return whether descriptor state makes extraction produce ``None``.""" return False @@ -1006,58 +928,9 @@ def _validate_expected_shape( f"{expected!r} at axis {axis}" ) - def _validate_expected_layout(self, expected_layout: str | None) -> None: - """Validate a required native layout before native array-actual handoff.""" - if expected_layout is None: - return - required = self._normalize_expected_layout_name(expected_layout) - actual_layout = self._call_op("layout") - actual = self._normalize_actual_layout_name(actual_layout) - if actual != required: - raise ValueError( - f"{self.descriptor_kind} handle layout {actual_layout!r} does not match expected layout " - f"{expected_layout!r}" - ) - - def _validate_writeable(self, require_writeable: bool) -> None: - """Validate writeability before native array-actual handoff.""" - if not require_writeable: - return - if not bool(self._call_op("writeable")): - raise TypeError(f"{self.descriptor_kind} handle array actual must be writeable") - - def _validate_contiguous(self, require_contiguous: bool) -> None: - """Validate the data-buffer ABI's contiguous-storage requirement.""" - if not require_contiguous: - return - if "contiguous" in self._ops: - contiguous = bool(self._call_op("contiguous")) - elif "layout" in self._ops: - contiguous = self._normalize_actual_layout_name(self._call_op("layout")) in {"C", "F"} - else: - raise ValueError(f"{self.descriptor_kind} handle cannot prove contiguous array storage") - if not contiguous: - raise ValueError(f"{self.descriptor_kind} handle array actual must be contiguous") - - def _validate_native_byte_order(self, require_native_byte_order: bool) -> None: - """Validate native byte order before native array-actual handoff.""" - if not require_native_byte_order: - return - if not bool(self._call_op("native_byte_order")): - raise TypeError(f"{self.descriptor_kind} handle array actual must use native byte order") - - def _validate_aligned(self, require_aligned: bool) -> None: - """Validate native alignment before native array-actual handoff.""" - if not require_aligned: - return - if not bool(self._call_op("aligned")): - raise TypeError(f"{self.descriptor_kind} handle array actual must be aligned") - def _validate_required_ops(self) -> None: if "shape" not in self._ops: raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation 'shape'") - if "array_actual" not in self._ops: - raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation 'array_actual'") if "descriptor" not in self._ops: raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation 'descriptor'") for name in sorted(self._REQUIRED_DESCRIPTOR_OPS): @@ -1107,20 +980,6 @@ def _normalize_expected_shape(shape: Sequence[int | None] | int) -> tuple[int | ) return normalized - @staticmethod - def _normalize_expected_layout_name(layout: str) -> str: - required = layout.upper() - if required not in {"C", "F"}: - raise ValueError(f"unsupported expected NumPy array layout {layout!r}") - return required - - @staticmethod - def _normalize_actual_layout_name(layout: Any) -> str: - actual = str(layout).upper() - if actual not in {"C", "F"}: - raise ValueError(f"native array handle layout operation returned unsupported layout {layout!r}") - return actual - class AllocatableArray(NativeArrayHandleBase): """Runtime handle for a native allocatable array descriptor.""" @@ -1153,10 +1012,6 @@ def __init__( def allocated(self) -> bool: return bool(self._call_op("allocated")) - def _validate_array_actual_state(self) -> None: - if not self.allocated: - raise ValueError("allocatable handle is unallocated and cannot be passed as an array actual") - def _to_numpy_absent_state(self) -> bool: return not self.allocated @@ -1198,14 +1053,6 @@ def __init__( def associated(self) -> bool: return bool(self._call_op("associated")) - def _validate_array_actual_state(self) -> None: - if not self.associated: - raise ValueError("pointer handle is unassociated and cannot be passed as an array actual") - if "contiguous" in self._ops and not bool(self._call_op("contiguous")): - raise ValueError( - "pointer handle target is noncontiguous and cannot use the pointer/shape array-actual handoff" - ) - def _to_numpy_absent_state(self) -> bool: return not self.associated @@ -1251,220 +1098,6 @@ def resize(self, shape: Sequence[int] | int) -> Any: return self._call_op("resize", self._normalize_shape(shape)) -def _native_array_actual_for_binding( - value: Any, - *, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - expected_layout: str | None = None, - require_writeable: bool = False, - require_native_byte_order: bool = False, - require_aligned: bool = False, - require_contiguous: bool = False, -) -> Any: - """Return an ndarray or generated native array actual for a normal array argument.""" - if isinstance(value, NativeArrayHandleBase): - return value._array_actual_for_binding( - expected_dtype=expected_dtype, - expected_rank=expected_rank, - expected_shape=expected_shape, - expected_layout=expected_layout, - require_writeable=require_writeable, - require_native_byte_order=require_native_byte_order, - require_aligned=require_aligned, - require_contiguous=require_contiguous, - ) - if isinstance(value, np.ndarray): - _validate_ndarray_array_actual( - value, - expected_dtype=expected_dtype, - expected_rank=expected_rank, - expected_shape=expected_shape, - expected_layout=expected_layout, - require_writeable=require_writeable, - require_native_byte_order=require_native_byte_order, - require_aligned=require_aligned, - require_contiguous=require_contiguous, - ) - return value - if value is None: - raise TypeError("normal array argument is required; received None") - raise TypeError(f"expected NumPy array or native array handle argument; received {type(value).__name__}") - - -def _native_array_actual_argument_for_binding_positional( - value: Any, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - expected_layout: str | None = None, - require_writeable: bool = False, - require_native_byte_order: bool = False, - require_aligned: bool = False, - include_rank: bool = False, - include_itemsize: bool = False, - include_strides: bool = False, - require_contiguous: bool = False, - flatten_storage: bool = False, - flat_axis: int | None = None, -) -> tuple[int, ...]: - """Pack a normal array actual into generated Bind-C array descriptor fields.""" - strided_ndarray = include_strides and isinstance(value, np.ndarray) - if strided_ndarray: - _validate_ndarray_positive_strides(value) - actual = _native_array_actual_for_binding( - value, - expected_dtype=expected_dtype, - expected_rank=None if flatten_storage else expected_rank, - expected_shape=None if flatten_storage else expected_shape, - # Positive-stride validation below is the exact Fortran-order contract - # for a strided ndarray; NumPy's contiguous flag is intentionally false. - expected_layout=None if strided_ndarray else expected_layout, - require_writeable=bool(require_writeable), - require_native_byte_order=bool(require_native_byte_order), - require_aligned=bool(require_aligned), - require_contiguous=bool(require_contiguous), - ) - address, shape, itemsize = _normal_array_actual_abi_facts(value, actual, expected_dtype) - if flatten_storage: - shape = _flattened_storage_shape(shape, expected_shape, flat_axis) - fields = [address] - if include_rank: - fields.append(len(shape)) - if include_itemsize: - fields.append(itemsize) - fields.extend(shape) - if include_strides: - extents, upper_bounds, strides = _normal_array_actual_stride_facts(actual, shape, itemsize) - fields[-len(shape) :] = extents - fields.extend(upper_bounds) - fields.extend(strides) - return tuple(fields) - - -def _flattened_storage_shape( - shape: tuple[int, ...], - expected_shape: Sequence[int | None] | int | None, - flat_axis: int | None, -) -> tuple[int, ...]: - """Return native extents for a contiguous actual with one flat edge.""" - if not 1 <= len(shape) <= 15: - raise TypeError(f"Flat storage expects NumPy array rank 1 through 15; received rank {len(shape)}") - expected = ( - NativeArrayHandleBase._normalize_expected_shape(expected_shape) if expected_shape is not None else (None,) - ) - if len(shape) < len(expected): - raise TypeError(f"Flat storage expects NumPy array rank at least {len(expected)}; received rank {len(shape)}") - axis = 0 if flat_axis is None or int(flat_axis) < 0 else int(flat_axis) - if axis not in {0, len(expected) - 1}: - raise ValueError("Flat storage axis must be the first or final contract dimension") - if axis == 0: - return _leading_flattened_storage_shape(shape, expected) - return _final_flattened_storage_shape(shape, expected) - - -def _final_flattened_storage_shape(shape: tuple[int, ...], expected: tuple[int | None, ...]) -> tuple[int, ...]: - """Keep prefix extents and flatten all remaining axes into the final extent.""" - prefix_count = len(expected) - 1 - _validate_flat_expected_shape(shape[:prefix_count], expected[:prefix_count], offset=0) - return (*shape[:prefix_count], _extent_product(shape[prefix_count:])) - - -def _leading_flattened_storage_shape(shape: tuple[int, ...], expected: tuple[int | None, ...]) -> tuple[int, ...]: - """Flatten leading axes and keep suffix extents at the Python edge.""" - suffix_count = len(expected) - 1 - suffix_shape = shape[len(shape) - suffix_count :] if suffix_count else () - _validate_flat_expected_shape(suffix_shape, expected[1:], offset=len(shape) - suffix_count) - return (_extent_product(shape[: len(shape) - suffix_count]), *suffix_shape) - - -def _extent_product(shape: tuple[int, ...]) -> int: - """Return the element count covered by a flattened extent segment.""" - size = 1 - for extent in shape: - size *= int(extent) - return size - - -def _validate_flat_expected_shape( - actual: tuple[int, ...], - expected: tuple[int | None, ...], - *, - offset: int, -) -> None: - """Validate fixed non-flat dimensions for a flattened storage contract.""" - for axis, (actual_extent, wanted) in enumerate(zip(actual, expected, strict=True)): - if wanted is not None and actual_extent != wanted: - raise TypeError( - f"NumPy array has incompatible shape at axis {offset + axis}: " - f"received {actual!r}, expected {expected!r}" - ) - - -def _normal_array_actual_stride_facts( - actual: Any, - shape: tuple[int, ...], - itemsize: int, -) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]: - """Pack the positive-stride base extent and slice facts used by the bridge.""" - if isinstance(actual, _NativeArrayHandoff): - return shape, tuple(max(extent - 1, -1) for extent in shape), (1,) * len(shape) - if not isinstance(actual, np.ndarray): - raise TypeError(f"normal array actual operation returned unsupported value {type(actual).__name__}") - if actual.size == 0: - # NumPy may report zero strides for empty dimensions. No element can - # be addressed, so use the bridge's canonical empty-array facts. - return shape, tuple(max(extent - 1, -1) for extent in shape), (1,) * len(shape) - if any(stride <= 0 for stride in actual.strides): - raise ValueError("array actual strides must be positive") - - extents = [] - upper_bounds = [] - relative_strides = [] - base_product = 1 - element_strides = tuple(stride // itemsize for stride in actual.strides) - for axis, (logical_extent, element_stride) in enumerate(zip(shape, element_strides, strict=True)): - relative_stride = element_stride // base_product - relative_strides.append(relative_stride) - upper_bound = -1 if logical_extent == 0 else (logical_extent - 1) * relative_stride - upper_bounds.append(upper_bound) - base_extent = max(element_strides[axis + 1] // base_product, 1) if axis + 1 < len(shape) else upper_bound + 1 - extents.append(base_extent) - base_product *= base_extent - return tuple(extents), tuple(upper_bounds), tuple(relative_strides) - - -def _validate_ndarray_positive_strides(value: np.ndarray) -> None: - """Match the bridge's positive non-overlapping Fortran slice contract.""" - for axis, stride in enumerate(value.strides): - invalid = stride % value.itemsize != 0 or (value.size > 0 and value.shape[axis] > 1 and stride <= 0) - if axis: - invalid |= ( - value.size > 0 - and value.shape[axis - 1] > 0 - and (stride < value.strides[axis - 1] * value.shape[axis - 1]) - ) - if invalid: - raise TypeError("NumPy array actual has incompatible layout; expected ordering (F)") - - -def _normal_array_actual_abi_facts( - value: Any, - actual: Any, - expected_dtype: Any, -) -> tuple[int, tuple[int, ...], int]: - if isinstance(actual, _NativeArrayHandoff): - shape = value.shape - if shape is None: - raise ValueError("native array handle array actual must report shape for binding handoff") - dtype = expected_dtype if expected_dtype is not None else value.dtype - return actual.address, tuple(int(axis) for axis in shape), int(np.dtype(dtype).itemsize) - if isinstance(actual, np.ndarray): - return int(actual.ctypes.data), tuple(int(axis) for axis in actual.shape), int(actual.dtype.itemsize) - raise TypeError(f"normal array actual operation returned unsupported value {type(actual).__name__}") - - def _native_array_descriptor_for_binding( value: Any, *, @@ -1560,83 +1193,6 @@ def _native_array_descriptor_handoff_for_binding_positional( ) -def _validate_ndarray_array_actual( - value: np.ndarray, - *, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - expected_layout: str | None = None, - require_writeable: bool = False, - require_native_byte_order: bool = False, - require_aligned: bool = False, - require_contiguous: bool = False, -) -> None: - _validate_ndarray_expected_rank(value, expected_rank) - _validate_ndarray_native_byte_order(value, require_native_byte_order) - _validate_ndarray_expected_dtype(value, expected_dtype) - _validate_ndarray_expected_shape(tuple(int(dimension) for dimension in value.shape), expected_shape) - _validate_ndarray_expected_layout(value, expected_layout) - _validate_ndarray_writeable(value, require_writeable) - _validate_ndarray_aligned(value, require_aligned) - _validate_ndarray_contiguous(value, require_contiguous) - - -def _validate_ndarray_expected_rank(value: np.ndarray, expected_rank: int | None) -> None: - if expected_rank is not None and value.ndim != int(expected_rank): - raise TypeError(f"NumPy array rank {value.ndim} does not match expected rank {int(expected_rank)}") - - -def _validate_ndarray_expected_dtype(value: np.ndarray, expected_dtype: Any) -> None: - if expected_dtype is not None and np.dtype(value.dtype) != np.dtype(expected_dtype): - raise TypeError(f"NumPy array dtype {value.dtype!r} does not match expected dtype {np.dtype(expected_dtype)!r}") - - -def _validate_ndarray_writeable(value: np.ndarray, require_writeable: bool) -> None: - if require_writeable and not value.flags.writeable: - raise TypeError("NumPy array actual must be writeable") - - -def _validate_ndarray_native_byte_order(value: np.ndarray, require_native_byte_order: bool) -> None: - if require_native_byte_order and not value.dtype.isnative: - raise TypeError("NumPy array actual must use native byte order") - - -def _validate_ndarray_aligned(value: np.ndarray, require_aligned: bool) -> None: - if require_aligned and not value.flags.aligned: - raise TypeError("NumPy array actual must be aligned") - - -def _validate_ndarray_contiguous(value: np.ndarray, require_contiguous: bool) -> None: - if require_contiguous and not (value.flags.c_contiguous or value.flags.f_contiguous): - raise TypeError("NumPy array actual must be contiguous") - - -def _validate_ndarray_expected_shape( - shape: tuple[int, ...], - expected_shape: Sequence[int | None] | int | None, -) -> None: - if expected_shape is None: - return - expected = NativeArrayHandleBase._normalize_expected_shape(expected_shape) - if len(expected) != len(shape): - raise TypeError(f"NumPy array shape rank {len(shape)} does not match expected shape rank {len(expected)}") - for axis, (actual, wanted) in enumerate(zip(shape, expected, strict=True)): - if wanted is not None and actual != wanted: - raise TypeError( - f"NumPy array has incompatible shape at axis {axis}: received {shape!r}, expected {expected!r}" - ) - - -def _validate_ndarray_expected_layout(value: np.ndarray, expected_layout: str | None) -> None: - if expected_layout is None: - return - required = NativeArrayHandleBase._normalize_expected_layout_name(expected_layout) - matches = value.flags.f_contiguous if required == "F" else value.flags.c_contiguous - if not matches: - raise TypeError(f"NumPy array actual has incompatible layout; expected ordering ({required})") - - __all__ = ( "AllocatableArray", "NativeArrayHandleBase", @@ -1658,7 +1214,6 @@ def resize(*extents: np.int64) -> None: 1, { "allocated": lambda: True, - "array_actual": lambda: state["array"].ctypes.data, "descriptor": lambda: state["array"].ctypes.data, "shape": lambda: state["array"].shape, "to_numpy": lambda: state["array"], diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 604bb71d5..a8fa50e74 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -23,9 +23,14 @@ #include #include -#define PRIK_NATIVE_ARRAY_HANDLE_ABI_VERSION 1u -#define PRIK_NATIVE_ARRAY_HANDLE_CAPSULE_NAME "prik.native_array_handle.v1" -#define PRIK_NATIVE_ARRAY_HANDLE_MAGIC UINT64_C(0x583250594e414831) +/* + * One versioned capsule publishes everything a generated binding needs from + * another extension's array handle. The version lives in the capsule name: + * PyCapsule_GetPointer refuses a capsule created under any other name, so a + * layout change is made by naming a new capsule rather than by adding a + * separate magic word and version field for the reader to compare. + */ +#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME "prik.native_array_backend.v1" #define PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE 1u #define PRIK_NATIVE_ARRAY_KIND_POINTER 2u @@ -59,21 +64,30 @@ void *prik_capture_address(void *base) } #endif -#define PRIK_NATIVE_ARRAY_OPS_ABI_VERSION 1u -#define PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME "prik.native_array_ops.v1" -#define PRIK_NATIVE_ARRAY_OPS_MAGIC UINT64_C(0x583250594e414f50) - /* - * Consumer for one descriptor the Fortran runtime builds for a single call. + * Consumer for one descriptor that is valid only while it runs. * The descriptor stays a compiler-owned representation here, as everywhere - * else in this header, so this record does not depend on the Fortran interop - * header and stays usable from a C-only extension. + * else in this header, so this signature does not depend on the Fortran + * interop header and stays usable from a C-only extension. */ typedef void (*prik_native_array_descriptor_fn)(void *descriptor, void *context); +/* + * Enter the native entity and run `consumer` while its descriptor is live. + * `context` is whatever that entity needs to be reached; see the backend + * record below. + */ +typedef void (*prik_native_array_with_descriptor_fn)( + void *context, + prik_native_array_descriptor_fn consumer, + void *consumer_context); + +/* Release the storage a backend owns. NULL when the backend borrows it. */ +typedef void (*prik_native_array_release_fn)(void *context); + /* * Bridges a generated descriptor bridge, whose consumer takes the compiler's - * descriptor type, to a table consumer that takes it as void *. Forwarding + * descriptor type, to a backend consumer that takes it as void *. Forwarding * through this record avoids casting between function pointer types. */ typedef struct { @@ -82,28 +96,44 @@ typedef struct { } prik_native_array_descriptor_forward; /* - * Versioned cross-extension table of native entry points for one array - * handle. The pointers are the generated bridge symbols for the entity the - * handle stands for, and `owner` is the address that entity needs -- the - * parent object for a derived-type field, NULL for a module variable. It is - * resolved once when the handle is built, so reaching the entity costs one - * indirect call instead of a Python attribute lookup per operation. + * Versioned cross-extension backend for one array handle. * - * `scoped_descriptor` invokes a consumer while the runtime's descriptor is - * valid. The consumer decides what to do with it: copy the record out, or - * make the native call in place while it is still live. + * `with_descriptor(context, ...)` is the single entry point: it produces a + * live descriptor and runs the consumer on it. `context` is the address that + * entity needs -- the parent object for a derived-type field, the wrapper's + * own descriptor storage for an owned handle, NULL for a module variable -- + * and is resolved once when the handle is built, so reaching the entity costs + * one indirect call instead of a Python attribute lookup per operation. + * + * A borrowed backend enters Fortran, which builds the descriptor for the call + * and copies back what the consumer wrote; the descriptor is gone when the + * consumer returns and must never be retained. An owned backend hands over the + * persistent storage it allocated, which stays valid for the handle's life. + * Consumers cannot tell the two apart, and must not try to. + * + * The leading metadata refuses an incompatible producer before any descriptor + * is interpreted: + * - struct_size attests this exact record layout; + * - descriptor_size attests the producer's CFI_CDESC_T(rank) layout, which + * nothing in the descriptor itself can be read to establish; + * - descriptor_kind, rank, cfi_type and element_size are what a reader + * compares against the dummy it is filling, and reporting them here means + * a mismatch is refused without entering Fortran at all. + * element_size is 0 when the element width is only known at run time, as for + * a deferred-length character array; such a reader takes it from the live + * descriptor's elem_len instead. */ typedef struct { - uint64_t magic; - uint32_t abi_version; uint32_t struct_size; uint32_t descriptor_kind; uint32_t rank; + uint32_t descriptor_size; int32_t cfi_type; size_t element_size; - void *owner; - void (*scoped_descriptor)(void *owner, prik_native_array_descriptor_fn consumer, void *context); -} prik_native_array_ops; + void *context; + prik_native_array_with_descriptor_fn with_descriptor; + prik_native_array_release_fn release; +} prik_native_array_backend; /* * Hand over a descriptor the wrapper itself owns. @@ -112,180 +142,225 @@ typedef struct { * allocated its descriptor when the handle was first bound and keeps it for * the handle's lifetime. There is no call-scoped window to stay inside, so * the consumer runs on that storage directly. Publishing it through the same - * table lets such a handle reach a call the way a module array does, without - * a Python round trip per call. + * backend lets such a handle reach a call the way a module array does. */ -static inline void prik_native_array_owned_scoped_descriptor( - void *owner, +static inline void prik_native_array_owned_with_descriptor( + void *context, prik_native_array_descriptor_fn consumer, - void *context) + void *consumer_context) { - consumer(owner, context); + consumer(context, consumer_context); } -/* Free the per-handle entry-point table a capsule owns. */ -static inline void prik_native_array_ops_capsule_destructor(PyObject *capsule) +/* + * Release owned storage exactly once. + * + * `release` is non-NULL only when `context` is storage this extension + * allocated, so a borrowed backend -- a module variable's, a field's -- never + * reaches the free below and Python never releases native storage it does not + * own. Clearing `context` makes the release idempotent, so an explicit + * close() and finalization can both run. + */ +static inline void prik_native_array_backend_release(prik_native_array_backend *backend) { - void *ops; + void *context; - ops = PyCapsule_GetPointer(capsule, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME); - if (ops == NULL) { - PyErr_Clear(); + if (backend == NULL || backend->release == NULL || backend->context == NULL) { return; } - free(ops); + context = backend->context; + backend->context = NULL; + backend->release(context); + free(context); +} + +/* Finalize one backend record owned by a Python capsule. */ +static inline void prik_native_array_backend_capsule_destructor(PyObject *capsule) +{ + PyObject *error_type = NULL; + PyObject *error_value = NULL; + PyObject *error_traceback = NULL; + prik_native_array_backend *backend; + + PyErr_Fetch(&error_type, &error_value, &error_traceback); + backend = (prik_native_array_backend *)PyCapsule_GetPointer( + capsule, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME); + if (backend == NULL) { + PyErr_Clear(); + } else { + prik_native_array_backend_release(backend); + free(backend); + } + PyErr_Restore(error_type, error_value, error_traceback); } /* - * Publish a per-handle entry-point table. A handle whose entity needs an - * owner address cannot share one file-scope record, so its table is built - * when the handle is and released with the capsule that carries it. + * Publish a per-handle backend. + * + * A handle whose entity needs a context address, or whose storage this + * extension owns, cannot share one file-scope record, so its backend is built + * when the handle is and released with the capsule that carries it. A module + * variable needs neither, and publishes a file-scope record directly. + * + * Ownership of `context` transfers only on success: when this returns NULL the + * caller is still responsible for releasing it. */ -static inline PyObject *prik_native_array_ops_capsule_new( +static inline PyObject *prik_native_array_backend_capsule_new( uint32_t descriptor_kind, uint32_t rank, + uint32_t descriptor_size, int cfi_type, size_t element_size, - void *owner, - void (*scoped_descriptor)(void *owner, prik_native_array_descriptor_fn consumer, void *context)) + void *context, + prik_native_array_with_descriptor_fn with_descriptor, + prik_native_array_release_fn release) { - prik_native_array_ops *ops; + prik_native_array_backend *backend; PyObject *capsule; - if (scoped_descriptor == NULL) { - PyErr_SetString(PyExc_ValueError, "prik native array ops needs a descriptor entry point"); + if (descriptor_kind != PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE + && descriptor_kind != PRIK_NATIVE_ARRAY_KIND_POINTER) { + PyErr_SetString(PyExc_ValueError, "invalid prik native array descriptor kind"); + return NULL; + } + if (with_descriptor == NULL) { + PyErr_SetString(PyExc_ValueError, "prik native array backend needs a descriptor entry point"); + return NULL; + } + if (release != NULL && context == NULL) { + PyErr_SetString(PyExc_ValueError, "prik native array backend has no storage to release"); return NULL; } - ops = (prik_native_array_ops *)calloc(1, sizeof(*ops)); - if (ops == NULL) { + backend = (prik_native_array_backend *)calloc(1, sizeof(*backend)); + if (backend == NULL) { PyErr_NoMemory(); return NULL; } - ops->magic = PRIK_NATIVE_ARRAY_OPS_MAGIC; - ops->abi_version = PRIK_NATIVE_ARRAY_OPS_ABI_VERSION; - ops->struct_size = (uint32_t)sizeof(*ops); - ops->descriptor_kind = descriptor_kind; - ops->rank = rank; - ops->cfi_type = (int32_t)cfi_type; - ops->element_size = element_size; - ops->owner = owner; - ops->scoped_descriptor = scoped_descriptor; - capsule = PyCapsule_New(ops, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME, prik_native_array_ops_capsule_destructor); + backend->struct_size = (uint32_t)sizeof(*backend); + backend->descriptor_kind = descriptor_kind; + backend->rank = rank; + backend->descriptor_size = descriptor_size; + backend->cfi_type = (int32_t)cfi_type; + backend->element_size = element_size; + backend->context = context; + backend->with_descriptor = with_descriptor; + backend->release = release; + capsule = PyCapsule_New( + backend, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, prik_native_array_backend_capsule_destructor); if (capsule == NULL) { - free(ops); + backend->context = NULL; + free(backend); } return capsule; } -/* Decode one ops capsule, rejecting a record this extension cannot read. */ /* - * Read a table for an ordinary array actual. + * Unwrap a backend capsule and check what makes its record usable at all. * - * An ordinary array dummy takes the storage behind a handle, not the handle's - * descriptor kind: an allocatable and a pointer are equally acceptable there, - * so the kind is not compared. Everything that decides whether the storage - * matches the dummy -- rank, element type and element size -- still is. + * A backend that owns its storage and has released it is closed; a borrowed + * one has no storage of its own and its NULL context means only that its + * entity needs no address. */ -static inline prik_native_array_ops *prik_native_array_ops_actual_from_capsule( - PyObject *capsule, - uint32_t expected_rank, - int expected_cfi_type, - size_t expected_element_size, - const char *dtype_name, - const char *argument_name) +static inline prik_native_array_backend *prik_native_array_backend_from_capsule(PyObject *capsule) { - prik_native_array_ops *ops; + prik_native_array_backend *backend; - ops = (prik_native_array_ops *)PyCapsule_GetPointer(capsule, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME); - if (ops == NULL) { + backend = (prik_native_array_backend *)PyCapsule_GetPointer( + capsule, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME); + if (backend == NULL) { return NULL; } - if (ops->magic != PRIK_NATIVE_ARRAY_OPS_MAGIC - || ops->abi_version != PRIK_NATIVE_ARRAY_OPS_ABI_VERSION - || ops->struct_size != (uint32_t)sizeof(*ops) - || ops->scoped_descriptor == NULL) { - PyErr_SetString(PyExc_TypeError, "incompatible prik native array ops record"); + if (backend->struct_size != (uint32_t)sizeof(*backend) || backend->with_descriptor == NULL) { + PyErr_SetString(PyExc_TypeError, "incompatible prik native array backend record"); return NULL; } - /* Zero means the handle states the fact rather than matching one: a - character dummy takes its width from the actual, and a dummy whose - storage is flattened takes an actual of any rank. - - A handle describing different storage is reported here, naming what the - dummy expects and what the handle carries. */ - if ((expected_rank != 0 && ops->rank != expected_rank) || ops->cfi_type != expected_cfi_type - || (expected_element_size != 0 && ops->element_size != expected_element_size)) { - PyErr_Format( - PyExc_TypeError, - "%s handle of rank %u with %zu-byte elements does not match expected dtype %s for argument %s", - ops->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER ? "pointer" : "allocatable", - (unsigned)ops->rank, - ops->element_size, - dtype_name, - argument_name); + if (backend->release != NULL && backend->context == NULL) { + PyErr_SetString(PyExc_ReferenceError, "prik native array handle is closed"); return NULL; } - return ops; + return backend; } -static inline prik_native_array_ops *prik_native_array_ops_from_capsule( +/* + * Read a backend for a descriptor dummy. + * + * Such a dummy is declared allocatable or pointer, so the handle's own kind + * has to be the declared one; everything else about the storage must match the + * declaration too. `expected_element_size` is 0 for a dummy that takes its + * width from the actual. + */ +static inline prik_native_array_backend *prik_native_array_backend_for_descriptor( PyObject *capsule, uint32_t expected_descriptor_kind, uint32_t expected_rank, + uint32_t expected_descriptor_size, int expected_cfi_type, size_t expected_element_size) { - prik_native_array_ops *ops; + prik_native_array_backend *backend; - ops = (prik_native_array_ops *)PyCapsule_GetPointer(capsule, PRIK_NATIVE_ARRAY_OPS_CAPSULE_NAME); - if (ops == NULL) { - return NULL; - } - if (ops->magic != PRIK_NATIVE_ARRAY_OPS_MAGIC - || ops->abi_version != PRIK_NATIVE_ARRAY_OPS_ABI_VERSION - || ops->struct_size != (uint32_t)sizeof(*ops)) { - PyErr_SetString(PyExc_TypeError, "incompatible prik native array ops record"); + backend = prik_native_array_backend_from_capsule(capsule); + if (backend == NULL) { return NULL; } - if (ops->scoped_descriptor == NULL) { - PyErr_SetString(PyExc_TypeError, "prik native array ops record has no descriptor entry point"); + if (backend->descriptor_size != expected_descriptor_size) { + PyErr_SetString(PyExc_TypeError, "incompatible Fortran descriptor storage size"); return NULL; } - if (ops->descriptor_kind != expected_descriptor_kind || ops->rank != expected_rank - || ops->cfi_type != expected_cfi_type || ops->element_size != expected_element_size) { + if (backend->descriptor_kind != expected_descriptor_kind || backend->rank != expected_rank + || backend->cfi_type != expected_cfi_type + || (expected_element_size != 0 && backend->element_size != expected_element_size)) { PyErr_SetString(PyExc_TypeError, "native array handle does not match the declared dummy argument"); return NULL; } - return ops; + return backend; } -typedef void (*prik_native_array_release_fn)(void *descriptor); - /* - * Versioned cross-extension record for one persistent Fortran array - * descriptor. The descriptor representation remains compiler-owned; this - * record only makes its metadata, ownership, and validation ABI common to - * independently generated prik extensions. + * Read a backend for an ordinary array actual. + * + * An ordinary array dummy takes the storage behind a handle, not the handle's + * descriptor kind: an allocatable and a pointer are equally acceptable there, + * so the kind is not compared. Everything that decides whether the storage + * matches the dummy -- rank, element type and element size -- still is, and a + * character dummy that takes its width from the actual passes 0 for the size. */ -typedef struct { - uint64_t magic; - uint32_t abi_version; - uint32_t struct_size; - uint32_t descriptor_kind; - uint32_t rank; - int32_t cfi_type; - uint32_t reserved; - size_t element_size; - size_t descriptor_size; - void *descriptor; - prik_native_array_release_fn release; -} prik_native_array_handle; +static inline prik_native_array_backend *prik_native_array_backend_for_actual( + PyObject *capsule, + uint32_t minimum_rank, + uint32_t maximum_rank, + int expected_cfi_type, + size_t expected_element_size, + const char *dtype_name, + const char *argument_name) +{ + prik_native_array_backend *backend; + + backend = prik_native_array_backend_from_capsule(capsule); + if (backend == NULL) { + return NULL; + } + if (backend->rank < minimum_rank || backend->rank > maximum_rank + || backend->cfi_type != expected_cfi_type + || (expected_element_size != 0 && backend->element_size != expected_element_size)) { + PyErr_Format( + PyExc_TypeError, + "%s handle of rank %u with %zu-byte elements does not match expected dtype %s for argument %s", + backend->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER ? "pointer" : "allocatable", + (unsigned)backend->rank, + backend->element_size, + dtype_name, + argument_name); + return NULL; + } + return backend; +} #define PRIK_MAX_ARRAY_RANK 15 #ifdef PRIK_BINDING_NATIVE_ARRAY_ACTUAL -/* Mechanical result of the normal-array native-handle slow path. */ +/* Mechanical result of reading a live handle descriptor for an ordinary array. */ typedef struct { void *data; int64_t rank; @@ -296,7 +371,6 @@ typedef struct { } prik_array_actual; #endif -/* Release descriptor payload and storage at most once while retaining the record. */ /* Build a Python string from caller-supplied status-message storage. The read never passes ``capacity`` because a native writer is not obliged to @@ -317,313 +391,6 @@ static inline PyObject *prik_status_message_text(const char *bytes, Py_ssize_t c } -/* - * Release for a descriptor this extension copied but does not own. The copy - * itself is freed by prik_native_array_handle_release; the Fortran allocation - * it describes belongs to the module or parent object that declared it and - * must never be deallocated here. - */ -static inline void prik_release_borrowed_native_descriptor(void *descriptor) -{ - (void)descriptor; -} - -static inline void prik_native_array_handle_release(prik_native_array_handle *handle) -{ - void *descriptor; - - if (handle == NULL || handle->descriptor == NULL) { - return; - } - descriptor = handle->descriptor; - handle->descriptor = NULL; - if (handle->release != NULL) { - handle->release(descriptor); - } - free(descriptor); -} - -/* Finalize one native handle record owned by a Python capsule. */ -static inline void prik_native_array_handle_capsule_destructor(PyObject *capsule) -{ - PyObject *error_type = NULL; - PyObject *error_value = NULL; - PyObject *error_traceback = NULL; - prik_native_array_handle *handle; - - PyErr_Fetch(&error_type, &error_value, &error_traceback); - handle = (prik_native_array_handle *)PyCapsule_GetPointer( - capsule, PRIK_NATIVE_ARRAY_HANDLE_CAPSULE_NAME); - if (handle == NULL) { - PyErr_Clear(); - } else { - prik_native_array_handle_release(handle); - handle->magic = 0; - free(handle); - } - PyErr_Restore(error_type, error_value, error_traceback); -} - -/* - * Create a capsule that takes descriptor ownership only on success. The - * caller remains responsible for descriptor cleanup when this function - * returns NULL. - */ -static inline PyObject *prik_native_array_handle_capsule_new( - uint32_t descriptor_kind, - uint32_t rank, - int cfi_type, - size_t element_size, - size_t descriptor_size, - void *descriptor, - prik_native_array_release_fn release) -{ - prik_native_array_handle *handle; - PyObject *capsule; - - if (descriptor_kind != PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE - && descriptor_kind != PRIK_NATIVE_ARRAY_KIND_POINTER) { - PyErr_SetString(PyExc_ValueError, "invalid prik native array descriptor kind"); - return NULL; - } - if (descriptor == NULL || descriptor_size == 0 || element_size == 0 || release == NULL) { - PyErr_SetString(PyExc_ValueError, "incomplete prik native array handle storage"); - return NULL; - } - handle = (prik_native_array_handle *)calloc(1, sizeof(*handle)); - if (handle == NULL) { - PyErr_NoMemory(); - return NULL; - } - handle->magic = PRIK_NATIVE_ARRAY_HANDLE_MAGIC; - handle->abi_version = PRIK_NATIVE_ARRAY_HANDLE_ABI_VERSION; - handle->struct_size = (uint32_t)sizeof(*handle); - handle->descriptor_kind = descriptor_kind; - handle->rank = rank; - handle->cfi_type = (int32_t)cfi_type; - handle->element_size = element_size; - handle->descriptor_size = descriptor_size; - handle->descriptor = descriptor; - handle->release = release; - capsule = PyCapsule_New( - handle, - PRIK_NATIVE_ARRAY_HANDLE_CAPSULE_NAME, - prik_native_array_handle_capsule_destructor); - if (capsule == NULL) { - handle->descriptor = NULL; - handle->magic = 0; - free(handle); - } - return capsule; -} - -/* Validate and unwrap one cross-extension native array handle capsule. */ -static inline prik_native_array_handle *prik_native_array_handle_from_capsule( - PyObject *capsule, - uint32_t expected_kind, - uint32_t expected_rank, - int expected_cfi_type, - size_t expected_element_size, - size_t expected_descriptor_size) -{ - prik_native_array_handle *handle; - - if (!PyCapsule_IsValid(capsule, PRIK_NATIVE_ARRAY_HANDLE_CAPSULE_NAME)) { - PyErr_SetString(PyExc_TypeError, "incompatible prik native array handle capsule"); - return NULL; - } - handle = (prik_native_array_handle *)PyCapsule_GetPointer( - capsule, PRIK_NATIVE_ARRAY_HANDLE_CAPSULE_NAME); - if (handle == NULL) { - return NULL; - } - if (handle->magic != PRIK_NATIVE_ARRAY_HANDLE_MAGIC - || handle->abi_version != PRIK_NATIVE_ARRAY_HANDLE_ABI_VERSION - || handle->struct_size != sizeof(*handle)) { - PyErr_SetString(PyExc_TypeError, "incompatible prik native array handle ABI"); - return NULL; - } - if (handle->descriptor_kind != expected_kind) { - PyErr_SetString(PyExc_TypeError, "prik native array descriptor kind does not match"); - return NULL; - } - if (handle->rank != expected_rank) { - PyErr_SetString(PyExc_ValueError, "prik native array descriptor rank does not match"); - return NULL; - } - if (handle->cfi_type != expected_cfi_type) { - PyErr_SetString(PyExc_TypeError, "prik native array element type does not match"); - return NULL; - } - if (expected_element_size != 0 && handle->element_size != expected_element_size) { - PyErr_SetString(PyExc_TypeError, "prik native array element size does not match"); - return NULL; - } - if (handle->descriptor_size != expected_descriptor_size) { - PyErr_SetString(PyExc_TypeError, "incompatible Fortran descriptor storage size"); - return NULL; - } - if (handle->descriptor == NULL) { - PyErr_SetString(PyExc_ReferenceError, "prik native array handle is closed"); - return NULL; - } - return handle; -} - -/* - * Execute the Python native-handle handoff once per slow-path call site. - * The completed wrapper plan supplies every contract selector; this helper - * only performs reference management and decodes the returned ABI fields. - */ -#ifdef PRIK_BINDING_NATIVE_ARRAY_ACTUAL -PRIK_NO_INLINE static int prik_array_actual_unpack( - PyObject *value, - const char *dtype, - int expected_rank, - PyObject *expected_shape, - const char *expected_layout, - int require_writeable, - int require_native_byte_order, - int require_aligned, - int include_rank, - int include_itemsize, - int include_strides, - int require_contiguous, - int flatten_storage, - int flat_axis, - prik_array_actual *actual) -{ - PyObject *runtime = NULL; - PyObject *helper = NULL; - PyObject *layout = NULL; - PyObject *packed = NULL; - PyObject *item; - Py_ssize_t expected_fields; - Py_ssize_t position; - int axis; - - if (expected_shape == NULL || actual == NULL) { - PyErr_SetString(PyExc_RuntimeError, "prik generated an incomplete native array actual"); - return -1; - } - if (expected_rank < 1 || expected_rank > PRIK_MAX_ARRAY_RANK) { - PyErr_SetString(PyExc_RuntimeError, "prik generated an invalid native array rank"); - return -1; - } - - actual->data = NULL; - actual->rank = 0; - actual->itemsize = 0; - for (axis = 0; axis < PRIK_MAX_ARRAY_RANK; axis++) { - actual->extents[axis] = 0; - actual->upper_bounds[axis] = 0; - actual->strides[axis] = 1; - } - - if (expected_layout == NULL) { - layout = Py_None; - Py_INCREF(layout); - } else { - layout = PyUnicode_FromString(expected_layout); - if (layout == NULL) { - return -1; - } - } - runtime = PyImport_ImportModule("prik.runtime.handles"); - if (runtime == NULL) { - Py_DECREF(layout); - return -1; - } - helper = PyObject_GetAttrString(runtime, "_native_array_actual_argument_for_binding_positional"); - Py_DECREF(runtime); - if (helper == NULL) { - Py_DECREF(layout); - return -1; - } - packed = PyObject_CallFunction( - helper, - "OsiOOiiiiiiiii", - value, - dtype, - expected_rank, - expected_shape, - layout, - require_writeable, - require_native_byte_order, - require_aligned, - include_rank, - include_itemsize, - include_strides, - require_contiguous, - flatten_storage, - flat_axis); - Py_DECREF(helper); - Py_DECREF(layout); - if (packed == NULL) { - return -1; - } - - expected_fields = 1 + include_rank + include_itemsize + expected_rank; - if (include_strides) { - expected_fields += 2 * expected_rank; - } - if (!PyTuple_Check(packed) || PyTuple_GET_SIZE(packed) != expected_fields) { - PyErr_SetString(PyExc_RuntimeError, "prik native array handoff returned invalid ABI fields"); - Py_DECREF(packed); - return -1; - } - - position = 0; - actual->data = PyLong_AsVoidPtr(PyTuple_GET_ITEM(packed, position++)); - if (actual->data == NULL && PyErr_Occurred()) { - Py_DECREF(packed); - return -1; - } - if (include_rank) { - actual->rank = (int64_t)PyLong_AsLongLong(PyTuple_GET_ITEM(packed, position++)); - if (PyErr_Occurred()) { - Py_DECREF(packed); - return -1; - } - } - if (include_itemsize) { - actual->itemsize = (int64_t)PyLong_AsLongLong(PyTuple_GET_ITEM(packed, position++)); - if (PyErr_Occurred()) { - Py_DECREF(packed); - return -1; - } - } - for (axis = 0; axis < expected_rank; axis++) { - item = PyTuple_GET_ITEM(packed, position++); - actual->extents[axis] = (int64_t)PyLong_AsLongLong(item); - if (PyErr_Occurred()) { - Py_DECREF(packed); - return -1; - } - } - if (include_strides) { - for (axis = 0; axis < expected_rank; axis++) { - item = PyTuple_GET_ITEM(packed, position++); - actual->upper_bounds[axis] = (int64_t)PyLong_AsLongLong(item); - if (PyErr_Occurred()) { - Py_DECREF(packed); - return -1; - } - } - for (axis = 0; axis < expected_rank; axis++) { - item = PyTuple_GET_ITEM(packed, position++); - actual->strides[axis] = (int64_t)PyLong_AsLongLong(item); - if (PyErr_Occurred()) { - Py_DECREF(packed); - return -1; - } - } - } - Py_DECREF(packed); - return 0; -} -#endif - /* Completed selectors for compact ordinary NumPy-array validation. */ #define PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS 0 #define PRIK_ARRAY_LAYOUT_C_CONTIGUOUS 1 @@ -765,17 +532,9 @@ static inline int prik_array_validate( * * A wrapper needs two things from an array argument: the raw pointer handed to * the native entrypoint, and one extent per contract axis. Obtaining them takes - * two routes. A NumPy array is validated and read directly, which is the route - * every ordinary call takes. Anything else is a native array handle returned - * earlier by generated code, whose Fortran-owned descriptor is resolved through - * prik.runtime.handles; that route also produces the diagnostics for an - * argument that is neither. - * - * Both routes live here so the emitted wrapper carries one call instead of the - * whole sequence. Every parameter is a selector already decided by the - * completed wrapper plan; this helper makes no interoperability decision of - * its own, and each is passed directly rather than through a descriptor struct - * so the values arrive in registers instead of behind a pointer. + * two routes. This helper validates and reads NumPy arrays. Generated wrappers + * read native handles through their versioned descriptor table before falling + * back here for the NumPy route and the common wrong-type diagnostic. * * object the Python argument to bind * numpy_type NPY_* element selector the plan chose for this array @@ -789,18 +548,11 @@ static inline int prik_array_validate( * require_contiguous non-zero when the plan requires contiguous storage * require_writeable non-zero when the plan may write through this argument * python_type public dtype name used in diagnostics, "numpy.float64" - * dtype_name handoff dtype name for the handle route, "float64" * argument_name public argument name used in diagnostics - * order handoff ordering for the handle route, "F", "C", or NULL * flatten_axis contract axis that absorbs every trailing runtime axis; * `rank - 1` when the plan does not flatten - * actual_* the nine handle-route selectors passed straight through - * to prik_array_actual_unpack * fixed one entry per contract axis: the required extent, or - * -1 when the axis is free. A required extent is checked - * on the direct route and becomes the expected-shape - * entry on the handle route; a free axis is neither - * checked nor constrained + * -1 when the axis is free * data receives the pointer passed to the native entrypoint * extents receives one extent per contract axis * @@ -817,19 +569,8 @@ PRIK_NO_INLINE static int prik_bind_array( int require_contiguous, int require_writeable, const char *python_type, - const char *dtype_name, const char *argument_name, - const char *order, int flatten_axis, - int actual_writable, - int actual_native_byte_order, - int actual_aligned, - int actual_runtime_rank, - int actual_itemsize, - int actual_strides, - int actual_contiguous, - int actual_flatten, - int actual_flat_axis, const long long *fixed, void **data, int64_t *extents) @@ -862,41 +603,15 @@ PRIK_NO_INLINE static int prik_bind_array( } return 0; } - { - PyObject *shape = PyTuple_New(rank); - prik_array_actual actual; - if (shape == NULL) { - return -1; - } - for (axis = 0; axis < rank; ++axis) { - PyObject *item; - if (fixed[axis] >= 0) { - item = PyLong_FromLongLong(fixed[axis]); - } else { - Py_INCREF(Py_None); - item = Py_None; - } - if (item == NULL) { - Py_DECREF(shape); - return -1; - } - PyTuple_SET_ITEM(shape, axis, item); - } - if (prik_array_actual_unpack( - object, dtype_name, rank, shape, order, - actual_writable, actual_native_byte_order, actual_aligned, - actual_runtime_rank, actual_itemsize, actual_strides, - actual_contiguous, actual_flatten, actual_flat_axis, &actual) < 0) { - Py_DECREF(shape); - return -1; - } - Py_DECREF(shape); - *data = actual.data; - for (axis = 0; axis < rank; ++axis) { - extents[axis] = actual.extents[axis]; - } - return 0; - } + /* A generated wrapper takes an accepted handle route before calling this + ndarray-only helper. */ + PyErr_Format( + PyExc_TypeError, + "Expected a compatible numpy.ndarray of dtype %s for argument %s. Received ", + python_type, + argument_name, + Py_TYPE(object)->tp_name); + return -1; } #endif diff --git a/tests/c/_support/cli.py b/tests/c/_support/cli.py index fa2ecb5d7..b18f6113e 100644 --- a/tests/c/_support/cli.py +++ b/tests/c/_support/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys import types import prik.cli as prik_cli @@ -82,4 +83,7 @@ def error(self, message): parser = FakeParser() monkeypatch.setattr(prik_cli, "_parser_for_argv", lambda argv: (parser, argv)) + # main() falls back to sys.argv when called without an argv, and an empty + # command line prints help instead of dispatching. + monkeypatch.setattr(sys, "argv", ["prik", "input.c"]) return parser diff --git a/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py b/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py index d2fdf705a..22f0258f7 100644 --- a/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py +++ b/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py @@ -4,7 +4,7 @@ from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner from prik.policy.completion import complete_semantic_policies -from prik.policy.models import ArrayPythonLayout +from prik.policy.models import ArrayPythonLayout, NativeArraySourceKind from prik.semantics.c2ir import c_file_to_semantic_module @@ -18,11 +18,13 @@ def test_direct_c_binding_keeps_pointer_abi_and_uses_completed_runtime_rank_boun binding = next(source.text for source in generated.sources if source.path.suffix == ".c") function = plan.namespaces[0].functions[0] array = function.arguments[0].array + actual = function.arguments[0].native_array_actual assert plan.bridge is None assert plan.entrypoint.native_languages == ("c",) assert array.rank is None assert (array.minimum_rank, array.maximum_rank) == (0, 15) + assert actual.accepted_sources == (NativeArraySourceKind.NDARRAY,) assert "double native_read(const double * input);" in binding assert array.python_layout is ArrayPythonLayout.ANY_STRIDED assert ("prik_array_validate(bound_input_obj, NPY_FLOAT64, 0, 15, PRIK_ARRAY_LAYOUT_ANY_STRIDED, 0, 0") in binding diff --git a/tests/c/primitive_scalars/codegen/test_exact_native_scalar_lowering.py b/tests/c/primitive_scalars/codegen/test_exact_native_scalar_lowering.py index a0adafbbc..022fba66b 100644 --- a/tests/c/primitive_scalars/codegen/test_exact_native_scalar_lowering.py +++ b/tests/c/primitive_scalars/codegen/test_exact_native_scalar_lowering.py @@ -123,4 +123,4 @@ def update(values: {annotation}[:]) -> None: ... assert f"void update({c_type} * values);" in binding assert f"prik_bind_array(bound_values_obj, {numpy_macro}," in binding assert f'"{numpy_name}", ' in binding - assert '"values", NULL,' in binding + assert '"values", ' in binding diff --git a/tests/fortran/_support/native_array_handles.py b/tests/fortran/_support/native_array_handles.py index d14311abd..0e4430450 100644 --- a/tests/fortran/_support/native_array_handles.py +++ b/tests/fortran/_support/native_array_handles.py @@ -19,10 +19,7 @@ def _handoff(address=1): def _required_handoff_ops(): - return { - "array_actual": lambda _handle: _handoff(101), - "descriptor": lambda _handle: _handoff(102), - } + return {"descriptor": lambda _handle: _handoff(102)} def _common_ops(state: _ArrayState): diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index a50bff02d..79e6c6bf9 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -175,6 +175,6 @@ def test_allocatable_argument_uses_the_descriptor_the_runtime_built(): artifacts = WrapperGenerator().generate(plan) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") - assert "scoped_descriptor(" in c_source + assert "with_descriptor(" in c_source copied = [line.strip() for line in c_source.splitlines() if "memcpy(" in line and "CFI_CDESC_T" in line] assert copied == [] diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index e8a31bf79..18a0b223a 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -1,7 +1,6 @@ """Allocatable result, module-array, and component-view ownership tests.""" import gc -import os import subprocess import sys from pathlib import Path @@ -27,16 +26,6 @@ pytestmark = pytest.mark.fortran_end_to_end -def _allocatable_dummy_handoff_supported() -> bool: - """Report whether this compiler accepts a handle at an allocatable dummy. - - ifx rejects the established descriptor for that argument form regardless of - the bounds it carries, so the round-trip below is checked where it works. - The descriptor facts themselves are asserted on every compiler. - """ - return "ifx" not in os.environ.get("PRIK_TEST_FORTRAN_COMPILER", "gfortran") - - PLAIN_ALLOCATABLE_MODULE_SOURCE = """\ module fallocatable_plain_f90 implicit none @@ -455,8 +444,6 @@ def test_module_allocatable_reports_its_real_lower_bound_with_or_without_target( # The bound is not a reported fact but part of the value: an allocatable # dummy adopts the bounds of the descriptor it is given, so a wrong one # makes the callee index the wrong elements. - if not _allocatable_dummy_handoff_supported(): - return for name in ("plain_a", "tgt_a"): handle = getattr(module, name) assert module.lower_bound_of(handle) == np.int32(5), name diff --git a/tests/fortran/allocatables/runtime/test_allocatable_array_actual_abi.py b/tests/fortran/allocatables/runtime/test_allocatable_array_actual_abi.py deleted file mode 100644 index fbb199ca8..000000000 --- a/tests/fortran/allocatables/runtime/test_allocatable_array_actual_abi.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Allocatable handle handoff to ordinary native array arguments.""" - -import numpy as np -import pytest -from prik.runtime.handles import ( - AllocatableArray, - _native_array_actual_argument_for_binding_positional, -) -from tests.fortran._support.native_array_handles import ( - _ArrayState, - _handoff, -) - - -def test_allocatable_array_actual_hook_requires_allocated_state_without_to_numpy(): - actual = _handoff(201) - calls = [] - state = _ArrayState() - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(202), - "shape": lambda _handle: state.shape, - "allocated": lambda _handle: state.shape is not None, - "to_numpy": lambda _handle: pytest.fail("array-actual handoff must not call to_numpy"), - "array_actual": lambda _handle: calls.append("array_actual") or actual, - }, - ) - - with pytest.raises(ValueError, match="unallocated"): - handle._array_actual_for_binding(expected_dtype=np.float64, expected_rank=1) - - state.shape = (4,) - - assert handle._array_actual_for_binding(expected_dtype="float64", expected_rank=1) is actual - assert calls == ["array_actual"] - - -def test_array_actual_argument_abi_packer_uses_allocatable_native_array_actual_without_numpy_conversion(): - actual = _handoff(246) - calls = [] - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(247), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "layout": lambda _handle: "F", - "writeable": lambda _handle: True, - "native_byte_order": lambda _handle: True, - "aligned": lambda _handle: True, - "to_numpy": lambda _handle: pytest.fail("array-actual ABI packing must not call to_numpy"), - "array_actual": lambda _handle: calls.append("array_actual") or actual, - }, - ) - - assert _native_array_actual_argument_for_binding_positional( - handle, - "float64", - 1, - (2,), - "F", - True, - True, - True, - True, - True, - True, - ) == (actual.address, 1, np.dtype(np.float64).itemsize, 2, 1, 1) - assert calls == ["array_actual"] diff --git a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py index 49c992c12..5565e80f3 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py @@ -69,7 +69,6 @@ def test_non_array_allocatable_annotations_are_not_factories(factory, message: s rank=1, ops={ "shape": lambda _handle: None, - "array_actual": lambda _handle: None, "descriptor": lambda _handle: None, "allocated": lambda _handle: False, }, @@ -131,7 +130,6 @@ def bind_default(value): 1, { "shape": lambda received_owner: calls.append(("shape", received_owner)) or None, - "array_actual": lambda received_owner: 0x5678, "descriptor": lambda received_owner: received_owner, "allocated": lambda received_owner: False, "destroy": lambda received_owner: calls.append(("destroy", received_owner)), diff --git a/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py b/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py index 77c093878..41bf8e917 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py @@ -16,7 +16,6 @@ def test_allocatable_descriptor_hook_accepts_unallocated_descriptor_without_nump "shape": lambda _handle: None, "allocated": lambda _handle: False, "to_numpy": lambda _handle: pytest.fail("descriptor handoff must not call to_numpy"), - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "descriptor": lambda _handle: calls.append("descriptor") or descriptor, }, to_numpy_policy="unsupported", diff --git a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py index 4c7c36390..dd823b26a 100644 --- a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py +++ b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py @@ -74,12 +74,11 @@ def test_required_array_buffer_dispatches_through_named_binding_and_bridge_metho bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") assert "double bind_c_sum_values(void * values, int64_t values_extent_0);" in c_source - # One shared binder call carries every completed selector: dtype, rank - # bounds, layout, contiguity, writeability, diagnostic names, and the nine - # selectors the native-handle route consumes. + # One shared binder call carries the completed NumPy selectors; a generated + # native handle is resolved separately through its descriptor table. assert ( "prik_bind_array(bound_values_obj, NPY_FLOAT64, 1, 1, 1, PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS, " - '1, 1, "numpy.float64", "float64", "values", NULL, 0, 1, 1, 1, 0, 0, 0, 1, 0, -1, ' + '1, 1, "numpy.float64", "values", 0, ' "bound_values_bind_fixed, &bound_values, bound_values_bind_extents)" ) in c_source assert c_source.count("prik_bind_array(bound_values_obj") == 1 diff --git a/tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py b/tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py index a308a2811..58f492838 100644 --- a/tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py +++ b/tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py @@ -181,12 +181,12 @@ def test_dense_array_lowering_uses_planned_shape_checks_and_bridge_orientation() assert "bound_values_bind_fixed[0] = -1;" in c_source assert ( "prik_bind_array(bound_values_obj, NPY_FLOAT64, 1, 1, 15, PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS, " - '1, 1, "numpy.float64", "float64", "values", NULL, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, ' + '1, 1, "numpy.float64", "values", 0, ' "bound_values_bind_fixed, &bound_values, bound_values_bind_extents)" ) in c_source assert ( "prik_bind_array(bound_values_obj, NPY_FLOAT64, 2, 2, 15, PRIK_ARRAY_LAYOUT_F_CONTIGUOUS, " - '1, 1, "numpy.float64", "float64", "values", "F", 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, ' + '1, 1, "numpy.float64", "values", 1, ' "bound_values_bind_fixed, &bound_values, bound_values_bind_extents)" ) in c_source assert "call c_f_pointer(bound_values, values, [values_extent_0, values_extent_1])" in bridge_source diff --git a/tests/fortran/arrays/codegen/test_specialized_array_roles.py b/tests/fortran/arrays/codegen/test_specialized_array_roles.py index 2fcb78f90..826c9ba1e 100644 --- a/tests/fortran/arrays/codegen/test_specialized_array_roles.py +++ b/tests/fortran/arrays/codegen/test_specialized_array_roles.py @@ -5,7 +5,7 @@ from tests.fortran._support.ownership_policy import parse_pyi_text from prik.policy.completion import complete_semantic_policies -from prik.policy.models import OptionalMode +from prik.policy.models import NativeArraySourceKind, OptionalMode from prik.codegen import CBindingGenerator from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner @@ -19,6 +19,7 @@ def _later_array_plan(): def optional(values: Float64[:] = ...) -> None: ... def any_rank(values: Float64[...]) -> Float64: ... def labels(values: String[8][:]) -> None: ... +def labels_any_width(values: String[...][:]) -> None: ... """, module_name="later_array_buffers", ) @@ -43,20 +44,38 @@ def hidden_labels() -> String[4][2]: ... def test_optional_assumed_rank_and_character_arrays_have_explicit_distinct_roles(): functions = {function.binding.python_name: function for function in _later_array_plan().namespaces[0].functions} optional = functions["optional"].arguments[0] - assumed = functions["any_rank"].arguments[0].array - character = functions["labels"].arguments[0].array + assumed_argument = functions["any_rank"].arguments[0] + assumed = assumed_argument.array + character_argument = functions["labels"].arguments[0] + character = character_argument.array + assumed_width_argument = functions["labels_any_width"].arguments[0] + handle_sources = ( + NativeArraySourceKind.NDARRAY, + NativeArraySourceKind.ALLOCATABLE_HANDLE, + NativeArraySourceKind.POINTER_HANDLE, + ) assert optional.binding.optional_mode is OptionalMode.NULLABLE_VALUE assert optional.entrypoint.optional_mode is OptionalMode.NULLABLE_VALUE + assert optional.native_array_actual is not None + assert optional.native_array_actual.accepted_sources == handle_sources assert assumed is not None assert assumed.rank is None assert assumed.contiguous is True assert assumed.runtime_rank_role == "later_array_buffers.any_rank.values:rank" assert len(assumed.extent_roles) == 15 + assert assumed_argument.native_array_actual is not None + assert assumed_argument.native_array_actual.rank is None + assert assumed_argument.native_array_actual.accepted_sources == handle_sources assert character is not None assert character.rank == 1 assert character.itemsize == 8 assert character.itemsize_role == "later_array_buffers.labels.values:itemsize" + assert character_argument.native_array_actual is not None + assert character_argument.native_array_actual.accepted_sources == handle_sources + assert assumed_width_argument.native_array_actual is not None + assert assumed_width_argument.native_array_actual.dtype == "S" + assert assumed_width_argument.native_array_actual.accepted_sources == handle_sources def test_optional_assumed_rank_and_character_lowering_follow_named_plan_fields(): @@ -66,7 +85,14 @@ def test_optional_assumed_rank_and_character_lowering_follow_named_plan_fields() assert "PyObject * bound_values_obj = Py_None;" in c_source assert "if (bound_values_obj != Py_None)" in c_source - assert "prik_array_validate(bound_values_obj, NPY_FLOAT64, 1, 15, PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" in c_source + assert ( + "prik_array_validate_ndarray((PyArrayObject *)bound_values_obj, NPY_FLOAT64, 1, 15, " + "PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" + ) in c_source + assert ( + "prik_native_array_backend_for_actual(bound_values_table_capsule, 1, 15, " + 'CFI_type_double, sizeof(double), "float64", "values")' + ) in c_source assert "NPY_FLOAT64, 1, 15, PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" in c_source assert "bound_values_rank = (int64_t)PyArray_NDIM" in c_source assert "NPY_STRING, 1, 1, PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS" in c_source @@ -79,7 +105,7 @@ def test_optional_assumed_rank_and_character_lowering_follow_named_plan_fields() assert max(map(len, bridge_source.splitlines())) <= 132 -def test_native_array_fallback_unpacks_planned_runtime_rank_and_itemsize_roles(): +def test_native_array_descriptor_result_unpacks_planned_runtime_rank_and_itemsize_roles(): functions = {function.binding.python_name: function for function in _later_array_plan().namespaces[0].functions} generator = CBindingGenerator() diff --git a/tests/fortran/arrays/codegen/test_strided_array_lowering.py b/tests/fortran/arrays/codegen/test_strided_array_lowering.py index ef8e846cc..7f3e9db43 100644 --- a/tests/fortran/arrays/codegen/test_strided_array_lowering.py +++ b/tests/fortran/arrays/codegen/test_strided_array_lowering.py @@ -48,7 +48,16 @@ def test_strided_array_lowering_validates_and_passes_one_explicit_bridge_slice() c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") - assert 'prik_array_actual_unpack(bound_values_obj, "float64", 2, bound_values_shape, "F"' in c_source + assert ( + "prik_native_array_backend_for_actual(bound_values_table_capsule, 2, 2, " + 'CFI_type_double, sizeof(double), "float64", "values")' + ) in c_source + assert ( + "bound_values_table->with_descriptor(bound_values_table->context, " + "prik_fill_array_actual_strided_arrays_strided_values, &bound_values_table_result)" + ) in c_source + assert "relative_stride = (int64_t)(source->dim[0].sm / base_bytes)" in c_source + assert "out->upper_bounds[1] = upper_bound" in c_source assert "NPY_FLOAT64, 2, 2, PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F, 0, 1" in c_source assert "bound_values_upper_bound_0 = bound_values_actual.upper_bounds[0]" in c_source assert "bound_values_stride_1 = bound_values_actual.strides[1]" in c_source diff --git a/tests/fortran/arrays/end_to_end/test_array_contract_validation.py b/tests/fortran/arrays/end_to_end/test_array_contract_validation.py index 01628aad0..6e3e0274a 100644 --- a/tests/fortran/arrays/end_to_end/test_array_contract_validation.py +++ b/tests/fortran/arrays/end_to_end/test_array_contract_validation.py @@ -9,7 +9,6 @@ from tests.fortran._support.wrapper_build import ( _build_source_or_generated_pyi_and_import, ) -from prik.runtime.handles import _NativeArrayHandoff, AllocatableArray, PointerArray FIXTURES = Path(__file__).parent / "fixtures" ARRAY_CONTRACTS_F90_SOURCE = FIXTURES / "native" / "farray_contracts_f90.f90" @@ -19,80 +18,6 @@ pytestmark = pytest.mark.fortran_end_to_end -def _handoff(address): - return _NativeArrayHandoff(address) - - -def _absent_allocatable_handle(): - return AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("absent allocatable must not provide an array actual"), - "descriptor": lambda _handle: _handoff(301), - "shape": lambda _handle: None, - "to_numpy": lambda _handle: None, - "allocated": lambda _handle: False, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, - ) - - -def _array_allocatable_handle(value): - return AllocatableArray( - dtype=value.dtype, - rank=value.ndim, - ops={ - "array_actual": lambda _handle: _handoff(value.ctypes.data), - "descriptor": lambda _handle: _handoff(303), - "shape": lambda _handle: value.shape, - "layout": lambda _handle: "F" if value.flags.f_contiguous else "C", - "writeable": lambda _handle: value.flags.writeable, - "native_byte_order": lambda _handle: value.dtype.isnative, - "aligned": lambda _handle: value.flags.aligned, - "to_numpy": lambda _handle: value, - "allocated": lambda _handle: True, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, - ) - - -def _absent_pointer_handle(): - return PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("absent pointer must not provide an array actual"), - "descriptor": lambda _handle: _handoff(304), - "shape": lambda _handle: None, - "to_numpy": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, - ) - - -def _array_pointer_handle(value): - return PointerArray( - dtype=value.dtype, - rank=value.ndim, - ops={ - "array_actual": lambda _handle: _handoff(value.ctypes.data), - "descriptor": lambda _handle: _handoff(306), - "shape": lambda _handle: value.shape, - "layout": lambda _handle: "F" if value.flags.f_contiguous else "C", - "writeable": lambda _handle: value.flags.writeable, - "native_byte_order": lambda _handle: value.dtype.isnative, - "aligned": lambda _handle: value.flags.aligned, - "to_numpy": lambda _handle: value, - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, - ) - - def test_remaining_array_contracts_are_validated_before_fortran_calls( pyi_parity_build_mode: str, tmp_path: Path, @@ -109,39 +34,6 @@ def test_remaining_array_contracts_are_validated_before_fortran_calls( pyi_parity_build_mode, ) - absent_allocatable = _absent_allocatable_handle() - absent_pointer = _absent_pointer_handle() - assert absent_allocatable.to_numpy() is None - assert absent_pointer.to_numpy() is None - with pytest.raises(TypeError): - module.sum_in(absent_allocatable.to_numpy()) - with pytest.raises(TypeError): - module.sum_in(absent_pointer.to_numpy()) - with pytest.raises(ValueError, match="unallocated"): - module.sum_in(absent_allocatable) - with pytest.raises(ValueError, match="unassociated"): - module.sum_in(absent_pointer) - - actual_values = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64) - assert module.sum_in(_array_allocatable_handle(actual_values)) == np.float64(10.0) - assert module.sum_in(_array_pointer_handle(actual_values)) == np.float64(10.0) - with pytest.raises(TypeError, match="dtype"): - module.sum_in(_array_allocatable_handle(np.array([1.0, 2.0], dtype=np.float32))) - - readonly_allocatable_array = np.array([1.0, 2.0], dtype=np.float64) - readonly_allocatable_array.setflags(write=False) - readonly_allocatable = _array_allocatable_handle(readonly_allocatable_array) - assert readonly_allocatable.to_numpy() is readonly_allocatable_array - with pytest.raises(TypeError, match="writeable"): - module.bump_inout(readonly_allocatable.to_numpy()) - - readonly_pointer_array = np.array([1.0, 2.0], dtype=np.float64) - readonly_pointer_array.setflags(write=False) - readonly_pointer = _array_pointer_handle(readonly_pointer_array) - assert readonly_pointer.to_numpy() is readonly_pointer_array - with pytest.raises(TypeError, match="writeable"): - module.bump_inout(readonly_pointer.to_numpy()) - readonly = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64) readonly.setflags(write=False) if pyi_parity_build_mode == "source": diff --git a/tests/fortran/arrays/end_to_end/test_array_wrapper_parity.py b/tests/fortran/arrays/end_to_end/test_array_wrapper_parity.py index e3eb532a8..a9a90b9d3 100644 --- a/tests/fortran/arrays/end_to_end/test_array_wrapper_parity.py +++ b/tests/fortran/arrays/end_to_end/test_array_wrapper_parity.py @@ -15,7 +15,6 @@ _sole_native_module, ) from prik import build_pyi_extension -from prik.runtime.handles import _NativeArrayHandoff, AllocatableArray, PointerArray FIXTURES = Path(__file__).parent / "fixtures" CONTRACTS = FIXTURES / "contracts" @@ -24,24 +23,6 @@ pytestmark = pytest.mark.fortran_end_to_end -def _native_array_actual(value: np.ndarray, *, pointer: bool): - state_name = "associated" if pointer else "allocated" - operations = { - "array_actual": lambda _handle: _NativeArrayHandoff(value.ctypes.data), - "descriptor": lambda _handle: _NativeArrayHandoff(value.ctypes.data), - "shape": lambda _handle: value.shape, - "layout": lambda _handle: "F" if value.flags.f_contiguous else "C", - "writeable": lambda _handle: value.flags.writeable, - "native_byte_order": lambda _handle: value.dtype.isnative, - "aligned": lambda _handle: value.flags.aligned, - "to_numpy": lambda _handle: value, - state_name: lambda _handle: True, - "nullify" if pointer else "deallocate": lambda _handle: None, - } - handle_type = PointerArray if pointer else AllocatableArray - return handle_type(dtype=value.dtype, rank=value.ndim, ops=operations) - - def test_fortran_array_wrapper_pipeline_matches_fmath_results_with_contiguous_arrays( pyi_parity_build_mode: str, tmp_path: Path, @@ -106,14 +87,6 @@ def test_required_array_buffers_use_canonical_wrapper_plan(tmp_path: Path): assert module.square_r8_contiguous(np.int32(values.size), values, output) == np.int32(values.size) np.testing.assert_array_equal(output, values**2) - handle_output = np.zeros_like(values) - assert module.square_r8_contiguous( - np.int32(values.size), - _native_array_actual(values, pointer=False), - _native_array_actual(handle_output, pointer=True), - ) == np.int32(values.size) - np.testing.assert_array_equal(handle_output, values**2) - empty = np.empty(0, dtype=np.float64) assert module.square_r8_contiguous(np.int32(0), empty, empty.copy()) == np.int32(0) diff --git a/tests/fortran/arrays/end_to_end/test_assumed_rank_arrays.py b/tests/fortran/arrays/end_to_end/test_assumed_rank_arrays.py index cc97cc224..6360e2366 100644 --- a/tests/fortran/arrays/end_to_end/test_assumed_rank_arrays.py +++ b/tests/fortran/arrays/end_to_end/test_assumed_rank_arrays.py @@ -8,7 +8,6 @@ from tests.fortran._support.wrapper_build import ( _build_source_or_generated_pyi_and_import, ) -from prik.runtime.handles import _NativeArrayHandoff, AllocatableArray, PointerArray FIXTURES = Path(__file__).parent / "fixtures" ASSUMED_RANK_F90_SOURCE = FIXTURES / "native" / "fassumed_rank_f90.f90" @@ -33,41 +32,6 @@ def assumed_rank_module(request: pytest.FixtureRequest, tmp_path_factory: pytest ) -def _allocated_handle_for_rejected_assumed_rank(value): - return AllocatableArray( - dtype=value.dtype, - rank=value.ndim, - ops={ - "array_actual": lambda _handle: pytest.fail("assumed-rank path must reject handles before handoff"), - "descriptor": lambda _handle: _NativeArrayHandoff(401), - "shape": lambda _handle: value.shape, - "layout": lambda _handle: "F" if value.flags.f_contiguous else "C", - "writeable": lambda _handle: value.flags.writeable, - "native_byte_order": lambda _handle: value.dtype.isnative, - "aligned": lambda _handle: value.flags.aligned, - "to_numpy": lambda _handle: value, - "allocated": lambda _handle: True, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, - ) - - -def _unassociated_handle_for_rejected_assumed_rank(): - return PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("assumed-rank path must reject handles before handoff"), - "descriptor": lambda _handle: _NativeArrayHandoff(402), - "shape": lambda _handle: None, - "to_numpy": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, - ) - - def test_assumed_rank_arguments_dispatch_to_runtime_rank( assumed_rank_module, ): @@ -92,12 +56,6 @@ def test_assumed_rank_arguments_dispatch_to_runtime_rank( with pytest.raises(TypeError): module.rank_weighted_sum(rank16) - handle_values = np.asfortranarray(np.array([1.0, 2.0], dtype=np.float64)) - with pytest.raises(TypeError): - module.rank_weighted_sum(_allocated_handle_for_rejected_assumed_rank(handle_values)) - with pytest.raises(TypeError): - module.rank_weighted_sum(_unassociated_handle_for_rejected_assumed_rank()) - def test_assumed_rank_bridge_dispatches_each_runtime_rank_argument( assumed_rank_module, diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py new file mode 100644 index 000000000..185895e77 --- /dev/null +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -0,0 +1,140 @@ +"""Generated handles used as every supported ordinary Fortran array form.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from prik.runtime.handles import AllocatableArray, PointerArray +from tests.fortran._support.wrapper_build import _build_text_and_import + + +pytestmark = pytest.mark.fortran_end_to_end + + +NATIVE_HANDLE_ARRAY_FORMS_SOURCE = """\ +module fhandle_array_forms_f90 + implicit none + real(8), allocatable :: values(:) + real(8), allocatable :: matrix(:, :) + real(8), allocatable :: hyper(:, :, :, :, :, :, :, :, :, :, :, :, :, :, :) + real(8), target :: backing(8) + real(8), pointer :: strided_values(:) + character(len=:), allocatable :: words(:) +contains + subroutine setup() + integer :: i + + allocate(values(4)) + values = [1.0_8, 2.0_8, 3.0_8, 4.0_8] + allocate(matrix(2, 3)) + matrix = reshape([(1.0_8 * i, i = 1, 6)], [2, 3]) + allocate(hyper(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)) + hyper = 1.0_8 + backing = [(1.0_8 * i, i = 1, 8)] + strided_values => backing(1:8:2) + allocate(character(len=5) :: words(2)) + words = [character(len=5) :: "alpha", "bravo"] + end subroutine setup + + function explicit_total(actual, n) result(total) + integer(4), intent(in) :: n + real(8), intent(in) :: actual(n) + real(8) :: total + + total = sum(actual) + end function explicit_total + + function assumed_total(actual) result(total) + real(8), intent(in) :: actual(:) + real(8) :: total + + total = sum(actual) + end function assumed_total + + function flat_total(actual, n) result(total) + integer(4), intent(in) :: n + real(8), intent(in) :: actual(*) + real(8) :: total + + total = sum(actual(:n)) + end function flat_total + + function optional_total(actual) result(total) + real(8), intent(in), optional :: actual(:) + real(8) :: total + + if (present(actual)) then + total = sum(actual) + else + total = -1.0_8 + end if + end function optional_total + + function rank_score(actual) result(score) + real(8), intent(in) :: actual(..) + integer(4) :: score + + select rank (actual) + rank (1) + score = 100 + size(actual) + rank (2) + score = 200 + size(actual) + rank (15) + score = 1500 + size(actual) + rank default + score = -1 + end select + end function rank_score + + function element_width(actual) result(width) + character(len=*), intent(in) :: actual(:) + integer(4) :: width + + width = len(actual) + end function element_width +end module fhandle_array_forms_f90 +""" + + +def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): + module = _build_text_and_import( + NATIVE_HANDLE_ARRAY_FORMS_SOURCE, + "fhandle_array_forms_f90.f90", + tmp_path, + { + "bind_c_fhandle_array_forms_f90_wrapper.f90", + "fhandle_array_forms_f90_wrapper.c", + "fhandle_array_forms_f90_wrapper.h", + }, + ) + + values = module.values + strided = module.strided_values + assert isinstance(values, AllocatableArray) + assert isinstance(strided, PointerArray) + with pytest.raises(ValueError, match="unallocated"): + module.assumed_total(values) + with pytest.raises(ValueError, match="unassociated"): + module.assumed_total(strided) + + module.setup() + + assert module.explicit_total(values, np.int32(4)) == np.float64(10.0) + assert module.assumed_total(values) == np.float64(10.0) + assert module.flat_total(values, np.int32(4)) == np.float64(10.0) + assert module.optional_total() == np.float64(-1.0) + assert module.optional_total(None) == np.float64(-1.0) + assert module.optional_total(values) == np.float64(10.0) + assert module.rank_score(values) == np.int32(104) + assert module.rank_score(module.matrix) == np.int32(206) + assert module.rank_score(module.hyper) == np.int32(1501) + assert module.assumed_total(strided) == np.float64(16.0) + assert module.element_width(module.words) == np.int32(5) + + with pytest.raises(TypeError, match="incompatible shape at axis 0"): + module.explicit_total(values, np.int32(3)) + with pytest.raises(TypeError, match="does not match expected dtype"): + module.assumed_total(module.words) diff --git a/tests/fortran/infrastructure/cli/pipeline/_support.py b/tests/fortran/infrastructure/cli/pipeline/_support.py index 1f91fbd93..c4902d63e 100644 --- a/tests/fortran/infrastructure/cli/pipeline/_support.py +++ b/tests/fortran/infrastructure/cli/pipeline/_support.py @@ -1,3 +1,4 @@ +import sys import types import prik.cli as prik_cli @@ -81,6 +82,9 @@ def error(self, message): parser = FakeParser() monkeypatch.setattr(prik_cli, "_parser_for_argv", lambda argv: (parser, argv)) + # main() falls back to sys.argv when called without an argv, and an empty + # command line prints help instead of dispatching. + monkeypatch.setattr(sys, "argv", ["prik", "input.f90"]) return parser diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index e97b67346..30ceb2593 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -10,30 +10,15 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): header = SUPPORT_HEADER.read_text(encoding="utf-8") assert not SUPPORT_SOURCE.exists() - assert '#define PRIK_NATIVE_ARRAY_HANDLE_CAPSULE_NAME "prik.native_array_handle.v1"' in header - assert "#define PRIK_NATIVE_ARRAY_HANDLE_ABI_VERSION 1u" in header - assert "typedef struct {" in header - assert "prik_native_array_release_fn release;" in header - expected_api = ( - "prik_native_array_handle_release", - "prik_native_array_handle_capsule_destructor", - "prik_native_array_handle_capsule_new", - "prik_native_array_handle_from_capsule", - "prik_array_actual_unpack", - "prik_array_validate", - "prik_release_owned_memory", - "prik_capture_address", - ) - for name in expected_api: - assert name in header - assert "PRIK_NO_INLINE static int prik_array_actual_unpack(" in header assert "static inline int prik_array_validate(" in header assert "static inline int prik_array_validate_ndarray(" in header assert "PyArrayObject *array," in header assert header.count("PyArray_Check(value)") == 1 assert "PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F" in header assert "prik_array_actual" in header + assert "prik_release_owned_memory" in header + assert "prik_capture_address" in header scalar_suffixes = ( "bool", @@ -53,6 +38,70 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): assert f"prik_{suffix}_to_numpy" in header +def test_native_array_backend_capsule_states_one_version_and_one_entry_point(): + """The cross-extension array ABI: its version, layout, and validation. + + Independently generated extensions exchange array handles through this one + capsule, so its name carries the version -- PyCapsule_GetPointer refuses a + capsule created under any other name -- and the record carries only what a + reader must compare before it interprets a descriptor it did not build. + """ + header = SUPPORT_HEADER.read_text(encoding="utf-8") + + assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME "prik.native_array_backend.v1"' in header + # One entry point reaches the descriptor; the context is what it needs to + # get there, and a release marks that context as this extension's to free. + assert "prik_native_array_with_descriptor_fn with_descriptor;" in header + assert "prik_native_array_release_fn release;" in header + assert "void *context;" in header + # The compatibility tags a reader compares before trusting the producer. + for field in ("uint32_t struct_size;", "uint32_t descriptor_kind;", "uint32_t rank;"): + assert field in header + assert "uint32_t descriptor_size;" in header + assert "int32_t cfi_type;" in header + assert "size_t element_size;" in header + # No second version word, and no per-operation table. + assert "MAGIC" not in header + assert "ABI_VERSION" not in header + + for name in ( + "prik_native_array_backend_capsule_new", + "prik_native_array_backend_capsule_destructor", + "prik_native_array_backend_from_capsule", + "prik_native_array_backend_for_descriptor", + "prik_native_array_backend_for_actual", + "prik_native_array_backend_release", + "prik_native_array_owned_with_descriptor", + ): + assert name in header + + +def test_native_array_backend_release_is_idempotent_and_never_frees_borrowed_storage(): + """Owned storage is released once; borrowed storage is never released here. + + A backend owns its context exactly when it carries a release callback, so a + module variable's or a field's backend -- which carries none -- cannot reach + the free below, and clearing the context makes an explicit close() and + finalization both safe. + """ + header = SUPPORT_HEADER.read_text(encoding="utf-8") + start = header.index("static inline void prik_native_array_backend_release(") + body = header[start : header.index("\n}\n", start)] + + assert "backend->release == NULL || backend->context == NULL" in body + assert "backend->context = NULL;" in body + assert "backend->release(context);" in body + assert "free(context);" in body + # A released owned backend reports itself closed rather than handing over + # storage that is gone. + reader = header[ + header.index("static inline prik_native_array_backend *prik_native_array_backend_from_capsule(") : + ] + reader = reader[: reader.index("\n}\n")] + assert "backend->release != NULL && backend->context == NULL" in reader + assert "prik native array handle is closed" in reader + + def test_address_capture_primitive_has_external_linkage_behind_one_opt_in(): """The one support symbol the generated Fortran bridge links against. diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 6d6e3bfbd..4a2d87b3a 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -368,12 +368,14 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert '"_native_array_descriptor_handoff_for_binding_positional"' in c_source assert '"_native_array_handle_from_generated_ops"' in c_source assert '"_bind_contract_native_array_handle"' in c_source - assert "prik_native_array_handle_capsule_new(" in c_source - assert "prik_native_array_handle_from_capsule(" in c_source + assert "prik_native_array_backend_capsule_new(" in c_source + assert "prik_native_array_backend_for_descriptor(" in c_source assert "PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE" in c_source assert "PRIK_NATIVE_ARRAY_KIND_POINTER" in c_source - assert "prik_native_array_handle_release(owner_handle)" in c_source - assert "bound_values_native_handle = prik_native_array_handle_from_capsule(bound_values_item" in c_source + assert "prik_native_array_backend_release(owner_backend)" in c_source + assert ( + "bound_values_native_backend = prik_native_array_backend_for_descriptor(bound_values_item" + ) in c_source assert "prik_bind_default_memory_handles_replace_values" in c_source assert "prik_owned_memory_handles_replace_values_destroy" in c_source assert "bound_values_default_binder" in c_source @@ -414,20 +416,21 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert "character(kind=c_char, len=:), allocatable, dimension(:) :: names" in bridge_source assert "result_owner_status = CFI_establish(result, NULL, CFI_attribute_pointer" in c_source assert ( - "PRIK_NATIVE_ARRAY_KIND_POINTER, 1, CFI_type_double, sizeof(double), sizeof(CFI_CDESC_T(1)), result" in c_source + "PRIK_NATIVE_ARRAY_KIND_POINTER, 1, (uint32_t)sizeof(CFI_CDESC_T(1)), CFI_type_double, " + "sizeof(double), result" in c_source ) -def test_constant_owned_handle_operations_do_not_emit_unused_descriptor_locals(): +def test_owned_descriptor_lifecycle_operations_do_not_materialize_descriptor_locals(): artifacts = WrapperGenerator().generate(_native_handle_plan()) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") - for operation in ("aligned", "descriptor", "destroy", "layout", "native_byte_order", "writeable"): + for operation in ("descriptor", "destroy"): function = _generated_c_function( c_source, f"prik_owned_memory_handles_make_return_{operation}", ) - assert "owner_handle" in function + assert "owner_backend" in function assert "owner_descriptor" not in function allocated = _generated_c_function( diff --git a/tests/fortran/memory_management/runtime/test_handle_lifecycle.py b/tests/fortran/memory_management/runtime/test_handle_lifecycle.py index e186c9a33..a309d9bb8 100644 --- a/tests/fortran/memory_management/runtime/test_handle_lifecycle.py +++ b/tests/fortran/memory_management/runtime/test_handle_lifecycle.py @@ -26,10 +26,6 @@ def shape(): calls.append(("shape", ())) return (3,) - def array_actual(): - calls.append(("array_actual", ())) - return 1001 - def descriptor(): calls.append(("descriptor", ())) return ctypes.c_void_p(1002) @@ -48,7 +44,6 @@ def to_numpy(): 1, { "shape": shape, - "array_actual": array_actual, "descriptor": descriptor, "allocated": allocated, "to_numpy": to_numpy, @@ -66,26 +61,14 @@ def to_numpy(): assert handle.generation == 9 assert handle.shape == (3,) assert handle.to_numpy() is value - assert handle._array_actual_for_binding(expected_dtype="float64", expected_rank=1).address == 1001 assert handle._descriptor_for_binding(expected_dtype="float64", expected_rank=1) == { "base_addr": 1002, "elem_len": 8, "rank": 1, "dim": [{"lower_bound": 0, "extent": 3, "sm": 8}], } - assert calls == [ - ("allocated", ()), - ("shape", ()), - ("allocated", ()), - ("to_numpy", ()), - ("allocated", ()), - ("allocated", ()), - ("shape", ()), - ("array_actual", ()), - ("allocated", ()), - ("shape", ()), - ("descriptor", ()), - ] + assert {name for name, _args in calls} == {"allocated", "shape", "to_numpy", "descriptor"} + assert all(args == () for _name, args in calls) def test_generated_handle_factory_splats_shape_operations_to_scalar_extents(): @@ -96,7 +79,6 @@ def test_generated_handle_factory_splats_shape_operations_to_scalar_extents(): 2, { "shape": lambda: (2, 3), - "array_actual": lambda: 1001, "descriptor": lambda: 1002, "allocated": lambda: True, "resize": lambda *extents: calls.append(("resize", extents)), @@ -127,7 +109,6 @@ def call(received_owner, *args): 1, { "shape": operation("shape", (3,)), - "array_actual": operation("array_actual", 0x5678), "descriptor": operation("descriptor", owner), "allocated": operation("allocated", True), "to_numpy": operation("to_numpy", value), @@ -140,7 +121,6 @@ def call(received_owner, *args): assert handle.shape == (3,) assert handle.to_numpy() is value - assert handle._array_actual_for_binding().address == 0x5678 assert _native_array_descriptor_handoff_for_binding( handle, descriptor_kind="allocatable", @@ -150,21 +130,17 @@ def call(received_owner, *args): handle.resize((5,)) handle.close() - assert calls == [ - ("allocated", owner, ()), - ("shape", owner, ()), - ("allocated", owner, ()), - ("to_numpy", owner, ()), - ("allocated", owner, ()), - ("allocated", owner, ()), - ("shape", owner, ()), - ("array_actual", owner, ()), - ("allocated", owner, ()), - ("shape", owner, ()), - ("descriptor", owner, ()), - ("resize", owner, (np.int64(5),)), - ("destroy", owner, ()), - ] + assert {name for name, _owner, _args in calls} == { + "allocated", + "shape", + "to_numpy", + "descriptor", + "resize", + "destroy", + } + assert all(received_owner is owner for _name, received_owner, _args in calls) + assert ("resize", owner, (np.int64(5),)) in calls + assert calls.count(("destroy", owner, ())) == 1 def test_generated_owned_handle_normalizes_compiler_zero_extent_descriptor_records(): @@ -181,7 +157,6 @@ def test_generated_owned_handle_normalizes_compiler_zero_extent_descriptor_recor 1, { "shape": lambda _native_owner: descriptor, - "array_actual": lambda _native_owner: 5678, "descriptor": lambda _native_owner: 5678, "to_numpy": lambda _native_owner: descriptor, "allocated": lambda _native_owner: True, @@ -204,7 +179,6 @@ def test_generated_handle_resolves_deferred_character_dtype_from_runtime_element { "shape": lambda: (2,), "element_length": lambda: state["itemsize"], - "array_actual": lambda: 0x1234, "descriptor": lambda: 0x1234, "allocated": lambda: True, "to_numpy": lambda: np.array([b"red", b"sky"], dtype=f"S{state['itemsize']}"), @@ -230,7 +204,6 @@ def destroy(received_owner): 1, { "shape": lambda _owner: (1,), - "array_actual": lambda _owner: 0x5678, "allocated": lambda _owner: True, "destroy": destroy, }, @@ -243,11 +216,10 @@ def destroy(received_owner): assert calls == [("destroy", owner)] -def test_generated_handle_factory_rejects_invalid_descriptor_kind_and_handoff_result(): +def test_generated_handle_factory_rejects_invalid_descriptor_kind_and_descriptor_result(): ops = { "shape": lambda: (1,), - "array_actual": lambda: object(), - "descriptor": lambda: 1, + "descriptor": lambda: object(), "allocated": lambda: True, "to_numpy": lambda: np.zeros(1, dtype=np.float64), } @@ -256,8 +228,8 @@ def test_generated_handle_factory_rejects_invalid_descriptor_kind_and_handoff_re _native_array_handle_from_generated_ops("target", "float64", 1, ops) handle = _native_array_handle_from_generated_ops("allocatable", "float64", 1, ops) - with pytest.raises(TypeError, match="handoff address must be an integer"): - handle._array_actual_for_binding(expected_dtype="float64", expected_rank=1) + with pytest.raises(TypeError, match="descriptor operation must return descriptor fields or an integer"): + handle._descriptor_for_binding(expected_dtype="float64", expected_rank=1) def test_owned_handle_close_calls_destroy_once_and_blocks_later_use(): diff --git a/tests/fortran/memory_management/runtime/test_native_array_actual_handoff.py b/tests/fortran/memory_management/runtime/test_native_array_actual_handoff.py deleted file mode 100644 index 8bf550ed7..000000000 --- a/tests/fortran/memory_management/runtime/test_native_array_actual_handoff.py +++ /dev/null @@ -1,598 +0,0 @@ -"""Runtime handoff validation for arrays and native storage handles.""" - -import numpy as np -import pytest -from prik.runtime.handles import ( - AllocatableArray, - PointerArray, - _NativeArrayHandoff, - _native_array_actual_argument_for_binding_positional, - _native_array_actual_for_binding, -) -from tests.fortran._support.native_array_handles import _handoff - - -def test_array_actual_hook_rejects_generated_none_handoff(): - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(203), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "array_actual": lambda _handle: None, - }, - to_numpy_policy="unsupported", - ) - - with pytest.raises(TypeError, match="array_actual operation must return a native handoff object"): - _native_array_actual_for_binding(handle) - - -def test_native_array_handoff_requires_non_null_pointer_address(): - assert _NativeArrayHandoff(1).address == 1 - with pytest.raises(TypeError, match="address must be an integer"): - _NativeArrayHandoff(True) - with pytest.raises(TypeError, match="address must be an integer"): - _NativeArrayHandoff("1") - with pytest.raises(ValueError, match="non-null positive pointer"): - _NativeArrayHandoff(0) - with pytest.raises(ValueError, match="non-null positive pointer"): - _NativeArrayHandoff(-1) - - -def test_array_actual_hook_rejects_generated_untyped_handoff_object(): - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(245), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "array_actual": lambda _handle: object(), - }, - to_numpy_policy="unsupported", - ) - - with pytest.raises(TypeError, match="received object"): - _native_array_actual_for_binding(handle) - - -def test_array_actual_hook_validates_expected_dtype_and_rank(): - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=2, - ops={ - "descriptor": lambda _handle: _handoff(204), - "shape": lambda _handle: (2, 3), - "allocated": lambda _handle: True, - "array_actual": lambda _handle: _handoff(205), - }, - to_numpy_policy="unsupported", - ) - - with pytest.raises(ValueError, match="expected rank 1"): - handle._array_actual_for_binding(expected_rank=1) - with pytest.raises(TypeError, match="expected dtype"): - handle._array_actual_for_binding(expected_dtype=np.int32) - - -def test_array_actual_hook_validates_expected_shape_layout_and_writeability(): - actual = _handoff(206) - calls = [] - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=2, - ops={ - "descriptor": lambda _handle: _handoff(207), - "shape": lambda _handle: (0, 3), - "allocated": lambda _handle: True, - "layout": lambda _handle: "F", - "writeable": lambda _handle: True, - "array_actual": lambda _handle: calls.append("array_actual") or actual, - }, - to_numpy_policy="unsupported", - ) - - assert ( - handle._array_actual_for_binding( - expected_dtype=np.float64, - expected_rank=2, - expected_shape=(0, 3), - expected_layout="F", - require_writeable=True, - ) - is actual - ) - assert calls == ["array_actual"] - - with pytest.raises(ValueError, match=r"expected shape .* axis 0"): - handle._array_actual_for_binding(expected_shape=(1, 3)) - with pytest.raises(ValueError, match="expected shape rank 1"): - handle._array_actual_for_binding(expected_shape=(0,)) - assert handle._array_actual_for_binding(expected_layout="f") is actual - with pytest.raises(ValueError, match="expected layout 'C'"): - handle._array_actual_for_binding(expected_layout="C") - - read_only = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(208), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "writeable": lambda _handle: False, - "array_actual": lambda _handle: _handoff(209), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(TypeError, match="must be writeable"): - read_only._array_actual_for_binding(require_writeable=True) - - missing_writeable = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(210), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "array_actual": lambda _handle: _handoff(211), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(NotImplementedError, match="operation 'writeable' is not available"): - missing_writeable._array_actual_for_binding(require_writeable=True) - - -def test_array_actual_hook_rejects_unsupported_layout_names_before_handoff(): - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(212), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "layout": lambda _handle: pytest.fail("unsupported expected layout must not call generated layout op"), - "array_actual": lambda _handle: pytest.fail("unsupported expected layout must block native handoff"), - }, - to_numpy_policy="unsupported", - ) - - with pytest.raises(ValueError, match="unsupported expected NumPy array layout 'A'"): - handle._array_actual_for_binding(expected_layout="A") - - invalid_actual = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(213), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "layout": lambda _handle: "K", - "array_actual": lambda _handle: pytest.fail("unsupported actual layout must block native handoff"), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(ValueError, match="layout operation returned unsupported layout 'K'"): - invalid_actual._array_actual_for_binding(expected_layout="F") - - -def test_array_actual_hook_validates_native_byte_order_and_alignment_ops(): - actual = _handoff(214) - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(215), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "native_byte_order": lambda _handle: True, - "aligned": lambda _handle: True, - "array_actual": lambda _handle: actual, - }, - to_numpy_policy="unsupported", - ) - - assert ( - handle._array_actual_for_binding( - require_native_byte_order=True, - require_aligned=True, - ) - is actual - ) - - byte_swapped = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(216), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "native_byte_order": lambda _handle: False, - "array_actual": lambda _handle: _handoff(217), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(TypeError, match="native byte order"): - byte_swapped._array_actual_for_binding(require_native_byte_order=True) - - unaligned = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(218), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "aligned": lambda _handle: False, - "array_actual": lambda _handle: _handoff(219), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(TypeError, match="aligned"): - unaligned._array_actual_for_binding(require_aligned=True) - - -def test_array_actual_binding_helper_accepts_ndarray_path_with_shared_validation(): - values = np.asfortranarray(np.zeros((2, 3), dtype=np.float64)) - - assert ( - _native_array_actual_for_binding( - values, - expected_dtype=np.float64, - expected_rank=2, - expected_shape=(2, 3), - expected_layout="F", - require_writeable=True, - ) - is values - ) - - with pytest.raises(TypeError, match="expected rank 1"): - _native_array_actual_for_binding(values, expected_rank=1) - with pytest.raises(TypeError, match="expected dtype"): - _native_array_actual_for_binding(values, expected_dtype=np.float32) - with pytest.raises(TypeError, match=r"incompatible shape at axis 0"): - _native_array_actual_for_binding(values, expected_shape=(1, 3)) - with pytest.raises(TypeError, match=r"expected ordering \(C\)"): - _native_array_actual_for_binding(values, expected_layout="C") - - read_only = values.copy() - read_only.setflags(write=False) - with pytest.raises(TypeError, match="writeable"): - _native_array_actual_for_binding(read_only, require_writeable=True) - - -def test_array_actual_binding_helper_rejects_byte_swapped_and_unaligned_ndarrays(): - swapped_dtype = np.dtype(np.float64).newbyteorder("S") - swapped = np.array([1.0, 2.0], dtype=swapped_dtype) - with pytest.raises(TypeError, match="native byte order"): - _native_array_actual_for_binding(swapped, require_native_byte_order=True) - - storage = np.zeros(8 * 2 + 1, dtype=np.uint8) - unaligned = storage[1:].view(np.float64) - assert not unaligned.flags.aligned - with pytest.raises(TypeError, match="aligned"): - _native_array_actual_for_binding(unaligned, require_aligned=True) - - -def test_array_actual_binding_helper_routes_handles_without_numpy_conversion(): - alloc_actual = _handoff(220) - pointer_actual = _handoff(221) - calls = [] - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(222), - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "layout": lambda _handle: "F", - "writeable": lambda _handle: True, - "native_byte_order": lambda _handle: True, - "aligned": lambda _handle: True, - "to_numpy": lambda _handle: pytest.fail("array-actual handoff must not call to_numpy"), - "array_actual": lambda _handle: calls.append("alloc") or alloc_actual, - }, - ) - pointer_handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(223), - "shape": lambda _handle: (2,), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - "layout": lambda _handle: "F", - "writeable": lambda _handle: True, - "native_byte_order": lambda _handle: True, - "aligned": lambda _handle: True, - "to_numpy": lambda _handle: pytest.fail("array-actual handoff must not call to_numpy"), - "array_actual": lambda _handle: calls.append("pointer") or pointer_actual, - }, - ) - - assert ( - _native_array_actual_for_binding( - alloc_handle, - expected_dtype=np.float64, - expected_rank=1, - expected_shape=(2,), - expected_layout="F", - require_writeable=True, - require_native_byte_order=True, - require_aligned=True, - ) - is alloc_actual - ) - assert ( - _native_array_actual_for_binding( - pointer_handle, - expected_dtype=np.float64, - expected_rank=1, - expected_shape=(2,), - expected_layout="F", - require_writeable=True, - require_native_byte_order=True, - require_aligned=True, - ) - is pointer_actual - ) - assert calls == ["alloc", "pointer"] - - -def test_array_actual_binding_helper_preserves_zero_length_array_actuals(): - values = np.zeros((0,), dtype=np.float64) - assert _native_array_actual_for_binding(values, expected_dtype=np.float64, expected_shape=(0,)) is values - - alloc_actual = _handoff(224) - pointer_actual = _handoff(225) - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(226), - "shape": lambda _handle: (0,), - "allocated": lambda _handle: True, - "array_actual": lambda _handle: alloc_actual, - }, - to_numpy_policy="unsupported", - ) - pointer_handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(227), - "shape": lambda _handle: (0,), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - "array_actual": lambda _handle: pointer_actual, - }, - to_numpy_policy="unsupported", - ) - - assert _native_array_actual_for_binding(alloc_handle, expected_shape=(0,)) is alloc_actual - assert _native_array_actual_for_binding(pointer_handle, expected_shape=(0,)) is pointer_actual - - -def test_array_actual_argument_abi_packer_uses_ndarray_data_pointer_and_shape_fields(): - values = np.asfortranarray(np.zeros((2, 3), dtype=np.float64)) - - assert _native_array_actual_argument_for_binding_positional( - values, - np.float64, - 2, - (2, 3), - "F", - True, - True, - True, - True, - True, - True, - ) == (values.ctypes.data, 2, values.dtype.itemsize, 2, 3, 1, 2, 1, 1) - - -def test_array_actual_argument_abi_packer_flattens_contiguous_storage_shape(): - values = np.asfortranarray(np.arange(6, dtype=np.float64).reshape((2, 3), order="F")) - - assert _native_array_actual_argument_for_binding_positional( - values, - expected_dtype=np.float64, - expected_rank=-1, - expected_shape=None, - require_native_byte_order=True, - require_aligned=True, - require_contiguous=True, - flatten_storage=True, - ) == (values.ctypes.data, values.size) - - -def test_array_actual_argument_abi_packer_flattens_final_edge_after_checked_prefix(): - values = np.asfortranarray(np.arange(24, dtype=np.float64).reshape((2, 3, 4), order="F")) - - assert _native_array_actual_argument_for_binding_positional( - values, - expected_dtype=np.float64, - expected_rank=2, - expected_shape=(2, None), - expected_layout="F", - require_native_byte_order=True, - require_aligned=True, - require_contiguous=True, - flatten_storage=True, - flat_axis=1, - ) == (values.ctypes.data, 2, 12) - - -def test_array_actual_argument_abi_packer_flattens_leading_edge_before_checked_suffix(): - values = np.arange(24, dtype=np.float64).reshape((2, 3, 4), order="C") - - assert _native_array_actual_argument_for_binding_positional( - values, - expected_dtype=np.float64, - expected_rank=2, - expected_shape=(None, 4), - expected_layout="C", - require_native_byte_order=True, - require_aligned=True, - require_contiguous=True, - flatten_storage=True, - flat_axis=0, - ) == (values.ctypes.data, 6, 4) - - -@pytest.mark.parametrize( - ("values", "expected_rank", "expected_shape", "flat_axis", "message"), - [ - ( - np.array(1.0, dtype=np.float64), - 1, - (None,), - 0, - "expects NumPy array rank 1 through 15", - ), - ( - np.ones((2,), dtype=np.float64), - 2, - (2, None), - 1, - "expects NumPy array rank at least 2", - ), - ( - np.ones((2, 3, 4), dtype=np.float64), - 3, - (2, None, 4), - 1, - "axis must be the first or final contract dimension", - ), - ( - np.ones((2, 3, 4), dtype=np.float64), - 2, - (3, None), - 1, - "incompatible shape at axis 0", - ), - ( - np.ones((2, 3, 4), dtype=np.float64), - 2, - (None, 3), - 0, - "incompatible shape at axis 2", - ), - ], -) -def test_array_actual_argument_abi_packer_rejects_invalid_flat_shapes( - values, - expected_rank, - expected_shape, - flat_axis, - message, -): - with pytest.raises((TypeError, ValueError), match=message): - _native_array_actual_argument_for_binding_positional( - values, - expected_dtype=np.float64, - expected_rank=expected_rank, - expected_shape=expected_shape, - require_native_byte_order=True, - require_aligned=True, - require_contiguous=True, - flatten_storage=True, - flat_axis=flat_axis, - ) - - -def test_array_actual_argument_abi_packer_flattens_native_handle_shape(): - actual = _handoff(252) - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=2, - ops={ - "descriptor": lambda _handle: _handoff(253), - "shape": lambda _handle: (2, 3), - "allocated": lambda _handle: True, - "layout": lambda _handle: "F", - "writeable": lambda _handle: True, - "native_byte_order": lambda _handle: True, - "aligned": lambda _handle: True, - "to_numpy": lambda _handle: pytest.fail("flat array-actual ABI packing must not call to_numpy"), - "array_actual": lambda _handle: actual, - }, - ) - - assert _native_array_actual_argument_for_binding_positional( - handle, - expected_dtype=np.float64, - expected_rank=-1, - expected_shape=None, - require_native_byte_order=True, - require_aligned=True, - require_contiguous=True, - flatten_storage=True, - ) == (actual.address, 6) - - -def test_array_actual_argument_abi_packer_rejects_absent_handles_before_generated_handoff(): - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(250), - "shape": lambda _handle: None, - "allocated": lambda _handle: False, - "to_numpy": lambda _handle: pytest.fail("array-actual ABI packing must not call to_numpy"), - "array_actual": lambda _handle: pytest.fail("unallocated handle must block native array handoff"), - }, - ) - pointer_handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(251), - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - "to_numpy": lambda _handle: pytest.fail("array-actual ABI packing must not call to_numpy"), - "array_actual": lambda _handle: pytest.fail("unassociated handle must block native array handoff"), - }, - ) - - with pytest.raises(ValueError, match="unallocated"): - _native_array_actual_argument_for_binding_positional(alloc_handle) - with pytest.raises(ValueError, match="unassociated"): - _native_array_actual_argument_for_binding_positional(pointer_handle) - - -def test_array_actual_binding_helper_rejects_absent_handles_none_and_non_arrays(): - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(230), - "shape": lambda _handle: None, - "allocated": lambda _handle: False, - "to_numpy": lambda _handle: pytest.fail("array-actual handoff must not call to_numpy"), - "array_actual": lambda _handle: _handoff(231), - }, - ) - pointer_handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(232), - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - "to_numpy": lambda _handle: pytest.fail("array-actual handoff must not call to_numpy"), - "array_actual": lambda _handle: _handoff(233), - }, - ) - - with pytest.raises(ValueError, match="unallocated"): - _native_array_actual_for_binding(alloc_handle) - with pytest.raises(ValueError, match="unassociated"): - _native_array_actual_for_binding(pointer_handle) - with pytest.raises(TypeError, match="received None"): - _native_array_actual_for_binding(None) - with pytest.raises(TypeError, match="received list"): - _native_array_actual_for_binding([1.0, 2.0]) diff --git a/tests/fortran/modules/end_to_end/test_logical_array_views.py b/tests/fortran/modules/end_to_end/test_logical_array_views.py index 86b053d2d..98cb52b13 100644 --- a/tests/fortran/modules/end_to_end/test_logical_array_views.py +++ b/tests/fortran/modules/end_to_end/test_logical_array_views.py @@ -22,6 +22,7 @@ logical(c_bool) :: narrow(4) logical :: wide(4) logical(c_bool), allocatable :: narrow_alloc(:) + logical, allocatable :: wide_alloc(:) contains @@ -30,6 +31,8 @@ wide = [.true., .false., .true., .false.] if (.not. allocated(narrow_alloc)) allocate(narrow_alloc(4)) narrow_alloc = [.true., .true., .false., .false.] + if (.not. allocated(wide_alloc)) allocate(wide_alloc(4)) + wide_alloc = [.true., .false., .true., .false.] end subroutine setup subroutine negate() @@ -46,6 +49,18 @@ integer :: total total = count(wide) end function count_wide + + function count_narrow_actual(values) result(total) + logical(c_bool), intent(in) :: values(:) + integer :: total + total = count(values) + end function count_narrow_actual + + function count_wide_actual(values) result(total) + logical, intent(in) :: values(:) + integer :: total + total = count(values) + end function count_wide_actual end module flogical_view_f90 """ @@ -79,6 +94,12 @@ def test_a_wider_logical_reports_the_width_its_elements_occupy(logical_view): assert wide.dtype == np.dtype(np.int32) assert wide.astype(bool).tolist() == [True, False, True, False] + assert logical_view.wide_alloc.to_numpy().dtype == np.dtype(np.int32) + + +def test_logical_allocatable_handles_reach_matching_ordinary_dummies(logical_view): + assert logical_view.count_narrow_actual(logical_view.narrow_alloc) == np.int32(2) + assert logical_view.count_wide_actual(logical_view.wide_alloc) == np.int32(2) def test_a_held_view_keeps_agreeing_with_fortran_across_native_writes(logical_view): diff --git a/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py b/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py index 0d1e07e2d..6d41634f8 100644 --- a/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py +++ b/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py @@ -7,7 +7,6 @@ handed back to an ordinary Fortran array dummy. """ -import os from pathlib import Path import numpy as np @@ -20,16 +19,6 @@ pytestmark = pytest.mark.fortran_end_to_end -def _allocatable_dummy_handoff_supported() -> bool: - """Report whether this compiler accepts a handle at an allocatable dummy. - - ifx rejects the established descriptor for that argument form regardless of - the bounds it carries, so the round-trip below is checked where it works. - The descriptor facts themselves are asserted on every compiler. - """ - return "ifx" not in os.environ.get("PRIK_TEST_FORTRAN_COMPILER", "gfortran") - - @pytest.fixture(scope="module") def array_forms(tmp_path_factory): module = _build_and_import( @@ -162,9 +151,8 @@ def test_only_an_allocatable_dummy_carries_the_declared_lower_bound(array_forms) """ assert array_forms.alloc_shifted._descriptor_record_for_binding()["dim"][0]["lower_bound"] == 5 assert array_forms.alloc_plain._descriptor_record_for_binding()["dim"][0]["lower_bound"] == 1 - if _allocatable_dummy_handoff_supported(): - assert array_forms.lower_bound_of(array_forms.alloc_shifted) == np.int32(5) - assert array_forms.lower_bound_of(array_forms.alloc_plain) == np.int32(1) + assert array_forms.lower_bound_of(array_forms.alloc_shifted) == np.int32(5) + assert array_forms.lower_bound_of(array_forms.alloc_plain) == np.int32(1) # `fixed_shifted` is declared (5:8) and sums the same as any other four # elements: nothing downstream can tell where it started. diff --git a/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py b/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py index 1a4a43aee..1f3d76f76 100644 --- a/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py +++ b/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py @@ -7,13 +7,13 @@ from prik import build_pyi_extension from tests.fortran._support.wrapper_build import ( - _compile_native_object, _build_source_or_generated_pyi_and_import, + _compile_native_object, + _compiler, _import_from_build_dir, _sole_native_module, ) from prik.contracts import Allocatable, Float64, Pointer -from prik.runtime.handles import _NativeArrayHandoff, AllocatableArray, PointerArray FIXTURES = Path(__file__).parent / "fixtures" OPTIONAL_F90_SOURCE = FIXTURES / "native" / "foptional_f90.f90" @@ -21,37 +21,6 @@ pytestmark = pytest.mark.fortran_end_to_end -def _unallocated_handle_for_rejected_optional_array(): - return AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("optional array path must reject handles before handoff"), - "descriptor": lambda _handle: _NativeArrayHandoff(501), - "shape": lambda _handle: None, - "to_numpy": lambda _handle: None, - "allocated": lambda _handle: False, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, - ) - - -def _unassociated_handle_for_rejected_optional_array(): - return PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "array_actual": lambda _handle: pytest.fail("optional array path must reject handles before handoff"), - "descriptor": lambda _handle: _NativeArrayHandoff(502), - "shape": lambda _handle: None, - "to_numpy": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, - ) - - def test_optional_scalar_descriptors_distinguish_omitted_none_and_value(tmp_path: Path): source = FIXTURES / "native" / "optional_scalar_descriptors.f90" native_object = _compile_native_object(source, tmp_path / "native") @@ -59,6 +28,7 @@ def test_optional_scalar_descriptors_distinguish_omitted_none_and_value(tmp_path result = build_pyi_extension( entry, + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "build", @@ -92,6 +62,7 @@ def test_optional_array_descriptors_preserve_presence_and_storage_state(tmp_path result = build_pyi_extension( contract, + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "array_descriptors", @@ -177,10 +148,6 @@ def test_optional_arguments_drive_fortran_present_behavior( module.summarize(np.int32(5), scale="bad") with pytest.raises(TypeError): module.fill_optional(np.int32(3), np.empty(3, dtype=np.float32)) - with pytest.raises(TypeError): - module.fill_optional(np.int32(3), _unallocated_handle_for_rejected_optional_array()) - with pytest.raises(TypeError): - module.fill_optional(np.int32(3), _unassociated_handle_for_rejected_optional_array()) def test_optional_array_buffers_preserve_omission_and_identity(tmp_path: Path): @@ -189,6 +156,7 @@ def test_optional_array_buffers_preserve_omission_and_identity(tmp_path: Path): contract_package = FIXTURES / "edited_contracts" / "optional_arrays" result = build_pyi_extension( contract_package / "__init__.pyi", + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "build", diff --git a/tests/fortran/pointers/end_to_end/test_pointer_handles.py b/tests/fortran/pointers/end_to_end/test_pointer_handles.py index 54f929e5a..8efd8d6a8 100644 --- a/tests/fortran/pointers/end_to_end/test_pointer_handles.py +++ b/tests/fortran/pointers/end_to_end/test_pointer_handles.py @@ -14,6 +14,7 @@ _build_text_and_import, _build_source_or_generated_pyi_and_import, _compile_native_object, + _compiler, _import_from_build_dir, _sole_native_module, ) @@ -193,6 +194,7 @@ def {total_name}(values: Pointer[Float64[:]]) -> Float64: ... ) result = build_pyi_extension( contract_dir / "__init__.pyi", + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=workdir / "build", @@ -231,6 +233,7 @@ def _pointer_handle_module(build_mode: str, tmp_path: Path): native_object = _compile_native_object(source, tmp_path / "native") result = build_pyi_extension( contract_dir / "__init__.pyi", + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "pyi_build", @@ -247,6 +250,7 @@ def _pointer_descriptor_view_module(tmp_path: Path): contract = CONTRACT_FIXTURES / "fpointer_handles_policy" / "__init__.pyi" result = build_pyi_extension( contract, + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "pyi_build", @@ -271,8 +275,7 @@ def test_module_and_derived_pointer_handles_track_native_association( assert module.module_values is module_handle assert module_handle.associated is True assert module_handle.shape == (2,) - with pytest.raises(ValueError, match="target is noncontiguous"): - module.sum_values(module_handle) + assert module.sum_values(module_handle) == np.float64(6.0) with pytest.raises(NotImplementedError, match="to_numpy extraction is unsupported"): module_handle.to_numpy() @@ -477,6 +480,7 @@ def sum_allocatable_descriptor(values: Allocatable[Float64[:]]) -> Float64: ... ) result = build_pyi_extension( contract, + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "build", @@ -548,6 +552,7 @@ def sum_pointer_descriptor(values: Pointer[Float64[:]]) -> Float64: ... ) result = build_pyi_extension( contract, + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "build", @@ -748,6 +753,7 @@ def total(values: Pointer[Float64[:]]) -> Float64: ... ) result = build_pyi_extension( contract, + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "build", diff --git a/tests/fortran/pointers/runtime/test_pointer_array_actual_abi.py b/tests/fortran/pointers/runtime/test_pointer_array_actual_abi.py deleted file mode 100644 index fb9d8d78a..000000000 --- a/tests/fortran/pointers/runtime/test_pointer_array_actual_abi.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Pointer array-actual handoff behavior.""" - -import numpy as np -import pytest -from prik.runtime.handles import ( - PointerArray, - _native_array_actual_argument_for_binding_positional, -) -from tests.fortran._support.native_array_handles import ( - _ArrayState, - _handoff, -) - - -def test_array_actual_argument_abi_packer_uses_pointer_native_array_actual_dtype_metadata(): - actual = _handoff(248) - handle = PointerArray( - dtype=np.dtype(np.float32), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(249), - "shape": lambda _handle: (3,), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - "array_actual": lambda _handle: actual, - }, - to_numpy_policy="unsupported", - ) - - assert _native_array_actual_argument_for_binding_positional( - handle, - None, - 1, - (3,), - None, - False, - False, - False, - False, - True, - False, - ) == (actual.address, np.dtype(np.float32).itemsize, 3) - - -def test_pointer_array_actual_hook_requires_associated_state_without_to_numpy(): - actual = _handoff(242) - calls = [] - state = _ArrayState() - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "descriptor": lambda _handle: _handoff(243), - "shape": lambda _handle: state.shape, - "associated": lambda _handle: state.shape is not None, - "nullify": lambda _handle: setattr(state, "shape", None), - "to_numpy": lambda _handle: pytest.fail("array-actual handoff must not call to_numpy"), - "array_actual": lambda _handle: calls.append("array_actual") or actual, - }, - ) - - with pytest.raises(ValueError, match="unassociated"): - handle._array_actual_for_binding(expected_dtype=np.float64, expected_rank=1) - - state.shape = (3,) - - assert handle._array_actual_for_binding(expected_dtype="float64", expected_rank=1) is actual - assert calls == ["array_actual"] diff --git a/tests/fortran/pointers/runtime/test_pointer_contract_handles.py b/tests/fortran/pointers/runtime/test_pointer_contract_handles.py index 2cd056471..efb8516e3 100644 --- a/tests/fortran/pointers/runtime/test_pointer_contract_handles.py +++ b/tests/fortran/pointers/runtime/test_pointer_contract_handles.py @@ -8,7 +8,6 @@ AllocatableArray, PointerArray, _bind_contract_native_array_handle, - _native_array_actual_for_binding, ) @@ -66,7 +65,6 @@ def source_nullify(_handle): rank=1, ops={ "shape": lambda _handle: value.shape, - "array_actual": lambda _handle: int(value.ctypes.data), "descriptor": lambda _handle: source_state["descriptor"], "to_numpy": lambda _handle: source_state["descriptor"], "associated": lambda _handle: source_state["descriptor"]["base_addr"] != 0, @@ -81,7 +79,6 @@ def source_nullify(_handle): assert target.associated is True assert target.shape == (3,) np.testing.assert_array_equal(target.to_numpy(), value) - assert _native_array_actual_for_binding(target).address == value.ctypes.data source.nullify() assert source.associated is False @@ -101,7 +98,6 @@ def test_fresh_pointer_pending_association_is_applied_when_native_storage_attach rank=1, ops={ "shape": lambda _handle: value.shape, - "array_actual": lambda _handle: int(value.ctypes.data), "descriptor": lambda _handle: descriptor, "to_numpy": lambda _handle: descriptor, "associated": lambda _handle: True, @@ -127,7 +123,6 @@ def associate(received_owner, facts): 1, { "shape": lambda _owner: value.shape if state["associated"] else None, - "array_actual": lambda _owner: int(value.ctypes.data), "descriptor": lambda received_owner: received_owner, "associated": lambda _owner: state["associated"], "associate": associate, diff --git a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py index 844e84000..6436dc93b 100644 --- a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py +++ b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py @@ -26,7 +26,6 @@ def test_descriptor_hook_rejects_generated_none_handoff(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: None, "allocated": lambda _handle: False, "descriptor": lambda _handle: None, @@ -46,7 +45,6 @@ def test_descriptor_hook_validates_expected_dtype_rank_and_current_shape(): dtype=np.dtype(np.float64), rank=2, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: (2, 3), "allocated": lambda _handle: True, "descriptor": lambda _handle: descriptor, @@ -82,7 +80,6 @@ def test_descriptor_binding_helper_accepts_matching_handles_and_optional_none(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: (0,), "allocated": lambda _handle: True, "descriptor": lambda _handle: alloc_descriptor, @@ -93,7 +90,6 @@ def test_descriptor_binding_helper_accepts_matching_handles_and_optional_none(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: None, "associated": lambda _handle: False, "nullify": lambda _handle: None, @@ -133,7 +129,6 @@ def test_descriptor_binding_helper_rejects_plain_arrays_none_and_wrong_kind(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, "descriptor": lambda _handle: _handoff(238), @@ -160,7 +155,6 @@ def test_projected_descriptor_handoff_requires_persistent_standard_descriptor_st dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: (2,), "allocated": lambda _handle: True, "descriptor": lambda _handle: direct, @@ -199,7 +193,6 @@ def test_owned_standard_descriptor_supplies_the_only_read_only_handoff(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: (2,), "associated": lambda _handle: True, "nullify": lambda _handle: None, @@ -233,7 +226,6 @@ def test_owned_standard_descriptor_supplies_the_only_read_only_handoff(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: pytest.fail("descriptor handoff must not request array actual"), "shape": lambda _handle: (2,), "allocated": lambda _handle: True, "descriptor": lambda _handle: _handoff(0x5678), diff --git a/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py b/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py index fd2c2bacf..3eeadabef 100644 --- a/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py +++ b/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py @@ -6,7 +6,6 @@ AllocatableArray, NativeArrayHandleBase, PointerArray, - _native_array_actual_for_binding, _native_array_descriptor_for_binding, _native_array_handle_from_generated_ops, ) @@ -175,14 +174,13 @@ def test_to_numpy_rejects_generated_array_with_wrong_rank_or_dtype(): wrong_dtype.to_numpy() -def test_runtime_handle_shapes_reject_negative_extents_before_binding_handoff(): +def test_runtime_handle_shapes_reject_negative_extents_before_descriptor_handoff(): handle = AllocatableArray( dtype=np.dtype(np.float64), rank=1, ops={ "shape": lambda _handle: (-1,), "allocated": lambda _handle: True, - "array_actual": lambda _handle: pytest.fail("negative shape must block native handoff"), "descriptor": lambda _handle: pytest.fail("negative shape must block descriptor handoff"), }, to_numpy_policy="unsupported", @@ -190,8 +188,6 @@ def test_runtime_handle_shapes_reject_negative_extents_before_binding_handoff(): with pytest.raises(ValueError, match="non-negative"): _ = handle.shape - with pytest.raises(ValueError, match="non-negative"): - _native_array_actual_for_binding(handle) with pytest.raises(ValueError, match="non-negative"): _native_array_descriptor_for_binding(handle, descriptor_kind="allocatable") with pytest.raises(ValueError, match="non-negative"): @@ -201,7 +197,6 @@ def test_runtime_handle_shapes_reject_negative_extents_before_binding_handoff(): dtype=np.dtype(np.float64), rank=1, ops={ - "array_actual": lambda _handle: _handoff(228), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, "descriptor": lambda _handle: _handoff(229), @@ -252,7 +247,6 @@ def pointer(state): rank=1, ops={ "shape": lambda _handle: tuple(dimension["extent"] for dimension in state["descriptor"]["dim"]), - "array_actual": lambda _handle: _handoff(state["descriptor"]["base_addr"]), "descriptor": lambda _handle: state["descriptor"], "to_numpy": lambda _handle: state["descriptor"], "associated": lambda _handle: state["descriptor"]["base_addr"] != 0, @@ -290,7 +284,6 @@ def test_generated_pointer_associate_packs_standard_descriptor_facts(): rank=1, ops={ "shape": lambda _handle: value.shape, - "array_actual": lambda _handle: _handoff(value.ctypes.data), "descriptor": lambda _handle: source_state["descriptor"], "to_numpy": lambda _handle: source_state["descriptor"], "associated": lambda _handle: True, @@ -306,7 +299,6 @@ def test_generated_pointer_associate_packs_standard_descriptor_facts(): 1, { "shape": lambda: None, - "array_actual": lambda: 1, "descriptor": lambda: 1, "associated": lambda: False, "associate": lambda facts: received.append(facts), @@ -520,23 +512,12 @@ def test_common_handle_requires_generated_shape_operation(): AllocatableArray(dtype="float64", rank=1, ops={}) -def test_common_handle_requires_generated_handoff_operations(): - with pytest.raises(ValueError, match="requires generated operation 'array_actual'"): - AllocatableArray( - dtype="float64", - rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - }, - to_numpy_policy="unsupported", - ) +def test_common_handle_requires_generated_descriptor_operation(): with pytest.raises(ValueError, match="requires generated operation 'descriptor'"): AllocatableArray( dtype="float64", rank=1, ops={ - "array_actual": lambda _handle: _handoff(244), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, }, From abc7b3a3fd3bec48c45ef0a35dabcb416e1951f7 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 05:56:05 +0100 Subject: [PATCH 23/47] codex: Answer every handle inquiry from the descriptor it already has A handle's shape, allocation and association state, element width, contiguity and NumPy view were each reached a different way. A borrowed module array had a Fortran procedure per inquiry and, for its view, built a Python dict of descriptor fields that handles.py decoded back with ctypes. An owned handle called Fortran procedures that took the descriptor it was already holding. All of it describes the same descriptor. So all of it now reads that descriptor, once, where it is valid. Five small consumers -- present, contiguous, element length, shape, view -- run through the backend's one entry point and hand back the finished Python object. Nothing copies a descriptor out, and the bridge emits nothing per variable for any of them: only the descriptor entry point and the mutations that must reach the entity survive. The view is built with the descriptor's own byte strides, so a reversed or non-contiguous pointer target comes through without the intermediate ctypes buffer the Python decode needed. Timings for a rank-1 module allocatable, best of five runs of 20k calls: .shape 4838 -> 1371 ns, .to_numpy() 12666 -> 1331 ns. A derived-type field goes 4001 -> 1668 and 12237 -> 2262. An owned handle pays about 150 ns more than its old inline read for going through the same entry point as everyone else, which is the price of there being one mechanism. One declaration cannot take this route. A bind(C) character dummy must have an assumed or constant length, so `character(len=:), pointer` has no legal descriptor interface; gfortran mistranslates it rather than rejecting it and divides by a zero element length in cfi_desc_to_gfc_desc. Completed policy now says so, and such a handle keeps generated Fortran inquiries and offers neither a view nor an association -- both of which crashed before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- AGENTS.md | 1 + CHANGELOG.md | 17 +- docs/developer/packages/runtime.md | 99 ++- prik/codegen/c/binding.py | 759 +++++++++++------ prik/codegen/fortran/bridge.py | 33 +- prik/pipeline/wrapper.py | 17 +- prik/planning/entrypoints.py | 69 +- prik/planning/models.py | 8 + prik/planning/planner.py | 1 + prik/policy/construction.py | 38 +- prik/policy/models.py | 8 + prik/runtime/handles.py | 769 ++++-------------- .../fortran/_support/native_array_handles.py | 64 +- .../codegen/test_allocatable_lowering.py | 15 +- .../end_to_end/test_allocatable_handles.py | 38 +- .../test_allocatable_contract_handles.py | 15 +- .../test_allocatable_descriptor_abi.py | 30 - .../test_allocatable_handle_protocol.py | 16 +- .../runtime/test_native_support.py | 4 +- .../codegen/test_native_handle_planning.py | 31 +- .../runtime/test_handle_lifecycle.py | 67 +- .../test_module_array_storage_forms.py | 2 - .../runtime/test_pointer_contract_handles.py | 50 +- .../runtime/test_pointer_descriptor_abi.py | 495 +++-------- .../runtime/test_pointer_handle_protocol.py | 145 +--- 25 files changed, 1162 insertions(+), 1629 deletions(-) delete mode 100644 tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py diff --git a/AGENTS.md b/AGENTS.md index e70be4d8a..2744026da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,7 @@ compilation should use the focused owners under `tests/fortran/infrastructure/building/compiling/` as applicable. Include the relevant end-to-end feature tests whenever a generated or compiled mechanism changes; run a broader suite when behavior spans multiple stages. +Run pytest with at most `-n 2`. Never `-n 4`, `-n 8`, or `-n auto`. The development machine has 12 cores but only about 7 GB of RAM, and every xdist worker loads NumPy while the Fortran end-to-end tests fork gfortran and cc per test on top of `pytest-monitor` profiling each one. Higher parallelism exhausts memory and thrashes swap, which has hard-frozen the machine and forced a reboot. Prefer the narrowest owning test path over a full suite run, and commit verified work promptly rather than batching it behind a long run. Do not run LAPACK wrapper tests locally unless the user explicitly asks for them. Local verification may run everything else, including BLAS-only real-library tests; leave LAPACK coverage to GitHub Actions by default. Do not run the full coverage workflow for routine changes. Run focused tests plus the required static-analysis suite. Reserve the complete CI-style coverage workflow for explicit pre-merge or pull-request verification, or when the user specifically requests it. When investigating coverage failures, mirror the GitHub Actions workflow before deciding the fix: run coverage with `COVERAGE_PROCESS_START=pyproject.toml`, combine parallel data with `python3 -m coverage combine`, then run `python3 -m coverage report`. Do not assume a plain local coverage run matches CI, especially when subprocess tests are involved. diff --git a/CHANGELOG.md b/CHANGELOG.md index 61528e509..2417dcc44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,20 @@ release tags add a leading `v` to the package version. rank 1 through 15, optional arrays, and fixed- or assumed-width character arrays. C array arguments continue to accept NumPy arrays only. -- Added a versioned native descriptor-operation table for generated array - handles. Ordinary argument handoff, shape queries, and NumPy views can use the - descriptor directly without serializing descriptor fields through Python. +- Generated array handles now publish one versioned native capsule, + `prik.native_array_backend.v1`, replacing the separate descriptor-operation + table and owned-descriptor record. It carries a single entry point that runs + a consumer while the handle's descriptor is live, whether that descriptor is + one Fortran builds for the call or persistent storage the wrapper owns. + Extensions built against the earlier branch-only table must be regenerated. + +- Argument handoff, shape, allocation and association state, element width, + contiguity and NumPy views are now all read from that live descriptor in C. + No descriptor is serialized into Python fields and decoded back, and the + generated Fortran bridge no longer carries a procedure per variable for any + of those inquiries. A NumPy view now carries the descriptor's own byte + strides directly, so negative strides and non-contiguous pointer targets are + exposed without an intermediate buffer. - Fixed writable module and derived-field allocatable arrays, and reject PROTECTED module arrays during policy completion when writable access would be diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 77ff4706c..24953ea6e 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -23,7 +23,7 @@ select a different view behavior from local descriptor facts. ## A Native Array Handle At Runtime ```text -generated operation dictionary + native descriptor table +generated operation dictionary + native backend capsule + dtype, rank, ownership, and view policy -> NativeArrayHandleBase validation and owner retention -> AllocatableArray or PointerArray @@ -31,11 +31,86 @@ generated operation dictionary + native descriptor table ``` The operation dictionary is the boundary between generated extension code and -the stable Python handle API. The versioned native table is the cross-extension -C boundary used to inspect a live descriptor without serializing it through -Python. An operation exists only when the completed plan allows the generator -to expose it. Missing operations fail explicitly rather than being inferred -from `allocatable` or `pointer` alone. +the stable Python handle API. An operation exists only when the completed plan +allows the generator to expose it. Missing operations fail explicitly rather +than being inferred from `allocatable` or `pointer` alone. + +### The Backend Capsule + +Every generated handle publishes one versioned capsule, +`prik.native_array_backend.v1`, on `_native_ops`. It is the whole +cross-extension ABI for an array handle: + +```c +typedef struct { + uint32_t struct_size; + uint32_t descriptor_kind; + uint32_t rank; + uint32_t descriptor_size; + int32_t cfi_type; + size_t element_size; + void *context; + prik_native_array_with_descriptor_fn with_descriptor; + prik_native_array_release_fn release; +} prik_native_array_backend; +``` + +`with_descriptor(context, consumer, consumer_context)` is the only route to a +descriptor. It produces a live one and runs the consumer on it: + +- **Borrowed** — a module variable or a derived-type field. The entry point + enters Fortran, which builds the descriptor for that call and copies back + what the consumer wrote. The descriptor is gone when the consumer returns and + must never be retained, copied, or serialized. +- **Owned** — a native result, or a contract handle that has been given + storage. The binding allocated a descriptor and keeps it for the handle's + life, so the entry point hands that storage straight to the consumer. + +Consumers cannot tell the two apart and must not try to. `context` is whatever +the entity needs to be reached: the parent's address for a field, the +descriptor storage for an owned handle, `NULL` for a module variable. +`release` is non-`NULL` exactly when `context` is storage this extension +allocated, so a borrowed backend can never free anything, and clearing +`context` after one release makes `close()` and finalization both safe. + +The version lives in the capsule name: `PyCapsule_GetPointer` refuses a +capsule created under any other name, so no magic word or second version field +is carried. `struct_size` catches a layout change made without renaming; +`descriptor_size` is `sizeof(CFI_CDESC_T(rank))` and is the only way one +extension can attest another's CFI layout, which nothing inside a descriptor +can establish. `descriptor_kind`, `rank`, `cfi_type` and `element_size` are +what a reader compares against the dummy it is filling, so a mismatched actual +is refused before any Fortran is entered. `element_size` is `0` when the width +is only known at run time, as for a deferred-length character array. + +### Inquiries Read The Descriptor + +`shape`, `allocated`, `associated`, `contiguous`, `element_length` and +`to_numpy` are all answered by small shared C consumers run through +`with_descriptor`, for borrowed and owned handles alike. Nothing crosses into +Python except the finished object, so no descriptor is serialized into Python +fields and no field is decoded back into C. There is correspondingly no Fortran +procedure per variable for any of them; the bridge emits only the descriptor +entry point and the mutations that must reach the entity itself — `allocate`, +`resize`, `deallocate`, `nullify`, `associate` and `destroy`. + +A pointer additionally reports `descriptor` as a flat fact tuple — base +address, element width, rank, then a lower bound, extent and byte stride per +axis. That is how a pointer assignment snapshots what another pointer is +associated with, which matters because a handle created from a `.pyi` contract +has no native storage until a call gives it some and so has nowhere else to +record it. + +### Views And Ownership + +`to_numpy()` builds the view in C while the descriptor is live, over the +storage the descriptor names, with the descriptor's own byte strides — so +negative strides, non-contiguous pointer targets and zero-sized dimensions all +come through unchanged. The view's base is what keeps that storage valid: the +parent object for a derived-type field, the backend capsule for an owned +handle, and nothing for a module variable, whose storage outlives every view of +it. An owned handle's view additionally retains the handle, because closing the +handle is what releases the storage. ## Local Structure @@ -52,7 +127,9 @@ prik/runtime/ `AllocatableArray` adds allocation state, resize, and deallocation; `PointerArray` adds association, nullification, allocation, resize, and deallocation when supplied. Internal adapters translate generated call - signatures and descriptor handoffs. + signatures. A handle created from a `.pyi` contract answers from a fact + tuple of its own until a call attaches generated storage; every other handle + answers from its descriptor. - `native_support/prik_binding.h` contains header-only CPython/NumPy conversion, descriptor, validation, capsule, and release support. Change it only with its generated C users and `prik/compiler/native_support.py`. @@ -60,9 +137,11 @@ prik/runtime/ `to_numpy()` returns `None` for an absent allocatable or pointer and otherwise validates the completed view policy, dtype, rank, and any required contiguity. -Native argument handoff performs the additional expected shape, layout, -alignment, byte-order, and writeability checks. A returned NumPy array is a -view of native storage; a caller that needs independent storage must copy it. +Native argument handoff is performed in the binding, against the live +descriptor, and refuses a mismatched dtype, rank, fixed shape, character width, +layout, byte order, alignment, writeability, or contiguity there. A returned +NumPy array is a view of native storage; a caller that needs independent +storage must copy it. ## Run The Handle Demonstration diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 28b2c45a4..1b2168926 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -180,6 +180,20 @@ class _COverloadDispatch: public: bool +# Every inquiry a handle answers is read from the live descriptor its entry +# point supplies, so none of these needs a Fortran procedure of its own. +_DESCRIPTOR_ANSWERED_OPERATIONS = frozenset( + { + NativeArrayOperation.ALLOCATED, + NativeArrayOperation.ASSOCIATED, + NativeArrayOperation.CONTIGUOUS, + NativeArrayOperation.DESCRIPTOR, + NativeArrayOperation.ELEMENT_LENGTH, + NativeArrayOperation.SHAPE, + NativeArrayOperation.TO_NUMPY, + } +) + _BINDING_GETTER_SUMMARIES = { ModuleGetterAction.CONSTANT_VALUE: "The value is a constant placed in the module dictionary at import.", ModuleGetterAction.NATIVE_CONSTANT_VALUE: "Builds a Python object from the compiler-evaluated constant.", @@ -321,6 +335,9 @@ def binding_module(self, plan: ModulePlan) -> CModule: declarations=self._module_declarations(plan), functions=( *self._module_allocator_functions(needs_free), + # Every handle inquiry runs through these, so they precede the + # first handle operation that names one. + *self._native_array_projection_functions(plan), *self._extent_expression_support_functions(plan), *self._callback_runtime_functions(plan), *self._derived_call_runtime_functions(plan), @@ -3070,7 +3087,9 @@ def _field_handle_backend_capsule_name(self, field: DerivedFieldPlan, prefix: st return "Py_None" return f"{prefix}_native_ops" - def _field_handle_backend_capsule_nodes(self, owner, field: DerivedFieldPlan, prefix: str, owner_name: str) -> tuple: + def _field_handle_backend_capsule_nodes( + self, owner, field: DerivedFieldPlan, prefix: str, owner_name: str + ) -> tuple: """Build the entry-point table this field handle publishes. A derived-type field reaches its entity through the parent's address, @@ -3671,14 +3690,6 @@ def _derived_handle_operation_declarations( # The getter that publishes this field's table is emitted before the # forwarder it names, so the forwarder is declared here. declarations.extend(self._field_handle_with_descriptor_prototypes(field, descriptor_callback)) - declarations.append( - CFunctionPrototype( - descriptor_callback, - "void", - (CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - ) - ) handle = field.native_array_handle if handle is None: continue @@ -3716,7 +3727,6 @@ def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFuncti ) ) for owner, field, operation_name, descriptor_callback in self._derived_handle_targets(plan): - functions.extend(self._field_handle_descriptor_callbacks(field, descriptor_callback)) functions.extend( self._field_handle_backend_nodes( field, @@ -3817,31 +3827,6 @@ def _field_handle_backend_nodes( ), ) - def _field_handle_descriptor_callbacks( - self, - field: DerivedFieldPlan, - descriptor_name: str, - ) -> tuple[CFunction, ...]: - """Decode one current field descriptor without copying its payload.""" - handle = field.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Native handle field {field.owner_path!r} has no descriptor rank") - descriptor = CFunction( - descriptor_name, - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), - *self._native_array_descriptor_record_nodes( - handle.array.rank, - "descriptor", - return_target="*(PyObject **)context", - ), - ), - ) - return (descriptor,) - def _field_handle_operation_function( self, owner, @@ -3862,25 +3847,21 @@ def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation """Dispatch one operation without inferring descriptor ownership.""" prefix = self._field_handle_owner_nodes(owner) owner_args = self._field_handle_owner_arguments(owner) - if operation in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY}: - callback = self._field_handle_descriptor_callback(owner, field) - descriptor_bridge = self._field_handle_bridge_name( - owner, - field, - NativeArrayOperation.DESCRIPTOR, + handle = field.native_array_handle + if handle is not None and operation in _DESCRIPTOR_ANSWERED_OPERATIONS: + if handle.descriptor_inquiries: + return (*prefix, *self._field_handle_inquiry_nodes(owner, field, operation, owner_args)) + return ( + *prefix, + *self._native_array_bridge_inquiry_nodes( + operation, + f"{self._field_handle_bridge_name(owner, field, operation)}({owner_args})", + handle.array.rank, + self._field_handle_bridge_name(owner, field, NativeArrayOperation.SHAPE), + owner_args, + ), ) - return (*prefix, *self._field_handle_descriptor_nodes(descriptor_bridge, owner_args, callback)) bridge = self._field_handle_bridge_name(owner, field, operation) - if operation in { - NativeArrayOperation.ALLOCATED, - NativeArrayOperation.ASSOCIATED, - NativeArrayOperation.CONTIGUOUS, - }: - return (*prefix, CReturn(CodeExpression(f"PyBool_FromLong({bridge}({owner_args}))"))) - if operation is NativeArrayOperation.ELEMENT_LENGTH: - return (*prefix, CReturn(CodeExpression(f"PyLong_FromLongLong((long long){bridge}({owner_args}))"))) - if operation is NativeArrayOperation.SHAPE: - return (*prefix, *self._field_handle_shape_nodes(field, bridge, owner_args)) if operation is NativeArrayOperation.ASSOCIATE: return self._field_handle_associate_body(field, prefix, bridge, owner_args) if operation in {NativeArrayOperation.ALLOCATE, NativeArrayOperation.RESIZE}: @@ -3894,6 +3875,37 @@ def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation ) raise ValueError(f"Unsupported field handle operation for {field.owner_path!r}: {operation!r}") + def _field_handle_inquiry_nodes( + self, + owner, + field: DerivedFieldPlan, + operation: NativeArrayOperation, + owner_args: str, + ) -> tuple: + """Answer one inquiry from the descriptor this field exposes. + + A view over a field's storage must keep the parent object alive, and + the parent is exactly the object this operation was bound to, so it + becomes the view's base. + """ + handle = field.native_array_handle + if handle is None or handle.array.rank is None: + raise ValueError(f"Native handle field {field.owner_path!r} has no descriptor rank") + return self._native_array_projection_call_nodes( + operation, + rank=handle.array.rank, + numpy_type=self._field_native_array_numpy_type(field), + base="self", + entry_point=self._field_handle_with_descriptor_name(self._field_handle_descriptor_callback(owner, field)), + context=owner_args or "NULL", + ) + + def _field_native_array_numpy_type(self, field: DerivedFieldPlan) -> str: + """Return the NumPy element type one field handle's view is built with.""" + if field.string_element: + return "NPY_STRING" + return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_numpy_type + def _field_handle_associate_body( self, field: DerivedFieldPlan, @@ -3942,41 +3954,6 @@ def _field_handle_descriptor_callback(self, owner, field: DerivedFieldPlan) -> s variable, member = owner return self._module_member_handle_descriptor_callback_name(variable, member) - def _field_handle_shape_nodes(self, field: DerivedFieldPlan, bridge: str, owner_args: str) -> tuple: - """Build field handle shape nodes from the supplied completed binding records; emitted nodes only project completed binding actions.""" - handle = field.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Native handle field {field.owner_path!r} has no shape rank") - extents = tuple(f"extent_{axis}" for axis in range(handle.array.rank)) - call_args = ", ".join((*((owner_args,) if owner_args else ()), *(f"&{item}" for item in extents))) - return ( - *(CDeclaration(item, "int64_t", CodeExpression("0")) for item in extents), - CExpressionStatement(CodeExpression(f"{bridge}({call_args})")), - CDeclaration("shape", "PyObject *", CodeExpression(f"PyTuple_New({handle.array.rank})")), - CIf(CodeExpression("shape == NULL"), body=(CReturn(CodeExpression("NULL")),)), - *( - CExpressionStatement( - CodeExpression(f"PyTuple_SET_ITEM(shape, {axis}, PyLong_FromLongLong((long long){extent}))") - ) - for axis, extent in enumerate(extents) - ), - CIf( - CodeExpression("PyErr_Occurred()"), - body=(CExpressionStatement(CodeExpression("Py_DECREF(shape)")), CReturn(CodeExpression("NULL"))), - ), - CReturn(CodeExpression("shape")), - ) - - @staticmethod - def _field_handle_descriptor_nodes(bridge: str, owner_args: str, callback: str) -> tuple: - """Build field handle descriptor nodes from the supplied local lowering values; emitted nodes only project completed binding actions.""" - arguments = ", ".join((*((owner_args,) if owner_args else ()), callback, "&descriptor_record")) - return ( - CDeclaration("descriptor_record", "PyObject *", CodeExpression("NULL")), - CExpressionStatement(CodeExpression(f"{bridge}({arguments})")), - CReturn(CodeExpression("descriptor_record")), - ) - def _field_handle_shape_mutation_nodes(self, field: DerivedFieldPlan, bridge: str, owner_args: str) -> tuple: """Build field handle shape mutation nodes from the supplied completed binding records; emitted nodes only project completed binding actions.""" handle = field.native_array_handle @@ -4141,7 +4118,7 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction *( callback for variable in self._module_native_array_variables(plan) - for callback in self._module_allocatable_descriptor_callbacks(variable) + for callback in self._module_native_array_backend_functions(variable) ), *( self._module_native_array_operation_function(variable, operation) @@ -4248,46 +4225,93 @@ def _module_native_array_operation_body( operation: NativeArrayOperation, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: """Dispatch one module operation without rediscovering semantic policy.""" - if operation in { - NativeArrayOperation.ALLOCATED, - NativeArrayOperation.ASSOCIATED, - NativeArrayOperation.CONTIGUOUS, - NativeArrayOperation.ELEMENT_LENGTH, - }: - return self._module_native_array_query_body(variable, operation) + handle = variable.native_array_handle + if handle is not None and operation in _DESCRIPTOR_ANSWERED_OPERATIONS: + if handle.descriptor_inquiries: + return self._module_native_array_inquiry_body(variable, operation) + return self._native_array_bridge_inquiry_nodes( + operation, + f"{self._module_native_array_bridge_operation_name(variable, operation)}()", + handle.array.rank, + self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.SHAPE), + "", + ) return self._module_native_array_data_operation_body(variable, operation) - def _module_native_array_query_body( + def _native_array_bridge_inquiry_nodes( self, - variable: ModuleVariablePlan, operation: NativeArrayOperation, - ) -> tuple[CReturn, ...]: - """Return one scalar fact queried from the native bridge.""" - call = f"{self._module_native_array_bridge_operation_name(variable, operation)}()" + call: str, + rank: int, + shape_bridge: str, + owner_args: str, + ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: + """Answer one inquiry through a generated Fortran procedure. + + Only a declaration with no legal bind(C) descriptor interface takes this + route; completed policy says which, and it is the compiler's own + inquiry that answers rather than a descriptor nothing may build. + """ if operation in { NativeArrayOperation.ALLOCATED, NativeArrayOperation.ASSOCIATED, NativeArrayOperation.CONTIGUOUS, }: - expression = f"PyBool_FromLong({call})" - elif operation is NativeArrayOperation.ELEMENT_LENGTH: - expression = f"PyLong_FromLongLong((long long){call})" - else: - raise ValueError(f"Module handle query {operation!r} has no scalar lowering") - return (CReturn(CodeExpression(expression)),) + return (CReturn(CodeExpression(f"PyBool_FromLong({call})")),) + if operation is NativeArrayOperation.ELEMENT_LENGTH: + return (CReturn(CodeExpression(f"PyLong_FromLongLong((long long){call})")),) + if operation is not NativeArrayOperation.SHAPE: + raise ValueError(f"Native array inquiry {operation.value!r} has no generated Fortran lowering") + extents = tuple(f"extent_{axis}" for axis in range(rank)) + arguments = ", ".join((*((owner_args,) if owner_args else ()), *(f"&{name}" for name in extents))) + return ( + *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in extents), + CExpressionStatement(CodeExpression(f"{shape_bridge}({arguments})")), + CDeclaration("shape", "PyObject *", CodeExpression(f"PyTuple_New({rank})")), + CIf(CodeExpression("shape == NULL"), body=(CReturn(CodeExpression("NULL")),)), + *( + CExpressionStatement( + CodeExpression(f"PyTuple_SET_ITEM(shape, {axis}, PyLong_FromLongLong((long long){name}))") + ) + for axis, name in enumerate(extents) + ), + CExpressionStatement(CodeExpression("if (PyErr_Occurred()) { Py_DECREF(shape); return NULL; }")), + CReturn(CodeExpression("shape")), + ) + + def _module_native_array_inquiry_body( + self, + variable: ModuleVariablePlan, + operation: NativeArrayOperation, + ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: + """Answer one inquiry from the descriptor this module array exposes.""" + handle = variable.native_array_handle + if handle is None or handle.array.rank is None: + raise ValueError(f"Module handle {variable.owner_path!r} has no descriptor rank") + return self._native_array_projection_call_nodes( + operation, + rank=handle.array.rank, + numpy_type=self._module_native_array_numpy_type(variable), + # Module storage outlives any view of it, so a view retains nothing. + base="NULL", + entry_point=self._module_with_descriptor_name(variable), + context="NULL", + ) + + def _module_native_array_numpy_type(self, variable: ModuleVariablePlan) -> str: + """Return the NumPy element type one module array's view is built with.""" + if variable.datatype_family is DatatypeFamily.STRING: + return "NPY_STRING" + # A wider logical is exposed with the integer dtype its elements + # occupy, which is what the array projection already selected. + return PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name).array_numpy_type def _module_native_array_data_operation_body( self, variable: ModuleVariablePlan, operation: NativeArrayOperation, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Lower shape, extraction, descriptor, and mutation operations.""" - if operation is NativeArrayOperation.SHAPE: - return self._module_native_array_shape_body(variable) - if operation is NativeArrayOperation.TO_NUMPY: - return self._module_native_array_descriptor_body(variable) - if operation is NativeArrayOperation.DESCRIPTOR: - return self._module_native_array_descriptor_body(variable) + """Lower the mutations that must reach the module variable itself.""" if operation is NativeArrayOperation.ASSOCIATE: return ( CDeclaration("source_packed", "PyObject *"), @@ -4344,20 +4368,6 @@ def _module_native_array_shape_body( CReturn(CodeExpression("shape")), ) - def _module_native_array_descriptor_body( - self, - variable: ModuleVariablePlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Return standard descriptor facts for module extraction and handoff.""" - handle = variable.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Module handle {variable.owner_path!r} has no descriptor rank") - if self._uses_module_allocatable_descriptor(variable): - return self._module_allocatable_descriptor_body(variable) - if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: - return self._module_pointer_descriptor_body(variable) - raise ValueError(f"Module handle {variable.owner_path!r} has no planned descriptor handoff") - @staticmethod def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: """Return whether a handle reaches its descriptor through a consumer. @@ -4376,51 +4386,17 @@ def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: } ) - def _module_allocatable_descriptor_body( - self, - variable: ModuleVariablePlan, - ) -> tuple[CDeclaration | CExpressionStatement | CReturn, ...]: - """Request the current standard descriptor and return its decoded facts.""" - callback = self._module_descriptor_callback_name(variable) - return ( - CDeclaration("descriptor_record", "PyObject *", CodeExpression("NULL")), - CExpressionStatement( - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR)}(" - f"{callback}, &descriptor_record)" - ) - ), - CReturn(CodeExpression("descriptor_record")), - ) - - def _module_allocatable_descriptor_callbacks( + def _module_native_array_backend_functions( self, variable: ModuleVariablePlan, ) -> tuple[CFunction, ...]: - """Return the C consumer for descriptor-record operations.""" + """Return the entry point one module array publishes, and its record.""" if not self._uses_module_allocatable_descriptor(variable): return () handle = variable.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Module handle {variable.owner_path!r} has no descriptor rank") - descriptor_callback = CFunction( - self._module_descriptor_callback_name(variable), - "void", - parameters=(CParameter("descriptor", "CFI_cdesc_t *"), CParameter("context", "void *")), - storage="static", - body=( - CExpressionStatement(CodeExpression("*(PyObject **)context = NULL")), - *self._native_array_descriptor_record_nodes( - handle.array.rank, - "descriptor", - return_target="*(PyObject **)context", - ), - ), - ) - return ( - descriptor_callback, - *self._module_native_array_backend_nodes(variable, handle), - ) + return self._module_native_array_backend_nodes(variable, handle) def _module_native_array_backend_nodes( self, @@ -4544,9 +4520,7 @@ def _module_native_array_backend_capsule(self, variable: ModuleVariablePlan) -> """Return the expression publishing this variable's native entry-point table.""" if not self._uses_module_allocatable_descriptor(variable): return "Py_None" - return ( - f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, NULL)" - ) + return f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, NULL)" def _module_with_descriptor_name(self, variable: ModuleVariablePlan) -> str: """Return the forwarder name that drives this variable's descriptor bridge.""" @@ -4561,49 +4535,6 @@ def _module_descriptor_callback_name(self, variable: ModuleVariablePlan) -> str: owner = re.sub(r"\W", "_", variable.owner_path).casefold() return f"prik_module_{owner}_descriptor_callback" - def _module_pointer_descriptor_body( - self, - variable: ModuleVariablePlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Decode a call-local standard pointer descriptor without copying data.""" - handle = variable.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Pointer module handle {variable.owner_path!r} has no descriptor rank") - cfi_type = self._module_native_array_cfi_type(variable) - if cfi_type is None: - raise ValueError(f"Pointer module handle {variable.owner_path!r} has no CFI type") - rank = handle.array.rank - elem_len = self._module_native_array_elem_size(variable) - return ( - CDeclaration("descriptor_storage", f"CFI_CDESC_T({rank})"), - CDeclaration("descriptor", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)&descriptor_storage")), - CDeclaration("status", "int", CodeExpression("CFI_SUCCESS")), - CExpressionStatement( - CodeExpression( - f"status = CFI_establish(descriptor, NULL, CFI_attribute_pointer, " - f"{cfi_type}, {elem_len}, {rank}, NULL)" - ) - ), - CIf( - CodeExpression("status != CFI_SUCCESS"), - body=( - CExpressionStatement( - CodeExpression( - 'PyErr_SetString(PyExc_RuntimeError, "failed to establish pointer descriptor reader")' - ) - ), - CReturn(CodeExpression("NULL")), - ), - ), - CExpressionStatement( - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.DESCRIPTOR)}" - "(descriptor)" - ) - ), - *self._native_array_descriptor_record_nodes(rank, "descriptor"), - ) - def _descriptor_record_return_nodes( self, base_addr: str, @@ -4907,6 +4838,8 @@ def _owned_native_array_operation_body( operation: NativeArrayOperation, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: """Return one operation body over persistent CFI owner storage.""" + if operation in _DESCRIPTOR_ANSWERED_OPERATIONS: + return self._owned_native_array_inquiry_body(result, operation) if operation is NativeArrayOperation.ASSOCIATE: return self._owned_native_array_associate_body(result) if operation in {NativeArrayOperation.ALLOCATE, NativeArrayOperation.RESIZE}: @@ -4915,29 +4848,50 @@ def _owned_native_array_operation_body( release_existing=operation is NativeArrayOperation.RESIZE, ) handler = self._owned_native_array_operation_handler(operation) - materialize_descriptor = operation not in { - NativeArrayOperation.DESCRIPTOR, - NativeArrayOperation.DESTROY, - } return ( *self._owned_native_array_owner_nodes( result, "owner", - materialize_descriptor=materialize_descriptor, + materialize_descriptor=operation is not NativeArrayOperation.DESTROY, ), *handler(result), ) + def _owned_native_array_inquiry_body( + self, + result: ArgumentTransferPlan | ResultPlan, + operation: NativeArrayOperation, + ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: + """Answer one inquiry from the descriptor this handle owns. + + The storage is already here, so the entry point hands it straight to the + consumer. A view over it must keep the capsule that owns the storage + alive, so that capsule becomes the view's base. + """ + handle = result.native_array_handle + if handle is None or handle.array.rank is None: + raise ValueError(f"Owned native array handle {result.owner_path!r} has no descriptor rank") + return ( + *self._owned_native_array_owner_nodes(result, "owner", materialize_descriptor=False), + *self._native_array_projection_call_nodes( + operation, + rank=handle.array.rank, + numpy_type=self._owned_native_array_numpy_type(result), + base="owner_obj", + entry_point="owner_backend->with_descriptor", + context="owner_backend->context", + ), + ) + + def _owned_native_array_numpy_type(self, plan: ArgumentTransferPlan | ResultPlan) -> str: + """Return the NumPy element type one owned handle's view is built with.""" + if plan.datatype_family is DatatypeFamily.STRING: + return "NPY_STRING" + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_numpy_type + def _owned_native_array_operation_handler(self, operation: NativeArrayOperation): """Return one directly named operation lowerer.""" handlers = { - NativeArrayOperation.SHAPE: self._owned_native_array_shape_body, - NativeArrayOperation.TO_NUMPY: self._owned_native_array_to_numpy_body, - NativeArrayOperation.ELEMENT_LENGTH: self._owned_native_array_element_length_body, - NativeArrayOperation.DESCRIPTOR: self._owned_native_array_descriptor_body, - NativeArrayOperation.ALLOCATED: self._owned_native_array_allocated_body, - NativeArrayOperation.ASSOCIATED: self._owned_native_array_associated_body, - NativeArrayOperation.CONTIGUOUS: self._owned_native_array_contiguous_body, NativeArrayOperation.DEALLOCATE: self._owned_native_array_deallocate_body, NativeArrayOperation.NULLIFY: self._owned_native_array_nullify_body, NativeArrayOperation.DESTROY: self._owned_native_array_destroy_body, @@ -8232,7 +8186,7 @@ def _lower_argument_native_array_direct( names, # Only a handle owning its descriptor reaches this path now: one # that publishes native entry points is placed through them. - "_native_array_descriptor_handoff_for_binding_positional", + "_native_array_backend_for_binding_positional", default_binder_definition=binder_definition, ) ) @@ -8263,7 +8217,7 @@ def _native_descriptor_helper_declarations( """Return binding-local Python objects used by one runtime helper call.""" declarations = tuple( CDeclaration(f"{prefix}_{suffix}", "PyObject *", CodeExpression("NULL")) - for suffix in ("runtime", "helper", "shape", "packed", "item") + for suffix in ("runtime", "helper", "packed", "item") ) if include_default_binder: return (*declarations, CDeclaration(f"{prefix}_default_binder", "PyObject *", CodeExpression("NULL"))) @@ -8303,11 +8257,6 @@ def _native_descriptor_helper_call_nodes( ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_runtime)")), CExpressionStatement(CodeExpression(f"if ({prefix}_helper == NULL) return NULL")), - CExpressionStatement(CodeExpression(f"{prefix}_shape = PyTuple_New({handle.array.rank})")), - CExpressionStatement( - CodeExpression(f"if ({prefix}_shape == NULL) {{ Py_DECREF({prefix}_helper); return NULL; }}") - ), - *self._native_descriptor_expected_shape_nodes(plan, context, names), ] binder_argument = "" binder_format = "" @@ -8319,10 +8268,7 @@ def _native_descriptor_helper_call_nodes( CodeExpression(f"{binder} = PyCFunction_NewEx(&{default_binder_definition}, NULL, NULL)") ), CExpressionStatement( - CodeExpression( - f"if ({binder} == NULL) {{ Py_DECREF({prefix}_helper); " - f"Py_DECREF({prefix}_shape); return NULL; }}" - ) + CodeExpression(f"if ({binder} == NULL) {{ Py_DECREF({prefix}_helper); return NULL; }}") ), ) ) @@ -8331,9 +8277,9 @@ def _native_descriptor_helper_call_nodes( nodes.append( CExpressionStatement( CodeExpression( - f'{prefix}_packed = PyObject_CallFunction({prefix}_helper, "Os{dtype_format}iOi{binder_format}", ' + f'{prefix}_packed = PyObject_CallFunction({prefix}_helper, "Os{dtype_format}ii{binder_format}", ' f'{names.object_name}, "{handle.descriptor_kind.value}", {dtype_argument}, ' - f"{handle.array.rank}, {prefix}_shape, {int(handle.optional_absent)}{binder_argument})" + f"{handle.array.rank}, {int(handle.optional_absent)}{binder_argument})" ) ) ) @@ -8342,44 +8288,11 @@ def _native_descriptor_helper_call_nodes( nodes.extend( ( CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_helper)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_shape)")), CExpressionStatement(CodeExpression(f"if ({prefix}_packed == NULL) return NULL")), ) ) return tuple(nodes) - def _native_descriptor_expected_shape_nodes( - self, - plan: ArgumentTransferPlan, - context: _CFunctionContext, - names: _CArgumentNames, - ) -> tuple[CExpressionStatement, ...]: - """Materialize the descriptor's declared shape for runtime validation.""" - handle = plan.native_array_handle - if handle is None: - return () - prefix = names.value_name - nodes = [] - for axis, expression in enumerate(handle.array.shape): - if expression in {":", "::Strided", "Flat"}: - nodes.append(CExpressionStatement(CodeExpression("Py_INCREF(Py_None)"))) - item = "Py_None" - else: - expected = self._array_extent_expression(handle.array, axis, expression, context) - item = f"PyLong_FromLongLong((long long)({expected}))" - nodes.extend( - ( - CExpressionStatement(CodeExpression(f"PyTuple_SET_ITEM({prefix}_shape, {axis}, {item})")), - CExpressionStatement( - CodeExpression( - f"if (PyTuple_GET_ITEM({prefix}_shape, {axis}) == NULL) {{ " - f"Py_DECREF({prefix}_helper); Py_DECREF({prefix}_shape); return NULL; }}" - ) - ), - ) - ) - return tuple(nodes) - def _native_descriptor_presence_unpack_nodes( self, plan: ArgumentTransferPlan, @@ -10199,6 +10112,316 @@ def _strided_native_array_actual_reader_nodes(argument: ArgumentTransferPlan) -> nodes.append(CExpressionStatement(CodeExpression(f"base_bytes *= (CFI_index_t)out->extents[{axis}]"))) return tuple(nodes) + # One live descriptor answers every handle inquiry. + # + # `with_descriptor` is the only way to reach a handle's descriptor, and it + # works the same whether the descriptor is one Fortran built for the call + # or persistent storage this wrapper owns. Every inquiry a handle answers + # is therefore one of these consumers run through that entry point: the + # descriptor is read where it is valid and only the finished Python object + # leaves. Nothing copies a descriptor out, and no operation needs a Fortran + # procedure of its own to report what the descriptor already says. + NATIVE_ARRAY_PROJECTION_RECORD = "prik_native_array_projection" + + def _native_array_projection_record(self) -> CStructDefinition: + """Define what one descriptor inquiry is asked for and what it reports.""" + return CStructDefinition( + self.NATIVE_ARRAY_PROJECTION_RECORD, + ( + CParameter("rank", "int"), + CParameter("numpy_type", "int"), + # The object a view must keep alive, or NULL when the storage + # outlives every view of it, as a module variable's does. + CParameter("base", "PyObject *"), + CParameter("result", "PyObject *"), + ), + ) + + def _native_array_projection_consumer( + self, + name: str, + body: tuple, + *doc: str, + ) -> CFunction: + """Wrap one descriptor reader in the shared consumer signature.""" + record = self.NATIVE_ARRAY_PROJECTION_RECORD + return CFunction( + name, + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + body=( + CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), + CDeclaration("out", f"{record} *", CodeExpression(f"({record} *)context")), + *body, + ), + doc=doc, + ) + + def _native_array_projection_functions(self, plan: ModulePlan) -> tuple: + """Emit the record and the consumers every handle inquiry runs.""" + if not self._reads_native_descriptors: + return () + return ( + self._native_array_projection_record(), + self._native_array_projection_consumer( + self._native_array_projection_name(NativeArrayOperation.ALLOCATED), + ( + CComment("A descriptor with no address describes storage that is not there."), + CExpressionStatement(CodeExpression("out->result = PyBool_FromLong(source->base_addr != NULL)")), + CReturn(), + ), + "Report whether this handle's storage is present.", + ), + self._native_array_projection_consumer( + self._native_array_projection_name(NativeArrayOperation.CONTIGUOUS), + ( + CComment("CFI_is_contiguous needs a descriptor that describes something,"), + CComment("and absent storage is not contiguous storage."), + CIf( + CodeExpression("source->base_addr == NULL"), + body=( + CExpressionStatement(CodeExpression("out->result = PyBool_FromLong(0)")), + CReturn(), + ), + ), + CExpressionStatement( + CodeExpression("out->result = PyBool_FromLong(CFI_is_contiguous(source) != 0)") + ), + CReturn(), + ), + "Report whether this handle's storage is contiguous.", + ), + self._native_array_projection_consumer( + self._native_array_projection_name(NativeArrayOperation.ELEMENT_LENGTH), + ( + CExpressionStatement( + CodeExpression("out->result = PyLong_FromLongLong((long long)source->elem_len)") + ), + CReturn(), + ), + "Report this handle's element width, which a deferred-length", + "character array only knows at run time.", + ), + self._native_array_projection_consumer( + self._native_array_projection_name(NativeArrayOperation.SHAPE), + ( + CDeclaration("shape", "PyObject *", CodeExpression("NULL")), + CDeclaration("extent", "PyObject *", CodeExpression("NULL")), + CDeclaration("axis", "int", CodeExpression("0")), + CComment("Storage that is not there has no shape to report."), + CIf( + CodeExpression("source->base_addr == NULL"), + body=( + CExpressionStatement(CodeExpression("out->result = Py_NewRef(Py_None)")), + CReturn(), + ), + ), + CExpressionStatement(CodeExpression("shape = PyTuple_New(out->rank)")), + CIf(CodeExpression("shape == NULL"), body=(CReturn(),)), + CFor( + "axis = 0", + CodeExpression("axis < out->rank"), + CodeExpression("++axis"), + body=( + CComment("A compiler may report an empty dimension as extent -1."), + CExpressionStatement( + CodeExpression( + "extent = PyLong_FromLongLong((long long)(source->dim[axis].extent == -1 " + "? 0 : source->dim[axis].extent))" + ) + ), + CIf( + CodeExpression("extent == NULL"), + body=( + CExpressionStatement(CodeExpression("Py_DECREF(shape)")), + CReturn(), + ), + ), + CExpressionStatement(CodeExpression("PyTuple_SET_ITEM(shape, axis, extent)")), + ), + ), + CExpressionStatement(CodeExpression("out->result = shape")), + CReturn(), + ), + "Report this handle's current extents as one Python tuple.", + ), + self._native_array_projection_consumer( + self._native_array_projection_name(NativeArrayOperation.DESCRIPTOR), + ( + CDeclaration("facts", "PyObject *", CodeExpression("NULL")), + CDeclaration("value", "PyObject *", CodeExpression("NULL")), + CDeclaration("axis", "int", CodeExpression("0")), + CDeclaration("slot", "int", CodeExpression("0")), + CExpressionStatement(CodeExpression("facts = PyTuple_New(3 + 3 * out->rank)")), + CIf(CodeExpression("facts == NULL"), body=(CReturn(),)), + CExpressionStatement( + CodeExpression("PyTuple_SET_ITEM(facts, 0, PyLong_FromVoidPtr(source->base_addr))") + ), + CExpressionStatement( + CodeExpression("PyTuple_SET_ITEM(facts, 1, PyLong_FromLongLong((long long)source->elem_len))") + ), + CExpressionStatement( + CodeExpression("PyTuple_SET_ITEM(facts, 2, PyLong_FromLong((long)out->rank))") + ), + CFor( + "axis = 0", + CodeExpression("axis < out->rank"), + CodeExpression("++axis"), + body=( + CExpressionStatement(CodeExpression("slot = 3 + 3 * axis")), + CExpressionStatement( + CodeExpression( + "PyTuple_SET_ITEM(facts, slot, " + "PyLong_FromLongLong((long long)source->dim[axis].lower_bound))" + ) + ), + CComment("A compiler may report an empty dimension as extent -1."), + CExpressionStatement( + CodeExpression( + "PyTuple_SET_ITEM(facts, slot + 1, PyLong_FromLongLong((long long)(" + "source->dim[axis].extent == -1 ? 0 : source->dim[axis].extent)))" + ) + ), + CExpressionStatement( + CodeExpression( + "PyTuple_SET_ITEM(facts, slot + 2, " + "PyLong_FromLongLong((long long)source->dim[axis].sm))" + ) + ), + ), + ), + CFor( + "slot = 0", + CodeExpression("slot < 3 + 3 * out->rank"), + CodeExpression("++slot"), + body=( + CExpressionStatement(CodeExpression("value = PyTuple_GET_ITEM(facts, slot)")), + CIf( + CodeExpression("value == NULL"), + body=( + CExpressionStatement(CodeExpression("Py_DECREF(facts)")), + CReturn(), + ), + ), + ), + ), + CExpressionStatement(CodeExpression("out->result = facts")), + CReturn(), + ), + "Report what this pointer is associated with, as the flat facts a", + "pointer with no storage of its own records and later replays.", + ), + self._native_array_projection_consumer( + self._native_array_projection_name(NativeArrayOperation.TO_NUMPY), + ( + CDeclaration("dimensions[PRIK_MAX_ARRAY_RANK]", "npy_intp"), + CDeclaration("strides[PRIK_MAX_ARRAY_RANK]", "npy_intp"), + CDeclaration("view", "PyObject *", CodeExpression("NULL")), + CDeclaration("axis", "int", CodeExpression("0")), + CComment("Storage that is not there exposes no view."), + CIf( + CodeExpression("source->base_addr == NULL"), + body=( + CExpressionStatement(CodeExpression("out->result = Py_NewRef(Py_None)")), + CReturn(), + ), + ), + CFor( + "axis = 0", + CodeExpression("axis < out->rank"), + CodeExpression("++axis"), + body=( + CComment("A compiler may report an empty dimension as extent -1."), + CExpressionStatement( + CodeExpression( + "dimensions[axis] = (npy_intp)(source->dim[axis].extent == -1 " + "? 0 : source->dim[axis].extent)" + ) + ), + CComment("The descriptor's stride multiplier is already the byte stride"), + CComment("NumPy wants, negative strides for a reversed target included."), + CExpressionStatement(CodeExpression("strides[axis] = (npy_intp)source->dim[axis].sm")), + ), + ), + CComment("The element width is only consulted for a flexible dtype, which is"), + CComment("how a character array takes its width from the descriptor."), + CExpressionStatement( + CodeExpression( + "view = PyArray_New(&PyArray_Type, out->rank, dimensions, out->numpy_type, strides, " + "source->base_addr, (int)source->elem_len, NPY_ARRAY_WRITEABLE, NULL)" + ) + ), + CIf(CodeExpression("view == NULL"), body=(CReturn(),)), + CComment("The view borrows this storage, so it retains whatever keeps the"), + CComment("storage valid: the parent object of a field, the capsule owning an"), + CComment("owned descriptor. A module variable outlives every view of it."), + CIf( + CodeExpression("out->base != NULL"), + body=( + CExpressionStatement(CodeExpression("Py_INCREF(out->base)")), + CIf( + CodeExpression("PyArray_SetBaseObject((PyArrayObject *)view, out->base) < 0"), + body=( + CExpressionStatement(CodeExpression("Py_DECREF(out->base)")), + CExpressionStatement(CodeExpression("Py_DECREF(view)")), + CReturn(), + ), + ), + ), + ), + CExpressionStatement(CodeExpression("out->result = view")), + CReturn(), + ), + "Build the NumPy view over this handle's storage.", + ), + ) + + @staticmethod + def _native_array_projection_name(operation: NativeArrayOperation) -> str: + """Return the shared consumer that answers one descriptor inquiry.""" + return f"prik_native_array_read_{operation.value}" + + def _native_array_projection_call_nodes( + self, + operation: NativeArrayOperation, + *, + rank: int, + numpy_type: str, + base: str, + entry_point: str, + context: str, + ) -> tuple: + """Run one inquiry through a handle's descriptor entry point. + + The consumer reports absence itself, so the caller does not ask first; + a NULL result with no exception set means the descriptor was never + reached, which only a broken entry point can cause. + """ + record = self.NATIVE_ARRAY_PROJECTION_RECORD + consumer = self._native_array_projection_name( + NativeArrayOperation.ALLOCATED if operation is NativeArrayOperation.ASSOCIATED else operation + ) + return ( + CDeclaration( + "projection", + record, + CodeExpression(f"{{{rank}, {numpy_type}, {base}, NULL}}"), + ), + CExpressionStatement(CodeExpression(f"{entry_point}({context}, {consumer}, &projection)")), + CIf( + CodeExpression("projection.result == NULL && !PyErr_Occurred()"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_SetString(PyExc_RuntimeError, "native array handle did not report a descriptor")' + ) + ), + ), + ), + CReturn(CodeExpression("projection.result")), + ) + def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: """Emit the record and reader for each array dummy a handle may reach. diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 798f96396..0d9e2d6fc 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -114,6 +114,20 @@ # address. The binding defines it; the bridge declares and calls it. _MODULE_ARRAY_CAPTURE_NAME = "prik_capture_address" +# The binding answers these from the live descriptor the handle's entry point +# supplies, so the bridge emits no procedure of its own for them. +_DESCRIPTOR_ANSWERED_OPERATIONS = frozenset( + { + NativeArrayOperation.ALLOCATED, + NativeArrayOperation.ASSOCIATED, + NativeArrayOperation.CONTIGUOUS, + NativeArrayOperation.DESCRIPTOR, + NativeArrayOperation.ELEMENT_LENGTH, + NativeArrayOperation.SHAPE, + NativeArrayOperation.TO_NUMPY, + } +) + _MODULE_GETTER_SUMMARIES = { ModuleGetterAction.CONSTANT_VALUE: "The value is a compile-time constant materialized by the binding.", ModuleGetterAction.NATIVE_CONSTANT_VALUE: "Returns the compiler-evaluated constant by value.", @@ -6896,12 +6910,19 @@ def _native_handle_field_procedures(self, owner, field: DerivedFieldPlan) -> tup handle = field.native_array_handle if handle is None: raise ValueError(f"Native handle field {field.owner_path!r} has no operation plan") - procedures = [] - for operation in handle.operations: - if operation is NativeArrayOperation.TO_NUMPY: - continue - procedures.append(self._native_handle_field_procedure(owner, field, operation)) - return tuple(procedures) + # The descriptor entry point is what the binding runs every inquiry + # through, so it is emitted for the handle itself; the rest are the + # mutations that must reach the field. + planned = [NativeArrayOperation.DESCRIPTOR] + planned.extend( + operation + for operation in handle.operations + # The descriptor entry point is already planned, and a NumPy view is + # a Python object, which only the binding can build. + if operation not in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY} + and (not handle.descriptor_inquiries or operation not in _DESCRIPTOR_ANSWERED_OPERATIONS) + ) + return tuple(self._native_handle_field_procedure(owner, field, operation) for operation in planned) def _native_handle_field_procedure( self, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index cd7845bd9..607b52b1f 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -3069,11 +3069,10 @@ def _native_array_default_handle_operation_diagnostics( roles = handle.default_handle.operation_roles required = { NativeArrayOperation.SHAPE, - NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.DESTROY, } if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: - required.add(NativeArrayOperation.ASSOCIATE) + required.update({NativeArrayOperation.ASSOCIATE, NativeArrayOperation.DESCRIPTOR}) diagnostics = [] complete = len(set(operations)) == len(operations) and required.issubset(operations) if not complete: @@ -3431,10 +3430,16 @@ def _native_array_operation_diagnostics( def _required_native_array_operations( handle: NativeArrayHandlePlan, ) -> set[NativeArrayOperation]: - """Return common operations required by the completed descriptor kind.""" - required = {NativeArrayOperation.SHAPE, NativeArrayOperation.DESCRIPTOR} - if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER: - required.add(NativeArrayOperation.ASSOCIATE) + """Return common operations required by the completed descriptor kind. + + Every handle reports its shape. A pointer additionally reports the + descriptor it is associated with and accepts a new association, because + a pointer that has no storage of its own has nowhere else to record + what another pointer was pointed at. + """ + required = {NativeArrayOperation.SHAPE} + if handle.descriptor_kind is NativeArrayDescriptorKind.POINTER and handle.descriptor_inquiries: + required.update({NativeArrayOperation.ASSOCIATE, NativeArrayOperation.DESCRIPTOR}) return required def _array_action_diagnostics( diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 2584a46a2..7907a44cd 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -24,7 +24,6 @@ ModuleGetterAction, ModuleObjectAccessMechanism, NativeArrayDefaultConstruction, - NativeArrayDescriptorInterop, NativeArrayOperation, NativeDescriptorHandoffABI, ) @@ -50,14 +49,21 @@ ) -_FIELD_HANDLE_LOCAL_OPERATIONS = frozenset({NativeArrayOperation.TO_NUMPY}) -_MODULE_HANDLE_LOCAL_OPERATIONS = frozenset({NativeArrayOperation.TO_NUMPY}) -_OWNED_HANDLE_ENTRYPOINT_OPERATIONS = frozenset( +# Answered in the binding from the descriptor the handle's entry point +# supplies, so no Fortran procedure is planned for them. +_DESCRIPTOR_ANSWERED_OPERATIONS = frozenset( { NativeArrayOperation.ALLOCATED, NativeArrayOperation.ASSOCIATED, NativeArrayOperation.CONTIGUOUS, + NativeArrayOperation.DESCRIPTOR, + NativeArrayOperation.ELEMENT_LENGTH, NativeArrayOperation.SHAPE, + NativeArrayOperation.TO_NUMPY, + } +) +_OWNED_HANDLE_ENTRYPOINT_OPERATIONS = frozenset( + { NativeArrayOperation.ASSOCIATE, NativeArrayOperation.DEALLOCATE, NativeArrayOperation.NULLIFY, @@ -715,9 +721,18 @@ def _field_handle_operations(self, owner, field, route, owner_path, owner_parame raise ValueError(f"Native handle field {field.owner_path!r} has no completed rank") owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () operations = [] - for operation in handle.operations: - if operation in _FIELD_HANDLE_LOCAL_OPERATIONS: - continue + # The descriptor entry point is what every inquiry runs through, so it + # is planned for the handle rather than for one of its capabilities. + planned = [NativeArrayOperation.DESCRIPTOR] + planned.extend( + operation + for operation in handle.operations + # The descriptor entry point is already planned, and a NumPy view is + # a Python object, which only the binding can build. + if operation not in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY} + and (not handle.descriptor_inquiries or operation not in _DESCRIPTOR_ANSWERED_OPERATIONS) + ) + for operation in planned: signature = self._field_handle_signature(field, handle, operation, owner_values) operations.append( self._operation( @@ -977,9 +992,18 @@ def _module_native_array_operations(self, variable): if handle is None or handle.array.rank is None: raise ValueError(f"Module handle {variable.owner_path!r} has no completed operation plan") operations = [] - for operation in handle.operations: - if operation in _MODULE_HANDLE_LOCAL_OPERATIONS: - continue + # The descriptor entry point is what every inquiry runs through, so it + # is planned for the handle rather than for one of its capabilities. + planned = [NativeArrayOperation.DESCRIPTOR] + planned.extend( + operation + for operation in handle.operations + # The descriptor entry point is already planned, and a NumPy view is + # a Python object, which only the binding can build. + if operation not in {NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.TO_NUMPY} + and (not handle.descriptor_inquiries or operation not in _DESCRIPTOR_ANSWERED_OPERATIONS) + ) + for operation in planned: signature = self._module_native_array_signature(variable, handle, operation) if signature is None: continue @@ -1010,12 +1034,7 @@ def _module_native_array_signature(self, variable, handle, operation): ) return NativeEntrypointSignaturePlan(extents, self._void_result()) if operation is NativeArrayOperation.DESCRIPTOR: - if self._uses_module_allocatable_descriptor(variable): - return self._module_descriptor_callback_signature(variable, handle) - if handle.descriptor_kind.value != "pointer": - return None - descriptor = self._descriptor_parameter("descriptor", handle, variable.semantic_type_name, intent="out") - return NativeEntrypointSignaturePlan((descriptor,), self._void_result()) + return self._module_descriptor_callback_signature(variable, handle) if operation is NativeArrayOperation.ASSOCIATE: source = self._descriptor_parameter("source", handle, variable.semantic_type_name, intent="in") return NativeEntrypointSignaturePlan((source,), self._void_result()) @@ -1034,24 +1053,6 @@ def _module_descriptor_callback_signature(self, variable, handle): ) return NativeEntrypointSignaturePlan((callback, self._opaque_parameter("context")), self._void_result()) - @staticmethod - def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: - """Report whether a module array reaches its descriptor through a consumer. - - The variable is handed to a consumer rather than filling a record - supplied from C, so the descriptor that crosses is one the compiler - built. Allocatable and pointer variables both do this. - """ - handle = variable.native_array_handle - return bool( - handle is not None - and handle.descriptor_interop - in { - NativeArrayDescriptorInterop.MODULE_ALLOCATABLE_C_DESCRIPTOR, - NativeArrayDescriptorInterop.POINTER_C_DESCRIPTOR, - } - ) - @staticmethod def _nullable_derived_module_proxy(variable: ModuleVariablePlan) -> bool: return bool( diff --git a/prik/planning/models.py b/prik/planning/models.py index c0d8d66b4..5749bdd62 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -580,6 +580,14 @@ class NativeArrayHandlePlan(StageRecord): destroy_behavior: NativeArrayDestroyBehavior extraction_action: NativeArrayExtractionAction descriptor_interop: NativeArrayDescriptorInterop + # A handle answers its inquiries -- shape, state, element width, + # contiguity, the NumPy view -- from the live descriptor its entry point + # supplies, unless its declaration cannot cross a bind(C) descriptor + # interface at all. A deferred-length character pointer is that case: the + # standard does not allow such a dummy in a bind(C) interface, and GNU + # Fortran mistranslates the descriptor rather than rejecting it, so those + # inquiries stay on generated Fortran procedures of their own. + descriptor_inquiries: bool nullable: bool optional_absent: bool storage_mode: StorageMode diff --git a/prik/planning/planner.py b/prik/planning/planner.py index d83617293..391fcc56d 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -2253,6 +2253,7 @@ def _native_array_handle_plan( destroy_behavior=policy.destroy_behavior, extraction_action=policy.extraction_action, descriptor_interop=policy.descriptor_interop, + descriptor_inquiries=policy.descriptor_inquiries, nullable=policy.nullable, optional_absent=policy.optional_absent, storage_mode=policy.storage_mode, diff --git a/prik/policy/construction.py b/prik/policy/construction.py index db30ee2da..5ff3d9c00 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6210,12 +6210,31 @@ def _native_array_handle_wrapper_policy( operations = { _native_array_enum(NativeArrayOperation, item, owner_path, "operation") for item in completed.operations } - # Shape and the descriptor are what a handle is asked for. The storage - # facts an ordinary dummy needs are read from the descriptor in the - # binding, so no operation reports them. - operations.update({NativeArrayOperation.SHAPE, NativeArrayOperation.DESCRIPTOR}) + # Shape is what every handle is asked for. Everything an argument needs + # is read from the live descriptor in the binding, so no operation + # reports it; only a pointer still reports one, because a pointer that has + # no storage of its own has nowhere else to record what it was pointed at. + operations.add(NativeArrayOperation.SHAPE) + if descriptor == "pointer": + operations.add(NativeArrayOperation.DESCRIPTOR) if semantic_type.name == "String": operations.add(NativeArrayOperation.ELEMENT_LENGTH) + # A bind(C) character dummy must have an assumed or constant length, so a + # deferred-length pointer array has no legal descriptor interface at all. + # Its state, shape and width still come from the compiler's own inquiries; + # anything that has to reach the descriptor itself does not exist for it. + descriptor_inquiries = not ( + descriptor == "pointer" and semantic_type.metadata.get("fortran_character_length") == ":" + ) + if not descriptor_inquiries: + operations.difference_update( + { + NativeArrayOperation.DESCRIPTOR, + NativeArrayOperation.ASSOCIATE, + NativeArrayOperation.TO_NUMPY, + } + ) + output_projection = NativeArrayOutputProjection.NONE if semantic_type.metadata.get("fortran_character_length") == ":": operations.difference_update({NativeArrayOperation.ALLOCATE, NativeArrayOperation.RESIZE}) if descriptor == "pointer": @@ -6271,13 +6290,13 @@ def _native_array_handle_wrapper_policy( owner_path, "destroy behavior", ), - extraction_action=_native_array_enum( - NativeArrayExtractionAction, - completed.to_numpy, - owner_path, - "extraction action", + extraction_action=( + _native_array_enum(NativeArrayExtractionAction, completed.to_numpy, owner_path, "extraction action") + if descriptor_inquiries + else NativeArrayExtractionAction.UNSUPPORTED ), descriptor_interop=interop, + descriptor_inquiries=descriptor_inquiries, nullable=completed.nullable, optional_absent=completed.optional_absent, storage_mode=_native_array_enum(StorageMode, completed.storage_mode, owner_path, "storage mode"), @@ -6335,6 +6354,7 @@ def _native_array_default_handle_policy( NativeArrayOperation.SHAPE, NativeArrayOperation.DESCRIPTOR, NativeArrayOperation.CONTIGUOUS, + NativeArrayOperation.ELEMENT_LENGTH, } ) return NativeArrayDefaultHandlePolicy( diff --git a/prik/policy/models.py b/prik/policy/models.py index 950c8f55e..6291c2682 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -1121,6 +1121,14 @@ class NativeArrayHandleWrapperPolicy: destroy_behavior: NativeArrayDestroyBehavior extraction_action: NativeArrayExtractionAction descriptor_interop: NativeArrayDescriptorInterop + # A handle answers its inquiries -- shape, state, element width, + # contiguity, the NumPy view -- from the live descriptor its entry point + # supplies, unless its declaration cannot cross a bind(C) descriptor + # interface at all. A deferred-length character pointer is that case: the + # standard does not allow such a dummy in a bind(C) interface, and GNU + # Fortran mistranslates the descriptor rather than rejecting it, so those + # inquiries stay on generated Fortran procedures of their own. + descriptor_inquiries: bool nullable: bool optional_absent: bool storage_mode: StorageMode diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 418d9e1d4..749b99e6c 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -6,7 +6,6 @@ import operator from collections.abc import Callable, Mapping, Sequence from contextlib import suppress -from dataclasses import dataclass from typing import Any import numpy as np @@ -16,13 +15,19 @@ _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT = ctypes.c_int(1) _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT_ADDRESS = ctypes.addressof(_PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT) -# The base a C-built descriptor starts from when no Fortran bound is available. -# Only a handle that reports its own descriptor can carry a declared lower -# bound; one reduced to a bare address has none to report. -_UNKNOWN_DESCRIPTOR_LOWER_BOUND = 0 -# Returned when an extraction reports descriptor fields rather than a view, so -# the caller falls back to decoding them. -_EXTRACTION_UNAVAILABLE = object() +# What a pointer reports about the target it is associated with, and what +# another pointer is given to reproduce that association: +# +# (base address, element width, rank, then per axis lower bound, extent, +# stride in bytes) +# +# A generated handle reads these out of its live descriptor in C and a +# generated association writes them back into one, so they are exchanged in +# this flat form and never assembled into a descriptor in Python. A contract +# handle that has no native storage yet keeps its association here until it +# is given some. +_DESCRIPTOR_FACT_HEADER = 3 +_DESCRIPTOR_FACTS_PER_AXIS = 3 class _OwnerRetainedNDArray(np.ndarray): @@ -36,59 +41,62 @@ def _retain_numpy_owner(value: np.ndarray, owner: Any) -> np.ndarray: return retained -@dataclass(frozen=True) -class _NativeArrayHandoff: - """Internal opaque native pointer handoff returned by generated handle ops.""" - - address: int - owner: Any = None - - def __post_init__(self) -> None: - if isinstance(self.address, bool) or not isinstance(self.address, int): - raise TypeError("native array handoff address must be an integer") - if self.address <= 0: - raise ValueError("native array handoff address must be a non-null positive pointer value") +def _descriptor_facts(value: Any, rank: int) -> tuple[int, ...]: + """Validate one flat descriptor-fact tuple reported for a handle.""" + expected = _DESCRIPTOR_FACT_HEADER + _DESCRIPTOR_FACTS_PER_AXIS * int(rank) + try: + facts = tuple(operator.index(field) for field in value) + except TypeError: + raise TypeError("native array descriptor facts must be a sequence of integers") from None + if len(facts) != expected: + raise ValueError(f"native array descriptor facts must hold {expected} values; received {len(facts)}") + if facts[0] < 0: + raise ValueError(f"native array descriptor base address must be non-negative; received {facts[0]}") + if facts[2] != int(rank): + raise ValueError(f"native array descriptor rank {facts[2]} does not match declared rank {int(rank)}") + return facts + + +def _descriptor_facts_shape_and_strides(facts: tuple[int, ...]) -> tuple[tuple[int, ...], tuple[int, ...]]: + """Split one fact tuple into its extents and byte strides.""" + axes = range(facts[2]) + offsets = tuple(_DESCRIPTOR_FACT_HEADER + _DESCRIPTOR_FACTS_PER_AXIS * axis for axis in axes) + return ( + tuple(facts[offset + 1] for offset in offsets), + tuple(facts[offset + 2] for offset in offsets), + ) -@dataclass(frozen=True) -class _NativeArrayDescriptorHandoff: - """Internal opaque handoff for one versioned native-handle capsule.""" +def _empty_descriptor_facts(dtype: Any, rank: int) -> tuple[int, ...]: + """Return the facts of storage that is not there. - capsule: Any + Every axis is empty, so the bounds describe nothing and no claim is made + about an array that does not exist. + """ + itemsize = np.dtype(dtype).itemsize + return (0, itemsize, int(rank), *((0, 0, itemsize) * int(rank))) - def __post_init__(self) -> None: - if self.capsule is None: - raise TypeError("native array descriptor handoff capsule is required") +def _numpy_view_from_descriptor_facts(facts: tuple[int, ...], dtype: Any) -> np.ndarray | None: + """Build a NumPy view over the storage one fact tuple describes. -def _numpy_view_from_pointer_c_descriptor( - descriptor: Any, - *, - dtype: Any, - expected_rank: int | None = None, -) -> np.ndarray | None: - """Build a NumPy view from generated TS 29113 pointer descriptor fields.""" - base_addr = _pointer_descriptor_base_addr(descriptor) - if base_addr == 0: + Only a handle with no native storage of its own takes this route; every + generated handle builds its view in C, where the descriptor is. + """ + if facts[0] == 0: return None array_dtype = np.dtype(dtype) - _validate_pointer_descriptor_itemsize(descriptor, array_dtype) - shape, strides = _pointer_descriptor_shape_and_strides(descriptor) - if expected_rank is not None and len(shape) != int(expected_rank): + if facts[1] != array_dtype.itemsize: raise ValueError( - f"pointer descriptor rank {len(shape)} does not match declared handle rank {int(expected_rank)}" + f"native array element width {facts[1]} does not match NumPy dtype itemsize {array_dtype.itemsize}" ) - buffer_offset, buffer_nbytes, view_offset = _descriptor_view_buffer_window( - tuple(shape), - tuple(strides), - array_dtype.itemsize, - ) - buffer_addr = base_addr + buffer_offset - if buffer_addr < 0: - raise ValueError("pointer descriptor view buffer starts before address zero") - buffer_type = ctypes.c_char * buffer_nbytes - buffer = buffer_type.from_address(buffer_addr) - return np.ndarray(tuple(shape), dtype=array_dtype, buffer=buffer, strides=tuple(strides), offset=view_offset) + shape, strides = _descriptor_facts_shape_and_strides(facts) + offset, nbytes, view_offset = _descriptor_view_buffer_window(shape, strides, array_dtype.itemsize) + start = facts[0] + offset + if start < 0: + raise ValueError("native array view buffer starts before address zero") + buffer = (ctypes.c_char * nbytes).from_address(start) + return np.ndarray(shape, dtype=array_dtype, buffer=buffer, strides=strides, offset=view_offset) def _native_array_handle_from_generated_ops( @@ -104,23 +112,15 @@ def _native_array_handle_from_generated_ops( ) -> NativeArrayHandleBase: """Build a runtime handle from generated operation callables. - ``native_ops`` is an optional capsule publishing the entity's native entry - points, so a binding can reach it with one indirect call. It is carried, - not required: a handle without one is placed through its operations. + ``native_ops`` is the capsule publishing the entity's native backend, which + a binding reads directly to reach the descriptor. It is carried, not + required: a handle created from a contract has none until it is given + storage. """ owned = descriptor_ownership == "owned" normalized_ops = {} for name, operation in ops.items(): - if name == "descriptor" and owned: - normalized = _generated_owned_descriptor_operation(operation, owner) - elif name in {"shape", "to_numpy"} and owned: - normalized = _generated_owned_descriptor_record_operation(operation, owner) - elif name == "associate": - normalized = _generated_pointer_associate_operation( - operation, - owner=owner if owned else None, - ) - elif name in {"allocate", "resize"}: + if name in {"allocate", "resize"}: normalized = _generated_shape_operation(operation, owner=owner if owned else None) elif owned: normalized = _generated_owned_handle_operation(operation, owner) @@ -158,43 +158,41 @@ def _native_array_handle_from_contract( dtype: Any, rank: int, ) -> NativeArrayHandleBase: - """Create one owned, initially empty descriptor handle from a contract.""" - descriptor_state = { - "record": _empty_descriptor_record(dtype, rank), - "owner": None, - } + """Create one owned, initially empty descriptor handle from a contract. + + Such a handle has no native storage until a call gives it some, so it + answers from a fact tuple of its own instead of from a descriptor. That is + also where a pointer association taken before it was ever bound is kept, + so the association can be replayed once storage arrives. + """ + state: dict[str, Any] = {"facts": _empty_descriptor_facts(dtype, rank), "source": None} def current_shape(_handle: NativeArrayHandleBase) -> tuple[int, ...] | None: - record = descriptor_state["record"] - if _pointer_descriptor_base_addr(record) == 0: + if state["facts"][0] == 0: return None - shape, _strides = _pointer_descriptor_shape_and_strides(record) + shape, _strides = _descriptor_facts_shape_and_strides(state["facts"]) return shape - def descriptor(_handle: NativeArrayHandleBase) -> Mapping[str, Any]: - return descriptor_state["record"] + def descriptor(_handle: NativeArrayHandleBase) -> tuple[int, ...]: + return state["facts"] def present(_handle: NativeArrayHandleBase) -> bool: - return _pointer_descriptor_base_addr(descriptor_state["record"]) != 0 + return state["facts"][0] != 0 def current_view(_handle: NativeArrayHandleBase) -> np.ndarray | None: - return _numpy_view_from_pointer_c_descriptor( - descriptor_state["record"], - dtype=dtype, - expected_rank=rank, - ) + return _numpy_view_from_descriptor_facts(state["facts"], dtype) def clear(_handle: NativeArrayHandleBase) -> None: - descriptor_state["record"] = _empty_descriptor_record(dtype, rank) - descriptor_state["owner"] = None + state["facts"] = _empty_descriptor_facts(dtype, rank) + state["source"] = None - def associate_record( + def associate_facts( _handle: NativeArrayHandleBase, - record: Mapping[str, Any], - owner: NativeArrayHandleBase, + facts: tuple[int, ...], + source: NativeArrayHandleBase, ) -> None: - descriptor_state["record"] = _copy_pointer_descriptor_record(record) - descriptor_state["owner"] = owner + state["facts"] = facts + state["source"] = source common_ops = { "shape": current_shape, @@ -210,7 +208,7 @@ def associate_record( { "associated": present, "nullify": clear, - "_associate_record": associate_record, + "_associate_facts": associate_facts, }, ), }[descriptor_kind] @@ -227,54 +225,6 @@ def associate_record( return handle -def _empty_descriptor_record(dtype: Any, rank: int) -> dict[str, Any]: - """Return canonical unallocated or unassociated descriptor facts.""" - array_dtype = np.dtype(dtype) - return { - "base_addr": 0, - "elem_len": array_dtype.itemsize, - "rank": int(rank), - "dim": [{"lower_bound": 0, "extent": 0, "sm": array_dtype.itemsize} for _axis in range(int(rank))], - } - - -def _copy_pointer_descriptor_record(descriptor: Mapping[str, Any]) -> dict[str, Any]: - """Copy validated standard descriptor facts for independent association state.""" - dimensions = _pointer_descriptor_dimensions(descriptor) - return { - "base_addr": _required_descriptor_int(descriptor, "base_addr"), - "elem_len": _required_descriptor_int(descriptor, "elem_len"), - "rank": _required_descriptor_int(descriptor, "rank"), - "dim": [ - { - "lower_bound": _required_descriptor_int(dimension, "lower_bound", field_owner=f"dim[{index}]"), - "extent": _required_descriptor_int(dimension, "extent", field_owner=f"dim[{index}]"), - "sm": _required_descriptor_int(dimension, "sm", field_owner=f"dim[{index}]"), - } - for index, dimension in enumerate(dimensions) - ], - } - - -def _pointer_descriptor_record_facts(descriptor: Mapping[str, Any]) -> tuple[int, ...]: - """Flatten standard descriptor facts for one generated association operation.""" - record = _copy_pointer_descriptor_record(descriptor) - fields = [ - record["base_addr"], - record["elem_len"], - record["rank"], - ] - for dimension in record["dim"]: - fields.extend( - ( - dimension["lower_bound"], - dimension["extent"], - dimension["sm"], - ) - ) - return tuple(fields) - - def _bind_contract_native_array_handle( handle: NativeArrayHandleBase, descriptor_kind: str, @@ -294,8 +244,8 @@ def _bind_contract_native_array_handle( to hand over, but it does not define what the handle exposes, so the handle keeps the exposure it was created with. - ``native_ops`` is the entry-point table for the attached storage, which - subsequent calls read directly from C. + ``native_ops`` is the backend over the attached storage, which every later + call reads directly from C. """ if not isinstance(handle, NativeArrayHandleBase) or not handle._contract_default: raise TypeError("generated descriptor storage can attach only to a fresh contract handle") @@ -307,9 +257,9 @@ def _bind_contract_native_array_handle( raise ValueError(f"{descriptor_kind} handle rank {handle.rank} does not match generated rank {int(rank)}") if not handle._dtype_matches(dtype): raise TypeError(f"{descriptor_kind} handle dtype {handle.dtype!r} does not match generated dtype {dtype!r}") - pending_pointer_descriptor = ( - handle._association_descriptor_record() if isinstance(handle, PointerArray) and handle.associated else None - ) + # An association taken before there was anywhere native to record it is + # replayed onto the storage that just arrived. + pending = handle._call_op("descriptor") if isinstance(handle, PointerArray) and handle.associated else None generated = _native_array_handle_from_generated_ops( descriptor_kind, dtype, @@ -331,8 +281,8 @@ def _bind_contract_native_array_handle( handle._native_ops = native_ops handle._contract_default = False generated._closed = True - if pending_pointer_descriptor is not None: - handle._call_op("associate", pending_pointer_descriptor) + if pending is not None: + handle._call_op("associate", pending) def _generated_handle_operation(operation: HandleOperation) -> HandleOperation: @@ -353,53 +303,6 @@ def call(_handle: NativeArrayHandleBase, *args: Any) -> Any: return call -def _generated_owned_descriptor_operation(operation: HandleOperation, owner: Any) -> HandleOperation: - """Adapt an owned standard-descriptor pointer operation to typed handoff.""" - - def call(_handle: NativeArrayHandleBase, *args: Any) -> _NativeArrayDescriptorHandoff: - value = operation(owner, *args) - return _native_array_descriptor_handoff_from_generated_result(value, owner=owner) - - return call - - -def _generated_owned_descriptor_record_operation(operation: HandleOperation, owner: Any) -> HandleOperation: - """Adapt owned descriptor facts and normalize compiler zero-extent sentinels.""" - - def call(_handle: NativeArrayHandleBase, *args: Any) -> Any: - value = operation(owner, *args) - if not isinstance(value, Mapping): - return value - dimensions = value.get("dim") - if not isinstance(dimensions, Sequence): - return value - normalized_dimensions = [] - for dimension in dimensions: - if not isinstance(dimension, Mapping) or dimension.get("extent") != -1: - normalized_dimensions.append(dimension) - continue - normalized_dimensions.append({**dimension, "extent": 0}) - return {**value, "dim": normalized_dimensions} - - return call - - -def _generated_pointer_associate_operation( - operation: HandleOperation, - *, - owner: Any = None, -) -> HandleOperation: - """Adapt pointer association to one generated standard-descriptor operation.""" - - def call(_handle: NativeArrayHandleBase, descriptor: Mapping[str, Any]) -> Any: - facts = _pointer_descriptor_record_facts(descriptor) - if owner is None: - return operation(facts) - return operation(owner, facts) - - return call - - def _generated_shape_operation(operation: HandleOperation, *, owner: Any = None) -> HandleOperation: """Adapt generated shape operations from one runtime shape tuple to scalar extents.""" @@ -410,116 +313,6 @@ def call(_handle: NativeArrayHandleBase, shape: Sequence[int]) -> Any: return call -def _native_array_descriptor_handoff_from_generated_result( - value: Any, - *, - owner: Any = None, -) -> _NativeArrayDescriptorHandoff: - """Normalize a generated native-handle capsule into a typed handoff.""" - if isinstance(value, _NativeArrayDescriptorHandoff): - return value - if owner is None: - raise TypeError("generated native array descriptor handoff requires an owner capsule") - if value is not owner: - raise TypeError("generated native array descriptor operation must return its owner capsule") - return _NativeArrayDescriptorHandoff(owner) - - -def _pointer_descriptor_base_addr(descriptor: Any) -> int: - base_addr = _required_descriptor_int(descriptor, "base_addr") - if base_addr < 0: - raise ValueError(f"pointer descriptor base_addr must be non-negative; received {base_addr}") - return base_addr - - -def _validate_pointer_descriptor_itemsize(descriptor: Any, array_dtype: np.dtype) -> None: - elem_len = _required_descriptor_int(descriptor, "elem_len") - if elem_len != array_dtype.itemsize: - raise ValueError( - f"pointer descriptor elem_len {elem_len} does not match NumPy dtype itemsize {array_dtype.itemsize}" - ) - - -def _pointer_descriptor_shape_and_strides(descriptor: Any) -> tuple[list[int], list[int]]: - dimensions = _pointer_descriptor_dimensions(descriptor) - shape: list[int] = [] - strides: list[int] = [] - for index, dimension in enumerate(dimensions): - extent, stride = _pointer_descriptor_dimension_extent_stride(index, dimension) - shape.append(extent) - strides.append(stride) - return shape, strides - - -def _pointer_descriptor_dimensions(descriptor: Any) -> Sequence[Any]: - rank = _pointer_descriptor_rank(descriptor) - dimensions = _required_descriptor_field(descriptor, "dim") - if not isinstance(dimensions, Sequence) or isinstance(dimensions, (str, bytes)): - raise TypeError("pointer descriptor field 'dim' must be a sequence of dimension records") - if rank != len(dimensions): - raise ValueError(f"pointer descriptor rank {rank} does not match {len(dimensions)} dimension records") - return dimensions - - -def _pointer_descriptor_rank(descriptor: Any) -> int: - rank = _required_descriptor_int(descriptor, "rank") - if rank < 0: - raise ValueError(f"pointer descriptor rank must be non-negative; received {rank}") - return rank - - -def _pointer_descriptor_dimension_extent_stride(index: int, dimension: Any) -> tuple[int, int]: - if not _is_pointer_descriptor_dimension_record(dimension): - raise TypeError(f"pointer descriptor dimension {index} must be a mapping or field-record object") - _required_descriptor_int(dimension, "lower_bound", field_owner=f"dim[{index}]") - extent = _required_descriptor_int(dimension, "extent", field_owner=f"dim[{index}]") - stride = _required_descriptor_int(dimension, "sm", field_owner=f"dim[{index}]") - if extent < 0: - raise ValueError(f"pointer descriptor dim[{index}].extent must be non-negative; received {extent}") - return extent, stride - - -def _required_descriptor_int( - descriptor: Any, - field: str, - *, - field_owner: str = "descriptor", -) -> int: - value = _required_descriptor_field(descriptor, field, field_owner=field_owner) - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"pointer {field_owner} field {field!r} must be an integer") - return value - - -def _required_descriptor_field( - record: Any, - field: str, - *, - field_owner: str = "descriptor", -) -> Any: - if isinstance(record, Mapping): - try: - return record[field] - except KeyError: - pass - else: - missing = object() - value = getattr(record, field, missing) - if value is not missing: - return value - raise TypeError(f"pointer {field_owner} field {field!r} is required") - - -def _is_pointer_descriptor_record(value: Any) -> bool: - fields = ("base_addr", "elem_len", "rank", "dim") - return isinstance(value, Mapping) or all(hasattr(value, field) for field in fields) - - -def _is_pointer_descriptor_dimension_record(value: Any) -> bool: - fields = ("lower_bound", "extent", "sm") - return isinstance(value, Mapping) or all(hasattr(value, field) for field in fields) - - def _descriptor_view_buffer_window( shape: tuple[int, ...], strides: tuple[int, ...], @@ -598,32 +391,10 @@ def dtype(self) -> np.dtype: def _deferred_character_dtype(self) -> np.dtype: """Resolve one deferred character width from generated native state.""" - if "element_length" in self._ops: - length = operator.index(self._call_op("element_length")) - if length < 0: - raise ValueError("native character array element length must be non-negative") - return np.dtype(f"S{length}") - value = self._call_op("to_numpy") - if isinstance(value, np.ndarray) and value.dtype.kind == "S": - return value.dtype - if _is_pointer_descriptor_record(value): - length = _required_descriptor_int(value, "elem_len") - if length < 0: - raise ValueError("native character array element length must be non-negative") - return np.dtype(f"S{length}") - raise TypeError("deferred character handle cannot resolve its runtime element length") - - @property - def _reads_its_own_descriptor(self) -> bool: - """Report whether this handle's inquiries read a descriptor it owns. - - A generated handle over wrapper-owned storage answers from the - descriptor in front of it, absence included. A borrowed one reaches - its entity through the compiler's own inquiries, which say nothing - about whether the entity is there, and a handle built from supplied - operations makes no promise at all. - """ - return self._native_ops is not None and self._descriptor_ownership == "owned" + length = operator.index(self._call_op("element_length")) + if length < 0: + raise ValueError("native character array element length must be non-negative") + return np.dtype(f"S{length}") @property def rank(self) -> int: @@ -631,27 +402,15 @@ def rank(self) -> int: @property def shape(self) -> tuple[int, ...] | None: - if self._reads_its_own_descriptor: - # The generated inquiry reads the descriptor, so it reports absent - # storage as None instead of being asked about it first, and the - # extents it returns are the compiler's own. - if self.closed: - raise ReferenceError(f"{self.descriptor_kind} handle is closed") - extents = self._ops["shape"](self) - if extents is None: - return None - if _is_pointer_descriptor_record(extents): - # A deferred character inquiry reports fields, not extents. - shape, _strides = _pointer_descriptor_shape_and_strides(extents) - return self._normalize_shape(shape) - return extents - if self._to_numpy_absent_state(): - return None + """Return current extents, or ``None`` when the storage is not there. + + The inquiry reads the descriptor itself, so it reports absence rather + than being asked about it first, and the extents it returns are the + ones the compiler recorded. + """ shape = self._call_op("shape") if shape is None: return None - if _is_pointer_descriptor_record(shape): - shape, _strides = _pointer_descriptor_shape_and_strides(shape) normalized = self._normalize_shape(shape) if len(normalized) != self.rank: raise ValueError( @@ -707,161 +466,30 @@ def __del__(self) -> None: with suppress(Exception): self.close() - def _descriptor_for_binding( - self, - *, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - ) -> Any: - """Return the generated native descriptor after validating handle metadata.""" - if expected_rank is not None and self.rank != int(expected_rank): - raise ValueError( - f"{self.descriptor_kind} handle rank {self.rank} does not match expected rank {int(expected_rank)}" - ) - if expected_dtype is not None and not self._dtype_matches(expected_dtype): - raise TypeError( - f"{self.descriptor_kind} handle dtype {self.dtype!r} does not match expected dtype {expected_dtype!r}" - ) - # Reading the shape is also the gate that rejects nonsense extents - # before any descriptor reaches native code, so it is not conditional - # on the dummy constraining a shape. - shape = self.shape - if shape is not None: - self._validate_expected_shape(shape, expected_shape) - descriptor = self._call_op("descriptor") - if isinstance(descriptor, _NativeArrayDescriptorHandoff): - return descriptor - if _is_pointer_descriptor_record(descriptor): - if _pointer_descriptor_base_addr(descriptor) == 0: - return self._absent_descriptor_record() - _validate_pointer_descriptor_itemsize(descriptor, np.dtype(self.dtype)) - descriptor_shape, _ = _pointer_descriptor_shape_and_strides(descriptor) - if len(descriptor_shape) != self.rank: - raise ValueError( - f"{self.descriptor_kind} descriptor rank {len(descriptor_shape)} " - f"does not match declared rank {self.rank}" - ) - return descriptor - address = descriptor.address if isinstance(descriptor, _NativeArrayHandoff) else descriptor - if isinstance(address, ctypes.c_void_p): - address = address.value or 0 - if isinstance(address, bool) or not isinstance(address, int): - raise TypeError( - f"{self.descriptor_kind} handle descriptor operation must return descriptor fields " - f"or an integer data address; received {type(descriptor).__name__}" - ) - return self._contiguous_descriptor_record(address, shape) - - def _descriptor_record_for_binding(self) -> Any: - """Return standard descriptor fields for a fact-packed descriptor call.""" - descriptor = self._call_op("to_numpy") - if not _is_pointer_descriptor_record(descriptor): - raise TypeError( - f"{self.descriptor_kind} handle cannot expose standard descriptor fields for binding handoff" - ) - return descriptor - - def _absent_descriptor_record(self) -> dict[str, Any]: - """Return descriptor fields for storage that is not there. - - Every axis is empty, so the bounds describe nothing and no value is - being asserted about an array that does not exist. - """ - dtype = np.dtype(self.dtype) - return { - "base_addr": 0, - "elem_len": dtype.itemsize, - "rank": self.rank, - "dim": [{"lower_bound": 0, "extent": 0, "sm": dtype.itemsize} for _axis in range(self.rank)], - } - - def _contiguous_descriptor_record(self, address: int, shape: tuple[int, ...] | None) -> dict[str, Any]: - """Build standard descriptor fields for a contiguous native array actual. + def to_numpy(self) -> Any: + """Return a live view of current native storage, or ``None``. - A bare address carries no bounds, so every axis is described from the - zero base a C-built descriptor starts at rather than from a Fortran - bound this cannot know. A handle that can name its bounds -- any that - reports its own descriptor -- must do so through its descriptor - operation, which is where a declared lower bound survives; generated - module handles all take that route, so nothing prik emits relies on the - base chosen here. + The extraction reads the descriptor itself, so it reports absent + storage rather than being asked about it first, and it builds the view + from the declared element type over the storage the descriptor names. """ - dtype = np.dtype(self.dtype) - extents = (0,) * self.rank if shape is None else shape - strides = [] - stride = dtype.itemsize - for extent in extents: - strides.append(stride) - stride *= max(int(extent), 1) - return { - "base_addr": int(address), - "elem_len": dtype.itemsize, - "rank": self.rank, - "dim": [ - {"lower_bound": _UNKNOWN_DESCRIPTOR_LOWER_BOUND, "extent": int(extent), "sm": int(axis_stride)} - for extent, axis_stride in zip(extents, strides, strict=True) - ], - } - - def to_numpy(self) -> Any: - """Return a live view of current native storage, or ``None``.""" policy = self._to_numpy_policy - if self._reads_its_own_descriptor and policy != "unsupported": - extracted = self._own_descriptor_view(policy) - if extracted is not _EXTRACTION_UNAVAILABLE: - return extracted - if self._to_numpy_absent_state(): - return None if policy == "unsupported": + # There is nothing to expose either way when the storage is not + # there, so absence is reported before the refusal. + if not self._present(): + return None raise NotImplementedError( f"{self.descriptor_kind} handle to_numpy extraction is unsupported by completed policy" ) - if policy == "contiguous_view" and "contiguous" in self._ops and not bool(self._call_op("contiguous")): - raise ValueError(f"{self.descriptor_kind} handle to_numpy target must be contiguous") value = self._call_op("to_numpy") - if value is None: - raise TypeError( - f"{self.descriptor_kind} handle to_numpy operation returned None for present descriptor state" - ) - # A generated operation builds the view itself; only an operation that - # reports descriptor fields needs decoding here. - if not isinstance(value, np.ndarray) and _is_pointer_descriptor_record(value): - value = _numpy_view_from_pointer_c_descriptor(value, dtype=self.dtype, expected_rank=self.rank) - if value is None: - raise TypeError( - f"{self.descriptor_kind} handle extraction returned a null descriptor for present descriptor state" - ) - value = _retain_numpy_owner(value, self) - elif isinstance(value, np.ndarray) and value.base is not None and value.base is self._owner: - # A generated operation builds its view over the storage the owner - # record holds, and this handle releases that storage when it is - # finalized, so the view has to keep the handle alive too. An - # operation returning an array of its own owns its memory already - # and is handed back untouched. - value = _retain_numpy_owner(value, self) - self._validate_numpy_result(value) - if policy == "contiguous_view": - self._validate_contiguous_numpy_result(value) - return value - - def _own_descriptor_view(self, policy: str) -> Any: - """Return the view a generated extraction builds over owned storage. - - The extraction reads the descriptor itself, so it reports an absent - state as None rather than needing to be asked first, and what it - returns was built from the declared type, so a second check of the - result adds nothing. An extraction that reports fields instead is not - one of these, and says so by returning the unavailable sentinel. - """ - if self.closed: - raise ReferenceError(f"{self.descriptor_kind} handle is closed") - value = self._ops["to_numpy"](self) if value is None: return None - if not isinstance(value, np.ndarray): - return _EXTRACTION_UNAVAILABLE + self._validate_numpy_result(value) if value.base is not None and value.base is self._owner: + # The view was built over storage this handle releases when it is + # finalized, so the view has to keep the handle alive too, not just + # the record that holds the storage. value = _retain_numpy_owner(value, self) if policy == "contiguous_view": self._validate_contiguous_numpy_result(value) @@ -876,10 +504,6 @@ def _call_op(self, name: str, *args: Any) -> Any: raise NotImplementedError(f"{self.descriptor_kind} handle operation {name!r} is not available") from None return operation(self, *args) - def _to_numpy_absent_state(self) -> bool: - """Return whether descriptor state makes extraction produce ``None``.""" - return False - def _validate_numpy_result(self, value: Any) -> None: if not isinstance(value, np.ndarray): raise TypeError( @@ -901,38 +525,19 @@ def _validate_contiguous_numpy_result(self, value: np.ndarray) -> None: if not (value.flags.c_contiguous or value.flags.f_contiguous): raise ValueError(f"{self.descriptor_kind} handle to_numpy result must be contiguous") + def _present(self) -> bool: + """Report whether this handle currently stands for any storage.""" + return True + def _dtype_matches(self, expected_dtype: Any) -> bool: try: return np.dtype(self.dtype) == np.dtype(expected_dtype) except TypeError: return self.dtype == expected_dtype - def _validate_expected_shape( - self, - shape: tuple[int, ...], - expected_shape: Sequence[int | None] | int | None, - ) -> None: - """Validate a concrete expected shape before native array-actual handoff.""" - if expected_shape is None: - return - expected = self._normalize_expected_shape(expected_shape) - if len(expected) != len(shape): - raise ValueError( - f"{self.descriptor_kind} handle shape rank {len(shape)} does not match expected shape rank " - f"{len(expected)}" - ) - for axis, (actual, wanted) in enumerate(zip(shape, expected, strict=True)): - if wanted is not None and actual != wanted: - raise ValueError( - f"{self.descriptor_kind} handle shape {shape!r} does not match expected shape " - f"{expected!r} at axis {axis}" - ) - def _validate_required_ops(self) -> None: if "shape" not in self._ops: raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation 'shape'") - if "descriptor" not in self._ops: - raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation 'descriptor'") for name in sorted(self._REQUIRED_DESCRIPTOR_OPS): if name not in self._ops: raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation {name!r}") @@ -967,19 +572,6 @@ def _normalize_shape(shape: Sequence[int] | int) -> tuple[int, ...]: raise ValueError(f"native array handle shape dimensions must be non-negative; received {normalized!r}") return normalized - @staticmethod - def _normalize_expected_shape(shape: Sequence[int | None] | int) -> tuple[int | None, ...]: - try: - normalized = (operator.index(shape),) - except TypeError: - normalized = tuple(None if dimension is None else int(dimension) for dimension in shape) - for dimension in normalized: - if dimension is not None and dimension < 0: - raise ValueError( - f"expected native array shape dimensions must be non-negative; received {normalized!r}" - ) - return normalized - class AllocatableArray(NativeArrayHandleBase): """Runtime handle for a native allocatable array descriptor.""" @@ -1012,8 +604,8 @@ def __init__( def allocated(self) -> bool: return bool(self._call_op("allocated")) - def _to_numpy_absent_state(self) -> bool: - return not self.allocated + def _present(self) -> bool: + return self.allocated def deallocate(self) -> Any: return self._call_op("deallocate") @@ -1053,20 +645,21 @@ def __init__( def associated(self) -> bool: return bool(self._call_op("associated")) - def _to_numpy_absent_state(self) -> bool: - return not self.associated + def _present(self) -> bool: + return self.associated - def _association_descriptor_record(self) -> dict[str, Any]: - """Return independent standard descriptor facts for pointer assignment.""" - descriptor = self._descriptor_for_binding( - expected_dtype=self.dtype, - expected_rank=self.rank, - ) - if isinstance(descriptor, _NativeArrayDescriptorHandoff): - descriptor = self._descriptor_record_for_binding() - record = _copy_pointer_descriptor_record(descriptor) - _validate_pointer_descriptor_itemsize(record, self.dtype) - return record + def _association_facts(self) -> tuple[int, ...]: + """Report what this pointer is associated with, as flat facts. + + A pointer assignment copies the association as it stands now; it does + not make the target follow the source afterwards. Reading the facts + here is what makes that snapshot. + """ + facts = _descriptor_facts(self._call_op("descriptor"), self.rank) + itemsize = np.dtype(self.dtype).itemsize + if facts[0] != 0 and facts[1] != itemsize: + raise ValueError(f"pointer handle element width {facts[1]} does not match NumPy dtype itemsize {itemsize}") + return facts def associate(self, other: PointerArray) -> Any: """Make this pointer's association match another pointer handle.""" @@ -1080,10 +673,12 @@ def associate(self, other: PointerArray) -> Any: raise ValueError(f"pointer handle rank {self.rank} does not match source rank {other.rank}") if not self._dtype_matches(other.dtype): raise TypeError(f"pointer handle dtype {self.dtype!r} does not match source dtype {other.dtype!r}") - descriptor = other._association_descriptor_record() + facts = other._association_facts() if self._contract_default: - return self._call_op("_associate_record", descriptor, other) - return self._call_op("associate", descriptor) + # No native storage yet: keep the association, and the handle it + # came from, until storage arrives and it can be replayed. + return self._call_op("_associate_facts", facts, other) + return self._call_op("associate", facts) def nullify(self) -> Any: return self._call_op("nullify") @@ -1098,16 +693,22 @@ def resize(self, shape: Sequence[int] | int) -> Any: return self._call_op("resize", self._normalize_shape(shape)) -def _native_array_descriptor_for_binding( +def _native_array_backend_for_binding( value: Any, *, descriptor_kind: str, expected_dtype: Any = None, expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - optional: bool = False, -) -> Any: - """Return a generated native descriptor for a handle-typed binding argument.""" + optional_absent: bool = False, + bind_default: HandleOperation | None = None, +) -> tuple[Any | None, ...]: + """Return the backend capsule a descriptor argument hands over. + + Only a handle that has no storage yet reaches this: everything else + publishes a backend the binding reads without coming back here. Attaching + storage is what gives such a handle one, so the binder runs first and the + backend it published is what goes back. + """ try: expected_type = { "allocatable": AllocatableArray, @@ -1116,78 +717,49 @@ def _native_array_descriptor_for_binding( except KeyError: raise ValueError(f"unsupported native array descriptor kind: {descriptor_kind!r}") from None if value is None: - if optional: - return None + if optional_absent: + return (None, None) raise TypeError(f"{descriptor_kind} native array handle argument is required; received None") if not isinstance(value, expected_type): raise TypeError(f"expected {descriptor_kind} native array handle argument; received {type(value).__name__}") - return value._descriptor_for_binding( - expected_dtype=expected_dtype, - expected_rank=expected_rank, - expected_shape=expected_shape, - ) - - -def _native_array_descriptor_handoff_for_binding( - value: Any, - *, - descriptor_kind: str, - expected_dtype: Any = None, - expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, - optional_absent: bool = False, - bind_default: HandleOperation | None = None, -) -> tuple[Any | None, ...]: - """Pack a versioned native-handle capsule for a descriptor argument.""" - if isinstance(value, NativeArrayHandleBase) and value._contract_default: + if expected_rank is not None and value.rank != int(expected_rank): + raise ValueError( + f"{descriptor_kind} handle rank {value.rank} does not match expected rank {int(expected_rank)}" + ) + if expected_dtype is not None and not value._dtype_matches(expected_dtype): + raise TypeError( + f"{descriptor_kind} handle dtype {value.dtype!r} does not match expected dtype {expected_dtype!r}" + ) + if value._contract_default: if bind_default is None: raise TypeError( f"writable {descriptor_kind} contract handle requires generated persistent descriptor storage" ) - _native_array_descriptor_for_binding( - value, - descriptor_kind=descriptor_kind, - expected_dtype=expected_dtype, - expected_rank=expected_rank, - expected_shape=expected_shape, - optional=optional_absent, - ) bind_default(value) - descriptor = _native_array_descriptor_for_binding( - value, - descriptor_kind=descriptor_kind, - expected_dtype=expected_dtype, - expected_rank=expected_rank, - expected_shape=expected_shape, - optional=optional_absent, - ) - if descriptor is None: - return (None, None) if optional_absent else (None,) - if not isinstance(descriptor, _NativeArrayDescriptorHandoff): + backend = value._native_ops + if backend is None: raise TypeError( - f"writable {descriptor_kind} descriptor argument requires a generated direct descriptor handoff" + f"writable {descriptor_kind} descriptor argument requires generated persistent descriptor storage" ) if optional_absent: - return descriptor.capsule, _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT_ADDRESS - return (descriptor.capsule,) + return backend, _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT_ADDRESS + return (backend,) -def _native_array_descriptor_handoff_for_binding_positional( +def _native_array_backend_for_binding_positional( value: Any, descriptor_kind: str, expected_dtype: Any = None, expected_rank: int | None = None, - expected_shape: Sequence[int | None] | int | None = None, optional_absent: bool = False, bind_default: HandleOperation | None = None, ) -> tuple[Any | None, ...]: """Positional wrapper used by projected-handle CPython binding code.""" - return _native_array_descriptor_handoff_for_binding( + return _native_array_backend_for_binding( value, descriptor_kind=str(descriptor_kind), expected_dtype=None if expected_dtype is None else np.dtype(expected_dtype), expected_rank=None if expected_rank is None else int(expected_rank), - expected_shape=expected_shape, optional_absent=bool(optional_absent), bind_default=bind_default, ) @@ -1214,7 +786,6 @@ def resize(*extents: np.int64) -> None: 1, { "allocated": lambda: True, - "descriptor": lambda: state["array"].ctypes.data, "shape": lambda: state["array"].shape, "to_numpy": lambda: state["array"], "resize": resize, diff --git a/tests/fortran/_support/native_array_handles.py b/tests/fortran/_support/native_array_handles.py index 0e4430450..2ca3866fa 100644 --- a/tests/fortran/_support/native_array_handles.py +++ b/tests/fortran/_support/native_array_handles.py @@ -3,59 +3,43 @@ import numpy as np -from prik.runtime.handles import ( - _NativeArrayHandoff, -) - - class _ArrayState: def __init__(self, *, shape=None, value=None): self.shape = shape self.value = value -def _handoff(address=1): - return _NativeArrayHandoff(address) - - -def _required_handoff_ops(): - return {"descriptor": lambda _handle: _handoff(102)} - - def _common_ops(state: _ArrayState): + """Return the operations every generated handle supplies. + + A generated handle answers both of these from its live descriptor, so each + reports absence itself rather than being asked about it first. + """ return { - **_required_handoff_ops(), "shape": lambda _handle: state.shape, "to_numpy": lambda _handle: state.value if state.shape is not None else None, } -def _pointer_descriptor_for_array(value: np.ndarray): - return { - "base_addr": int(value.ctypes.data), - "elem_len": int(value.dtype.itemsize), - "rank": value.ndim, - "dim": [ - { - "lower_bound": 1, - "extent": int(extent), - "sm": int(stride), - } - for extent, stride in zip(value.shape, value.strides, strict=True) - ], - } - +def _descriptor_facts_for_array(value: np.ndarray, *, lower_bound: int = 1): + """Return the flat facts a generated pointer reports for one array. -class _DescriptorFieldRecord: - def __init__(self, **fields): - self.__dict__.update(fields) + The layout is the one the generated consumer writes: base address, element + width, rank, then a lower bound, extent and byte stride per axis. + """ + return ( + int(value.ctypes.data), + int(value.dtype.itemsize), + value.ndim, + *( + field + for extent, stride in zip(value.shape, value.strides, strict=True) + for field in (lower_bound, int(extent), int(stride)) + ), + ) -def _pointer_descriptor_record_for_array(value: np.ndarray): - descriptor = _pointer_descriptor_for_array(value) - return _DescriptorFieldRecord( - base_addr=descriptor["base_addr"], - elem_len=descriptor["elem_len"], - rank=descriptor["rank"], - dim=[_DescriptorFieldRecord(**dimension) for dimension in descriptor["dim"]], - ) +def _absent_descriptor_facts(dtype, rank: int): + """Return the flat facts a generated handle reports for absent storage.""" + itemsize = int(np.dtype(dtype).itemsize) + return (0, itemsize, rank, *((0, 0, itemsize) * rank)) diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index 79e6c6bf9..55f1ca364 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -45,9 +45,12 @@ def test_plain_module_allocatable_uses_standard_descriptor_callback_without_copy bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") assert "void (*callback)(CFI_cdesc_t *, void *)" in c_source - assert "prik_module_allocatable_module_handles_plain_allocatable_descriptor_callback" in c_source - assert "descriptor->base_addr" in c_source - assert "Py_BuildValue" in c_source + assert "prik_module_allocatable_module_handles_plain_allocatable_descriptor_callback_with_descriptor" in c_source + # Every inquiry runs one shared consumer over the descriptor the bridge + # supplies; nothing copies the descriptor out to be read in Python. + assert "prik_native_array_read_shape(void * descriptor, void * context)" in c_source + assert "source->base_addr" in c_source + assert "Py_BuildValue" not in c_source assert "subroutine bind_c_plain_allocatable_descriptor(" in bridge_source assert 'bind(c, name="bind_c_plain_allocatable_descriptor")' in bridge_source assert "type(c_funptr), value :: callback_address" in bridge_source @@ -74,9 +77,9 @@ def test_allocated_direct_result_assigns_then_moves_into_owned_descriptor(): assert "deallocate(result)" in procedure assert "call prik_collect_allocatable_array_result(native_make(n), result)" not in procedure assert "result = result_value" not in procedure - assert "function bind_c_owned_result_" in bridge_source - assert "_allocated(" in bridge_source - assert "real(c_double), allocatable, dimension(:), intent(in) :: result" in bridge_source + # Allocation state is read from the owned descriptor in the binding, so no + # Fortran inquiry is emitted for it. + assert "_allocated(" not in bridge_source assert "subroutine bind_c_owned_result_" in bridge_source assert "_deallocate(" in bridge_source assert "real(c_double), allocatable, dimension(:), intent(inout) :: result" in bridge_source diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 18a0b223a..17cd49d7d 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -320,7 +320,7 @@ def test_plain_allocatable_module_array_exposes_current_live_view( module, wrapper_source_text = _plain_allocatable_module(pyi_parity_build_mode, tmp_path) assert "void (*callback)(CFI_cdesc_t *, void *)" in wrapper_source_text - assert "descriptor->base_addr" in wrapper_source_text + assert "source->base_addr" in wrapper_source_text handle = module.values assert isinstance(handle, AllocatableArray) @@ -386,6 +386,12 @@ def test_plain_allocatable_module_array_exposes_current_live_view( value = x(index) end function element_at + function deferred_word_bound_and_width(x) result(packed) + character(len=:), allocatable, intent(in) :: x(:) + integer(int32) :: packed + packed = 100 * lbound(x, 1) + len(x) + end function deferred_word_bound_and_width + subroutine setup() allocate(plain_a(5:8)) plain_a = 1.0d0 @@ -423,31 +429,23 @@ def test_module_allocatable_reports_its_real_lower_bound_with_or_without_target( ) module.setup() - for name in ("plain_a", "tgt_a", "defaulted"): + # The bound is not a reported fact but part of the value: an allocatable + # dummy adopts the bounds of the descriptor it is given, so a wrong one + # makes the callee index the wrong elements. + for name, bound in (("plain_a", 5), ("tgt_a", 5), ("defaulted", 1)): handle = getattr(module, name) - record = handle._descriptor_record_for_binding() - # A defaulted allocation still starts at one, which the reconstruction - # also got wrong by reporting zero. - assert record["dim"][0]["lower_bound"] == (1 if name == "defaulted" else 5), name - assert record["dim"][0]["extent"] == 4, name - assert record["elem_len"] == 8, name + assert module.lower_bound_of(handle) == np.int32(bound), name + assert module.element_at(handle, np.int32(bound)) == handle.to_numpy()[0], name # The Python view is unaffected: NumPy indexing stays zero-based. assert handle.to_numpy().shape == (4,) - # A character allocatable reads the same descriptor, and its element length + # A character allocatable carries the same bounds, and its element length # comes from the array rather than from a width the binding assumed. for name, width in (("fixed_words", 5), ("deferred_words", 6)): - record = getattr(module, name)._descriptor_record_for_binding() - assert record["dim"][0]["lower_bound"] == 5, name - assert record["elem_len"] == width, name - - # The bound is not a reported fact but part of the value: an allocatable - # dummy adopts the bounds of the descriptor it is given, so a wrong one - # makes the callee index the wrong elements. - for name in ("plain_a", "tgt_a"): - handle = getattr(module, name) - assert module.lower_bound_of(handle) == np.int32(5), name - assert module.element_at(handle, np.int32(5)) == handle.to_numpy()[0], name + assert getattr(module, name).dtype == np.dtype(f"S{width}"), name + # A deferred-length actual reaches an allocatable dummy carrying both, so + # the bound and the width are read back out of the array itself. + assert module.deferred_word_bound_and_width(module.deferred_words) == np.int32(506) BORROWED_DESCRIPTOR_SOURCE = """\ diff --git a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py index 5565e80f3..21ee51266 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py @@ -7,7 +7,7 @@ from prik.runtime.handles import ( AllocatableArray, _bind_contract_native_array_handle, - _native_array_descriptor_handoff_for_binding, + _native_array_backend_for_binding, ) @@ -22,7 +22,7 @@ def test_fresh_contract_handle_has_no_descriptor_to_hand_over_on_its_own(): handle = contracts.Allocatable[contracts.Float64[:]]() with pytest.raises(TypeError, match="requires generated persistent descriptor storage"): - _native_array_descriptor_handoff_for_binding( + _native_array_backend_for_binding( handle, descriptor_kind="allocatable", expected_dtype=np.float64, @@ -69,7 +69,6 @@ def test_non_array_allocatable_annotations_are_not_factories(factory, message: s rank=1, ops={ "shape": lambda _handle: None, - "descriptor": lambda _handle: None, "allocated": lambda _handle: False, }, to_numpy_policy="unsupported", @@ -118,6 +117,12 @@ def test_generated_storage_rejects_incompatible_allocatable_contract_handles( def test_writable_contract_handle_adopts_generated_storage_and_closes_once(): + """Binding attaches storage, and the backend over it is what goes to the call. + + An owned handle's backend and its owner are the same capsule: the record + holds the descriptor the binder allocated, and every later call reads it + without coming back through Python. + """ handle = contracts.Allocatable[contracts.Float64[:]]() calls = [] owner = object() @@ -130,16 +135,16 @@ def bind_default(value): 1, { "shape": lambda received_owner: calls.append(("shape", received_owner)) or None, - "descriptor": lambda received_owner: received_owner, "allocated": lambda received_owner: False, "destroy": lambda received_owner: calls.append(("destroy", received_owner)), }, owner, "owned", "unsupported", + native_ops=owner, ) - assert _native_array_descriptor_handoff_for_binding( + assert _native_array_backend_for_binding( handle, descriptor_kind="allocatable", expected_dtype=np.float64, diff --git a/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py b/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py deleted file mode 100644 index 41bf8e917..000000000 --- a/tests/fortran/allocatables/runtime/test_allocatable_descriptor_abi.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Allocatable descriptor handoff through the runtime ABI.""" - -import numpy as np -import pytest -from prik.runtime.handles import AllocatableArray -from tests.fortran._support.native_array_handles import _handoff - - -def test_allocatable_descriptor_hook_accepts_unallocated_descriptor_without_numpy_conversion(): - descriptor = _handoff(234) - calls = [] - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: None, - "allocated": lambda _handle: False, - "to_numpy": lambda _handle: pytest.fail("descriptor handoff must not call to_numpy"), - "descriptor": lambda _handle: calls.append("descriptor") or descriptor, - }, - to_numpy_policy="unsupported", - ) - - assert handle._descriptor_for_binding(expected_dtype="float64", expected_rank=1) == { - "base_addr": descriptor.address, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 0, "sm": 8}], - } - assert calls == ["descriptor"] diff --git a/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py b/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py index 0b12b6abe..51b26d4d4 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py @@ -9,7 +9,6 @@ from tests.fortran._support.native_array_handles import ( _ArrayState, _common_ops, - _required_handoff_ops, ) @@ -47,15 +46,19 @@ def test_allocatable_handle_uses_common_metadata_shape_owner_and_numpy_dispatch( assert handle.allocated is True -def test_allocatable_to_numpy_short_circuits_unallocated_state_before_generated_extraction(): +def test_allocatable_extraction_reports_unallocated_state_as_no_view(): + """Absence is reported by the extraction, not asked about beforehand. + + The generated extraction reads the descriptor, which is where whether the + storage exists is recorded, so nothing has to test allocation first. + """ handle = AllocatableArray( dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, - "to_numpy": lambda _handle: pytest.fail("unallocated handles must not call generated extraction"), - "allocated": lambda _handle: False, + "to_numpy": lambda _handle: None, + "allocated": lambda _handle: pytest.fail("extraction must not need the allocation state"), }, ) @@ -139,7 +142,6 @@ def test_allocatable_handle_requires_generated_allocated_operation(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "to_numpy": lambda _handle: None, }, @@ -151,7 +153,6 @@ def test_allocatable_operations_are_gated_by_the_completed_ops_table(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, "allocated": lambda _handle: False, }, @@ -170,7 +171,6 @@ def test_close_is_a_noop_for_a_borrowed_allocatable_handle(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, "allocated": lambda _handle: False, }, diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index 30ceb2593..fb58d7ce0 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -94,9 +94,7 @@ def test_native_array_backend_release_is_idempotent_and_never_frees_borrowed_sto assert "free(context);" in body # A released owned backend reports itself closed rather than handing over # storage that is gone. - reader = header[ - header.index("static inline prik_native_array_backend *prik_native_array_backend_from_capsule(") : - ] + reader = header[header.index("static inline prik_native_array_backend *prik_native_array_backend_from_capsule(") :] reader = reader[: reader.index("\n}\n")] assert "backend->release != NULL && backend->context == NULL" in reader assert "prik native array handle is closed" in reader diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 4a2d87b3a..f79ffd04a 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -349,10 +349,11 @@ def test_deferred_character_module_handles_use_runtime_element_length(): c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") - assert '"elem_len", (unsigned long long)descriptor->elem_len' in c_source - assert "bind_c_module_names_element_length()" in c_source - assert "function bind_c_module_names_element_length() result(result)" in bridge_source - assert "result = len(native_module_names, kind=c_int64_t)" in bridge_source + assert "out->result = PyLong_FromLongLong((long long)source->elem_len)" in c_source + assert "prik_native_array_read_element_length" in c_source + # The width comes out of the descriptor, so the bridge carries no inquiry + # of its own for it. + assert "bind_c_module_names_element_length" not in bridge_source def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): @@ -365,7 +366,7 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): # Descriptor arguments reach the runtime through one packer. The # fact-reporting one is gone: nothing rebuilds a descriptor in C. assert '"_native_array_descriptor_argument_for_binding_positional"' not in c_source - assert '"_native_array_descriptor_handoff_for_binding_positional"' in c_source + assert '"_native_array_backend_for_binding_positional"' in c_source assert '"_native_array_handle_from_generated_ops"' in c_source assert '"_bind_contract_native_array_handle"' in c_source assert "prik_native_array_backend_capsule_new(" in c_source @@ -373,9 +374,7 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert "PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE" in c_source assert "PRIK_NATIVE_ARRAY_KIND_POINTER" in c_source assert "prik_native_array_backend_release(owner_backend)" in c_source - assert ( - "bound_values_native_backend = prik_native_array_backend_for_descriptor(bound_values_item" - ) in c_source + assert ("bound_values_native_backend = prik_native_array_backend_for_descriptor(bound_values_item") in c_source assert "prik_bind_default_memory_handles_replace_values" in c_source assert "prik_owned_memory_handles_replace_values_destroy" in c_source assert "bound_values_default_binder" in c_source @@ -405,11 +404,13 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert "call prik_collect_allocatable_array_result(native_maybe_make(n), result)" in bridge_source assert "if (allocated(value)) then" in bridge_source assert "call move_alloc(value, result)" in bridge_source - assert "allocated(CFI_cdesc_t * result);" in c_source - assert "_allocated(owner_descriptor));" in c_source + # An owned handle answers its inquiries from the descriptor it holds, so + # only the mutations reach Fortran. + assert "bind_c_owned_result_allocated(" not in c_source + assert "_shape(owner_descriptor" not in c_source assert "_deallocate(owner_descriptor);" in c_source assert "_destroy(owner_descriptor);" in c_source - assert "_shape(owner_descriptor, &extent_0);" in c_source + assert "owner_backend->with_descriptor(owner_backend->context, prik_native_array_read_shape" in c_source assert "character(kind=c_char, len=:), allocatable :: value_value" in bridge_source assert "result_itemsize" in c_source assert "CFI_type_char" in c_source @@ -425,7 +426,7 @@ def test_owned_descriptor_lifecycle_operations_do_not_materialize_descriptor_loc artifacts = WrapperGenerator().generate(_native_handle_plan()) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") - for operation in ("descriptor", "destroy"): + for operation in ("allocated", "shape", "to_numpy", "destroy"): function = _generated_c_function( c_source, f"prik_owned_memory_handles_make_return_{operation}", @@ -433,11 +434,11 @@ def test_owned_descriptor_lifecycle_operations_do_not_materialize_descriptor_loc assert "owner_backend" in function assert "owner_descriptor" not in function - allocated = _generated_c_function( + deallocate = _generated_c_function( c_source, - "prik_owned_memory_handles_make_return_allocated", + "prik_owned_memory_handles_make_return_deallocate", ) - assert "owner_descriptor" in allocated + assert "owner_descriptor" in deallocate @pytest.mark.parametrize( diff --git a/tests/fortran/memory_management/runtime/test_handle_lifecycle.py b/tests/fortran/memory_management/runtime/test_handle_lifecycle.py index a309d9bb8..cefab6edc 100644 --- a/tests/fortran/memory_management/runtime/test_handle_lifecycle.py +++ b/tests/fortran/memory_management/runtime/test_handle_lifecycle.py @@ -1,19 +1,17 @@ """Runtime ownership, factory, close, and finalizer behavior for native handles.""" -import ctypes import gc import numpy as np import pytest from prik.runtime.handles import ( AllocatableArray, PointerArray, - _native_array_descriptor_handoff_for_binding, + _native_array_backend_for_binding, _native_array_handle_from_generated_ops, ) from tests.fortran._support.native_array_handles import ( _ArrayState, _common_ops, - _required_handoff_ops, ) @@ -26,10 +24,6 @@ def shape(): calls.append(("shape", ())) return (3,) - def descriptor(): - calls.append(("descriptor", ())) - return ctypes.c_void_p(1002) - def allocated(): calls.append(("allocated", ())) return True @@ -44,7 +38,6 @@ def to_numpy(): 1, { "shape": shape, - "descriptor": descriptor, "allocated": allocated, "to_numpy": to_numpy, }, @@ -60,14 +53,9 @@ def to_numpy(): assert handle.owner is owner assert handle.generation == 9 assert handle.shape == (3,) + assert handle.allocated is True assert handle.to_numpy() is value - assert handle._descriptor_for_binding(expected_dtype="float64", expected_rank=1) == { - "base_addr": 1002, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 3, "sm": 8}], - } - assert {name for name, _args in calls} == {"allocated", "shape", "to_numpy", "descriptor"} + assert {name for name, _args in calls} == {"allocated", "shape", "to_numpy"} assert all(args == () for _name, args in calls) @@ -79,7 +67,6 @@ def test_generated_handle_factory_splats_shape_operations_to_scalar_extents(): 2, { "shape": lambda: (2, 3), - "descriptor": lambda: 1002, "allocated": lambda: True, "resize": lambda *extents: calls.append(("resize", extents)), }, @@ -109,7 +96,6 @@ def call(received_owner, *args): 1, { "shape": operation("shape", (3,)), - "descriptor": operation("descriptor", owner), "allocated": operation("allocated", True), "to_numpy": operation("to_numpy", value), "resize": operation("resize"), @@ -117,11 +103,13 @@ def call(received_owner, *args): }, owner=owner, descriptor_ownership="owned", + native_ops=owner, ) assert handle.shape == (3,) + assert handle.allocated is True assert handle.to_numpy() is value - assert _native_array_descriptor_handoff_for_binding( + assert _native_array_backend_for_binding( handle, descriptor_kind="allocatable", expected_dtype=np.float64, @@ -134,7 +122,6 @@ def call(received_owner, *args): "allocated", "shape", "to_numpy", - "descriptor", "resize", "destroy", } @@ -143,33 +130,6 @@ def call(received_owner, *args): assert calls.count(("destroy", owner, ())) == 1 -def test_generated_owned_handle_normalizes_compiler_zero_extent_descriptor_records(): - owner = 1234 - descriptor = { - "base_addr": 5678, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 1, "extent": -1, "sm": 8}], - } - handle = _native_array_handle_from_generated_ops( - "allocatable", - "float64", - 1, - { - "shape": lambda _native_owner: descriptor, - "descriptor": lambda _native_owner: 5678, - "to_numpy": lambda _native_owner: descriptor, - "allocated": lambda _native_owner: True, - "destroy": lambda _native_owner: None, - }, - owner=owner, - descriptor_ownership="owned", - ) - - assert handle.shape == (0,) - assert handle.to_numpy().shape == (0,) - - def test_generated_handle_resolves_deferred_character_dtype_from_runtime_element_length(): state = {"itemsize": 3} handle = _native_array_handle_from_generated_ops( @@ -179,7 +139,6 @@ def test_generated_handle_resolves_deferred_character_dtype_from_runtime_element { "shape": lambda: (2,), "element_length": lambda: state["itemsize"], - "descriptor": lambda: 0x1234, "allocated": lambda: True, "to_numpy": lambda: np.array([b"red", b"sky"], dtype=f"S{state['itemsize']}"), }, @@ -197,14 +156,13 @@ def test_generated_owned_handle_factory_releases_owner_once_when_construction_fa def destroy(received_owner): calls.append(("destroy", received_owner)) - with pytest.raises(ValueError, match="requires generated operation 'descriptor'"): + with pytest.raises(ValueError, match="requires generated operation 'allocated'"): _native_array_handle_from_generated_ops( "allocatable", "float64", 1, { "shape": lambda _owner: (1,), - "allocated": lambda _owner: True, "destroy": destroy, }, owner=owner, @@ -216,10 +174,9 @@ def destroy(received_owner): assert calls == [("destroy", owner)] -def test_generated_handle_factory_rejects_invalid_descriptor_kind_and_descriptor_result(): +def test_generated_handle_factory_rejects_an_invalid_descriptor_kind(): ops = { "shape": lambda: (1,), - "descriptor": lambda: object(), "allocated": lambda: True, "to_numpy": lambda: np.zeros(1, dtype=np.float64), } @@ -227,10 +184,6 @@ def test_generated_handle_factory_rejects_invalid_descriptor_kind_and_descriptor with pytest.raises(ValueError, match="generated native array handle kind"): _native_array_handle_from_generated_ops("target", "float64", 1, ops) - handle = _native_array_handle_from_generated_ops("allocatable", "float64", 1, ops) - with pytest.raises(TypeError, match="descriptor operation must return descriptor fields or an integer"): - handle._descriptor_for_binding(expected_dtype="float64", expected_rank=1) - def test_owned_handle_close_calls_destroy_once_and_blocks_later_use(): calls = [] @@ -268,7 +221,6 @@ def destroy(_handle): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, "destroy": destroy, @@ -295,7 +247,6 @@ def test_owned_handle_finalizer_calls_destroy_once(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, "destroy": lambda _handle: calls.append("destroy"), @@ -316,7 +267,6 @@ def test_owned_handle_construction_requires_generated_destroy_operation(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, }, @@ -332,7 +282,6 @@ def test_borrowed_handle_close_and_finalizer_do_not_destroy_native_storage(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "associated": lambda _handle: True, "nullify": lambda _handle: None, diff --git a/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py b/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py index 6d41634f8..8f5b150d0 100644 --- a/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py +++ b/tests/fortran/modules/end_to_end/test_module_array_storage_forms.py @@ -149,8 +149,6 @@ def test_only_an_allocatable_dummy_carries_the_declared_lower_bound(array_forms) the actual's descriptor, so it adopts them -- which is why a module allocatable has to report the bounds it really has. """ - assert array_forms.alloc_shifted._descriptor_record_for_binding()["dim"][0]["lower_bound"] == 5 - assert array_forms.alloc_plain._descriptor_record_for_binding()["dim"][0]["lower_bound"] == 1 assert array_forms.lower_bound_of(array_forms.alloc_shifted) == np.int32(5) assert array_forms.lower_bound_of(array_forms.alloc_plain) == np.int32(1) diff --git a/tests/fortran/pointers/runtime/test_pointer_contract_handles.py b/tests/fortran/pointers/runtime/test_pointer_contract_handles.py index efb8516e3..239fc4e9c 100644 --- a/tests/fortran/pointers/runtime/test_pointer_contract_handles.py +++ b/tests/fortran/pointers/runtime/test_pointer_contract_handles.py @@ -8,23 +8,12 @@ AllocatableArray, PointerArray, _bind_contract_native_array_handle, + _numpy_view_from_descriptor_facts, +) +from tests.fortran._support.native_array_handles import ( + _absent_descriptor_facts, + _descriptor_facts_for_array, ) - - -def _pointer_descriptor(value): - return { - "base_addr": int(value.ctypes.data), - "elem_len": int(value.dtype.itemsize), - "rank": value.ndim, - "dim": [ - { - "lower_bound": 1, - "extent": int(extent), - "sm": int(stride), - } - for extent, stride in zip(value.shape, value.strides, strict=True) - ], - } def test_contract_default_handle_constructors_preserve_dtype_rank_and_empty_state(): @@ -50,25 +39,20 @@ def test_contract_default_handle_constructors_preserve_dtype_rank_and_empty_stat def test_fresh_pointer_associate_copies_association_without_following_source_descriptor(): value = np.arange(6, dtype=np.float64)[::2] - source_state = {"descriptor": _pointer_descriptor(value)} + source_state = {"facts": _descriptor_facts_for_array(value)} def source_nullify(_handle): - source_state["descriptor"] = { - "base_addr": 0, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 0, "sm": 8}], - } + source_state["facts"] = _absent_descriptor_facts("float64", 1) source = PointerArray( dtype="float64", rank=1, ops={ - "shape": lambda _handle: value.shape, - "descriptor": lambda _handle: source_state["descriptor"], - "to_numpy": lambda _handle: source_state["descriptor"], - "associated": lambda _handle: source_state["descriptor"]["base_addr"] != 0, - "associate": lambda _handle, descriptor: source_state.update(descriptor=descriptor), + "shape": lambda _handle: value.shape if source_state["facts"][0] else None, + "descriptor": lambda _handle: source_state["facts"], + "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(source_state["facts"], "float64"), + "associated": lambda _handle: source_state["facts"][0] != 0, + "associate": lambda _handle, facts: source_state.update(facts=facts), "nullify": source_nullify, }, to_numpy_policy="descriptor_view", @@ -92,16 +76,16 @@ def source_nullify(_handle): def test_fresh_pointer_pending_association_is_applied_when_native_storage_attaches(): value = np.arange(4, dtype=np.float64) - descriptor = _pointer_descriptor(value) + facts = _descriptor_facts_for_array(value) source = PointerArray( dtype="float64", rank=1, ops={ "shape": lambda _handle: value.shape, - "descriptor": lambda _handle: descriptor, - "to_numpy": lambda _handle: descriptor, + "descriptor": lambda _handle: facts, + "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(facts, "float64"), "associated": lambda _handle: True, - "associate": lambda _handle, _descriptor: None, + "associate": lambda _handle, _facts: None, "nullify": lambda _handle: None, }, to_numpy_policy="descriptor_view", @@ -123,7 +107,7 @@ def associate(received_owner, facts): 1, { "shape": lambda _owner: value.shape if state["associated"] else None, - "descriptor": lambda received_owner: received_owner, + "descriptor": lambda _owner: facts, "associated": lambda _owner: state["associated"], "associate": associate, "nullify": lambda _owner: state.update(associated=False), diff --git a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py index 6436dc93b..dde6333c1 100644 --- a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py +++ b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py @@ -1,403 +1,172 @@ -"""Pointer descriptor handoff and strided-view ABI behavior.""" +"""Descriptor handoff and view construction through the runtime ABI.""" import numpy as np import pytest + +import prik.contracts as contracts from prik.runtime.handles import ( AllocatableArray, PointerArray, - _NativeArrayDescriptorHandoff, - _native_array_descriptor_for_binding, - _native_array_descriptor_handoff_for_binding, - _native_array_descriptor_handoff_for_binding_positional, - _numpy_view_from_pointer_c_descriptor, -) -from tests.fortran._support.native_array_handles import ( - _ArrayState, - _common_ops, - _handoff, - _pointer_descriptor_for_array, - _pointer_descriptor_record_for_array, - _required_handoff_ops, + _bind_contract_native_array_handle, + _native_array_backend_for_binding, + _native_array_backend_for_binding_positional, + _numpy_view_from_descriptor_facts, ) +from tests.fortran._support.native_array_handles import _descriptor_facts_for_array -def test_descriptor_hook_rejects_generated_none_handoff(): - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, +def _bound_pointer(backend, *, dtype=np.float64, rank=1): + """Return a pointer handle standing for storage a wrapper attached.""" + handle = PointerArray( + dtype=np.dtype(dtype), + rank=rank, ops={ "shape": lambda _handle: None, - "allocated": lambda _handle: False, + "associated": lambda _handle: False, + "nullify": lambda _handle: None, "descriptor": lambda _handle: None, }, to_numpy_policy="unsupported", ) + handle._native_ops = backend + return handle - with pytest.raises( - TypeError, match="descriptor operation must return descriptor fields or an integer data address" - ): - _native_array_descriptor_for_binding(handle, descriptor_kind="allocatable") - - -def test_descriptor_hook_validates_expected_dtype_rank_and_current_shape(): - descriptor = _handoff(235) - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=2, - ops={ - "shape": lambda _handle: (2, 3), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: descriptor, - }, - to_numpy_policy="unsupported", - ) - assert handle._descriptor_for_binding( - expected_dtype=np.float64, - expected_rank=2, - expected_shape=(2, 3), - ) == { - "base_addr": descriptor.address, - "elem_len": 8, - "rank": 2, - "dim": [ - {"lower_bound": 0, "extent": 2, "sm": 8}, - {"lower_bound": 0, "extent": 3, "sm": 16}, - ], - } - with pytest.raises(ValueError, match="expected rank 1"): - handle._descriptor_for_binding(expected_rank=1) - with pytest.raises(TypeError, match="expected dtype"): - handle._descriptor_for_binding(expected_dtype=np.int32) - with pytest.raises(ValueError, match=r"expected shape .* axis 1"): - handle._descriptor_for_binding(expected_shape=(2, 4)) - - -def test_descriptor_binding_helper_accepts_matching_handles_and_optional_none(): - alloc_descriptor = _handoff(236) - pointer_descriptor = _handoff(237) - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: (0,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: alloc_descriptor, - }, - to_numpy_policy="unsupported", - ) - pointer_handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - "descriptor": lambda _handle: pointer_descriptor, - }, - to_numpy_policy="unsupported", - ) +def test_descriptor_argument_hands_over_the_backend_the_handle_publishes(): + """The backend is what crosses; nothing rebuilds a descriptor in Python.""" + backend = object() + handle = _bound_pointer(backend) - assert _native_array_descriptor_for_binding( - alloc_handle, - descriptor_kind="allocatable", - expected_dtype=np.float64, - expected_rank=1, - expected_shape=(0,), - ) == { - "base_addr": alloc_descriptor.address, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 0, "sm": 8}], - } - assert _native_array_descriptor_for_binding( - pointer_handle, + assert _native_array_backend_for_binding( + handle, descriptor_kind="pointer", expected_dtype=np.float64, expected_rank=1, - ) == { - "base_addr": pointer_descriptor.address, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 0, "sm": 8}], - } - assert _native_array_descriptor_for_binding(None, descriptor_kind="pointer", optional=True) is None - - -def test_descriptor_binding_helper_rejects_plain_arrays_none_and_wrong_kind(): - alloc_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: _handoff(238), - }, - to_numpy_policy="unsupported", - ) + ) == (backend,) + assert _native_array_backend_for_binding( + None, + descriptor_kind="pointer", + optional_absent=True, + ) == (None, None) - with pytest.raises(TypeError, match="received ndarray"): - _native_array_descriptor_for_binding(np.ones(1), descriptor_kind="allocatable") - with pytest.raises(TypeError, match="received None"): - _native_array_descriptor_for_binding(None, descriptor_kind="allocatable") - with pytest.raises(TypeError, match="expected pointer native array handle"): - _native_array_descriptor_for_binding(alloc_handle, descriptor_kind="pointer") - with pytest.raises(ValueError, match="unsupported native array descriptor kind"): - _native_array_descriptor_for_binding(alloc_handle, descriptor_kind="coarray") - with pytest.raises(ValueError, match="unsupported native array descriptor kind"): - _native_array_descriptor_for_binding(None, descriptor_kind="coarray", optional=True) - - -def test_projected_descriptor_handoff_requires_persistent_standard_descriptor_storage(): - owner = object() - direct = _NativeArrayDescriptorHandoff(owner) - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: direct, - }, - to_numpy_policy="unsupported", - ) - assert _native_array_descriptor_handoff_for_binding( - handle, - descriptor_kind="allocatable", +def test_an_optional_descriptor_argument_reports_presence_alongside_its_backend(): + backend = object() + supplied, presence = _native_array_backend_for_binding( + _bound_pointer(backend), + descriptor_kind="pointer", expected_dtype=np.float64, expected_rank=1, - expected_shape=(2,), - ) == (owner,) - assert _native_array_descriptor_handoff_for_binding_positional( - handle, - "allocatable", - "float64", - 1, - (2,), - False, - ) == (owner,) - - -def test_owned_standard_descriptor_supplies_the_only_read_only_handoff(): - """A handle hands over the descriptor it owns, and nothing else will do.""" - owner = object() - direct = _NativeArrayDescriptorHandoff(owner) - record = { - "base_addr": 0x5678, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 2, "sm": 8}], - } - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: (2,), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - "descriptor": lambda _handle: direct, - "to_numpy": lambda _handle: record, - "destroy": lambda _handle: None, - }, - descriptor_ownership="owned", - to_numpy_policy="unsupported", + optional_absent=True, ) - assert _native_array_descriptor_handoff_for_binding( + assert supplied is backend + assert isinstance(presence, int) + assert presence > 0 + + +@pytest.mark.parametrize( + ("value", "error", "message"), + [ + (np.zeros(2, dtype=np.float64), TypeError, "expected pointer native array handle"), + (None, TypeError, "handle argument is required"), + ( + AllocatableArray( + dtype=np.dtype(np.float64), + rank=1, + ops={"shape": lambda _handle: None, "allocated": lambda _handle: False}, + to_numpy_policy="unsupported", + ), + TypeError, + "expected pointer native array handle", + ), + ], +) +def test_descriptor_argument_rejects_values_that_are_not_the_declared_handle(value, error, message: str): + with pytest.raises(error, match=message): + _native_array_backend_for_binding(value, descriptor_kind="pointer") + + +def test_descriptor_argument_rejects_a_mismatched_dtype_or_rank(): + handle = _bound_pointer(object()) + + with pytest.raises(ValueError, match="does not match expected rank 2"): + _native_array_backend_for_binding(handle, descriptor_kind="pointer", expected_rank=2) + with pytest.raises(TypeError, match="does not match expected dtype"): + _native_array_backend_for_binding(handle, descriptor_kind="pointer", expected_dtype=np.int32) + + +def test_descriptor_argument_refuses_a_handle_that_has_no_storage_yet(): + """A fresh contract handle publishes no backend until a binder attaches one.""" + handle = contracts.Pointer[contracts.Float64[:]]() + + with pytest.raises(TypeError, match="requires generated persistent descriptor storage"): + _native_array_backend_for_binding(handle, descriptor_kind="pointer") + + +def test_descriptor_argument_binds_a_fresh_contract_handle_then_reads_its_backend(): + handle = contracts.Pointer[contracts.Float64[:]]() + backend = object() + + def bind_default(value): + _bind_contract_native_array_handle( + value, + "pointer", + "float64", + 1, + { + "shape": lambda _owner: None, + "associated": lambda _owner: False, + "nullify": lambda _owner: None, + "descriptor": lambda _owner: None, + "associate": lambda _owner, _facts: None, + "destroy": lambda _owner: None, + }, + backend, + "owned", + "unsupported", + native_ops=backend, + ) + + assert _native_array_backend_for_binding_positional( handle, - descriptor_kind="pointer", - expected_dtype=np.float64, - expected_rank=1, - expected_shape=(2,), - ) == (owner,) - assert _native_array_descriptor_handoff_for_binding_positional( - None, - "allocatable", - "float64", + "pointer", + np.float64, 1, - None, - True, - ) == (None, None) - - # A handle that only reports descriptor fields is refused: rebuilding a - # descriptor from them is what C is not allowed to do for an allocatable. - reports_facts_only = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: (2,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: _handoff(0x5678), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(TypeError, match="requires a generated direct descriptor handoff"): - _native_array_descriptor_handoff_for_binding(reports_facts_only, descriptor_kind="allocatable") - - -def test_pointer_c_descriptor_helper_builds_strided_numpy_view_from_decoded_fields(): - source = np.arange(24, dtype=np.float64).reshape((4, 6), order="F") - expected = source[1:4:2, 2:6:3] - - view = _numpy_view_from_pointer_c_descriptor(_pointer_descriptor_for_array(expected), dtype=np.float64) - - assert view.shape == expected.shape - assert view.strides == expected.strides - np.testing.assert_allclose(view, expected) - assert np.shares_memory(view, source) - - view[0, 1] = 77.0 - assert expected[0, 1] == 77.0 - - -def test_pointer_c_descriptor_helper_builds_negative_stride_numpy_view_from_decoded_fields(): - source = np.arange(10, dtype=np.float64) - expected = source[8:1:-2] - - view = _numpy_view_from_pointer_c_descriptor(_pointer_descriptor_for_array(expected), dtype=np.float64) - - assert view.shape == expected.shape - assert view.strides == expected.strides - np.testing.assert_allclose(view, expected) - assert np.shares_memory(view, source) - - view[0] = 88.0 - assert source[8] == 88.0 - - -def test_pointer_c_descriptor_helper_accepts_field_record_objects(): - source = np.arange(12, dtype=np.float64).reshape((3, 4)) - expected = source[:, 1:4:2] - - view = _numpy_view_from_pointer_c_descriptor(_pointer_descriptor_record_for_array(expected), dtype=np.float64) - - assert view.shape == expected.shape - assert view.strides == expected.strides - np.testing.assert_allclose(view, expected) - assert np.shares_memory(view, source) - - view[1, 0] = 55.0 - assert source[1, 1] == 55.0 - - -def test_pointer_c_descriptor_helper_validates_decoded_descriptor_fields(): - source = np.arange(4, dtype=np.float64) - descriptor = _pointer_descriptor_for_array(source) - - assert _numpy_view_from_pointer_c_descriptor({**descriptor, "base_addr": 0}, dtype=np.float64) is None - for field in ("base_addr", "elem_len", "rank", "dim"): - incomplete = dict(descriptor) - incomplete.pop(field) - with pytest.raises(TypeError, match=f"field {field!r} is required"): - _numpy_view_from_pointer_c_descriptor(incomplete, dtype=np.float64) - with pytest.raises(ValueError, match="base_addr must be non-negative"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "base_addr": -1}, dtype=np.float64) - with pytest.raises(TypeError, match="field 'base_addr' must be an integer"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "base_addr": True}, dtype=np.float64) - with pytest.raises(ValueError, match=r"elem_len .* itemsize"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "elem_len": 4}, dtype=np.float64) - with pytest.raises(ValueError, match="rank must be non-negative"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "rank": -1}, dtype=np.float64) - with pytest.raises(ValueError, match="rank 2 does not match 1 dimension records"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "rank": 2}, dtype=np.float64) - for field in ("lower_bound", "extent", "sm"): - bad_dim_record = dict(descriptor["dim"][0]) - bad_dim_record.pop(field) - with pytest.raises(TypeError, match=f"dim\\[0\\] field {field!r} is required"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "dim": [bad_dim_record]}, dtype=np.float64) - with pytest.raises(TypeError, match="field 'lower_bound' must be an integer"): - bad_dim = [{**descriptor["dim"][0], "lower_bound": True}] - _numpy_view_from_pointer_c_descriptor({**descriptor, "dim": bad_dim}, dtype=np.float64) - with pytest.raises(ValueError, match="dim\\[0\\]\\.extent must be non-negative"): - bad_dim = [{**descriptor["dim"][0], "extent": -1}] - _numpy_view_from_pointer_c_descriptor({**descriptor, "dim": bad_dim}, dtype=np.float64) - with pytest.raises(TypeError, match="field 'sm' must be an integer"): - bad_dim = [{**descriptor["dim"][0], "sm": True}] - _numpy_view_from_pointer_c_descriptor({**descriptor, "dim": bad_dim}, dtype=np.float64) - with pytest.raises(TypeError, match="field 'dim' must be a sequence"): - _numpy_view_from_pointer_c_descriptor({**descriptor, "dim": None}, dtype=np.float64) - - -def test_pointer_descriptor_view_policy_uses_decoded_descriptor_fields_for_strided_view(): - source = np.arange(10, dtype=np.float64) - strided = source[1::2] - state = _ArrayState(shape=strided.shape, value=strided) - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - **_common_ops(state), - "to_numpy": lambda _handle: _pointer_descriptor_for_array(strided), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, - to_numpy_policy="descriptor_view", - ) + False, + bind_default, + ) == (backend,) - view = handle.to_numpy() - assert view is not strided - assert np.shares_memory(view, source) - assert view.strides == strided.strides - np.testing.assert_allclose(view, strided) - view[0] = 42.0 - assert source[1] == 42.0 +def test_view_from_facts_preserves_a_strided_target(): + source = np.arange(8, dtype=np.float64) + strided = source[::2] + view = _numpy_view_from_descriptor_facts(_descriptor_facts_for_array(strided), np.float64) -def test_pointer_descriptor_view_policy_rejects_decoded_descriptor_rank_mismatch(): - source = np.arange(4, dtype=np.float64) - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=2, - ops={ - **_required_handoff_ops(), - "shape": lambda _handle: (2, 2), - "to_numpy": lambda _handle: _pointer_descriptor_for_array(source), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, - to_numpy_policy="descriptor_view", - ) + assert view.shape == (4,) + assert view.strides == (16,) + np.testing.assert_allclose(view, strided) + view[1] = np.float64(99.0) + assert source[2] == np.float64(99.0) - with pytest.raises(ValueError, match="pointer descriptor rank 1 does not match declared handle rank 2"): - handle.to_numpy() +def test_view_from_facts_preserves_a_negative_stride_target(): + """A reversed target keeps its data pointer, strides and accessible span.""" + source = np.arange(6, dtype=np.float64) + reversed_view = source[::-1] -def test_pointer_descriptor_view_policy_maps_null_decoded_descriptor_to_none(): - source = np.arange(4, dtype=np.float64) - descriptor = {**_pointer_descriptor_for_array(source), "base_addr": 0} - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - **_required_handoff_ops(), - "shape": lambda _handle: None, - "to_numpy": lambda _handle: descriptor, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, - to_numpy_policy="descriptor_view", - ) + view = _numpy_view_from_descriptor_facts(_descriptor_facts_for_array(reversed_view), np.float64) - assert handle.to_numpy() is None + assert view.shape == (6,) + assert view.strides == (-8,) + np.testing.assert_allclose(view, source[::-1]) + view[0] = np.float64(42.0) + assert source[5] == np.float64(42.0) -def test_pointer_descriptor_view_policy_rejects_null_descriptor_for_present_state(): - source = np.arange(4, dtype=np.float64) - descriptor = {**_pointer_descriptor_for_array(source), "base_addr": 0} - handle = PointerArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - **_required_handoff_ops(), - "shape": lambda _handle: (4,), - "to_numpy": lambda _handle: descriptor, - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, - to_numpy_policy="descriptor_view", - ) +def test_view_from_facts_reports_absent_storage_and_a_disagreeing_element_width(): + assert _numpy_view_from_descriptor_facts((0, 8, 1, 0, 0, 8), np.float64) is None - with pytest.raises(TypeError, match="null descriptor for present descriptor state"): - handle.to_numpy() + with pytest.raises(ValueError, match="does not match NumPy dtype itemsize"): + _numpy_view_from_descriptor_facts((1024, 4, 1, 1, 2, 4), np.float64) diff --git a/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py b/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py index 3eeadabef..0efdb0f6c 100644 --- a/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py +++ b/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py @@ -6,24 +6,26 @@ AllocatableArray, NativeArrayHandleBase, PointerArray, - _native_array_descriptor_for_binding, _native_array_handle_from_generated_ops, + _numpy_view_from_descriptor_facts, ) from tests.fortran._support.native_array_handles import ( _ArrayState, _common_ops, - _handoff, - _pointer_descriptor_for_array, - _required_handoff_ops, + _descriptor_facts_for_array, ) -def test_pointer_to_numpy_short_circuits_unassociated_state_before_unsupported_policy(): +def test_pointer_to_numpy_reports_unassociated_state_before_an_unsupported_policy(): + """There is nothing to expose either way when the target is not there. + + A policy that blocks extraction still has to answer an unassociated + pointer with None rather than refusing, because no view is being withheld. + """ handle = PointerArray( dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, "associated": lambda _handle: False, "nullify": lambda _handle: None, @@ -34,17 +36,16 @@ def test_pointer_to_numpy_short_circuits_unassociated_state_before_unsupported_p assert handle.to_numpy() is None -def test_shape_short_circuits_absent_descriptor_state_before_generated_shape(): - def fail_shape(_handle): - pytest.fail("absent descriptor state must not call generated shape") +def test_shape_reports_absent_descriptor_state_without_being_asked_first(): + def fail_state(_handle): + pytest.fail("the shape inquiry reads the descriptor, which records absence itself") allocatable = AllocatableArray( dtype="float64", rank=1, ops={ - **_required_handoff_ops(), - "shape": fail_shape, - "allocated": lambda _handle: False, + "shape": lambda _handle: None, + "allocated": fail_state, }, to_numpy_policy="unsupported", ) @@ -52,9 +53,8 @@ def fail_shape(_handle): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), - "shape": fail_shape, - "associated": lambda _handle: False, + "shape": lambda _handle: None, + "associated": fail_state, "nullify": lambda _handle: None, }, to_numpy_policy="unsupported", @@ -71,7 +71,6 @@ def test_to_numpy_contiguous_view_policy_rejects_non_contiguous_storage(): dtype=np.dtype(np.float64), rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: strided.shape, "to_numpy": lambda _handle: strided, "associated": lambda _handle: True, @@ -90,7 +89,6 @@ def test_to_numpy_descriptor_view_policy_never_copies_storage(): dtype=np.dtype(np.float64), rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: source.shape, "to_numpy": lambda _handle: source, "associated": lambda _handle: True, @@ -116,7 +114,6 @@ def test_to_numpy_rejects_generated_non_numpy_results(policy: str): dtype=np.dtype(np.float64), rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (2,), "to_numpy": lambda _handle: [1.0, 2.0], "allocated": lambda _handle: True, @@ -130,28 +127,11 @@ def test_to_numpy_rejects_generated_non_numpy_results(policy: str): handle.to_numpy() -def test_to_numpy_rejects_generated_none_for_present_descriptor_state(): - handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - **_required_handoff_ops(), - "shape": lambda _handle: (2,), - "to_numpy": lambda _handle: None, - "allocated": lambda _handle: True, - }, - ) - - with pytest.raises(TypeError, match="returned None for present descriptor state"): - handle.to_numpy() - - def test_to_numpy_rejects_generated_array_with_wrong_rank_or_dtype(): wrong_rank = AllocatableArray( dtype=np.dtype(np.float64), rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (2,), "to_numpy": lambda _handle: np.zeros((1, 2), dtype=np.float64), "allocated": lambda _handle: True, @@ -164,7 +144,6 @@ def test_to_numpy_rejects_generated_array_with_wrong_rank_or_dtype(): dtype=np.dtype(np.float64), rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (2,), "to_numpy": lambda _handle: np.zeros(2, dtype=np.int32), "allocated": lambda _handle: True, @@ -174,38 +153,23 @@ def test_to_numpy_rejects_generated_array_with_wrong_rank_or_dtype(): wrong_dtype.to_numpy() -def test_runtime_handle_shapes_reject_negative_extents_before_descriptor_handoff(): +def test_runtime_handle_shapes_reject_negative_extents(): handle = AllocatableArray( dtype=np.dtype(np.float64), rank=1, ops={ "shape": lambda _handle: (-1,), "allocated": lambda _handle: True, - "descriptor": lambda _handle: pytest.fail("negative shape must block descriptor handoff"), + "resize": lambda _handle, _shape: None, }, to_numpy_policy="unsupported", ) with pytest.raises(ValueError, match="non-negative"): _ = handle.shape - with pytest.raises(ValueError, match="non-negative"): - _native_array_descriptor_for_binding(handle, descriptor_kind="allocatable") with pytest.raises(ValueError, match="non-negative"): handle.resize(-1) - valid_handle = AllocatableArray( - dtype=np.dtype(np.float64), - rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - "descriptor": lambda _handle: _handoff(229), - }, - to_numpy_policy="unsupported", - ) - with pytest.raises(ValueError, match="non-negative"): - valid_handle._descriptor_for_binding(expected_shape=(-1,)) - def test_pointer_handle_uses_common_base_and_nullify_operation(): state = _ArrayState(shape=(5,), value=np.zeros(5, dtype=np.int32)) @@ -235,36 +199,29 @@ def nullify(_handle): assert handle.to_numpy() is None -def test_pointer_associate_accepts_reassociation_and_an_unassociated_source(): +def test_pointer_associate_copies_the_target_as_it_stands_and_does_not_follow_it(): + """A pointer assignment snapshots the source's target, it does not track it.""" first_value = np.arange(3, dtype=np.float64) second_value = np.arange(4, dtype=np.float64) - destination_state = {"descriptor": _pointer_descriptor_for_array(first_value)} - source_state = {"descriptor": _pointer_descriptor_for_array(second_value)} + absent = (0, 8, 1, 0, 0, 8) def pointer(state): return PointerArray( dtype="float64", rank=1, ops={ - "shape": lambda _handle: tuple(dimension["extent"] for dimension in state["descriptor"]["dim"]), - "descriptor": lambda _handle: state["descriptor"], - "to_numpy": lambda _handle: state["descriptor"], - "associated": lambda _handle: state["descriptor"]["base_addr"] != 0, - "associate": lambda _handle, descriptor: state.update(descriptor=descriptor), - "nullify": lambda _handle: state.update( - descriptor={ - "base_addr": 0, - "elem_len": 8, - "rank": 1, - "dim": [{"lower_bound": 0, "extent": 0, "sm": 8}], - } - ), + "shape": lambda _handle: (state["facts"][4],) if state["facts"][0] else None, + "descriptor": lambda _handle: state["facts"], + "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(state["facts"], "float64"), + "associated": lambda _handle: state["facts"][0] != 0, + "associate": lambda _handle, facts: state.update(facts=facts), + "nullify": lambda _handle: state.update(facts=absent), }, to_numpy_policy="descriptor_view", ) - destination = pointer(destination_state) - source = pointer(source_state) + destination = pointer({"facts": _descriptor_facts_for_array(first_value)}) + source = pointer({"facts": _descriptor_facts_for_array(second_value)}) destination.associate(source) assert destination.associated is True @@ -272,25 +229,24 @@ def pointer(state): np.testing.assert_array_equal(destination.to_numpy(), second_value) source.nullify() + assert destination.associated is True destination.associate(source) assert destination.associated is False -def test_generated_pointer_associate_packs_standard_descriptor_facts(): +def test_generated_pointer_associate_hands_over_flat_descriptor_facts(): value = np.arange(6, dtype=np.float64)[::2] - source_state = {"descriptor": _pointer_descriptor_for_array(value)} source = PointerArray( dtype="float64", rank=1, ops={ "shape": lambda _handle: value.shape, - "descriptor": lambda _handle: source_state["descriptor"], - "to_numpy": lambda _handle: source_state["descriptor"], + "descriptor": lambda _handle: _descriptor_facts_for_array(value), "associated": lambda _handle: True, - "associate": lambda _handle, descriptor: source_state.update(descriptor=descriptor), "nullify": lambda _handle: None, + "associate": lambda _handle, _facts: None, }, - to_numpy_policy="descriptor_view", + to_numpy_policy="unsupported", ) received = [] destination = _native_array_handle_from_generated_ops( @@ -299,7 +255,7 @@ def test_generated_pointer_associate_packs_standard_descriptor_facts(): 1, { "shape": lambda: None, - "descriptor": lambda: 1, + "descriptor": lambda: None, "associated": lambda: False, "associate": lambda facts: received.append(facts), "nullify": lambda: None, @@ -309,16 +265,7 @@ def test_generated_pointer_associate_packs_standard_descriptor_facts(): destination.associate(source) - assert received == [ - ( - int(value.ctypes.data), - 8, - 1, - 1, - 3, - 16, - ) - ] + assert received == [(int(value.ctypes.data), 8, 1, 1, 3, 16)] @pytest.mark.parametrize( @@ -330,7 +277,6 @@ def test_generated_pointer_associate_packs_standard_descriptor_facts(): dtype="int32", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, "associated": lambda _handle: False, "nullify": lambda _handle: None, @@ -345,7 +291,6 @@ def test_generated_pointer_associate_packs_standard_descriptor_facts(): dtype="float64", rank=2, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, "associated": lambda _handle: False, "nullify": lambda _handle: None, @@ -362,7 +307,6 @@ def test_pointer_associate_rejects_incompatible_sources(other, error, message): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: None, "associated": lambda _handle: False, "associate": lambda _handle, _descriptor: None, @@ -441,7 +385,6 @@ def test_pointer_to_numpy_reports_missing_descriptor_extraction(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (2,), "associated": lambda _handle: True, "nullify": lambda _handle: None, @@ -458,7 +401,6 @@ def test_to_numpy_policy_unsupported_reports_completed_policy_block(): dtype=np.dtype(np.float64), rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (2,), "to_numpy": lambda _handle: pytest.fail("unsupported policy must not call generated extraction"), "associated": lambda _handle: True, @@ -476,7 +418,6 @@ def test_common_shape_dispatch_validates_rank(): dtype="float64", rank=2, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (4,), "to_numpy": lambda _handle: None, "allocated": lambda _handle: True, @@ -512,26 +453,12 @@ def test_common_handle_requires_generated_shape_operation(): AllocatableArray(dtype="float64", rank=1, ops={}) -def test_common_handle_requires_generated_descriptor_operation(): - with pytest.raises(ValueError, match="requires generated operation 'descriptor'"): - AllocatableArray( - dtype="float64", - rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - }, - to_numpy_policy="unsupported", - ) - - def test_extraction_enabled_handle_requires_generated_to_numpy_operation(): with pytest.raises(ValueError, match="requires generated operation 'to_numpy'"): AllocatableArray( dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "allocated": lambda _handle: True, }, @@ -545,7 +472,6 @@ def test_pointer_handle_requires_generated_associated_and_nullify_operations(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "nullify": lambda _handle: None, }, @@ -555,7 +481,6 @@ def test_pointer_handle_requires_generated_associated_and_nullify_operations(): dtype="float64", rank=1, ops={ - **_required_handoff_ops(), "shape": lambda _handle: (1,), "associated": lambda _handle: True, }, From b76447a09ea44a50dda6e591204bdd7890828621 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 06:24:41 +0100 Subject: [PATCH 24/47] codex: Delete the lowering the descriptor route replaced, and cover the matrix Sixteen methods in the C binding and three Fortran procedure builders were left with no callers once every inquiry started reading the descriptor: the descriptor-record builders, and the owned handle's own allocated, associated, contiguous, shape, element-length, descriptor and to_numpy bodies, each of which now runs the same shared consumer as everyone else. The array matrix covers what the one route has to keep working: integer, real(4), complex, interoperable logical, fixed- and deferred-width character, zero-sized, and rank-3 storage, each reporting its own dtype, shape and view and reaching a dummy of its own type; a pointer target that is strided, reversed, or unassociated; a derived-type field whose view outlives the parent name; and reallocation through a dummy landing on the caller's handle. A reversed target is where the direct stride mattered most, so its view is asserted where PointerPolicy makes one available -- shape, sign of the stride, the values, and that a write through it reaches the same storage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 380 +++--------------- prik/codegen/fortran/bridge.py | 85 +--- .../test_native_handle_array_forms.py | 251 ++++++++++++ .../fpointer_handles_f90.pyi | 1 + .../end_to_end/test_pointer_handles.py | 31 ++ 5 files changed, 352 insertions(+), 396 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 1b2168926..2f5bc8a6d 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -4535,27 +4535,6 @@ def _module_descriptor_callback_name(self, variable: ModuleVariablePlan) -> str: owner = re.sub(r"\W", "_", variable.owner_path).casefold() return f"prik_module_{owner}_descriptor_callback" - def _descriptor_record_return_nodes( - self, - base_addr: str, - elem_len: str, - rank: int, - ) -> tuple[CDeclaration | CExpressionStatement | CReturn, ...]: - """Finish one standard descriptor mapping from existing dimensions.""" - return ( - CDeclaration( - "descriptor_record", - "PyObject *", - CodeExpression( - f'Py_BuildValue("{{sK,sK,si,sO}}", "base_addr", ' - f'(unsigned long long)(uintptr_t){base_addr}, "elem_len", ' - f'(unsigned long long)({elem_len}), "rank", {rank}, "dim", dimensions)' - ), - ), - CExpressionStatement(CodeExpression("Py_DECREF(dimensions)")), - CReturn(CodeExpression("descriptor_record")), - ) - def _module_native_array_shape_mutation_body( self, variable: ModuleVariablePlan, @@ -4918,242 +4897,6 @@ def _owned_native_array_associate_body( CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) - def _owned_native_array_to_numpy_body( - self, - result: ResultPlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Build the NumPy view over one owned descriptor's storage. - - The descriptor is already here, so the view is built from it directly - rather than reported as fields for the runtime to decode back. The - capsule owning the descriptor becomes the array's base, so the storage - outlives any view taken of it. - """ - handle = result.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Owned result {result.owner_path!r} has no descriptor rank") - # A pointer describes its target to another pointer through these - # fields, and a view cannot carry the Fortran lower bounds an - # association preserves. A character element width is only known at - # runtime. Both keep reporting fields; an allocatable never associates, - # so its extraction hands back the view itself. - if ( - result.datatype_family is DatatypeFamily.STRING - or handle.descriptor_kind is not NativeArrayDescriptorKind.ALLOCATABLE - ): - return self._native_array_descriptor_record_nodes(handle.array.rank, "owner_descriptor") - rank = handle.array.rank - scalar = PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name) - nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ - CDeclaration(f"view_dimensions[{rank}]", "npy_intp"), - CDeclaration(f"view_strides[{rank}]", "npy_intp"), - CDeclaration("view", "PyObject *", CodeExpression("NULL")), - CComment("An unallocated or disassociated descriptor exposes no storage."), - CIf( - CodeExpression("owner_descriptor->base_addr == NULL"), - body=(CReturn(CodeExpression("Py_NewRef(Py_None)")),), - ), - ] - for axis in range(rank): - nodes.extend( - ( - # A compiler may report an empty dimension as extent -1. - CExpressionStatement( - CodeExpression( - f"view_dimensions[{axis}] = (npy_intp)(owner_descriptor->dim[{axis}].extent == -1 " - f"? 0 : owner_descriptor->dim[{axis}].extent)" - ) - ), - CExpressionStatement( - CodeExpression(f"view_strides[{axis}] = (npy_intp)owner_descriptor->dim[{axis}].sm") - ), - ) - ) - nodes.extend( - ( - CExpressionStatement( - CodeExpression( - f"view = PyArray_New(&PyArray_Type, {rank}, view_dimensions, " - f"{scalar.numpy_type_macro}, view_strides, owner_descriptor->base_addr, 0, " - f"NPY_ARRAY_WRITEABLE, NULL)" - ) - ), - CIf(CodeExpression("view == NULL"), body=(CReturn(CodeExpression("NULL")),)), - CComment("The view borrows the descriptor's storage, so it keeps the capsule"), - CComment("that owns the descriptor alive for as long as the view exists."), - CExpressionStatement(CodeExpression("Py_INCREF(owner_obj)")), - CIf( - CodeExpression("PyArray_SetBaseObject((PyArrayObject *)view, owner_obj) < 0"), - body=( - CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), - CExpressionStatement(CodeExpression("Py_DECREF(view)")), - CReturn(CodeExpression("NULL")), - ), - ), - CReturn(CodeExpression("view")), - ) - ) - return tuple(nodes) - - def _owned_native_array_descriptor_record_body( - self, - result: ResultPlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Expose one owned descriptor record for shape or NumPy extraction.""" - handle = result.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Owned result {result.owner_path!r} has no descriptor rank") - return self._native_array_descriptor_record_nodes(handle.array.rank, "owner_descriptor") - - def _owned_native_array_shape_body( - self, - result: ResultPlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Expose extents using the typed compiler descriptor inquiry.""" - if self._is_owned_deferred_character_result(result): - return self._owned_native_array_descriptor_record_body(result) - handle = result.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Owned result {result.owner_path!r} has no shape rank") - dimensions = tuple(f"extent_{axis}" for axis in range(handle.array.rank)) - return ( - *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in dimensions), - CComment("Storage that is not there has no shape, and the compiler's"), - CComment("inquiry has nothing to answer about, so report it here."), - CIf( - CodeExpression("owner_descriptor->base_addr == NULL"), - body=(CReturn(CodeExpression("Py_NewRef(Py_None)")),), - ), - CExpressionStatement( - CodeExpression( - f"{self._owned_native_array_bridge_operation_name(result, NativeArrayOperation.SHAPE)}" - f"(owner_descriptor, {', '.join(f'&{name}' for name in dimensions)})" - ) - ), - CReturn(CodeExpression(f'Py_BuildValue("({",".join("L" for _ in dimensions)})", {", ".join(dimensions)})')), - ) - - def _owned_native_array_element_length_body(self, _result: ResultPlan) -> tuple[CReturn, ...]: - """Expose the current deferred character element width.""" - return (CReturn(CodeExpression("PyLong_FromSize_t(owner_descriptor->elem_len)")),) - - def _owned_native_array_descriptor_body( - self, - _result: ResultPlan, - ) -> tuple[CExpressionStatement | CReturn, ...]: - """Expose the versioned owner capsule for cross-extension handoff.""" - return ( - CExpressionStatement(CodeExpression("Py_INCREF(owner_obj)")), - CReturn(CodeExpression("owner_obj")), - ) - - def _owned_native_array_allocated_body(self, _result: ResultPlan) -> tuple[CReturn, ...]: - """Report the current allocation state.""" - if self._is_owned_deferred_character_result(_result): - return (CReturn(CodeExpression("PyBool_FromLong(owner_descriptor->base_addr != NULL)")),) - return ( - CReturn( - CodeExpression( - f"PyBool_FromLong({self._owned_native_array_bridge_operation_name(_result, NativeArrayOperation.ALLOCATED)}" - "(owner_descriptor))" - ) - ), - ) - - def _owned_native_array_associated_body(self, result: ResultPlan) -> tuple[CReturn, ...]: - """Report the current pointer association state.""" - return self._owned_native_array_bridge_state_body(result, NativeArrayOperation.ASSOCIATED) - - def _owned_native_array_contiguous_body(self, result: ResultPlan) -> tuple[CReturn, ...]: - """Report whether the current pointer target is contiguous.""" - return self._owned_native_array_bridge_state_body(result, NativeArrayOperation.CONTIGUOUS) - - def _owned_native_array_bridge_state_body( - self, - result: ResultPlan, - operation: NativeArrayOperation, - ) -> tuple[CReturn, ...]: - """Call one typed compiler descriptor inquiry.""" - return ( - CReturn( - CodeExpression( - f"PyBool_FromLong({self._owned_native_array_bridge_operation_name(result, operation)}" - "(owner_descriptor))" - ) - ), - ) - - def _owned_native_array_deallocate_body( - self, - result: ResultPlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Deallocate payload while retaining owner storage.""" - return self._owned_native_array_deallocate_nodes(result, NativeArrayOperation.DEALLOCATE, free_owner=False) - - def _owned_native_array_nullify_body( - self, - result: ResultPlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Clear pointer association while retaining owner storage.""" - return self._owned_native_array_deallocate_nodes(result, NativeArrayOperation.NULLIFY, free_owner=False) - - def _owned_native_array_destroy_body( - self, - _result: ResultPlan, - ) -> tuple[CExpressionStatement, ...]: - """Destroy payload and persistent owner storage.""" - return ( - CExpressionStatement(CodeExpression("prik_native_array_backend_release(owner_backend)")), - CExpressionStatement(CodeExpression("Py_RETURN_NONE")), - ) - - def _owned_native_array_owner_nodes( - self, - plan: ArgumentTransferPlan | ResultPlan, - prefix: str, - *, - trailing_objects: tuple[str, ...] = (), - materialize_descriptor: bool = True, - ) -> tuple[CDeclaration | CExpressionStatement, ...]: - """Decode a versioned descriptor owner capsule from a validated plan.""" - handle = plan.native_array_handle - cfi_type = self._native_array_cfi_type(plan) - return ( - CDeclaration(f"{prefix}_obj", "PyObject *"), - *(CDeclaration(name, "PyObject *") for name in trailing_objects), - CDeclaration(f"{prefix}_backend", "prik_native_array_backend *", CodeExpression("NULL")), - *( - (CDeclaration(f"{prefix}_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")),) - if materialize_descriptor - else () - ), - CExpressionStatement( - CodeExpression( - f'if (!PyArg_ParseTuple(args, "{"O" * (1 + len(trailing_objects))}", ' - f"&{prefix}_obj{', ' if trailing_objects else ''}" - f"{', '.join(f'&{name}' for name in trailing_objects)})) return NULL" - ) - ), - CExpressionStatement( - CodeExpression( - f"{prefix}_backend = prik_native_array_backend_for_descriptor({prefix}_obj, " - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " - f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, " - f"{self._native_array_expected_element_size(plan)})" - ) - ), - CExpressionStatement(CodeExpression(f"if ({prefix}_backend == NULL) return NULL")), - *( - ( - CExpressionStatement( - CodeExpression(f"{prefix}_descriptor = (CFI_cdesc_t *){prefix}_backend->context") - ), - ) - if materialize_descriptor - else () - ), - ) - def _pointer_association_source_nodes( self, plan: ArgumentTransferPlan | ResultPlan | ModuleVariablePlan | DerivedFieldPlan, @@ -5280,65 +5023,76 @@ def _pointer_association_cfi_type( return "CFI_type_char" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling - def _native_array_descriptor_record_nodes( + def _owned_native_array_deallocate_body( self, - rank: int, - descriptor_name: str, - *, - return_target: str | None = None, + result: ResultPlan, ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Decode a standard C descriptor into the runtime's mapping protocol.""" - failure_return = CReturn() if return_target is not None else CReturn(CodeExpression("NULL")) - nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ - CDeclaration("dimensions", "PyObject *", CodeExpression(f"PyList_New({rank})")), - CIf(CodeExpression("dimensions == NULL"), body=(failure_return,)), - ] - for axis in range(rank): - item = f"dimension_{axis}" - nodes.extend( + """Deallocate payload while retaining owner storage.""" + return self._owned_native_array_deallocate_nodes(result, NativeArrayOperation.DEALLOCATE, free_owner=False) + + def _owned_native_array_nullify_body( + self, + result: ResultPlan, + ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: + """Clear pointer association while retaining owner storage.""" + return self._owned_native_array_deallocate_nodes(result, NativeArrayOperation.NULLIFY, free_owner=False) + + def _owned_native_array_destroy_body( + self, + _result: ResultPlan, + ) -> tuple[CExpressionStatement, ...]: + """Destroy payload and persistent owner storage.""" + return ( + CExpressionStatement(CodeExpression("prik_native_array_backend_release(owner_backend)")), + CExpressionStatement(CodeExpression("Py_RETURN_NONE")), + ) + + def _owned_native_array_owner_nodes( + self, + plan: ArgumentTransferPlan | ResultPlan, + prefix: str, + *, + trailing_objects: tuple[str, ...] = (), + materialize_descriptor: bool = True, + ) -> tuple[CDeclaration | CExpressionStatement, ...]: + """Decode a versioned descriptor owner capsule from a validated plan.""" + handle = plan.native_array_handle + cfi_type = self._native_array_cfi_type(plan) + return ( + CDeclaration(f"{prefix}_obj", "PyObject *"), + *(CDeclaration(name, "PyObject *") for name in trailing_objects), + CDeclaration(f"{prefix}_backend", "prik_native_array_backend *", CodeExpression("NULL")), + *( + (CDeclaration(f"{prefix}_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")),) + if materialize_descriptor + else () + ), + CExpressionStatement( + CodeExpression( + f'if (!PyArg_ParseTuple(args, "{"O" * (1 + len(trailing_objects))}", ' + f"&{prefix}_obj{', ' if trailing_objects else ''}" + f"{', '.join(f'&{name}' for name in trailing_objects)})) return NULL" + ) + ), + CExpressionStatement( + CodeExpression( + f"{prefix}_backend = prik_native_array_backend_for_descriptor({prefix}_obj, " + f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, " + f"{self._native_array_expected_element_size(plan)})" + ) + ), + CExpressionStatement(CodeExpression(f"if ({prefix}_backend == NULL) return NULL")), + *( ( - CDeclaration( - item, - "PyObject *", - CodeExpression( - f'Py_BuildValue("{{sL,sL,sL}}", "lower_bound", ' - f'(long long){descriptor_name}->dim[{axis}].lower_bound, "extent", ' - f'(long long){descriptor_name}->dim[{axis}].extent, "sm", ' - f"(long long){descriptor_name}->dim[{axis}].sm)" - ), - ), - CIf( - CodeExpression(f"{item} == NULL"), - body=( - CExpressionStatement(CodeExpression("Py_DECREF(dimensions)")), - failure_return, - ), + CExpressionStatement( + CodeExpression(f"{prefix}_descriptor = (CFI_cdesc_t *){prefix}_backend->context") ), - CExpressionStatement(CodeExpression(f"PyList_SET_ITEM(dimensions, {axis}, {item})")), ) - ) - nodes.extend( - ( - CDeclaration( - "descriptor_record", - "PyObject *", - CodeExpression( - f'Py_BuildValue("{{sK,sK,si,sO}}", "base_addr", ' - f'(unsigned long long)(uintptr_t){descriptor_name}->base_addr, "elem_len", ' - f'(unsigned long long){descriptor_name}->elem_len, "rank", ' - f'(int){descriptor_name}->rank, "dim", dimensions)' - ), - ), - CExpressionStatement(CodeExpression("Py_DECREF(dimensions)")), - ( - CExpressionStatement(CodeExpression(f"{return_target} = descriptor_record")) - if return_target is not None - else CReturn(CodeExpression("descriptor_record")) - ), - *((CReturn(),) if return_target is not None else ()), - ) + if materialize_descriptor + else () + ), ) - return tuple(nodes) def _owned_native_array_deallocate_nodes( self, @@ -8624,12 +8378,6 @@ def _module_native_array_cfi_type(self, plan: ModuleVariablePlan) -> str | None: return "CFI_type_char" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling - def _module_native_array_elem_size(self, plan: ModuleVariablePlan) -> str: - """Return the completed numeric size or runtime character element length.""" - if plan.datatype_family is DatatypeFamily.STRING: - return f"{self._module_native_array_bridge_operation_name(plan, NativeArrayOperation.ELEMENT_LENGTH)}()" - return f"sizeof({PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).c_spelling})" - def _native_array_handle_factory_call( self, *, diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 0d9e2d6fc..3d52e2aa8 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -1916,92 +1916,17 @@ def _owned_native_array_result_operation( result: ArgumentTransferPlan | ResultPlan, operation: NativeArrayOperation, ) -> FortranFunction | None: - """Dispatch one generated operation selected by completed handle policy.""" - if operation in {NativeArrayOperation.ALLOCATED, NativeArrayOperation.ASSOCIATED}: - return self._owned_native_array_result_state_operation(result, operation) - if operation is NativeArrayOperation.CONTIGUOUS: - return self._owned_native_array_result_contiguous_operation(result) - if operation is NativeArrayOperation.SHAPE: - return self._owned_native_array_result_shape_operation(result) + """Dispatch one generated operation selected by completed handle policy. + + An owned handle already holds its descriptor, so every inquiry is read + from it in the binding and only the mutations reach Fortran. + """ if operation is NativeArrayOperation.ASSOCIATE: return self._owned_native_array_result_associate_operation(result) if operation in {NativeArrayOperation.DEALLOCATE, NativeArrayOperation.NULLIFY, NativeArrayOperation.DESTROY}: return self._owned_native_array_result_release_operation(result, operation) return None - def _owned_native_array_result_state_operation( - self, - result: ArgumentTransferPlan | ResultPlan, - operation: NativeArrayOperation, - ) -> FortranFunction: - """Return descriptor presence using its completed compiler inquiry.""" - inquiry = self._owned_native_array_result_presence_inquiry(result) - name = self._owned_native_array_result_operation_name(result, operation) - return FortranFunction( - name=name, - parameters=(self._owned_native_array_result_parameter(result, intent="in"),), - result_name="state", - result_type="logical(c_bool)", - bind_name=name, - body=(FortranAssignment("state", CodeExpression(f"{inquiry}(result)")),), - ) - - def _owned_native_array_result_contiguous_operation( - self, - result: ArgumentTransferPlan | ResultPlan, - ) -> FortranFunction: - """Return target contiguity without querying an absent pointer target.""" - name = self._owned_native_array_result_operation_name(result, NativeArrayOperation.CONTIGUOUS) - return FortranFunction( - name=name, - parameters=(self._owned_native_array_result_parameter(result, intent="in"),), - result_name="state", - result_type="logical(c_bool)", - bind_name=name, - body=( - FortranAssignment("state", CodeExpression(".false._c_bool")), - FortranIf( - CodeExpression("associated(result)"), - body=(FortranAssignment("state", CodeExpression("is_contiguous(result)")),), - ), - ), - ) - - def _owned_native_array_result_shape_operation( - self, - result: ArgumentTransferPlan | ResultPlan, - ) -> FortranFunction: - """Return shape through Fortran when the owned descriptor is allocated.""" - handle = result.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Owned result {result.owner_path!r} has no shape rank") - name = self._owned_native_array_result_operation_name(result, NativeArrayOperation.SHAPE) - extents = tuple(FortranParameter(f"extent_{axis}", "integer(c_int64_t)") for axis in range(handle.array.rank)) - present = tuple( - FortranAssignment( - f"extent_{axis}", - CodeExpression(f"size(result, {axis + 1}, kind=c_int64_t)"), - ) - for axis in range(handle.array.rank) - ) - absent = tuple( - FortranAssignment(f"extent_{axis}", CodeExpression("0_c_int64_t")) for axis in range(handle.array.rank) - ) - inquiry = self._owned_native_array_result_presence_inquiry(result) - return FortranFunction( - name=name, - parameters=(self._owned_native_array_result_parameter(result, intent="in"), *extents), - bind_name=name, - body=( - FortranIf( - CodeExpression(f"{inquiry}(result)"), - body=present, - else_body=absent, - ), - ), - is_subroutine=True, - ) - def _owned_native_array_result_release_operation( self, result: ArgumentTransferPlan | ResultPlan, diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index 185895e77..a0d9fd5e7 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -2,6 +2,7 @@ from __future__ import annotations +import gc from pathlib import Path import numpy as np @@ -138,3 +139,253 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): module.explicit_total(values, np.int32(3)) with pytest.raises(TypeError, match="does not match expected dtype"): module.assumed_total(module.words) + + +NATIVE_HANDLE_DESCRIPTOR_MATRIX_SOURCE = """\ +module fhandle_descriptor_matrix_f90 + use iso_c_binding, only: c_bool + implicit none + + type :: holder + real(8), allocatable :: field_alloc(:) + real(8), pointer :: field_ptr(:) => null() + end type holder + + integer(4), allocatable :: ints(:) + real(4), allocatable :: reals(:) + complex(8), allocatable :: complexes(:) + logical(c_bool), allocatable :: flags(:) + character(len=4), allocatable :: fixed_words(:) + character(len=:), allocatable :: deferred_words(:) + real(8), allocatable :: empty(:) + real(8), allocatable :: spare(:) + real(8), allocatable :: cube(:, :, :) + real(8), target :: store(8) + real(8), pointer :: reversed(:) => null() + real(8), pointer :: strided(:) => null() + real(8), pointer :: unassociated(:) => null() + type(holder) :: parent + +contains + + subroutine setup() + integer :: i + + allocate(ints(3)); ints = [1_4, 2_4, 3_4] + allocate(reals(2)); reals = [1.5_4, 2.5_4] + allocate(complexes(2)); complexes = [(1.0_8, 2.0_8), (3.0_8, 4.0_8)] + allocate(flags(3)); flags = [.true._c_bool, .false._c_bool, .true._c_bool] + allocate(fixed_words(2)); fixed_words = ['abcd', 'efgh'] + allocate(character(len=6) :: deferred_words(2)) + deferred_words = ['alphas', 'bravos'] + allocate(empty(0)) + allocate(cube(2, 3, 4)); cube = 1.0_8 + store = [(1.0_8 * i, i = 1, 8)] + reversed => store(8:1:-1) + strided => store(1:8:2) + allocate(parent%field_alloc(3)); parent%field_alloc = 5.0_8 + parent%field_ptr => store(2:6:2) + end subroutine setup + + subroutine reshape_alloc(values, n) + real(8), allocatable, intent(inout) :: values(:) + integer(4), intent(in) :: n + + if (allocated(values)) deallocate(values) + allocate(values(n)) + values = 4.0_8 + end subroutine reshape_alloc + + function assumed_total(actual) result(total) + real(8), intent(in) :: actual(:) + real(8) :: total + + total = sum(actual) + end function assumed_total + + function int_total(actual) result(total) + integer(4), intent(in) :: actual(:) + integer(4) :: total + + total = sum(actual) + end function int_total + + function real4_total(actual) result(total) + real(4), intent(in) :: actual(:) + real(4) :: total + + total = sum(actual) + end function real4_total + + function complex_total(actual) result(total) + complex(8), intent(in) :: actual(:) + complex(8) :: total + + total = sum(actual) + end function complex_total + + function true_count(actual) result(counted) + logical(c_bool), intent(in) :: actual(:) + integer(4) :: counted + + counted = count(actual) + end function true_count + + function word_width(actual) result(width) + character(len=*), intent(in) :: actual(:) + integer(4) :: width + + width = len(actual) + end function word_width + + function cube_score(actual) result(score) + real(8), intent(in) :: actual(:, :, :) + integer(4) :: score + + score = size(actual) + end function cube_score +end module fhandle_descriptor_matrix_f90 +""" + + +@pytest.fixture(scope="module") +def descriptor_matrix(tmp_path_factory): + module = _build_text_and_import( + NATIVE_HANDLE_DESCRIPTOR_MATRIX_SOURCE, + "fhandle_descriptor_matrix_f90.f90", + tmp_path_factory.mktemp("descriptor_matrix"), + { + "bind_c_fhandle_descriptor_matrix_f90_wrapper.f90", + "fhandle_descriptor_matrix_f90_wrapper.c", + "fhandle_descriptor_matrix_f90_wrapper.h", + }, + ) + module.setup() + return module + + +@pytest.mark.parametrize( + ("name", "dtype", "shape", "expected"), + [ + ("ints", np.int32, (3,), [1, 2, 3]), + ("reals", np.float32, (2,), [1.5, 2.5]), + ("complexes", np.complex128, (2,), [1 + 2j, 3 + 4j]), + ("flags", np.bool_, (3,), [True, False, True]), + ("fixed_words", "S4", (2,), [b"abcd", b"efgh"]), + ("deferred_words", "S6", (2,), [b"alphas", b"bravos"]), + ("empty", np.float64, (0,), []), + ("cube", np.float64, (2, 3, 4), None), + ], +) +def test_every_supported_element_type_reports_its_own_dtype_shape_and_view( + descriptor_matrix, + name: str, + dtype, + shape: tuple[int, ...], + expected, +): + """One descriptor read answers dtype, shape and the view, for every element type.""" + handle = getattr(descriptor_matrix, name) + + assert isinstance(handle, AllocatableArray) + assert handle.allocated is True + assert handle.dtype == np.dtype(dtype) + assert handle.shape == shape + + view = handle.to_numpy() + assert view.dtype == np.dtype(dtype) + assert view.shape == shape + if expected is not None: + np.testing.assert_array_equal(view, np.array(expected, dtype=dtype)) + + +def test_each_element_type_reaches_a_matching_ordinary_dummy(descriptor_matrix): + """The storage behind a handle satisfies an ordinary dummy of its own type.""" + assert descriptor_matrix.int_total(descriptor_matrix.ints) == np.int32(6) + assert descriptor_matrix.real4_total(descriptor_matrix.reals) == np.float32(4.0) + assert descriptor_matrix.complex_total(descriptor_matrix.complexes) == np.complex128(4 + 6j) + assert descriptor_matrix.true_count(descriptor_matrix.flags) == np.int32(2) + assert descriptor_matrix.word_width(descriptor_matrix.fixed_words) == np.int32(4) + assert descriptor_matrix.word_width(descriptor_matrix.deferred_words) == np.int32(6) + assert descriptor_matrix.cube_score(descriptor_matrix.cube) == np.int32(24) + # A zero-sized allocation is present storage that happens to hold nothing. + assert descriptor_matrix.assumed_total(descriptor_matrix.empty) == np.float64(0.0) + + +def test_pointer_targets_report_their_shape_and_reach_an_ordinary_dummy(descriptor_matrix): + """A pointer's storage satisfies an array dummy however its target is laid out. + + Both targets report their real shape. A positive stride reaches the dummy; + a reversed one is refused by completed layout policy rather than silently + taking another route. Extraction itself stays gated behind PointerPolicy. + """ + reversed_handle = descriptor_matrix.reversed + strided_handle = descriptor_matrix.strided + + assert isinstance(reversed_handle, PointerArray) + assert reversed_handle.associated is True + assert reversed_handle.shape == (8,) + assert strided_handle.shape == (4,) + + assert descriptor_matrix.assumed_total(strided_handle) == np.float64(16.0) + with pytest.raises(ValueError, match="noncontiguous"): + descriptor_matrix.assumed_total(reversed_handle) + + for handle in (reversed_handle, strided_handle): + with pytest.raises(NotImplementedError, match="unsupported by completed policy"): + handle.to_numpy() + + +def test_an_unassociated_pointer_reports_absence_through_every_inquiry(descriptor_matrix): + handle = descriptor_matrix.unassociated + + assert handle.associated is False + assert handle.shape is None + assert handle.to_numpy() is None + with pytest.raises(ValueError, match="unassociated"): + descriptor_matrix.assumed_total(handle) + + +def test_a_derived_type_field_view_retains_its_parent(descriptor_matrix): + """A field's storage belongs to its parent, so a view has to keep it alive.""" + parent = descriptor_matrix.parent + field = parent.field_alloc + pointer_field = parent.field_ptr + + assert field.shape == (3,) + np.testing.assert_allclose(field.to_numpy(), np.array([5.0, 5.0, 5.0])) + assert pointer_field.associated is True + assert pointer_field.shape == (3,) + + view = field.to_numpy() + assert view.base is not None + del field + del parent + gc.collect() + np.testing.assert_allclose(view, np.array([5.0, 5.0, 5.0])) + + +def test_reallocating_through_a_dummy_updates_every_later_inquiry(descriptor_matrix): + """Allocation state written by the callee reaches the caller's handle. + + `spare` is this test's alone, so the shared fixture keeps the state every + other test in this module reads. + """ + spare = descriptor_matrix.spare + assert spare.allocated is False + assert spare.shape is None + + descriptor_matrix.reshape_alloc(spare, np.int32(3)) + assert spare.shape == (3,) + np.testing.assert_allclose(spare.to_numpy(), np.array([4.0, 4.0, 4.0])) + assert descriptor_matrix.assumed_total(spare) == np.float64(12.0) + + descriptor_matrix.reshape_alloc(spare, np.int32(5)) + assert spare.shape == (5,) + + spare.deallocate() + assert spare.allocated is False + assert spare.shape is None + assert spare.to_numpy() is None + with pytest.raises(ValueError, match="unallocated"): + descriptor_matrix.assumed_total(spare) diff --git a/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointer_handles_policy/fpointer_handles_f90.pyi b/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointer_handles_policy/fpointer_handles_f90.pyi index a71159558..5480d2ea1 100644 --- a/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointer_handles_policy/fpointer_handles_f90.pyi +++ b/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointer_handles_policy/fpointer_handles_f90.pyi @@ -48,6 +48,7 @@ module_allocatable: Annotated[Allocatable[Float64[:]], Aliased] def associate_module_slice() -> None: ... def associate_module_contiguous() -> None: ... +def associate_module_reversed() -> None: ... def allocate_module_values() -> None: ... def box_associate_values(self: pointer_box) -> None: ... diff --git a/tests/fortran/pointers/end_to_end/test_pointer_handles.py b/tests/fortran/pointers/end_to_end/test_pointer_handles.py index 8efd8d6a8..bc6ce37a3 100644 --- a/tests/fortran/pointers/end_to_end/test_pointer_handles.py +++ b/tests/fortran/pointers/end_to_end/test_pointer_handles.py @@ -93,6 +93,10 @@ module_values => module_storage(2:4) end subroutine associate_module_contiguous + subroutine associate_module_reversed() + module_values => module_storage(5:2:-1) + end subroutine associate_module_reversed + subroutine select_module_values(values) real(8), pointer, intent(out) :: values(:) values => module_storage(2:4) @@ -393,6 +397,33 @@ def test_caller_created_pointer_crosses_separately_built_extensions(tmp_path: Pa assert values.closed is True +def test_a_reversed_pointer_target_keeps_its_data_pointer_strides_and_span(tmp_path: Path): + """A negative stride reaches the view exactly as the descriptor records it. + + The descriptor's base address is the first element in Fortran order and its + stride multiplier is signed, which is also what NumPy indexes with, so the + view is built from them directly rather than from a window computed around + them. + """ + module = _pointer_descriptor_view_module(tmp_path) + handle = module.module_values + module.associate_module_reversed() + + view = handle.to_numpy() + + assert view.shape == (4,) + assert view.strides == (-8,) + np.testing.assert_allclose(view, np.array([5.0, 4.0, 3.0, 2.0], dtype=np.float64)) + + # The view spans the same storage as the forward slice of the same target. + view[0] = np.float64(50.0) + module.associate_module_contiguous() + np.testing.assert_allclose(module_handle_view := handle.to_numpy(), np.array([2.0, 3.0, 4.0])) + assert module_handle_view.strides == (8,) + module.associate_module_reversed() + np.testing.assert_allclose(handle.to_numpy(), np.array([50.0, 4.0, 3.0, 2.0])) + + def test_pointer_descriptor_views_preserve_slice_shape_strides_and_parent_lifetime(tmp_path: Path): module = _pointer_descriptor_view_module(tmp_path) From e10f5f206ad092809f30d753be67d8afab3a68be Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 10:21:14 +0100 Subject: [PATCH 25/47] Dispatch every array handle through one backend and one call The handle now carries a dispatcher, a completed capability set and one versioned backend capsule instead of a dictionary of generated callables and a second owned-descriptor record. Every inquiry -- shape, allocation and association state, element width, contiguity and the NumPy view -- is answered by a shared C consumer run over the descriptor the backend makes live, so nothing is packed into Python values and decoded back, and the bridge no longer carries an inquiry procedure per variable. Argument handoff reads the capsule in C, so a bound handle reaches an ordinary or descriptor dummy without executing a Python frame; the test added here observes exactly that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 5 + docs/developer/packages/runtime.md | 32 +- prik/codegen/c/binding.py | 701 +++++++++--------- prik/codegen/fortran/bridge.py | 4 +- prik/runtime/handles.py | 268 ++++--- .../fortran/_support/native_array_handles.py | 20 + .../codegen/test_allocatable_lowering.py | 5 +- .../end_to_end/test_allocatable_handles.py | 2 + .../test_allocatable_contract_handles.py | 33 +- .../test_allocatable_handle_protocol.py | 77 +- .../test_native_handle_array_forms.py | 36 + .../codegen/test_native_handle_planning.py | 27 +- .../runtime/test_handle_lifecycle.py | 150 ++-- .../pointers/codegen/test_pointer_lowering.py | 20 + .../runtime/test_pointer_contract_handles.py | 60 +- .../runtime/test_pointer_descriptor_abi.py | 44 +- .../runtime/test_pointer_handle_protocol.py | 342 +++++---- 17 files changed, 1007 insertions(+), 819 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2417dcc44..e20f4add3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ release tags add a leading `v` to the package version. one Fortran builds for the call or persistent storage the wrapper owns. Extensions built against the earlier branch-only table must be regenerated. +- Generated array handles now route operations through one native dispatcher + and an immutable capability set instead of constructing and storing one + Python callable per operation. + - Argument handoff, shape, allocation and association state, element width, contiguity and NumPy views are now all read from that live descriptor in C. No descriptor is serialized into Python fields and decoded back, and the @@ -48,6 +52,7 @@ release tags add a leading `v` to the package version. `standard_logicals=False` only when linking Intel objects compiled without that option. Wider logical arrays are exposed with their matching integer dtype; `logical(c_bool)` remains `numpy.bool_`. + ## 0.4.3 — 2026-08-31 - Republishes 0.4.2. That tag carried the previous package version, so the diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 24953ea6e..44e87f7a5 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -23,22 +23,23 @@ select a different view behavior from local descriptor facts. ## A Native Array Handle At Runtime ```text -generated operation dictionary + native backend capsule +generated dispatcher + completed capability set + native backend capsule + dtype, rank, ownership, and view policy -> NativeArrayHandleBase validation and owner retention -> AllocatableArray or PointerArray -> state, lifecycle, association, and to_numpy() operations ``` -The operation dictionary is the boundary between generated extension code and -the stable Python handle API. An operation exists only when the completed plan -allows the generator to expose it. Missing operations fail explicitly rather -than being inferred from `allocatable` or `pointer` alone. +The dispatcher is the single Python call boundary between generated extension +code and the stable handle API. Its immutable capability set comes from the +completed plan and states which operation names the dispatcher accepts. +Missing capabilities fail explicitly rather than being inferred from +`allocatable` or `pointer` alone. ### The Backend Capsule Every generated handle publishes one versioned capsule, -`prik.native_array_backend.v1`, on `_native_ops`. It is the whole +`prik.native_array_backend.v1`, on `_native_backend`. It is the whole cross-extension ABI for an array handle: ```c @@ -123,13 +124,13 @@ prik/runtime/ ``` - [`handles.py`](../../../prik/runtime/handles.py) contains the Python runtime. - `NativeArrayHandleBase` validates common metadata and operations. + `NativeArrayHandleBase` validates common metadata, the dispatcher, and its + completed capabilities. `AllocatableArray` adds allocation state, resize, and deallocation; `PointerArray` adds association, nullification, allocation, resize, and - deallocation when supplied. Internal adapters translate generated call - signatures. A handle created from a `.pyi` contract answers from a fact - tuple of its own until a call attaches generated storage; every other handle - answers from its descriptor. + deallocation when supplied. A handle created from a `.pyi` contract answers + from a fact tuple of its own until a call attaches generated storage; every + other handle answers from its descriptor. - `native_support/prik_binding.h` contains header-only CPython/NumPy conversion, descriptor, validation, capsule, and release support. Change it only with its generated C users and `prik/compiler/native_support.py`. @@ -157,11 +158,10 @@ Resized shape: (4,) Generated resize received NumPy extents: True ``` -The example supplies the same Python operation-dictionary shape as generated -code. -It creates an allocatable handle, reads its live NumPy view, and routes a -resize through the adapter. The native header has no standalone Python route; -the compiler installs it into a generated `binding_support/` directory. +The example supplies the same dispatcher and capability set as generated code. +It creates an allocatable handle, reads its live NumPy view, and routes a resize +through the dispatcher. The native header has no standalone Python route; the +compiler installs it into a generated `binding_support/` directory. ## Change Routes And Evidence diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 2f5bc8a6d..a58ae8834 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -3075,22 +3075,22 @@ def _fixed_string_field_input_nodes(self, field: DerivedFieldPlan, object_name: ) def _field_handle_backend_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: - """Release the reference the published table capsule was created with.""" + """Release the reference the published backend capsule was created with.""" if self._field_handle_backend_capsule_name(field, prefix) == "Py_None": return () - return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_ops)")),) + return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_backend)")),) def _field_handle_backend_capsule_name(self, field: DerivedFieldPlan, prefix: str) -> str: - """Return the local holding this field handle's published entry-point table.""" + """Return the local holding this field handle's published backend.""" handle = field.native_array_handle if handle is None or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: return "Py_None" - return f"{prefix}_native_ops" + return f"{prefix}_native_backend" def _field_handle_backend_capsule_nodes( self, owner, field: DerivedFieldPlan, prefix: str, owner_name: str ) -> tuple: - """Build the entry-point table this field handle publishes. + """Build the descriptor backend this field handle publishes. A derived-type field reaches its entity through the parent's address, so that address is resolved once here rather than on every operation. @@ -3107,7 +3107,7 @@ def _field_handle_backend_capsule_nodes( parent = f"{prefix}_parent" address = f"{parent}_address" if isinstance(owner, DerivedTypePlan) else "NULL" forward = self._field_handle_with_descriptor_name(self._field_handle_descriptor_callback(owner, field)) - capsule = f"{prefix}_native_ops" + capsule = f"{prefix}_native_backend" return ( *( self._derived_address_from_object_nodes(owner.backend_symbol, owner_name, parent) @@ -3128,52 +3128,41 @@ def _field_handle_backend_capsule_nodes( ) def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name: str) -> tuple: - """Build a fresh borrowed handle whose operations are bound to its parent.""" + """Build a fresh borrowed handle whose dispatcher is bound to its parent.""" handle = field.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Native handle field {field.owner_path!r} has no factory plan") prefix = re.sub(r"\W", "_", field.owner_path).casefold() - ops = f"{prefix}_ops" - operation_object = f"{prefix}_operation" + capabilities = f"{prefix}_capabilities" + invoke = f"{prefix}_invoke" runtime = f"{prefix}_runtime" helper = f"{prefix}_helper" result = f"{prefix}_handle" - # The entry-point table is built before anything that would need - # releasing, so its failure paths can return without cleanup. + dispatch = self._field_handle_dispatch_name(owner, field) nodes = [ *self._field_handle_backend_capsule_nodes(owner, field, prefix, owner_name), - CDeclaration(ops, "PyObject *", CodeExpression("PyDict_New()")), - CDeclaration(operation_object, "PyObject *", CodeExpression("NULL")), + CDeclaration(capabilities, "PyObject *", CodeExpression("NULL")), + CDeclaration(invoke, "PyObject *", CodeExpression("NULL")), CDeclaration(runtime, "PyObject *", CodeExpression("NULL")), CDeclaration(helper, "PyObject *", CodeExpression("NULL")), CDeclaration(result, "PyObject *", CodeExpression("NULL")), - CIf(CodeExpression(f"{ops} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CExpressionStatement( + CodeExpression(f"{capabilities} = {self._native_array_capabilities_expression(handle.operations)}") + ), + CIf( + CodeExpression(f"{capabilities} == NULL"), + body=(*self._field_handle_backend_release_nodes(field, prefix), CReturn(CodeExpression("NULL"))), + ), + CExpressionStatement(CodeExpression(f"{invoke} = PyCFunction_NewEx(&{dispatch}_def, {owner_name}, NULL)")), + CIf( + CodeExpression(f"{invoke} == NULL"), + body=( + CExpressionStatement(CodeExpression(f"Py_DECREF({capabilities})")), + *self._field_handle_backend_release_nodes(field, prefix), + CReturn(CodeExpression("NULL")), + ), + ), ] - for operation in handle.operations: - name = self._field_handle_operation_name(owner, field, operation) - nodes.extend( - ( - CExpressionStatement( - CodeExpression(f"{operation_object} = PyCFunction_NewEx(&{name}_def, {owner_name}, NULL)") - ), - CIf( - CodeExpression(f"{operation_object} == NULL"), - body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), - CReturn(CodeExpression("NULL")), - ), - ), - CIf( - CodeExpression(f'PyDict_SetItemString({ops}, "{operation.value}", {operation_object}) < 0'), - body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({operation_object})")), - CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), - CReturn(CodeExpression("NULL")), - ), - ), - CExpressionStatement(CodeExpression(f"Py_DECREF({operation_object})")), - ) - ) family = DatatypeFamily.STRING if field.string_element else DatatypeFamily.REAL nodes.extend( ( @@ -3181,20 +3170,24 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name CIf( CodeExpression(f"{runtime} == NULL"), body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), + CExpressionStatement(CodeExpression(f"Py_DECREF({invoke})")), + CExpressionStatement(CodeExpression(f"Py_DECREF({capabilities})")), + *self._field_handle_backend_release_nodes(field, prefix), CReturn(CodeExpression("NULL")), ), ), CExpressionStatement( CodeExpression( - f'{helper} = PyObject_GetAttrString({runtime}, "_native_array_handle_from_generated_ops")' + f'{helper} = PyObject_GetAttrString({runtime}, "_native_array_handle_from_generated_dispatch")' ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({runtime})")), CIf( CodeExpression(f"{helper} == NULL"), body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), + CExpressionStatement(CodeExpression(f"Py_DECREF({invoke})")), + CExpressionStatement(CodeExpression(f"Py_DECREF({capabilities})")), + *self._field_handle_backend_release_nodes(field, prefix), CReturn(CodeExpression("NULL")), ), ), @@ -3207,16 +3200,18 @@ def _field_handle_factory_nodes(self, owner, field: DerivedFieldPlan, owner_name semantic_type_name=field.semantic_type_name, datatype_family=family, rank=handle.array.rank, - ops=ops, + invoke=invoke, + capabilities=capabilities, owner=owner_name, descriptor_ownership="borrowed", - native_ops=self._field_handle_backend_capsule_name(field, prefix), + native_backend=self._field_handle_backend_capsule_name(field, prefix), extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({helper})")), - CExpressionStatement(CodeExpression(f"Py_DECREF({ops})")), + CExpressionStatement(CodeExpression(f"Py_DECREF({invoke})")), + CExpressionStatement(CodeExpression(f"Py_DECREF({capabilities})")), *self._field_handle_backend_release_nodes(field, prefix), CReturn(CodeExpression(result)), ) @@ -3235,6 +3230,10 @@ def _field_handle_operation_name( variable, member = owner return self._module_member_handle_operation_name(variable, member, operation) + def _field_handle_dispatch_name(self, owner, field: DerivedFieldPlan) -> str: + """Return the single Python dispatcher name for one field handle.""" + return f"{self._field_handle_operation_name(owner, field, NativeArrayOperation.SHAPE)}_dispatch" + def _module_ordinary_array_member_setter( self, variable: ModuleVariablePlan, @@ -3684,32 +3683,31 @@ def _derived_handle_operation_declarations( self, plan: ModulePlan, ) -> tuple[CFunctionPrototype | CDeclaration, ...]: - """Declare every parent-bound field-handle callable and method record.""" + """Declare one parent-bound dispatcher per field handle.""" declarations = [] for _owner, field, operation_name, descriptor_callback in self._derived_handle_targets(plan): - # The getter that publishes this field's table is emitted before the + # The getter that publishes this field's backend is emitted before the # forwarder it names, so the forwarder is declared here. declarations.extend(self._field_handle_with_descriptor_prototypes(field, descriptor_callback)) handle = field.native_array_handle if handle is None: continue - for operation in handle.operations: - name = operation_name(operation) - declarations.extend( - ( - CFunctionPrototype( - name, - "PyObject *", - (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), - storage="static", - ), - CDeclaration( - f"{name}_def", - "static PyMethodDef", - CodeExpression(f'{{"{name}", (PyCFunction){name}, METH_VARARGS, ""}}'), - ), - ) + name = self._native_array_dispatch_name(operation_name) + declarations.extend( + ( + CFunctionPrototype( + name, + "PyObject *", + (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), + storage="static", + ), + CDeclaration( + f"{name}_def", + "static PyMethodDef", + CodeExpression(f'{{"{name}", (PyCFunction){name}, METH_VARARGS, ""}}'), + ), ) + ) return tuple(declarations) def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: @@ -3738,9 +3736,13 @@ def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFuncti handle = field.native_array_handle if handle is None: continue - functions.extend( - self._field_handle_operation_function(owner, field, operation, operation_name(operation)) - for operation in handle.operations + functions.append( + self._field_handle_dispatch_function( + owner, + field, + handle.operations, + self._native_array_dispatch_name(operation_name), + ) ) return tuple(functions) @@ -3790,7 +3792,7 @@ def _field_handle_backend_nodes( """Emit the forwarder driving one field's descriptor bridge. A field reaches its entity through its parent's address, so unlike a - module variable the table cannot be a file-scope constant: the owner + module variable the backend cannot be a file-scope constant: the owner differs per handle and is filled in when the handle is built. """ handle = field.native_array_handle @@ -3827,22 +3829,72 @@ def _field_handle_backend_nodes( ), ) - def _field_handle_operation_function( + def _field_handle_dispatch_function( self, owner, field: DerivedFieldPlan, - operation: NativeArrayOperation, + operations: tuple[NativeArrayOperation, ...], name: str, ) -> CFunction: - """Lower one live field-handle operation selected by completed policy.""" + """Lower one live field-handle dispatcher selected by completed policy.""" return CFunction( name, "PyObject *", parameters=(CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), storage="static", - body=self._field_handle_operation_body(owner, field, operation), + body=self._native_array_dispatch_body( + operations, + lambda operation: self._field_handle_operation_body(owner, field, operation), + ), ) + def _native_array_dispatch_body(self, operations, operation_body) -> tuple: + """Dispatch one generated callable through its completed capability set.""" + return ( + CDeclaration("operation_object", "PyObject *", CodeExpression("NULL")), + CDeclaration("operation_args", "PyObject *", CodeExpression("NULL")), + CDeclaration("operation", "const char *", CodeExpression("NULL")), + CExpressionStatement( + CodeExpression( + 'if (!PyArg_ParseTuple(args, "OO!:native array dispatcher", &operation_object, ' + "&PyTuple_Type, &operation_args)) return NULL" + ) + ), + CIf( + CodeExpression("!PyUnicode_Check(operation_object)"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_SetString(PyExc_TypeError, "native array operation name must be a string")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + CExpressionStatement(CodeExpression("operation = PyUnicode_AsUTF8(operation_object)")), + CIf(CodeExpression("operation == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CExpressionStatement(CodeExpression("args = operation_args")), + *( + CIf( + CodeExpression(f'strcmp(operation, "{item.value}") == 0'), + body=operation_body(item), + ) + for item in operations + ), + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_NotImplementedError, "native array operation %R is not available", ' + "operation_object)" + ) + ), + CReturn(CodeExpression("NULL")), + ) + + @staticmethod + def _native_array_dispatch_name(operation_name) -> str: + """Name the single dispatcher replacing one handle's operation methods.""" + return f"{operation_name(NativeArrayOperation.SHAPE)}_dispatch" + def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation: NativeArrayOperation) -> tuple: """Dispatch one operation without inferring descriptor ownership.""" prefix = self._field_handle_owner_nodes(owner) @@ -4005,12 +4057,12 @@ def _module_allocator_functions(self, required: bool) -> tuple[CFunction, ...]: ), ) - # Owned native-array-handle operation tables. + # Native-array-handle Python dispatch. def _native_array_operation_declarations( self, plan: ModulePlan, ) -> tuple[CFunctionPrototype | CDeclaration, ...]: - """Declare private operation wrappers and their callable definitions.""" + """Declare one private dispatcher per generated handle source.""" declarations = [] for variable in self._module_array_owner_variables(plan): if variable.binding.getter_action is ModuleGetterAction.NATIVE_ARRAY_HANDLE: @@ -4030,41 +4082,13 @@ def _native_array_operation_declarations( ) if variable.native_array_handle is None: continue - for operation in variable.native_array_handle.operations: - name = self._module_native_array_operation_name(variable, operation) - declarations.extend( - ( - CFunctionPrototype( - name, - "PyObject *", - (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), - storage="static", - ), - CDeclaration( - self._module_native_array_operation_def_name(variable, operation), - "static PyMethodDef", - CodeExpression(f'{{"{name}", (PyCFunction){name}, METH_VARARGS, ""}}'), - ), - ) - ) + declarations.extend( + self._native_array_dispatch_declarations(self._module_native_array_dispatch_name(variable)) + ) for function, result in self._owned_native_array_results(plan): - for operation in result.native_array_handle.operations: - name = self._owned_native_array_operation_name(function, result, operation) - declarations.extend( - ( - CFunctionPrototype( - name, - "PyObject *", - (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), - storage="static", - ), - CDeclaration( - self._owned_native_array_operation_def_name(function, result, operation), - "static PyMethodDef", - CodeExpression(f'{{"{name}", (PyCFunction){name}, METH_VARARGS, ""}}'), - ), - ) - ) + declarations.extend( + self._native_array_dispatch_declarations(self._owned_native_array_dispatch_name(function, result)) + ) for function, argument in self._default_native_array_arguments(plan): binder_name = self._default_native_array_binder_name(argument) declarations.extend( @@ -4082,27 +4106,30 @@ def _native_array_operation_declarations( ), ) ) - for operation in argument.native_array_handle.default_handle.operations: - name = self._owned_native_array_operation_name(function, argument, operation) - declarations.extend( - ( - CFunctionPrototype( - name, - "PyObject *", - (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), - storage="static", - ), - CDeclaration( - self._owned_native_array_operation_def_name(function, argument, operation), - "static PyMethodDef", - CodeExpression(f'{{"{name}", (PyCFunction){name}, METH_VARARGS, ""}}'), - ), - ) - ) + declarations.extend( + self._native_array_dispatch_declarations(self._owned_native_array_dispatch_name(function, argument)) + ) return tuple(declarations) + @staticmethod + def _native_array_dispatch_declarations(name: str) -> tuple[CFunctionPrototype | CDeclaration, ...]: + """Declare one dispatcher function and its Python callable record.""" + return ( + CFunctionPrototype( + name, + "PyObject *", + (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), + storage="static", + ), + CDeclaration( + f"{name}_def", + "static PyMethodDef", + CodeExpression(f'{{"{name}", (PyCFunction){name}, METH_VARARGS, ""}}'), + ), + ) + def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: - """Lower every planned owned-descriptor operation into a named C method.""" + """Lower each generated handle source into one named dispatcher.""" return ( *( self._native_array_capsule_release_function(result) @@ -4121,15 +4148,13 @@ def _native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction for callback in self._module_native_array_backend_functions(variable) ), *( - self._module_native_array_operation_function(variable, operation) + self._module_native_array_dispatch_function(variable) for variable in self._module_native_array_variables(plan) if variable.native_array_handle is not None - for operation in variable.native_array_handle.operations ), *( - self._owned_native_array_operation_function(function, result, operation) + self._owned_native_array_dispatch_function(function, result) for function, result in self._owned_native_array_results(plan) - for operation in result.native_array_handle.operations ), *self._default_native_array_operation_functions(plan), ) @@ -4175,17 +4200,15 @@ def _native_array_capsule_release_function( ) def _default_native_array_operation_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: - """Lower lazy caller-handle binders and their owned operation methods.""" + """Lower lazy caller-handle binders and their owned dispatchers.""" arguments = self._default_native_array_arguments(plan) - operations = tuple( - self._owned_native_array_operation_function(function, argument, operation) - for function, argument in arguments - for operation in argument.native_array_handle.default_handle.operations + dispatchers = tuple( + self._owned_native_array_dispatch_function(function, argument) for function, argument in arguments ) binders = tuple( self._default_native_array_binder_function(function, argument) for function, argument in arguments ) - return (*operations, *binders) + return (*dispatchers, *binders) def _module_native_array_variables(self, plan: ModulePlan) -> tuple[ModuleVariablePlan, ...]: """Return borrowed module-handle plans in stable namespace order.""" @@ -4205,18 +4228,20 @@ def _module_array_owner_variables(self, plan: ModulePlan) -> tuple[ModuleVariabl ) # Borrowed module native-array-handle operations. - def _module_native_array_operation_function( - self, - variable: ModuleVariablePlan, - operation: NativeArrayOperation, - ) -> CFunction: - """Lower one planned borrowed-module operation into a private callable.""" + def _module_native_array_dispatch_function(self, variable: ModuleVariablePlan) -> CFunction: + """Lower one planned borrowed-module dispatcher.""" + handle = variable.native_array_handle + if handle is None: + raise ValueError(f"Module native array handle {variable.owner_path!r} is incomplete") return CFunction( - self._module_native_array_operation_name(variable, operation), + self._module_native_array_dispatch_name(variable), "PyObject *", parameters=(CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), storage="static", - body=self._module_native_array_operation_body(variable, operation), + body=self._native_array_dispatch_body( + handle.operations, + lambda operation: self._module_native_array_operation_body(variable, operation), + ), ) def _module_native_array_operation_body( @@ -4403,11 +4428,11 @@ def _module_native_array_backend_nodes( variable: ModuleVariablePlan, handle: NativeArrayHandlePlan, ) -> tuple[CFunction | CDeclaration, ...]: - """Emit the native entry-point table one module array handle publishes. + """Emit the native descriptor backend one module array handle publishes. - The table names the bridge symbols for this variable so a consumer can + The backend names the bridge symbols for this variable so a consumer can reach it with one indirect call instead of a Python operation lookup. - A module variable needs no owner, so the record is a file-scope + A module variable needs no owner, so the backend is a file-scope constant rather than per-handle storage. """ cfi_type = self._module_native_array_cfi_type(variable) @@ -4454,7 +4479,7 @@ def _module_native_array_backend_nodes( ) def _emits_native_array_backend(self, plan: ModulePlan) -> bool: - """Report whether any handle in this module publishes an entry-point table.""" + """Report whether any handle in this module publishes a native backend.""" if any( self._uses_module_allocatable_descriptor(variable) for variable in self._module_native_array_variables(plan) ): @@ -4467,7 +4492,7 @@ def _emits_native_array_backend(self, plan: ModulePlan) -> bool: @staticmethod def _native_array_forward_descriptor_function() -> CFunction: - """Emit the consumer that hands a runtime descriptor to a table consumer.""" + """Emit the consumer that hands a runtime descriptor to a backend consumer.""" return CFunction( "prik_native_array_forward_descriptor", "void", @@ -4485,24 +4510,24 @@ def _native_array_forward_descriptor_function() -> CFunction: ) def _module_native_array_backend_capsule_name(self, variable: ModuleVariablePlan, prefix: str) -> str: - """Return the local holding this variable's published entry-point table.""" + """Return the local holding this variable's published backend.""" if not self._uses_module_allocatable_descriptor(variable): return "Py_None" - return f"{prefix}_native_ops" + return f"{prefix}_native_backend" def _module_native_array_backend_declaration_nodes( self, variable: ModuleVariablePlan, prefix: str, ) -> tuple[CDeclaration, ...]: - """Declare and build the capsule publishing one variable's entry-point table.""" + """Declare and build the capsule publishing one variable's backend.""" if not self._uses_module_allocatable_descriptor(variable): return () return ( CDeclaration( - f"{prefix}_native_ops", + f"{prefix}_native_backend", "PyObject *", - CodeExpression(self._module_native_array_backend_capsule(variable)), + CodeExpression("NULL"), ), ) @@ -4511,13 +4536,13 @@ def _module_native_array_backend_release_nodes( variable: ModuleVariablePlan, prefix: str, ) -> tuple[CExpressionStatement, ...]: - """Release the reference the published table capsule was created with.""" + """Release the reference the published backend capsule was created with.""" if not self._uses_module_allocatable_descriptor(variable): return () - return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_ops)")),) + return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_backend)")),) def _module_native_array_backend_capsule(self, variable: ModuleVariablePlan) -> str: - """Return the expression publishing this variable's native entry-point table.""" + """Return the expression publishing this variable's native backend.""" if not self._uses_module_allocatable_descriptor(variable): return "Py_None" return f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, NULL)" @@ -4527,8 +4552,8 @@ def _module_with_descriptor_name(self, variable: ModuleVariablePlan) -> str: return f"{self._module_descriptor_callback_name(variable)}_with_descriptor" def _module_native_array_backend_name(self, variable: ModuleVariablePlan) -> str: - """Return the file-scope native entry-point table name for one module array.""" - return f"{self._module_descriptor_callback_name(variable)}_ops" + """Return the file-scope native backend name for one module array.""" + return f"{self._module_descriptor_callback_name(variable)}_backend" def _module_descriptor_callback_name(self, variable: ModuleVariablePlan) -> str: """Return the binding-local module descriptor callback name derived from the supplied completed binding records; this helper preserves completed policy.""" @@ -4570,22 +4595,10 @@ def _module_native_array_shape_mutation_body( CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) - def _module_native_array_operation_name( - self, - variable: ModuleVariablePlan, - operation: NativeArrayOperation, - ) -> str: - """Return the binding-local module native array operation name derived from the supplied completed binding records; this helper preserves completed policy.""" + def _module_native_array_dispatch_name(self, variable: ModuleVariablePlan) -> str: + """Return the binding-local module native array dispatcher name.""" owner = re.sub(r"\W", "_", variable.owner_path).casefold() - return f"prik_module_{owner}_{operation.value}" - - def _module_native_array_operation_def_name( - self, - variable: ModuleVariablePlan, - operation: NativeArrayOperation, - ) -> str: - """Return the binding-local module native array operation def name derived from the supplied completed binding records; this helper preserves completed policy.""" - return f"{self._module_native_array_operation_name(variable, operation)}_def" + return f"prik_module_{owner}_dispatch" def _module_native_array_bridge_operation_name( self, @@ -4631,21 +4644,26 @@ def _default_native_array_arguments( is NativeArrayDefaultConstruction.LAZY_OWNED_DESCRIPTOR ) - def _owned_native_array_operation_function( + def _owned_native_array_dispatch_function( self, function: FunctionPlan, - result: ResultPlan, - operation: NativeArrayOperation, + result: ArgumentTransferPlan | ResultPlan, ) -> CFunction: - """Dispatch one planned owned-descriptor runtime operation.""" - name = self._owned_native_array_operation_name(function, result, operation) - body = self._owned_native_array_operation_body(result, operation) + """Lower one planned owned-descriptor dispatcher.""" + operations = ( + result.native_array_handle.default_handle.operations + if isinstance(result, ArgumentTransferPlan) + else result.native_array_handle.operations + ) return CFunction( - name, + self._owned_native_array_dispatch_name(function, result), "PyObject *", parameters=(CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), storage="static", - body=body, + body=self._native_array_dispatch_body( + operations, + lambda operation: self._owned_native_array_operation_body(result, operation), + ), ) def _default_native_array_binder_function( @@ -4673,12 +4691,13 @@ def _default_native_array_binder_function( ) cfi_type = self._native_array_cfi_type(argument) elem_len = f"sizeof({PrimitiveScalarTypeRegistry.type_for(argument.semantic_type_name).c_spelling})" + dispatch = self._owned_native_array_dispatch_name(function, argument) nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ CDeclaration("handle_obj", "PyObject *"), CDeclaration("owner_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")), CDeclaration("owner_status", "int", CodeExpression("CFI_SUCCESS")), - CDeclaration("ops", "PyObject *", CodeExpression("NULL")), - CDeclaration("operation", "PyObject *", CodeExpression("NULL")), + CDeclaration("capabilities", "PyObject *", CodeExpression("NULL")), + CDeclaration("invoke", "PyObject *", CodeExpression("NULL")), CDeclaration("owner_obj", "PyObject *", CodeExpression("NULL")), CDeclaration("runtime", "PyObject *", CodeExpression("NULL")), CDeclaration("helper", "PyObject *", CodeExpression("NULL")), @@ -4714,40 +4733,26 @@ def _default_native_array_binder_function( CReturn(CodeExpression("NULL")), ), ), - CExpressionStatement(CodeExpression("ops = PyDict_New()")), + CExpressionStatement( + CodeExpression(f"capabilities = {self._native_array_capabilities_expression(default.operations)}") + ), CIf( - CodeExpression("ops == NULL"), + CodeExpression("capabilities == NULL"), body=( CExpressionStatement(CodeExpression("free(owner_descriptor)")), CReturn(CodeExpression("NULL")), ), ), + CExpressionStatement(CodeExpression(f"invoke = PyCFunction_NewEx(&{dispatch}_def, NULL, NULL)")), + CIf( + CodeExpression("invoke == NULL"), + body=( + CExpressionStatement(CodeExpression("Py_DECREF(capabilities)")), + CExpressionStatement(CodeExpression("free(owner_descriptor)")), + CReturn(CodeExpression("NULL")), + ), + ), ] - for operation in default.operations: - definition = self._owned_native_array_operation_def_name(function, argument, operation) - nodes.extend( - ( - CExpressionStatement(CodeExpression(f"operation = PyCFunction_NewEx(&{definition}, NULL, NULL)")), - CIf( - CodeExpression("operation == NULL"), - body=( - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), - CExpressionStatement(CodeExpression("free(owner_descriptor)")), - CReturn(CodeExpression("NULL")), - ), - ), - CIf( - CodeExpression(f'PyDict_SetItemString(ops, "{operation.value}", operation) < 0'), - body=( - CExpressionStatement(CodeExpression("Py_DECREF(operation)")), - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), - CExpressionStatement(CodeExpression("free(owner_descriptor)")), - CReturn(CodeExpression("NULL")), - ), - ), - CExpressionStatement(CodeExpression("Py_DECREF(operation)")), - ) - ) nodes.extend( ( CExpressionStatement( @@ -4758,7 +4763,8 @@ def _default_native_array_binder_function( CIf( CodeExpression("owner_obj == NULL"), body=( - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), + CExpressionStatement(CodeExpression("Py_DECREF(invoke)")), + CExpressionStatement(CodeExpression("Py_DECREF(capabilities)")), CExpressionStatement(CodeExpression("free(owner_descriptor)")), CReturn(CodeExpression("NULL")), ), @@ -4769,7 +4775,8 @@ def _default_native_array_binder_function( CodeExpression("runtime == NULL"), body=( CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), + CExpressionStatement(CodeExpression("Py_DECREF(invoke)")), + CExpressionStatement(CodeExpression("Py_DECREF(capabilities)")), CExpressionStatement(CodeExpression("free(owner_descriptor)")), CReturn(CodeExpression("NULL")), ), @@ -4782,7 +4789,8 @@ def _default_native_array_binder_function( CodeExpression("helper == NULL"), body=( CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), + CExpressionStatement(CodeExpression("Py_DECREF(invoke)")), + CExpressionStatement(CodeExpression("Py_DECREF(capabilities)")), CExpressionStatement(CodeExpression("free(owner_descriptor)")), CReturn(CodeExpression("NULL")), ), @@ -4792,14 +4800,16 @@ def _default_native_array_binder_function( # it is published once and handed over under both names. CExpressionStatement( CodeExpression( - f'result = PyObject_CallFunction(helper, "OssiOOszOO", handle_obj, ' - f'"{handle.descriptor_kind.value}", "{dtype}", {handle.array.rank}, ops, owner_obj, ' + f'result = PyObject_CallFunction(helper, "OssiOOOszOO", handle_obj, ' + f'"{handle.descriptor_kind.value}", "{dtype}", {handle.array.rank}, ' + "invoke, capabilities, owner_obj, " f'"{default.descriptor_ownership.value}", {exposure}, Py_None, owner_obj)' ) ), CExpressionStatement(CodeExpression("Py_DECREF(helper)")), CExpressionStatement(CodeExpression("Py_DECREF(owner_obj)")), - CExpressionStatement(CodeExpression("Py_DECREF(ops)")), + CExpressionStatement(CodeExpression("Py_DECREF(invoke)")), + CExpressionStatement(CodeExpression("Py_DECREF(capabilities)")), CReturn(CodeExpression("result")), ) ) @@ -5239,15 +5249,14 @@ def _owned_native_array_resize_release_nodes( ), ) - def _owned_native_array_operation_name( + def _owned_native_array_dispatch_name( self, _function: FunctionPlan | None, result: ArgumentTransferPlan | ResultPlan, - operation: NativeArrayOperation, ) -> str: - """Return one stable private operation symbol.""" + """Return one stable private dispatcher symbol.""" owner = re.sub(r"\W", "_", result.owner_path).casefold() - return f"prik_owned_{owner}_{operation.value}" + return f"prik_owned_{owner}_dispatch" def _owned_native_array_bridge_operation_name( self, @@ -5259,15 +5268,6 @@ def _owned_native_array_bridge_operation_name( result.owner_path, f"native_array:owned:{operation.value}" ).symbol_name - def _owned_native_array_operation_def_name( - self, - function: FunctionPlan | None, - result: ArgumentTransferPlan | ResultPlan, - operation: NativeArrayOperation, - ) -> str: - """Return the private PyMethodDef symbol for one operation.""" - return f"{self._owned_native_array_operation_name(function, result, operation)}_def" - def _default_native_array_binder_name(self, argument: ArgumentTransferPlan) -> str: """Return one private lazy descriptor-attachment callable name.""" owner = re.sub(r"\W", "_", argument.owner_path).casefold() @@ -5582,13 +5582,14 @@ def _lower_module_getter_borrowed_array_view(self, plan: ModuleVariablePlan) -> ) def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> tuple[CFunction, ...]: - """Create one stable borrowed runtime handle from planned module operations.""" + """Create one stable borrowed runtime handle from its planned dispatcher.""" handle = plan.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Module native array handle {plan.owner_path!r} is incomplete") cache = self._module_native_array_cache_name(plan) owner = self._module_native_array_owner_name(plan) prefix = f"{cache}_build" + dispatch = self._module_native_array_dispatch_name(plan) nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ CIf( CodeExpression(f"{cache} != NULL"), @@ -5597,40 +5598,51 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> CReturn(CodeExpression(cache)), ), ), - CDeclaration(f"{prefix}_ops", "PyObject *", CodeExpression("PyDict_New()")), - CDeclaration(f"{prefix}_operation", "PyObject *", CodeExpression("NULL")), + CDeclaration( + f"{prefix}_capabilities", + "PyObject *", + CodeExpression("NULL"), + ), + CDeclaration( + f"{prefix}_invoke", + "PyObject *", + CodeExpression("NULL"), + ), CDeclaration(f"{prefix}_runtime", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")), *self._module_native_array_backend_declaration_nodes(plan, prefix), - CIf(CodeExpression(f"{prefix}_ops == NULL"), body=(CReturn(CodeExpression("NULL")),)), - ] - for operation in handle.operations: - definition = self._module_native_array_operation_def_name(plan, operation) - nodes.extend( + CExpressionStatement( + CodeExpression( + f"{prefix}_capabilities = {self._native_array_capabilities_expression(handle.operations)}" + ) + ), + CIf(CodeExpression(f"{prefix}_capabilities == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CExpressionStatement(CodeExpression(f"{prefix}_invoke = PyCFunction_NewEx(&{dispatch}_def, NULL, NULL)")), + CIf( + CodeExpression(f"{prefix}_invoke == NULL"), + body=( + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), + CReturn(CodeExpression("NULL")), + ), + ), + *( ( CExpressionStatement( - CodeExpression(f"{prefix}_operation = PyCFunction_NewEx(&{definition}, NULL, NULL)") - ), - CIf( - CodeExpression(f"{prefix}_operation == NULL"), - body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), - CReturn(CodeExpression("NULL")), - ), + CodeExpression(f"{prefix}_native_backend = {self._module_native_array_backend_capsule(plan)}") ), CIf( - CodeExpression( - f'PyDict_SetItemString({prefix}_ops, "{operation.value}", {prefix}_operation) < 0' - ), + CodeExpression(f"{prefix}_native_backend == NULL"), body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_operation)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), CReturn(CodeExpression("NULL")), ), ), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_operation)")), ) - ) + if self._uses_module_allocatable_descriptor(plan) + else () + ), + ] nodes.extend( ( CExpressionStatement( @@ -5639,21 +5651,25 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> CIf( CodeExpression(f"{prefix}_runtime == NULL"), body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), + *self._module_native_array_backend_release_nodes(plan, prefix), CReturn(CodeExpression("NULL")), ), ), CExpressionStatement( CodeExpression( f"{prefix}_helper = PyObject_GetAttrString({prefix}_runtime, " - '"_native_array_handle_from_generated_ops")' + '"_native_array_handle_from_generated_dispatch")' ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_runtime)")), CIf( CodeExpression(f"{prefix}_helper == NULL"), body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), + *self._module_native_array_backend_release_nodes(plan, prefix), CReturn(CodeExpression("NULL")), ), ), @@ -5666,16 +5682,18 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> semantic_type_name=plan.semantic_type_name, datatype_family=plan.datatype_family, rank=handle.array.rank, - ops=f"{prefix}_ops", + invoke=f"{prefix}_invoke", + capabilities=f"{prefix}_capabilities", owner=f"{owner} != NULL ? {owner} : Py_None", descriptor_ownership="borrowed", - native_ops=self._module_native_array_backend_capsule_name(plan, prefix), + native_backend=self._module_native_array_backend_capsule_name(plan, prefix), extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_helper)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), *self._module_native_array_backend_release_nodes(plan, prefix), CIf(CodeExpression(f"{cache} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement(CodeExpression(f"Py_INCREF({cache})")), @@ -6950,7 +6968,7 @@ def _array_actual_table_nodes( CExpressionStatement(CodeExpression(f"{found}.refused = 1")), CExpressionStatement(CodeExpression(f"{found}.width = 0")), CExpressionStatement( - CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_backend")') ), CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), CIf( @@ -7242,7 +7260,7 @@ def _native_array_actual_table_nodes( CExpressionStatement(CodeExpression(f"{found}.width = 0")), CExpressionStatement(CodeExpression(f"{prefix}_actual.data = NULL")), CExpressionStatement( - CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_backend")') ), CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), CIf( @@ -7826,7 +7844,7 @@ def _lower_argument_native_array_handle( return self._lower_argument_native_array_direct(plan, context) raise ValueError(f"Unsupported C native descriptor ABI for {plan.owner_path!r}: {handle.handoff.abi!r}") - def _inverted_descriptor_table_nodes( + def _inverted_descriptor_backend_nodes( self, plan: ArgumentTransferPlan, names: _CArgumentNames, @@ -7835,38 +7853,36 @@ def _inverted_descriptor_table_nodes( ) -> tuple: """Reach this argument's descriptor by whichever route its handle offers. - A handle standing for a module array or a field publishes a table, and + A handle standing for a module array or a field publishes a backend, and the descriptor it names is built inside the consumer that makes the call -- the only place a callee can change what the dummy stands for (an allocatable's allocation, a pointer's association) and have that - reach the caller's entity. A handle that owns its descriptor publishes - no table and needs none: the descriptor it already holds is handed to - the same consumer directly. + reach the caller's entity. An unattached contract handle instead takes + the fallback that first gives it persistent descriptor storage. """ prefix = names.value_name - capsule = f"{prefix}_ops_capsule" - table = f"{prefix}_native_ops" + capsule = f"{prefix}_backend_capsule" + backend = f"{prefix}_borrowed_backend" # Presence is otherwise decided by the packing helper, which only the - # other branch calls. A handle that published a table was supplied, so + # other branch calls. A handle that published a backend was supplied, so # an optional argument reaching this branch is present. present = ( ( CComment("This handle was supplied, so an optional argument is present."), - CExpressionStatement(CodeExpression(f"{names.present_name} = {table}")), + CExpressionStatement(CodeExpression(f"{names.present_name} = {backend}")), ) if plan.entrypoint.pass_descriptor_presence else () ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_backend *", CodeExpression("NULL")), + CDeclaration(backend, "prik_native_array_backend *", CodeExpression("NULL")), CComment( f"'{plan.binding.python_name}' may have its {_descriptor_binding_noun(handle)} changed by the callee." ), - CComment("A handle that publishes native entry points builds its descriptor inside"), - CComment("the consumer; one that owns a descriptor already hands that over instead."), + CComment("The backend supplies a live descriptor inside the consumer."), CExpressionStatement( - CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_ops")') + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_backend")') ), CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), CIf( @@ -7874,7 +7890,7 @@ def _inverted_descriptor_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_backend_for_descriptor({capsule}, " + f"{backend} = prik_native_array_backend_for_descriptor({capsule}, " f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), " f"{self._native_array_cfi_type(plan)}, " @@ -7882,11 +7898,11 @@ def _inverted_descriptor_table_nodes( ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), - CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CIf(CodeExpression(f"{backend} == NULL"), body=(CReturn(CodeExpression("NULL")),)), *present, ), else_body=( - CComment("No table: this handle owns the descriptor it will hand over."), + CComment("No backend yet: attach storage to a fresh contract handle."), CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), *fallback, ), @@ -7948,7 +7964,7 @@ def _lower_argument_native_array_direct( general.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) general.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) if inverted: - nodes.extend(self._inverted_descriptor_table_nodes(plan, names, handle, tuple(general))) + nodes.extend(self._inverted_descriptor_backend_nodes(plan, names, handle, tuple(general))) else: nodes.extend(general) return tuple(nodes) @@ -8387,26 +8403,35 @@ def _native_array_handle_factory_call( semantic_type_name: str, datatype_family: DatatypeFamily, rank: int, - ops: str, + invoke: str, + capabilities: str, owner: str, descriptor_ownership: str, - native_ops: str, + native_backend: str, extraction_action: str, ) -> str: """Call the runtime factory with a fixed dtype or deferred character dtype.""" dtype = self._native_array_dtype_for_semantic_type(semantic_type_name, datatype_family) if dtype is None: return ( - f'{target} = PyObject_CallFunction({helper}, "sOiOOssOO", "{descriptor_kind}", Py_None, ' - f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' - f"{native_ops}, Py_None)" + f'{target} = PyObject_CallFunction({helper}, "sOiOOOssOO", "{descriptor_kind}", Py_None, ' + f'{rank}, {invoke}, {capabilities}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' + f"{native_backend}, Py_None)" ) return ( - f'{target} = PyObject_CallFunction({helper}, "ssiOOssOO", "{descriptor_kind}", "{dtype}", ' - f'{rank}, {ops}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' - f"{native_ops}, Py_None)" + f'{target} = PyObject_CallFunction({helper}, "ssiOOOssOO", "{descriptor_kind}", "{dtype}", ' + f'{rank}, {invoke}, {capabilities}, {owner}, "{descriptor_ownership}", "{extraction_action}", ' + f"{native_backend}, Py_None)" ) + @staticmethod + def _native_array_capabilities_expression(operations: tuple[NativeArrayOperation, ...]) -> str: + """Build one immutable Python tuple from completed handle operations.""" + if not operations: + return "PyTuple_New(0)" + values = ", ".join(f'"{operation.value}"' for operation in operations) + return f'Py_BuildValue("({"s" * len(operations)})", {values})' + def _lower_argument_nullable_value( self, plan: ArgumentTransferPlan, @@ -8695,13 +8720,14 @@ def _lower_result_owned_native_array_handle( if python_name is None or handle is None or handle.array.rank is None: raise ValueError(f"Owned native array result {plan.owner_path!r} has no binding consumer") prefix = f"{descriptor_name}_handle" + dispatch = self._owned_native_array_dispatch_name(None, plan) cleanup = self._owned_descriptor_failure_cleanup(plan, descriptor_name) nodes: list[CDeclaration | CExpressionStatement | CIf] = [ CDeclaration(f"{prefix}_runtime", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_helper", "PyObject *", CodeExpression("NULL")), - CDeclaration(f"{prefix}_ops", "PyObject *", CodeExpression("NULL")), + CDeclaration(f"{prefix}_capabilities", "PyObject *", CodeExpression("NULL")), + CDeclaration(f"{prefix}_invoke", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{prefix}_owner", "PyObject *", CodeExpression("NULL")), - CDeclaration(f"{prefix}_operation", "PyObject *", CodeExpression("NULL")), CDeclaration(python_name, "PyObject *", CodeExpression("NULL")), *self._owned_pointer_result_normalization_nodes( plan, @@ -8710,9 +8736,13 @@ def _lower_result_owned_native_array_handle( failure_cleanup, pending_native_cleanup, ), - CExpressionStatement(CodeExpression(f"{prefix}_ops = PyDict_New()")), + CExpressionStatement( + CodeExpression( + f"{prefix}_capabilities = {self._native_array_capabilities_expression(handle.operations)}" + ) + ), CIf( - CodeExpression(f"{prefix}_ops == NULL"), + CodeExpression(f"{prefix}_capabilities == NULL"), body=( *cleanup, *pending_native_cleanup, @@ -8720,16 +8750,18 @@ def _lower_result_owned_native_array_handle( CReturn(CodeExpression("NULL")), ), ), + CExpressionStatement(CodeExpression(f"{prefix}_invoke = PyCFunction_NewEx(&{dispatch}_def, NULL, NULL)")), + CIf( + CodeExpression(f"{prefix}_invoke == NULL"), + body=( + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), + *cleanup, + *pending_native_cleanup, + *self._decref_names(failure_cleanup), + CReturn(CodeExpression("NULL")), + ), + ), ] - nodes.extend( - self._owned_native_array_ops_dictionary_nodes( - plan, - prefix, - cleanup, - failure_cleanup, - pending_native_cleanup, - ) - ) nodes.extend( ( CExpressionStatement( @@ -8740,7 +8772,8 @@ def _lower_result_owned_native_array_handle( CIf( CodeExpression(f"{prefix}_owner == NULL"), body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), *cleanup, *pending_native_cleanup, *self._decref_names(failure_cleanup), @@ -8755,7 +8788,8 @@ def _lower_result_owned_native_array_handle( CodeExpression(f"{prefix}_runtime == NULL"), body=( CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_owner)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), *cleanup, *pending_native_cleanup, *self._decref_names(failure_cleanup), @@ -8765,7 +8799,7 @@ def _lower_result_owned_native_array_handle( CExpressionStatement( CodeExpression( f"{prefix}_helper = PyObject_GetAttrString({prefix}_runtime, " - '"_native_array_handle_from_generated_ops")' + '"_native_array_handle_from_generated_dispatch")' ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_runtime)")), @@ -8773,7 +8807,8 @@ def _lower_result_owned_native_array_handle( CodeExpression(f"{prefix}_helper == NULL"), body=( CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_owner)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), *cleanup, *pending_native_cleanup, *self._decref_names(failure_cleanup), @@ -8789,17 +8824,19 @@ def _lower_result_owned_native_array_handle( semantic_type_name=plan.semantic_type_name, datatype_family=plan.datatype_family, rank=handle.array.rank, - ops=f"{prefix}_ops", + invoke=f"{prefix}_invoke", + capabilities=f"{prefix}_capabilities", owner=f"{prefix}_owner", descriptor_ownership="owned", - native_ops=f"{prefix}_owner", + native_backend=f"{prefix}_owner", extraction_action=handle.extraction_action.value, ) ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_helper)")), CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_owner)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_invoke)")), + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_capabilities)")), CIf( CodeExpression(f"{python_name} == NULL"), body=( @@ -8866,54 +8903,6 @@ def _owned_pointer_result_normalization_nodes( ), ) - def _owned_native_array_ops_dictionary_nodes( - self, - result: ResultPlan, - prefix: str, - cleanup: tuple[CExpressionStatement | CIf, ...], - failure_cleanup: tuple[str, ...], - pending_native_cleanup: tuple[CExpressionStatement, ...], - ) -> tuple[CExpressionStatement | CIf, ...]: - """Populate a result handle's operation dictionary from planned roles.""" - handle = result.native_array_handle - if handle is None: - return () - nodes = [] - for operation in handle.operations: - definition = self._owned_native_array_operation_def_name(None, result, operation) - nodes.extend( - ( - CExpressionStatement( - CodeExpression(f"{prefix}_operation = PyCFunction_NewEx(&{definition}, NULL, NULL)") - ), - CIf( - CodeExpression(f"{prefix}_operation == NULL"), - body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), - *cleanup, - *pending_native_cleanup, - *self._decref_names(failure_cleanup), - CReturn(CodeExpression("NULL")), - ), - ), - CIf( - CodeExpression( - f'PyDict_SetItemString({prefix}_ops, "{operation.value}", {prefix}_operation) < 0' - ), - body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_operation)")), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_ops)")), - *cleanup, - *pending_native_cleanup, - *self._decref_names(failure_cleanup), - CReturn(CodeExpression("NULL")), - ), - ), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_operation)")), - ) - ) - return tuple(nodes) - # Ordinary-array result lowering. def _lower_result_array_copy( self, @@ -9456,7 +9445,7 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) if context.inverted_descriptor is None: return self._lower_native_call(plan, self._entrypoint_call_statement(plan, context)) names = context.arguments[context.inverted_descriptor] - table = f"{names.value_name}_native_ops" + backend = f"{names.value_name}_borrowed_backend" consumer = self._inverted_consumer_name(plan) record = self._inverted_context_name(plan) fields = self._inverted_context_fields(plan, context) @@ -9472,10 +9461,10 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) CComment("what the callee writes into it is what Fortran copies back to the"), CComment("caller's entity when the bridge returns."), CIf( - CodeExpression(f"{table} != NULL"), + CodeExpression(f"{backend} != NULL"), body=( CExpressionStatement( - CodeExpression(f"{table}->with_descriptor({table}->context, {consumer}, &call_context)") + CodeExpression(f"{backend}->with_descriptor({backend}->context, {consumer}, &call_context)") ), ), else_body=( diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 3d52e2aa8..983b9d97e 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -5945,7 +5945,9 @@ def _scalar_descriptor_copy_nodes( copy_body = ( FortranAssignment( name, - CodeExpression(f"c_malloc(max(1_c_size_t, c_sizeof({value_name})))"), + CodeExpression( + f"c_malloc(max(1_c_size_t, storage_size({value_name}, kind=c_size_t) / 8_c_size_t))" + ), ), FortranIf( CodeExpression(f"c_associated({name})"), diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 749b99e6c..f111ebbc1 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -4,14 +4,14 @@ import ctypes import operator -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from typing import Any import numpy as np -HandleOperation = Callable[..., Any] +HandleDispatcher = Callable[[str, tuple[Any, ...]], Any] _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT = ctypes.c_int(1) _PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT_ADDRESS = ctypes.addressof(_PRESENT_NATIVE_ARRAY_DESCRIPTOR_ARGUMENT) @@ -99,34 +99,26 @@ def _numpy_view_from_descriptor_facts(facts: tuple[int, ...], dtype: Any) -> np. return np.ndarray(shape, dtype=array_dtype, buffer=buffer, strides=strides, offset=view_offset) -def _native_array_handle_from_generated_ops( +def _native_array_handle_from_generated_dispatch( descriptor_kind: str, dtype: Any, rank: int, - ops: Mapping[str, HandleOperation], + invoke: HandleDispatcher, + capabilities: Iterable[str], owner: Any = None, descriptor_ownership: str = "borrowed", to_numpy_policy: str = "borrowed_view", - native_ops: Any = None, + native_backend: Any = None, generation: int | None = None, ) -> NativeArrayHandleBase: - """Build a runtime handle from generated operation callables. + """Build a runtime handle from one generated operation dispatcher. - ``native_ops`` is the capsule publishing the entity's native backend, which - a binding reads directly to reach the descriptor. It is carried, not - required: a handle created from a contract has none until it is given - storage. + ``native_backend`` is the capsule publishing the entity's native backend, + which a binding reads directly to reach the descriptor. It is carried, not + required: a handle created from a contract has none until it is given storage. """ owned = descriptor_ownership == "owned" - normalized_ops = {} - for name, operation in ops.items(): - if name in {"allocate", "resize"}: - normalized = _generated_shape_operation(operation, owner=owner if owned else None) - elif owned: - normalized = _generated_owned_handle_operation(operation, owner) - else: - normalized = _generated_handle_operation(operation) - normalized_ops[name] = normalized + normalized_capabilities = frozenset(capabilities) try: handle_cls = { "allocatable": AllocatableArray, @@ -138,18 +130,19 @@ def _native_array_handle_from_generated_ops( handle = handle_cls( dtype=dtype, rank=rank, - ops=normalized_ops, + invoke=invoke, + capabilities=normalized_capabilities, owner=owner, descriptor_ownership=descriptor_ownership, to_numpy_policy=to_numpy_policy, generation=generation, ) - handle._native_ops = native_ops + handle._native_backend = native_backend return handle except BaseException: - if owned and "destroy" in normalized_ops: + if owned and "destroy" in normalized_capabilities: with suppress(Exception): - normalized_ops["destroy"](None) + invoke("destroy", (owner,) if owner is not None else ()) raise @@ -167,49 +160,52 @@ def _native_array_handle_from_contract( """ state: dict[str, Any] = {"facts": _empty_descriptor_facts(dtype, rank), "source": None} - def current_shape(_handle: NativeArrayHandleBase) -> tuple[int, ...] | None: + def current_shape() -> tuple[int, ...] | None: if state["facts"][0] == 0: return None shape, _strides = _descriptor_facts_shape_and_strides(state["facts"]) return shape - def descriptor(_handle: NativeArrayHandleBase) -> tuple[int, ...]: + def descriptor() -> tuple[int, ...]: return state["facts"] - def present(_handle: NativeArrayHandleBase) -> bool: + def present() -> bool: return state["facts"][0] != 0 - def current_view(_handle: NativeArrayHandleBase) -> np.ndarray | None: + def current_view() -> np.ndarray | None: return _numpy_view_from_descriptor_facts(state["facts"], dtype) - def clear(_handle: NativeArrayHandleBase) -> None: + def clear() -> None: state["facts"] = _empty_descriptor_facts(dtype, rank) state["source"] = None def associate_facts( - _handle: NativeArrayHandleBase, facts: tuple[int, ...], source: NativeArrayHandleBase, ) -> None: state["facts"] = facts state["source"] = source - common_ops = { + operations = { "shape": current_shape, "descriptor": descriptor, "to_numpy": current_view, "destroy": clear, + "allocated": present, + "associated": present, + "nullify": clear, + "_associate_facts": associate_facts, } + + def dispatch(operation: str, args: tuple[Any, ...]) -> Any: + return operations[operation](*args) + try: - handle_cls, descriptor_ops = { - "allocatable": (AllocatableArray, {"allocated": present}), + handle_cls, capabilities = { + "allocatable": (AllocatableArray, {"allocated", "shape", "descriptor", "to_numpy", "destroy"}), "pointer": ( PointerArray, - { - "associated": present, - "nullify": clear, - "_associate_facts": associate_facts, - }, + {"associated", "shape", "descriptor", "to_numpy", "destroy", "nullify", "_associate_facts"}, ), }[descriptor_kind] except KeyError: @@ -217,7 +213,8 @@ def associate_facts( handle = handle_cls( dtype=dtype, rank=rank, - ops={**common_ops, **descriptor_ops}, + invoke=dispatch, + capabilities=capabilities, descriptor_ownership="owned", to_numpy_policy="borrowed_view", ) @@ -230,12 +227,13 @@ def _bind_contract_native_array_handle( descriptor_kind: str, dtype: Any, rank: int, - ops: Mapping[str, HandleOperation], + invoke: HandleDispatcher, + capabilities: Iterable[str], owner: Any, descriptor_ownership: str, to_numpy_policy: str | None, generation: int | None = None, - native_ops: Any = None, + native_backend: Any = None, ) -> None: """Attach generated persistent descriptor storage to a contract handle. @@ -244,8 +242,8 @@ def _bind_contract_native_array_handle( to hand over, but it does not define what the handle exposes, so the handle keeps the exposure it was created with. - ``native_ops`` is the backend over the attached storage, which every later - call reads directly from C. + ``native_backend`` is the backend over the attached storage, which every + later call reads directly from C. """ if not isinstance(handle, NativeArrayHandleBase) or not handle._contract_default: raise TypeError("generated descriptor storage can attach only to a fresh contract handle") @@ -259,18 +257,21 @@ def _bind_contract_native_array_handle( raise TypeError(f"{descriptor_kind} handle dtype {handle.dtype!r} does not match generated dtype {dtype!r}") # An association taken before there was anywhere native to record it is # replayed onto the storage that just arrived. - pending = handle._call_op("descriptor") if isinstance(handle, PointerArray) and handle.associated else None - generated = _native_array_handle_from_generated_ops( + pending = handle._call_operation("descriptor") if isinstance(handle, PointerArray) and handle.associated else None + generated = _native_array_handle_from_generated_dispatch( descriptor_kind, dtype, rank, - ops, + invoke, + capabilities, owner=owner, descriptor_ownership=descriptor_ownership, to_numpy_policy=handle._to_numpy_policy if to_numpy_policy is None else to_numpy_policy, + native_backend=native_backend, generation=generation, ) - handle._ops = generated._ops + handle._invoke = generated._invoke + handle._capabilities = generated._capabilities handle._owner = generated._owner handle._descriptor_ownership = generated._descriptor_ownership handle._to_numpy_policy = generated._to_numpy_policy @@ -278,39 +279,11 @@ def _bind_contract_native_array_handle( # The storage just attached is the wrapper's own and lives as long as the # handle, so the handle can publish it the way a module array publishes # its entity. Later calls then reach it from C without coming back here. - handle._native_ops = native_ops + handle._native_backend = generated._native_backend handle._contract_default = False generated._closed = True if pending is not None: - handle._call_op("associate", pending) - - -def _generated_handle_operation(operation: HandleOperation) -> HandleOperation: - """Adapt a generated operation callable to the handle operation protocol.""" - - def call(_handle: NativeArrayHandleBase, *args: Any) -> Any: - return operation(*args) - - return call - - -def _generated_owned_handle_operation(operation: HandleOperation, owner: Any) -> HandleOperation: - """Adapt an operation whose first argument is persistent native owner storage.""" - - def call(_handle: NativeArrayHandleBase, *args: Any) -> Any: - return operation(owner, *args) - - return call - - -def _generated_shape_operation(operation: HandleOperation, *, owner: Any = None) -> HandleOperation: - """Adapt generated shape operations from one runtime shape tuple to scalar extents.""" - - def call(_handle: NativeArrayHandleBase, shape: Sequence[int]) -> Any: - extents = tuple(np.int64(extent) for extent in shape) - return operation(*extents) if owner is None else operation(owner, *extents) - - return call + handle._call_operation("associate", pending) def _descriptor_view_buffer_window( @@ -329,9 +302,9 @@ def _descriptor_view_buffer_window( class NativeArrayHandleBase: - """Shared runtime state and operation dispatch for native array handles.""" + """Shared runtime state and single-call dispatch for native array handles.""" - _REQUIRED_DESCRIPTOR_OPS: frozenset[str] = frozenset() + _REQUIRED_CAPABILITIES: frozenset[str] = frozenset() _VALID_DESCRIPTOR_KINDS = frozenset({"allocatable", "pointer"}) _VALID_DESCRIPTOR_OWNERSHIP = frozenset({"borrowed", "owned"}) _VALID_TO_NUMPY_POLICIES = frozenset( @@ -348,7 +321,8 @@ def __init__( *, dtype: Any, rank: int, - ops: Mapping[str, HandleOperation], + invoke: HandleDispatcher, + capabilities: Iterable[str], owner: Any = None, descriptor_kind: str, descriptor_ownership: str, @@ -368,19 +342,19 @@ def __init__( ) self._dtype = None if dtype is None else np.dtype(dtype) self._rank = int(rank) - self._ops = self._normalize_ops(ops) + if not callable(invoke): + raise TypeError(f"native array handle dispatcher must be callable; received {type(invoke).__name__}") + self._invoke: HandleDispatcher | None = invoke + self._capabilities = self._normalize_capabilities(capabilities) self._owner = owner self._descriptor_kind = descriptor_kind self._descriptor_ownership = descriptor_ownership self._to_numpy_policy = to_numpy_policy self._generation = generation - # Holds the most recent borrowed descriptor copy so it outlives the call - # that reads it; see _generated_borrowed_descriptor_operation. - self._borrowed_descriptor: Any = None - # Optional capsule publishing this entity's native entry points. - self._native_ops: Any = None + # Optional capsule publishing this entity's native descriptor backend. + self._native_backend: Any = None self._contract_default = False - self._validate_required_ops() + self._validate_required_capabilities() self._closed = False @property @@ -391,7 +365,7 @@ def dtype(self) -> np.dtype: def _deferred_character_dtype(self) -> np.dtype: """Resolve one deferred character width from generated native state.""" - length = operator.index(self._call_op("element_length")) + length = operator.index(self._call_operation("element_length")) if length < 0: raise ValueError("native character array element length must be non-negative") return np.dtype(f"S{length}") @@ -408,7 +382,7 @@ def shape(self) -> tuple[int, ...] | None: than being asked about it first, and the extents it returns are the ones the compiler recorded. """ - shape = self._call_op("shape") + shape = self._call_operation("shape") if shape is None: return None normalized = self._normalize_shape(shape) @@ -454,13 +428,17 @@ def close(self) -> Any: """Release generated owner storage for an owned native descriptor handle.""" if self.closed or not self.owned: return None - operation = self._ops["destroy"] + invoke = self._invoke + if invoke is None: + return None try: - return operation(self) + return self._call_operation("destroy") finally: self._closed = True self._owner = None - self._ops = {} + self._native_backend = None + self._invoke = None + self._capabilities = frozenset() def __del__(self) -> None: with suppress(Exception): @@ -482,7 +460,7 @@ def to_numpy(self) -> Any: raise NotImplementedError( f"{self.descriptor_kind} handle to_numpy extraction is unsupported by completed policy" ) - value = self._call_op("to_numpy") + value = self._call_operation("to_numpy") if value is None: return None self._validate_numpy_result(value) @@ -495,14 +473,19 @@ def to_numpy(self) -> Any: self._validate_contiguous_numpy_result(value) return value - def _call_op(self, name: str, *args: Any) -> Any: + def _call_operation(self, name: str, *args: Any) -> Any: if self.closed: raise ReferenceError(f"{self.descriptor_kind} handle is closed") - try: - operation = self._ops[name] - except KeyError: + if name not in self._capabilities: raise NotImplementedError(f"{self.descriptor_kind} handle operation {name!r} is not available") from None - return operation(self, *args) + invoke = self._invoke + if invoke is None: + raise ReferenceError(f"{self.descriptor_kind} handle is closed") + if name in {"allocate", "resize"}: + args = tuple(np.int64(extent) for extent in args[0]) + if self.owned and self._owner is not None: + args = (self._owner, *args) + return invoke(name, args) def _validate_numpy_result(self, value: Any) -> None: if not isinstance(value, np.ndarray): @@ -535,30 +518,26 @@ def _dtype_matches(self, expected_dtype: Any) -> bool: except TypeError: return self.dtype == expected_dtype - def _validate_required_ops(self) -> None: - if "shape" not in self._ops: + def _validate_required_capabilities(self) -> None: + if "shape" not in self._capabilities: raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation 'shape'") - for name in sorted(self._REQUIRED_DESCRIPTOR_OPS): - if name not in self._ops: + for name in sorted(self._REQUIRED_CAPABILITIES): + if name not in self._capabilities: raise ValueError(f"{self.descriptor_kind} native array handle requires generated operation {name!r}") - if self.to_numpy_policy != "unsupported" and "to_numpy" not in self._ops: + if self.to_numpy_policy != "unsupported" and "to_numpy" not in self._capabilities: raise ValueError( f"{self.descriptor_kind} native array handle with to_numpy_policy " f"{self.to_numpy_policy!r} requires generated operation 'to_numpy'" ) - if self.owned and "destroy" not in self._ops: + if self.owned and "destroy" not in self._capabilities: raise ValueError(f"{self.descriptor_kind} owned native array handle requires generated operation 'destroy'") @staticmethod - def _normalize_ops(ops: Mapping[str, HandleOperation]) -> dict[str, HandleOperation]: - normalized = dict(ops) - for name, operation in normalized.items(): + def _normalize_capabilities(capabilities: Iterable[str]) -> frozenset[str]: + normalized = frozenset(capabilities) + for name in normalized: if not isinstance(name, str): - raise TypeError(f"native array handle operation names must be strings; received {type(name).__name__}") - if not callable(operation): - raise TypeError( - f"native array handle operation {name!r} must be callable; received {type(operation).__name__}" - ) + raise TypeError(f"native array handle capability names must be strings; received {type(name).__name__}") return normalized @staticmethod @@ -576,14 +555,15 @@ def _normalize_shape(shape: Sequence[int] | int) -> tuple[int, ...]: class AllocatableArray(NativeArrayHandleBase): """Runtime handle for a native allocatable array descriptor.""" - _REQUIRED_DESCRIPTOR_OPS = frozenset({"allocated"}) + _REQUIRED_CAPABILITIES = frozenset({"allocated"}) def __init__( self, *, dtype: Any, rank: int, - ops: Mapping[str, HandleOperation], + invoke: HandleDispatcher, + capabilities: Iterable[str], owner: Any = None, descriptor_ownership: str = "borrowed", to_numpy_policy: str = "borrowed_view", @@ -592,7 +572,8 @@ def __init__( super().__init__( dtype=dtype, rank=rank, - ops=ops, + invoke=invoke, + capabilities=capabilities, owner=owner, descriptor_kind="allocatable", descriptor_ownership=descriptor_ownership, @@ -602,29 +583,30 @@ def __init__( @property def allocated(self) -> bool: - return bool(self._call_op("allocated")) + return bool(self._call_operation("allocated")) def _present(self) -> bool: return self.allocated def deallocate(self) -> Any: - return self._call_op("deallocate") + return self._call_operation("deallocate") def resize(self, shape: Sequence[int] | int) -> Any: - return self._call_op("resize", self._normalize_shape(shape)) + return self._call_operation("resize", self._normalize_shape(shape)) class PointerArray(NativeArrayHandleBase): """Runtime handle for a native pointer array descriptor.""" - _REQUIRED_DESCRIPTOR_OPS = frozenset({"associated", "nullify"}) + _REQUIRED_CAPABILITIES = frozenset({"associated", "nullify"}) def __init__( self, *, dtype: Any, rank: int, - ops: Mapping[str, HandleOperation], + invoke: HandleDispatcher, + capabilities: Iterable[str], owner: Any = None, descriptor_ownership: str = "borrowed", to_numpy_policy: str = "borrowed_view", @@ -633,7 +615,8 @@ def __init__( super().__init__( dtype=dtype, rank=rank, - ops=ops, + invoke=invoke, + capabilities=capabilities, owner=owner, descriptor_kind="pointer", descriptor_ownership=descriptor_ownership, @@ -643,7 +626,7 @@ def __init__( @property def associated(self) -> bool: - return bool(self._call_op("associated")) + return bool(self._call_operation("associated")) def _present(self) -> bool: return self.associated @@ -655,7 +638,7 @@ def _association_facts(self) -> tuple[int, ...]: not make the target follow the source afterwards. Reading the facts here is what makes that snapshot. """ - facts = _descriptor_facts(self._call_op("descriptor"), self.rank) + facts = _descriptor_facts(self._call_operation("descriptor"), self.rank) itemsize = np.dtype(self.dtype).itemsize if facts[0] != 0 and facts[1] != itemsize: raise ValueError(f"pointer handle element width {facts[1]} does not match NumPy dtype itemsize {itemsize}") @@ -677,20 +660,20 @@ def associate(self, other: PointerArray) -> Any: if self._contract_default: # No native storage yet: keep the association, and the handle it # came from, until storage arrives and it can be replayed. - return self._call_op("_associate_facts", facts, other) - return self._call_op("associate", facts) + return self._call_operation("_associate_facts", facts, other) + return self._call_operation("associate", facts) def nullify(self) -> Any: - return self._call_op("nullify") + return self._call_operation("nullify") def allocate(self, shape: Sequence[int] | int) -> Any: - return self._call_op("allocate", self._normalize_shape(shape)) + return self._call_operation("allocate", self._normalize_shape(shape)) def deallocate(self) -> Any: - return self._call_op("deallocate") + return self._call_operation("deallocate") def resize(self, shape: Sequence[int] | int) -> Any: - return self._call_op("resize", self._normalize_shape(shape)) + return self._call_operation("resize", self._normalize_shape(shape)) def _native_array_backend_for_binding( @@ -700,7 +683,7 @@ def _native_array_backend_for_binding( expected_dtype: Any = None, expected_rank: int | None = None, optional_absent: bool = False, - bind_default: HandleOperation | None = None, + bind_default: Callable[..., Any] | None = None, ) -> tuple[Any | None, ...]: """Return the backend capsule a descriptor argument hands over. @@ -736,7 +719,7 @@ def _native_array_backend_for_binding( f"writable {descriptor_kind} contract handle requires generated persistent descriptor storage" ) bind_default(value) - backend = value._native_ops + backend = value._native_backend if backend is None: raise TypeError( f"writable {descriptor_kind} descriptor argument requires generated persistent descriptor storage" @@ -752,7 +735,7 @@ def _native_array_backend_for_binding_positional( expected_dtype: Any = None, expected_rank: int | None = None, optional_absent: bool = False, - bind_default: HandleOperation | None = None, + bind_default: Callable[..., Any] | None = None, ) -> tuple[Any | None, ...]: """Positional wrapper used by projected-handle CPython binding code.""" return _native_array_backend_for_binding( @@ -773,23 +756,28 @@ def _native_array_backend_for_binding_positional( if __name__ == "__main__": - # Generated extensions supply small operation dictionaries like this one. - # The adapter turns their raw call signatures into the stable handle API. + # Generated extensions supply one dispatcher and its completed capabilities. state = {"array": np.array([1.0, 2.0, 3.0], dtype=np.float64)} def resize(*extents: np.int64) -> None: state["array"] = np.zeros(tuple(int(extent) for extent in extents), dtype=np.float64) - array = _native_array_handle_from_generated_ops( + operations = { + "allocated": lambda: True, + "shape": lambda: state["array"].shape, + "to_numpy": lambda: state["array"], + "resize": resize, + } + + def invoke(operation: str, args: tuple[Any, ...]) -> Any: + return operations[operation](*args) + + array = _native_array_handle_from_generated_dispatch( "allocatable", np.float64, 1, - { - "allocated": lambda: True, - "shape": lambda: state["array"].shape, - "to_numpy": lambda: state["array"], - "resize": resize, - }, + invoke, + operations, ) print(f"Runtime handle: {type(array).__name__}") diff --git a/tests/fortran/_support/native_array_handles.py b/tests/fortran/_support/native_array_handles.py index 2ca3866fa..df4f158cf 100644 --- a/tests/fortran/_support/native_array_handles.py +++ b/tests/fortran/_support/native_array_handles.py @@ -21,6 +21,26 @@ def _common_ops(state: _ArrayState): } +def _handle_dispatch(operations): + """Adapt concise operation test doubles to the runtime dispatcher contract.""" + + def invoke(operation, args): + if operation in {"allocate", "resize"}: + args = (args,) + return operations[operation](None, *args) + + return {"invoke": invoke, "capabilities": operations} + + +def _generated_handle_dispatch(operations): + """Adapt generated-call-shaped test doubles to one dispatcher callable.""" + + def invoke(operation, args): + return operations[operation](*args) + + return invoke + + def _descriptor_facts_for_array(value: np.ndarray, *, lower_bound: int = 1): """Return the flat facts a generated pointer reports for one array. diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index 55f1ca364..d1d3d9a3f 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -50,7 +50,10 @@ def test_plain_module_allocatable_uses_standard_descriptor_callback_without_copy # supplies; nothing copies the descriptor out to be read in Python. assert "prik_native_array_read_shape(void * descriptor, void * context)" in c_source assert "source->base_addr" in c_source - assert "Py_BuildValue" not in c_source + # The capability tuple is the only Python object the module builds; no + # descriptor field is packed into Python values for the handle to read back. + built = [line.strip() for line in c_source.splitlines() if "Py_BuildValue" in line] + assert built and all("build_capabilities" in line for line in built) assert "subroutine bind_c_plain_allocatable_descriptor(" in bridge_source assert 'bind(c, name="bind_c_plain_allocatable_descriptor")' in bridge_source assert "type(c_funptr), value :: callback_address" in bridge_source diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 17cd49d7d..63bb07047 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -11,6 +11,7 @@ from tests.fortran._support.wrapper_build import ( _build_source_or_generated_pyi_and_import, _build_text_and_import, + _compiler, _compile_native_object, _import_from_build_dir, _require_maybe_unallocated_function_result_support, @@ -93,6 +94,7 @@ def _plain_allocatable_module(build_mode: str, tmp_path: Path): native_object = _compile_native_object(source, tmp_path / "native") result = build_pyi_extension( contract_dir / "__init__.pyi", + input_compiler=_compiler(), native_objects=[native_object], native_include_dirs=[native_object.parent], output_dir=tmp_path / "pyi_build", diff --git a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py index 21ee51266..5e0600c72 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_contract_handles.py @@ -9,6 +9,7 @@ _bind_contract_native_array_handle, _native_array_backend_for_binding, ) +from tests.fortran._support.native_array_handles import _generated_handle_dispatch, _handle_dispatch def test_fresh_contract_handle_has_no_descriptor_to_hand_over_on_its_own(): @@ -67,10 +68,12 @@ def test_non_array_allocatable_annotations_are_not_factories(factory, message: s lambda: AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "allocated": lambda _handle: False, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "allocated": lambda _handle: False, + } + ), to_numpy_policy="unsupported", ), "float64", @@ -104,12 +107,14 @@ def test_generated_storage_rejects_incompatible_allocatable_contract_handles( handle = prepare() with pytest.raises(error, match=message): + operations = {} _bind_contract_native_array_handle( handle, "allocatable", dtype, rank, - {}, + _generated_handle_dispatch(operations), + operations, object(), "owned", "unsupported", @@ -128,20 +133,22 @@ def test_writable_contract_handle_adopts_generated_storage_and_closes_once(): owner = object() def bind_default(value): + operations = { + "shape": lambda received_owner: calls.append(("shape", received_owner)) or None, + "allocated": lambda received_owner: False, + "destroy": lambda received_owner: calls.append(("destroy", received_owner)), + } _bind_contract_native_array_handle( value, "allocatable", "float64", 1, - { - "shape": lambda received_owner: calls.append(("shape", received_owner)) or None, - "allocated": lambda received_owner: False, - "destroy": lambda received_owner: calls.append(("destroy", received_owner)), - }, + _generated_handle_dispatch(operations), + operations, owner, "owned", "unsupported", - native_ops=owner, + native_backend=owner, ) assert _native_array_backend_for_binding( @@ -163,12 +170,14 @@ def test_generated_storage_rejects_a_closed_contract_handle(): handle.close() with pytest.raises(ReferenceError, match="handle is closed"): + operations = {} _bind_contract_native_array_handle( handle, "allocatable", "float64", 1, - {}, + _generated_handle_dispatch(operations), + operations, object(), "owned", "unsupported", diff --git a/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py b/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py index 51b26d4d4..b58b89787 100644 --- a/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py +++ b/tests/fortran/allocatables/runtime/test_allocatable_handle_protocol.py @@ -9,6 +9,7 @@ from tests.fortran._support.native_array_handles import ( _ArrayState, _common_ops, + _handle_dispatch, ) @@ -25,7 +26,7 @@ def test_allocatable_handle_uses_common_metadata_shape_owner_and_numpy_dispatch( handle = AllocatableArray( dtype="float64", rank=2, - ops=ops, + **_handle_dispatch(ops), owner=owner, descriptor_ownership="borrowed", generation=7, @@ -55,11 +56,13 @@ def test_allocatable_extraction_reports_unallocated_state_as_no_view(): handle = AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "to_numpy": lambda _handle: None, - "allocated": lambda _handle: pytest.fail("extraction must not need the allocation state"), - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "to_numpy": lambda _handle: None, + "allocated": lambda _handle: pytest.fail("extraction must not need the allocation state"), + } + ), ) assert handle.to_numpy() is None @@ -73,7 +76,7 @@ def test_allocatable_handle_reports_absent_state_and_routes_resize_deallocate(): "deallocate": lambda _handle: setattr(state, "shape", None), "resize": lambda _handle, shape: setattr(state, "shape", shape), } - handle = AllocatableArray(dtype="float64", rank=1, ops=ops) + handle = AllocatableArray(dtype="float64", rank=1, **_handle_dispatch(ops)) assert handle.allocated is False assert handle.shape is None @@ -94,12 +97,14 @@ def test_allocatable_to_numpy_policy_returns_mutable_borrowed_view(): handle = AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={ - **_common_ops(state), - "allocated": lambda _handle: True, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, + **_handle_dispatch( + { + **_common_ops(state), + "allocated": lambda _handle: True, + "deallocate": lambda _handle: None, + "resize": lambda _handle, _shape: None, + } + ), to_numpy_policy="borrowed_view", ) @@ -117,12 +122,14 @@ def test_allocatable_to_numpy_explicit_copy_is_independent(): handle = AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={ - **_common_ops(state), - "allocated": lambda _handle: True, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, + **_handle_dispatch( + { + **_common_ops(state), + "allocated": lambda _handle: True, + "deallocate": lambda _handle: None, + "resize": lambda _handle, _shape: None, + } + ), to_numpy_policy="descriptor_view", ) @@ -141,21 +148,25 @@ def test_allocatable_handle_requires_generated_allocated_operation(): AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "to_numpy": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "to_numpy": lambda _handle: None, + } + ), ) -def test_allocatable_operations_are_gated_by_the_completed_ops_table(): +def test_allocatable_operations_are_gated_by_completed_capabilities(): handle = AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "allocated": lambda _handle: False, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "allocated": lambda _handle: False, + } + ), to_numpy_policy="unsupported", ) @@ -170,10 +181,12 @@ def test_close_is_a_noop_for_a_borrowed_allocatable_handle(): handle = AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "allocated": lambda _handle: False, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "allocated": lambda _handle: False, + } + ), owner=owner, descriptor_ownership="borrowed", to_numpy_policy="unsupported", diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index a0d9fd5e7..39138f9a5 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -3,6 +3,7 @@ from __future__ import annotations import gc +import sys from pathlib import Path import numpy as np @@ -159,6 +160,7 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): character(len=:), allocatable :: deferred_words(:) real(8), allocatable :: empty(:) real(8), allocatable :: spare(:) + real(8), allocatable :: probe(:) real(8), allocatable :: cube(:, :, :) real(8), target :: store(8) real(8), pointer :: reversed(:) => null() @@ -179,6 +181,7 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): allocate(character(len=6) :: deferred_words(2)) deferred_words = ['alphas', 'bravos'] allocate(empty(0)) + allocate(probe(3)); probe = 2.0_8 allocate(cube(2, 3, 4)); cube = 1.0_8 store = [(1.0_8 * i, i = 1, 8)] reversed => store(8:1:-1) @@ -389,3 +392,36 @@ def test_reallocating_through_a_dummy_updates_every_later_inquiry(descriptor_mat assert spare.to_numpy() is None with pytest.raises(ValueError, match="unallocated"): descriptor_matrix.assumed_total(spare) + + +def test_a_bound_handle_reaches_a_native_call_without_running_python(descriptor_matrix): + """Argument handoff costs no Python frame once the arguments are parsed. + + A handle publishes its backend to C, so the binding reads the capsule, + validates it against the dummy and enters the descriptor itself. Nothing on + that path imports the runtime, looks an operation up on the handle, or packs + descriptor fields into Python values for C to read back -- which is the + whole point of the capsule, and is only observable as the absence of a + Python call. + """ + ordinary = descriptor_matrix.ints + descriptor = descriptor_matrix.probe + int_total = descriptor_matrix.int_total + reshape_alloc = descriptor_matrix.reshape_alloc + assumed_total = descriptor_matrix.assumed_total + called: list[str] = [] + + def record(frame, event, _arg): + if event == "call": + called.append(f"{frame.f_code.co_filename}:{frame.f_code.co_name}") + + sys.setprofile(record) + try: + int_total(ordinary) + assumed_total(descriptor) + reshape_alloc(descriptor, np.int32(2)) + finally: + sys.setprofile(None) + + assert called == [] + assert descriptor.shape == (2,) diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index f79ffd04a..0c5fa4cea 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -367,7 +367,7 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): # fact-reporting one is gone: nothing rebuilds a descriptor in C. assert '"_native_array_descriptor_argument_for_binding_positional"' not in c_source assert '"_native_array_backend_for_binding_positional"' in c_source - assert '"_native_array_handle_from_generated_ops"' in c_source + assert '"_native_array_handle_from_generated_dispatch"' in c_source assert '"_bind_contract_native_array_handle"' in c_source assert "prik_native_array_backend_capsule_new(" in c_source assert "prik_native_array_backend_for_descriptor(" in c_source @@ -376,7 +376,7 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert "prik_native_array_backend_release(owner_backend)" in c_source assert ("bound_values_native_backend = prik_native_array_backend_for_descriptor(bound_values_item") in c_source assert "prik_bind_default_memory_handles_replace_values" in c_source - assert "prik_owned_memory_handles_replace_values_destroy" in c_source + assert "prik_owned_memory_handles_replace_values_dispatch" in c_source assert "bound_values_default_binder" in c_source assert "CFI_CDESC_T(1)" in c_source assert "CFI_CDESC_T(2)" in c_source @@ -422,23 +422,22 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): ) -def test_owned_descriptor_lifecycle_operations_do_not_materialize_descriptor_locals(): +def test_owned_descriptor_handles_publish_one_dispatcher_and_capability_tuple(): artifacts = WrapperGenerator().generate(_native_handle_plan()) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") - for operation in ("allocated", "shape", "to_numpy", "destroy"): - function = _generated_c_function( - c_source, - f"prik_owned_memory_handles_make_return_{operation}", - ) - assert "owner_backend" in function - assert "owner_descriptor" not in function - - deallocate = _generated_c_function( + dispatch = _generated_c_function( c_source, - "prik_owned_memory_handles_make_return_deallocate", + "prik_owned_memory_handles_make_return_dispatch", ) - assert "owner_descriptor" in deallocate + assert 'strcmp(operation, "allocated") == 0' in dispatch + assert 'strcmp(operation, "shape") == 0' in dispatch + assert 'strcmp(operation, "to_numpy") == 0' in dispatch + assert 'strcmp(operation, "destroy") == 0' in dispatch + assert "owner_backend" in dispatch + assert "owner_descriptor" in dispatch + assert 'Py_BuildValue("(ssssss)", "allocated", "deallocate", "destroy", "resize", "shape", "to_numpy")' in c_source + assert "PyDict_SetItemString" not in c_source @pytest.mark.parametrize( diff --git a/tests/fortran/memory_management/runtime/test_handle_lifecycle.py b/tests/fortran/memory_management/runtime/test_handle_lifecycle.py index cefab6edc..a444a17ef 100644 --- a/tests/fortran/memory_management/runtime/test_handle_lifecycle.py +++ b/tests/fortran/memory_management/runtime/test_handle_lifecycle.py @@ -7,15 +7,17 @@ AllocatableArray, PointerArray, _native_array_backend_for_binding, - _native_array_handle_from_generated_ops, + _native_array_handle_from_generated_dispatch, ) from tests.fortran._support.native_array_handles import ( _ArrayState, _common_ops, + _generated_handle_dispatch, + _handle_dispatch, ) -def test_generated_handle_factory_adapts_private_operations_to_runtime_protocol(): +def test_generated_handle_factory_adapts_one_dispatcher_to_runtime_protocol(): owner = object() value = np.arange(3, dtype=np.float64) calls = [] @@ -32,15 +34,17 @@ def to_numpy(): calls.append(("to_numpy", ())) return value - handle = _native_array_handle_from_generated_ops( + operations = { + "shape": shape, + "allocated": allocated, + "to_numpy": to_numpy, + } + handle = _native_array_handle_from_generated_dispatch( "allocatable", "float64", 1, - { - "shape": shape, - "allocated": allocated, - "to_numpy": to_numpy, - }, + _generated_handle_dispatch(operations), + operations, owner=owner, descriptor_ownership="borrowed", to_numpy_policy="borrowed_view", @@ -61,15 +65,17 @@ def to_numpy(): def test_generated_handle_factory_splats_shape_operations_to_scalar_extents(): calls = [] - handle = _native_array_handle_from_generated_ops( + operations = { + "shape": lambda: (2, 3), + "allocated": lambda: True, + "resize": lambda *extents: calls.append(("resize", extents)), + } + handle = _native_array_handle_from_generated_dispatch( "allocatable", "float64", 2, - { - "shape": lambda: (2, 3), - "allocated": lambda: True, - "resize": lambda *extents: calls.append(("resize", extents)), - }, + _generated_handle_dispatch(operations), + operations, to_numpy_policy="unsupported", ) @@ -90,20 +96,22 @@ def call(received_owner, *args): return call - handle = _native_array_handle_from_generated_ops( + operations = { + "shape": operation("shape", (3,)), + "allocated": operation("allocated", True), + "to_numpy": operation("to_numpy", value), + "resize": operation("resize"), + "destroy": operation("destroy"), + } + handle = _native_array_handle_from_generated_dispatch( "allocatable", "float64", 1, - { - "shape": operation("shape", (3,)), - "allocated": operation("allocated", True), - "to_numpy": operation("to_numpy", value), - "resize": operation("resize"), - "destroy": operation("destroy"), - }, + _generated_handle_dispatch(operations), + operations, owner=owner, descriptor_ownership="owned", - native_ops=owner, + native_backend=owner, ) assert handle.shape == (3,) @@ -132,16 +140,18 @@ def call(received_owner, *args): def test_generated_handle_resolves_deferred_character_dtype_from_runtime_element_length(): state = {"itemsize": 3} - handle = _native_array_handle_from_generated_ops( + operations = { + "shape": lambda: (2,), + "element_length": lambda: state["itemsize"], + "allocated": lambda: True, + "to_numpy": lambda: np.array([b"red", b"sky"], dtype=f"S{state['itemsize']}"), + } + handle = _native_array_handle_from_generated_dispatch( "allocatable", None, 1, - { - "shape": lambda: (2,), - "element_length": lambda: state["itemsize"], - "allocated": lambda: True, - "to_numpy": lambda: np.array([b"red", b"sky"], dtype=f"S{state['itemsize']}"), - }, + _generated_handle_dispatch(operations), + operations, ) assert handle.dtype == np.dtype("S3") @@ -157,14 +167,16 @@ def destroy(received_owner): calls.append(("destroy", received_owner)) with pytest.raises(ValueError, match="requires generated operation 'allocated'"): - _native_array_handle_from_generated_ops( + operations = { + "shape": lambda _owner: (1,), + "destroy": destroy, + } + _native_array_handle_from_generated_dispatch( "allocatable", "float64", 1, - { - "shape": lambda _owner: (1,), - "destroy": destroy, - }, + _generated_handle_dispatch(operations), + operations, owner=owner, descriptor_ownership="owned", to_numpy_policy="unsupported", @@ -182,7 +194,13 @@ def test_generated_handle_factory_rejects_an_invalid_descriptor_kind(): } with pytest.raises(ValueError, match="generated native array handle kind"): - _native_array_handle_from_generated_ops("target", "float64", 1, ops) + _native_array_handle_from_generated_dispatch( + "target", + "float64", + 1, + _generated_handle_dispatch(ops), + ops, + ) def test_owned_handle_close_calls_destroy_once_and_blocks_later_use(): @@ -191,11 +209,13 @@ def test_owned_handle_close_calls_destroy_once_and_blocks_later_use(): handle = AllocatableArray( dtype="float64", rank=1, - ops={ - **_common_ops(state), - "allocated": lambda _handle: True, - "destroy": lambda _handle: calls.append(("destroy", _handle.shape, _handle.to_numpy())), - }, + **_handle_dispatch( + { + **_common_ops(state), + "allocated": lambda _handle: True, + "destroy": lambda _handle: calls.append(("destroy", state.shape, state.value)), + } + ), descriptor_ownership="owned", ) @@ -220,11 +240,13 @@ def destroy(_handle): handle = AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - "destroy": destroy, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "allocated": lambda _handle: True, + "destroy": destroy, + } + ), descriptor_ownership="owned", to_numpy_policy="unsupported", ) @@ -246,11 +268,13 @@ def test_owned_handle_finalizer_calls_destroy_once(): handle = AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - "destroy": lambda _handle: calls.append("destroy"), - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "allocated": lambda _handle: True, + "destroy": lambda _handle: calls.append("destroy"), + } + ), descriptor_ownership="owned", to_numpy_policy="unsupported", ) @@ -266,10 +290,12 @@ def test_owned_handle_construction_requires_generated_destroy_operation(): AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "allocated": lambda _handle: True, + } + ), descriptor_ownership="owned", to_numpy_policy="unsupported", ) @@ -281,12 +307,14 @@ def test_borrowed_handle_close_and_finalizer_do_not_destroy_native_storage(): handle = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - "destroy": lambda _handle: calls.append("destroy"), - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + "destroy": lambda _handle: calls.append("destroy"), + } + ), to_numpy_policy="unsupported", ) diff --git a/tests/fortran/pointers/codegen/test_pointer_lowering.py b/tests/fortran/pointers/codegen/test_pointer_lowering.py index 5a4a2c7a8..46dd5862b 100644 --- a/tests/fortran/pointers/codegen/test_pointer_lowering.py +++ b/tests/fortran/pointers/codegen/test_pointer_lowering.py @@ -134,3 +134,23 @@ def test_pointer_lowering_assigns_descriptors_and_emits_manual_target_release(): # Release is manual and caller-driven, matching the ``deallocate`` a Fortran # caller would write for the same pointer; prik never runs it on its own. assert "deallocate(result)" in pointer_operations + + +def test_nullable_scalar_pointer_result_uses_attribute_independent_storage_sizing(): + module = parse_pyi_text( + """ +from prik.contracts import Addr, Aliased, Annotated, Arg, Destruction, Float64, Ownership, Pointer, Return, Transfer, native_call + +@native_call([Addr(Arg(0))], result=Pointer(Return(0))) +def select_scalar( + value: Annotated[Float64, Aliased], +) -> Annotated[Float64, Ownership("python"), Transfer("snapshot_copy"), Destruction("python_refcount")] | None: ... +""", + module_name="pointer_scalar_lowering", + ) + complete_semantic_policies(module) + artifacts = WrapperGenerator().generate(WrapperPlanner().build(module)) + bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") + + assert "storage_size(result_value, kind=c_size_t) / 8_c_size_t" in bridge_source + assert "c_sizeof(result_value)" not in bridge_source diff --git a/tests/fortran/pointers/runtime/test_pointer_contract_handles.py b/tests/fortran/pointers/runtime/test_pointer_contract_handles.py index 239fc4e9c..d3f2431fe 100644 --- a/tests/fortran/pointers/runtime/test_pointer_contract_handles.py +++ b/tests/fortran/pointers/runtime/test_pointer_contract_handles.py @@ -13,6 +13,8 @@ from tests.fortran._support.native_array_handles import ( _absent_descriptor_facts, _descriptor_facts_for_array, + _generated_handle_dispatch, + _handle_dispatch, ) @@ -47,14 +49,16 @@ def source_nullify(_handle): source = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: value.shape if source_state["facts"][0] else None, - "descriptor": lambda _handle: source_state["facts"], - "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(source_state["facts"], "float64"), - "associated": lambda _handle: source_state["facts"][0] != 0, - "associate": lambda _handle, facts: source_state.update(facts=facts), - "nullify": source_nullify, - }, + **_handle_dispatch( + { + "shape": lambda _handle: value.shape if source_state["facts"][0] else None, + "descriptor": lambda _handle: source_state["facts"], + "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(source_state["facts"], "float64"), + "associated": lambda _handle: source_state["facts"][0] != 0, + "associate": lambda _handle, facts: source_state.update(facts=facts), + "nullify": source_nullify, + } + ), to_numpy_policy="descriptor_view", ) target = contracts.Pointer[contracts.Float64[:]]() @@ -80,14 +84,16 @@ def test_fresh_pointer_pending_association_is_applied_when_native_storage_attach source = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: value.shape, - "descriptor": lambda _handle: facts, - "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(facts, "float64"), - "associated": lambda _handle: True, - "associate": lambda _handle, _facts: None, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: value.shape, + "descriptor": lambda _handle: facts, + "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(facts, "float64"), + "associated": lambda _handle: True, + "associate": lambda _handle, _facts: None, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="descriptor_view", ) target = contracts.Pointer[contracts.Float64[:]]() @@ -100,19 +106,21 @@ def associate(received_owner, facts): received.append((received_owner, facts)) state["associated"] = True + operations = { + "shape": lambda _owner: value.shape if state["associated"] else None, + "descriptor": lambda _owner: facts, + "associated": lambda _owner: state["associated"], + "associate": associate, + "nullify": lambda _owner: state.update(associated=False), + "destroy": lambda _owner: None, + } _bind_contract_native_array_handle( target, "pointer", "float64", 1, - { - "shape": lambda _owner: value.shape if state["associated"] else None, - "descriptor": lambda _owner: facts, - "associated": lambda _owner: state["associated"], - "associate": associate, - "nullify": lambda _owner: state.update(associated=False), - "destroy": lambda _owner: None, - }, + _generated_handle_dispatch(operations), + operations, owner, "owned", "unsupported", @@ -158,12 +166,14 @@ def test_generated_storage_rejects_incompatible_contract_handles( handle = prepare() with pytest.raises(error, match=message): + operations = {} _bind_contract_native_array_handle( handle, descriptor_kind, dtype, rank, - {}, + _generated_handle_dispatch(operations), + operations, object(), "owned", "unsupported", diff --git a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py index dde6333c1..4050e3a1a 100644 --- a/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py +++ b/tests/fortran/pointers/runtime/test_pointer_descriptor_abi.py @@ -12,7 +12,11 @@ _native_array_backend_for_binding_positional, _numpy_view_from_descriptor_facts, ) -from tests.fortran._support.native_array_handles import _descriptor_facts_for_array +from tests.fortran._support.native_array_handles import ( + _descriptor_facts_for_array, + _generated_handle_dispatch, + _handle_dispatch, +) def _bound_pointer(backend, *, dtype=np.float64, rank=1): @@ -20,15 +24,17 @@ def _bound_pointer(backend, *, dtype=np.float64, rank=1): handle = PointerArray( dtype=np.dtype(dtype), rank=rank, - ops={ - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - "descriptor": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "associated": lambda _handle: False, + "nullify": lambda _handle: None, + "descriptor": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ) - handle._native_ops = backend + handle._native_backend = backend return handle @@ -74,7 +80,7 @@ def test_an_optional_descriptor_argument_reports_presence_alongside_its_backend( AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={"shape": lambda _handle: None, "allocated": lambda _handle: False}, + **_handle_dispatch({"shape": lambda _handle: None, "allocated": lambda _handle: False}), to_numpy_policy="unsupported", ), TypeError, @@ -109,23 +115,25 @@ def test_descriptor_argument_binds_a_fresh_contract_handle_then_reads_its_backen backend = object() def bind_default(value): + operations = { + "shape": lambda _owner: None, + "associated": lambda _owner: False, + "nullify": lambda _owner: None, + "descriptor": lambda _owner: None, + "associate": lambda _owner, _facts: None, + "destroy": lambda _owner: None, + } _bind_contract_native_array_handle( value, "pointer", "float64", 1, - { - "shape": lambda _owner: None, - "associated": lambda _owner: False, - "nullify": lambda _owner: None, - "descriptor": lambda _owner: None, - "associate": lambda _owner, _facts: None, - "destroy": lambda _owner: None, - }, + _generated_handle_dispatch(operations), + operations, backend, "owned", "unsupported", - native_ops=backend, + native_backend=backend, ) assert _native_array_backend_for_binding_positional( diff --git a/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py b/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py index 0efdb0f6c..ef2560845 100644 --- a/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py +++ b/tests/fortran/pointers/runtime/test_pointer_handle_protocol.py @@ -6,13 +6,15 @@ AllocatableArray, NativeArrayHandleBase, PointerArray, - _native_array_handle_from_generated_ops, + _native_array_handle_from_generated_dispatch, _numpy_view_from_descriptor_facts, ) from tests.fortran._support.native_array_handles import ( _ArrayState, _common_ops, _descriptor_facts_for_array, + _generated_handle_dispatch, + _handle_dispatch, ) @@ -25,11 +27,13 @@ def test_pointer_to_numpy_reports_unassociated_state_before_an_unsupported_polic handle = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "associated": lambda _handle: False, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ) @@ -43,20 +47,19 @@ def fail_state(_handle): allocatable = AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "allocated": fail_state, - }, + **_handle_dispatch({"shape": lambda _handle: None, "allocated": fail_state}), to_numpy_policy="unsupported", ) pointer = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "associated": fail_state, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "associated": fail_state, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ) @@ -70,12 +73,14 @@ def test_to_numpy_contiguous_view_policy_rejects_non_contiguous_storage(): handle = PointerArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: strided.shape, - "to_numpy": lambda _handle: strided, - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: strided.shape, + "to_numpy": lambda _handle: strided, + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="contiguous_view", ) @@ -88,12 +93,14 @@ def test_to_numpy_descriptor_view_policy_never_copies_storage(): handle = PointerArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: source.shape, - "to_numpy": lambda _handle: source, - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: source.shape, + "to_numpy": lambda _handle: source, + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="descriptor_view", ) @@ -113,13 +120,15 @@ def test_to_numpy_rejects_generated_non_numpy_results(policy: str): handle = AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: (2,), - "to_numpy": lambda _handle: [1.0, 2.0], - "allocated": lambda _handle: True, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (2,), + "to_numpy": lambda _handle: [1.0, 2.0], + "allocated": lambda _handle: True, + "deallocate": lambda _handle: None, + "resize": lambda _handle, _shape: None, + } + ), to_numpy_policy=policy, ) @@ -131,11 +140,13 @@ def test_to_numpy_rejects_generated_array_with_wrong_rank_or_dtype(): wrong_rank = AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: (2,), - "to_numpy": lambda _handle: np.zeros((1, 2), dtype=np.float64), - "allocated": lambda _handle: True, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (2,), + "to_numpy": lambda _handle: np.zeros((1, 2), dtype=np.float64), + "allocated": lambda _handle: True, + } + ), ) with pytest.raises(ValueError, match="to_numpy result rank 2 does not match declared rank 1"): wrong_rank.to_numpy() @@ -143,11 +154,13 @@ def test_to_numpy_rejects_generated_array_with_wrong_rank_or_dtype(): wrong_dtype = AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: (2,), - "to_numpy": lambda _handle: np.zeros(2, dtype=np.int32), - "allocated": lambda _handle: True, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (2,), + "to_numpy": lambda _handle: np.zeros(2, dtype=np.int32), + "allocated": lambda _handle: True, + } + ), ) with pytest.raises(TypeError, match="to_numpy result dtype"): wrong_dtype.to_numpy() @@ -157,11 +170,13 @@ def test_runtime_handle_shapes_reject_negative_extents(): handle = AllocatableArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: (-1,), - "allocated": lambda _handle: True, - "resize": lambda _handle, _shape: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (-1,), + "allocated": lambda _handle: True, + "resize": lambda _handle, _shape: None, + } + ), to_numpy_policy="unsupported", ) @@ -184,7 +199,7 @@ def nullify(_handle): "nullify": nullify, "destroy": lambda _handle: None, } - handle = PointerArray(dtype="int32", rank=1, ops=ops, descriptor_ownership="owned") + handle = PointerArray(dtype="int32", rank=1, **_handle_dispatch(ops), descriptor_ownership="owned") assert isinstance(handle, NativeArrayHandleBase) assert handle.descriptor_kind == "pointer" @@ -209,14 +224,16 @@ def pointer(state): return PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (state["facts"][4],) if state["facts"][0] else None, - "descriptor": lambda _handle: state["facts"], - "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(state["facts"], "float64"), - "associated": lambda _handle: state["facts"][0] != 0, - "associate": lambda _handle, facts: state.update(facts=facts), - "nullify": lambda _handle: state.update(facts=absent), - }, + **_handle_dispatch( + { + "shape": lambda _handle: (state["facts"][4],) if state["facts"][0] else None, + "descriptor": lambda _handle: state["facts"], + "to_numpy": lambda _handle: _numpy_view_from_descriptor_facts(state["facts"], "float64"), + "associated": lambda _handle: state["facts"][0] != 0, + "associate": lambda _handle, facts: state.update(facts=facts), + "nullify": lambda _handle: state.update(facts=absent), + } + ), to_numpy_policy="descriptor_view", ) @@ -239,27 +256,31 @@ def test_generated_pointer_associate_hands_over_flat_descriptor_facts(): source = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: value.shape, - "descriptor": lambda _handle: _descriptor_facts_for_array(value), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - "associate": lambda _handle, _facts: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: value.shape, + "descriptor": lambda _handle: _descriptor_facts_for_array(value), + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + "associate": lambda _handle, _facts: None, + } + ), to_numpy_policy="unsupported", ) received = [] - destination = _native_array_handle_from_generated_ops( + operations = { + "shape": lambda: None, + "descriptor": lambda: None, + "associated": lambda: False, + "associate": lambda facts: received.append(facts), + "nullify": lambda: None, + } + destination = _native_array_handle_from_generated_dispatch( "pointer", "float64", 1, - { - "shape": lambda: None, - "descriptor": lambda: None, - "associated": lambda: False, - "associate": lambda facts: received.append(facts), - "nullify": lambda: None, - }, + _generated_handle_dispatch(operations), + operations, to_numpy_policy="unsupported", ) @@ -276,11 +297,13 @@ def test_generated_pointer_associate_hands_over_flat_descriptor_facts(): PointerArray( dtype="int32", rank=1, - ops={ - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "associated": lambda _handle: False, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ), TypeError, @@ -290,11 +313,13 @@ def test_generated_pointer_associate_hands_over_flat_descriptor_facts(): PointerArray( dtype="float64", rank=2, - ops={ - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "associated": lambda _handle: False, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ), ValueError, @@ -306,12 +331,14 @@ def test_pointer_associate_rejects_incompatible_sources(other, error, message): destination = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: None, - "associated": lambda _handle: False, - "associate": lambda _handle, _descriptor: None, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: None, + "associated": lambda _handle: False, + "associate": lambda _handle, _descriptor: None, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ) @@ -319,16 +346,18 @@ def test_pointer_associate_rejects_incompatible_sources(other, error, message): destination.associate(other) -def test_pointer_allocation_operations_are_policy_gated_by_ops_table(): +def test_pointer_allocation_operations_are_policy_gated_by_capabilities(): state = _ArrayState(shape=(1,), value=object()) handle = PointerArray( dtype="float64", rank=1, - ops={ - **_common_ops(state), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + **_common_ops(state), + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + } + ), ) with pytest.raises(NotImplementedError, match="pointer handle operation 'allocate' is not available"): @@ -339,7 +368,7 @@ def test_pointer_allocation_operations_are_policy_gated_by_ops_table(): handle.resize((4,)) -def test_pointer_allocation_operations_route_when_policy_ops_exist(): +def test_pointer_allocation_operations_route_when_capabilities_exist(): state = _ArrayState(shape=None, value=None) def allocate(_handle, shape): @@ -357,14 +386,16 @@ def resize(_handle, shape): handle = PointerArray( dtype="float64", rank=2, - ops={ - **_common_ops(state), - "associated": lambda _handle: state.shape is not None, - "nullify": lambda _handle: deallocate(_handle), - "allocate": allocate, - "deallocate": deallocate, - "resize": resize, - }, + **_handle_dispatch( + { + **_common_ops(state), + "associated": lambda _handle: state.shape is not None, + "nullify": lambda _handle: deallocate(_handle), + "allocate": allocate, + "deallocate": deallocate, + "resize": resize, + } + ), ) assert handle.associated is False @@ -384,11 +415,13 @@ def test_pointer_to_numpy_reports_missing_descriptor_extraction(): handle = PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (2,), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (2,), + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ) @@ -400,12 +433,14 @@ def test_to_numpy_policy_unsupported_reports_completed_policy_block(): handle = PointerArray( dtype=np.dtype(np.float64), rank=1, - ops={ - "shape": lambda _handle: (2,), - "to_numpy": lambda _handle: pytest.fail("unsupported policy must not call generated extraction"), - "associated": lambda _handle: True, - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (2,), + "to_numpy": lambda _handle: pytest.fail("unsupported policy must not call generated extraction"), + "associated": lambda _handle: True, + "nullify": lambda _handle: None, + } + ), to_numpy_policy="unsupported", ) @@ -417,13 +452,15 @@ def test_common_shape_dispatch_validates_rank(): handle = AllocatableArray( dtype="float64", rank=2, - ops={ - "shape": lambda _handle: (4,), - "to_numpy": lambda _handle: None, - "allocated": lambda _handle: True, - "deallocate": lambda _handle: None, - "resize": lambda _handle, _shape: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (4,), + "to_numpy": lambda _handle: None, + "allocated": lambda _handle: True, + "deallocate": lambda _handle: None, + "resize": lambda _handle, _shape: None, + } + ), ) with pytest.raises(ValueError, match="shape rank 1 does not match declared rank 2"): @@ -435,22 +472,23 @@ def test_common_handle_rejects_invalid_descriptor_kind(): NativeArrayHandleBase( dtype="float64", rank=1, - ops={}, + invoke=lambda _operation: None, + capabilities=(), descriptor_kind="target", descriptor_ownership="borrowed", ) -def test_common_handle_rejects_invalid_generated_operation_table(): - with pytest.raises(TypeError, match="operation names must be strings"): - AllocatableArray(dtype="float64", rank=1, ops={1: lambda _handle: None}) - with pytest.raises(TypeError, match="operation 'shape' must be callable"): - AllocatableArray(dtype="float64", rank=1, ops={"shape": None}) +def test_common_handle_rejects_invalid_dispatch_contract(): + with pytest.raises(TypeError, match="dispatcher must be callable"): + AllocatableArray(dtype="float64", rank=1, invoke=None, capabilities={"shape", "allocated"}) + with pytest.raises(TypeError, match="capability names must be strings"): + AllocatableArray(dtype="float64", rank=1, invoke=lambda _operation: None, capabilities={1}) def test_common_handle_requires_generated_shape_operation(): with pytest.raises(ValueError, match="requires generated operation 'shape'"): - AllocatableArray(dtype="float64", rank=1, ops={}) + AllocatableArray(dtype="float64", rank=1, invoke=lambda _operation: None, capabilities=()) def test_extraction_enabled_handle_requires_generated_to_numpy_operation(): @@ -458,10 +496,12 @@ def test_extraction_enabled_handle_requires_generated_to_numpy_operation(): AllocatableArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "allocated": lambda _handle: True, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "allocated": lambda _handle: True, + } + ), to_numpy_policy="borrowed_view", ) @@ -471,27 +511,43 @@ def test_pointer_handle_requires_generated_associated_and_nullify_operations(): PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "nullify": lambda _handle: None, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "nullify": lambda _handle: None, + } + ), ) with pytest.raises(ValueError, match="requires generated operation 'nullify'"): PointerArray( dtype="float64", rank=1, - ops={ - "shape": lambda _handle: (1,), - "associated": lambda _handle: True, - }, + **_handle_dispatch( + { + "shape": lambda _handle: (1,), + "associated": lambda _handle: True, + } + ), ) def test_common_handle_rejects_invalid_descriptor_ownership(): with pytest.raises(ValueError, match="descriptor_ownership must be 'borrowed' or 'owned'"): - AllocatableArray(dtype="float64", rank=1, ops={}, descriptor_ownership="temporary") + AllocatableArray( + dtype="float64", + rank=1, + invoke=lambda _operation: None, + capabilities=(), + descriptor_ownership="temporary", + ) def test_common_handle_rejects_invalid_to_numpy_policy(): with pytest.raises(ValueError, match="to_numpy_policy must be one of"): - AllocatableArray(dtype="float64", rank=1, ops={}, to_numpy_policy="maybe_copy") + AllocatableArray( + dtype="float64", + rank=1, + invoke=lambda _operation: None, + capabilities=(), + to_numpy_policy="maybe_copy", + ) From a2cf473c3a71d10d976f72fd27b8049dc0728610 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 11:44:39 +0100 Subject: [PATCH 26/47] Refuse a borrowed descriptor a call cannot hold open A call reaches a borrowed entity by running inside the consumer holding its descriptor, and only one call fits inside one consumer. An entrypoint with a second descriptor dummy therefore took the general path, which read the backend's context as the descriptor -- true only of an owned backend, whose context is its own persistent storage. A module array's context is NULL, so two module handles reached the callee as null descriptors and the Fortran runtime freed them: free(): invalid pointer. The general path now asks for a descriptor that outlives a consumer and is told when there is none, naming the argument. Caller-created handles, which own their storage, are placed exactly as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 6 +++ docs/developer/packages/runtime.md | 8 ++++ prik/codegen/c/binding.py | 13 +++++- prik/runtime/native_support/prik_binding.h | 31 +++++++++++++ .../test_native_handle_array_forms.py | 45 +++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e20f4add3..cbf6220ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ release tags add a leading `v` to the package version. strides directly, so negative strides and non-contiguous pointer targets are exposed without an intermediate buffer. +- A borrowed module or derived-field array handle passed to an entrypoint with + more than one allocatable or pointer dummy is now refused, naming the + argument. Such a descriptor is only valid inside the call that borrows it, + and only one dummy per call can be reached that way; caller-created handles, + which own persistent descriptor storage, are unaffected. + - Fixed writable module and derived-field allocatable arrays, and reject PROTECTED module arrays during policy completion when writable access would be required. diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 44e87f7a5..69b58fff1 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -102,6 +102,14 @@ associated with, which matters because a handle created from a `.pyi` contract has no native storage until a call gives it some and so has nowhere else to record it. +A call is made inside the consumer holding its argument's descriptor, and +only one call can be inside one consumer. An entrypoint that takes a second +descriptor dummy therefore needs descriptors that outlive a consumer, which +only an owned backend has -- its `context` *is* persistent descriptor storage. +A caller-created handle is placed there; a borrowed module array or field is +refused by the binding, naming the argument, rather than handed a descriptor +that would dangle. + ### Views And Ownership `to_numpy()` builds the view in C while the descriptor is live, over the diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index a58ae8834..1fa2588c2 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -8127,8 +8127,19 @@ def _native_descriptor_pointer_unpack_nodes( CReturn(CodeExpression("NULL")), ), ), + CComment("This call is made outside any consumer, so the descriptor must outlive one."), CExpressionStatement( - CodeExpression(f"{names.value_name} = (CFI_cdesc_t *){prefix}_native_backend->context") + CodeExpression( + f"{names.value_name} = (CFI_cdesc_t *)prik_native_array_backend_persistent_descriptor(" + f'{prefix}_native_backend, "{plan.binding.python_name}")' + ) + ), + CIf( + CodeExpression(f"{names.value_name} == NULL"), + body=( + CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)")), + CReturn(CodeExpression("NULL")), + ), ), ), else_body=absent, diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index a8fa50e74..1b64be413 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -316,6 +316,37 @@ static inline prik_native_array_backend *prik_native_array_backend_for_descripto return backend; } +/* + * Take a descriptor that stays valid after this returns. + * + * `prik_native_array_owned_with_descriptor` hands its consumer the context + * itself, so an owned backend's context *is* persistent descriptor storage and + * a caller may hold it for as long as the handle lives. A borrowed backend's + * descriptor is instead built by the Fortran runtime for one call and is gone + * when the consumer returns, so there is nothing here to hand back: such a + * handle can only be reached from inside `with_descriptor`. `release` is + * non-NULL exactly for storage this extension owns, which is the same fact and + * the one that survives being read from another extension, where the inline + * entry point above is a different function. + * + * A caller that cannot enter the consumer is told so rather than handed a + * pointer that would dangle the moment the call it was fetched for begins. + */ +static inline void *prik_native_array_backend_persistent_descriptor( + prik_native_array_backend *backend, + const char *argument_name) +{ + if (backend->release == NULL) { + PyErr_Format( + PyExc_TypeError, + "argument %s is a borrowed native array handle, whose descriptor is only valid inside a call; " + "this entrypoint takes more than one descriptor argument and cannot enter it", + argument_name); + return NULL; + } + return backend->context; +} + /* * Read a backend for an ordinary array actual. * diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index 39138f9a5..b85dd1270 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -9,6 +9,7 @@ import numpy as np import pytest +from prik import contracts from prik.runtime.handles import AllocatableArray, PointerArray from tests.fortran._support.wrapper_build import _build_text_and_import @@ -161,6 +162,8 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): real(8), allocatable :: empty(:) real(8), allocatable :: spare(:) real(8), allocatable :: probe(:) + real(8), allocatable :: pair_left(:) + real(8), allocatable :: pair_right(:) real(8), allocatable :: cube(:, :, :) real(8), target :: store(8) real(8), pointer :: reversed(:) => null() @@ -182,6 +185,8 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): deferred_words = ['alphas', 'bravos'] allocate(empty(0)) allocate(probe(3)); probe = 2.0_8 + allocate(pair_left(3)); pair_left = 6.0_8 + allocate(pair_right(3)); pair_right = 7.0_8 allocate(cube(2, 3, 4)); cube = 1.0_8 store = [(1.0_8 * i, i = 1, 8)] reversed => store(8:1:-1) @@ -199,6 +204,16 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): values = 4.0_8 end subroutine reshape_alloc + subroutine grow_pair(first, second, n) + real(8), allocatable, intent(inout) :: first(:), second(:) + integer(4), intent(in) :: n + + if (allocated(first)) deallocate(first) + if (allocated(second)) deallocate(second) + allocate(first(n)); first = 8.0_8 + allocate(second(n)); second = 9.0_8 + end subroutine grow_pair + function assumed_total(actual) result(total) real(8), intent(in) :: actual(:) real(8) :: total @@ -425,3 +440,33 @@ def record(frame, event, _arg): assert called == [] assert descriptor.shape == (2,) + + +def test_two_descriptor_dummies_take_owned_storage_and_refuse_a_borrowed_handle(descriptor_matrix): + """A descriptor only one call can hold is refused, not handed over to be dangled. + + Reaching a borrowed entity means making the call inside the consumer that + holds its descriptor, and only one call can be inside one consumer. An + entrypoint with a second descriptor dummy therefore needs descriptors that + outlive a consumer, which only an owned handle has: a caller-created one is + placed, and a module array is refused while both handles stay usable. + """ + left = descriptor_matrix.pair_left + right = descriptor_matrix.pair_right + + with pytest.raises(TypeError, match="borrowed native array handle"): + descriptor_matrix.grow_pair(left, right, np.int32(2)) + assert left.shape == (3,) + assert right.shape == (3,) + + owned_first = contracts.Allocatable[contracts.Float64[:]]() + owned_second = contracts.Allocatable[contracts.Float64[:]]() + try: + descriptor_matrix.grow_pair(owned_first, owned_second, np.int32(2)) + assert owned_first.shape == (2,) + assert owned_second.shape == (2,) + np.testing.assert_allclose(owned_first.to_numpy(), np.array([8.0, 8.0])) + np.testing.assert_allclose(owned_second.to_numpy(), np.array([9.0, 9.0])) + finally: + owned_first.close() + owned_second.close() From 9d02d1bf14e8246df80f42934c6a134615a63296 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 12:39:30 +0100 Subject: [PATCH 27/47] Make the call inside the descriptors, always There was still a second way to reach a descriptor argument: read the backend's context in the wrapper frame and call from there. It worked only because an owned backend's context is its own persistent storage, so a borrowed handle had to be refused, and every argument on that route was packed by a helper in prik.runtime.handles first. Now one consumer is emitted per descriptor argument. Each records what it was handed and enters the next, so the last one makes the call with all of them live, and a callee that reallocates or reassociates any argument writes into the descriptor Fortran copies back to that caller's entity. An owned handle enters the same way, because its entry point hands the consumer its own storage; an absent optional has nothing to enter and passes on the unallocated placeholder recorded for it. A hidden output needs nothing new: its address is carried like any other value, and this frame outlives every consumer it enters. Argument handoff now runs no Python for any supported form, and the two refusals this removes -- more than one descriptor dummy, and a descriptor dummy beside an intent(out) argument -- become supported for borrowed handles too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 13 +- docs/developer/packages/runtime.md | 20 +- prik/codegen/c/binding.py | 349 +++++++++++------- prik/runtime/native_support/prik_binding.h | 31 -- .../test_native_handle_array_forms.py | 72 +++- 5 files changed, 293 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf6220ec..38bd3c36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,6 @@ release tags add a leading `v` to the package version. one Fortran builds for the call or persistent storage the wrapper owns. Extensions built against the earlier branch-only table must be regenerated. -- Generated array handles now route operations through one native dispatcher - and an immutable capability set instead of constructing and storing one - Python callable per operation. - - Argument handoff, shape, allocation and association state, element width, contiguity and NumPy views are now all read from that live descriptor in C. No descriptor is serialized into Python fields and decoded back, and the @@ -38,11 +34,10 @@ release tags add a leading `v` to the package version. strides directly, so negative strides and non-contiguous pointer targets are exposed without an intermediate buffer. -- A borrowed module or derived-field array handle passed to an entrypoint with - more than one allocatable or pointer dummy is now refused, naming the - argument. Such a descriptor is only valid inside the call that borrows it, - and only one dummy per call can be reached that way; caller-created handles, - which own persistent descriptor storage, are unaffected. +- Fortran entrypoints can now accept generated array handles for several + allocatable or pointer dummies in one call, including calls with `intent(out)` + arguments or status outputs. Allocation and association changes reach each + supplied module, field, result, or caller-created handle. - Fixed writable module and derived-field allocatable arrays, and reject PROTECTED module arrays during policy completion when writable access would be diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 69b58fff1..dc27bc581 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -32,9 +32,9 @@ generated dispatcher + completed capability set + native backend capsule The dispatcher is the single Python call boundary between generated extension code and the stable handle API. Its immutable capability set comes from the -completed plan and states which operation names the dispatcher accepts. -Missing capabilities fail explicitly rather than being inferred from -`allocatable` or `pointer` alone. +completed plan and states which operation names the dispatcher accepts. The +runtime validates that set when it creates the handle and before dispatching an +operation. ### The Backend Capsule @@ -102,13 +102,13 @@ associated with, which matters because a handle created from a `.pyi` contract has no native storage until a call gives it some and so has nowhere else to record it. -A call is made inside the consumer holding its argument's descriptor, and -only one call can be inside one consumer. An entrypoint that takes a second -descriptor dummy therefore needs descriptors that outlive a consumer, which -only an owned backend has -- its `context` *is* persistent descriptor storage. -A caller-created handle is placed there; a borrowed module array or field is -refused by the binding, naming the argument, rather than handed a descriptor -that would dangle. +A call with more than one allocatable or pointer dummy enters each argument's +backend in turn. Each consumer records its descriptor and enters the next, and +the call runs inside the last consumer while every descriptor is live. Borrowed +and owned handles use the same placement; an owned backend hands the consumer +its persistent storage. An absent optional argument contributes an unallocated +placeholder to the chain. Each descriptor remains scoped to the consumer that +supplied it. ### Views And Ownership diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 1fa2588c2..f789e2b0b 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -165,7 +165,10 @@ class _CFunctionContext: python_result_name: str | None python_results: dict[str, str] role_values: dict[str, str] - inverted_descriptor: str | None = None + # Every argument reached through its descriptor entry point, in call order. + # The call is made inside the innermost consumer, so each one is entered in + # turn and they are all live together by the time it happens. + inverted_descriptors: tuple[str, ...] = () # The function whose lowering this context serves, so an argument's nodes # can name helpers emitted once per function at module scope. function: FunctionPlan | None = None @@ -7947,26 +7950,22 @@ def _lower_argument_native_array_direct( else () ), ] - inverted = context.inverted_descriptor == plan.owner_path - general: list[CDeclaration | CExpressionStatement | CIf] = [] - general.extend( + # A handle with no storage yet has no backend to enter, so storage is + # attached first and the backend that gives is what the chain enters. + attach: list[CDeclaration | CExpressionStatement | CIf] = [] + attach.extend( self._native_descriptor_helper_call_nodes( plan, context, names, - # Only a handle owning its descriptor reaches this path now: one - # that publishes native entry points is placed through them. "_native_array_backend_for_binding_positional", default_binder_definition=binder_definition, ) ) - general.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 1)) - general.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) - general.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) - if inverted: - nodes.extend(self._inverted_descriptor_backend_nodes(plan, names, handle, tuple(general))) - else: - nodes.extend(general) + attach.extend(self._native_descriptor_presence_unpack_nodes(plan, names, 1)) + attach.extend(self._native_descriptor_pointer_unpack_nodes(plan, names)) + attach.append(CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)"))) + nodes.extend(self._inverted_descriptor_backend_nodes(plan, names, handle, tuple(attach))) return tuple(nodes) def _native_descriptor_object_declaration( @@ -8127,19 +8126,9 @@ def _native_descriptor_pointer_unpack_nodes( CReturn(CodeExpression("NULL")), ), ), - CComment("This call is made outside any consumer, so the descriptor must outlive one."), + CComment("Attaching storage published a backend; the chain enters that."), CExpressionStatement( - CodeExpression( - f"{names.value_name} = (CFI_cdesc_t *)prik_native_array_backend_persistent_descriptor(" - f'{prefix}_native_backend, "{plan.binding.python_name}")' - ) - ), - CIf( - CodeExpression(f"{names.value_name} == NULL"), - body=( - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)")), - CReturn(CodeExpression("NULL")), - ), + CodeExpression(f"{self._inverted_backend_local(names)} = {prefix}_native_backend") ), ), else_body=absent, @@ -9422,66 +9411,63 @@ def _result_native_name(self, plan: ResultPlan, context: _CFunctionContext) -> s except KeyError: raise ValueError(f"Hidden result {plan.owner_path!r} has no C output storage") from None - def _inverted_descriptor_argument(self, plan: FunctionPlan) -> ArgumentTransferPlan | None: - """Return the argument whose descriptor must stay live across the call. + def _inverted_descriptor_arguments(self, plan: FunctionPlan) -> tuple[ArgumentTransferPlan, ...]: + """Return every argument whose descriptor must be live across the call. The descriptor the runtime builds for a module array or a field exists only while the consumer it was handed to is running. Making the call inside that consumer is what lets a callee change the allocation of a writable dummy and have it reach the caller's entity, and it means no descriptor is ever copied: a read-only argument is placed the same way, - so C only ever passes on a descriptor Fortran made. - - An optional argument is placed the same way when it is present. When - it is absent there is no handle and so no consumer to enter, and the - unallocated placeholder is handed to the same call site directly. + so C only ever passes on a descriptor Fortran made. A handle that owns + its descriptor enters the same way, because its entry point hands the + consumer the storage directly. + + Each one is entered in turn, so several descriptors are live together by + the time the innermost consumer makes the call. An optional argument + that is absent has no entity to enter and contributes its unallocated + placeholder to the same chain instead. """ - candidates = [ + return tuple( argument for argument in plan.arguments if argument.native_array_handle is not None and argument.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR and argument.native_array_handle.array.rank is not None - ] - if len(candidates) != 1: - return None - # Hidden outputs and status projections read native storage the consumer - # does not carry, so those keep the general path. - if plan.results and any(result.source_kind != "direct_return" for result in plan.results): - return None - return candidates[0] + ) + + def _inverted_descriptor_slot(self, plan: FunctionPlan, owner_path: str) -> int: + """Return the position one descriptor argument holds in the chain.""" + for slot, argument in enumerate(self._inverted_descriptor_arguments(plan)): + if argument.owner_path == owner_path: + return slot + raise ValueError(f"{owner_path!r} is not reached through a descriptor entry point") def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) -> tuple: - """Emit the native call, inside a descriptor consumer where one is required.""" - if context.inverted_descriptor is None: + """Emit the native call, inside the consumers holding its descriptors.""" + if not context.inverted_descriptors: return self._lower_native_call(plan, self._entrypoint_call_statement(plan, context)) - names = context.arguments[context.inverted_descriptor] - backend = f"{names.value_name}_borrowed_backend" - consumer = self._inverted_consumer_name(plan) + names = context.arguments[context.inverted_descriptors[0]] record = self._inverted_context_name(plan) + chain = self._inverted_chain_fields(plan, context) fields = self._inverted_context_fields(plan, context) result = self._direct_result(plan) - initializer = ", ".join(value for _declaration, value in fields) + values = [value for _declaration, value in chain + fields] if result is not None: - initializer = f"{initializer}, 0" if initializer else "0" - return ( - CComment("Everything the call needs apart from the descriptor is gathered here,"), - CComment("because the consumer runs outside this frame."), - CDeclaration("call_context", record, CodeExpression(f"{{{initializer}}}")), - CComment("The call is made inside the consumer, where the descriptor is live, so"), - CComment("what the callee writes into it is what Fortran copies back to the"), - CComment("caller's entity when the bridge returns."), - CIf( - CodeExpression(f"{backend} != NULL"), - body=( - CExpressionStatement( - CodeExpression(f"{backend}->with_descriptor({backend}->context, {consumer}, &call_context)") - ), - ), - else_body=( - CComment("This handle owns its descriptor, so hand it to the same consumer."), - CExpressionStatement(CodeExpression(f"{consumer}({names.value_name}, &call_context)")), - ), + values.append("0") + return ( + CComment("Everything the call needs apart from the descriptors themselves is"), + CComment("gathered here, because the consumers run outside this frame."), + CDeclaration("call_context", record, CodeExpression(f"{{{', '.join(values)}}}")), + CComment("Each descriptor is entered in turn and the call is made inside the last"), + CComment("consumer, where they are all live, so what the callee writes into any of"), + CComment("them is what Fortran copies back to that caller's entity."), + *self._inverted_enter_nodes( + plan, + 0, + backend=self._inverted_backend_local(names), + call_context="&call_context", + placeholder=f"call_context.{self._inverted_descriptor_field(0)}", ), *( (CExpressionStatement(CodeExpression(f"{context.result_name} = call_context.result")),) @@ -10267,55 +10253,88 @@ def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: return tuple(nodes) def _inverted_descriptor_consumer_functions(self, plan: ModulePlan) -> tuple: - """Emit the record and consumer for each entrypoint called inside one.""" + """Emit the record and consumer chain for each entrypoint called inside one.""" nodes: list = [] for function in self._functions(plan): - if self._inverted_descriptor_argument(function) is None: - continue context = self._function_context(function) - fields = self._inverted_context_fields(function, context) - record = self._inverted_context_name(function) - result = self._direct_result(function) - result_field = ( - (CParameter("result", self._inverted_result_type(function, result)),) if result is not None else () + if not context.inverted_descriptors: + continue + nodes.append(self._inverted_context_record(function, context)) + nodes.extend(self._inverted_consumer_chain(function, context)) + return tuple(nodes) + + def _inverted_context_record(self, plan: FunctionPlan, context: _CFunctionContext) -> CStructDefinition: + """Declare everything the consumer chain carries between its links.""" + chain = self._inverted_chain_fields(plan, context) + fields = self._inverted_context_fields(plan, context) + result = self._direct_result(plan) + result_field = (CParameter("result", self._inverted_result_type(plan, result)),) if result is not None else () + return CStructDefinition( + self._inverted_context_name(plan), + tuple(declaration for declaration, _value in chain + fields) + result_field, + ) + + def _inverted_consumer_chain(self, plan: FunctionPlan, context: _CFunctionContext) -> tuple[CFunction, ...]: + """Emit one consumer per descriptor, each entering the next. + + Every link records the descriptor it was handed and then enters the one + after it, so by the time the last link runs, every descriptor the call + needs is live at once and none of them has been copied or outlived the + entity it describes. The call is made there, where a callee that + reallocates or reassociates any of its arguments writes into the + descriptor the Fortran runtime copies back to that caller's entity. + """ + record = self._inverted_context_name(plan) + slots = len(context.inverted_descriptors) + fields = self._inverted_context_fields(plan, context) + functions: list[CFunction] = [] + for slot in range(slots): + last = slot == slots - 1 + body: tuple = ( + CExpressionStatement( + CodeExpression(f"call->{self._inverted_descriptor_field(slot)} = (CFI_cdesc_t *)descriptor") + ), ) - nodes.append( - CStructDefinition( - record, - tuple(declaration for declaration, _value in fields) + result_field, + if last: + body += (CExpressionStatement(CodeExpression(self._inverted_consumer_call(plan, context, fields))),) + doc = ( + f"Call {self._entrypoint_function_name(plan)} with every descriptor live.", + "Each argument's descriptor was recorded by the consumer that was handed" + " it, and every one of those is still running, so what the callee writes" + " into any of them is what Fortran copies back to that caller's entity.", + "Every other value the call needs arrives through the context record," + " because this runs outside the frame that computed them.", ) - ) - call = self._inverted_consumer_call(function, context, fields) - nodes.append( + else: + body += self._inverted_enter_nodes( + plan, + slot + 1, + backend=f"call->{self._inverted_backend_field(slot + 1)}", + call_context="call", + placeholder=f"call->{self._inverted_descriptor_field(slot + 1)}", + ) + doc = ( + f"Record descriptor {slot} of {self._entrypoint_function_name(plan)} and enter the next.", + "This descriptor is valid only while this function runs, so the call is" + " not made until every argument's descriptor has been entered and they" + " are all live together.", + ) + functions.append( CFunction( - self._inverted_consumer_name(function), + self._inverted_consumer_name(plan, slot), "void", parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), storage="static", - doc=( - f"Call {self._entrypoint_function_name(function)} on a live descriptor.", - "The callee may change the allocation of the array it receives. Making" - " the call here, while the descriptor the Fortran runtime built for it" - " is still valid, means the callee writes into the descriptor Fortran" - " copies back to the caller's entity, so a new allocation reaches it.", - "Every other value the call needs arrives through the context record," - " because this runs outside the frame that computed them.", - ), + doc=doc, body=( CDeclaration("call", f"{record} *", CodeExpression(f"({record} *)context")), - # The record is empty when the descriptor is the only value the - # call needs, and an unused local would warn. - *( - () - if fields or result is not None - else (CExpressionStatement(CodeExpression("(void)call")),) - ), - CExpressionStatement(CodeExpression(call)), + *body, CReturn(), ), ) ) - return tuple(nodes) + # A link may only be named once the one it enters has been defined. + return tuple(reversed(functions)) def _inverted_result_type(self, plan: FunctionPlan, result) -> str: """Return the C storage a carried direct result is written into.""" @@ -10332,17 +10351,19 @@ def _inverted_consumer_call( ) -> str: """Assemble the entrypoint call as the consumer makes it.""" carried = {value: f"call->{declaration.name}" for declaration, value in fields} - descriptor_value = context.arguments[context.inverted_descriptor].value_name + recorded = { + context.arguments[owner_path].value_name: f"call->{self._inverted_descriptor_field(slot)}" + for slot, owner_path in enumerate(context.inverted_descriptors) + } + owners = set(context.inverted_descriptors) arguments = [] for group in sorted(plan.entrypoint.parameters, key=lambda item: item.position): values = self._entrypoint_parameter_values(plan, group, context) - if group.owner_path == context.inverted_descriptor: - # Only the descriptor itself is the consumer's argument. An - # optional one is planned alongside its present flag, and that - # flag is an ordinary carried value like any other. - arguments.extend( - "(CFI_cdesc_t *)descriptor" if value == descriptor_value else carried[value] for value in values - ) + if group.owner_path in owners: + # Only the descriptor itself comes from its slot. An optional + # one is planned alongside its present flag, and that flag is an + # ordinary carried value like any other. + arguments.extend(recorded.get(value) or carried[value] for value in values) continue arguments.extend(carried[value] for value in values) call = f"{self._entrypoint_function_name(plan)}({', '.join(arguments)})" @@ -10357,17 +10378,19 @@ def _inverted_context_fields( plan: FunctionPlan, context: _CFunctionContext, ) -> tuple[tuple[CParameter, str], ...]: - """Pair every entrypoint value the consumer needs with its declaration. - - The inverted descriptor is excluded: the consumer receives that - directly. Everything else the call needs is carried into the consumer - through the context record, because the consumer runs outside the frame - that computed it -- including the present flag planned beside an - optional descriptor, which is a value like any other. + """Pair every entrypoint value the consumers need with its declaration. + + The descriptors themselves are excluded: each arrives as its own + consumer's argument and is recorded in the slot named for it. + Everything else the call needs is carried through the context record, + because the consumers run outside the frame that computed it -- + including the present flag planned beside an optional descriptor, which + is a value like any other, and the address of a hidden output, which + stays valid because this frame outlives every consumer it enters. """ - descriptor_value = ( - None if context.inverted_descriptor is None else context.arguments[context.inverted_descriptor].value_name - ) + descriptor_values = { + context.arguments[owner_path].value_name: owner_path for owner_path in context.inverted_descriptors + } pairs: list[tuple[CParameter, str]] = [] for group in sorted(plan.entrypoint.parameters, key=lambda item: item.position): declarations = self._entrypoint_parameter_declarations(plan, group) @@ -10377,13 +10400,92 @@ def _inverted_context_fields( pairs.extend( (declaration, value) for declaration, value in zip(declarations, values, strict=True) - if not (group.owner_path == context.inverted_descriptor and value == descriptor_value) + if descriptor_values.get(value) != group.owner_path + ) + return tuple(pairs) + + def _inverted_chain_fields( + self, + plan: FunctionPlan, + context: _CFunctionContext, + ) -> tuple[tuple[CParameter, str], ...]: + """Pair the slot each descriptor is recorded in with what starts it. + + A slot begins as whatever this frame already has for it: nothing for a + handle that will be entered, and the unallocated placeholder for an + absent optional, which is never entered and so is passed on as it + stands. Every backend after the first is carried too, because the + consumer that enters it runs outside this frame. + """ + pairs: list[tuple[CParameter, str]] = [] + for slot, owner_path in enumerate(context.inverted_descriptors): + if slot > 0: + pairs.append( + ( + CParameter(self._inverted_backend_field(slot), "prik_native_array_backend *"), + self._inverted_backend_local(context.arguments[owner_path]), + ) + ) + for slot, owner_path in enumerate(context.inverted_descriptors): + pairs.append( + ( + CParameter(self._inverted_descriptor_field(slot), "CFI_cdesc_t *"), + context.arguments[owner_path].value_name, + ) ) return tuple(pairs) - def _inverted_consumer_name(self, plan: FunctionPlan) -> str: - """Return the consumer that performs one inverted entrypoint call.""" - return f"{self._binding_function_name(plan)}_call_with_descriptor" + @staticmethod + def _inverted_backend_local(names: _CArgumentNames) -> str: + """Return the local holding one descriptor argument's backend.""" + return f"{names.value_name}_borrowed_backend" + + @staticmethod + def _inverted_descriptor_field(slot: int) -> str: + """Return the record slot one entered descriptor is recorded in.""" + return f"descriptor_{slot}" + + @staticmethod + def _inverted_backend_field(slot: int) -> str: + """Return the record slot one not-yet-entered backend is carried in.""" + return f"backend_{slot}" + + def _inverted_consumer_name(self, plan: FunctionPlan, slot: int) -> str: + """Return the consumer that enters one descriptor of an inverted call.""" + return f"{self._binding_function_name(plan)}_call_with_descriptor_{slot}" + + def _inverted_enter_nodes( + self, + plan: FunctionPlan, + slot: int, + *, + backend: str, + call_context: str, + placeholder: str, + ) -> tuple[CIf, ...]: + """Enter one descriptor's backend, or pass on what stands for it. + + A backend is absent only for an optional argument that was not + supplied: everything else publishes one, whether it borrows its + descriptor or owns it. There is no entity to enter for an absent one, + so the placeholder already recorded in its slot goes straight to the + same consumer and the chain continues from there. + """ + consumer = self._inverted_consumer_name(plan, slot) + return ( + CIf( + CodeExpression(f"{backend} != NULL"), + body=( + CExpressionStatement( + CodeExpression(f"{backend}->with_descriptor({backend}->context, {consumer}, {call_context})") + ), + ), + else_body=( + CComment("This optional argument is absent, so there is nothing to enter for it."), + CExpressionStatement(CodeExpression(f"{consumer}({placeholder}, {call_context})")), + ), + ), + ) def _output_nodes( self, @@ -11368,7 +11470,6 @@ def _function_context(self, plan: FunctionPlan) -> _CFunctionContext: python_result = self._python_result_name(plan) native_result = self._native_result_name(plan) role_values = self._argument_role_values(plan, arguments) - inverted = self._inverted_descriptor_argument(plan) return _CFunctionContext( arguments, native_outputs, @@ -11376,7 +11477,7 @@ def _function_context(self, plan: FunctionPlan) -> _CFunctionContext: python_result, python_results, role_values, - inverted.owner_path if inverted is not None else None, + tuple(argument.owner_path for argument in self._inverted_descriptor_arguments(plan)), plan, ) diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 1b64be413..a8fa50e74 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -316,37 +316,6 @@ static inline prik_native_array_backend *prik_native_array_backend_for_descripto return backend; } -/* - * Take a descriptor that stays valid after this returns. - * - * `prik_native_array_owned_with_descriptor` hands its consumer the context - * itself, so an owned backend's context *is* persistent descriptor storage and - * a caller may hold it for as long as the handle lives. A borrowed backend's - * descriptor is instead built by the Fortran runtime for one call and is gone - * when the consumer returns, so there is nothing here to hand back: such a - * handle can only be reached from inside `with_descriptor`. `release` is - * non-NULL exactly for storage this extension owns, which is the same fact and - * the one that survives being read from another extension, where the inline - * entry point above is a different function. - * - * A caller that cannot enter the consumer is told so rather than handed a - * pointer that would dangle the moment the call it was fetched for begins. - */ -static inline void *prik_native_array_backend_persistent_descriptor( - prik_native_array_backend *backend, - const char *argument_name) -{ - if (backend->release == NULL) { - PyErr_Format( - PyExc_TypeError, - "argument %s is a borrowed native array handle, whose descriptor is only valid inside a call; " - "this entrypoint takes more than one descriptor argument and cannot enter it", - argument_name); - return NULL; - } - return backend->context; -} - /* * Read a backend for an ordinary array actual. * diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index b85dd1270..008452de7 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -214,6 +214,16 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): allocate(second(n)); second = 9.0_8 end subroutine grow_pair + subroutine grow_and_count(values, n, produced) + real(8), allocatable, intent(inout) :: values(:) + integer(4), intent(in) :: n + integer(4), intent(out) :: produced + + if (allocated(values)) deallocate(values) + allocate(values(n)); values = 11.0_8 + produced = n + end subroutine grow_and_count + function assumed_total(actual) result(total) real(8), intent(in) :: actual(:) real(8) :: total @@ -442,31 +452,57 @@ def record(frame, event, _arg): assert descriptor.shape == (2,) -def test_two_descriptor_dummies_take_owned_storage_and_refuse_a_borrowed_handle(descriptor_matrix): - """A descriptor only one call can hold is refused, not handed over to be dangled. +def test_two_descriptor_dummies_reach_borrowed_and_owned_storage_alike(descriptor_matrix): + """Two descriptors are live at once, so both callees' writes reach their entities. - Reaching a borrowed entity means making the call inside the consumer that - holds its descriptor, and only one call can be inside one consumer. An - entrypoint with a second descriptor dummy therefore needs descriptors that - outlive a consumer, which only an owned handle has: a caller-created one is - placed, and a module array is refused while both handles stay usable. + Each argument is entered in turn and the call is made inside the last + consumer, where every descriptor the Fortran runtime built is still valid. + A module array and a caller-created handle are placed the same way, and + both see the reallocation the callee performed. """ left = descriptor_matrix.pair_left right = descriptor_matrix.pair_right - with pytest.raises(TypeError, match="borrowed native array handle"): - descriptor_matrix.grow_pair(left, right, np.int32(2)) - assert left.shape == (3,) - assert right.shape == (3,) + descriptor_matrix.grow_pair(left, right, np.int32(2)) + assert left.shape == (2,) + assert right.shape == (2,) + np.testing.assert_allclose(left.to_numpy(), np.array([8.0, 8.0])) + np.testing.assert_allclose(right.to_numpy(), np.array([9.0, 9.0])) owned_first = contracts.Allocatable[contracts.Float64[:]]() - owned_second = contracts.Allocatable[contracts.Float64[:]]() try: - descriptor_matrix.grow_pair(owned_first, owned_second, np.int32(2)) - assert owned_first.shape == (2,) - assert owned_second.shape == (2,) - np.testing.assert_allclose(owned_first.to_numpy(), np.array([8.0, 8.0])) - np.testing.assert_allclose(owned_second.to_numpy(), np.array([9.0, 9.0])) + # One borrowed and one owned handle in the same call: the chain enters + # whatever each publishes without distinguishing them. + descriptor_matrix.grow_pair(left, owned_first, np.int32(3)) + assert left.shape == (3,) + assert owned_first.shape == (3,) + np.testing.assert_allclose(owned_first.to_numpy(), np.array([9.0, 9.0, 9.0])) finally: owned_first.close() - owned_second.close() + + +def test_a_handle_reaches_a_call_with_a_hidden_output_without_running_python(descriptor_matrix): + """An intent(out) argument is carried, not read after the consumer returns. + + The call runs inside the consumer holding the descriptor, so the hidden + output is written through the address this frame carried in; the frame + outlives every consumer it enters, so reading it afterwards is sound and + no Python runs on the way. + """ + spare = descriptor_matrix.pair_left + called: list[str] = [] + + def record(frame, event, _arg): + if event == "call": + called.append(frame.f_code.co_name) + + descriptor_matrix.grow_and_count(spare, np.int32(2)) + sys.setprofile(record) + try: + produced = descriptor_matrix.grow_and_count(spare, np.int32(4)) + finally: + sys.setprofile(None) + + assert called == [] + assert int(produced[1]) == 4 + assert spare.shape == (4,) From 3479b32895396d71f55a4307775487ac3097859d Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 12:47:48 +0100 Subject: [PATCH 28/47] codex: Keep native array documentation current --- CHANGELOG.md | 57 ++++++++++------------------ docs/developer/packages/runtime.md | 58 +++++++++++++---------------- docs/user/guide/data-types.md | 50 ++++++++++--------------- docs/user/guide/wrapping-modules.md | 33 +++++----------- docs/user/reference/pyi-format.md | 2 +- 5 files changed, 74 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38bd3c36c..faedc6596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,43 +10,26 @@ release tags add a leading `v` to the package version. - Fixed Fortran allocatable and pointer descriptor arguments to use the compiler's live descriptor. This works on Intel ifx as well as GNU Fortran, preserves lower bounds, allocation and association changes, and covers - required and optional dummies reached from module variables, fields, results, - and caller-created handles. - -- Generated Fortran allocatable and pointer handles can satisfy matching - ordinary array arguments without conversion through NumPy. Supported forms - include explicit and assumed shape, positive strides, assumed size, assumed - rank 1 through 15, optional arrays, and fixed- or assumed-width character - arrays. C array arguments continue to accept NumPy arrays only. - -- Generated array handles now publish one versioned native capsule, - `prik.native_array_backend.v1`, replacing the separate descriptor-operation - table and owned-descriptor record. It carries a single entry point that runs - a consumer while the handle's descriptor is live, whether that descriptor is - one Fortran builds for the call or persistent storage the wrapper owns. - Extensions built against the earlier branch-only table must be regenerated. - -- Argument handoff, shape, allocation and association state, element width, - contiguity and NumPy views are now all read from that live descriptor in C. - No descriptor is serialized into Python fields and decoded back, and the - generated Fortran bridge no longer carries a procedure per variable for any - of those inquiries. A NumPy view now carries the descriptor's own byte - strides directly, so negative strides and non-contiguous pointer targets are - exposed without an intermediate buffer. - -- Fortran entrypoints can now accept generated array handles for several - allocatable or pointer dummies in one call, including calls with `intent(out)` - arguments or status outputs. Allocation and association changes reach each - supplied module, field, result, or caller-created handle. - -- Fixed writable module and derived-field allocatable arrays, and reject - PROTECTED module arrays during policy completion when writable access would be - required. - -- Fixed module array views and descriptor facts across fixed, target, - allocatable, pointer, shifted-bound, character, and logical storage. Ordinary - fixed-shape module arrays no longer require TARGET solely to expose a live - NumPy view. + required and optional dummies, multiple descriptor dummies per call, + `intent(out)` and status-bearing calls, and handles from module variables, + fields, results, and caller-created storage. + +- Generated Fortran allocatable and pointer handles can be passed directly to + matching ordinary array arguments. Supported forms include explicit and + assumed shape, positive strides, assumed size, assumed rank 1 through 15, + optional arrays, and fixed- or assumed-width character arrays. C array + arguments accept NumPy arrays. + +- Descriptor-backed NumPy views preserve native byte strides, including + negative strides, non-contiguous pointer targets, and zero-sized dimensions. + +- Writable module and derived-field allocatable arrays support allocation and + reassignment. PRIK rejects `protected` module arrays when the generated API + would require writable access. + +- Module array views cover fixed, target, allocatable, pointer, shifted-bound, + character, and logical storage. Ordinary fixed-shape module arrays expose + live NumPy views with or without `target`. - PRIK now selects interoperable Fortran logical storage on Intel and PGI/NVIDIA compilers. Use `--no-standard-logicals` or diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index dc27bc581..04c3ff0e8 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -56,8 +56,8 @@ typedef struct { } prik_native_array_backend; ``` -`with_descriptor(context, consumer, consumer_context)` is the only route to a -descriptor. It produces a live one and runs the consumer on it: +`with_descriptor(context, consumer, consumer_context)` supplies a live +descriptor and runs the consumer on it: - **Borrowed** — a module variable or a derived-type field. The entry point enters Fortran, which builds the descriptor for that call and copies back @@ -67,40 +67,32 @@ descriptor. It produces a live one and runs the consumer on it: storage. The binding allocated a descriptor and keeps it for the handle's life, so the entry point hands that storage straight to the consumer. -Consumers cannot tell the two apart and must not try to. `context` is whatever -the entity needs to be reached: the parent's address for a field, the -descriptor storage for an owned handle, `NULL` for a module variable. -`release` is non-`NULL` exactly when `context` is storage this extension -allocated, so a borrowed backend can never free anything, and clearing -`context` after one release makes `close()` and finalization both safe. - -The version lives in the capsule name: `PyCapsule_GetPointer` refuses a -capsule created under any other name, so no magic word or second version field -is carried. `struct_size` catches a layout change made without renaming; -`descriptor_size` is `sizeof(CFI_CDESC_T(rank))` and is the only way one -extension can attest another's CFI layout, which nothing inside a descriptor -can establish. `descriptor_kind`, `rank`, `cfi_type` and `element_size` are -what a reader compares against the dummy it is filling, so a mismatched actual -is refused before any Fortran is entered. `element_size` is `0` when the width -is only known at run time, as for a deferred-length character array. +Consumers use the same contract for both forms. `context` is the parent's +address for a field, descriptor storage for an owned handle, and `NULL` for a +module variable. `release` is non-`NULL` when the extension owns `context`. +Clearing `context` after release makes `close()` and finalization idempotent. + +The capsule name carries the ABI version. `struct_size` validates the backend +layout, and `descriptor_size` records `sizeof(CFI_CDESC_T(rank))` for the +producing extension. A consumer validates `descriptor_kind`, `rank`, +`cfi_type`, `element_size`, and descriptor size against its dummy before +entering Fortran. `element_size` is `0` for widths determined at run time, such +as deferred-length character arrays. ### Inquiries Read The Descriptor `shape`, `allocated`, `associated`, `contiguous`, `element_length` and `to_numpy` are all answered by small shared C consumers run through -`with_descriptor`, for borrowed and owned handles alike. Nothing crosses into -Python except the finished object, so no descriptor is serialized into Python -fields and no field is decoded back into C. There is correspondingly no Fortran -procedure per variable for any of them; the bridge emits only the descriptor -entry point and the mutations that must reach the entity itself — `allocate`, -`resize`, `deallocate`, `nullify`, `associate` and `destroy`. - -A pointer additionally reports `descriptor` as a flat fact tuple — base -address, element width, rank, then a lower bound, extent and byte stride per -axis. That is how a pointer assignment snapshots what another pointer is -associated with, which matters because a handle created from a `.pyi` contract -has no native storage until a call gives it some and so has nowhere else to -record it. +`with_descriptor`, for borrowed and owned handles alike. Each consumer returns +the completed Python value. The bridge provides the descriptor entry point and +the mutations that act on the entity itself: `allocate`, `resize`, +`deallocate`, `nullify`, `associate`, and `destroy`. + +A pointer additionally reports `descriptor` as a flat fact tuple: base address, +element width, rank, then a lower bound, extent, and byte stride per axis. A +pointer assignment uses this snapshot. A handle created from a `.pyi` contract +retains the snapshot until a call attaches native storage and replays the +association. A call with more than one allocatable or pointer dummy enters each argument's backend in turn. Each consumer records its descriptor and enters the next, and @@ -168,8 +160,8 @@ Generated resize received NumPy extents: True The example supplies the same dispatcher and capability set as generated code. It creates an allocatable handle, reads its live NumPy view, and routes a resize -through the dispatcher. The native header has no standalone Python route; the -compiler installs it into a generated `binding_support/` directory. +through the dispatcher. The compiler installs the native header into the +generated `binding_support/` directory. ## Change Routes And Evidence diff --git a/docs/user/guide/data-types.md b/docs/user/guide/data-types.md index f4e5d393b..c778036fe 100644 --- a/docs/user/guide/data-types.md +++ b/docs/user/guide/data-types.md @@ -195,7 +195,7 @@ table below explains the target-mantissa rule. | `complex(4)` | `Complex64` | `np.complex64` | `np.complex64` | | `complex(8)` | `Complex128` | `np.complex128` | `np.complex128` | | `complex(c_long_double_complex)` — `complex(10)` on x86-64 | `Complex256` | `np.clongdouble` | `np.clongdouble` | -| `logical` | `Bool8`-`Bool64` | `bool` | `bool` | +| `logical` | `Bool8`-`Bool64` | `bool` or `np.bool_` | `bool` | | `character` | `String` / `String[n]` | Depends on the string boundary | Depends on the string boundary | | Derived Type | Generated Class | Instance of that class | Instance of that class | @@ -206,25 +206,22 @@ double` is IEEE quad, `real(16)` maps to it instead. PRIK decides from the mantissa width the compiler reports, never from storage size — see [Unsupported Widths And Forms](#unsupported-widths-and-forms). -Boolean contract names describe native storage. A scalar crosses by value and is -converted, so it stays a Python `bool`. An **array is aliased**, element for -element, and NumPy has no Boolean wider than one byte — so a logical array -reports the integer dtype of matching width: +Boolean contract names describe native storage. Scalars cross as Python +`bool`. Arrays are aliased element by element: one-byte logical arrays use +`numpy.bool_`, and wider kinds use the integer dtype of matching width. | Semantic Contract | Native Logical Storage Represented | Scalar Input | Direct Result | Array Storage | | --- | --- | --- | --- | --- | -| `Bool` | 8 bits; portable default, equivalent to `Bool8` | `bool` | `bool` | `dtype=np.bool_` | -| `Bool8` | 8 bits | `bool` | `bool` | `dtype=np.bool_` | -| `Bool16` | 16 bits | `bool` | `bool` | `dtype=np.int16` | -| `Bool32` | 32 bits | `bool` | `bool` | `dtype=np.int32` | -| `Bool64` | 64 bits | `bool` | `bool` | `dtype=np.int64` | +| `Bool` | 8 bits; portable default, equivalent to `Bool8` | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | +| `Bool8` | 8 bits | `bool` or `np.bool_` | `bool` | `dtype=np.bool_` | +| `Bool16` | 16 bits | `bool` or `np.bool_` | `bool` | `dtype=np.int16` | +| `Bool32` | 32 bits | `bool` or `np.bool_` | `bool` | `dtype=np.int32` | +| `Bool64` | 64 bits | `bool` or `np.bool_` | `bool` | `dtype=np.int64` | Generated contracts select a numbered name after probing the chosen compiler. -A `logical(c_bool)` array is one byte per element holding zero or one, which is -exactly `numpy.bool_`, so it is exposed as a NumPy Boolean and needs no -conversion. A wider kind has no NumPy Boolean to be — there is none larger than -a byte — so it reports the integer of matching width and is read with +A `logical(c_bool)` array uses `numpy.bool_`. Wider logical kinds use the +integer dtype of matching width and can be read as Boolean values with `.astype(bool)`: ```python @@ -234,16 +231,13 @@ wide.astype(bool) # array([True, False, True]) wide[0] = 0 # visible to Fortran ``` -Write only `0` or `1` into an integer-typed logical array. Any other value is -undefined in Fortran itself, not just through PRIK: writing raw `2` into a -`logical` array and asking `count()` gives `4` on gfortran and `0` on ifx, and -each compiler then contradicts itself about whether a single element is true. +Write only `0` or `1` into an integer-typed logical array. Other integer values +are not portable Fortran logical representations. ### Compiler options for interoperable logicals -A Fortran `logical` has no fixed representation, and several compilers default -to one their own C compiler cannot read. PRIK requests the option that selects -the interoperable form, so this is handled for you: +PRIK requests each compiler's interoperable representation for Fortran +`logical` values: | Compiler | Option PRIK passes | | --- | --- | @@ -251,9 +245,7 @@ the interoperable form, so this is handled for you: | Intel `ifx` / `ifort` | `-standard-semantics` | | PGI / NVIDIA | `-Munixlogical` | -Without it, Intel stores all bits set for `.true.`, so a `logical(c_bool)` array -handed to C contains `255` where `_Bool` is defined to hold `1` — and C then -miscounts it. If you override PRIK's compiler flags, keep this one. +Keep the listed option when overriding PRIK's compiler flags. #### Turning it off for prebuilt Intel objects @@ -280,13 +272,9 @@ build_fortran_extension( ) ``` -That restores link compatibility at the cost of the guarantee above: `.true.` -is stored as all bits set again, so a `logical(c_bool)` array reaching NumPy -holds `255` for true. Comparisons against `True` and `.astype(bool)` still read -it correctly, because every non-zero value is true — but `numpy.bool_` values -that are neither `0` nor `1` are outside what NumPy documents, and -`tobytes()`, buffer sharing, and anything reading the raw byte will see `255`. -Prefer rebuilding the dependency. +Disabling standard logicals also disables the interoperable representation +guarantee. Prefer rebuilding the dependency with `-standard-semantics` when +possible. --- diff --git a/docs/user/guide/wrapping-modules.md b/docs/user/guide/wrapping-modules.md index 4af1900f3..974d63de7 100644 --- a/docs/user/guide/wrapping-modules.md +++ b/docs/user/guide/wrapping-modules.md @@ -136,23 +136,12 @@ The whole variable cannot be reassigned (`mod.grid = ...` raises `AttributeError`); its shape belongs to the Fortran declaration. Write into the view instead, with `mod.grid[:] = ...`. -- The `target` attribute is not required. It is what lets `c_loc` name a - variable in Fortran, not what gives a module array its address, so for an - ordinary declaration PRIK takes the address on the C side instead: the whole - array is passed to `prik_capture_address`, a `bind(C)` primitive in PRIK's - bundled support header whose assumed-size dummy receives the bare base - address. Both forms produce the same live view. -- Derived-type array fields never needed `target` either, and are borrowed the - same way. An object reached through its address makes its components - addressable, so `c_loc` names them directly; a member of a module object - declared without `target` takes the same C-side route as a module array. A - plain `real(real64) :: grid(2, 3)` component is a live view whether the type, - the field, or the containing module variable declares the attribute. -- A Fortran `logical` array is borrowed only when its kind is one byte wide - (`logical(c_bool)`). A wider kind — including the default `logical` on common - compilers — cannot be aliased by NumPy's one-byte bool, so it is reported - unsupported rather than exposed as a view that would read the wrong elements. - Return it from a procedure instead, which converts each element. +- Fixed-shape module arrays and derived-type array fields expose live views + whether or not their declarations include `target`. +- A `logical(c_bool)` array uses `numpy.bool_`; wider logical kinds use the + matching NumPy integer dtype. See the + [logical type mapping](data-types.md#scalar-type-mapping) for reading and + writing those values. - Allocatable module arrays use the `Allocatable[T[...]]` API. - Allocation, lifetime, NumPy views, and mutation rules are covered in the storage and objects section. @@ -161,13 +150,9 @@ view instead, with `mod.grid[:] = ...`. !!! warning "A borrowed view assumes module storage stays put" - The Fortran standard does not require a module variable to occupy one - address for the life of the program, so a view held across native code that - could relocate module storage — device offload, for instance — is your - responsibility rather than something the language guarantees. This holds on - the toolchains PRIK tests, and declaring `target` puts the language behind - it. If you would rather not hold a view at all, copy what you need: - `np.array(mod.grid)`. + A module view borrows the variable's current address. Copy it with + `np.array(mod.grid)` before native work that may relocate that storage. + Declaring the variable `target` gives its address Fortran-defined stability. --- diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index fb189603b..071f0e403 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -684,7 +684,7 @@ contract is generated. | Family | Available names | Normal Python boundary | | --- | --- | --- | -| Boolean | `Bool`, `Bool8`, `Bool16`, `Bool32`, `Bool64` | Scalars are `bool`; arrays are aliased and use the integer dtype of the same width (`numpy.uint8`, `int16`, `int32`, `int64`). Read with `.astype(bool)`. | +| Boolean | `Bool`, `Bool8`, `Bool16`, `Bool32`, `Bool64` | Scalar inputs accept `bool` or `numpy.bool_`; results are `bool`. `Bool` and `Bool8` arrays use `numpy.bool_`; wider arrays use `int16`, `int32`, or `int64`. | | Signed integer | `Int`, `Int8`, `Int16`, `Int32`, `Int64` | Matching NumPy integer scalar or array dtype. `Int` retains target-dependent C `int` identity. | | Unsigned integer | `UInt`, `UInt8`, `UInt16`, `UInt32`, `UInt64`, `SizeT` | Matching NumPy unsigned scalar or array dtype; `UInt` and `SizeT` are target-dependent. | | Real | `Float16`, `Float32`, `Float64`, `Float128` | Matching NumPy real dtype when the selected target supports it. | From 3e586a411c620de43c03fa10bc18478fee7bdec9 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 12:56:44 +0100 Subject: [PATCH 29/47] Say which storage an owned handle operation needs allocate, resize and deallocate take their own handle's descriptor out of the backend's context, which is right only for a backend that owns its storage. Nothing published those operations on a borrowed handle, so the raw cast was never reached with one -- but it is the same shape that handed a null descriptor to a callee, and naming the requirement makes a mis-wired capsule an error rather than a call into CFI_allocate with nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 13 +++++++++-- prik/runtime/native_support/prik_binding.h | 26 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index f789e2b0b..9a416d72a 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -5099,8 +5099,12 @@ def _owned_native_array_owner_nodes( *( ( CExpressionStatement( - CodeExpression(f"{prefix}_descriptor = (CFI_cdesc_t *){prefix}_backend->context") + CodeExpression( + f"{prefix}_descriptor = (CFI_cdesc_t *)prik_native_array_backend_owned_descriptor(" + f"{prefix}_backend)" + ) ), + CExpressionStatement(CodeExpression(f"if ({prefix}_descriptor == NULL) return NULL")), ) if materialize_descriptor else () @@ -5189,7 +5193,12 @@ def _owned_native_array_shape_mutation_body( ) ), CExpressionStatement(CodeExpression("if (owner_backend == NULL) return NULL")), - CExpressionStatement(CodeExpression("owner_descriptor = (CFI_cdesc_t *)owner_backend->context")), + CExpressionStatement( + CodeExpression( + "owner_descriptor = (CFI_cdesc_t *)prik_native_array_backend_owned_descriptor(owner_backend)" + ) + ), + CExpressionStatement(CodeExpression("if (owner_descriptor == NULL) return NULL")), ] for axis, item in enumerate(extent_objects): nodes.extend( diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index a8fa50e74..2c1e240c2 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -316,6 +316,32 @@ static inline prik_native_array_backend *prik_native_array_backend_for_descripto return backend; } +/* + * Take the descriptor a backend owns. + * + * `prik_native_array_owned_with_descriptor` hands its consumer the context + * itself, so an owned backend's context *is* persistent descriptor storage and + * may be held for as long as the handle lives. Only the operations published + * on an owned handle -- allocate, resize, deallocate, destroy -- ask for it, + * and only ever about their own handle's storage; a borrowed backend has + * nothing of the kind, and saying so here keeps a mis-wired one an error + * instead of a null descriptor handed to CFI_allocate. + * + * `release` is non-NULL exactly for storage this extension owns, which is the + * same fact and the one that survives being read from another extension, where + * the inline entry point above is a different function. + */ +static inline void *prik_native_array_backend_owned_descriptor(prik_native_array_backend *backend) +{ + if (backend->release == NULL) { + PyErr_SetString( + PyExc_TypeError, + "native array handle operation needs storage the handle owns; this one borrows its descriptor"); + return NULL; + } + return backend->context; +} + /* * Read a backend for an ordinary array actual. * From 127e3c4c02825d88baf9317ff1de70a941e28916 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 12:59:19 +0100 Subject: [PATCH 30/47] Record the capsule ABI break for extension builders The versioned capsule name is what refuses an incompatible producer, so an extension built against the earlier branch-only table has to be regenerated. That is the one thing a builder cannot find out from their own code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index faedc6596..0272c841a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ release tags add a leading `v` to the package version. `intent(out)` and status-bearing calls, and handles from module variables, fields, results, and caller-created storage. +- Generated array handles publish one versioned native capsule, + `prik.native_array_backend.v1`, replacing the separate descriptor-operation + table and owned-descriptor record. It carries a single entry point that runs + a consumer while the handle's descriptor is live. Extensions built against + the earlier branch-only table must be regenerated. + - Generated Fortran allocatable and pointer handles can be passed directly to matching ordinary array arguments. Supported forms include explicit and assumed shape, positive strides, assumed size, assumed rank 1 through 15, From daa8f29ecc7c6c1aefcacad19ff76874e61fbca7 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 18:28:54 +0100 Subject: [PATCH 31/47] codex: Complete native array backend audit --- CHANGELOG.md | 10 +- docs/user/guide/pointers.md | 5 + prik/codegen/c/binding.py | 396 ++++++------------ prik/codegen/fortran/bridge.py | 58 +-- prik/codegen/nodes.py | 6 + prik/codegen/primitive_scalar_types.py | 11 + prik/contracts/__init__.py | 14 +- prik/naming/native_symbols.py | 7 + prik/planning/entrypoints.py | 11 +- prik/policy/completion.py | 36 +- prik/policy/construction.py | 34 +- prik/policy/native_array_handles.py | 2 + prik/runtime/handles.py | 2 +- prik/runtime/native_support/prik_binding.h | 2 +- tests/fortran/_support/ownership_policy.py | 2 + .../codegen/test_allocatable_lowering.py | 22 +- .../codegen/test_array_buffer_lowering.py | 2 +- .../codegen/test_specialized_array_roles.py | 2 +- .../codegen/test_strided_array_lowering.py | 6 +- .../test_logical_kind_array_conversions.py | 132 +++++- .../test_native_handle_array_forms.py | 66 ++- .../test_contract_scalar_constructors.py | 17 + .../test_derived_array_field_lowering.py | 31 +- .../runtime/test_native_support.py | 4 - .../codegen/test_native_handle_planning.py | 20 +- .../end_to_end/test_logical_array_views.py | 10 + .../test_module_variables_and_state.py | 19 + .../policy/test_pointer_ownership_policy.py | 25 ++ 28 files changed, 563 insertions(+), 389 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0272c841a..173085b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ release tags add a leading `v` to the package version. `intent(out)` and status-bearing calls, and handles from module variables, fields, results, and caller-created storage. +- Descriptor-backed native calls honor `@nogil` while the native procedure is + running, including calls with multiple allocatable or pointer arguments. + +- Deferred-length character pointer-array arguments are rejected before code + generation; module and field handles expose only operations that do not + require an unsupported `bind(C)` descriptor interface. + - Generated array handles publish one versioned native capsule, `prik.native_array_backend.v1`, replacing the separate descriptor-operation table and owned-descriptor record. It carries a single entry point that runs @@ -41,7 +48,8 @@ release tags add a leading `v` to the package version. PGI/NVIDIA compilers. Use `--no-standard-logicals` or `standard_logicals=False` only when linking Intel objects compiled without that option. Wider logical arrays are exposed with their matching integer - dtype; `logical(c_bool)` remains `numpy.bool_`. + dtype, including caller-created allocatable and pointer handles; + `logical(c_bool)` remains `numpy.bool_`. ## 0.4.3 — 2026-08-31 diff --git a/docs/user/guide/pointers.md b/docs/user/guide/pointers.md index b9fe64301..8d30e37e5 100644 --- a/docs/user/guide/pointers.md +++ b/docs/user/guide/pointers.md @@ -99,6 +99,11 @@ boundary as values rather than array handles. support allocation, target deallocation, resizing, and NumPy extraction. An unavailable operation raises `NotImplementedError`. +A deferred-length character pointer array can report `associated`, `shape`, +and its current element width, and can be nullified or deallocated. It cannot +be passed as a pointer-descriptor argument or exposed with `to_numpy()`, because +Fortran does not permit its descriptor form in a `bind(C)` interface. + --- ## Associate Two Pointers diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 9a416d72a..3fbaf7310 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -3086,7 +3086,11 @@ def _field_handle_backend_release_nodes(self, field: DerivedFieldPlan, prefix: s def _field_handle_backend_capsule_name(self, field: DerivedFieldPlan, prefix: str) -> str: """Return the local holding this field handle's published backend.""" handle = field.native_array_handle - if handle is None or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + if ( + handle is None + or not handle.descriptor_inquiries + or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ): return "Py_None" return f"{prefix}_native_backend" @@ -3102,7 +3106,10 @@ def _field_handle_backend_capsule_nodes( handle = field.native_array_handle if handle is None or handle.array.rank is None: return () - if handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + if ( + not handle.descriptor_inquiries + or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ): return () cfi_type = self._field_native_array_cfi_type(field) if cfi_type is None: @@ -3667,7 +3674,11 @@ def _field_handle_with_descriptor_prototypes( ) -> tuple[CFunctionPrototype, ...]: """Declare the forwarder driving one field's descriptor bridge.""" handle = field.native_array_handle - if handle is None or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + if ( + handle is None + or not handle.descriptor_inquiries + or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ): return () return ( CFunctionPrototype( @@ -3728,15 +3739,16 @@ def _derived_handle_operation_functions(self, plan: ModulePlan) -> tuple[CFuncti ) ) for owner, field, operation_name, descriptor_callback in self._derived_handle_targets(plan): - functions.extend( - self._field_handle_backend_nodes( - field, - self._field_handle_bridge_name(owner, field, NativeArrayOperation.DESCRIPTOR), - self._field_handle_with_descriptor_name(descriptor_callback), - takes_owner=isinstance(owner, DerivedTypePlan), - ) - ) handle = field.native_array_handle + if handle is not None and handle.descriptor_inquiries: + functions.extend( + self._field_handle_backend_nodes( + field, + self._field_handle_bridge_name(owner, field, NativeArrayOperation.DESCRIPTOR), + self._field_handle_with_descriptor_name(descriptor_callback), + takes_owner=isinstance(owner, DerivedTypePlan), + ) + ) if handle is None: continue functions.append( @@ -3801,7 +3813,10 @@ def _field_handle_backend_nodes( handle = field.native_array_handle if handle is None or handle.array.rank is None: return () - if handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR: + if ( + not handle.descriptor_inquiries + or handle.handoff.abi is not NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + ): return () return ( CFunction( @@ -4294,7 +4309,11 @@ def _native_array_bridge_inquiry_nodes( arguments = ", ".join((*((owner_args,) if owner_args else ()), *(f"&{name}" for name in extents))) return ( *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in extents), - CExpressionStatement(CodeExpression(f"{shape_bridge}({arguments})")), + CDeclaration("present", "bool", CodeExpression(f"{shape_bridge}({arguments})")), + CIf( + CodeExpression("!present"), + body=(CReturn(CodeExpression("Py_NewRef(Py_None)")),), + ), CDeclaration("shape", "PyObject *", CodeExpression(f"PyTuple_New({rank})")), CIf(CodeExpression("shape == NULL"), body=(CReturn(CodeExpression("NULL")),)), *( @@ -4366,38 +4385,8 @@ def _module_native_array_data_operation_body( ) raise ValueError(f"Unsupported module native array operation for {variable.owner_path!r}: {operation!r}") - def _module_native_array_shape_body( - self, - variable: ModuleVariablePlan, - ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: - """Return current module-array extents as one Python tuple.""" - handle = variable.native_array_handle - if handle is None or handle.array.rank is None: - raise ValueError(f"Module handle {variable.owner_path!r} has no rank") - rank = handle.array.rank - extents = tuple(f"extent_{axis}" for axis in range(rank)) - return ( - *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in extents), - CExpressionStatement( - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.SHAPE)}(" - f"{', '.join(f'&{name}' for name in extents)})" - ) - ), - CDeclaration("shape", "PyObject *", CodeExpression(f"PyTuple_New({rank})")), - CIf(CodeExpression("shape == NULL"), body=(CReturn(CodeExpression("NULL")),)), - *( - CExpressionStatement( - CodeExpression(f"PyTuple_SET_ITEM(shape, {axis}, PyLong_FromLongLong((long long){name}))") - ) - for axis, name in enumerate(extents) - ), - CExpressionStatement(CodeExpression("if (PyErr_Occurred()) { Py_DECREF(shape); return NULL; }")), - CReturn(CodeExpression("shape")), - ) - @staticmethod - def _uses_module_allocatable_descriptor(variable: ModuleVariablePlan) -> bool: + def _uses_module_descriptor_backend(variable: ModuleVariablePlan) -> bool: """Return whether a handle reaches its descriptor through a consumer. A module array hands its variable to a consumer rather than filling a @@ -4419,7 +4408,7 @@ def _module_native_array_backend_functions( variable: ModuleVariablePlan, ) -> tuple[CFunction, ...]: """Return the entry point one module array publishes, and its record.""" - if not self._uses_module_allocatable_descriptor(variable): + if not self._uses_module_descriptor_backend(variable): return () handle = variable.native_array_handle if handle is None or handle.array.rank is None: @@ -4446,7 +4435,7 @@ def _module_native_array_backend_nodes( element_size = ( "0" if variable.datatype_family is DatatypeFamily.STRING - else f"sizeof({PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name).c_spelling})" + else f"sizeof({PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name).array_c_spelling})" ) return ( CFunction( @@ -4484,11 +4473,12 @@ def _module_native_array_backend_nodes( def _emits_native_array_backend(self, plan: ModulePlan) -> bool: """Report whether any handle in this module publishes a native backend.""" if any( - self._uses_module_allocatable_descriptor(variable) for variable in self._module_native_array_variables(plan) + self._uses_module_descriptor_backend(variable) for variable in self._module_native_array_variables(plan) ): return True return any( field.native_array_handle is not None + and field.native_array_handle.descriptor_inquiries and field.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR for _owner, field, _operation_name, _callbacks in self._derived_handle_targets(plan) ) @@ -4514,7 +4504,7 @@ def _native_array_forward_descriptor_function() -> CFunction: def _module_native_array_backend_capsule_name(self, variable: ModuleVariablePlan, prefix: str) -> str: """Return the local holding this variable's published backend.""" - if not self._uses_module_allocatable_descriptor(variable): + if not self._uses_module_descriptor_backend(variable): return "Py_None" return f"{prefix}_native_backend" @@ -4524,7 +4514,7 @@ def _module_native_array_backend_declaration_nodes( prefix: str, ) -> tuple[CDeclaration, ...]: """Declare and build the capsule publishing one variable's backend.""" - if not self._uses_module_allocatable_descriptor(variable): + if not self._uses_module_descriptor_backend(variable): return () return ( CDeclaration( @@ -4540,13 +4530,13 @@ def _module_native_array_backend_release_nodes( prefix: str, ) -> tuple[CExpressionStatement, ...]: """Release the reference the published backend capsule was created with.""" - if not self._uses_module_allocatable_descriptor(variable): + if not self._uses_module_descriptor_backend(variable): return () return (CExpressionStatement(CodeExpression(f"Py_XDECREF({prefix}_native_backend)")),) def _module_native_array_backend_capsule(self, variable: ModuleVariablePlan) -> str: """Return the expression publishing this variable's native backend.""" - if not self._uses_module_allocatable_descriptor(variable): + if not self._uses_module_descriptor_backend(variable): return "Py_None" return f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, NULL)" @@ -4693,7 +4683,7 @@ def _default_native_array_binder_function( argument.datatype_family, ) cfi_type = self._native_array_cfi_type(argument) - elem_len = f"sizeof({PrimitiveScalarTypeRegistry.type_for(argument.semantic_type_name).c_spelling})" + elem_len = f"sizeof({PrimitiveScalarTypeRegistry.type_for(argument.semantic_type_name).array_c_spelling})" dispatch = self._owned_native_array_dispatch_name(function, argument) nodes: list[CDeclaration | CExpressionStatement | CIf | CReturn] = [ CDeclaration("handle_obj", "PyObject *"), @@ -5034,7 +5024,7 @@ def _pointer_association_cfi_type( return "CFI_type_char" elif plan.datatype_family is DatatypeFamily.STRING: return "CFI_type_char" - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_cfi_type def _owned_native_array_deallocate_body( self, @@ -5651,7 +5641,7 @@ def _lower_module_getter_native_array_handle(self, plan: ModuleVariablePlan) -> ), ), ) - if self._uses_module_allocatable_descriptor(plan) + if self._uses_module_descriptor_backend(plan) else () ), ] @@ -6845,7 +6835,7 @@ def _outlined_array_bind_nodes( CExpressionStatement(CodeExpression(f"{prefix}_bind_fixed[{axis}] = {value}")) for axis, value in enumerate(fixed) ), - *self._array_actual_table_nodes( + *self._array_actual_backend_nodes( plan, context, names, @@ -6862,19 +6852,19 @@ def _outlined_array_bind_nodes( ), ) - def _array_actual_table_nodes( + def _array_actual_backend_nodes( self, plan: ArgumentTransferPlan, context: _CFunctionContext, names: _CArgumentNames, fallback: CExpressionStatement, ) -> tuple: - """Take an array handle's storage from its table when it publishes one. + """Take an array handle's storage from its backend when it publishes one. - A handle standing for native storage names it through its table, so the + A handle standing for native storage names it through its backend, so the address and extents an ordinary dummy needs are read here rather than asked for through the runtime one operation at a time. Anything else -- - an ndarray, or a handle with no table -- takes the shared binder. + an ndarray, or a handle with no backend -- takes the shared binder. """ function = context.function if ( @@ -6891,7 +6881,7 @@ def _array_actual_table_nodes( record = self._array_actual_reader_record_name(function, plan) reader = self._array_actual_reader_name(function, plan) capsule = f"{prefix}_actual_capsule" - table = f"{prefix}_actual_ops" + backend = f"{prefix}_actual_backend" found = f"{prefix}_actual_found" actual = plan.native_array_actual contiguous_check: tuple = () @@ -6912,7 +6902,7 @@ def _array_actual_table_nodes( ) checks: list = [ CComment("Each condition is reported the way the runtime reports it,"), - CComment("so a handle reads alike whether or not it publishes a table."), + CComment("so a handle reads alike whether or not it publishes a backend."), CIf( CodeExpression(f"{found}.refused == 2"), body=( @@ -6920,7 +6910,7 @@ def _array_actual_table_nodes( CodeExpression( f"PyErr_Format(PyExc_TypeError, \"%s handle dtype dtype('S%zu') does not " f"match expected dtype dtype('S%d')\", " - f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + f"{backend}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " f'? "pointer" : "allocatable", {found}.width, ' f"{self._declared_character_width(plan)})" ) @@ -6934,7 +6924,7 @@ def _array_actual_table_nodes( CExpressionStatement( CodeExpression( f"PyErr_SetString(PyExc_ValueError, " - f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + f"{backend}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " f'? "pointer handle is unassociated and cannot be passed as an array actual" ' f': "allocatable handle is unallocated and cannot be passed as an array actual")' ) @@ -6973,7 +6963,7 @@ def _array_actual_table_nodes( ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_backend *", CodeExpression("NULL")), + CDeclaration(backend, "prik_native_array_backend *", CodeExpression("NULL")), CDeclaration(found, record), CExpressionStatement(CodeExpression(f"{found}.present = 0")), CExpressionStatement(CodeExpression(f"{found}.contiguous = 0")), @@ -6988,7 +6978,7 @@ def _array_actual_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_backend_for_actual({capsule}, " + f"{backend} = prik_native_array_backend_for_actual({capsule}, " f"{plan.array.minimum_rank}, {plan.array.maximum_rank}, " f"{self._native_array_cfi_type(plan)}, " f"{self._native_array_expected_element_size(plan)}, " @@ -6996,9 +6986,9 @@ def _array_actual_table_nodes( ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), - CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CIf(CodeExpression(f"{backend} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement( - CodeExpression(f"{table}->with_descriptor({table}->context, {reader}, &{found})") + CodeExpression(f"{backend}->with_descriptor({backend}->context, {reader}, &{found})") ), *checks, ), @@ -7176,39 +7166,39 @@ def _native_array_actual_call_nodes( context: _CFunctionContext, names: _CArgumentNames, ) -> tuple[CExpressionStatement, ...]: - """Read a normal-array native handle through its descriptor table.""" + """Read a normal-array native handle through its descriptor backend.""" actual = plan.native_array_actual if actual is None: return () prefix = names.value_name - table_nodes = self._native_array_actual_table_nodes(plan, context, names) + backend_nodes = self._native_array_actual_backend_nodes(plan, context, names) refuse = self._native_array_actual_type_refusal(plan, names) - if not table_nodes: + if not backend_nodes: return (refuse,) return ( - *table_nodes, + *backend_nodes, CIf(CodeExpression(f"{prefix}_actual.data == NULL"), body=(refuse,)), ) - def _native_array_actual_table_nodes( + def _native_array_actual_backend_nodes( self, plan: ArgumentTransferPlan, context: _CFunctionContext, names: _CArgumentNames, ) -> tuple: - """Fill the array-actual record from a handle's table when it has one. + """Fill the array-actual record from a handle's backend when it has one. - A handle standing for native storage names it through its table, so the + A handle standing for native storage names it through its backend, so the record is filled here rather than assembled by asking the runtime one - operation at a time. Anything without a compatible table is refused. + operation at a time. Anything without a compatible backend is refused. """ if not self._inline_array_actual_fast_path(plan): return () prefix = names.value_name - capsule = f"{prefix}_table_capsule" - table = f"{prefix}_table" + capsule = f"{prefix}_backend_capsule" + backend = f"{prefix}_native_backend" record = self._array_actual_struct_reader_record_name(plan) - found = f"{prefix}_table_result" + found = f"{prefix}_backend_result" refusals = ( CIf( CodeExpression(f"{found}.refused == 1"), @@ -7216,7 +7206,7 @@ def _native_array_actual_table_nodes( CExpressionStatement( CodeExpression( f"PyErr_SetString(PyExc_ValueError, " - f"{table}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + f"{backend}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " f'? "pointer handle is unassociated and cannot be passed as an array actual" ' f': "allocatable handle is unallocated and cannot be passed as an array actual")' ) @@ -7265,7 +7255,7 @@ def _native_array_actual_table_nodes( ) return ( CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(table, "prik_native_array_backend *", CodeExpression("NULL")), + CDeclaration(backend, "prik_native_array_backend *", CodeExpression("NULL")), CDeclaration(found, record), CExpressionStatement(CodeExpression(f"{found}.actual = &{prefix}_actual")), CExpressionStatement(CodeExpression(f"{found}.refused = 1")), @@ -7280,7 +7270,7 @@ def _native_array_actual_table_nodes( body=( CExpressionStatement( CodeExpression( - f"{table} = prik_native_array_backend_for_actual({capsule}, " + f"{backend} = prik_native_array_backend_for_actual({capsule}, " f"{plan.array.minimum_rank}, {plan.array.maximum_rank}, " f"{self._native_array_cfi_type(plan)}, " f"{self._native_array_expected_element_size(plan)}, " @@ -7288,10 +7278,10 @@ def _native_array_actual_table_nodes( ) ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), - CIf(CodeExpression(f"{table} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CIf(CodeExpression(f"{backend} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CExpressionStatement( CodeExpression( - f"{table}->with_descriptor({table}->context, " + f"{backend}->with_descriptor({backend}->context, " f"{self._array_actual_struct_reader_name(plan)}, &{found})" ) ), @@ -7874,7 +7864,7 @@ def _inverted_descriptor_backend_nodes( """ prefix = names.value_name capsule = f"{prefix}_backend_capsule" - backend = f"{prefix}_borrowed_backend" + backend = f"{prefix}_native_backend" # Presence is otherwise decided by the packing helper, which only the # other branch calls. A handle that published a backend was supplied, so # an optional argument reaching this branch is present. @@ -7886,13 +7876,7 @@ def _inverted_descriptor_backend_nodes( if plan.entrypoint.pass_descriptor_presence else () ) - return ( - CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), - CDeclaration(backend, "prik_native_array_backend *", CodeExpression("NULL")), - CComment( - f"'{plan.binding.python_name}' may have its {_descriptor_binding_noun(handle)} changed by the callee." - ), - CComment("The backend supplies a live descriptor inside the consumer."), + resolve = ( CExpressionStatement( CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_backend")') ), @@ -7920,6 +7904,30 @@ def _inverted_descriptor_backend_nodes( ), ), ) + if plan.binding.optional_mode is OptionalMode.DESCRIPTOR: + absent = ( + self._absent_descriptor_placeholder_nodes(plan, names, handle, packed_owner=None) + if plan.entrypoint.pass_descriptor_presence + else () + ) + resolve = ( + CIf( + CodeExpression(f"{names.object_name} == Py_None"), + body=( + CComment("An absent optional has no handle or backend to enter."), + *absent, + ), + else_body=resolve, + ), + ) + return ( + CDeclaration(capsule, "PyObject *", CodeExpression("NULL")), + CComment( + f"'{plan.binding.python_name}' may have its {_descriptor_binding_noun(handle)} changed by the callee." + ), + CComment("The backend supplies a live descriptor inside the consumer."), + *resolve, + ) def _lower_argument_native_array_direct( self, @@ -8135,10 +8143,7 @@ def _native_descriptor_pointer_unpack_nodes( CReturn(CodeExpression("NULL")), ), ), - CComment("Attaching storage published a backend; the chain enters that."), - CExpressionStatement( - CodeExpression(f"{self._inverted_backend_local(names)} = {prefix}_native_backend") - ), + CComment("Attaching storage published the backend the chain enters."), ), else_body=absent, ), @@ -8149,6 +8154,8 @@ def _absent_descriptor_placeholder_nodes( plan: ArgumentTransferPlan, names: _CArgumentNames, handle: NativeArrayHandlePlan, + *, + packed_owner: str | None = "packed", ) -> tuple[CComment | CExpressionStatement | CIf, ...]: """Establish the unallocated descriptor an absent optional hands over. @@ -8168,6 +8175,11 @@ def _absent_descriptor_placeholder_nodes( cfi_type = self._native_array_cfi_type(plan) elem_len = self._native_array_expected_element_size(plan) status = f"{prefix}_establish_status" + cleanup = ( + (CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_{packed_owner})")),) + if packed_owner is not None + else () + ) return ( CComment("Absent: hand over an unallocated placeholder, not a descriptor of"), CComment("someone else's storage. The present flag tells the bridge to ignore it."), @@ -8186,177 +8198,17 @@ def _absent_descriptor_placeholder_nodes( f'for argument {plan.binding.python_name}: %d", {status})' ) ), - CExpressionStatement(CodeExpression(f"Py_DECREF({prefix}_packed)")), + *cleanup, CReturn(CodeExpression("NULL")), ), ), CExpressionStatement(CodeExpression(f"{names.value_name} = (CFI_cdesc_t *)&{prefix}_storage")), ) - def _native_descriptor_fact_present_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - ) -> tuple[CExpressionStatement, ...]: - """Unpack one present fact tuple and initialize its CFI dimensions.""" - handle = plan.native_array_handle - if handle is None or handle.array.rank is None: - return () - prefix = names.value_name - rank = handle.array.rank - nodes = [ - *self._native_descriptor_integer_field_nodes(prefix, f"{prefix}_base_addr", 0, pointer=True), - *self._native_descriptor_integer_field_nodes(prefix, f"{prefix}_elem_len", 1), - *self._native_descriptor_integer_field_nodes(prefix, f"{prefix}_descriptor_rank", 2), - ] - for axis in range(rank): - offset = 3 + 3 * axis - nodes.extend(self._native_descriptor_integer_field_nodes(prefix, f"{prefix}_lower_bound_{axis}", offset)) - nodes.extend( - self._native_descriptor_integer_field_nodes(prefix, f"{prefix}_descriptor_extent_{axis}", offset + 1) - ) - nodes.extend( - self._native_descriptor_integer_field_nodes(prefix, f"{prefix}_stride_multiplier_{axis}", offset + 2) - ) - nodes.append( - CExpressionStatement( - CodeExpression( - f"if ({prefix}_descriptor_rank != {rank}) {{ PyErr_Format(PyExc_ValueError, " - f'"native descriptor rank %lld does not match planned rank {rank} for argument ' - f'{plan.binding.python_name}", (long long){prefix}_descriptor_rank); ' - f"Py_DECREF({prefix}_packed); return NULL; }}" - ) - ) - ) - nodes.extend(self._native_descriptor_establish_nodes(plan, names)) - return tuple(nodes) - - def _native_descriptor_fact_absent_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - ) -> tuple[CExpressionStatement, ...]: - """Establish one valid placeholder descriptor for an omitted argument.""" - handle = plan.native_array_handle - if handle is None or handle.array.rank is None: - return () - prefix = names.value_name - nodes = [ - CExpressionStatement( - CodeExpression(f"{prefix}_elem_len = {self._native_descriptor_placeholder_elem_len(plan)}") - ), - CExpressionStatement(CodeExpression(f"{prefix}_descriptor_rank = {handle.array.rank}")), - ] - for axis in range(handle.array.rank): - nodes.append( - CExpressionStatement( - CodeExpression(f"{prefix}_stride_multiplier_{axis} = (CFI_index_t){prefix}_elem_len") - ) - ) - nodes.extend(self._native_descriptor_establish_nodes(plan, names)) - return tuple(nodes) - - def _native_descriptor_establish_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - ) -> tuple[CExpressionStatement, ...]: - """Establish call-local descriptor storage from already completed facts.""" - handle = plan.native_array_handle - if handle is None or handle.array.rank is None: - return () - prefix = names.value_name - rank = handle.array.rank - cfi_type = self._native_array_cfi_type(plan) - if cfi_type is None: - raise ValueError(f"Missing CFI type for {plan.owner_path!r}") - attribute = ( - "CFI_attribute_allocatable" - if handle.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE - else "CFI_attribute_pointer" - ) - nodes = [ - *( - CExpressionStatement( - CodeExpression(f"{prefix}_cfi_extents[{axis}] = {prefix}_descriptor_extent_{axis}") - ) - for axis in range(rank) - ), - CExpressionStatement( - CodeExpression( - f"{prefix}_establish_status = CFI_establish((CFI_cdesc_t *)&{prefix}_storage, " - f"{prefix}_base_addr, {attribute}, {cfi_type}, " - f"{prefix}_elem_len, {rank}, {prefix}_cfi_extents)" - ) - ), - CExpressionStatement( - CodeExpression( - f"if ({prefix}_establish_status != CFI_SUCCESS) {{ PyErr_Format(PyExc_RuntimeError, " - f'"Unable to establish native descriptor for argument {plan.binding.python_name}: %d", ' - f"{prefix}_establish_status); Py_DECREF({prefix}_packed); return NULL; }}" - ) - ), - ] - for axis in range(rank): - nodes.extend( - ( - CExpressionStatement( - CodeExpression( - f"((CFI_cdesc_t *)&{prefix}_storage)->dim[{axis}].lower_bound = {prefix}_lower_bound_{axis}" - ) - ), - CExpressionStatement( - CodeExpression( - f"((CFI_cdesc_t *)&{prefix}_storage)->dim[{axis}].extent = " - f"{prefix}_descriptor_extent_{axis}" - ) - ), - CExpressionStatement( - CodeExpression( - f"((CFI_cdesc_t *)&{prefix}_storage)->dim[{axis}].sm = {prefix}_stride_multiplier_{axis}" - ) - ), - ) - ) - nodes.append(CExpressionStatement(CodeExpression(f"{names.value_name} = (CFI_cdesc_t *)&{prefix}_storage"))) - return tuple(nodes) - - @staticmethod - def _native_descriptor_placeholder_elem_len(plan: ArgumentTransferPlan) -> str: - """Return a valid element length for one absent call-local descriptor.""" - if plan.datatype_family is DatatypeFamily.STRING: - return "0" - return f"sizeof({PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).c_spelling})" - - def _native_descriptor_integer_field_nodes( - self, - prefix: str, - target: str, - index: int, - *, - pointer: bool = False, - ) -> tuple[CExpressionStatement, ...]: - """Decode one validated tuple integer with local failure cleanup.""" - converter = "PyLong_AsVoidPtr" if pointer else "PyLong_AsLongLong" - cast = "(void *)" if pointer else "" - error = f"{target} == NULL && PyErr_Occurred()" if pointer else "PyErr_Occurred()" - return ( - CExpressionStatement(CodeExpression(f"{prefix}_item = PyTuple_GetItem({prefix}_packed, {index})")), - CExpressionStatement( - CodeExpression(f"if ({prefix}_item == NULL) {{ Py_DECREF({prefix}_packed); return NULL; }}") - ), - CExpressionStatement(CodeExpression(f"{target} = {cast}{converter}({prefix}_item)")), - CExpressionStatement(CodeExpression(f"if ({error}) {{ Py_DECREF({prefix}_packed); return NULL; }}")), - ) - def _native_array_dtype(self, plan: ArgumentTransferPlan) -> str | None: """Return the NumPy dtype spelling already selected by primitive type.""" return self._native_array_dtype_for_semantic_type(plan.semantic_type_name, plan.datatype_family) - def _native_array_dtype_for_result(self, plan: ResultPlan) -> str | None: - """Return the NumPy dtype spelling selected for one handle result.""" - return self._native_array_dtype_for_semantic_type(plan.semantic_type_name, plan.datatype_family) - def _native_array_dtype_for_semantic_type( self, semantic_type_name: str, @@ -8383,25 +8235,25 @@ def _native_array_cfi_type(self, plan: ArgumentTransferPlan | ResultPlan) -> str """Return the standard-descriptor element type after array-family dispatch.""" if plan.datatype_family is DatatypeFamily.STRING: return "CFI_type_char" - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_cfi_type def _field_native_array_cfi_type(self, field: DerivedFieldPlan) -> str | None: """Return one field handle's standard-descriptor element type.""" if field.string_element: return "CFI_type_char" - return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).cfi_type_spelling + return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_cfi_type def _field_native_array_element_size(self, field: DerivedFieldPlan) -> str: """Return one field handle's fixed element size, or zero for a runtime width.""" if field.string_element: return "0" - return f"sizeof({PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).c_spelling})" + return f"sizeof({PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_c_spelling})" def _module_native_array_cfi_type(self, plan: ModuleVariablePlan) -> str | None: """Return one module handle's standard-descriptor element type.""" if plan.datatype_family is DatatypeFamily.STRING: return "CFI_type_char" - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).cfi_type_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_cfi_type def _native_array_handle_factory_call( self, @@ -9445,13 +9297,6 @@ def _inverted_descriptor_arguments(self, plan: FunctionPlan) -> tuple[ArgumentTr and argument.native_array_handle.array.rank is not None ) - def _inverted_descriptor_slot(self, plan: FunctionPlan, owner_path: str) -> int: - """Return the position one descriptor argument holds in the chain.""" - for slot, argument in enumerate(self._inverted_descriptor_arguments(plan)): - if argument.owner_path == owner_path: - return slot - raise ValueError(f"{owner_path!r} is not reached through a descriptor entry point") - def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) -> tuple: """Emit the native call, inside the consumers holding its descriptors.""" if not context.inverted_descriptors: @@ -9474,7 +9319,7 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) *self._inverted_enter_nodes( plan, 0, - backend=self._inverted_backend_local(names), + backend=self._descriptor_backend_local(names), call_context="&call_context", placeholder=f"call_context.{self._inverted_descriptor_field(0)}", ), @@ -10305,7 +10150,10 @@ def _inverted_consumer_chain(self, plan: FunctionPlan, context: _CFunctionContex ), ) if last: - body += (CExpressionStatement(CodeExpression(self._inverted_consumer_call(plan, context, fields))),) + body += self._lower_native_call( + plan, + CExpressionStatement(CodeExpression(self._inverted_consumer_call(plan, context, fields))), + ) doc = ( f"Call {self._entrypoint_function_name(plan)} with every descriptor live.", "Each argument's descriptor was recorded by the consumer that was handed" @@ -10432,7 +10280,7 @@ def _inverted_chain_fields( pairs.append( ( CParameter(self._inverted_backend_field(slot), "prik_native_array_backend *"), - self._inverted_backend_local(context.arguments[owner_path]), + self._descriptor_backend_local(context.arguments[owner_path]), ) ) for slot, owner_path in enumerate(context.inverted_descriptors): @@ -10445,9 +10293,9 @@ def _inverted_chain_fields( return tuple(pairs) @staticmethod - def _inverted_backend_local(names: _CArgumentNames) -> str: + def _descriptor_backend_local(names: _CArgumentNames) -> str: """Return the local holding one descriptor argument's backend.""" - return f"{names.value_name}_borrowed_backend" + return f"{names.value_name}_native_backend" @staticmethod def _inverted_descriptor_field(slot: int) -> str: @@ -11764,7 +11612,7 @@ def _native_call_setup_nodes( cfi_type = self._native_array_cfi_type(result) if cfi_type is None: raise ValueError(f"Owned result {result.owner_path!r} is missing a CFI element type") - elem_len = f"sizeof({PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name).c_spelling})" + elem_len = f"sizeof({PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name).array_c_spelling})" cleanup = tuple( node for previous_result, previous_descriptor in reversed(initialized) @@ -11895,7 +11743,7 @@ def _native_array_expected_element_size(plan: ArgumentTransferPlan | ResultPlan) """Return a fixed element-size check or zero for runtime-width strings.""" if plan.datatype_family is DatatypeFamily.STRING: return "0" - return f"sizeof({PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).c_spelling})" + return f"sizeof({PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_c_spelling})" def _native_array_capsule_new_expression( self, diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 983b9d97e..08f6e97cb 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -13,6 +13,7 @@ from dataclasses import replace import re +from prik.naming.native_symbols import NativeSymbolNames from prik.utilities.declaration_expressions import render_declaration_extent from prik.policy.ownership import ( AssignmentMode, @@ -482,7 +483,7 @@ def _support_procedure_fortran_type(value: NativeEntrypointABIValuePlan) -> str # declares is spelled. length = ":" if value.character_length is None else str(value.character_length) return f"character(kind=c_char, len={length})" - return PrimitiveScalarTypeRegistry.type_for(value.semantic_type_name).fortran_spelling + return PrimitiveScalarTypeRegistry.type_for(value.semantic_type_name).array_fortran_type try: return types[value.kind] except KeyError: @@ -2533,7 +2534,7 @@ def _module_native_array_element_length_operation(self, plan: ModuleVariablePlan ) def _module_native_array_shape_operation(self, plan: ModuleVariablePlan) -> FortranFunction: - """Return current extents, preserving absent descriptor state as zeroes.""" + """Return whether storage is present and write its current extents.""" handle = plan.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Module handle {plan.owner_path!r} has no shape rank") @@ -2552,36 +2553,30 @@ def _module_native_array_shape_operation(self, plan: ModuleVariablePlan) -> Fort return FortranFunction( name=name, parameters=parameters, + result_name="result", + result_type="logical(c_bool)", bind_name=name, body=( + FortranAssignment("result", CodeExpression(self._module_native_array_presence_expression(plan))), FortranIf( CodeExpression(self._module_native_array_presence_expression(plan)), body=present, else_body=absent, ), ), - is_subroutine=True, ) def _module_native_array_descriptor_operation(self, plan: ModuleVariablePlan) -> FortranFunction | None: """Expose current module descriptor state through the selected mechanism.""" - handle = plan.native_array_handle - if self._uses_module_allocatable_descriptor(plan): + if self._uses_module_descriptor_backend(plan): return self._module_allocatable_descriptor_callback_operation( plan, NativeArrayOperation.DESCRIPTOR, ) - if handle is None or handle.descriptor_kind is not NativeArrayDescriptorKind.POINTER: - return None - if handle.array.rank is None: - raise ValueError(f"Pointer module handle {plan.owner_path!r} has no descriptor rank") - # The variable is handed to a consumer, as an allocatable one is, so the - # descriptor that crosses is the one this compiler builds for the call - # rather than a record C established and this filled in. - return self._module_allocatable_descriptor_callback_operation(plan, NativeArrayOperation.DESCRIPTOR) + return None @staticmethod - def _uses_module_allocatable_descriptor(plan: ModuleVariablePlan) -> bool: + def _uses_module_descriptor_backend(plan: ModuleVariablePlan) -> bool: """Return whether a handle reaches its descriptor through a consumer. A module array hands its variable to a consumer rather than filling a @@ -3351,7 +3346,7 @@ def _native_array_argument_element_type(self, plan: ArgumentTransferPlan) -> str """Return one numeric or deferred-character descriptor dummy type.""" if plan.datatype_family is DatatypeFamily.STRING: return "character(kind=c_char, len=:)" - return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).fortran_spelling + return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _lower_argument_required(self, plan: ArgumentTransferPlan) -> tuple[FortranParameter, ...]: """Dispatch one required entrypoint parameter from its completed ABI shape.""" @@ -6840,7 +6835,7 @@ def _native_handle_field_procedures(self, owner, field: DerivedFieldPlan) -> tup # The descriptor entry point is what the binding runs every inquiry # through, so it is emitted for the handle itself; the rest are the # mutations that must reach the field. - planned = [NativeArrayOperation.DESCRIPTOR] + planned = [NativeArrayOperation.DESCRIPTOR] if handle.descriptor_inquiries else [] planned.extend( operation for operation in handle.operations @@ -6927,7 +6922,7 @@ def _native_handle_field_length_procedure(self, owner, field) -> FortranFunction ) def _native_handle_field_shape_procedure(self, owner, field) -> FortranFunction: - """Build the per-axis shape inquiry for one native-array-handle field.""" + """Report field presence and write its current per-axis extents.""" handle = field.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Native handle field {field.owner_path!r} has no shape rank") @@ -6948,13 +6943,15 @@ def _native_handle_field_shape_procedure(self, owner, field) -> FortranFunction: return FortranFunction( name=name, parameters=(*self._native_handle_field_owner_parameters(owner), *extents), + result_name="result", + result_type="logical(c_bool)", bind_name=name, declarations=self._native_handle_field_owner_declarations(owner), body=( *self._native_handle_field_owner_body(owner), + FortranAssignment("result", CodeExpression(presence)), FortranIf(CodeExpression(presence), body=present, else_body=absent), ), - is_subroutine=True, ) def _native_handle_field_descriptor_procedure(self, owner, field) -> FortranFunction: @@ -7027,7 +7024,7 @@ def _native_handle_field_associate_procedure(self, owner, field) -> FortranFunct element_type = ( "character(kind=c_char, len=:)" if field.string_element - else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).fortran_spelling + else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_fortran_type ) expression = self._native_handle_field_expression(owner, field) name = self._native_handle_field_bridge_name(owner, field, NativeArrayOperation.ASSOCIATE) @@ -7514,7 +7511,9 @@ def _derived_handle_callback_interface_name( field: DerivedFieldPlan, ) -> str: """Return the consumer-interface name associated with one direct native-array-handle field.""" - return f"prik_field_handle_{self._derived_field_symbol(derived, field)}_consumer" + owner_path = f"{derived.owner_path}.{field.name}" + preferred = f"prik_field_handle_{self._derived_field_symbol(derived, field)}_consumer" + return NativeSymbolNames.bounded(f"{owner_path}::field:direct:consumer", preferred) @staticmethod def _module_member_symbol(variable: ModuleVariablePlan, member: DerivedMemberPathPlan) -> str: @@ -7550,7 +7549,9 @@ def _module_member_handle_callback_interface_name( member: DerivedMemberPathPlan, ) -> str: """Return the consumer-interface name for one module native-array-handle member.""" - return f"prik_module_field_handle_{self._module_member_symbol(variable, member)}_consumer" + owner_path = ".".join((variable.owner_path, *member.path)) + preferred = f"prik_module_field_handle_{self._module_member_symbol(variable, member)}_consumer" + return NativeSymbolNames.bounded(f"{owner_path}::field:module:consumer", preferred) def _derived_member_proxy_variables(self, plan: ModulePlan) -> tuple[ModuleVariablePlan, ...]: """Return derived module variables whose completed access mechanism is member proxying.""" @@ -8055,7 +8056,7 @@ def _module_descriptor_callback_interfaces(self, plan: ModulePlan) -> tuple[Fort procedures = tuple( self._module_descriptor_callback_interface(variable) for variable in self._variables(plan) - if self._uses_module_allocatable_descriptor(variable) + if self._uses_module_descriptor_backend(variable) ) return (FortranInterface(procedures),) if procedures else () @@ -8077,6 +8078,8 @@ def _direct_handle_callback_interfaces(self, plan: ModulePlan) -> tuple: for derived in self._derived_types(plan) for field in derived.fields if field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE + and field.native_array_handle is not None + and field.native_array_handle.descriptor_inquiries ) def _module_handle_callback_interfaces(self, plan: ModulePlan) -> tuple: @@ -8089,6 +8092,8 @@ def _module_handle_callback_interfaces(self, plan: ModulePlan) -> tuple: for variable in self._derived_member_proxy_variables(plan) for member in variable.derived.member_paths if member.field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE + and member.field.native_array_handle is not None + and member.field.native_array_handle.descriptor_inquiries ) def _native_handle_callback_interface( @@ -8104,7 +8109,7 @@ def _native_handle_callback_interface( element_type = ( "character(kind=c_char, len=:)" if field.string_element - else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).fortran_spelling + else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_fortran_type ) imports = (self._iso_symbol(field.semantic_type_name), "c_ptr") return FortranInterfaceProcedure( @@ -8148,7 +8153,8 @@ def _module_descriptor_callback_interface( def _module_descriptor_callback_interface_name(self, plan: ModuleVariablePlan) -> str: """Return one unique typed callback interface name.""" - return f"prik_{plan.symbol_name}_descriptor_consumer" + preferred = f"prik_{plan.symbol_name}_descriptor_consumer" + return NativeSymbolNames.bounded(f"{plan.owner_path}::module:descriptor:consumer", preferred) def _allocator_interfaces(self, plan: ModulePlan) -> tuple[FortranInterface, ...]: """Return the allocator interface required by detached bridge copies.""" @@ -8739,9 +8745,7 @@ def _extended_precision_iso_symbols(self, plan: ModulePlan) -> tuple[str, ...]: def _uses_c_function_pointer_symbols(self, plan: ModulePlan) -> bool: """Return whether completed module or field descriptor actions require C procedure-pointer support.""" - module_descriptors = any( - self._uses_module_allocatable_descriptor(variable) for variable in self._variables(plan) - ) + module_descriptors = any(self._uses_module_descriptor_backend(variable) for variable in self._variables(plan)) field_descriptors = any( field.access in { diff --git a/prik/codegen/nodes.py b/prik/codegen/nodes.py index 5dce9d367..a41548d3d 100644 --- a/prik/codegen/nodes.py +++ b/prik/codegen/nodes.py @@ -40,6 +40,7 @@ class BackendScalarType(StageRecord): array_element_c_spelling: str | None = None array_python_type_name: str | None = None array_fortran_spelling: str | None = None + array_cfi_type_spelling: str | None = None @property def array_numpy_type(self) -> str | None: @@ -61,6 +62,11 @@ def array_fortran_type(self) -> str: """Return the Fortran type declaring one aliased array's elements.""" return self.array_fortran_spelling or self.fortran_spelling + @property + def array_cfi_type(self) -> str | None: + """Return the C descriptor type code for one array element.""" + return self.array_cfi_type_spelling or self.cfi_type_spelling + @dataclass class CodeExpression(StageRecord): diff --git a/prik/codegen/primitive_scalar_types.py b/prik/codegen/primitive_scalar_types.py index 1a892cc49..85fafde52 100644 --- a/prik/codegen/primitive_scalar_types.py +++ b/prik/codegen/primitive_scalar_types.py @@ -180,6 +180,16 @@ def expression_for(cls, semantic_dtype: str | None) -> str: "Bool32": "numpy.int32", "Bool64": "numpy.int64", } +_BOOLEAN_ARRAY_CFI_TYPES = { + "Bool": "CFI_type_Bool", + "Bool8": "CFI_type_Bool", + # No C-interoperable Boolean type is wider than C_BOOL. The portable + # descriptor spelling for the wider Fortran logical kinds is therefore + # CFI_type_other, with elem_len carrying their exact storage width. + "Bool16": "CFI_type_other", + "Bool32": "CFI_type_other", + "Bool64": "CFI_type_other", +} class PrimitiveScalarTypeRegistry: @@ -194,6 +204,7 @@ class PrimitiveScalarTypeRegistry: array_numpy_type_macro=_BOOLEAN_ARRAY_NUMPY_MACROS[name], array_element_c_spelling=_BOOLEAN_ARRAY_C_SPELLINGS[name], array_python_type_name=_BOOLEAN_ARRAY_DTYPE_NAMES[name], + array_cfi_type_spelling=_BOOLEAN_ARRAY_CFI_TYPES[name], ) for name in BOOLEAN_SEMANTIC_TYPE_NAMES }, diff --git a/prik/contracts/__init__.py b/prik/contracts/__init__.py index 334b64cc4..7bebd6d16 100644 --- a/prik/contracts/__init__.py +++ b/prik/contracts/__init__.py @@ -96,15 +96,15 @@ def __call__(self, *args: object, **kwargs: object) -> object: shape_items = self.array.shape if isinstance(self.array.shape, tuple) else (self.array.shape,) if self.array.rank <= 0 or Ellipsis in shape_items: raise TypeError(f"{self.descriptor_kind} handle constructor requires one concrete positive array rank") - scalar_dtype = getattr(self.array.element_type, "_scalar_dtype", None) - if scalar_dtype is None: + array_dtype = getattr(self.array.element_type, "_array_dtype", None) + if array_dtype is None: name = getattr(self.array.element_type, "__name__", type(self.array.element_type).__name__) raise TypeError(f"{self.descriptor_kind} handle element contract {name!r} has no concrete NumPy dtype") from prik.runtime.handles import _native_array_handle_from_contract return _native_array_handle_from_contract( self.descriptor_kind, - scalar_dtype, + array_dtype, self.array.rank, ) @@ -113,6 +113,7 @@ def _contract_type( name: str, scalar_factory: object | None = None, *, + array_factory: object | None = None, constructor_error: str | None = None, ) -> type[_ContractType]: namespace = { @@ -121,6 +122,7 @@ def _contract_type( } if scalar_factory is not None: namespace["_scalar_dtype"] = np.dtype(scalar_factory) + namespace["_array_dtype"] = np.dtype(scalar_factory if array_factory is None else array_factory) return _ContractTypeMeta(name, (_ContractType,), namespace) @@ -164,9 +166,9 @@ def apply(target): Bool = _contract_type("Bool", _CONTRACT_NUMPY_FACTORIES["Bool"]) Bool8 = _contract_type("Bool8", _CONTRACT_NUMPY_FACTORIES["Bool8"]) -Bool16 = _contract_type("Bool16", _CONTRACT_NUMPY_FACTORIES["Bool16"]) -Bool32 = _contract_type("Bool32", _CONTRACT_NUMPY_FACTORIES["Bool32"]) -Bool64 = _contract_type("Bool64", _CONTRACT_NUMPY_FACTORIES["Bool64"]) +Bool16 = _contract_type("Bool16", _CONTRACT_NUMPY_FACTORIES["Bool16"], array_factory=np.int16) +Bool32 = _contract_type("Bool32", _CONTRACT_NUMPY_FACTORIES["Bool32"], array_factory=np.int32) +Bool64 = _contract_type("Bool64", _CONTRACT_NUMPY_FACTORIES["Bool64"], array_factory=np.int64) Byte = _contract_type("Byte", constructor_error="Byte has no portable NumPy scalar default") CEnum = _contract_type("CEnum", constructor_error="CEnum requires a resolved native underlying type") Char = _contract_type("Char", constructor_error="Char has no portable NumPy scalar default") diff --git a/prik/naming/native_symbols.py b/prik/naming/native_symbols.py index 1be3bffd5..253ce6f48 100644 --- a/prik/naming/native_symbols.py +++ b/prik/naming/native_symbols.py @@ -14,6 +14,13 @@ class NativeSymbolNames: """Create stable backend symbols within native compiler limits.""" + @classmethod + def bounded(cls, owner_path: str, preferred: str, *, limit: int = 63) -> str: + """Keep a valid readable symbol, compacting it only when it is too long.""" + if len(preferred) <= limit: + return preferred + return cls.compact(owner_path, preferred, limit=limit) + @staticmethod def compact(owner_path: str, preferred: str, *, limit: int = 27) -> str: """Return a readable, collision-resistant symbol fragment.""" diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 7907a44cd..388ad1746 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -225,6 +225,7 @@ def _operation( GeneratedSupportProcedureImplementationOwner.FORTRAN ), ) -> GeneratedSupportProcedureEntrypointPlan: + symbol_name = NativeSymbolNames.bounded(f"{owner_path}::{role}", symbol_name) return GeneratedSupportProcedureEntrypointPlan( key=f"{owner_path}::{role}", owner_path=owner_path, @@ -723,7 +724,7 @@ def _field_handle_operations(self, owner, field, route, owner_path, owner_parame operations = [] # The descriptor entry point is what every inquiry runs through, so it # is planned for the handle rather than for one of its capabilities. - planned = [NativeArrayOperation.DESCRIPTOR] + planned = [NativeArrayOperation.DESCRIPTOR] if handle.descriptor_inquiries else [] planned.extend( operation for operation in handle.operations @@ -758,7 +759,8 @@ def _field_handle_signature(self, field, handle, operation, owner_values): extents = tuple( self._int64_parameter(f"extent_{axis}", reference=True) for axis in range(handle.array.rank) ) - return NativeEntrypointSignaturePlan((*owner_values, *extents), self._void_result()) + result = self._bool_result() if not handle.descriptor_inquiries else self._void_result() + return NativeEntrypointSignaturePlan((*owner_values, *extents), result) if operation is NativeArrayOperation.DESCRIPTOR: callback = self._descriptor_callback_parameter( semantic_type_name=field.semantic_type_name, @@ -994,7 +996,7 @@ def _module_native_array_operations(self, variable): operations = [] # The descriptor entry point is what every inquiry runs through, so it # is planned for the handle rather than for one of its capabilities. - planned = [NativeArrayOperation.DESCRIPTOR] + planned = [NativeArrayOperation.DESCRIPTOR] if handle.descriptor_inquiries else [] planned.extend( operation for operation in handle.operations @@ -1032,7 +1034,8 @@ def _module_native_array_signature(self, variable, handle, operation): self._int64_parameter(f"extent_{axis}", reference=True, intent="out") for axis in range(handle.array.rank) ) - return NativeEntrypointSignaturePlan(extents, self._void_result()) + result = self._bool_result() if not handle.descriptor_inquiries else self._void_result() + return NativeEntrypointSignaturePlan(extents, result) if operation is NativeArrayOperation.DESCRIPTOR: return self._module_descriptor_callback_signature(variable, handle) if operation is NativeArrayOperation.ASSOCIATE: diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 487edc382..ffa4a1757 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -1374,9 +1374,21 @@ def _native_array_handle_policy( ) handle_kind = _native_array_handle_kind(descriptor_kind, context, optional_absent=optional_absent) blocker = _native_array_handle_blocker(descriptor_kind, handle_kind, decision) + descriptor_inquiries = _native_array_descriptor_inquiries(descriptor_kind, semantic_type) + if not descriptor_inquiries and handle_kind not in { + "borrowed_module_descriptor", + "borrowed_field_descriptor", + }: + blocker = "deferred-length character pointer arrays cannot cross a bind(C) descriptor interface" descriptor_ownership = _native_array_descriptor_ownership(handle_kind) - to_numpy = _native_array_to_numpy_policy(descriptor_kind, handle_kind, decision, semantic_type) - operations = _native_array_handle_operations(descriptor_kind, handle_kind, context, semantic_type) + to_numpy = ( + _native_array_to_numpy_policy(descriptor_kind, handle_kind, decision, semantic_type) + if descriptor_inquiries + else "unsupported" + ) + operations = set(_native_array_handle_operations(descriptor_kind, handle_kind, context, semantic_type)) + if not descriptor_inquiries: + operations.difference_update({"allocate", "associate", "resize", "to_numpy"}) default_construction = _native_array_default_construction(handle_kind, context, semantic_type) return NativeArrayHandlePolicy( descriptor_kind=descriptor_kind, @@ -1389,7 +1401,9 @@ def _native_array_handle_policy( getter_behavior=_native_array_getter_behavior(handle_kind, context, blocker), python_setter=_native_array_python_setter(variable), native_setter=_native_array_native_setter(variable), - output_projection=_native_array_output_projection(descriptor_kind, handle_kind, context), + output_projection=( + _native_array_output_projection(descriptor_kind, handle_kind, context) if descriptor_inquiries else "none" + ), result_allocation=_native_array_result_allocation(descriptor_kind, handle_kind, context, semantic_type), release=_native_array_release_responsibility(handle_kind), target_lifetime=_native_array_target_lifetime(descriptor_kind, handle_kind, semantic_type, blocker), @@ -1399,17 +1413,19 @@ def _native_array_handle_policy( descriptor_kind, handle_kind, semantic_type, + descriptor_inquiries=descriptor_inquiries, ), nullable=bool(decision.nullable or optional_absent), optional_absent=optional_absent, storage_mode=decision.storage_mode.value, - operations=operations, + operations=tuple(sorted(operations)), blocker=blocker, default_construction=default_construction, default_descriptor_ownership="owned" if default_construction != "none" else "unknown", default_release="wrapper_dealloc" if default_construction != "none" else "none", default_destroy_behavior="handle_finalizer" if default_construction != "none" else "none", default_operations=(tuple(sorted({*operations, "destroy"})) if default_construction != "none" else ()), + descriptor_inquiries=descriptor_inquiries, ) @@ -1696,8 +1712,12 @@ def _native_array_descriptor_interop_requirement( descriptor_kind: str, handle_kind: str, semantic_type: models.SemanticType, + *, + descriptor_inquiries: bool, ) -> str: """Return the C-descriptor interop mechanism required by a supported handle.""" + if not descriptor_inquiries: + return "none" if descriptor_kind == "allocatable" and handle_kind == "owned_result_descriptor": return "owned_allocatable_c_descriptor" if descriptor_kind == "allocatable" and handle_kind == "borrowed_module_descriptor": @@ -1707,6 +1727,14 @@ def _native_array_descriptor_interop_requirement( return "none" +def _native_array_descriptor_inquiries( + descriptor_kind: str, + semantic_type: models.SemanticType, +) -> bool: + """Return whether the declaration has a legal bind(C) descriptor interface.""" + return not (descriptor_kind == "pointer" and semantic_type.metadata.get("fortran_character_length") == ":") + + def _pointer_policy_metadata(semantic_type: models.SemanticType) -> dict[str, object]: """Copy pointer-policy metadata into a safe mutable mapping, or return an empty mapping.""" policy = semantic_type.metadata.get(POINTER_POLICY_METADATA) diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 5ff3d9c00..8117447d8 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -6207,6 +6207,7 @@ def _native_array_handle_wrapper_policy( owner_path, "descriptor interop", ) + descriptor_inquiries = completed.descriptor_inquiries operations = { _native_array_enum(NativeArrayOperation, item, owner_path, "operation") for item in completed.operations } @@ -6215,30 +6216,10 @@ def _native_array_handle_wrapper_policy( # reports it; only a pointer still reports one, because a pointer that has # no storage of its own has nowhere else to record what it was pointed at. operations.add(NativeArrayOperation.SHAPE) - if descriptor == "pointer": - operations.add(NativeArrayOperation.DESCRIPTOR) + if descriptor == "pointer" and descriptor_inquiries: + operations.update({NativeArrayOperation.CONTIGUOUS, NativeArrayOperation.DESCRIPTOR}) if semantic_type.name == "String": operations.add(NativeArrayOperation.ELEMENT_LENGTH) - # A bind(C) character dummy must have an assumed or constant length, so a - # deferred-length pointer array has no legal descriptor interface at all. - # Its state, shape and width still come from the compiler's own inquiries; - # anything that has to reach the descriptor itself does not exist for it. - descriptor_inquiries = not ( - descriptor == "pointer" and semantic_type.metadata.get("fortran_character_length") == ":" - ) - if not descriptor_inquiries: - operations.difference_update( - { - NativeArrayOperation.DESCRIPTOR, - NativeArrayOperation.ASSOCIATE, - NativeArrayOperation.TO_NUMPY, - } - ) - output_projection = NativeArrayOutputProjection.NONE - if semantic_type.metadata.get("fortran_character_length") == ":": - operations.difference_update({NativeArrayOperation.ALLOCATE, NativeArrayOperation.RESIZE}) - if descriptor == "pointer": - operations.add(NativeArrayOperation.CONTIGUOUS) if completed.destroy_behavior == NativeArrayDestroyBehavior.HANDLE_FINALIZER.value: operations.add(NativeArrayOperation.DESTROY) array = _array_handoff_policy(semantic_type) @@ -6290,10 +6271,11 @@ def _native_array_handle_wrapper_policy( owner_path, "destroy behavior", ), - extraction_action=( - _native_array_enum(NativeArrayExtractionAction, completed.to_numpy, owner_path, "extraction action") - if descriptor_inquiries - else NativeArrayExtractionAction.UNSUPPORTED + extraction_action=_native_array_enum( + NativeArrayExtractionAction, + completed.to_numpy, + owner_path, + "extraction action", ), descriptor_interop=interop, descriptor_inquiries=descriptor_inquiries, diff --git a/prik/policy/native_array_handles.py b/prik/policy/native_array_handles.py index 8c7a4326c..4e52e9028 100644 --- a/prik/policy/native_array_handles.py +++ b/prik/policy/native_array_handles.py @@ -53,6 +53,7 @@ class NativeArrayHandlePolicy: destroy_behavior: str to_numpy: str descriptor_interop: str + descriptor_inquiries: bool nullable: bool optional_absent: bool storage_mode: str @@ -403,6 +404,7 @@ def _variable_native_array_policy( destroy_behavior="nullify", to_numpy="borrowed_view", descriptor_interop="pointer_c_descriptor", + descriptor_inquiries=True, nullable=True, optional_absent=False, storage_mode="alias", diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index f111ebbc1..ade6797f3 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -464,7 +464,7 @@ def to_numpy(self) -> Any: if value is None: return None self._validate_numpy_result(value) - if value.base is not None and value.base is self._owner: + if self.owned and value.base is not None and value.base is self._owner: # The view was built over storage this handle releases when it is # finalized, so the view has to keep the handle alive too, not just # the record that holds the storage. diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 2c1e240c2..37ca8c341 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -559,7 +559,7 @@ static inline int prik_array_validate( * A wrapper needs two things from an array argument: the raw pointer handed to * the native entrypoint, and one extent per contract axis. Obtaining them takes * two routes. This helper validates and reads NumPy arrays. Generated wrappers - * read native handles through their versioned descriptor table before falling + * read native handles through their versioned backend capsule before falling * back here for the NumPy route and the common wrong-type diagnostic. * * object the Python argument to bind diff --git a/tests/fortran/_support/ownership_policy.py b/tests/fortran/_support/ownership_policy.py index 6d9cb300f..39752a3cb 100644 --- a/tests/fortran/_support/ownership_policy.py +++ b/tests/fortran/_support/ownership_policy.py @@ -125,6 +125,7 @@ def _native_array_policy( handle_kind: str = "borrowed_module_descriptor", to_numpy: str = "borrowed_view", descriptor_interop: str = "none", + descriptor_inquiries: bool = True, nullable: bool = False, optional_absent: bool = False, operations: tuple[str, ...] = ("allocated", "to_numpy"), @@ -147,6 +148,7 @@ def _native_array_policy( destroy_behavior="none", to_numpy=to_numpy, descriptor_interop=descriptor_interop, + descriptor_inquiries=descriptor_inquiries, nullable=nullable, optional_absent=optional_absent, storage_mode="alias", diff --git a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py index d1d3d9a3f..a621d9f36 100644 --- a/tests/fortran/allocatables/codegen/test_allocatable_lowering.py +++ b/tests/fortran/allocatables/codegen/test_allocatable_lowering.py @@ -80,9 +80,6 @@ def test_allocated_direct_result_assigns_then_moves_into_owned_descriptor(): assert "deallocate(result)" in procedure assert "call prik_collect_allocatable_array_result(native_make(n), result)" not in procedure assert "result = result_value" not in procedure - # Allocation state is read from the owned descriptor in the binding, so no - # Fortran inquiry is emitted for it. - assert "_allocated(" not in bridge_source assert "subroutine bind_c_owned_result_" in bridge_source assert "_deallocate(" in bridge_source assert "real(c_double), allocatable, dimension(:), intent(inout) :: result" in bridge_source @@ -129,8 +126,9 @@ def invalid_argument(values: Annotated[Allocatable[Float64[:]], MaybeUnallocated def _allocatable_argument_plan(): module = parse_pyi_text( """ -from prik.contracts import Allocatable, Float64, native_call +from prik.contracts import Allocatable, Float64, native_call, nogil +@nogil @native_call([]) def total(values: Allocatable[Float64[:]]) -> Float64: ... @@ -184,3 +182,19 @@ def test_allocatable_argument_uses_the_descriptor_the_runtime_built(): assert "with_descriptor(" in c_source copied = [line.strip() for line in c_source.splitlines() if "memcpy(" in line and "CFI_CDESC_T" in line] assert copied == [] + + +def test_nogil_releases_only_while_the_descriptor_consumer_calls_fortran(): + c_source = next( + source.text + for source in WrapperGenerator().generate(_allocatable_argument_plan()).sources + if source.path.suffix == ".c" + ) + start = c_source.index("static void wrap_total_call_with_descriptor_0(") + end = c_source.index("\n}\n", start) + consumer = c_source[start:end] + + begin = consumer.index("Py_BEGIN_ALLOW_THREADS") + call = consumer.index("bind_c_total(") + finish = consumer.index("Py_END_ALLOW_THREADS") + assert begin < call < finish diff --git a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py index dd823b26a..3ba90828a 100644 --- a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py +++ b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py @@ -75,7 +75,7 @@ def test_required_array_buffer_dispatches_through_named_binding_and_bridge_metho assert "double bind_c_sum_values(void * values, int64_t values_extent_0);" in c_source # One shared binder call carries the completed NumPy selectors; a generated - # native handle is resolved separately through its descriptor table. + # native handle is resolved separately through its descriptor backend. assert ( "prik_bind_array(bound_values_obj, NPY_FLOAT64, 1, 1, 1, PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS, " '1, 1, "numpy.float64", "values", 0, ' diff --git a/tests/fortran/arrays/codegen/test_specialized_array_roles.py b/tests/fortran/arrays/codegen/test_specialized_array_roles.py index 826c9ba1e..9f3d5893c 100644 --- a/tests/fortran/arrays/codegen/test_specialized_array_roles.py +++ b/tests/fortran/arrays/codegen/test_specialized_array_roles.py @@ -90,7 +90,7 @@ def test_optional_assumed_rank_and_character_lowering_follow_named_plan_fields() "PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" ) in c_source assert ( - "prik_native_array_backend_for_actual(bound_values_table_capsule, 1, 15, " + "prik_native_array_backend_for_actual(bound_values_backend_capsule, 1, 15, " 'CFI_type_double, sizeof(double), "float64", "values")' ) in c_source assert "NPY_FLOAT64, 1, 15, PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" in c_source diff --git a/tests/fortran/arrays/codegen/test_strided_array_lowering.py b/tests/fortran/arrays/codegen/test_strided_array_lowering.py index 7f3e9db43..b2d61b56f 100644 --- a/tests/fortran/arrays/codegen/test_strided_array_lowering.py +++ b/tests/fortran/arrays/codegen/test_strided_array_lowering.py @@ -49,12 +49,12 @@ def test_strided_array_lowering_validates_and_passes_one_explicit_bridge_slice() bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") assert ( - "prik_native_array_backend_for_actual(bound_values_table_capsule, 2, 2, " + "prik_native_array_backend_for_actual(bound_values_backend_capsule, 2, 2, " 'CFI_type_double, sizeof(double), "float64", "values")' ) in c_source assert ( - "bound_values_table->with_descriptor(bound_values_table->context, " - "prik_fill_array_actual_strided_arrays_strided_values, &bound_values_table_result)" + "bound_values_native_backend->with_descriptor(bound_values_native_backend->context, " + "prik_fill_array_actual_strided_arrays_strided_values, &bound_values_backend_result)" ) in c_source assert "relative_stride = (int64_t)(source->dim[0].sm / base_bytes)" in c_source assert "out->upper_bounds[1] = upper_bound" in c_source diff --git a/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py b/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py index 0c336f80a..aaf985ef9 100644 --- a/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py +++ b/tests/fortran/arrays/end_to_end/test_logical_kind_array_conversions.py @@ -7,6 +7,7 @@ import numpy as np import pytest +from prik import contracts from tests.fortran._support.wrapper_build import _build_inline_pyi_contract_module, _build_text_and_import @@ -64,10 +65,71 @@ inout_values = input_values .neqv. inout_values end subroutine exercise_64 + subroutine replace_c_bool(values) + logical(kind=c_bool), allocatable, intent(inout) :: values(:) + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = .false. + values(1) = .true. + values(3) = .true. + end subroutine replace_c_bool + + subroutine replace_8(values) + logical(kind=1), allocatable, intent(inout) :: values(:) + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = .false. + values(1) = .true. + values(3) = .true. + end subroutine replace_8 + + subroutine replace_16(values) + logical(kind=2), allocatable, intent(inout) :: values(:) + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = .false. + values(1) = .true. + values(3) = .true. + end subroutine replace_16 + + subroutine replace_32(values) + logical(kind=4), allocatable, intent(inout) :: values(:) + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = .false. + values(1) = .true. + values(3) = .true. + end subroutine replace_32 + + subroutine replace_64(values) + logical(kind=8), allocatable, intent(inout) :: values(:) + if (allocated(values)) deallocate(values) + allocate(values(3)) + values = .false. + values(1) = .true. + values(3) = .true. + end subroutine replace_64 + end module logical_kind_arrays """ +_LOGICAL_POINTER_SOURCE = """ +module logical_pointer_arrays + implicit none +contains + subroutine replace_pointer_32(values) + logical(kind=4), pointer, intent(inout) :: values(:) + if (associated(values)) deallocate(values) + allocate(values(3)) + values = .false. + values(1) = .true. + values(3) = .true. + end subroutine replace_pointer_32 +end module logical_pointer_arrays +""" + + _LOGICAL_KIND_DTYPES = { "c_bool": np.bool_, "8": np.bool_, @@ -96,12 +158,7 @@ def test_boolean_arrays_are_aliased_at_their_own_width_without_any_copy(tmp_path }, ) bridge_source = (tmp_path / "bind_c_logical_kind_arrays_wrapper.f90").read_text(encoding="utf-8") - assert "_native = " not in bridge_source - assert "merge(.true._c_bool, .false._c_bool," not in bridge_source assert "call native_exercise_c_bool(n, input_values, output_values, inout_values)" in bridge_source - # Written arrays are normalized at the element's own width, not copied. - assert "_logical_bytes" not in bridge_source - assert "iand(" not in bridge_source for suffix, dtype in _LOGICAL_KIND_DTYPES.items(): input_values = np.array([1, 0, 1, 0], dtype=dtype) @@ -132,7 +189,7 @@ def test_boolean_arrays_are_aliased_at_their_own_width_without_any_copy(tmp_path def test_numbered_boolean_pyi_contracts_probe_and_call_every_supported_width(tmp_path: Path): contract = """ -from prik.contracts import Bool8, Bool16, Bool32, Bool64, Int32 +from prik.contracts import Allocatable, Bool8, Bool16, Bool32, Bool64, Int32 def exercise_c_bool( n: Int32, @@ -168,6 +225,12 @@ def exercise_64( output_values: Bool64[n], inout_values: Bool64[n], ) -> None: ... + +def replace_c_bool(values: Allocatable[Bool8[:]]) -> None: ... +def replace_8(values: Allocatable[Bool8[:]]) -> None: ... +def replace_16(values: Allocatable[Bool16[:]]) -> None: ... +def replace_32(values: Allocatable[Bool32[:]]) -> None: ... +def replace_64(values: Allocatable[Bool64[:]]) -> None: ... """ module, result = _build_inline_pyi_contract_module( tmp_path, @@ -180,7 +243,6 @@ def exercise_64( # Each numbered contract width aliases a buffer of its own size. for kind in ("logical(c_bool)", "logical(2)", "logical(4)", "logical(8)"): assert f"{kind}, pointer, contiguous, dimension(:) :: input_values" in bridge_source, kind - assert "_native = " not in bridge_source for suffix, dtype in _LOGICAL_KIND_DTYPES.items(): input_values = np.array([1, 0, 1, 0], dtype=dtype) @@ -203,3 +265,59 @@ def exercise_64( np.logical_xor(input_values.astype(bool), initial_inout.astype(bool)), err_msg=suffix, ) + + handle_contracts = { + "c_bool": contracts.Bool8, + "8": contracts.Bool8, + "16": contracts.Bool16, + "32": contracts.Bool32, + "64": contracts.Bool64, + } + for suffix, dtype in _LOGICAL_KIND_DTYPES.items(): + handle = contracts.Allocatable[handle_contracts[suffix][:]]() + try: + assert getattr(module, f"replace_{suffix}")(handle) is None + assert handle.dtype == np.dtype(dtype) + assert handle.shape == (3,) + np.testing.assert_array_equal(handle.to_numpy().astype(bool), [True, False, True]) + finally: + handle.close() + + +def test_caller_created_wide_logical_pointer_uses_its_native_width(tmp_path: Path): + contract = """ +from prik.contracts import Annotated, Bool32, Pointer, PointerPolicy + +def replace_pointer_32( + values: Annotated[ + Pointer[Bool32[:]], + PointerPolicy( + nullable=True, + transfer="call_local", + target_owner="caller", + lifetime="call", + deallocation="deallocate_resize", + shape_source="pointer_bounds", + contiguity="contiguous", + reassociation="allocate_resize", + aliasing="descriptor", + mutability="mutable", + ), + ], +) -> None: ... +""" + module, _result = _build_inline_pyi_contract_module( + tmp_path, + module_name="logical_pointer_arrays", + source_text=_LOGICAL_POINTER_SOURCE, + contract_text=contract, + ) + pointer = contracts.Pointer[contracts.Bool32[:]]() + try: + assert module.replace_pointer_32(pointer) is None + assert pointer.dtype == np.dtype(np.int32) + assert pointer.shape == (3,) + np.testing.assert_array_equal(pointer.to_numpy().astype(bool), [True, False, True]) + pointer.deallocate() + finally: + pointer.close() diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index 008452de7..bda4db7da 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -149,8 +149,10 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): implicit none type :: holder - real(8), allocatable :: field_alloc(:) + real(8), allocatable :: field_allocatable_values_with_long_name(:) + logical(4), allocatable :: field_flags(:) real(8), pointer :: field_ptr(:) => null() + character(len=:), pointer :: field_words(:) => null() end type holder integer(4), allocatable :: ints(:) @@ -164,6 +166,7 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): real(8), allocatable :: probe(:) real(8), allocatable :: pair_left(:) real(8), allocatable :: pair_right(:) + real(8), allocatable :: pair_third(:) real(8), allocatable :: cube(:, :, :) real(8), target :: store(8) real(8), pointer :: reversed(:) => null() @@ -187,12 +190,17 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): allocate(probe(3)); probe = 2.0_8 allocate(pair_left(3)); pair_left = 6.0_8 allocate(pair_right(3)); pair_right = 7.0_8 + allocate(pair_third(3)); pair_third = 8.0_8 allocate(cube(2, 3, 4)); cube = 1.0_8 store = [(1.0_8 * i, i = 1, 8)] reversed => store(8:1:-1) strided => store(1:8:2) - allocate(parent%field_alloc(3)); parent%field_alloc = 5.0_8 + allocate(parent%field_allocatable_values_with_long_name(3)) + parent%field_allocatable_values_with_long_name = 5.0_8 + allocate(parent%field_flags(3)); parent%field_flags = [.true., .false., .true.] parent%field_ptr => store(2:6:2) + allocate(character(len=5) :: parent%field_words(2)) + parent%field_words = ['alpha', 'beta '] end subroutine setup subroutine reshape_alloc(values, n) @@ -214,6 +222,18 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): allocate(second(n)); second = 9.0_8 end subroutine grow_pair + subroutine grow_three(first, second, third, n) + real(8), allocatable, intent(inout) :: first(:), second(:), third(:) + integer(4), intent(in) :: n + + if (allocated(first)) deallocate(first) + if (allocated(second)) deallocate(second) + if (allocated(third)) deallocate(third) + allocate(first(n)); first = 8.0_8 + allocate(second(n)); second = 9.0_8 + allocate(third(n)); third = 10.0_8 + end subroutine grow_three + subroutine grow_and_count(values, n, produced) real(8), allocatable, intent(inout) :: values(:) integer(4), intent(in) :: n @@ -224,6 +244,19 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): produced = n end subroutine grow_and_count + function optional_alloc_state(values) result(state) + real(8), allocatable, intent(in), optional :: values(:) + integer(4) :: state + + if (.not. present(values)) then + state = 0 + else if (allocated(values)) then + state = 2 + else + state = 1 + end if + end function optional_alloc_state + function assumed_total(actual) result(total) real(8), intent(in) :: actual(:) real(8) :: total @@ -377,13 +410,23 @@ def test_an_unassociated_pointer_reports_absence_through_every_inquiry(descripto def test_a_derived_type_field_view_retains_its_parent(descriptor_matrix): """A field's storage belongs to its parent, so a view has to keep it alive.""" parent = descriptor_matrix.parent - field = parent.field_alloc + field = parent.field_allocatable_values_with_long_name pointer_field = parent.field_ptr + logical_field = parent.field_flags + words_field = parent.field_words assert field.shape == (3,) np.testing.assert_allclose(field.to_numpy(), np.array([5.0, 5.0, 5.0])) assert pointer_field.associated is True assert pointer_field.shape == (3,) + assert logical_field.dtype == np.dtype(np.int32) + np.testing.assert_array_equal(logical_field.to_numpy().astype(bool), [True, False, True]) + assert words_field.associated is True + assert words_field.shape == (2,) + assert words_field.dtype == np.dtype("S5") + words_field.deallocate() + assert words_field.associated is False + assert words_field.shape is None view = field.to_numpy() assert view.base is not None @@ -434,6 +477,7 @@ def test_a_bound_handle_reaches_a_native_call_without_running_python(descriptor_ int_total = descriptor_matrix.int_total reshape_alloc = descriptor_matrix.reshape_alloc assumed_total = descriptor_matrix.assumed_total + optional_alloc_state = descriptor_matrix.optional_alloc_state called: list[str] = [] def record(frame, event, _arg): @@ -445,6 +489,9 @@ def record(frame, event, _arg): int_total(ordinary) assumed_total(descriptor) reshape_alloc(descriptor, np.int32(2)) + assert optional_alloc_state() == np.int32(0) + assert optional_alloc_state(None) == np.int32(0) + assert optional_alloc_state(descriptor) == np.int32(2) finally: sys.setprofile(None) @@ -481,6 +528,19 @@ def test_two_descriptor_dummies_reach_borrowed_and_owned_storage_alike(descripto owned_first.close() +def test_three_descriptor_dummies_keep_every_borrowed_descriptor_live(descriptor_matrix): + first = descriptor_matrix.pair_left + second = descriptor_matrix.pair_right + third = descriptor_matrix.pair_third + + descriptor_matrix.grow_three(first, second, third, np.int32(4)) + + assert first.shape == second.shape == third.shape == (4,) + np.testing.assert_allclose(first.to_numpy(), np.full(4, 8.0)) + np.testing.assert_allclose(second.to_numpy(), np.full(4, 9.0)) + np.testing.assert_allclose(third.to_numpy(), np.full(4, 10.0)) + + def test_a_handle_reaches_a_call_with_a_hidden_output_without_running_python(descriptor_matrix): """An intent(out) argument is carried, not read after the consumer returns. diff --git a/tests/fortran/data_types/runtime/test_contract_scalar_constructors.py b/tests/fortran/data_types/runtime/test_contract_scalar_constructors.py index f3bacccdf..d637e2cbf 100644 --- a/tests/fortran/data_types/runtime/test_contract_scalar_constructors.py +++ b/tests/fortran/data_types/runtime/test_contract_scalar_constructors.py @@ -44,3 +44,20 @@ def test_primitive_contract_constructors_reject_values_and_array_annotations(): contracts.Float64[:]() with pytest.raises(TypeError, match="default constructor takes no arguments"): contracts.Int32(3) + + +def test_logical_descriptor_handles_report_their_native_array_width(): + cases = ( + (contracts.Bool, np.bool_), + (contracts.Bool8, np.bool_), + (contracts.Bool16, np.int16), + (contracts.Bool32, np.int32), + (contracts.Bool64, np.int64), + ) + + for element_type, dtype in cases: + handle = contracts.Allocatable[element_type[:]]() + try: + assert handle.dtype == np.dtype(dtype) + finally: + handle.close() diff --git a/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py b/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py index 7db8373b4..9ea1cb383 100644 --- a/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py +++ b/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py @@ -26,16 +26,20 @@ """ -def _bridge_source(): - parsed = parse_fortran_project({"field_state.f90": ARRAY_FIELD_SOURCE}) +def _bridge_source_for(source: str, module_name: str) -> str: + parsed = parse_fortran_project({f"{module_name}.f90": source}) modules = fortran_project_to_semantic_modules(parsed) _apply_source_python_exports(modules) - module = _merge_wrapper_modules(modules, name="field_state") + module = _merge_wrapper_modules(modules, name=module_name) complete_semantic_policies(module) plan = WrapperPlanner().build(module) return FortranSourcePrinter().visit(FortranBridgeGenerator().visit(plan)) +def _bridge_source(): + return _bridge_source_for(ARRAY_FIELD_SOURCE, "field_state") + + def test_owned_array_field_takes_its_address_through_the_owner_pointer(): """An owner reached as a pointer makes its components addressable. @@ -87,3 +91,24 @@ def test_array_field_getters_report_extents_instead_of_passing_a_descriptor(): # No descriptor-consumer interface is declared for an ordinary array field. assert "_grid_consumer" not in source + + +def test_deferred_character_pointer_field_uses_only_legal_inquiry_entrypoints(): + source = _bridge_source_for( + """ +module deferred_field_state + implicit none + type :: box + character(len=:), pointer :: words(:) => null() + end type box + type(box) :: plain_box +end module deferred_field_state +""", + "deferred_field_state", + ) + + shape = _procedure(source, "bind_c_prik_field_handle_box_words_shape") + assert "logical(c_bool) :: result" in shape + assert "result = associated(owner%words)" in shape + assert "_words_consumer" not in source + assert "character(kind=c_char, len=:), pointer, dimension(:), intent(inout)" not in source diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index fb58d7ce0..92fa83032 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -60,10 +60,6 @@ def test_native_array_backend_capsule_states_one_version_and_one_entry_point(): assert "uint32_t descriptor_size;" in header assert "int32_t cfi_type;" in header assert "size_t element_size;" in header - # No second version word, and no per-operation table. - assert "MAGIC" not in header - assert "ABI_VERSION" not in header - for name in ( "prik_native_array_backend_capsule_new", "prik_native_array_backend_capsule_destructor", diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index 0c5fa4cea..dc2801e8b 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -338,22 +338,12 @@ def test_module_variables_use_borrowed_handle_plans_and_operation_sets(): def test_deferred_character_module_handles_use_runtime_element_length(): - """A deferred length is reported at runtime, and the descriptor supplies it. - - The width is not in the declaration, so it can only come from the array - itself. It reaches the descriptor record from the descriptor now rather than - through a second call, while the standalone query remains for the callers - that ask for the length on its own. - """ + """The live descriptor supplies a deferred character element width.""" artifacts = WrapperGenerator().generate(_module_handle_plan()) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") - bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") assert "out->result = PyLong_FromLongLong((long long)source->elem_len)" in c_source assert "prik_native_array_read_element_length" in c_source - # The width comes out of the descriptor, so the bridge carries no inquiry - # of its own for it. - assert "bind_c_module_names_element_length" not in bridge_source def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): @@ -363,9 +353,6 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert artifacts.required_headers == ("ISO_Fortran_binding.h",) assert "prik_bind_array(" in c_source - # Descriptor arguments reach the runtime through one packer. The - # fact-reporting one is gone: nothing rebuilds a descriptor in C. - assert '"_native_array_descriptor_argument_for_binding_positional"' not in c_source assert '"_native_array_backend_for_binding_positional"' in c_source assert '"_native_array_handle_from_generated_dispatch"' in c_source assert '"_bind_contract_native_array_handle"' in c_source @@ -404,10 +391,6 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert "call prik_collect_allocatable_array_result(native_maybe_make(n), result)" in bridge_source assert "if (allocated(value)) then" in bridge_source assert "call move_alloc(value, result)" in bridge_source - # An owned handle answers its inquiries from the descriptor it holds, so - # only the mutations reach Fortran. - assert "bind_c_owned_result_allocated(" not in c_source - assert "_shape(owner_descriptor" not in c_source assert "_deallocate(owner_descriptor);" in c_source assert "_destroy(owner_descriptor);" in c_source assert "owner_backend->with_descriptor(owner_backend->context, prik_native_array_read_shape" in c_source @@ -437,7 +420,6 @@ def test_owned_descriptor_handles_publish_one_dispatcher_and_capability_tuple(): assert "owner_backend" in dispatch assert "owner_descriptor" in dispatch assert 'Py_BuildValue("(ssssss)", "allocated", "deallocate", "destroy", "resize", "shape", "to_numpy")' in c_source - assert "PyDict_SetItemString" not in c_source @pytest.mark.parametrize( diff --git a/tests/fortran/modules/end_to_end/test_logical_array_views.py b/tests/fortran/modules/end_to_end/test_logical_array_views.py index 98cb52b13..ac4eda978 100644 --- a/tests/fortran/modules/end_to_end/test_logical_array_views.py +++ b/tests/fortran/modules/end_to_end/test_logical_array_views.py @@ -23,6 +23,8 @@ logical :: wide(4) logical(c_bool), allocatable :: narrow_alloc(:) logical, allocatable :: wide_alloc(:) + logical, target :: wide_store(4) + logical, pointer :: wide_pointer(:) => null() contains @@ -33,6 +35,8 @@ narrow_alloc = [.true., .true., .false., .false.] if (.not. allocated(wide_alloc)) allocate(wide_alloc(4)) wide_alloc = [.true., .false., .true., .false.] + wide_store = [.true., .false., .true., .false.] + wide_pointer => wide_store end subroutine setup subroutine negate() @@ -102,6 +106,12 @@ def test_logical_allocatable_handles_reach_matching_ordinary_dummies(logical_vie assert logical_view.count_wide_actual(logical_view.wide_alloc) == np.int32(2) +def test_wide_logical_pointer_handles_reach_matching_ordinary_dummies(logical_view): + assert logical_view.wide_pointer.dtype == np.dtype(np.int32) + assert logical_view.wide_pointer.shape == (4,) + assert logical_view.count_wide_actual(logical_view.wide_pointer) == np.int32(2) + + def test_a_held_view_keeps_agreeing_with_fortran_across_native_writes(logical_view): """The view aliases the storage, and both sides read the same bytes. 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 91f2d75ec..04268fe8c 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 @@ -474,6 +474,12 @@ def test_assumed_length_character_parameter_array_reports_its_inferred_width(tmp fixed_ptr => store deferred_ptr => store end subroutine setup + + subroutine allocate_deferred() + if (associated(deferred_ptr)) nullify(deferred_ptr) + allocate(character(len=6) :: deferred_ptr(3)) + deferred_ptr = ['a ', 'bb ', 'ccc '] + end subroutine allocate_deferred end module fchar_declared_arrays_f90 """ @@ -515,3 +521,16 @@ def test_declared_length_character_module_arrays_compile_and_expose_their_width( assert module.fixed_ptr.shape == (2,) assert module.deferred_ptr.associated is True assert module.deferred_ptr.shape == (2,) + assert module.deferred_ptr.dtype == np.dtype("S4") + + module.deferred_ptr.nullify() + assert module.deferred_ptr.associated is False + assert module.deferred_ptr.shape is None + + module.allocate_deferred() + assert module.deferred_ptr.associated is True + assert module.deferred_ptr.shape == (3,) + assert module.deferred_ptr.dtype == np.dtype("S6") + module.deferred_ptr.deallocate() + assert module.deferred_ptr.associated is False + assert module.deferred_ptr.shape is None diff --git a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py index 2a5f70ac0..32351a53d 100644 --- a/tests/fortran/pointers/policy/test_pointer_ownership_policy.py +++ b/tests/fortran/pointers/policy/test_pointer_ownership_policy.py @@ -389,6 +389,31 @@ class box: assert set(field_policy.operations) == {"associate", "associated", "deallocate", "nullify", "to_numpy"} +def test_deferred_character_pointer_arrays_require_a_legal_descriptor_interface(): + module = parse_pyi_text( + """ +deferred_ptr: Pointer[String[:][:]] + +def inspect(values: Pointer[String[:][:]]) -> None: ... +""", + module_name="deferred_character_pointer_arrays", + ) + + complete_semantic_policies(module) + + module_policy = module.variables[0].metadata[RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA] + argument_policy = module.functions[0].arguments[0].metadata[RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA] + + assert module_policy.is_blocked is False + assert module_policy.descriptor_inquiries is False + assert module_policy.descriptor_interop == "none" + assert set(module_policy.operations) == {"associated", "deallocate", "nullify"} + assert argument_policy.is_blocked is True + assert argument_policy.descriptor_inquiries is False + assert argument_policy.descriptor_interop == "none" + assert "cannot cross a bind(C) descriptor interface" in argument_policy.blocker + + def test_complete_pointer_policy_metadata_round_trips_without_overriding_container_ownership(): module = parse_pyi_text( """ From b9e2d3f4ce9b5eb75ad5b4d5914295897a14be17 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 19:06:13 +0100 Subject: [PATCH 32/47] Prove the two things the capsule name and an absent optional promise The version in the capsule name was only asserted as header text, and the absent-optional path was only ever measured by hand. Both now have a test. A capsule holding this handle's own live backend, republished under another version name, is refused by the reader that asks for the one it understands -- so the same address goes from working to raising, and only the name differs. That is the whole reason the version is spelled in the name rather than compared out of a field the stranger also wrote. An optional descriptor argument runs no Python whether it is omitted, passed as None, or supplied, which is what the C short-circuit on Py_None bought and what the earlier matrix never covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- .../test_allocatable_cross_extension.py | 42 ++++++++++++++++ .../test_native_handle_array_forms.py | 49 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py index b172a4876..bdaf51caa 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py @@ -1,5 +1,6 @@ """Cross-extension allocatable descriptor and native-memory evidence.""" +import ctypes import shutil import subprocess import sys @@ -109,6 +110,47 @@ def test_caller_created_allocatable_crosses_separately_built_extensions(tmp_path assert values.closed is True +def test_a_backend_capsule_from_another_producer_is_refused_not_interpreted(tmp_path: Path): + """The capsule name is the ABI version, and it is what refuses a stranger. + + Nothing in the record says which layout wrote it, so the name has to: + ``PyCapsule_GetPointer`` matches names exactly, and a reader asks for the + one version it understands. An extension built against any other layout -- + an older PRIK, a future one -- is therefore refused before a single field + is read, which is the whole reason the version is spelled in the name + rather than compared out of a header field. + """ + module = _build_text_and_import( + ALLOCATABLE_CROSS_A_SOURCE, + "fallocatable_cross_a.f90", + tmp_path, + { + "bind_c_fallocatable_cross_a_wrapper.f90", + "fallocatable_cross_a_wrapper.c", + "fallocatable_cross_a_wrapper.h", + }, + ) + values = Allocatable[Float64[:]]() + module.select_a(values) + assert module.total_a(values) == np.float64(3.0) + + capsule_new = ctypes.pythonapi.PyCapsule_New + capsule_new.restype = ctypes.py_object + capsule_new.argtypes = (ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p) + capsule_get = ctypes.pythonapi.PyCapsule_GetPointer + capsule_get.restype = ctypes.c_void_p + capsule_get.argtypes = (ctypes.py_object, ctypes.c_char_p) + # Stand in for an extension that published the same record under a + # different version. The address is this handle's own live backend, so + # only the name differs and only the name can do the refusing. + address = capsule_get(values._native_backend, b"prik.native_array_backend.v1") + assert address + values._native_backend = capsule_new(address, b"prik.native_array_backend.v2", None) + + with pytest.raises(ValueError, match="PyCapsule_GetPointer called with incorrect name"): + module.total_a(values) + + @pytest.mark.skipif(shutil.which("valgrind") is None, reason="Valgrind is required for native ownership checks") def test_allocatable_replacement_has_no_native_memory_errors( pyi_parity_build_mode: str, diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index bda4db7da..89e833394 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -164,6 +164,7 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): real(8), allocatable :: empty(:) real(8), allocatable :: spare(:) real(8), allocatable :: probe(:) + real(8), allocatable :: optional_probe(:) real(8), allocatable :: pair_left(:) real(8), allocatable :: pair_right(:) real(8), allocatable :: pair_third(:) @@ -188,6 +189,7 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): deferred_words = ['alphas', 'bravos'] allocate(empty(0)) allocate(probe(3)); probe = 2.0_8 + allocate(optional_probe(3)); optional_probe = 2.0_8 allocate(pair_left(3)); pair_left = 6.0_8 allocate(pair_right(3)); pair_right = 7.0_8 allocate(pair_third(3)); pair_third = 8.0_8 @@ -234,6 +236,19 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): allocate(third(n)); third = 10.0_8 end subroutine grow_three + function optional_state(values) result(state) + real(8), allocatable, optional, intent(in) :: values(:) + integer(4) :: state + + if (.not. present(values)) then + state = 0 + else if (.not. allocated(values)) then + state = 1 + else + state = int(sum(values), kind=4) + end if + end function optional_state + subroutine grow_and_count(values, n, produced) real(8), allocatable, intent(inout) :: values(:) integer(4), intent(in) :: n @@ -566,3 +581,37 @@ def record(frame, event, _arg): assert called == [] assert int(produced[1]) == 4 assert spare.shape == (4,) + + +def test_an_optional_descriptor_argument_runs_no_python_however_it_is_supplied(descriptor_matrix): + """Absence is decided in C, for an omitted argument as much as a supplied one. + + An absent optional has no handle to publish a backend, so nothing is + entered for it and the binding establishes the unallocated placeholder the + bridge is handed. Deciding that needs the argument object and nothing else, + so none of the three ways of supplying it goes back into Python. + """ + # This array is this test's alone, so its sum stays what the fixture set. + present = descriptor_matrix.optional_probe + called: list[str] = [] + + def record(frame, event, _arg): + if event == "call": + called.append(frame.f_code.co_name) + + descriptor_matrix.optional_state() + descriptor_matrix.optional_state(None) + descriptor_matrix.optional_state(present) + + sys.setprofile(record) + try: + omitted = descriptor_matrix.optional_state() + explicit_none = descriptor_matrix.optional_state(None) + supplied = descriptor_matrix.optional_state(present) + finally: + sys.setprofile(None) + + assert called == [] + assert omitted == np.int32(0) + assert explicit_none == np.int32(0) + assert supplied == np.int32(6) From 7feba1ce38abe6a06f08f2ce7e22ecdb362193cc Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 19:19:52 +0100 Subject: [PATCH 33/47] Hold the record still while the version name does The name is the ABI version, which only means anything if the record cannot move under it -- and it could: adding a field, reordering two, or widening one all left the name at .v1 and the suite green. A same-width reordering is the bad one, because struct_size sees no difference and a consumer that still recognizes the name reads the fields straight through in the wrong order. The record and the name are now pinned together in one test, so changing either alone fails, and the header says which way to resolve it: publish .v2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- docs/developer/packages/runtime.md | 10 ++- prik/runtime/native_support/prik_binding.h | 14 +++- .../runtime/test_native_support.py | 74 ++++++++++++++----- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 04c3ff0e8..3ee2b45f3 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -72,7 +72,15 @@ address for a field, descriptor storage for an owned handle, and `NULL` for a module variable. `release` is non-`NULL` when the extension owns `context`. Clearing `context` after release makes `close()` and finalization idempotent. -The capsule name carries the ABI version. `struct_size` validates the backend +The capsule name carries the ABI version, and carrying it there is what makes +a magic word and a version field redundant: both would be fields the stranger +also wrote, and comparing them means dereferencing its pointer first. The +obligation is that the record may not change while the name does not. Adding a +field, reordering two, widening one, or changing what a field means makes it +`prik.native_array_backend.v2` — `struct_size` cannot see a same-width +reordering, so the name is the only thing separating the two layouts. The +record and the name are pinned together in +`tests/fortran/infrastructure/runtime/test_native_support.py`. `struct_size` validates the backend layout, and `descriptor_size` records `sizeof(CFI_CDESC_T(rank))` for the producing extension. A consumer validates `descriptor_kind`, `rank`, `cfi_type`, `element_size`, and descriptor size against its dummy before diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 37ca8c341..815b91a7e 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -27,8 +27,18 @@ * One versioned capsule publishes everything a generated binding needs from * another extension's array handle. The version lives in the capsule name: * PyCapsule_GetPointer refuses a capsule created under any other name, so a - * layout change is made by naming a new capsule rather than by adding a - * separate magic word and version field for the reader to compare. + * reader asks for the one version it understands and every other producer is + * turned away before a field is read. That is what makes a separate magic word + * and version field redundant -- both were fields the stranger also wrote, and + * comparing them meant dereferencing its pointer first. + * + * The obligation this creates: the record below may not change while the name + * stays the same. Add a field, reorder two, widen one, or change what a field + * means, and this becomes .v2 -- every .v1 consumer then refuses it, instead of + * reading a same-width reordering straight through. `struct_size` cannot see + * such a reordering, so the name is the only thing standing between the two + * layouts. The record and this name are pinned together by + * test_the_backend_record_and_its_version_name_change_together. */ #define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME "prik.native_array_backend.v1" #define PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE 1u diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index 92fa83032..8d2ad5790 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -1,5 +1,7 @@ """Public native-binding support surface checks.""" +import re + from tests.fortran._support.paths import REPO_ROOT @@ -38,34 +40,70 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): assert f"prik_{suffix}_to_numpy" in header -def test_native_array_backend_capsule_states_one_version_and_one_entry_point(): - """The cross-extension array ABI: its version, layout, and validation. - - Independently generated extensions exchange array handles through this one - capsule, so its name carries the version -- PyCapsule_GetPointer refuses a - capsule created under any other name -- and the record carries only what a - reader must compare before it interprets a descriptor it did not build. +BACKEND_RECORD_V1 = ( + ("uint32_t", "struct_size"), + ("uint32_t", "descriptor_kind"), + ("uint32_t", "rank"), + ("uint32_t", "descriptor_size"), + ("int32_t", "cfi_type"), + ("size_t", "element_size"), + ("void *", "context"), + ("prik_native_array_with_descriptor_fn", "with_descriptor"), + ("prik_native_array_release_fn", "release"), +) + + +def _backend_record_fields(header: str) -> tuple[tuple[str, str], ...]: + """Return the declared record in order, as (type, name) pairs.""" + # A struct body has no braces of its own, so this cannot span the record before it. + body = re.search(r"typedef struct \{\n([^{}]*?)\n\} prik_native_array_backend;", header, re.S) + assert body is not None, "prik_native_array_backend is not declared as one struct" + fields = [] + for line in body.group(1).strip().splitlines(): + declaration = line.strip().rstrip(";") + spelling, _, name = declaration.rpartition(" ") + if name.startswith("*"): + spelling, name = f"{spelling} *", name[1:] + fields.append((spelling.strip(), name)) + return tuple(fields) + + +def test_the_backend_record_and_its_version_name_change_together(): + """The capsule name is the ABI version, so the record may not move under it. + + Nothing inside the record says which layout wrote it: a reader asks + ``PyCapsule_GetPointer`` for the one version it understands, and every + other producer is refused before a field is read. That only holds while + the name is renamed whenever the record changes -- and a same-width + reordering, `descriptor_kind` and `rank` swapped say, would otherwise be + read straight through by a consumer that still recognizes the name, since + `struct_size` sees no difference. + + So the two are pinned here together. If this test fails because the + record genuinely changed, publish it under a new version name and update + both halves; do not update the layout alone. """ header = SUPPORT_HEADER.read_text(encoding="utf-8") assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME "prik.native_array_backend.v1"' in header - # One entry point reaches the descriptor; the context is what it needs to - # get there, and a release marks that context as this extension's to free. - assert "prik_native_array_with_descriptor_fn with_descriptor;" in header - assert "prik_native_array_release_fn release;" in header - assert "void *context;" in header - # The compatibility tags a reader compares before trusting the producer. - for field in ("uint32_t struct_size;", "uint32_t descriptor_kind;", "uint32_t rank;"): - assert field in header - assert "uint32_t descriptor_size;" in header - assert "int32_t cfi_type;" in header - assert "size_t element_size;" in header + assert _backend_record_fields(header) == BACKEND_RECORD_V1 + + +def test_native_array_backend_capsule_exposes_one_entry_point_and_its_readers(): + """One entry point reaches the descriptor; the readers validate a producer. + + The context is what that entry point needs to get there, and a release + marks that context as this extension's to free. + """ + header = SUPPORT_HEADER.read_text(encoding="utf-8") + for name in ( "prik_native_array_backend_capsule_new", "prik_native_array_backend_capsule_destructor", "prik_native_array_backend_from_capsule", "prik_native_array_backend_for_descriptor", "prik_native_array_backend_for_actual", + "prik_native_array_backend_owned_descriptor", "prik_native_array_backend_release", "prik_native_array_owned_with_descriptor", ): From e7a3a52f181eac9af151ed7524e8211a6f0f1b38 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 21:20:09 +0100 Subject: [PATCH 34/47] Name the capsule after the record it carries A version anyone types is a version someone forgets, and the field-by-field checks could not cover the part that matters most: context, with_descriptor and release are bare addresses, so a producer that merely exchanged two of them passed every check and segfaulted on the call. Nothing can sanity-check an address after the fact. So the layout names the capsule. A tag folds sizeof and offsetof for the record and each field in order -- reorder, widen, insert or remove and it moves -- and PyCapsule_GetPointer compares names before it hands back the pointer, so a mismatched producer is refused with nothing read through it. Both sides compute the tag from their own header, so agreement on the name is agreement on the record. struct_size goes: the tag folds the size in and covers reorderings it never could. The v2 in the name stays for people, and for the one drift no tag can see -- a field that keeps its offset and width but changes meaning. The tag lists its fields by hand, so the record and that list are pinned together in a test; adding a field to one and not the other fails there rather than quietly keeping the old name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- docs/developer/packages/runtime.md | 37 ++++-- prik/codegen/c/binding.py | 8 +- prik/runtime/native_support/prik_binding.h | 122 ++++++++++++++---- .../test_allocatable_cross_extension.py | 19 ++- .../runtime/test_native_support.py | 49 ++++--- 5 files changed, 170 insertions(+), 65 deletions(-) diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 3ee2b45f3..bf9737281 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -72,20 +72,29 @@ address for a field, descriptor storage for an owned handle, and `NULL` for a module variable. `release` is non-`NULL` when the extension owns `context`. Clearing `context` after release makes `close()` and finalization idempotent. -The capsule name carries the ABI version, and carrying it there is what makes -a magic word and a version field redundant: both would be fields the stranger -also wrote, and comparing them means dereferencing its pointer first. The -obligation is that the record may not change while the name does not. Adding a -field, reordering two, widening one, or changing what a field means makes it -`prik.native_array_backend.v2` — `struct_size` cannot see a same-width -reordering, so the name is the only thing separating the two layouts. The -record and the name are pinned together in -`tests/fortran/infrastructure/runtime/test_native_support.py`. `struct_size` validates the backend -layout, and `descriptor_size` records `sizeof(CFI_CDESC_T(rank))` for the -producing extension. A consumer validates `descriptor_kind`, `rank`, -`cfi_type`, `element_size`, and descriptor size against its dummy before -entering Fortran. `element_size` is `0` for widths determined at run time, such -as deferred-length character arrays. +The capsule name carries the layout, not a version anyone maintains. It is +`prik.native_array_backend.v2.`, where the tag folds `sizeof` and +`offsetof` for the record and every field in it, in order. Two extensions +therefore agree on the name exactly when they agree on the record, and +`PyCapsule_GetPointer` compares names *before* returning the pointer — so a +producer built from a different header is refused without a byte being read +through it. That matters most for `context`, `with_descriptor` and `release`: +they are bare addresses, nothing can sanity-check them after the fact, and +calling one from a mismatched record is a crash. A version field could not +have done this job, because reading it already assumes the layout in question. + +`.v2` remains for people. It says which generation of the ABI is meant, and it +is what changes when the record keeps its shape but a field takes on a new +meaning — the one drift a mechanical tag cannot see. `descriptor_size` stays in +the record because it attests the producer's `CFI_CDESC_T(rank)` layout, which +is the compiler's, not this header's, and so is not folded into the tag. A +reader still validates `descriptor_kind`, `rank`, `cfi_type` and `element_size` +against the dummy it is filling. `element_size` is `0` for widths determined at +run time, such as deferred-length character arrays. + +The record and the tag's field list are pinned together in +`tests/fortran/infrastructure/runtime/test_native_support.py`, so a field added +to one and not the other fails there rather than silently keeping the old name. ### Inquiries Read The Descriptor diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 3fbaf7310..3e02ae562 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -4462,8 +4462,7 @@ def _module_native_array_backend_nodes( self._module_native_array_backend_name(variable), "static prik_native_array_backend", CodeExpression( - "{(uint32_t)sizeof(prik_native_array_backend), " - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"{{{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, {element_size}, " f"NULL, {forward}, NULL}}" ), @@ -4538,7 +4537,10 @@ def _module_native_array_backend_capsule(self, variable: ModuleVariablePlan) -> """Return the expression publishing this variable's native backend.""" if not self._uses_module_descriptor_backend(variable): return "Py_None" - return f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, NULL)" + return ( + f"PyCapsule_New(&{self._module_native_array_backend_name(variable)}, " + "prik_native_array_backend_capsule_name(), NULL)" + ) def _module_with_descriptor_name(self, variable: ModuleVariablePlan) -> str: """Return the forwarder name that drives this variable's descriptor bridge.""" diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 815b91a7e..f7d58e35e 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -8,7 +8,9 @@ #include #include #include +#include #include +#include #include #include @@ -24,23 +26,29 @@ #include /* - * One versioned capsule publishes everything a generated binding needs from - * another extension's array handle. The version lives in the capsule name: - * PyCapsule_GetPointer refuses a capsule created under any other name, so a - * reader asks for the one version it understands and every other producer is - * turned away before a field is read. That is what makes a separate magic word - * and version field redundant -- both were fields the stranger also wrote, and - * comparing them meant dereferencing its pointer first. + * One capsule publishes everything a generated binding needs from another + * extension's array handle, and its name is derived from the record's own + * layout rather than from a version anyone maintains by hand. * - * The obligation this creates: the record below may not change while the name - * stays the same. Add a field, reorder two, widen one, or change what a field - * means, and this becomes .v2 -- every .v1 consumer then refuses it, instead of - * reading a same-width reordering straight through. `struct_size` cannot see - * such a reordering, so the name is the only thing standing between the two - * layouts. The record and this name are pinned together by - * test_the_backend_record_and_its_version_name_change_together. + * The problem it solves: a capsule carries an address, and C has no runtime + * types, so a reader has to decide what is at that address using offsets its + * own compiler baked in. Two extensions built from different snapshots of this + * header disagree about those offsets while agreeing about everything they can + * name. Comparing a version *field* cannot settle it -- reading the field + * already assumes the layout in question -- and it goes wrong worst on + * `context`, `with_descriptor` and `release`, which are opaque addresses no + * reader can sanity-check before calling one. + * + * So the layout is folded into the capsule name. PyCapsule_GetPointer compares + * names before it hands back the pointer, so a producer whose record differs in + * size, in field order, or in any field's width is refused without a single + * byte being dereferenced. Nothing has to be remembered for that to hold: the + * tag is computed from `sizeof` and `offsetof`, so it moves when the record + * does. `.v2` in the name stays for people -- it says which generation of this + * ABI is meant, and it is what changes when the record keeps its shape but a + * field takes on a new meaning, which no mechanical tag can see. */ -#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME "prik.native_array_backend.v1" +#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v2" #define PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE 1u #define PRIK_NATIVE_ARRAY_KIND_POINTER 2u @@ -121,11 +129,11 @@ typedef struct { * persistent storage it allocated, which stays valid for the handle's life. * Consumers cannot tell the two apart, and must not try to. * - * The leading metadata refuses an incompatible producer before any descriptor - * is interpreted: - * - struct_size attests this exact record layout; + * The metadata refuses an incompatible producer before any descriptor is + * interpreted. The record's own layout is attested by the capsule name, so + * nothing here restates it; what remains is what the name cannot know: * - descriptor_size attests the producer's CFI_CDESC_T(rank) layout, which - * nothing in the descriptor itself can be read to establish; + * neither this record nor the descriptor itself can be read to establish; * - descriptor_kind, rank, cfi_type and element_size are what a reader * compares against the dummy it is filling, and reporting them here means * a mismatch is refused without entering Fortran at all. @@ -134,7 +142,6 @@ typedef struct { * descriptor's elem_len instead. */ typedef struct { - uint32_t struct_size; uint32_t descriptor_kind; uint32_t rank; uint32_t descriptor_size; @@ -145,6 +152,72 @@ typedef struct { prik_native_array_release_fn release; } prik_native_array_backend; +/* + * Fold this record's layout into one tag. + * + * Every field contributes both where it starts and how wide it is, in + * declaration order, so a reorder, a widening, an insertion and a removal all + * change the result; the total size goes in first so a trailing change cannot + * be silent either. FNV-1a is used because the mixing is order-dependent -- + * XOR-ing the offsets would give the same tag for two fields exchanged. + * + * A tag cannot see a field that keeps its offset and width but changes what it + * means. That is what the version in the name is for. + */ +static inline uint64_t prik_native_array_backend_layout_tag(void) +{ + const size_t layout[] = { + sizeof(prik_native_array_backend), + offsetof(prik_native_array_backend, descriptor_kind), + sizeof(((prik_native_array_backend *)0)->descriptor_kind), + offsetof(prik_native_array_backend, rank), + sizeof(((prik_native_array_backend *)0)->rank), + offsetof(prik_native_array_backend, descriptor_size), + sizeof(((prik_native_array_backend *)0)->descriptor_size), + offsetof(prik_native_array_backend, cfi_type), + sizeof(((prik_native_array_backend *)0)->cfi_type), + offsetof(prik_native_array_backend, element_size), + sizeof(((prik_native_array_backend *)0)->element_size), + offsetof(prik_native_array_backend, context), + sizeof(((prik_native_array_backend *)0)->context), + offsetof(prik_native_array_backend, with_descriptor), + sizeof(((prik_native_array_backend *)0)->with_descriptor), + offsetof(prik_native_array_backend, release), + sizeof(((prik_native_array_backend *)0)->release), + }; + uint64_t tag = UINT64_C(14695981039346656037); + size_t index; + + for (index = 0; index < sizeof(layout) / sizeof(layout[0]); ++index) { + tag = (tag ^ (uint64_t)layout[index]) * UINT64_C(1099511628211); + } + return tag; +} + +/* + * Name the capsule this extension publishes and accepts. + * + * Both sides build this string from their own header, so two extensions agree + * on it exactly when they agree on the record. The name is what + * PyCapsule_GetPointer compares, which is why a disagreement is reported + * before the pointer is handed over rather than after something has been read + * through it. + */ +static inline const char *prik_native_array_backend_capsule_name(void) +{ + static char name[80]; + + if (name[0] == '\0') { + /* Every caller computes the same bytes, so a race writes them twice. */ + snprintf( + name, + sizeof(name), + PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX ".%016llx", + (unsigned long long)prik_native_array_backend_layout_tag()); + } + return name; +} + /* * Hand over a descriptor the wrapper itself owns. * @@ -194,7 +267,7 @@ static inline void prik_native_array_backend_capsule_destructor(PyObject *capsul PyErr_Fetch(&error_type, &error_value, &error_traceback); backend = (prik_native_array_backend *)PyCapsule_GetPointer( - capsule, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME); + capsule, prik_native_array_backend_capsule_name()); if (backend == NULL) { PyErr_Clear(); } else { @@ -246,7 +319,6 @@ static inline PyObject *prik_native_array_backend_capsule_new( PyErr_NoMemory(); return NULL; } - backend->struct_size = (uint32_t)sizeof(*backend); backend->descriptor_kind = descriptor_kind; backend->rank = rank; backend->descriptor_size = descriptor_size; @@ -256,7 +328,7 @@ static inline PyObject *prik_native_array_backend_capsule_new( backend->with_descriptor = with_descriptor; backend->release = release; capsule = PyCapsule_New( - backend, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME, prik_native_array_backend_capsule_destructor); + backend, prik_native_array_backend_capsule_name(), prik_native_array_backend_capsule_destructor); if (capsule == NULL) { backend->context = NULL; free(backend); @@ -276,11 +348,11 @@ static inline prik_native_array_backend *prik_native_array_backend_from_capsule( prik_native_array_backend *backend; backend = (prik_native_array_backend *)PyCapsule_GetPointer( - capsule, PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME); + capsule, prik_native_array_backend_capsule_name()); if (backend == NULL) { return NULL; } - if (backend->struct_size != (uint32_t)sizeof(*backend) || backend->with_descriptor == NULL) { + if (backend->with_descriptor == NULL) { PyErr_SetString(PyExc_TypeError, "incompatible prik native array backend record"); return NULL; } diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py index bdaf51caa..2b1de7f84 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py @@ -140,13 +140,22 @@ def test_a_backend_capsule_from_another_producer_is_refused_not_interpreted(tmp_ capsule_get = ctypes.pythonapi.PyCapsule_GetPointer capsule_get.restype = ctypes.c_void_p capsule_get.argtypes = (ctypes.py_object, ctypes.c_char_p) - # Stand in for an extension that published the same record under a - # different version. The address is this handle's own live backend, so - # only the name differs and only the name can do the refusing. - address = capsule_get(values._native_backend, b"prik.native_array_backend.v1") + capsule_name = ctypes.pythonapi.PyCapsule_GetName + capsule_name.restype = ctypes.c_char_p + capsule_name.argtypes = (ctypes.py_object,) + + published = capsule_name(values._native_backend) + # The layout is folded into the name, so this is what an extension built + # from any other record would publish instead. + assert published.startswith(b"prik.native_array_backend.v2.") + address = capsule_get(values._native_backend, published) assert address - values._native_backend = capsule_new(address, b"prik.native_array_backend.v2", None) + stranger = published[: published.rindex(b".")] + b".0000000000000000" + values._native_backend = capsule_new(address, stranger, None) + # The address is this handle's own live backend, so only the name differs + # and only the name can do the refusing -- before anything is read through + # it, which is the point for the three fields that are bare addresses. with pytest.raises(ValueError, match="PyCapsule_GetPointer called with incorrect name"): module.total_a(values) diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index 8d2ad5790..a6d55dc78 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -40,8 +40,7 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): assert f"prik_{suffix}_to_numpy" in header -BACKEND_RECORD_V1 = ( - ("uint32_t", "struct_size"), +BACKEND_RECORD_V2 = ( ("uint32_t", "descriptor_kind"), ("uint32_t", "rank"), ("uint32_t", "descriptor_size"), @@ -60,33 +59,45 @@ def _backend_record_fields(header: str) -> tuple[tuple[str, str], ...]: assert body is not None, "prik_native_array_backend is not declared as one struct" fields = [] for line in body.group(1).strip().splitlines(): - declaration = line.strip().rstrip(";") - spelling, _, name = declaration.rpartition(" ") + spelling, _, name = line.strip().rstrip(";").rpartition(" ") if name.startswith("*"): spelling, name = f"{spelling} *", name[1:] fields.append((spelling.strip(), name)) return tuple(fields) -def test_the_backend_record_and_its_version_name_change_together(): - """The capsule name is the ABI version, so the record may not move under it. +def _layout_tag_members(header: str) -> tuple[str, ...]: + """Return the field names the layout tag folds, in the order it folds them.""" + body = re.search(r"const size_t layout\[\] = \{(.*?)\};", header, re.S) + assert body is not None, "the layout tag does not declare what it folds" + return tuple(re.findall(r"offsetof\(prik_native_array_backend, (\w+)\)", body.group(1))) - Nothing inside the record says which layout wrote it: a reader asks - ``PyCapsule_GetPointer`` for the one version it understands, and every - other producer is refused before a field is read. That only holds while - the name is renamed whenever the record changes -- and a same-width - reordering, `descriptor_kind` and `rank` swapped say, would otherwise be - read straight through by a consumer that still recognizes the name, since - `struct_size` sees no difference. - So the two are pinned here together. If this test fails because the - record genuinely changed, publish it under a new version name and update - both halves; do not update the layout alone. +def test_the_capsule_name_is_derived_from_the_whole_record(): + """Two extensions agree on the name exactly when they agree on the record. + + A capsule carries an address and C has no runtime types, so a reader + interprets it with offsets its own compiler baked in. Comparing a version + field cannot settle a disagreement -- reading the field already assumes the + layout in question -- and it fails worst on `context`, `with_descriptor` + and `release`, which are opaque addresses nothing can sanity-check before + one of them is called. + + So the layout is folded into the name, which PyCapsule_GetPointer compares + before handing the pointer back. Every field must contribute its offset + and its width, or a change to the field it forgot would keep the old name: + the record and the tag are therefore required to list the same fields in + the same order. """ header = SUPPORT_HEADER.read_text(encoding="utf-8") - assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_NAME "prik.native_array_backend.v1"' in header - assert _backend_record_fields(header) == BACKEND_RECORD_V1 + assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v2"' in header + assert _backend_record_fields(header) == BACKEND_RECORD_V2 + assert _layout_tag_members(header) == tuple(name for _spelling, name in BACKEND_RECORD_V2) + # The size goes in first, so a change that only moves the tail is caught too. + assert "sizeof(prik_native_array_backend)," in header + for _spelling, name in BACKEND_RECORD_V2: + assert f"sizeof(((prik_native_array_backend *)0)->{name})" in header def test_native_array_backend_capsule_exposes_one_entry_point_and_its_readers(): @@ -104,6 +115,8 @@ def test_native_array_backend_capsule_exposes_one_entry_point_and_its_readers(): "prik_native_array_backend_for_descriptor", "prik_native_array_backend_for_actual", "prik_native_array_backend_owned_descriptor", + "prik_native_array_backend_layout_tag", + "prik_native_array_backend_capsule_name", "prik_native_array_backend_release", "prik_native_array_owned_with_descriptor", ): From ab6e467f8a0a050f0dfeb1feb9c4a616a9c597fc Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 21:34:56 +0100 Subject: [PATCH 35/47] Drop the version number the tag made redundant The tag folded offsets and widths, so a version number was still carrying one case: a field that keeps its shape and takes on a new meaning. That is not a case a hand-kept number is good at -- it works only when someone remembers, which is the property this design set out to remove. Each field now folds its name in along with its offset and width, all three from the one token that names it. Renaming a field when its meaning changes is what you would do anyway, and now every reader built against the old meaning stops recognizing the record. So the version segment has nothing left to distinguish, and the capsule is named for its layout alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- docs/developer/packages/runtime.md | 38 ++++++---- prik/runtime/native_support/prik_binding.h | 75 +++++++++++-------- .../test_allocatable_cross_extension.py | 2 +- .../runtime/test_native_support.py | 33 ++++---- 4 files changed, 86 insertions(+), 62 deletions(-) diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index bf9737281..87dc020cd 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -72,25 +72,31 @@ address for a field, descriptor storage for an owned handle, and `NULL` for a module variable. `release` is non-`NULL` when the extension owns `context`. Clearing `context` after release makes `close()` and finalization idempotent. -The capsule name carries the layout, not a version anyone maintains. It is -`prik.native_array_backend.v2.`, where the tag folds `sizeof` and -`offsetof` for the record and every field in it, in order. Two extensions -therefore agree on the name exactly when they agree on the record, and -`PyCapsule_GetPointer` compares names *before* returning the pointer — so a +The capsule name carries the layout, and nothing else does. It is +`prik.native_array_backend.`, where the tag folds `sizeof` for the record +and then, for every field in order, its name, its offset and its width. Two +extensions therefore agree on the name exactly when they agree on the record, +and `PyCapsule_GetPointer` compares names *before* returning the pointer — so a producer built from a different header is refused without a byte being read through it. That matters most for `context`, `with_descriptor` and `release`: they are bare addresses, nothing can sanity-check them after the fact, and -calling one from a mismatched record is a crash. A version field could not -have done this job, because reading it already assumes the layout in question. - -`.v2` remains for people. It says which generation of the ABI is meant, and it -is what changes when the record keeps its shape but a field takes on a new -meaning — the one drift a mechanical tag cannot see. `descriptor_size` stays in -the record because it attests the producer's `CFI_CDESC_T(rank)` layout, which -is the compiler's, not this header's, and so is not folded into the tag. A -reader still validates `descriptor_kind`, `rank`, `cfi_type` and `element_size` -against the dummy it is filling. `element_size` is `0` for widths determined at -run time, such as deferred-length character arrays. +calling one from a mismatched record is a crash. A version field could not have +done this job, because reading it already assumes the layout in question. + +There is no version number beside the tag, because nothing is left for one to +distinguish. Reordering, widening, inserting and removing all move the offsets; +folding each field's name in reaches the last case offsets cannot show — a +field that keeps its shape and takes on a new meaning — provided it is renamed +to say so, which is what you would do anyway. A hand-kept version covers that +case only when someone remembers, which is the property this design set out to +remove. + +`descriptor_size` stays in the record because it attests the producer's +`CFI_CDESC_T(rank)` layout, which is the compiler's, not this header's, and so +is not folded into the tag. A reader still validates `descriptor_kind`, `rank`, +`cfi_type` and `element_size` against the dummy it is filling. `element_size` +is `0` for widths determined at run time, such as deferred-length character +arrays. The record and the tag's field list are pinned together in `tests/fortran/infrastructure/runtime/test_native_support.py`, so a field added diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index f7d58e35e..63dbcbcf0 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -43,12 +43,13 @@ * names before it hands back the pointer, so a producer whose record differs in * size, in field order, or in any field's width is refused without a single * byte being dereferenced. Nothing has to be remembered for that to hold: the - * tag is computed from `sizeof` and `offsetof`, so it moves when the record - * does. `.v2` in the name stays for people -- it says which generation of this - * ABI is meant, and it is what changes when the record keeps its shape but a - * field takes on a new meaning, which no mechanical tag can see. + * tag is computed from the record itself, so it moves when the record does. + * There is no version number beside it, because there is nothing left for one + * to distinguish: a field's name is folded in along with its offset and width, + * so even a field that keeps its shape and takes on a new meaning is caught, + * as long as it is renamed to say so. */ -#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v2" +#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend" #define PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE 1u #define PRIK_NATIVE_ARRAY_KIND_POINTER 2u @@ -153,43 +154,53 @@ typedef struct { } prik_native_array_backend; /* - * Fold this record's layout into one tag. + * Describe one field for the layout tag: its name, where it starts, how wide + * it is. All three come from the same token, so they cannot disagree. + */ +#define PRIK_NATIVE_ARRAY_BACKEND_FIELD(member) \ + {#member, offsetof(prik_native_array_backend, member), sizeof(((prik_native_array_backend *)0)->member)} + +/* + * Fold this record into one tag. * - * Every field contributes both where it starts and how wide it is, in - * declaration order, so a reorder, a widening, an insertion and a removal all - * change the result; the total size goes in first so a trailing change cannot - * be silent either. FNV-1a is used because the mixing is order-dependent -- - * XOR-ing the offsets would give the same tag for two fields exchanged. + * Every field contributes its name, its offset and its width, in declaration + * order, and the total size goes in first. A reorder, a widening, an + * insertion, a removal and a rename all change the result. FNV-1a is used + * because the mixing has to be order-dependent -- XOR-ing the offsets would + * give the same tag for two fields exchanged. * - * A tag cannot see a field that keeps its offset and width but changes what it - * means. That is what the version in the name is for. + * Names are folded so that the one drift offsets cannot show -- a field that + * keeps its shape and takes on a new meaning -- is reachable too: rename it, + * which is what you would do anyway, and every reader built against the old + * meaning stops recognizing this record. */ static inline uint64_t prik_native_array_backend_layout_tag(void) { - const size_t layout[] = { - sizeof(prik_native_array_backend), - offsetof(prik_native_array_backend, descriptor_kind), - sizeof(((prik_native_array_backend *)0)->descriptor_kind), - offsetof(prik_native_array_backend, rank), - sizeof(((prik_native_array_backend *)0)->rank), - offsetof(prik_native_array_backend, descriptor_size), - sizeof(((prik_native_array_backend *)0)->descriptor_size), - offsetof(prik_native_array_backend, cfi_type), - sizeof(((prik_native_array_backend *)0)->cfi_type), - offsetof(prik_native_array_backend, element_size), - sizeof(((prik_native_array_backend *)0)->element_size), - offsetof(prik_native_array_backend, context), - sizeof(((prik_native_array_backend *)0)->context), - offsetof(prik_native_array_backend, with_descriptor), - sizeof(((prik_native_array_backend *)0)->with_descriptor), - offsetof(prik_native_array_backend, release), - sizeof(((prik_native_array_backend *)0)->release), + static const struct { + const char *name; + size_t offset; + size_t width; + } layout[] = { + PRIK_NATIVE_ARRAY_BACKEND_FIELD(descriptor_kind), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(rank), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(descriptor_size), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(cfi_type), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(element_size), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(context), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(with_descriptor), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(release), }; uint64_t tag = UINT64_C(14695981039346656037); size_t index; + const char *character; + tag = (tag ^ (uint64_t)sizeof(prik_native_array_backend)) * UINT64_C(1099511628211); for (index = 0; index < sizeof(layout) / sizeof(layout[0]); ++index) { - tag = (tag ^ (uint64_t)layout[index]) * UINT64_C(1099511628211); + for (character = layout[index].name; *character != '\0'; ++character) { + tag = (tag ^ (uint64_t)(unsigned char)*character) * UINT64_C(1099511628211); + } + tag = (tag ^ (uint64_t)layout[index].offset) * UINT64_C(1099511628211); + tag = (tag ^ (uint64_t)layout[index].width) * UINT64_C(1099511628211); } return tag; } diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py index 2b1de7f84..af3b623ab 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py @@ -147,7 +147,7 @@ def test_a_backend_capsule_from_another_producer_is_refused_not_interpreted(tmp_ published = capsule_name(values._native_backend) # The layout is folded into the name, so this is what an extension built # from any other record would publish instead. - assert published.startswith(b"prik.native_array_backend.v2.") + assert published.startswith(b"prik.native_array_backend.") address = capsule_get(values._native_backend, published) assert address stranger = published[: published.rindex(b".")] + b".0000000000000000" diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index a6d55dc78..62d9efcd0 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -40,7 +40,7 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): assert f"prik_{suffix}_to_numpy" in header -BACKEND_RECORD_V2 = ( +BACKEND_RECORD = ( ("uint32_t", "descriptor_kind"), ("uint32_t", "rank"), ("uint32_t", "descriptor_size"), @@ -68,9 +68,9 @@ def _backend_record_fields(header: str) -> tuple[tuple[str, str], ...]: def _layout_tag_members(header: str) -> tuple[str, ...]: """Return the field names the layout tag folds, in the order it folds them.""" - body = re.search(r"const size_t layout\[\] = \{(.*?)\};", header, re.S) + body = re.search(r"\} layout\[\] = \{(.*?)\};", header, re.S) assert body is not None, "the layout tag does not declare what it folds" - return tuple(re.findall(r"offsetof\(prik_native_array_backend, (\w+)\)", body.group(1))) + return tuple(re.findall(r"PRIK_NATIVE_ARRAY_BACKEND_FIELD\((\w+)\)", body.group(1))) def test_the_capsule_name_is_derived_from_the_whole_record(): @@ -83,21 +83,28 @@ def test_the_capsule_name_is_derived_from_the_whole_record(): and `release`, which are opaque addresses nothing can sanity-check before one of them is called. - So the layout is folded into the name, which PyCapsule_GetPointer compares - before handing the pointer back. Every field must contribute its offset - and its width, or a change to the field it forgot would keep the old name: - the record and the tag are therefore required to list the same fields in + So the record names its own capsule, and there is no version number beside + the tag because nothing is left for one to distinguish: each field folds in + its name as well as its offset and width, so a field that keeps its shape + and takes on a new meaning is caught too, as long as it is renamed to say + so. Every field must contribute, or a change to the one it forgot would + keep the old name -- so the record and the tag must list the same fields in the same order. """ header = SUPPORT_HEADER.read_text(encoding="utf-8") - assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v2"' in header - assert _backend_record_fields(header) == BACKEND_RECORD_V2 - assert _layout_tag_members(header) == tuple(name for _spelling, name in BACKEND_RECORD_V2) + assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend"' in header + assert _backend_record_fields(header) == BACKEND_RECORD + assert _layout_tag_members(header) == tuple(name for _spelling, name in BACKEND_RECORD) + # Name, offset and width all come from the one token naming the field. + assert ( + "{#member, offsetof(prik_native_array_backend, member), " + "sizeof(((prik_native_array_backend *)0)->member)}" in header + ) # The size goes in first, so a change that only moves the tail is caught too. - assert "sizeof(prik_native_array_backend)," in header - for _spelling, name in BACKEND_RECORD_V2: - assert f"sizeof(((prik_native_array_backend *)0)->{name})" in header + assert "tag = (tag ^ (uint64_t)sizeof(prik_native_array_backend))" in header + # And the name is folded, which is what makes a rename reach every reader. + assert "for (character = layout[index].name; *character != '\\0'; ++character)" in header def test_native_array_backend_capsule_exposes_one_entry_point_and_its_readers(): From 72c1031075f3063925ae11bba7baaaa983a9cc3e Mon Sep 17 00:00:00 2001 From: said Date: Sat, 5 Sep 2026 21:54:16 +0100 Subject: [PATCH 36/47] Describe the capsule name the code actually publishes The entry still named a version the last two commits removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 173085b44..add305c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,14 @@ release tags add a leading `v` to the package version. generation; module and field handles expose only operations that do not require an unsupported `bind(C)` descriptor interface. -- Generated array handles publish one versioned native capsule, - `prik.native_array_backend.v1`, replacing the separate descriptor-operation - table and owned-descriptor record. It carries a single entry point that runs - a consumer while the handle's descriptor is live. Extensions built against - the earlier branch-only table must be regenerated. +- Generated array handles publish one native capsule, replacing the separate + descriptor-operation table and owned-descriptor record. It carries a single + entry point that runs a consumer while the handle's descriptor is live. Its + name, `prik.native_array_backend.`, is derived from the record's + own layout — every field's name, offset and width — so extensions built from + different PRIK versions refuse each other's handles instead of reading them at + the wrong offsets. Extensions built against the earlier branch-only table must + be regenerated. - Generated Fortran allocatable and pointer handles can be passed directly to matching ordinary array arguments. Supported forms include explicit and From 7a812cbe2ab5e0bfe941fb6b3f68a8961c11c587 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 01:53:29 +0100 Subject: [PATCH 37/47] codex: Version native array backend semantics --- CHANGELOG.md | 13 ++++----- docs/developer/packages/runtime.md | 28 ++++--------------- prik/runtime/native_support/prik_binding.h | 23 ++++++--------- .../test_allocatable_cross_extension.py | 19 +++---------- .../runtime/test_native_support.py | 22 ++------------- 5 files changed, 27 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index add305c14..df2863f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,14 +21,11 @@ release tags add a leading `v` to the package version. generation; module and field handles expose only operations that do not require an unsupported `bind(C)` descriptor interface. -- Generated array handles publish one native capsule, replacing the separate - descriptor-operation table and owned-descriptor record. It carries a single - entry point that runs a consumer while the handle's descriptor is live. Its - name, `prik.native_array_backend.`, is derived from the record's - own layout — every field's name, offset and width — so extensions built from - different PRIK versions refuse each other's handles instead of reading them at - the wrong offsets. Extensions built against the earlier branch-only table must - be regenerated. +- Generated array handles share descriptors between extensions through the + versioned `prik.native_array_backend.v1.` capsule. It runs a + consumer while the handle's descriptor is live and refuses incompatible ABI + versions or record layouts before reading the backend. Rebuild generated + extensions together after upgrading PRIK. - Generated Fortran allocatable and pointer handles can be passed directly to matching ordinary array arguments. Supported forms include explicit and diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index 87dc020cd..c5bb22f2b 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -72,24 +72,12 @@ address for a field, descriptor storage for an owned handle, and `NULL` for a module variable. `release` is non-`NULL` when the extension owns `context`. Clearing `context` after release makes `close()` and finalization idempotent. -The capsule name carries the layout, and nothing else does. It is -`prik.native_array_backend.`, where the tag folds `sizeof` for the record -and then, for every field in order, its name, its offset and its width. Two -extensions therefore agree on the name exactly when they agree on the record, -and `PyCapsule_GetPointer` compares names *before* returning the pointer — so a -producer built from a different header is refused without a byte being read -through it. That matters most for `context`, `with_descriptor` and `release`: -they are bare addresses, nothing can sanity-check them after the fact, and -calling one from a mismatched record is a crash. A version field could not have -done this job, because reading it already assumes the layout in question. - -There is no version number beside the tag, because nothing is left for one to -distinguish. Reordering, widening, inserting and removing all move the offsets; -folding each field's name in reaches the last case offsets cannot show — a -field that keeps its shape and takes on a new meaning — provided it is renamed -to say so, which is what you would do anyway. A hand-kept version covers that -case only when someone remembers, which is the property this design set out to -remove. +The capsule name is `prik.native_array_backend.v1.`. The version identifies +the callback contract and field meanings. Change it when either changes without +changing the C record layout. The tag folds the record size and each field's +name, offset, and width, so a layout mismatch also changes the name. +`PyCapsule_GetPointer` compares that name before returning the record pointer; +an incompatible producer is therefore refused before its fields are read. `descriptor_size` stays in the record because it attests the producer's `CFI_CDESC_T(rank)` layout, which is the compiler's, not this header's, and so @@ -98,10 +86,6 @@ is not folded into the tag. A reader still validates `descriptor_kind`, `rank`, is `0` for widths determined at run time, such as deferred-length character arrays. -The record and the tag's field list are pinned together in -`tests/fortran/infrastructure/runtime/test_native_support.py`, so a field added -to one and not the other fails there rather than silently keeping the old name. - ### Inquiries Read The Descriptor `shape`, `allocated`, `associated`, `contiguous`, `element_length` and diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 63dbcbcf0..31ae0ecc3 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -39,17 +39,13 @@ * `context`, `with_descriptor` and `release`, which are opaque addresses no * reader can sanity-check before calling one. * - * So the layout is folded into the capsule name. PyCapsule_GetPointer compares - * names before it hands back the pointer, so a producer whose record differs in - * size, in field order, or in any field's width is refused without a single - * byte being dereferenced. Nothing has to be remembered for that to hold: the - * tag is computed from the record itself, so it moves when the record does. - * There is no version number beside it, because there is nothing left for one - * to distinguish: a field's name is folded in along with its offset and width, - * so even a field that keeps its shape and takes on a new meaning is caught, - * as long as it is renamed to say so. + * The capsule name carries both a semantic ABI version and a layout tag. + * PyCapsule_GetPointer compares names before it returns the pointer, so a + * producer with a different callback contract or record layout is refused + * before any field is read. Bump the version when field meanings or callback + * behavior change without changing the record layout. */ -#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend" +#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v1" #define PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE 1u #define PRIK_NATIVE_ARRAY_KIND_POINTER 2u @@ -169,10 +165,9 @@ typedef struct { * because the mixing has to be order-dependent -- XOR-ing the offsets would * give the same tag for two fields exchanged. * - * Names are folded so that the one drift offsets cannot show -- a field that - * keeps its shape and takes on a new meaning -- is reachable too: rename it, - * which is what you would do anyway, and every reader built against the old - * meaning stops recognizing this record. + * Field names are folded too, so renaming a field changes the tag even when its + * offset and width stay the same. The semantic version covers contract changes + * that do not alter or rename a field. */ static inline uint64_t prik_native_array_backend_layout_tag(void) { diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py index af3b623ab..afa0099bc 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_cross_extension.py @@ -111,15 +111,7 @@ def test_caller_created_allocatable_crosses_separately_built_extensions(tmp_path def test_a_backend_capsule_from_another_producer_is_refused_not_interpreted(tmp_path: Path): - """The capsule name is the ABI version, and it is what refuses a stranger. - - Nothing in the record says which layout wrote it, so the name has to: - ``PyCapsule_GetPointer`` matches names exactly, and a reader asks for the - one version it understands. An extension built against any other layout -- - an older PRIK, a future one -- is therefore refused before a single field - is read, which is the whole reason the version is spelled in the name - rather than compared out of a header field. - """ + """A reader refuses a capsule with another ABI name before reading it.""" module = _build_text_and_import( ALLOCATABLE_CROSS_A_SOURCE, "fallocatable_cross_a.f90", @@ -145,17 +137,14 @@ def test_a_backend_capsule_from_another_producer_is_refused_not_interpreted(tmp_ capsule_name.argtypes = (ctypes.py_object,) published = capsule_name(values._native_backend) - # The layout is folded into the name, so this is what an extension built - # from any other record would publish instead. - assert published.startswith(b"prik.native_array_backend.") + assert published.startswith(b"prik.native_array_backend.v1.") address = capsule_get(values._native_backend, published) assert address stranger = published[: published.rindex(b".")] + b".0000000000000000" values._native_backend = capsule_new(address, stranger, None) - # The address is this handle's own live backend, so only the name differs - # and only the name can do the refusing -- before anything is read through - # it, which is the point for the three fields that are bare addresses. + # Only the capsule name differs; the reader must reject it before using the + # live backend address. with pytest.raises(ValueError, match="PyCapsule_GetPointer called with incorrect name"): module.total_a(values) diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index 62d9efcd0..257b774fa 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -73,27 +73,11 @@ def _layout_tag_members(header: str) -> tuple[str, ...]: return tuple(re.findall(r"PRIK_NATIVE_ARRAY_BACKEND_FIELD\((\w+)\)", body.group(1))) -def test_the_capsule_name_is_derived_from_the_whole_record(): - """Two extensions agree on the name exactly when they agree on the record. - - A capsule carries an address and C has no runtime types, so a reader - interprets it with offsets its own compiler baked in. Comparing a version - field cannot settle a disagreement -- reading the field already assumes the - layout in question -- and it fails worst on `context`, `with_descriptor` - and `release`, which are opaque addresses nothing can sanity-check before - one of them is called. - - So the record names its own capsule, and there is no version number beside - the tag because nothing is left for one to distinguish: each field folds in - its name as well as its offset and width, so a field that keeps its shape - and takes on a new meaning is caught too, as long as it is renamed to say - so. Every field must contribute, or a change to the one it forgot would - keep the old name -- so the record and the tag must list the same fields in - the same order. - """ +def test_the_capsule_name_covers_semantic_version_and_the_whole_record(): + """The name protects the callback contract and the compiled record layout.""" header = SUPPORT_HEADER.read_text(encoding="utf-8") - assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend"' in header + assert '#define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v1"' in header assert _backend_record_fields(header) == BACKEND_RECORD assert _layout_tag_members(header) == tuple(name for _spelling, name in BACKEND_RECORD) # Name, offset and width all come from the one token naming the field. From 78ef2cd2d14fb92ca38defbfce4f6e2e04b023f0 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 03:15:35 +0100 Subject: [PATCH 38/47] codex: Classify how each array dummy is reached, and say why a layout is refused The direct question -- what would a bind(C) procedure with no bridge receive for this dummy -- has one answer per array form, and nothing recorded it. Completed policy now carries it: an explicit-shape, assumed-size or raw C pointer dummy is reached by the address of its first element, and every other form by a CFI descriptor, which has an extent and a signed byte stride per axis. Whether an axis of the actual may run backwards follows from that, plus the dummy's own contiguity requirement, which a descriptor does not lift. The refusals say which of those they come from. A reversed axis is a perfectly good Fortran array section that an address-only entrypoint has no way to describe; a broadcast axis is not a section at all, because Fortran has no form for an element repeated by a zero step; and axes in the wrong order keep the ordering message, which is what a caller can act on. Previously all three read "expected ordering (F)". The mechanism that would carry a signed stride is not built yet, so ordinary arrays still cross as an address with extents beside it, and _array_handoff_ signed_strides says so rather than claiming an ability the transport does not have. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 1 + prik/policy/construction.py | 56 ++++++++- prik/policy/models.py | 35 ++++++ prik/runtime/native_support/prik_binding.h | 109 +++++++++++++++--- .../test_layout_and_strided_arrays.py | 17 ++- 5 files changed, 194 insertions(+), 24 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 3e02ae562..9c1de3554 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -7431,6 +7431,7 @@ def _array_rank_bounds(handoff: ArrayHandoffPlan) -> tuple[int, int]: ArrayPythonLayout.C_CONTIGUOUS: "PRIK_ARRAY_LAYOUT_C_CONTIGUOUS", ArrayPythonLayout.F_CONTIGUOUS: "PRIK_ARRAY_LAYOUT_F_CONTIGUOUS", ArrayPythonLayout.POSITIVE_STRIDED_F: "PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F", + ArrayPythonLayout.SIGNED_STRIDED_F: "PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F", ArrayPythonLayout.ANY_STRIDED: "PRIK_ARRAY_LAYOUT_ANY_STRIDED", } diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 8117447d8..5e53b7073 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -149,6 +149,7 @@ NativeStatusErrorPolicy, ModuleVariablePolicy, LifecyclePolicy, + ArrayEntrypointABI, ArrayHandoffPolicy, ProcedurePrototypeArgumentPolicy, ProcedurePrototypeResultPolicy, @@ -7410,6 +7411,8 @@ def _array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandoffPol minimum_rank, maximum_rank = _array_handoff_rank_bounds(rank, array.category, flatten_python_storage) order = _array_handoff_order(array.order, array.category) contiguous = _array_handoff_contiguous(array.contiguous, array.category) + entrypoint_abi = _array_entrypoint_abi(array.category) + signed_strides = _array_handoff_signed_strides(entrypoint_abi, contiguous) return ArrayHandoffPolicy( rank=rank, shape=shape, @@ -7417,7 +7420,9 @@ def _array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandoffPol order=order, native_order=_array_handoff_native_order(array.order, array.copy_order, array.category), contiguous=contiguous, - python_layout=_array_handoff_python_layout(order, contiguous, rank), + python_layout=_array_handoff_python_layout(order, contiguous, rank, signed_strides), + entrypoint_abi=entrypoint_abi, + signed_strides=signed_strides, minimum_rank=minimum_rank, maximum_rank=maximum_rank, flatten_python_storage=flatten_python_storage, @@ -7477,16 +7482,60 @@ def _array_handoff_contiguous(contiguous: bool | None, category: str | None) -> return None +# Ordinary arrays are still handed over as an address plus extents. The +# descriptor-carrying mechanism they are entitled to by ABI is being built; this +# names the one fact that gates it, so the two land together. +_ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS = False + + +def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: + """Complete how one array dummy is reached, by asking the direct question. + + A ``bind(C)`` procedure with no bridge receives the address of the first + element and nothing more for an explicit-shape or assumed-size dummy, and + for a raw C pointer: the declaration already says what the layout is, so + there is nothing to convey. Every other form -- assumed-shape, + deferred-shape, assumed-rank, and a contract that names no Fortran category + because a bridge dummy will be generated for it -- is reached through a + ``CFI_cdesc_t *``, which carries an extent and a signed byte stride per + axis. + """ + if category in {"explicit_shape", "assumed_size", "raw_address", SCALAR_STORAGE_CATEGORY}: + return ArrayEntrypointABI.RAW_ADDRESS + return ArrayEntrypointABI.C_DESCRIPTOR + + +def _array_handoff_signed_strides( + entrypoint_abi: ArrayEntrypointABI, + contiguous: bool | None, +) -> bool: + """Complete whether an axis of the actual may run backwards. + + Three things have to hold. The entrypoint must carry a descriptor, because + a bare address says nothing about which way an axis runs. The dummy must + not require contiguous storage, which a reversed axis is not -- a + ``CONTIGUOUS`` dummy keeps its requirement whatever its calling convention + carries. And the actual must reach it as a descriptor: an ordinary array + is still carried to its dummy as an address with extents beside it, and + that carries no direction however the dummy is declared. + """ + if entrypoint_abi is not ArrayEntrypointABI.C_DESCRIPTOR or contiguous is True: + return False + return _ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS + + def _array_handoff_python_layout( order: str | None, contiguous: bool | None, rank: int | None, + signed_strides: bool = False, ) -> ArrayPythonLayout: """Select the layout every accepted Python array actual must already have.""" if contiguous is None: return ArrayPythonLayout.ANY_STRIDED if contiguous is False: - return ArrayPythonLayout.POSITIVE_STRIDED_F + # The section rules are the same either way; only the sign is at stake. + return ArrayPythonLayout.SIGNED_STRIDED_F if signed_strides else ArrayPythonLayout.POSITIVE_STRIDED_F if order == "ORDER_C": return ArrayPythonLayout.C_CONTIGUOUS if order == "ORDER_F" or (rank is not None and rank > 1): @@ -7607,6 +7656,9 @@ def _raw_array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandof native_order=order, contiguous=True, python_layout=_array_handoff_python_layout(order, True, rank), + # A raw C address is the whole ABI here: nothing conveys a stride. + entrypoint_abi=ArrayEntrypointABI.RAW_ADDRESS, + signed_strides=False, minimum_rank=rank, maximum_rank=rank, itemsize=_character_length(semantic_type) if semantic_type.name == "String" else None, diff --git a/prik/policy/models.py b/prik/policy/models.py index 6291c2682..1add7be85 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -137,15 +137,42 @@ class ArrayPythonLayout(str, Enum): Policy selects the constraint; a backend only enforces it. ``ANY_STRIDED`` states that the contract constrains neither ordering nor contiguity, so the caller's own strides reach the native call unchanged. + + ``SIGNED_STRIDED_F`` is ``POSITIVE_STRIDED_F`` with the sign requirement + lifted: the axes must still be a Fortran array section -- ordered by + magnitude of stride, each a whole number of elements, none overlapping + another -- because that is what can be described to Fortran, but an axis + may run backwards. It is selected only where the entrypoint carries a + descriptor, since an address alone cannot say which way an axis runs. """ ANY_CONTIGUOUS = "any_contiguous" C_CONTIGUOUS = "c_contiguous" F_CONTIGUOUS = "f_contiguous" POSITIVE_STRIDED_F = "positive_strided_f" + SIGNED_STRIDED_F = "signed_strided_f" ANY_STRIDED = "any_strided" +class ArrayEntrypointABI(str, Enum): + """Completed shape in which one array actual reaches its native dummy. + + This is the answer to the project's direct-entrypoint question for arrays: + what would a ``bind(C)`` procedure with no bridge receive? An + ``assumed_shape`` or ``assumed_rank`` dummy is interoperable and receives a + ``CFI_cdesc_t *``, which carries a signed byte stride per axis. An + ``explicit_shape`` or ``assumed_size`` dummy receives the address of its + first element and nothing else, so nothing about its layout can be + conveyed, and only the layout the dummy already assumes is acceptable. + + A bridge implements whichever of these the direct route would have used; + it does not get to pick. + """ + + RAW_ADDRESS = "raw_address" + C_DESCRIPTOR = "c_descriptor" + + class ArgumentHandoffMode(str, Enum): """Completed binding-to-bridge ABI shape for one argument.""" @@ -977,6 +1004,12 @@ class ArrayHandoffPolicy: native_order: str | None contiguous: bool | None python_layout: ArrayPythonLayout + # How this dummy is reached, and therefore what can be said about layout. + entrypoint_abi: ArrayEntrypointABI + # Whether an axis of the actual may run backwards. Only a descriptor can + # carry that, and only a dummy that does not require contiguous storage can + # accept it. + signed_strides: bool minimum_rank: int maximum_rank: int flatten_python_storage: bool = False @@ -1431,6 +1464,8 @@ class FunctionWrapperPolicy: native_order="F", contiguous=True, python_layout=ArrayPythonLayout.F_CONTIGUOUS, + entrypoint_abi=ArrayEntrypointABI.RAW_ADDRESS, + signed_strides=False, minimum_rank=2, maximum_rank=2, ) diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 31ae0ecc3..c63a690ff 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -510,7 +510,95 @@ static inline PyObject *prik_status_message_text(const char *bytes, Py_ssize_t c #define PRIK_ARRAY_LAYOUT_C_CONTIGUOUS 1 #define PRIK_ARRAY_LAYOUT_F_CONTIGUOUS 2 #define PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F 3 -#define PRIK_ARRAY_LAYOUT_ANY_STRIDED 4 +#define PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F 4 +#define PRIK_ARRAY_LAYOUT_ANY_STRIDED 5 + +/* + * Report why one strided array cannot be described to Fortran. + * + * A Fortran array section runs over a contiguous parent: each axis advances by + * a whole number of elements, the axes are ordered by how far they step, and no + * axis steps back into another's span. A view that breaks those rules -- one + * NumPy made by broadcasting, or by overlapping itself -- has no parent to be a + * section of, whatever its strides say, so it cannot be handed over without + * copying. An axis that merely runs backwards breaks none of them, and is + * refused only where the entrypoint takes an address and so has nowhere to say + * so. + */ +static inline int prik_array_refuse_section( + const char *argument_name, + int axis, + int signed_strides) +{ + PyErr_Format( + PyExc_TypeError, + "Argument %s has a layout at axis %d that is not a Fortran array section: " + "each axis must step a whole number of elements%s, in increasing order of step, without overlapping", + argument_name, + axis, + signed_strides ? "" : " forward"); + return -1; +} + +/* + * Validate one axis of an F-ordered strided array. + * + * ``signed_strides`` says whether an axis may run backwards, which is exactly + * whether the entrypoint carries a descriptor to record it in. Everything else + * is required either way, because it is what makes the view a section at all. + */ +static inline int prik_array_validate_strided_axis( + PyArrayObject *array, + int axis, + int signed_strides, + const char *argument_name) +{ + npy_intp stride = PyArray_STRIDE(array, axis); + npy_intp itemsize = PyArray_ITEMSIZE(array); + npy_intp span; + npy_intp previous; + + if (itemsize <= 0 || (stride % itemsize) != 0) { + /* A step that is not a whole element has no Fortran spelling at all. */ + return prik_array_refuse_section(argument_name, axis, signed_strides); + } + if (PyArray_SIZE(array) == 0 || PyArray_DIM(array, axis) <= 1) { + /* One element cannot step anywhere, and no element cannot either. */ + return 0; + } + if (stride == 0) { + /* A repeated element: NumPy broadcasting, which Fortran has no form for. */ + return prik_array_refuse_section(argument_name, axis, signed_strides); + } + if (!signed_strides && stride < 0) { + PyErr_Format( + PyExc_TypeError, + "Argument %s runs backwards along axis %d, and this entrypoint receives only an address, " + "which cannot record a direction", + argument_name, + axis); + return -1; + } + if (axis > 0 && PyArray_DIM(array, axis - 1) > 0) { + previous = PyArray_STRIDE(array, axis - 1); + previous = previous < 0 ? -previous : previous; + span = previous * PyArray_DIM(array, axis - 1); + if ((stride < 0 ? -stride : stride) < span) { + /* + * This axis steps less far than the one before it covers, so the + * axes are either in the wrong order for Fortran or they overlap. + * Both are the same measurement, and ordering is what a caller can + * actually act on. + */ + PyErr_Format( + PyExc_TypeError, + "Argument %s has incompatible layout; expected ordering (F)", + argument_name); + return -1; + } + } + return 0; +} /* * Validate mechanics shared by every ordinary NumPy-array argument. The @@ -552,23 +640,10 @@ static inline int prik_array_validate_ndarray( } if (layout == PRIK_ARRAY_LAYOUT_ANY_STRIDED) { /* The plan accepts whatever strides the caller's array already has. */ - } else if (layout == PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F) { + } else if (layout == PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F || layout == PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F) { + int signed_strides = layout == PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F; for (axis = 0; axis < rank; axis++) { - npy_intp stride = PyArray_STRIDE(array, axis); - if ((stride % PyArray_ITEMSIZE(array)) != 0 - || (PyArray_SIZE(array) > 0 && PyArray_DIM(array, axis) > 1 && stride <= 0)) { - PyErr_Format( - PyExc_TypeError, - "Argument %s has incompatible layout; expected ordering (F)", - argument_name); - return -1; - } - if (axis > 0 && PyArray_SIZE(array) > 0 && PyArray_DIM(array, axis - 1) > 0 - && stride < PyArray_STRIDE(array, axis - 1) * PyArray_DIM(array, axis - 1)) { - PyErr_Format( - PyExc_TypeError, - "Argument %s has incompatible layout; expected ordering (F)", - argument_name); + if (prik_array_validate_strided_axis(array, axis, signed_strides, argument_name) < 0) { return -1; } } diff --git a/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py b/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py index 30e5af9e1..44d78e2e2 100644 --- a/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py +++ b/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py @@ -166,25 +166,32 @@ def test_rank2_assumed_shape_accepts_fortran_ordered_strided_views(compiled_mult def test_rank2_assumed_shape_rejects_non_positive_strides(compiled_multid_array_module): + """Each refusal names the restriction it comes from, not one shared phrase. + + A reversed axis and a broadcast axis fail for different reasons: the first + is a perfectly good array section this entrypoint has no way to describe, + because it receives an address; the second is not a section at all, since + Fortran has no form for an element repeated by a zero step. + """ source = _matrix() out = np.zeros_like(source, order="F") checksum = np.zeros(1, dtype=np.float64) reversed_source = _reversed_fortran_matrix() - with pytest.raises(TypeError, match=r"expected ordering \(F\)"): + with pytest.raises(TypeError, match=r"runs backwards along axis \d+"): compiled_multid_array_module.scale2_strided(reversed_source, out) - with pytest.raises(TypeError, match=r"expected ordering \(F\)"): + with pytest.raises(TypeError, match=r"cannot record a direction"): compiled_multid_array_module.checksum2_strided(reversed_source, checksum) broadcast_source = _broadcast_fortran_like_matrix() assert broadcast_source.strides[0] == 0 - with pytest.raises(TypeError, match=r"expected ordering \(F\)"): + with pytest.raises(TypeError, match=r"not a Fortran array section"): compiled_multid_array_module.scale2_strided(broadcast_source, out) - with pytest.raises(TypeError, match=r"expected ordering \(F\)"): + with pytest.raises(TypeError, match=r"not a Fortran array section"): compiled_multid_array_module.checksum2_strided(broadcast_source, checksum) reversed_out = _reversed_fortran_matrix() - with pytest.raises(TypeError, match=r"expected ordering \(F\)"): + with pytest.raises(TypeError, match=r"runs backwards along axis \d+"): compiled_multid_array_module.scale2_strided(source, reversed_out) From 3ab611f8a4193de1a398c14ab76fb832c672cb96 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 03:43:15 +0100 Subject: [PATCH 39/47] codex: Describe a NumPy array to a Fortran descriptor dummy The construction a strided actual needs: CFI_establish only makes contiguous descriptors, so a view has to be a section of one. The parent established here is the smallest contiguous array the view is a section of, with steps chosen to divide the view's, and CFI_section cuts the view back out of it -- which is what carries a signed stride. An axis that runs backwards starts at its far end and walks down; an empty array needs no section at all. The conditions this needs turn out to be the ones validation already enforces: requiring each axis to step a whole number of elements, in increasing order of step, without overlapping, is exactly what guarantees the parent contains the section. Verified against both compilers before wiring: rank-one reversal, step -2, rank-two with either axis reversed, both reversed, and a zero-sized axis, read element by element and through a real assumed-shape dummy, with intent(inout) writeback touching exactly the viewed elements. Lower bounds differ -- gfortran normalizes a section to 0, ifx keeps the parent subscript -- so nothing may assume them. The bridge dummy for such an argument is the array itself rather than an address with extents beside it, so no pointer local, no c_f_pointer and no section reconstruction is emitted for it. Ordinary arrays do not take this route yet: a native handle reaching the same dummy has to be entered through its own descriptor entry point, and that is the next piece. _ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS names the gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 226 ++++++++++++++++++++++++++++++++- prik/codegen/fortran/bridge.py | 41 ++++++ prik/planning/models.py | 5 + prik/planning/planner.py | 2 + prik/policy/construction.py | 15 +-- 5 files changed, 280 insertions(+), 9 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 9c1de3554..c41fbc252 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -340,7 +340,8 @@ def binding_module(self, plan: ModulePlan) -> CModule: *self._module_allocator_functions(needs_free), # Every handle inquiry runs through these, so they precede the # first handle operation that names one. - *self._native_array_projection_functions(plan), + *self._numpy_descriptor_builder_function(plan), + *self._native_array_projection_functions(plan), *self._extent_expression_support_functions(plan), *self._callback_runtime_functions(plan), *self._derived_call_runtime_functions(plan), @@ -6700,9 +6701,32 @@ def _lower_argument_required_array_storage( self._array_validation_statement(plan, names), *self._array_shape_checks(plan, context, array), ] + if self._array_crosses_as_descriptor(plan): + nodes.extend(self._numpy_descriptor_nodes(plan, names, array)) + return tuple(nodes) nodes.extend(self._array_extraction_nodes(plan, names, array)) return tuple(nodes) + def _numpy_descriptor_nodes( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + array: ArrayHandoffPlan, + ) -> tuple[CExpressionStatement | CIf, ...]: + """Describe the caller's NumPy storage to Fortran without copying it.""" + prefix = names.value_name + return ( + CComment("The dummy takes a descriptor, so one is made over the array as it is."), + CIf( + CodeExpression( + f"{self.NUMPY_DESCRIPTOR_BUILDER}((CFI_cdesc_t *)&{prefix}_parent, " + f"(CFI_cdesc_t *)&{prefix}_section, (PyArrayObject *){names.object_name}, " + f"{self._native_array_cfi_type(plan)}, \"{plan.binding.python_name}\") < 0" + ), + body=(CReturn(CodeExpression("NULL")),), + ), + ) + def _ordinary_array_argument_declarations( self, plan: ArgumentTransferPlan, @@ -6712,6 +6736,16 @@ def _ordinary_array_argument_declarations( array = plan.array if array is None: raise ValueError(f"Array argument {plan.owner_path!r} is missing its handoff") + if self._array_crosses_as_descriptor(plan): + if array.rank is None: + raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") + # Both live for the whole call: the section describes the caller's + # storage, and it is a section of the parent, which must outlive it. + return ( + CDeclaration(names.object_name, "PyObject *"), + CDeclaration(f"{names.value_name}_parent", f"CFI_CDESC_T({array.rank})"), + CDeclaration(f"{names.value_name}_section", f"CFI_CDESC_T({array.rank})"), + ) declarations = [ CDeclaration(names.object_name, "PyObject *"), CDeclaration(names.value_name, "void *", CodeExpression("NULL")), @@ -9333,6 +9367,12 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) ), ) + @staticmethod + def _array_crosses_as_descriptor(argument: ArgumentTransferPlan) -> bool: + """Report whether completed policy hands this array over as a descriptor.""" + array = argument.array + return array is not None and array.signed_strides + def _array_actual_reader_name(self, function: FunctionPlan, argument: ArgumentTransferPlan) -> str: """Return the reader that copies one handle's storage out of its descriptor.""" owner = re.sub(r"\W", "_", argument.owner_path).casefold() @@ -9712,6 +9752,184 @@ def _strided_native_array_actual_reader_nodes(argument: ArgumentTransferPlan) -> # descriptor is read where it is valid and only the finished Python object # leaves. Nothing copies a descriptor out, and no operation needs a Fortran # procedure of its own to report what the descriptor already says. + NUMPY_DESCRIPTOR_BUILDER = "prik_describe_numpy_array" + + def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: + """Emit the one constructor every NumPy array reaching a descriptor uses.""" + if not self._module_describes_numpy_arrays(plan): + return () + return ( + CFunction( + self.NUMPY_DESCRIPTOR_BUILDER, + "int", + parameters=( + CParameter("parent", "CFI_cdesc_t *"), + CParameter("section", "CFI_cdesc_t *"), + CParameter("array", "PyArrayObject *"), + CParameter("cfi_type", "CFI_type_t"), + CParameter("argument_name", "const char *"), + ), + storage="static", + doc=( + "Describe borrowed NumPy storage to Fortran, without copying it.", + "CFI_establish only makes contiguous descriptors, so a strided view has" + " to be a section of one: the parent established here is the smallest" + " contiguous array the view is a section of, and CFI_section cuts the" + " view back out of it. That is the only construction the standard" + " offers, and it is what carries a signed stride.", + "The parent's own steps are chosen to divide the view's, so each axis" + " needs a whole-number step; an axis that runs backwards starts at its" + " far end and walks down. Validation has already refused anything these" + " rules cannot describe, so a failure here is the Fortran runtime's.", + "Both descriptors belong to the caller's frame and last exactly as long" + " as the call, which is as long as the array is borrowed.", + ), + body=( + CDeclaration("rank", "int", CodeExpression("PyArray_NDIM(array)")), + CDeclaration("elem_len", "CFI_index_t", CodeExpression("(CFI_index_t)PyArray_ITEMSIZE(array)")), + CDeclaration("extents[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("parent_extents[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("lower[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("upper[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("step[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("unit[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("element_stride[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("offset", "CFI_index_t", CodeExpression("0")), + CDeclaration("empty", "int", CodeExpression("0")), + CDeclaration("axis", "int", CodeExpression("0")), + CDeclaration("status", "int", CodeExpression("CFI_SUCCESS")), + CIf( + CodeExpression("elem_len <= 0"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_TypeError, "Argument %s has no element width", ' + "argument_name)" + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CFor( + "axis = 0", + CodeExpression("axis < rank"), + CodeExpression("++axis"), + body=( + CExpressionStatement( + CodeExpression("extents[axis] = (CFI_index_t)PyArray_DIM(array, axis)") + ), + CExpressionStatement( + CodeExpression( + "element_stride[axis] = (CFI_index_t)PyArray_STRIDE(array, axis) / elem_len" + ) + ), + CIf(CodeExpression("extents[axis] == 0"), body=( + CExpressionStatement(CodeExpression("empty = 1")), + )), + ), + ), + CComment("Nothing steps anywhere in an empty array, so it needs no section."), + CIf( + CodeExpression("empty"), + body=( + CReturn( + CodeExpression( + "CFI_establish(section, PyArray_DATA(array), CFI_attribute_other, cfi_type, " + "(size_t)elem_len, rank, extents) == CFI_SUCCESS ? 0 : -1" + ) + ), + ), + ), + CComment("The parent's step along each axis, chosen to divide the view's."), + CExpressionStatement(CodeExpression("unit[0] = 1")), + CFor( + "axis = 1", + CodeExpression("axis < rank"), + CodeExpression("++axis"), + body=( + CExpressionStatement( + CodeExpression( + "unit[axis] = element_stride[axis] < 0 ? -element_stride[axis] " + ": element_stride[axis]" + ) + ), + ), + ), + CFor( + "axis = 0", + CodeExpression("axis < rank"), + CodeExpression("++axis"), + body=( + CExpressionStatement(CodeExpression("step[axis] = element_stride[axis] / unit[axis]")), + CExpressionStatement( + CodeExpression( + "parent_extents[axis] = axis + 1 < rank ? (element_stride[axis + 1] < 0 " + "? -element_stride[axis + 1] : element_stride[axis + 1]) / unit[axis] " + ": extents[axis]" + ) + ), + CComment("A backward axis starts at its far end and walks down."), + CExpressionStatement( + CodeExpression( + "lower[axis] = step[axis] > 0 ? 0 : (extents[axis] - 1) * (-step[axis])" + ) + ), + CExpressionStatement( + CodeExpression("upper[axis] = lower[axis] + (extents[axis] - 1) * step[axis]") + ), + CExpressionStatement(CodeExpression("offset += lower[axis] * unit[axis]")), + ), + ), + CExpressionStatement( + CodeExpression( + "status = CFI_establish(parent, (char *)PyArray_DATA(array) - offset * elem_len, " + "CFI_attribute_other, cfi_type, (size_t)elem_len, rank, parent_extents)" + ) + ), + CIf( + CodeExpression("status == CFI_SUCCESS"), + body=( + CExpressionStatement( + CodeExpression( + "status = CFI_establish(section, NULL, CFI_attribute_other, cfi_type, " + "(size_t)elem_len, rank, NULL)" + ) + ), + ), + ), + CIf( + CodeExpression("status == CFI_SUCCESS"), + body=( + CExpressionStatement( + CodeExpression("status = CFI_section(section, parent, lower, upper, step)") + ), + ), + ), + CIf( + CodeExpression("status != CFI_SUCCESS"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_TypeError, "Argument %s could not be described to Fortran ' + 'as an array section: %d", argument_name, status)' + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CReturn(CodeExpression("0")), + ), + ), + ) + + def _module_describes_numpy_arrays(self, plan: ModulePlan) -> bool: + """Report whether any argument in this module takes the descriptor route.""" + return any( + self._array_crosses_as_descriptor(argument) + for function in self._functions(plan) + for argument in function.arguments + ) + NATIVE_ARRAY_PROJECTION_RECORD = "prik_native_array_projection" def _native_array_projection_record(self) -> CStructDefinition: @@ -12213,6 +12431,8 @@ def _entrypoint_argument_values( if plan.entrypoint.handoff_mode is ArgumentHandoffMode.CHARACTER_BUFFER: return self._string_entrypoint_argument_values(plan, names, passing=passing) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER: + if self._array_crosses_as_descriptor(plan): + return (f"(CFI_cdesc_t *)&{names.value_name}_section",) return self._array_entrypoint_argument_values(plan, names) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: return (names.value_name,) @@ -12509,6 +12729,10 @@ def _ordinary_entrypoint_argument_parameters( if argument.entrypoint.handoff_mode is ArgumentHandoffMode.CHARACTER_BUFFER: return self._string_entrypoint_argument_parameters(argument, name, passing=passing) if argument.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER: + if self._array_crosses_as_descriptor(argument): + # Extents and strides travel inside the descriptor, so the + # address and the fields beside it are not needed. + return (CParameter(name, "CFI_cdesc_t *"),) return self._array_entrypoint_argument_parameters(argument, name) if argument.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: parameters = [CParameter(name, "CFI_cdesc_t *")] diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 08f6e97cb..cceee188a 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -3405,6 +3405,34 @@ def _lower_argument_string_value( FortranParameter(f"{name}_length", "integer(c_int64_t)", ("value",)), ) + @staticmethod + def _array_crosses_as_descriptor(plan: ArgumentTransferPlan) -> bool: + """Report whether completed policy hands this array over as a descriptor.""" + array = plan.array + return array is not None and array.signed_strides + + def _lower_argument_array_descriptor( + self, + plan: ArgumentTransferPlan, + ) -> tuple[FortranParameter, ...]: + """Receive one ordinary array as the descriptor its direct route uses. + + An assumed-shape dummy is interoperable, so C passes a ``CFI_cdesc_t *`` + and the extents and strides arrive inside it. Nothing is reconstructed + here: the dummy is the array, with the bounds and directions the caller + described, and it is handed to the native procedure as it stands. + """ + array = plan.array + if array is None or array.rank is None: + raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") + return ( + FortranParameter( + plan.entrypoint.parameter_name, + self._array_element_fortran_type(plan), + (self._array_dimension_attribute(array.rank),), + ), + ) + # Ordinary-array argument lowering. def _lower_argument_array_buffer( self, @@ -3414,6 +3442,8 @@ def _lower_argument_array_buffer( array = plan.array if array is None: raise ValueError(f"Array argument {plan.owner_path!r} has no handoff spec") + if self._array_crosses_as_descriptor(plan): + return self._lower_argument_array_descriptor(plan) name = plan.entrypoint.parameter_name return ( FortranParameter(f"bound_{name}", "type(c_ptr)", ("value",)), @@ -4044,6 +4074,9 @@ def _prepare_present_associated_view( if plan.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: return () if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER: + if self._array_crosses_as_descriptor(plan): + # The dummy already is the array the caller described. + return () return self._array_pointer_initializer_nodes(plan) if plan.entrypoint.optional_mode is OptionalMode.NULLABLE_VALUE: return ( @@ -4305,6 +4338,9 @@ def _array_declarations(self, plan: FunctionPlan) -> tuple[FortranDeclaration, . for argument in plan.arguments: if argument.entrypoint.handoff_mode is not ArgumentHandoffMode.ARRAY_BUFFER: continue + if self._array_crosses_as_descriptor(argument): + # The dummy is the view; there is no address to make one from. + continue array = argument.array if array is None: raise ValueError(f"Array argument {argument.owner_path!r} is missing its handoff") @@ -4528,6 +4564,8 @@ def _array_initializers(self, plan: FunctionPlan) -> tuple[FortranCall | Fortran continue if argument.array is not None and argument.array.rank is None: continue + if self._array_crosses_as_descriptor(argument): + continue initializers.extend(self._array_pointer_initializer_nodes(argument)) return tuple(initializers) @@ -4678,6 +4716,9 @@ def _array_boundary_argument_expression(self, argument: ArgumentTransferPlan) -> name = argument.entrypoint.parameter_name if array.rank is None: return name + if self._array_crosses_as_descriptor(argument): + # The dummy carries the caller's own bounds and directions. + return name pointer_name = self._array_pointer_name(argument) if array.contiguous is not False: return pointer_name diff --git a/prik/planning/models.py b/prik/planning/models.py index 5749bdd62..71a5eead8 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -34,6 +34,7 @@ ArgumentConversionPhase, ArgumentHandoffMode, ArrayLogicalABI, + ArrayEntrypointABI, ArrayPythonLayout, ArrayWritebackABI, BridgeDataAction, @@ -479,6 +480,10 @@ class ArrayHandoffPlan(StageRecord): native_order: str | None contiguous: bool | None python_layout: ArrayPythonLayout + # How the dummy is reached, and whether an axis may run backwards. Both are + # completed in policy; a backend implements the mechanism they name. + entrypoint_abi: ArrayEntrypointABI + signed_strides: bool minimum_rank: int maximum_rank: int flatten_python_storage: bool diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 391fcc56d..fc1462f6b 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -2360,6 +2360,8 @@ def _array_plan( native_order=policy.native_order, contiguous=policy.contiguous, python_layout=policy.python_layout, + entrypoint_abi=policy.entrypoint_abi, + signed_strides=policy.signed_strides, minimum_rank=policy.minimum_rank, maximum_rank=policy.maximum_rank, flatten_python_storage=policy.flatten_python_storage, diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 5e53b7073..7573c4571 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -7482,9 +7482,10 @@ def _array_handoff_contiguous(contiguous: bool | None, category: str | None) -> return None -# Ordinary arrays are still handed over as an address plus extents. The -# descriptor-carrying mechanism they are entitled to by ABI is being built; this -# names the one fact that gates it, so the two land together. +# A NumPy actual can be described to a descriptor dummy today; a native handle +# reaching the same dummy has to be entered through its own descriptor entry +# point, and that integration is not finished. Until it is, ordinary arrays keep +# the address-and-extents handoff, which both sources share. _ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS = False @@ -7511,13 +7512,11 @@ def _array_handoff_signed_strides( ) -> bool: """Complete whether an axis of the actual may run backwards. - Three things have to hold. The entrypoint must carry a descriptor, because - a bare address says nothing about which way an axis runs. The dummy must + Two things have to hold. The entrypoint must carry a descriptor, because a + bare address says nothing about which way an axis runs. And the dummy must not require contiguous storage, which a reversed axis is not -- a ``CONTIGUOUS`` dummy keeps its requirement whatever its calling convention - carries. And the actual must reach it as a descriptor: an ordinary array - is still carried to its dummy as an address with extents beside it, and - that carries no direction however the dummy is declared. + carries. """ if entrypoint_abi is not ArrayEntrypointABI.C_DESCRIPTOR or contiguous is True: return False From 75717c9fe94a6ea0db476974aca8ae69a98ff59a Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 03:43:51 +0100 Subject: [PATCH 40/47] codex: Format the descriptor constructor Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index c41fbc252..7ca429083 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -341,7 +341,7 @@ def binding_module(self, plan: ModulePlan) -> CModule: # Every handle inquiry runs through these, so they precede the # first handle operation that names one. *self._numpy_descriptor_builder_function(plan), - *self._native_array_projection_functions(plan), + *self._native_array_projection_functions(plan), *self._extent_expression_support_functions(plan), *self._callback_runtime_functions(plan), *self._derived_call_runtime_functions(plan), @@ -6721,7 +6721,7 @@ def _numpy_descriptor_nodes( CodeExpression( f"{self.NUMPY_DESCRIPTOR_BUILDER}((CFI_cdesc_t *)&{prefix}_parent, " f"(CFI_cdesc_t *)&{prefix}_section, (PyArrayObject *){names.object_name}, " - f"{self._native_array_cfi_type(plan)}, \"{plan.binding.python_name}\") < 0" + f'{self._native_array_cfi_type(plan)}, "{plan.binding.python_name}") < 0' ), body=(CReturn(CodeExpression("NULL")),), ), @@ -9803,8 +9803,7 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: body=( CExpressionStatement( CodeExpression( - 'PyErr_Format(PyExc_TypeError, "Argument %s has no element width", ' - "argument_name)" + 'PyErr_Format(PyExc_TypeError, "Argument %s has no element width", argument_name)' ) ), CReturn(CodeExpression("-1")), @@ -9823,9 +9822,10 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: "element_stride[axis] = (CFI_index_t)PyArray_STRIDE(array, axis) / elem_len" ) ), - CIf(CodeExpression("extents[axis] == 0"), body=( - CExpressionStatement(CodeExpression("empty = 1")), - )), + CIf( + CodeExpression("extents[axis] == 0"), + body=(CExpressionStatement(CodeExpression("empty = 1")),), + ), ), ), CComment("Nothing steps anywhere in an empty array, so it needs no section."), @@ -9870,9 +9870,7 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: ), CComment("A backward axis starts at its far end and walks down."), CExpressionStatement( - CodeExpression( - "lower[axis] = step[axis] > 0 ? 0 : (extents[axis] - 1) * (-step[axis])" - ) + CodeExpression("lower[axis] = step[axis] > 0 ? 0 : (extents[axis] - 1) * (-step[axis])") ), CExpressionStatement( CodeExpression("upper[axis] = lower[axis] + (extents[axis] - 1) * step[axis]") From 6e431b5de1603e0ea8b80c49a78a9d505f53b72b Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 04:09:38 +0100 Subject: [PATCH 41/47] codex: Reach a descriptor dummy from a handle and from NumPy alike Both sources now arrive at the same slot. A handle already has a descriptor and it is valid only inside its own entry point, so the consumer chain enters it; a NumPy array has none, so a section is built over its storage in the caller's frame, which outlives the call. The chain needed no new shape for this: a slot with a backend is entered, a slot without one is passed on, and that is what already distinguished a present optional from an absent one. An omissible descriptor dummy is declared optional and its presence asked with present(), because C omits such an argument by passing no descriptor rather than a null pointer beside one. Verified on the shapes that matter: a reversed NumPy view, a reversed pointer handle and a reversed bind(C) call all reach an assumed-shape dummy and return the right sum, and the handle path runs no Python frame. Still gated. Removing the extent parameters removes what other declarations read from them -- a result declared dimension(size(x)) among them -- and those extents have to be carried out of the descriptor before this can be the only route. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 77 +++++++++++++++++++++++++++++----- prik/codegen/fortran/bridge.py | 22 ++++++++-- prik/policy/construction.py | 9 ++-- 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 7ca429083..b24090880 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -6712,11 +6712,19 @@ def _numpy_descriptor_nodes( plan: ArgumentTransferPlan, names: _CArgumentNames, array: ArrayHandoffPlan, - ) -> tuple[CExpressionStatement | CIf, ...]: - """Describe the caller's NumPy storage to Fortran without copying it.""" + ) -> tuple[CComment | CExpressionStatement | CIf, ...]: + """Reach one descriptor dummy from whichever source the caller supplied. + + A handle already has a descriptor, and it is valid only inside its own + entry point, so what is recorded here is the backend and the chain + enters it. A NumPy array has no descriptor, so one is made over its + storage as it stands, in this frame, which outlives the call. Both + arrive at the same slot, and the callee cannot tell them apart. + """ prefix = names.value_name - return ( - CComment("The dummy takes a descriptor, so one is made over the array as it is."), + describe: tuple = ( + CComment("No descriptor of its own, so one is made over the array as it is."), + self._array_validation_statement(plan, names, object_kind_checked=True), CIf( CodeExpression( f"{self.NUMPY_DESCRIPTOR_BUILDER}((CFI_cdesc_t *)&{prefix}_parent, " @@ -6725,6 +6733,38 @@ def _numpy_descriptor_nodes( ), body=(CReturn(CodeExpression("NULL")),), ), + CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{prefix}_section")), + ) + if not self._takes_array_handle(plan): + return describe + capsule = f"{prefix}_capsule" + backend = self._descriptor_backend_local(names) + return ( + CExpressionStatement( + CodeExpression(f'{capsule} = PyObject_GetAttrString({names.object_name}, "_native_backend")') + ), + CExpressionStatement(CodeExpression(f"if ({capsule} == NULL) {{ PyErr_Clear(); }}")), + CIf( + CodeExpression(f"{capsule} != NULL && {capsule} != Py_None"), + body=( + CComment("A handle's descriptor is the runtime's; the chain enters it."), + CExpressionStatement( + CodeExpression( + f"{backend} = prik_native_array_backend_for_actual({capsule}, " + f"{plan.array.minimum_rank}, {plan.array.maximum_rank}, " + f"{self._native_array_cfi_type(plan)}, " + f"{self._native_array_expected_element_size(plan)}, " + f'"{plan.native_array_actual.dtype}", "{plan.binding.python_name}")' + ) + ), + CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), + CIf(CodeExpression(f"{backend} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + ), + else_body=( + CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), + *describe, + ), + ), ) def _ordinary_array_argument_declarations( @@ -6739,10 +6779,17 @@ def _ordinary_array_argument_declarations( if self._array_crosses_as_descriptor(plan): if array.rank is None: raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") - # Both live for the whole call: the section describes the caller's - # storage, and it is a section of the parent, which must outlive it. + # A handle is entered through its backend; a NumPy array is + # described into the storage below, which lives as long as the call. return ( CDeclaration(names.object_name, "PyObject *"), + CDeclaration(names.value_name, "CFI_cdesc_t *", CodeExpression("NULL")), + CDeclaration( + self._descriptor_backend_local(names), + "prik_native_array_backend *", + CodeExpression("NULL"), + ), + CDeclaration(f"{names.value_name}_capsule", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{names.value_name}_parent", f"CFI_CDESC_T({array.rank})"), CDeclaration(f"{names.value_name}_section", f"CFI_CDESC_T({array.rank})"), ) @@ -6794,6 +6841,13 @@ def _lower_argument_required_array_actual( names = context.arguments[plan.owner_path] prefix = names.value_name array_object = f"(PyArrayObject *){names.object_name}" + if self._array_crosses_as_descriptor(plan): + # One dummy, one descriptor; the source only decides where it comes + # from, and the shape checks apply to whichever supplied it. + return ( + *self._ordinary_array_argument_declarations(plan, names), + *self._numpy_descriptor_nodes(plan, names, array), + ) if not self._takes_array_handle(plan): outlined = self._outlined_array_bind_nodes(plan, context, names) if outlined is not None: @@ -9329,9 +9383,12 @@ def _inverted_descriptor_arguments(self, plan: FunctionPlan) -> tuple[ArgumentTr return tuple( argument for argument in plan.arguments - if argument.native_array_handle is not None - and argument.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR - and argument.native_array_handle.array.rank is not None + if self._array_crosses_as_descriptor(argument) + or ( + argument.native_array_handle is not None + and argument.native_array_handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR + and argument.native_array_handle.array.rank is not None + ) ) def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) -> tuple: @@ -12430,7 +12487,7 @@ def _entrypoint_argument_values( return self._string_entrypoint_argument_values(plan, names, passing=passing) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER: if self._array_crosses_as_descriptor(plan): - return (f"(CFI_cdesc_t *)&{names.value_name}_section",) + return (names.value_name,) return self._array_entrypoint_argument_values(plan, names) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: return (names.value_name,) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index cceee188a..b29d443ef 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -3425,11 +3425,16 @@ def _lower_argument_array_descriptor( array = plan.array if array is None or array.rank is None: raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") + attributes = [self._array_dimension_attribute(array.rank)] + if plan.entrypoint.optional_mode is not OptionalMode.REQUIRED: + # C omits it by passing no descriptor, which is what optional means + # for an interoperable dummy. + attributes.append("optional") return ( FortranParameter( plan.entrypoint.parameter_name, self._array_element_fortran_type(plan), - (self._array_dimension_attribute(array.rank),), + tuple(attributes), ), ) @@ -4018,6 +4023,10 @@ def _presence_condition(self, plan: ArgumentTransferPlan) -> str: name = plan.entrypoint.parameter_name if plan.derived_call is not None: return f"bound_{name}_access /= 0_c_int" + if self._array_crosses_as_descriptor(plan): + # The dummy is the array itself, and C omits it by passing no + # descriptor at all, so Fortran's own inquiry is the condition. + return f"present({name})" suffix = "_present" if plan.entrypoint.optional_mode is OptionalMode.DESCRIPTOR else "" return f"c_associated(bound_{name}{suffix})" @@ -8611,15 +8620,20 @@ def _array_shape_from_roles(self, array: ArrayHandoffPlan, plan: FunctionPlan) - for axis, expression in enumerate(array.shape) ) - @staticmethod - def _array_shape_role_names(plan: FunctionPlan) -> dict[str, str]: + def _array_shape_role_names(self, plan: FunctionPlan) -> dict[str, str]: """Map planned scalar, extent, and callable roles to bridge spellings.""" role_names = { argument.entrypoint.handoff_role: argument.entrypoint.parameter_name for argument in plan.arguments } role_names.update( { - role: f"{argument.entrypoint.parameter_name}_extent_{axis}" + role: ( + # A descriptor argument brought its extents with it, so + # Fortran asks the array rather than a parameter beside it. + f"size({argument.entrypoint.parameter_name}, {axis + 1})" + if self._array_crosses_as_descriptor(argument) + else f"{argument.entrypoint.parameter_name}_extent_{axis}" + ) for argument in plan.arguments if argument.array is not None for axis, role in enumerate(argument.array.extent_roles) diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 7573c4571..42a8b7dcc 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -7482,10 +7482,11 @@ def _array_handoff_contiguous(contiguous: bool | None, category: str | None) -> return None -# A NumPy actual can be described to a descriptor dummy today; a native handle -# reaching the same dummy has to be entered through its own descriptor entry -# point, and that integration is not finished. Until it is, ordinary arrays keep -# the address-and-extents handoff, which both sources share. +# Both sources reach a descriptor dummy correctly now -- a NumPy array through a +# section built over its storage, a handle through its own entry point. What is +# not finished is everything that referenced the extent parameters this route +# removes: a result declared dimension(size(x)) reads them, and they have to be +# carried out of the descriptor instead. _ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS = False From 2aa5423f85800f980cd3258f1897890d10395c8e Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 05:22:28 +0100 Subject: [PATCH 42/47] codex: Hand every descriptor-capable array over as a descriptor The route is now the only one for an assumed-shape, deferred-shape or assumed-rank dummy, which is what its direct bind(C) equivalent receives. A reversed NumPy view, a reversed pointer target and a reversed bind(C) call all reach such a dummy and read the elements the caller sees; writing through one reaches the caller's own storage. What the extents left behind is carried out of the descriptor while it is live, because a borrowed one is gone once its consumer returns, and a declaration written in terms of this array's shape still has to read them. The same read reports whether there is storage at all, so an unallocated allocatable and an unassociated pointer are refused as before, and a character dummy is still matched on its declared width -- from the descriptor for a handle, from the array for a NumPy actual. The positive-stride reconstruction is gone. A probe confirmed nothing reaches it: no c_f_pointer, no base pointer, no dense-actual flag and no upper-bound or stride parameters are emitted for these dummies. Explicit-shape, assumed-size and C pointers keep the address handoff, which is all their ABI can carry. An interoperable dummy takes its character width from the descriptor, so a runtime-width character array is declared assumed-length; both compilers accept that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/c/binding.py | 184 +++++++++++++++++- prik/codegen/fortran/bridge.py | 51 +---- prik/policy/construction.py | 9 +- .../codegen/test_strided_array_lowering.py | 65 +++---- .../test_layout_and_strided_arrays.py | 41 ++-- .../test_native_handle_array_forms.py | 12 +- 6 files changed, 247 insertions(+), 115 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index b24090880..f39fbc338 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -341,6 +341,7 @@ def binding_module(self, plan: ModulePlan) -> CModule: # Every handle inquiry runs through these, so they precede the # first handle operation that names one. *self._numpy_descriptor_builder_function(plan), + *self._array_extents_reader_function(plan), *self._native_array_projection_functions(plan), *self._extent_expression_support_functions(plan), *self._callback_runtime_functions(plan), @@ -6722,9 +6723,29 @@ def _numpy_descriptor_nodes( arrive at the same slot, and the callee cannot tell them apart. """ prefix = names.value_name + declared = self._declared_character_width(plan) + width_guard: tuple = () + if declared: + width_guard = ( + CComment("A character dummy is matched on its declared width."), + CIf( + CodeExpression(f"PyArray_ITEMSIZE((PyArrayObject *){names.object_name}) != {declared}"), + body=( + CExpressionStatement( + CodeExpression( + "PyErr_Format(PyExc_TypeError, " + f"\"{plan.binding.python_name} does not match expected dtype dtype('S%d')\", " + f"{declared})" + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + ) describe: tuple = ( CComment("No descriptor of its own, so one is made over the array as it is."), self._array_validation_statement(plan, names, object_kind_checked=True), + *width_guard, CIf( CodeExpression( f"{self.NUMPY_DESCRIPTOR_BUILDER}((CFI_cdesc_t *)&{prefix}_parent, " @@ -6734,6 +6755,12 @@ def _numpy_descriptor_nodes( body=(CReturn(CodeExpression("NULL")),), ), CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{prefix}_section")), + *( + CExpressionStatement( + CodeExpression(f"{name} = (int64_t)PyArray_DIM((PyArrayObject *){names.object_name}, {axis})") + ) + for axis, name in enumerate(names.extent_names) + ), ) if not self._takes_array_handle(plan): return describe @@ -6759,6 +6786,36 @@ def _numpy_descriptor_nodes( ), CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), CIf(CodeExpression(f"{backend} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + CComment("Its state and extents are read while its own descriptor is live."), + CExpressionStatement(CodeExpression(f"{prefix}_extents_out.rank = {plan.array.rank}")), + CExpressionStatement(CodeExpression(f"{prefix}_extents_out.present = 0")), + CExpressionStatement(CodeExpression(f"{prefix}_extents_out.elem_len = 0")), + CExpressionStatement(CodeExpression(f"{prefix}_extents_out.extents = {prefix}_extents")), + CExpressionStatement( + CodeExpression( + f"{backend}->with_descriptor({backend}->context, " + f"{self.ARRAY_EXTENTS_READER}, &{prefix}_extents_out)" + ) + ), + *self._descriptor_character_width_guard(plan, prefix), + CIf( + CodeExpression(f"!{prefix}_extents_out.present"), + body=( + CExpressionStatement( + CodeExpression( + "PyErr_SetString(PyExc_ValueError, " + f"{backend}->descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER " + '? "pointer handle is unassociated and cannot be passed as an array actual" ' + ': "allocatable handle is unallocated and cannot be passed as an array actual")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + *( + CExpressionStatement(CodeExpression(f"{name} = {prefix}_extents[{axis}]")) + for axis, name in enumerate(names.extent_names) + ), ), else_body=( CExpressionStatement(CodeExpression(f"Py_XDECREF({capsule})")), @@ -6792,6 +6849,9 @@ def _ordinary_array_argument_declarations( CDeclaration(f"{names.value_name}_capsule", "PyObject *", CodeExpression("NULL")), CDeclaration(f"{names.value_name}_parent", f"CFI_CDESC_T({array.rank})"), CDeclaration(f"{names.value_name}_section", f"CFI_CDESC_T({array.rank})"), + CDeclaration(f"{names.value_name}_extents[{array.rank}]", "int64_t"), + CDeclaration(f"{names.value_name}_extents_out", self.ARRAY_EXTENTS_RECORD), + *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in names.extent_names), ) declarations = [ CDeclaration(names.object_name, "PyObject *"), @@ -9399,9 +9459,8 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) record = self._inverted_context_name(plan) chain = self._inverted_chain_fields(plan, context) fields = self._inverted_context_fields(plan, context) - result = self._direct_result(plan) values = [value for _declaration, value in chain + fields] - if result is not None: + if self._inverted_carries_result(plan): values.append("0") return ( CComment("Everything the call needs apart from the descriptors themselves is"), @@ -9419,7 +9478,7 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) ), *( (CExpressionStatement(CodeExpression(f"{context.result_name} = call_context.result")),) - if result is not None and context.result_name is not None + if self._inverted_carries_result(plan) and context.result_name is not None else () ), ) @@ -9810,6 +9869,70 @@ def _strided_native_array_actual_reader_nodes(argument: ArgumentTransferPlan) -> # leaves. Nothing copies a descriptor out, and no operation needs a Fortran # procedure of its own to report what the descriptor already says. NUMPY_DESCRIPTOR_BUILDER = "prik_describe_numpy_array" + ARRAY_EXTENTS_READER = "prik_read_array_extents" + ARRAY_EXTENTS_RECORD = "prik_array_extents_out" + + def _array_extents_reader_function(self, plan: ModulePlan) -> tuple: + """Emit the consumer that reads a descriptor's extents into the frame. + + A declaration elsewhere may be written in terms of this array's shape -- + a result declared ``dimension(size(values))`` is -- and a borrowed + descriptor is gone once its consumer returns. So the extents are taken + while it is live and kept in the caller's own storage, which is what + every later use reads. + """ + if not self._module_describes_numpy_arrays(plan): + return () + return ( + CStructDefinition( + self.ARRAY_EXTENTS_RECORD, + ( + CParameter("rank", "int"), + CParameter("present", "int"), + CParameter("elem_len", "int64_t"), + CParameter("extents", "int64_t *"), + ), + ), + CFunction( + self.ARRAY_EXTENTS_READER, + "void", + parameters=(CParameter("descriptor", "void *"), CParameter("context", "void *")), + storage="static", + doc=( + "Copy one descriptor's extents out while it is live, and say" + " whether there is any storage behind it.", + "A descriptor with no address describes an allocatable that was never" + " allocated, or a pointer that points at nothing. Neither may be handed" + " to a dummy that expects data.", + ), + body=( + CDeclaration("source", "CFI_cdesc_t *", CodeExpression("(CFI_cdesc_t *)descriptor")), + CDeclaration( + "out", + f"{self.ARRAY_EXTENTS_RECORD} *", + CodeExpression(f"({self.ARRAY_EXTENTS_RECORD} *)context"), + ), + CDeclaration("axis", "int", CodeExpression("0")), + CExpressionStatement(CodeExpression("out->present = source->base_addr != NULL")), + CExpressionStatement(CodeExpression("out->elem_len = (int64_t)source->elem_len")), + CFor( + "axis = 0", + CodeExpression("axis < out->rank"), + CodeExpression("++axis"), + body=( + CComment("A compiler may report an empty dimension as extent -1."), + CExpressionStatement( + CodeExpression( + "out->extents[axis] = (int64_t)(source->dim[axis].extent == -1 " + "? 0 : source->dim[axis].extent)" + ) + ), + ), + ), + CReturn(), + ), + ), + ) def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: """Emit the one constructor every NumPy array reaching a descriptor uses.""" @@ -9977,6 +10100,32 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: ), ) + def _descriptor_character_width_guard(self, plan: ArgumentTransferPlan, prefix: str) -> tuple: + """Decline storage whose element width is not the one the dummy declares. + + A character dummy is matched on its width as well as its kind, and the + descriptor states the width the storage actually has. + """ + declared = self._declared_character_width(plan) + if not declared: + return () + return ( + CComment("A character dummy is matched on its declared width."), + CIf( + CodeExpression(f"{prefix}_extents_out.elem_len != {declared}"), + body=( + CExpressionStatement( + CodeExpression( + "PyErr_Format(PyExc_TypeError, " + f"\"{plan.binding.python_name} does not match expected dtype dtype('S%d')\", " + f"{declared})" + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + ) + def _module_describes_numpy_arrays(self, plan: ModulePlan) -> bool: """Report whether any argument in this module takes the descriptor route.""" return any( @@ -10398,7 +10547,11 @@ def _inverted_context_record(self, plan: FunctionPlan, context: _CFunctionContex chain = self._inverted_chain_fields(plan, context) fields = self._inverted_context_fields(plan, context) result = self._direct_result(plan) - result_field = (CParameter("result", self._inverted_result_type(plan, result)),) if result is not None else () + result_field = ( + (CParameter("result", self._inverted_result_type(plan, result)),) + if self._inverted_carries_result(plan) + else () + ) return CStructDefinition( self._inverted_context_name(plan), tuple(declaration for declaration, _value in chain + fields) + result_field, @@ -10469,12 +10622,23 @@ def _inverted_consumer_chain(self, plan: FunctionPlan, context: _CFunctionContex # A link may only be named once the one it enters has been defined. return tuple(reversed(functions)) + def _inverted_carries_result(self, plan: FunctionPlan) -> bool: + """Report whether the entrypoint returns a value the chain must carry out. + + A result the entrypoint writes through a parameter is already reaching + this frame by address; only a returned value has to be brought back. + """ + return self._direct_result(plan) is not None and self._entrypoint_return_type(plan) != "void" + def _inverted_result_type(self, plan: FunctionPlan, result) -> str: - """Return the C storage a carried direct result is written into.""" - direct_c_abi = plan.entrypoint.direct_c_abi - if direct_c_abi is not None and direct_c_abi.result is not None: - return direct_c_abi.result.c_spelling - return PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name).c_spelling + """Return the C storage a carried direct result is written into. + + It is whatever the entrypoint returns, which is not always the scalar + the semantic type names: a function whose result is an array returns + the address of its storage. + """ + del result + return self._entrypoint_return_type(plan) def _inverted_consumer_call( self, @@ -10500,7 +10664,7 @@ def _inverted_consumer_call( continue arguments.extend(carried[value] for value in values) call = f"{self._entrypoint_function_name(plan)}({', '.join(arguments)})" - return f"call->result = {call}" if self._direct_result(plan) is not None else call + return f"call->result = {call}" if self._inverted_carries_result(plan) else call def _inverted_context_name(self, plan: FunctionPlan) -> str: """Return the record carrying one inverted call's other values.""" diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index b29d443ef..ed4f111e8 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -3425,18 +3425,17 @@ def _lower_argument_array_descriptor( array = plan.array if array is None or array.rank is None: raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") + element_type = self._array_element_fortran_type(plan) + if plan.datatype_family is DatatypeFamily.STRING and array.itemsize is None: + # The width travels in the descriptor, and a bind(C) character dummy + # may not name a variable for it, so it is assumed here. + element_type = "character(kind=c_char, len=*)" attributes = [self._array_dimension_attribute(array.rank)] if plan.entrypoint.optional_mode is not OptionalMode.REQUIRED: # C omits it by passing no descriptor, which is what optional means # for an interoperable dummy. attributes.append("optional") - return ( - FortranParameter( - plan.entrypoint.parameter_name, - self._array_element_fortran_type(plan), - tuple(attributes), - ), - ) + return (FortranParameter(plan.entrypoint.parameter_name, element_type, tuple(attributes)),) # Ordinary-array argument lowering. def _lower_argument_array_buffer( @@ -4652,24 +4651,7 @@ def _array_pointer_initializer_nodes( 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.entrypoint.parameter_name - 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)), - ), - ), - ), - ) + return (self._array_pointer_initializer(argument),) def _assumed_rank_array_declarations( self, @@ -4728,22 +4710,9 @@ def _array_boundary_argument_expression(self, argument: ArgumentTransferPlan) -> if self._array_crosses_as_descriptor(argument): # The dummy carries the caller's own bounds and directions. return name - 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.entrypoint.parameter_name - 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)})" + # Every other array reaches its dummy as an address, which the + # declaration already says how to read. + return self._array_pointer_name(argument) def _array_element_fortran_type(self, argument: ArgumentTransferPlan) -> str: """Return the completed primitive or fixed-width character element type.""" diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 42a8b7dcc..74d59db15 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -7487,7 +7487,7 @@ def _array_handoff_contiguous(contiguous: bool | None, category: str | None) -> # not finished is everything that referenced the extent parameters this route # removes: a result declared dimension(size(x)) reads them, and they have to be # carried out of the descriptor instead. -_ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS = False +_ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS = True def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: @@ -7495,14 +7495,15 @@ def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: A ``bind(C)`` procedure with no bridge receives the address of the first element and nothing more for an explicit-shape or assumed-size dummy, and - for a raw C pointer: the declaration already says what the layout is, so - there is nothing to convey. Every other form -- assumed-shape, + for a raw C pointer, whose rank the contract may leave to run time but + which is still only ever an address: the declaration already says how to + read what is there, so nothing is conveyed beside it. Every other form -- assumed-shape, deferred-shape, assumed-rank, and a contract that names no Fortran category because a bridge dummy will be generated for it -- is reached through a ``CFI_cdesc_t *``, which carries an extent and a signed byte stride per axis. """ - if category in {"explicit_shape", "assumed_size", "raw_address", SCALAR_STORAGE_CATEGORY}: + if category in {"explicit_shape", "assumed_size", "raw_address", "runtime_rank", SCALAR_STORAGE_CATEGORY}: return ArrayEntrypointABI.RAW_ADDRESS return ArrayEntrypointABI.C_DESCRIPTOR diff --git a/tests/fortran/arrays/codegen/test_strided_array_lowering.py b/tests/fortran/arrays/codegen/test_strided_array_lowering.py index b2d61b56f..32523fd34 100644 --- a/tests/fortran/arrays/codegen/test_strided_array_lowering.py +++ b/tests/fortran/arrays/codegen/test_strided_array_lowering.py @@ -1,4 +1,4 @@ -"""Positive-strided ordinary array view lowering.""" +"""Signed-stride ordinary array view lowering.""" from __future__ import annotations @@ -43,51 +43,42 @@ def test_strided_array_plan_names_bounds_and_element_strides_explicitly(): assert array.dense_actual_role == f"{argument.owner_path}:dense-actual" -def test_strided_array_lowering_validates_and_passes_one_explicit_bridge_slice(): +def test_strided_array_lowering_hands_over_one_descriptor_from_either_source(): + """A strided dummy is reached by a descriptor, whoever supplied the array. + + This is the direct-entrypoint answer for an assumed-shape dummy: a bind(C) + procedure with no bridge receives a ``CFI_cdesc_t *``, and the extents and + signed strides travel inside it. So the generated C describes a NumPy array + into one and enters a handle's own, and the bridge dummy is the array + itself -- there is nothing left to reconstruct on the Fortran side. + """ artifacts = WrapperGenerator().generate(_strided_plan()) c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") + # A handle is entered through its own descriptor entry point. assert ( - "prik_native_array_backend_for_actual(bound_values_backend_capsule, 2, 2, " + "prik_native_array_backend_for_actual(bound_values_capsule, 2, 2, " 'CFI_type_double, sizeof(double), "float64", "values")' ) in c_source - assert ( - "bound_values_native_backend->with_descriptor(bound_values_native_backend->context, " - "prik_fill_array_actual_strided_arrays_strided_values, &bound_values_backend_result)" - ) in c_source - assert "relative_stride = (int64_t)(source->dim[0].sm / base_bytes)" in c_source - assert "out->upper_bounds[1] = upper_bound" in c_source - assert "NPY_FLOAT64, 2, 2, PRIK_ARRAY_LAYOUT_POSITIVE_STRIDED_F, 0, 1" in c_source - assert "bound_values_upper_bound_0 = bound_values_actual.upper_bounds[0]" in c_source - assert "bound_values_stride_1 = bound_values_actual.strides[1]" 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 + # A NumPy array has none, so one is built over its storage as it stands. + assert "prik_describe_numpy_array((CFI_cdesc_t *)&bound_values_parent" in c_source + assert "CFI_section(section, parent, lower, upper, step)" in c_source + # Signed strides are what this layout accepts now. + assert "NPY_FLOAT64, 2, 2, PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F, 0, 1" in c_source + # One descriptor crosses, not an address with extents beside it. + assert "double bind_c_strided(CFI_cdesc_t * values)" in c_source or ( + "void bind_c_strided(CFI_cdesc_t * values)" 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 => 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 - + assert "bound_values_dense_actual" not in c_source + assert "bound_values_upper_bound_0" not in c_source -def test_rank3_strided_array_pointer_sections_respect_free_form_line_limit(): - artifacts = WrapperGenerator().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 "real(c_double), dimension(:, :) :: values" in bridge_source + assert "call native_strided(values)" in bridge_source + # Nothing is rebuilt from an address any more. + assert "call c_f_pointer(" not in bridge_source + assert "values_base" not in bridge_source + assert "values_dense_actual" not in bridge_source assert max(map(len, bridge_source.splitlines())) <= 132 diff --git a/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py b/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py index 44d78e2e2..9c86a4f5c 100644 --- a/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py +++ b/tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py @@ -165,23 +165,32 @@ def test_rank2_assumed_shape_accepts_fortran_ordered_strided_views(compiled_mult compiled_multid_array_module.scale2_strided(contiguous_source, c_order_out) -def test_rank2_assumed_shape_rejects_non_positive_strides(compiled_multid_array_module): - """Each refusal names the restriction it comes from, not one shared phrase. - - A reversed axis and a broadcast axis fail for different reasons: the first - is a perfectly good array section this entrypoint has no way to describe, - because it receives an address; the second is not a section at all, since - Fortran has no form for an element repeated by a zero step. +def test_rank2_assumed_shape_accepts_reversed_axes_and_refuses_what_is_not_a_section( + compiled_multid_array_module, +): + """A reversed axis is described; a broadcast one has nothing to describe. + + The dummy is reached through a descriptor, which records a signed step per + axis, so an axis that runs backwards is passed on as it stands and the + callee reads the same elements the caller sees. A zero step is not a + direction, it is a repetition, and Fortran has no array section for it -- + so that one is still refused, and says so in its own terms. """ - source = _matrix() - out = np.zeros_like(source, order="F") + reversed_source = _reversed_fortran_matrix() + out = np.zeros_like(reversed_source, order="F") checksum = np.zeros(1, dtype=np.float64) - reversed_source = _reversed_fortran_matrix() - with pytest.raises(TypeError, match=r"runs backwards along axis \d+"): - compiled_multid_array_module.scale2_strided(reversed_source, out) - with pytest.raises(TypeError, match=r"cannot record a direction"): - compiled_multid_array_module.checksum2_strided(reversed_source, checksum) + compiled_multid_array_module.scale2_strided(reversed_source, out) + np.testing.assert_allclose(out, 3.0 * reversed_source) + + compiled_multid_array_module.checksum2_strided(reversed_source, checksum) + np.testing.assert_allclose(checksum[0], _checksum2(reversed_source)) + + # Writing through a reversed view reaches the caller's own elements. + reversed_out = _reversed_fortran_matrix() + before = np.array(reversed_out, copy=True) + compiled_multid_array_module.scale2_strided(reversed_out, reversed_out) + np.testing.assert_allclose(reversed_out, 3.0 * before) broadcast_source = _broadcast_fortran_like_matrix() assert broadcast_source.strides[0] == 0 @@ -190,10 +199,6 @@ def test_rank2_assumed_shape_rejects_non_positive_strides(compiled_multid_array_ with pytest.raises(TypeError, match=r"not a Fortran array section"): compiled_multid_array_module.checksum2_strided(broadcast_source, checksum) - reversed_out = _reversed_fortran_matrix() - with pytest.raises(TypeError, match=r"runs backwards along axis \d+"): - compiled_multid_array_module.scale2_strided(source, reversed_out) - def test_rank2_explicit_shape_requires_fortran_contiguous(compiled_multid_array_module): source = _matrix() diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index 89e833394..e2140e7da 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -391,9 +391,11 @@ def test_each_element_type_reaches_a_matching_ordinary_dummy(descriptor_matrix): def test_pointer_targets_report_their_shape_and_reach_an_ordinary_dummy(descriptor_matrix): """A pointer's storage satisfies an array dummy however its target is laid out. - Both targets report their real shape. A positive stride reaches the dummy; - a reversed one is refused by completed layout policy rather than silently - taking another route. Extraction itself stays gated behind PointerPolicy. + The dummy is reached through a descriptor, which carries a signed stride per + axis, so the direction an axis runs is something the callee is told rather + than something the caller has to undo. A reversed target and a strided one + both arrive, and both sum to what their own elements sum to. Extraction + itself stays gated behind PointerPolicy. """ reversed_handle = descriptor_matrix.reversed strided_handle = descriptor_matrix.strided @@ -404,8 +406,8 @@ def test_pointer_targets_report_their_shape_and_reach_an_ordinary_dummy(descript assert strided_handle.shape == (4,) assert descriptor_matrix.assumed_total(strided_handle) == np.float64(16.0) - with pytest.raises(ValueError, match="noncontiguous"): - descriptor_matrix.assumed_total(reversed_handle) + # store is 1..8, so the reversed view holds the same elements either way. + assert descriptor_matrix.assumed_total(reversed_handle) == np.float64(36.0) for handle in (reversed_handle, strided_handle): with pytest.raises(NotImplementedError, match="unsupported by completed policy"): From ebad1547d7dfad2cf2d7165d4fc67fe26552b6f5 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 07:10:45 +0100 Subject: [PATCH 43/47] codex: Cover every source and shape a signed-stride handoff accepts The matrix the feature needed: rank-one reversal and step -2, rank-two with either axis reversed, both reversed, mixed sign and zero-sized; from NumPy views, module pointer handles, derived-type field handles, through a bridged dummy and a direct bind(C) one, with several descriptors in one call and an optional one omitted, passed as None and supplied. The rank-two cases weight each element by its subscripts, so reading the axes in the wrong order is a different answer rather than the same sum. Two compiler-driven limitations, both found by this matrix and both recorded where they are decided: GNU Fortran's CFI_section resets a character descriptor's elem_len to 1, so a sectioned character array reaches the callee with every element truncated to one character; Intel's is correct. A silently wrong width is worse than a refusal, so completed policy does not section a character array at all. An assumed-rank dummy asserts contiguous storage in its own contract, so a reversed view is refused there as it would be for any dummy that asks for contiguity. Its rank still comes from the descriptor. A profiling test pins the handoff: a reversed borrowed handle reaches an ordinary dummy with no Python frame. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/policy/construction.py | 14 +- .../end_to_end/test_signed_stride_handoff.py | 396 ++++++++++++++++++ 2 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 74d59db15..4c2e3adf3 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -7412,7 +7412,11 @@ def _array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandoffPol order = _array_handoff_order(array.order, array.category) contiguous = _array_handoff_contiguous(array.contiguous, array.category) entrypoint_abi = _array_entrypoint_abi(array.category) - signed_strides = _array_handoff_signed_strides(entrypoint_abi, contiguous) + signed_strides = _array_handoff_signed_strides( + entrypoint_abi, + contiguous, + character=semantic_type.name == "String", + ) return ArrayHandoffPolicy( rank=rank, shape=shape, @@ -7511,6 +7515,7 @@ def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: def _array_handoff_signed_strides( entrypoint_abi: ArrayEntrypointABI, contiguous: bool | None, + character: bool = False, ) -> bool: """Complete whether an axis of the actual may run backwards. @@ -7522,6 +7527,13 @@ def _array_handoff_signed_strides( """ if entrypoint_abi is not ArrayEntrypointABI.C_DESCRIPTOR or contiguous is True: return False + if character: + # GNU Fortran's CFI_section resets a character descriptor's elem_len to + # 1, so the callee reads len(a) == 1 and every element is truncated to + # its first character. Intel's is correct. A silently wrong width is + # worse than a refusal, so a character array is not sectioned at all + # until that is fixed or detected. + return False return _ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS diff --git a/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py b/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py new file mode 100644 index 000000000..0d4b7ce43 --- /dev/null +++ b/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py @@ -0,0 +1,396 @@ +"""Signed-stride handoff to ordinary array dummies, from every supported source.""" + +from __future__ import annotations + +import sys + +import numpy as np +import pytest + +from prik.runtime.handles import PointerArray +from tests.fortran._support.wrapper_build import _build_text_and_import + + +pytestmark = pytest.mark.fortran_end_to_end + + +SIGNED_STRIDE_SOURCE = """\ +module fsigned_strides_f90 + use iso_c_binding + implicit none + + type :: holder + real(8), pointer :: field_ptr(:) => null() + end type holder + + real(8), target :: store(24) + real(8), pointer :: reversed_ptr(:) => null() + real(8), pointer :: strided_ptr(:) => null() + real(8), pointer :: unassociated_ptr(:) => null() + type(holder) :: parent + character(len=4), allocatable :: words(:) + +contains + + subroutine setup() + integer :: i + + do i = 1, 24 + store(i) = real(i, kind=8) + end do + reversed_ptr => store(8:1:-1) + strided_ptr => store(1:8:2) + parent%field_ptr => store(6:1:-1) + if (allocated(words)) deallocate(words) + allocate(words(2)) + words = ['abcd', 'efgh'] + end subroutine setup + + ! Bridged assumed-shape: not bind(C), so a bridge exists. + function total1(a) result(t) + real(8), intent(in) :: a(:) + real(8) :: t + + t = sum(a) + end function total1 + + ! Direct assumed-shape: bind(C), so C calls it with no bridge at all. + function total1_bindc(a) result(t) bind(c, name="fsigned_total1_bindc") + real(c_double), intent(in) :: a(:) + real(c_double) :: t + + t = sum(a) + end function total1_bindc + + ! Weighted so that reading the axes in the wrong order changes the answer. + function checksum2(a) result(t) + real(8), intent(in) :: a(:, :) + real(8) :: t + integer :: i, j + + t = 0.0_8 + do j = 1, size(a, 2) + do i = 1, size(a, 1) + t = t + a(i, j) * (100.0_8 * i + 10.0_8 * j) + end do + end do + end function checksum2 + + subroutine negate1(a) + real(8), intent(inout) :: a(:) + + a = -a + end subroutine negate1 + + ! Two descriptor dummies in one call. + function dot2(a, b) result(t) + real(8), intent(in) :: a(:), b(:) + real(8) :: t + + t = sum(a * b) + end function dot2 + + function optional_total(a) result(t) + real(8), intent(in), optional :: a(:) + real(8) :: t + + if (present(a)) then + t = sum(a) + else + t = -1.0_8 + end if + end function optional_total + + function rank_and_size(a) result(s) + real(8), intent(in) :: a(..) + integer(4) :: s + + s = 100_4 * int(rank(a), 4) + int(size(a), 4) + end function rank_and_size + + ! An assumed-shape dummy always sees lower bound 1, whatever the actual had. + function first_and_last(a) result(t) + real(8), intent(in) :: a(:) + real(8) :: t + + t = a(1) * 1000.0_8 + a(size(a)) + real(lbound(a, 1), kind=8) + end function first_and_last + + ! Raw-address dummies: the declaration says the layout, nothing is conveyed. + function explicit_total(a, n) result(t) + integer(4), intent(in) :: n + real(8), intent(in) :: a(n) + real(8) :: t + + t = sum(a) + end function explicit_total + + function flat_total(a, n) result(t) + integer(4), intent(in) :: n + real(8), intent(in) :: a(*) + real(8) :: t + + t = sum(a(:n)) + end function flat_total + + function contig_total(a) result(t) + real(8), intent(in), contiguous :: a(:) + real(8) :: t + + t = sum(a) + end function contig_total + + function word_width(a) result(w) + character(len=*), intent(in) :: a(:) + integer(4) :: w + + w = int(len(a), 4) + end function word_width +end module fsigned_strides_f90 +""" + + +@pytest.fixture(scope="module") +def signed(tmp_path_factory): + module = _build_text_and_import( + SIGNED_STRIDE_SOURCE, + "fsigned_strides_f90.f90", + tmp_path_factory.mktemp("signed-strides"), + { + "bind_c_fsigned_strides_f90_wrapper.f90", + "fsigned_strides_f90_wrapper.c", + "fsigned_strides_f90_wrapper.h", + }, + ) + module.setup() + return module + + +def _base(n=8): + return np.arange(1.0, n + 1.0) + + +def _matrix(rows=4, cols=3): + return np.asfortranarray(np.arange(1.0, rows * cols + 1.0).reshape((rows, cols), order="F")) + + +def _checksum2(array): + total = 0.0 + for i, j in np.ndindex(array.shape): + total += array[i, j] * (100.0 * (i + 1) + 10.0 * (j + 1)) + return total + + +@pytest.mark.parametrize( + "view", + [ + pytest.param(lambda: _base()[::-1], id="rank-one-reversal"), + pytest.param(lambda: _base()[::-2], id="step-minus-2"), + pytest.param(lambda: _base()[::2], id="step-2"), + pytest.param(lambda: _base(), id="contiguous"), + pytest.param(lambda: _base()[:0], id="zero-sized"), + pytest.param(lambda: _base()[:0][::-1], id="zero-sized-reversed"), + pytest.param(lambda: _base()[:1][::-1], id="single-element-reversed"), + ], +) +def test_rank_one_numpy_views_reach_an_assumed_shape_dummy(signed, view): + """Whatever direction an axis runs, the callee reads the caller's elements.""" + array = view() + + assert signed.total1(array) == pytest.approx(float(array.sum())) + assert signed.total1_bindc(array) == pytest.approx(float(array.sum())) + + +@pytest.mark.parametrize( + "view", + [ + pytest.param(lambda: _matrix()[::-1, :], id="axis-0-reversed"), + pytest.param(lambda: _matrix()[:, ::-1], id="axis-1-reversed"), + pytest.param(lambda: _matrix()[::-1, ::-1], id="both-axes-reversed"), + pytest.param(lambda: _matrix(8, 3)[::-2, :], id="mixed-sign-strided"), + pytest.param(lambda: _matrix(8, 3)[::2, :], id="positive-strided"), + pytest.param(lambda: _matrix(0, 3), id="zero-sized-axis"), + ], +) +def test_rank_two_numpy_views_keep_their_axis_order(signed, view): + """The weighting makes a transposed or misread axis a different answer.""" + array = view() + + assert signed.checksum2(array) == pytest.approx(_checksum2(array)) + + +def test_a_reversed_view_is_written_through_to_the_callers_storage(signed): + """intent(inout) reaches the caller's own elements, in their own order.""" + array = _base() + reversed_view = array[::-1] + before = np.array(reversed_view, copy=True) + + signed.negate1(reversed_view) + + np.testing.assert_allclose(reversed_view, -before) + # The original, unreversed array holds the same negated elements. + np.testing.assert_allclose(array, -_base()) + + +def test_an_assumed_shape_dummy_rebases_every_actual_to_one(signed): + """A descriptor's own lower bounds are not portable, and are not relied on. + + gfortran normalises a section to zero and ifx keeps the parent's subscript, + so nothing may read them. An assumed-shape dummy has lower bound 1 whatever + it was handed, which is what the callee sees. + """ + for view in (_base(), _base()[::-1], _base()[::2], _base()[::-2]): + expected = view[0] * 1000.0 + view[-1] + 1.0 + assert signed.first_and_last(view) == pytest.approx(expected) + + +def test_a_reversed_pointer_handle_reaches_the_same_dummy(signed): + """A handle's descriptor already records its direction; it is entered as it is.""" + reversed_handle = signed.reversed_ptr + strided_handle = signed.strided_ptr + + assert isinstance(reversed_handle, PointerArray) + assert reversed_handle.shape == (8,) + + # store is 1..24; the reversed view covers store(8:1:-1). + assert signed.total1(reversed_handle) == pytest.approx(36.0) + assert signed.total1_bindc(reversed_handle) == pytest.approx(36.0) + assert signed.total1(strided_handle) == pytest.approx(16.0) + + +def test_a_reversed_derived_field_handle_reaches_the_same_dummy(signed): + """A field handle is entered through its parent, and keeps its direction.""" + field = signed.parent.field_ptr + + assert isinstance(field, PointerArray) + assert field.shape == (6,) + # store(6:1:-1) holds 6, 5, 4, 3, 2, 1. + assert signed.total1(field) == pytest.approx(21.0) + + +def test_one_call_takes_several_descriptors_from_different_sources(signed): + """Every descriptor in a call is live at once, whoever supplied it.""" + left = _base(4)[::-1] + right = _base(4) + + assert signed.dot2(left, right) == pytest.approx(float((left * right).sum())) + + # A borrowed handle and a NumPy array in the same call. + handle = signed.strided_ptr + ones = np.ones(4) + assert signed.dot2(handle, ones) == pytest.approx(16.0) + assert signed.dot2(ones, handle) == pytest.approx(16.0) + + # Two borrowed handles, both reversed, in the same call. + assert signed.dot2(signed.reversed_ptr, signed.reversed_ptr) == pytest.approx(204.0) + + +def test_an_optional_descriptor_dummy_accepts_omitted_none_and_reversed(signed): + """Absence is decided before anything is described.""" + assert signed.optional_total() == pytest.approx(-1.0) + assert signed.optional_total(None) == pytest.approx(-1.0) + assert signed.optional_total(_base()) == pytest.approx(36.0) + assert signed.optional_total(_base()[::-1]) == pytest.approx(36.0) + assert signed.optional_total(signed.reversed_ptr) == pytest.approx(36.0) + + +def test_assumed_rank_dummies_read_rank_and_size_from_the_descriptor(signed): + """An assumed-rank dummy takes its rank and size from what it is handed. + + Its contract asserts contiguous storage, which a reversed axis is not, so + completed policy keeps that requirement here as it would for any other + dummy that asks for it. The rank still comes from the descriptor rather + than from anything the caller states. + """ + assert signed.rank_and_size(_base()) == np.int32(108) + assert signed.rank_and_size(_matrix()) == np.int32(212) + + with pytest.raises(TypeError, match=r"contiguous|expected ordering"): + signed.rank_and_size(_base()[::-1]) + + +def test_a_character_dummy_reports_its_own_width_from_either_source(signed): + """Character arrays keep the address handoff, and keep their width with it. + + GNU Fortran's CFI_section resets a character descriptor's elem_len to 1, so + a sectioned character array would reach the callee with every element + truncated to one character. Intel's is correct. Completed policy therefore + does not section a character array at all, and a reversed one is refused + rather than silently mis-sized. + """ + assert signed.word_width(signed.words) == np.int32(4) + assert signed.word_width(np.array([b"abcd", b"efgh"], dtype="S4")) == np.int32(4) + + with pytest.raises(TypeError, match=r"runs backwards|cannot record a direction|expected ordering"): + signed.word_width(np.array([b"abcd", b"efgh"], dtype="S4")[::-1]) + + +def test_a_bound_handle_reaches_a_signed_stride_call_without_running_python(signed): + """The reversed handoff costs no Python frame once the arguments are parsed.""" + handle = signed.reversed_ptr + total1 = signed.total1 + called: list[str] = [] + + def record(frame, event, _arg): + if event == "call": + called.append(frame.f_code.co_name) + + total1(handle) + sys.setprofile(record) + try: + value = total1(handle) + finally: + sys.setprofile(None) + + assert called == [] + assert value == pytest.approx(36.0) + + +def test_raw_address_dummies_refuse_what_an_address_cannot_convey(signed): + """Each refusal names the restriction it comes from. + + An explicit-shape or assumed-size dummy receives the address of the first + element and nothing else, so a direction has nowhere to be recorded. A + CONTIGUOUS dummy keeps its requirement even though its calling convention + carries a descriptor. Neither is the same as a layout Fortran has no form + for at all. + """ + reversed_view = _base()[::-1] + + with pytest.raises(TypeError, match=r"contiguous|expected ordering"): + signed.explicit_total(reversed_view, np.int32(8)) + with pytest.raises(TypeError, match=r"contiguous|expected ordering"): + signed.flat_total(reversed_view, np.int32(8)) + with pytest.raises(TypeError, match=r"expected ordering|contiguous"): + signed.contig_total(reversed_view) + + # Positive strides still reach the ones that accept them. + assert signed.explicit_total(_base(), np.int32(8)) == pytest.approx(36.0) + assert signed.contig_total(_base()) == pytest.approx(36.0) + + +def test_layouts_that_are_not_array_sections_stay_refused(signed): + """A broadcast or overlapping view has no contiguous parent to be a section of.""" + broadcast = np.broadcast_to(np.arange(1.0, 4.0), (4, 3)) + assert broadcast.strides[0] == 0 + with pytest.raises(TypeError, match=r"not a Fortran array section"): + signed.checksum2(broadcast) + + overlapping = np.lib.stride_tricks.as_strided(_base(), shape=(4, 3), strides=(8, 8)) + with pytest.raises(TypeError, match=r"expected ordering|not a Fortran array section"): + signed.checksum2(overlapping) + + +def test_absent_and_mismatched_storage_stay_refused(signed): + """State and type checks are unchanged by how the storage is handed over.""" + with pytest.raises(ValueError, match=r"unassociated"): + signed.total1(signed.unassociated_ptr) + with pytest.raises(TypeError, match=r"dtype"): + signed.total1(np.arange(4, dtype=np.float32)) + with pytest.raises(TypeError, match=r"dtype|rank"): + signed.total1(_matrix()) + with pytest.raises(TypeError, match=r"byte order"): + signed.total1(_base().astype(">f8")) + # word_width declares character(len=*), which takes whatever width it is + # given, so a different width is not a mismatch for it. + assert signed.word_width(np.array([b"abcdef"], dtype="S6")) == np.int32(6) From 20d4f74629beb6975a04d5890b3c002eeaa4b0ec Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 14:39:31 +0100 Subject: [PATCH 44/47] codex: Complete and harden descriptor array handoff --- CHANGELOG.md | 12 +- docs/developer/packages/runtime.md | 8 + docs/user/guide/arrays.md | 27 +- docs/user/guide/pointers.md | 10 +- prik/codegen/c/binding.py | 377 ++++++++++++++++-- prik/codegen/docstrings.py | 4 + prik/codegen/fortran/bridge.py | 164 +++++++- prik/pipeline/wrapper.py | 75 +++- prik/planning/planner.py | 34 +- prik/policy/construction.py | 162 +++++--- prik/runtime/native_support/prik_binding.h | 27 +- .../test_runtime_rank_pointer_lowering.py | 3 +- .../test_runtime_rank_pointer_policy.py | 10 +- .../codegen/test_array_buffer_lowering.py | 34 +- .../codegen/test_array_output_identity.py | 7 +- .../codegen/test_array_result_lowering.py | 7 +- .../codegen/test_specialized_array_roles.py | 29 +- .../codegen/test_strided_array_lowering.py | 31 +- .../end_to_end/test_signed_stride_handoff.py | 42 +- .../codegen/test_native_entrypoint_routing.py | 35 ++ .../codegen/test_native_handle_planning.py | 2 +- .../codegen/test_optional_lowering.py | 19 + .../end_to_end/test_pointer_handles.py | 6 +- 23 files changed, 907 insertions(+), 218 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2863f85..3b71eee5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,17 @@ release tags add a leading `v` to the package version. extensions together after upgrading PRIK. - Generated Fortran allocatable and pointer handles can be passed directly to - matching ordinary array arguments. Supported forms include explicit and - assumed shape, positive strides, assumed size, assumed rank 1 through 15, - optional arrays, and fixed- or assumed-width character arrays. C array + matching ordinary array arguments. Numeric assumed-shape and assumed-rank + arguments accept representable forward or reversed Fortran sections from + either handles or NumPy arrays, including direct `bind(C)` procedures. + Optional arrays apply the same layout rules when present and accept omission + or `None` as absence. Explicit-shape, assumed-size, and fixed- or + assumed-width character arrays retain their declared layout. C array arguments accept NumPy arrays. +- Array-valued functions whose result extents depend on descriptor arguments + return initialized NumPy arrays on Intel ifx and GNU Fortran. + - Descriptor-backed NumPy views preserve native byte strides, including negative strides, non-contiguous pointer targets, and zero-sized dimensions. diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index c5bb22f2b..b25e33d78 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -109,6 +109,14 @@ its persistent storage. An absent optional argument contributes an unallocated placeholder to the chain. Each descriptor remains scoped to the consumer that supplied it. +Ordinary numeric assumed-shape and assumed-rank arguments use the same C +descriptor entrypoint for both direct and adapted calls. The binding describes +a NumPy array with call-local descriptor storage, or enters a handle's live +descriptor through the consumer chain. A direct `bind(C)` procedure receives +that descriptor itself; a non-`bind(C)` procedure has an interoperable bridge +dummy that passes the array through unchanged. Explicit-shape, assumed-size, +raw C-pointer, and character-array entrypoints keep their planned address ABI. + ### Views And Ownership `to_numpy()` builds the view in C while the descriptor is live, over the diff --git a/docs/user/guide/arrays.md b/docs/user/guide/arrays.md index d4e2929cf..dcac58c76 100644 --- a/docs/user/guide/arrays.md +++ b/docs/user/guide/arrays.md @@ -481,7 +481,7 @@ This checks the final Python axis and flattens the leading axes. ## Strided Views Use `::` for an assumed-shape axis that accepts F-contiguous arrays and -positive-stride views without copying: +Fortran-ordered strided views without copying: ```python from prik.contracts import Float64 @@ -511,9 +511,16 @@ print(out) # [21. 45. 69.]] ``` -PRIK passes the base address, extents, and positive element strides. Reversed -slices, broadcasted views, and C-order strided matrices are rejected for this -Fortran-oriented contract. Strides are not an order workaround. +Numeric assumed-shape and assumed-rank arguments receive a Fortran descriptor, +so an axis may run forward or backward. For example, +`scale_visible_rows(visible_rows[::-1, :], out[::-1, :])` updates the same +selected rows in reverse order. + +The view must still be a non-overlapping Fortran-ordered array section. +Broadcasted views, overlapping views, and C-order strided matrices are +rejected. Strides are not an order workaround. Explicit-shape, assumed-size, +and character arrays use an address-based entrypoint and therefore require a +forward layout that their contract can express. --- @@ -548,16 +555,18 @@ Use this list when reading or editing a generated `.pyi` contract: - `T[rows, Flat]`: Fortran-contiguous; checked prefix, remaining axes flattened - `Annotated[T[Flat, columns], ORDER_C]`: C-contiguous; checked suffix, leading axes flattened -- `T[...]`: assumed-rank, currently rank 1-15 +- `T[...]`: assumed-rank, currently rank 1-15, including supported reversed + sections An allocated `Allocatable[T[...]]` handle or associated `Pointer[T[...]]` handle can also satisfy a matching ordinary Fortran array argument. The same element type, rank, shape, layout, contiguity, and writeability requirements apply as for a NumPy array. This includes explicit-shape, assumed-shape, -positive-strided, assumed-size/`Flat`, and assumed-rank arguments, plus -fixed-width and assumed-width character arrays. An absent handle is rejected; -pass `None` only when the ordinary argument itself is optional. C array -arguments accept NumPy arrays, not Fortran descriptor handles. +signed-strided, assumed-size/`Flat`, and assumed-rank arguments, plus +fixed-width and assumed-width character arrays. Reversed targets are accepted +by matching numeric assumed-shape and assumed-rank arguments. An absent handle +is rejected; pass `None` only when the ordinary argument itself is optional. +C array arguments accept NumPy arrays, not Fortran descriptor handles. Generated contracts may describe a shape with visible arguments, such as `T[rows, columns]`. Most users should keep those generated relationships diff --git a/docs/user/guide/pointers.md b/docs/user/guide/pointers.md index 8d30e37e5..a844b2d6b 100644 --- a/docs/user/guide/pointers.md +++ b/docs/user/guide/pointers.md @@ -51,10 +51,12 @@ def sum_values(values: Float64[:]) -> Float64: ... An associated pointer handle may satisfy an ordinary Fortran array parameter when its dtype, rank, shape, layout, contiguity, and writeability meet that -parameter's contract. Positive-strided targets are accepted by matching -strided arguments; optional, flattened, and assumed-rank ordinary arguments -use the same rules. A plain NumPy array cannot satisfy a `Pointer[T[...]]` -parameter because it does not carry a native pointer descriptor. +parameter's contract. Forward- and reverse-strided numeric targets are +accepted by matching assumed-shape and assumed-rank arguments; address-only +and contiguous arguments retain their declared layout requirements. Optional +and flattened ordinary arguments use the same rules. A plain NumPy array +cannot satisfy a `Pointer[T[...]]` parameter because it does not carry a native +pointer descriptor. --- diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index f39fbc338..2ef8cb6e5 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -23,6 +23,7 @@ ) from prik.policy.models import ( ArgumentHandoffMode, + ArrayEntrypointABI, ArrayPythonLayout, CallbackABIKind, CallbackResultAction, @@ -6703,7 +6704,9 @@ def _lower_argument_required_array_storage( *self._array_shape_checks(plan, context, array), ] if self._array_crosses_as_descriptor(plan): - nodes.extend(self._numpy_descriptor_nodes(plan, names, array)) + if plan.transformations: + return tuple(nodes) + nodes.extend(self._numpy_descriptor_nodes(plan, names, numpy_validated=True)) return tuple(nodes) nodes.extend(self._array_extraction_nodes(plan, names, array)) return tuple(nodes) @@ -6712,7 +6715,8 @@ def _numpy_descriptor_nodes( self, plan: ArgumentTransferPlan, names: _CArgumentNames, - array: ArrayHandoffPlan, + *, + numpy_validated: bool = False, ) -> tuple[CComment | CExpressionStatement | CIf, ...]: """Reach one descriptor dummy from whichever source the caller supplied. @@ -6722,6 +6726,9 @@ def _numpy_descriptor_nodes( storage as it stands, in this frame, which outlives the call. Both arrive at the same slot, and the callee cannot tell them apart. """ + array = plan.array + if array is None: + raise ValueError(f"Array argument {plan.owner_path!r} is missing its handoff") prefix = names.value_name declared = self._declared_character_width(plan) width_guard: tuple = () @@ -6744,7 +6751,7 @@ def _numpy_descriptor_nodes( ) describe: tuple = ( CComment("No descriptor of its own, so one is made over the array as it is."), - self._array_validation_statement(plan, names, object_kind_checked=True), + *(() if numpy_validated else (self._array_validation_statement(plan, names),)), *width_guard, CIf( CodeExpression( @@ -6756,8 +6763,26 @@ def _numpy_descriptor_nodes( ), CExpressionStatement(CodeExpression(f"{prefix} = (CFI_cdesc_t *)&{prefix}_section")), *( - CExpressionStatement( - CodeExpression(f"{name} = (int64_t)PyArray_DIM((PyArrayObject *){names.object_name}, {axis})") + ( + CExpressionStatement( + CodeExpression( + f"{names.runtime_rank_name} = (int64_t)PyArray_NDIM((PyArrayObject *){names.object_name})" + ) + ), + ) + if plan.array.runtime_rank_role is not None + else () + ), + *( + CIf( + CodeExpression(f"PyArray_NDIM((PyArrayObject *){names.object_name}) > {axis}"), + body=( + CExpressionStatement( + CodeExpression( + f"{name} = (int64_t)PyArray_DIM((PyArrayObject *){names.object_name}, {axis})" + ) + ), + ), ) for axis, name in enumerate(names.extent_names) ), @@ -6787,8 +6812,9 @@ def _numpy_descriptor_nodes( CExpressionStatement(CodeExpression(f"Py_DECREF({capsule})")), CIf(CodeExpression(f"{backend} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CComment("Its state and extents are read while its own descriptor is live."), - CExpressionStatement(CodeExpression(f"{prefix}_extents_out.rank = {plan.array.rank}")), + CExpressionStatement(CodeExpression(f"{prefix}_extents_out.rank = (int){backend}->rank")), CExpressionStatement(CodeExpression(f"{prefix}_extents_out.present = 0")), + CExpressionStatement(CodeExpression(f"{prefix}_extents_out.contiguous = 0")), CExpressionStatement(CodeExpression(f"{prefix}_extents_out.elem_len = 0")), CExpressionStatement(CodeExpression(f"{prefix}_extents_out.extents = {prefix}_extents")), CExpressionStatement( @@ -6812,6 +6838,29 @@ def _numpy_descriptor_nodes( CReturn(CodeExpression("NULL")), ), ), + *( + ( + CIf( + CodeExpression(f"!{prefix}_extents_out.contiguous"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_SetString(PyExc_ValueError, "pointer handle target is ' + f'noncontiguous and cannot be passed to argument {plan.binding.python_name}")' + ) + ), + CReturn(CodeExpression("NULL")), + ), + ), + ) + if array.contiguous is True + else () + ), + *( + (CExpressionStatement(CodeExpression(f"{names.runtime_rank_name} = (int64_t){backend}->rank")),) + if plan.array.runtime_rank_role is not None + else () + ), *( CExpressionStatement(CodeExpression(f"{name} = {prefix}_extents[{axis}]")) for axis, name in enumerate(names.extent_names) @@ -6834,25 +6883,16 @@ def _ordinary_array_argument_declarations( if array is None: raise ValueError(f"Array argument {plan.owner_path!r} is missing its handoff") if self._array_crosses_as_descriptor(plan): - if array.rank is None: - raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") - # A handle is entered through its backend; a NumPy array is - # described into the storage below, which lives as long as the call. - return ( - CDeclaration(names.object_name, "PyObject *"), - CDeclaration(names.value_name, "CFI_cdesc_t *", CodeExpression("NULL")), - CDeclaration( - self._descriptor_backend_local(names), - "prik_native_array_backend *", - CodeExpression("NULL"), - ), - CDeclaration(f"{names.value_name}_capsule", "PyObject *", CodeExpression("NULL")), - CDeclaration(f"{names.value_name}_parent", f"CFI_CDESC_T({array.rank})"), - CDeclaration(f"{names.value_name}_section", f"CFI_CDESC_T({array.rank})"), - CDeclaration(f"{names.value_name}_extents[{array.rank}]", "int64_t"), - CDeclaration(f"{names.value_name}_extents_out", self.ARRAY_EXTENTS_RECORD), - *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in names.extent_names), - ) + return self._descriptor_array_argument_declarations(plan, names, array) + return self._raw_array_argument_declarations(plan, names, array) + + def _raw_array_argument_declarations( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + array: ArrayHandoffPlan, + ) -> tuple[CDeclaration, ...]: + """Declare the address and metadata selected by a raw-array plan.""" declarations = [ CDeclaration(names.object_name, "PyObject *"), CDeclaration(names.value_name, "void *", CodeExpression("NULL")), @@ -6877,6 +6917,41 @@ def _ordinary_array_argument_declarations( declarations.append(CDeclaration(names.itemsize_name, "int64_t", CodeExpression("0"))) return tuple(declarations) + def _descriptor_array_argument_declarations( + self, + plan: ArgumentTransferPlan, + names: _CArgumentNames, + array: ArrayHandoffPlan, + ) -> tuple[CDeclaration, ...]: + """Declare call-local storage for a NumPy or native descriptor source.""" + descriptor_rank = "PRIK_MAX_ARRAY_RANK" if array.rank is None else str(array.rank) + declarations = [ + CDeclaration(names.object_name, "PyObject *"), + CDeclaration(names.value_name, "CFI_cdesc_t *", CodeExpression("NULL")), + CDeclaration( + self._descriptor_backend_local(names), + "prik_native_array_backend *", + CodeExpression("NULL"), + ), + CDeclaration(f"{names.value_name}_capsule", "PyObject *", CodeExpression("NULL")), + CDeclaration(f"{names.value_name}_parent", f"CFI_CDESC_T({descriptor_rank})"), + CDeclaration(f"{names.value_name}_section", f"CFI_CDESC_T({descriptor_rank})"), + CDeclaration(f"{names.value_name}_extents[{descriptor_rank}]", "int64_t", CodeExpression("{0}")), + CDeclaration(f"{names.value_name}_extents_out", self.ARRAY_EXTENTS_RECORD), + *(CDeclaration(name, "int64_t", CodeExpression("0")) for name in names.extent_names), + ] + if plan.transformations: + declarations.append( + CDeclaration( + self._array_transformation_temp_name(names), + "PyObject *", + CodeExpression("NULL"), + ) + ) + if array.runtime_rank_role is not None: + declarations.append(CDeclaration(names.runtime_rank_name, "int64_t", CodeExpression("0"))) + return tuple(declarations) + @staticmethod def _array_dense_actual_declarations( array: ArrayHandoffPlan, @@ -6906,7 +6981,8 @@ def _lower_argument_required_array_actual( # from, and the shape checks apply to whichever supplied it. return ( *self._ordinary_array_argument_declarations(plan, names), - *self._numpy_descriptor_nodes(plan, names, array), + *self._numpy_descriptor_nodes(plan, names), + *self._descriptor_array_shape_checks(plan, context, names), ) if not self._takes_array_handle(plan): outlined = self._outlined_array_bind_nodes(plan, context, names) @@ -7618,6 +7694,32 @@ def _array_shape_checks( ) return tuple(checks) + def _descriptor_array_shape_checks( + self, + plan: ArgumentTransferPlan, + context: _CFunctionContext, + names: _CArgumentNames, + ) -> tuple[CExpressionStatement, ...]: + """Validate declared extents after either descriptor source has been read.""" + handoff = plan.array + if handoff is None or handoff.rank is None: + return () + checks = [] + for axis, expression in enumerate(handoff.shape): + if expression in {":", "::Strided", "Flat"} or handoff.extent_evaluation[axis] == "bridge": + continue + expected = self._array_extent_expression(handoff, axis, expression, context) + checks.append( + CExpressionStatement( + CodeExpression( + f"if ({names.extent_names[axis]} != (int64_t)({expected})) {{ " + f'PyErr_SetString(PyExc_TypeError, "Argument {plan.binding.python_name} has incompatible ' + f'shape at axis {axis}"); return NULL; }}' + ) + ) + ) + return tuple(checks) + @staticmethod def _array_actual_axis_expression(handoff, array: str, axis: int) -> str: """Map one contract axis to the runtime ndarray axis selected by the plan.""" @@ -9487,7 +9589,11 @@ def _lower_entrypoint_call(self, plan: FunctionPlan, context: _CFunctionContext) def _array_crosses_as_descriptor(argument: ArgumentTransferPlan) -> bool: """Report whether completed policy hands this array over as a descriptor.""" array = argument.array - return array is not None and array.signed_strides + return ( + argument.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER + and array is not None + and array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + ) def _array_actual_reader_name(self, function: FunctionPlan, argument: ArgumentTransferPlan) -> str: """Return the reader that copies one handle's storage out of its descriptor.""" @@ -9889,6 +9995,7 @@ def _array_extents_reader_function(self, plan: ModulePlan) -> tuple: ( CParameter("rank", "int"), CParameter("present", "int"), + CParameter("contiguous", "int"), CParameter("elem_len", "int64_t"), CParameter("extents", "int64_t *"), ), @@ -9914,6 +10021,9 @@ def _array_extents_reader_function(self, plan: ModulePlan) -> tuple: ), CDeclaration("axis", "int", CodeExpression("0")), CExpressionStatement(CodeExpression("out->present = source->base_addr != NULL")), + CExpressionStatement( + CodeExpression("out->contiguous = out->present && CFI_is_contiguous(source) != 0") + ), CExpressionStatement(CodeExpression("out->elem_len = (int64_t)source->elem_len")), CFor( "axis = 0", @@ -9974,9 +10084,14 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: CDeclaration("step[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), CDeclaration("unit[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), CDeclaration("element_stride[PRIK_MAX_ARRAY_RANK]", "CFI_index_t"), + CDeclaration("step_magnitude", "CFI_index_t", CodeExpression("0")), + CDeclaration("section_span", "CFI_index_t", CodeExpression("0")), + CDeclaration("contribution", "CFI_index_t", CodeExpression("0")), + CDeclaration("previous_unit", "CFI_index_t", CodeExpression("0")), CDeclaration("offset", "CFI_index_t", CodeExpression("0")), CDeclaration("empty", "int", CodeExpression("0")), CDeclaration("axis", "int", CodeExpression("0")), + CDeclaration("next_axis", "int", CodeExpression("0")), CDeclaration("status", "int", CodeExpression("CFI_SUCCESS")), CIf( CodeExpression("elem_len <= 0"), @@ -10012,12 +10127,122 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: CIf( CodeExpression("empty"), body=( - CReturn( + CExpressionStatement( CodeExpression( - "CFI_establish(section, PyArray_DATA(array), CFI_attribute_other, cfi_type, " - "(size_t)elem_len, rank, extents) == CFI_SUCCESS ? 0 : -1" + "status = CFI_establish(section, PyArray_DATA(array), CFI_attribute_other, " + "cfi_type, (size_t)elem_len, rank, extents)" ) ), + CIf( + CodeExpression("status != CFI_SUCCESS"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_TypeError, "Argument %s could not be described ' + 'to Fortran as an empty array: %d", argument_name, status)' + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CReturn(CodeExpression("0")), + ), + ), + CComment("A singleton axis has no observable stride; choose one that keeps a contiguous parent."), + CFor( + "axis = 0", + CodeExpression("axis < rank"), + CodeExpression("++axis"), + body=( + CIf( + CodeExpression("extents[axis] <= 1"), + body=( + CIf( + CodeExpression("axis == 0"), + body=(CExpressionStatement(CodeExpression("element_stride[axis] = 1")),), + else_body=( + CFor( + "next_axis = axis + 1", + CodeExpression("next_axis < rank && extents[next_axis] <= 1"), + CodeExpression("++next_axis"), + body=(), + ), + CIf( + CodeExpression("next_axis < rank"), + body=( + CExpressionStatement( + CodeExpression( + "element_stride[axis] = element_stride[next_axis] < 0 " + "? -element_stride[next_axis] : element_stride[next_axis]" + ) + ), + ), + else_body=( + CExpressionStatement( + CodeExpression( + "previous_unit = axis == 1 ? 1 : " + "(element_stride[axis - 1] < 0 " + "? -element_stride[axis - 1] : element_stride[axis - 1])" + ) + ), + CExpressionStatement( + CodeExpression( + "step_magnitude = element_stride[axis - 1] < 0 " + "? -element_stride[axis - 1] : element_stride[axis - 1]" + ) + ), + CExpressionStatement( + CodeExpression("step_magnitude /= previous_unit") + ), + CIf( + CodeExpression( + "extents[axis - 1] > 1 && step_magnitude > " + "((CFI_index_t)PTRDIFF_MAX - 1) / " + "(extents[axis - 1] - 1)" + ), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_OverflowError, "Argument %s has ' + 'strides too large for a Fortran descriptor", ' + "argument_name)" + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CExpressionStatement( + CodeExpression( + "section_span = (extents[axis - 1] - 1) * " + "step_magnitude + 1" + ) + ), + CIf( + CodeExpression( + "previous_unit > (CFI_index_t)PTRDIFF_MAX / section_span" + ), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_OverflowError, "Argument %s has ' + 'strides too large for a Fortran descriptor", ' + "argument_name)" + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CExpressionStatement( + CodeExpression( + "element_stride[axis] = previous_unit * section_span" + ) + ), + ), + ), + ), + ), + ), + ), ), ), CComment("The parent's step along each axis, chosen to divide the view's."), @@ -10041,21 +10266,77 @@ def _numpy_descriptor_builder_function(self, plan: ModulePlan) -> tuple: CodeExpression("++axis"), body=( CExpressionStatement(CodeExpression("step[axis] = element_stride[axis] / unit[axis]")), + CExpressionStatement( + CodeExpression("step_magnitude = step[axis] < 0 ? -step[axis] : step[axis]") + ), + CIf( + CodeExpression( + "extents[axis] > 1 && step_magnitude > " + "((CFI_index_t)PTRDIFF_MAX - 1) / (extents[axis] - 1)" + ), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_OverflowError, "Argument %s has strides too large ' + 'for a Fortran descriptor", argument_name)' + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CExpressionStatement( + CodeExpression("section_span = (extents[axis] - 1) * step_magnitude + 1") + ), CExpressionStatement( CodeExpression( "parent_extents[axis] = axis + 1 < rank ? (element_stride[axis + 1] < 0 " "? -element_stride[axis + 1] : element_stride[axis + 1]) / unit[axis] " - ": extents[axis]" + ": section_span" ) ), CComment("A backward axis starts at its far end and walks down."), - CExpressionStatement( - CodeExpression("lower[axis] = step[axis] > 0 ? 0 : (extents[axis] - 1) * (-step[axis])") + CExpressionStatement(CodeExpression("lower[axis] = step[axis] > 0 ? 0 : section_span - 1")), + CExpressionStatement(CodeExpression("upper[axis] = step[axis] > 0 ? section_span - 1 : 0")), + CIf( + CodeExpression( + "lower[axis] != 0 && unit[axis] > (CFI_index_t)PTRDIFF_MAX / lower[axis]" + ), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_OverflowError, "Argument %s has strides too large ' + 'for a Fortran descriptor", argument_name)' + ) + ), + CReturn(CodeExpression("-1")), + ), + ), + CExpressionStatement(CodeExpression("contribution = lower[axis] * unit[axis]")), + CIf( + CodeExpression("contribution > (CFI_index_t)PTRDIFF_MAX - offset"), + body=( + CExpressionStatement( + CodeExpression( + 'PyErr_Format(PyExc_OverflowError, "Argument %s has strides too large ' + 'for a Fortran descriptor", argument_name)' + ) + ), + CReturn(CodeExpression("-1")), + ), ), + CExpressionStatement(CodeExpression("offset += contribution")), + ), + ), + CIf( + CodeExpression("offset > (CFI_index_t)PTRDIFF_MAX / elem_len"), + body=( CExpressionStatement( - CodeExpression("upper[axis] = lower[axis] + (extents[axis] - 1) * step[axis]") + CodeExpression( + 'PyErr_Format(PyExc_OverflowError, "Argument %s has strides too large for a ' + 'Fortran descriptor", argument_name)' + ) ), - CExpressionStatement(CodeExpression("offset += lower[axis] * unit[axis]")), + CReturn(CodeExpression("-1")), ), ), CExpressionStatement( @@ -12241,8 +12522,30 @@ def _binding_transformation_setup_nodes( CodeExpression(f"{temporary} == NULL"), body=(*prior_cleanup, CReturn(CodeExpression("NULL"))), ), - CExpressionStatement( - CodeExpression(f"{names.value_name} = PyArray_DATA((PyArrayObject *){temporary})") + *( + ( + CIf( + CodeExpression( + f"{self.NUMPY_DESCRIPTOR_BUILDER}((CFI_cdesc_t *)&{names.value_name}_parent, " + f"(CFI_cdesc_t *)&{names.value_name}_section, (PyArrayObject *){temporary}, " + f'{self._native_array_cfi_type(argument)}, "{argument.binding.python_name}") < 0' + ), + body=( + CExpressionStatement(CodeExpression(f"Py_DECREF({temporary})")), + *prior_cleanup, + CReturn(CodeExpression("NULL")), + ), + ), + CExpressionStatement( + CodeExpression(f"{names.value_name} = (CFI_cdesc_t *)&{names.value_name}_section") + ), + ) + if self._array_crosses_as_descriptor(argument) + else ( + CExpressionStatement( + CodeExpression(f"{names.value_name} = PyArray_DATA((PyArrayObject *){temporary})") + ), + ) ), ) ) diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index af7e3edb5..db7eaa0c6 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -1004,6 +1004,10 @@ def _array_layout_label(array: ArrayHandoffPlan) -> str | None: """Render the layout every accepted actual must already have, if any.""" if array.python_layout is ArrayPythonLayout.ANY_STRIDED: return "Any strides" + if array.python_layout is ArrayPythonLayout.SIGNED_STRIDED_F: + return "Fortran-ordered strides, including reversed axes" + if array.python_layout is ArrayPythonLayout.POSITIVE_STRIDED_F: + return "Fortran-ordered positive strides" if (array.rank is None or array.rank > 1) and array.order in {"ORDER_C", "ORDER_F"}: return "C-contiguous" if array.order == "ORDER_C" else "F-contiguous" return None diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index ed4f111e8..e8047a5a0 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -25,6 +25,7 @@ from prik.semantics.metadata import SCALAR_STORAGE_CATEGORY from prik.policy.models import ( ArgumentHandoffMode, + ArrayEntrypointABI, ArrayLogicalABI, ArrayWritebackABI, BridgeDataAction, @@ -3409,7 +3410,11 @@ def _lower_argument_string_value( def _array_crosses_as_descriptor(plan: ArgumentTransferPlan) -> bool: """Report whether completed policy hands this array over as a descriptor.""" array = plan.array - return array is not None and array.signed_strides + return ( + plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER + and array is not None + and array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + ) def _lower_argument_array_descriptor( self, @@ -3422,20 +3427,36 @@ def _lower_argument_array_descriptor( here: the dummy is the array, with the bounds and directions the caller described, and it is handed to the native procedure as it stands. """ + return ( + self._array_descriptor_parameter( + plan, + plan.entrypoint.parameter_name, + optional=plan.entrypoint.optional_mode is not OptionalMode.REQUIRED, + ), + ) + + def _array_descriptor_parameter( + self, + plan: ArgumentTransferPlan, + name: str, + *, + optional: bool, + ) -> FortranParameter: + """Declare one interoperable ordinary-array descriptor dummy.""" array = plan.array - if array is None or array.rank is None: - raise ValueError(f"Descriptor array argument {plan.owner_path!r} requires a concrete rank") + if array is None: + raise ValueError(f"Descriptor array argument {plan.owner_path!r} has no handoff") element_type = self._array_element_fortran_type(plan) if plan.datatype_family is DatatypeFamily.STRING and array.itemsize is None: # The width travels in the descriptor, and a bind(C) character dummy # may not name a variable for it, so it is assumed here. element_type = "character(kind=c_char, len=*)" - attributes = [self._array_dimension_attribute(array.rank)] - if plan.entrypoint.optional_mode is not OptionalMode.REQUIRED: + attributes = ["dimension(..)" if array.rank is None else self._array_dimension_attribute(array.rank)] + if optional: # C omits it by passing no descriptor, which is what optional means # for an interoperable dummy. attributes.append("optional") - return (FortranParameter(plan.entrypoint.parameter_name, element_type, tuple(attributes)),) + return FortranParameter(name, element_type, tuple(attributes)) # Ordinary-array argument lowering. def _lower_argument_array_buffer( @@ -3515,8 +3536,14 @@ def _function_body( and argument.entrypoint.optional_mode in {OptionalMode.NULLABLE_VALUE, OptionalMode.DESCRIPTOR} ) if derived_optional: - procedures = self._derived_optional_dispatch_procedures(plan, derived_optional, result_name) - return (FortranCall(self._derived_optional_step_name(0), ()),), procedures + forwarded = self._contained_optional_descriptor_arguments(plan) + procedures = self._derived_optional_dispatch_procedures( + plan, + derived_optional, + result_name, + forwarded, + ) + return (self._contained_optional_descriptor_call_tree(forwarded, 0, ()),), procedures return self._ordinary_function_body(plan, result_name), () def _ordinary_function_body( @@ -3569,15 +3596,19 @@ def _polymorphic_arguments(plan: FunctionPlan) -> tuple[ArgumentTransferPlan, .. @staticmethod def _assumed_rank_arguments(plan: FunctionPlan) -> tuple[ArgumentTransferPlan, ...]: - """Return assumed-rank arrays in original-Fortran call order.""" + """Return raw-address runtime-rank arrays requiring bridge dispatch.""" return tuple( argument for argument in sorted(plan.arguments, key=lambda item: item.projected_call_slot.native_position) - if argument.array is not None and argument.array.rank is None + if argument.array is not None + and argument.array.rank is None + and argument.array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS ) @staticmethod - def _non_derived_optional_arguments(plan: FunctionPlan) -> tuple[ArgumentTransferPlan, ...]: + def _non_derived_optional_arguments( + plan: FunctionPlan, + ) -> tuple[ArgumentTransferPlan, ...]: """Return optional arguments handled by the ordinary presence tree.""" return tuple( argument @@ -3586,6 +3617,44 @@ def _non_derived_optional_arguments(plan: FunctionPlan) -> tuple[ArgumentTransfe and argument.derived_call is None ) + def _contained_optional_descriptor_arguments( + self, + plan: FunctionPlan, + ) -> tuple[ArgumentTransferPlan, ...]: + """Return descriptor optionals that must not be host-associated on ifx.""" + return tuple( + argument + for argument in sorted(plan.arguments, key=lambda item: item.projected_call_slot.native_position) + if argument.derived_call is None + and argument.entrypoint.optional_mode in {OptionalMode.NULLABLE_VALUE, OptionalMode.DESCRIPTOR} + and argument.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER + and self._array_crosses_as_descriptor(argument) + ) + + def _contained_optional_descriptor_call_tree( + self, + arguments: tuple[ArgumentTransferPlan, ...], + index: int, + passed: tuple[CodeExpression, ...], + ) -> FortranCall | FortranIf: + """Enter the contained chain without forwarding an absent descriptor.""" + if index == len(arguments): + return FortranCall(self._derived_optional_step_name(0), passed) + argument = arguments[index] + local_name = self._forwarded_optional_descriptor_parameter_name(argument) + actual_name = argument.entrypoint.parameter_name + return FortranIf( + CodeExpression(f"present({actual_name})"), + body=( + self._contained_optional_descriptor_call_tree( + arguments, + index + 1, + (*passed, CodeExpression(f"{local_name}={actual_name}")), + ), + ), + else_body=(self._contained_optional_descriptor_call_tree(arguments, index + 1, passed),), + ) + def _polymorphic_call_tree( self, plan: FunctionPlan, @@ -3632,13 +3701,21 @@ def _derived_optional_dispatch_procedures( plan: FunctionPlan, optional: tuple[ArgumentTransferPlan, ...], result_name: str | None, + forwarded: tuple[ArgumentTransferPlan, ...], ) -> tuple[FortranFunction, ...]: """Propagate N optional derived dummies with O(N) adapter procedures.""" procedures = [] + forwarded_parameters = tuple(self._forwarded_optional_descriptor_parameter(item) for item in forwarded) + forwarded_passed = tuple( + CodeExpression(self._forwarded_optional_descriptor_parameter_name(item)) for item in forwarded + ) for index, argument in enumerate(optional): carried = optional[:index] - parameters = tuple(self._derived_optional_parameter(item) for item in carried) - passed = tuple(CodeExpression(self._derived_optional_parameter_name(item)) for item in carried) + parameters = (*forwarded_parameters, *(self._derived_optional_parameter(item) for item in carried)) + passed = ( + *forwarded_passed, + *(CodeExpression(self._derived_optional_parameter_name(item)) for item in carried), + ) expression = CodeExpression(self._native_argument_expression(argument)) procedures.append( FortranFunction( @@ -3659,12 +3736,18 @@ def _derived_optional_dispatch_procedures( is_subroutine=True, ) ) - replacements = {argument.owner_path: self._derived_optional_parameter_name(argument) for argument in optional} + replacements = { + **{argument.owner_path: self._derived_optional_parameter_name(argument) for argument in optional}, + **{ + argument.owner_path: self._forwarded_optional_descriptor_parameter_name(argument) + for argument in forwarded + }, + } present = frozenset(argument.owner_path for argument in optional) procedures.append( FortranFunction( name=self._derived_optional_step_name(len(optional)), - parameters=tuple(self._derived_optional_parameter(item) for item in optional), + parameters=(*forwarded_parameters, *(self._derived_optional_parameter(item) for item in optional)), body=self._ordinary_function_body( plan, result_name, @@ -3676,6 +3759,22 @@ def _derived_optional_dispatch_procedures( ) return tuple(procedures) + def _forwarded_optional_descriptor_parameter( + self, + argument: ArgumentTransferPlan, + ) -> FortranParameter: + """Declare one optional descriptor passed into every contained step.""" + return self._array_descriptor_parameter( + argument, + self._forwarded_optional_descriptor_parameter_name(argument), + optional=True, + ) + + @staticmethod + def _forwarded_optional_descriptor_parameter_name(argument: ArgumentTransferPlan) -> str: + """Name an optional descriptor local to the contained dispatch chain.""" + return f"prik_optional_{argument.entrypoint.parameter_name}" + def _derived_optional_parameter(self, argument: ArgumentTransferPlan) -> FortranParameter: """Mirror the completed native dummy category and add OPTIONAL.""" return self._derived_native_parameter( @@ -3735,7 +3834,11 @@ def _optional_call_tree( argument = optional[index] present_roles = present | {argument.owner_path} return FortranIf( - condition=CodeExpression(self._presence_condition(argument)), + condition=CodeExpression( + f"present({replacements[argument.owner_path]})" + if self._array_crosses_as_descriptor(argument) and argument.owner_path in replacements + else self._presence_condition(argument) + ), body=( *self._present_preparation(argument), self._optional_call_tree(plan, optional, index + 1, present_roles, result_name, replacements), @@ -5500,7 +5603,7 @@ def _direct_array_result_declarations( FortranDeclaration("result_copy", element_type, ("pointer",)), ) copy_type = "character(kind=c_char)" if result.datatype_family is DatatypeFamily.STRING else element_type - if "bridge" in result.array.extent_evaluation: + if "bridge" in result.array.extent_evaluation or self._array_result_depends_on_descriptor(plan, result): return ( FortranDeclaration( "result_value", @@ -5524,8 +5627,11 @@ def _direct_array_result_initializers( result is None or result.object_kind is not ObjectKind.NUMPY_ARRAY or result.array is None - or "bridge" not in result.array.extent_evaluation or self._is_scalar_storage_array(result.array) + or ( + "bridge" not in result.array.extent_evaluation + and not self._array_result_depends_on_descriptor(plan, result) + ) ): return () shape = list(self._array_result_shape(plan, result)) @@ -5534,6 +5640,28 @@ def _direct_array_result_initializers( shape[axis] = self._declaration_extent_result_name(result, axis) return (FortranAllocate(f"result_value({', '.join(shape)})"),) + def _array_result_depends_on_descriptor( + self, + plan: FunctionPlan, + result: ResultPlan, + ) -> bool: + """Use portable allocatable storage for descriptor-derived result extents. + + ifx can leave an automatic local undefined when it is assigned an + array-valued function result and its bounds refer to an assumed-shape + dummy. Allocatable call-local storage has the same ownership and copy + semantics without relying on that compiler path. + """ + descriptor_extent_roles = { + role + for argument in plan.arguments + if self._array_crosses_as_descriptor(argument) + for role in argument.array.extent_roles + } + return any( + role in descriptor_extent_roles for axis_roles in result.array.extent_reference_roles for role in axis_roles + ) + def _direct_result_finalizers( self, plan: FunctionPlan, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 607b52b1f..ec9606f0c 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -36,6 +36,7 @@ from prik.semantics.metadata import SCALAR_STORAGE_CATEGORY from prik.policy.models import ( ArgumentHandoffMode, + ArrayEntrypointABI, ArrayLogicalABI, ArrayPythonLayout, ArrayWritebackABI, @@ -62,6 +63,7 @@ DerivedWriteback, DeclarationCallableAction, DirectResultABI, + EntrypointPassingConvention, LifecycleOperation, FIXED_STRING_RESULT_COPY_REASON, OWNED_NATIVE_ARRAY_HANDLE_COPY_REASON, @@ -521,16 +523,20 @@ def _required_header_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDia if handle is not None ) expected_headers = list(self._native_array_required_headers(handles)) - if any( - field.access - in { - DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR, - DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, - } - for namespace in plan.namespaces - for derived in namespace.derived_types - for field in derived.fields - ) or self._accepts_array_handle_actual(plan): + if ( + any( + field.access + in { + DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR, + DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, + } + for namespace in plan.namespaces + for derived in namespace.derived_types + for field in derived.fields + ) + or self._accepts_array_handle_actual(plan) + or self._uses_array_descriptor_abi(plan) + ): expected_headers.append(NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER) expected = tuple(dict.fromkeys(expected_headers)) if plan.required_headers == expected: @@ -555,6 +561,16 @@ def _accepts_array_handle_actual(plan: ModulePlan) -> bool: for argument in function.arguments ) + @staticmethod + def _uses_array_descriptor_abi(plan: ModulePlan) -> bool: + """Return whether an ordinary argument uses the standard descriptor ABI.""" + return any( + argument.array is not None and argument.array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + for namespace in plan.namespaces + for function in namespace.functions + for argument in function.arguments + ) + def _namespace_native_array_handles( self, namespace: NamespacePlan, @@ -3861,12 +3877,39 @@ def _array_layout_role_diagnostics( if array is None: return () return ( + *self._array_entrypoint_abi_diagnostics(plan), *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_entrypoint_abi_diagnostics( + self, + plan: ArgumentTransferPlan, + ) -> tuple[WrapperPlanDiagnostic, ...]: + """Require the transport fields selected by completed array ABI policy.""" + array = plan.array + if array is None: + return () + diagnostics = [] + if array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR: + if plan.entrypoint.passing is not EntrypointPassingConvention.C_DESCRIPTOR_POINTER: + diagnostics.append( + self._diagnostic(plan.owner_path, "invalid-array-descriptor-passing", plan.entrypoint.passing.value) + ) + if plan.entrypoint.pass_array_metadata: + diagnostics.append(self._diagnostic(plan.owner_path, "unexpected-array-descriptor-metadata", None)) + if array.upper_bound_roles or array.stride_roles or array.dense_actual_role is not None: + diagnostics.append(self._diagnostic(plan.owner_path, "unexpected-array-descriptor-roles", None)) + elif array.entrypoint_abi is not ArrayEntrypointABI.RAW_ADDRESS: + diagnostics.append(self._diagnostic(plan.owner_path, "invalid-array-entrypoint-abi", array.entrypoint_abi)) + if array.signed_strides and ( + array.entrypoint_abi is not ArrayEntrypointABI.C_DESCRIPTOR or array.contiguous is True + ): + diagnostics.append(self._diagnostic(plan.owner_path, "invalid-array-signed-strides", None)) + return tuple(diagnostics) + def _array_dense_actual_role_diagnostics( self, plan: ArgumentTransferPlan, @@ -3875,7 +3918,13 @@ def _array_dense_actual_role_diagnostics( 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 + expected = ( + f"{plan.owner_path}:dense-actual" + if array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS + and 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 () @@ -3906,7 +3955,7 @@ def _array_axis_mode_diagnostics(self, plan: ArgumentTransferPlan) -> tuple[Wrap return () if array.contiguous is True and any(axis != "dense" for axis in array.axes): return (self._diagnostic(plan.owner_path, "invalid-array-axis-modes", array.axes),) - if array.contiguous is False and "strided" not in array.axes: + if array.contiguous is False and array.rank is not None and "strided" not in array.axes: return (self._diagnostic(plan.owner_path, "invalid-array-axis-modes", array.axes),) return () @@ -3915,7 +3964,7 @@ def _array_stride_role_diagnostics(self, plan: ArgumentTransferPlan) -> tuple[Wr array = plan.array if array is None: return () - if array.contiguous is False: + if array.contiguous is False and array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS: return ( *self._required_array_stride_role_diagnostics(plan), *self._array_stride_role_count_diagnostics(plan), diff --git a/prik/planning/planner.py b/prik/planning/planner.py index fc1462f6b..2a874807f 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -21,6 +21,7 @@ from prik.policy.models import ( ArgumentConversionPhase, ArgumentHandoffMode, + ArrayEntrypointABI, ArrayHandoffPolicy, CallbackHandoffPolicy, CallbackResultPolicy, @@ -2375,8 +2376,8 @@ def _array_plan( extent_callable_tokens=policy.extent_callable_references, extent_callable_roles=policy.extent_callable_roles, extent_evaluation=policy.extent_evaluation, - 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"), + upper_bound_roles=self._array_layout_roles(policy, owner_path, abi_rank, "upper-bound"), + stride_roles=self._array_layout_roles(policy, owner_path, abi_rank, "stride"), dense_actual_role=self._array_dense_actual_role( policy, owner_path, @@ -2414,7 +2415,12 @@ def _array_dense_actual_role( 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: + if ( + not enabled + or policy.entrypoint_abi is not ArrayEntrypointABI.RAW_ADDRESS + or policy.rank is None + or policy.contiguous is not False + ): return None return f"{owner_path}:dense-actual" @@ -2451,13 +2457,13 @@ def _array_itemsize_role(self, policy: ArrayHandoffPolicy, owner_path: str) -> s def _array_layout_roles( self, + policy: ArrayHandoffPolicy, owner_path: str, rank: int, - contiguous: bool | None, label: str, ) -> tuple[str, ...]: - """Name one ABI role per axis only for stride-aware layouts.""" - if contiguous is not False: + """Name per-axis metadata only when the raw ABI has to carry it.""" + if policy.entrypoint_abi is not ArrayEntrypointABI.RAW_ADDRESS or policy.contiguous is not False: return () return tuple(f"{owner_path}:{label}:{axis}" for axis in range(rank)) @@ -2565,10 +2571,24 @@ def _required_headers(self, namespaces: tuple[NamespacePlan, ...]) -> tuple[str, if handle is not None ) headers = list(self._native_array_headers(handles)) - if self._requires_derived_descriptor_header(namespaces) or self._accepts_array_handle_actual(namespaces): + if ( + self._requires_derived_descriptor_header(namespaces) + or self._accepts_array_handle_actual(namespaces) + or self._uses_array_descriptor_abi(namespaces) + ): headers.append(NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER) return tuple(dict.fromkeys(headers)) + @staticmethod + def _uses_array_descriptor_abi(namespaces: tuple[NamespacePlan, ...]) -> bool: + """Return whether an ordinary argument uses the standard descriptor ABI.""" + return any( + argument.array is not None and argument.array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + for namespace in namespaces + for function in namespace.functions + for argument in function.arguments + ) + @staticmethod def _accepts_array_handle_actual(namespaces: tuple[NamespacePlan, ...]) -> bool: """Return whether an ordinary array argument accepts an array handle. diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 4c2e3adf3..a5245b39b 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -1983,7 +1983,12 @@ def _complete_entrypoint_argument_route( ) ) ), - entrypoint_pass_array_metadata=(uses_adapter and argument.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER), + entrypoint_pass_array_metadata=( + uses_adapter + and argument.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER + and argument.array is not None + and argument.array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS + ), entrypoint_pass_descriptor_presence=(uses_adapter and argument.optional_mode is OptionalMode.DESCRIPTOR), entrypoint_pass_derived_transaction=(uses_adapter and argument.derived_call is not None), entrypoint_pass_callback_parameter=( @@ -2065,6 +2070,7 @@ def _argument_entrypoint_passing( function: models.SemanticFunction, argument: models.SemanticArgument, boundary: _ArgumentBoundaryPolicy, + array: ArrayHandoffPolicy | None, slot: NativeCallSlotPolicy | None, callback: CallbackHandoffPolicy | None, ) -> EntrypointPassingConvention: @@ -2081,6 +2087,12 @@ def _argument_entrypoint_passing( ) if boundary.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: return EntrypointPassingConvention.C_DESCRIPTOR_POINTER + if ( + boundary.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER + and array is not None + and array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + ): + return EntrypointPassingConvention.C_DESCRIPTOR_POINTER if argument.optional: return EntrypointPassingConvention.NULLABLE_POINTER if direct_c_abi: @@ -2103,6 +2115,7 @@ def _argument_entrypoint_optionality( function: models.SemanticFunction, argument: models.SemanticArgument, boundary: _ArgumentBoundaryPolicy, + array: ArrayHandoffPolicy | None, slot: NativeCallSlotPolicy | None, ) -> EntrypointOptionalityAction: """Complete original native presence independently from the Python surface.""" @@ -2110,6 +2123,12 @@ def _argument_entrypoint_optionality( return EntrypointOptionalityAction.REQUIRED if boundary.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: return EntrypointOptionalityAction.NULL_C_DESCRIPTOR_POINTER + if ( + boundary.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER + and array is not None + and array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + ): + return EntrypointOptionalityAction.NULL_C_DESCRIPTOR_POINTER if _argument_passes_by_value(argument, slot): return EntrypointOptionalityAction.ADAPTER_SIDE_FORTRAN_OMISSION if (function.origin.source_language == "fortran" and function.origin.native_abi == "c") or ( @@ -2525,8 +2544,9 @@ def slot_semantic_type(slot: NativeCallSlotPolicy) -> models.SemanticType | None semantic_argument = semantic_arguments_by_name.get(slot.python_name) return semantic_argument.semantic_type if semantic_argument is not None else None - parameters = tuple( - _direct_c_abi_type_policy( + def parameter_policy(slot: NativeCallSlotPolicy) -> DirectCABITypePolicy: + """Keep a descriptor array's standardized C declaration explicit.""" + policy = _direct_c_abi_type_policy( parameter_source[slot.native_position] if slot.native_position < len(parameter_source) and isinstance(parameter_source[slot.native_position], dict) else None, @@ -2544,8 +2564,16 @@ def slot_semantic_type(slot: NativeCallSlotPolicy) -> models.SemanticType | None ) ), ) - for slot in sorted(slots, key=lambda item: item.native_position) - ) + argument = argument_policies_by_name.get(slot.python_name or "") + if ( + argument is not None + and argument.array is not None + and argument.array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + ): + return replace(policy, source_spelling="CFI_cdesc_t *", pointer_depth=1) + return policy + + parameters = tuple(parameter_policy(slot) for slot in sorted(slots, key=lambda item: item.native_position)) direct_result = next((result for result in results if result.source_kind == "direct_return"), None) result_source = source_abi.get("result") if isinstance(source_abi.get("result"), dict) else None result = ( @@ -2748,16 +2776,26 @@ def _direct_scalar_supported(argument: ArgumentPolicy) -> bool: def _direct_array_supported(argument: ArgumentPolicy) -> bool: - """Return whether one explicit or assumed-size array can use its C pointer.""" + """Return whether one ordinary array has the direct ABI its declaration names.""" + array = argument.array return bool( - argument.rank > 0 - and ( + ( argument.semantic_type_name in _PLAN_PRIMITIVE_SCALAR_TYPES or (argument.semantic_type_name == "String" and argument.character_length == 1) ) and argument.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER - and argument.array is not None - and argument.array.category in {"explicit_shape", "assumed_size"} + and array is not None + and ( + ( + array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS + and array.category in {"explicit_shape", "assumed_size"} + and argument.entrypoint_passing is EntrypointPassingConvention.POINTER_REFERENCE + ) + or ( + array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + and argument.entrypoint_passing is EntrypointPassingConvention.C_DESCRIPTOR_POINTER + ) + ) ) @@ -2998,7 +3036,10 @@ def _argument_policy( ) optional_mode = _optional_mode(argument, decision) callback = _callback_handoff_policy(argument) - array_policy = _array_handoff_policy(argument.semantic_type) + array_policy = _array_handoff_policy( + argument.semantic_type, + source_language=function.origin.source_language, + ) transformations, transformation_blockers = _argument_transformation_policies( argument, decision, @@ -3034,6 +3075,7 @@ def _argument_policy( function, argument, boundary, + array_policy, native_slot, callback, ) @@ -3041,6 +3083,7 @@ def _argument_policy( function, argument, boundary, + array_policy, native_slot, ) blockers = _completed_argument_blockers( @@ -3407,7 +3450,10 @@ def _direct_result_policy(context: _FunctionPolicyContext) -> _ResultPolicyCandi bridge_data_action=bridge_data_action, bridge_copy_reason=bridge_copy_reason, character_length=_character_length(return_type), - array=_array_handoff_policy(return_type), + array=_array_handoff_policy( + return_type, + source_language=context.function.origin.source_language, + ), native_array_handle=direct_handle, scalar_descriptor=scalar_descriptor, derived=derived, @@ -3629,7 +3675,10 @@ def _hidden_result_candidate( bridge_data_action=bridge_data_action, bridge_copy_reason=bridge_copy_reason, character_length=_character_length(argument.semantic_type), - array=_array_handoff_policy(argument.semantic_type), + array=_array_handoff_policy( + argument.semantic_type, + source_language=context.function.origin.source_language, + ), source_kind="hidden_output", python_returned=not argument.metadata.get(models.HIDDEN_NATIVE_OUTPUT_METADATA), native_name=mapping.native_name or argument.name, @@ -3753,6 +3802,7 @@ def _projected_native_call_slot_policy( python_position, visible_arguments, derived_types, + source_language=function.origin.source_language, ) @@ -3843,6 +3893,8 @@ def _projected_argument_native_call_slot_policy( python_position: int | None, visible_arguments: tuple[models.SemanticArgument, ...], derived_types: Mapping[tuple[str, str], DerivedTypePolicy], + *, + source_language: str | None, ) -> tuple[NativeCallSlotPolicy | None, int | None, tuple[str, ...]]: """Complete one Python argument projection after checking its position.""" if python_position is None: @@ -3864,6 +3916,7 @@ def _projected_argument_native_call_slot_policy( native_position, python_position, derived_types, + source_language=source_language, ) return slot, python_position, blockers @@ -3876,6 +3929,8 @@ def _projected_argument_slot( native_position: int, python_position: int, derived_types: Mapping[tuple[str, str], DerivedTypePolicy], + *, + source_language: str | None, ) -> tuple[NativeCallSlotPolicy, tuple[str, ...]]: """Construct one completed native slot for a visible projected argument.""" argument_path = f"{owner_path}.{argument.name}" @@ -3924,7 +3979,10 @@ def _projected_argument_slot( result_position=mapping.result_position, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), - array=_array_handoff_policy(argument.semantic_type), + array=_array_handoff_policy( + argument.semantic_type, + source_language=source_language, + ), native_array_handle=_native_array_handle_wrapper_policy( argument.semantic_type, argument.metadata.get(models.RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA), @@ -4027,7 +4085,10 @@ def _hidden_result_native_call_slot_policy( result_position=mapping.result_position, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), - array=_array_handoff_policy(argument.semantic_type), + array=_array_handoff_policy( + argument.semantic_type, + source_language=function.origin.source_language, + ), native_array_handle=_native_array_handle_wrapper_policy( argument.semantic_type, argument.metadata.get(models.RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA), @@ -4080,7 +4141,10 @@ def _hidden_result_native_call_slot_policy( result_position=mapping.result_position, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), - array=_array_handoff_policy(argument.semantic_type), + array=_array_handoff_policy( + argument.semantic_type, + source_language=function.origin.source_language, + ), native_array_handle=_native_array_handle_wrapper_policy( argument.semantic_type, argument.metadata.get(models.RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA), @@ -4261,7 +4325,10 @@ def _implicit_native_call_slot_policies( array_copy_out=array_copy_out, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), - array=_array_handoff_policy(argument.semantic_type), + array=_array_handoff_policy( + argument.semantic_type, + source_language=function.origin.source_language, + ), native_array_handle=_native_array_handle_wrapper_policy( argument.semantic_type, argument.metadata.get(models.RESOLVED_NATIVE_ARRAY_HANDLE_POLICY_METADATA), @@ -7391,7 +7458,11 @@ def _array_writeback_abi( return ArrayWritebackABI.NATIVE_ARRAY -def _array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandoffPolicy | None: +def _array_handoff_policy( + semantic_type: models.SemanticType, + *, + source_language: str | None = None, +) -> ArrayHandoffPolicy | None: """Copy structured buffer or raw-pointee facts into completed wrapper policy.""" if _is_raw_array_address_type(semantic_type): return _raw_array_handoff_policy(semantic_type) @@ -7411,12 +7482,12 @@ def _array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandoffPol minimum_rank, maximum_rank = _array_handoff_rank_bounds(rank, array.category, flatten_python_storage) order = _array_handoff_order(array.order, array.category) contiguous = _array_handoff_contiguous(array.contiguous, array.category) - entrypoint_abi = _array_entrypoint_abi(array.category) - signed_strides = _array_handoff_signed_strides( - entrypoint_abi, - contiguous, + entrypoint_abi = _array_entrypoint_abi( + array.category, character=semantic_type.name == "String", + source_language=source_language, ) + signed_strides = _array_handoff_signed_strides(entrypoint_abi, contiguous) return ArrayHandoffPolicy( rank=rank, shape=shape, @@ -7472,29 +7543,29 @@ def _array_handoff_native_order( def _array_handoff_contiguous(contiguous: bool | None, category: str | None) -> bool | None: - """Complete the contiguity a contract asserts, leaving C runtime rank open. + """Complete the contiguity and section layout a contract asserts. ``None`` states that the contract asserts nothing about layout: the caller's - own strides reach the native call. Fortran assumed rank keeps its contiguous - descriptor default, and every C ``T[...]`` that did not spell ``Contiguous`` - stays stride-agnostic. + own strides reach the native call. A Fortran assumed-rank dummy receives a + descriptor, but a NumPy actual still has to be representable as a Fortran + array section, so it uses the same stride-aware layout as assumed shape. + Every C ``T[...]`` that did not spell ``Contiguous`` stays stride-agnostic. """ if contiguous is not None: return contiguous - if category in {SCALAR_STORAGE_CATEGORY, "assumed_rank"}: + if category == SCALAR_STORAGE_CATEGORY: return True + if category == "assumed_rank": + return False return None -# Both sources reach a descriptor dummy correctly now -- a NumPy array through a -# section built over its storage, a handle through its own entry point. What is -# not finished is everything that referenced the extent parameters this route -# removes: a result declared dimension(size(x)) reads them, and they have to be -# carried out of the descriptor instead. -_ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS = True - - -def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: +def _array_entrypoint_abi( + category: str | None, + *, + character: bool, + source_language: str | None, +) -> ArrayEntrypointABI: """Complete how one array dummy is reached, by asking the direct question. A ``bind(C)`` procedure with no bridge receives the address of the first @@ -7507,6 +7578,13 @@ def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: ``CFI_cdesc_t *``, which carries an extent and a signed byte stride per axis. """ + if source_language == "c": + return ArrayEntrypointABI.RAW_ADDRESS + if character: + # GNU Fortran currently loses elem_len when CFI_section constructs a + # character view. Keep character arrays on the address-and-width ABI so + # every supported compiler observes the correct element length. + return ArrayEntrypointABI.RAW_ADDRESS if category in {"explicit_shape", "assumed_size", "raw_address", "runtime_rank", SCALAR_STORAGE_CATEGORY}: return ArrayEntrypointABI.RAW_ADDRESS return ArrayEntrypointABI.C_DESCRIPTOR @@ -7515,7 +7593,6 @@ def _array_entrypoint_abi(category: str | None) -> ArrayEntrypointABI: def _array_handoff_signed_strides( entrypoint_abi: ArrayEntrypointABI, contiguous: bool | None, - character: bool = False, ) -> bool: """Complete whether an axis of the actual may run backwards. @@ -7525,16 +7602,7 @@ def _array_handoff_signed_strides( ``CONTIGUOUS`` dummy keeps its requirement whatever its calling convention carries. """ - if entrypoint_abi is not ArrayEntrypointABI.C_DESCRIPTOR or contiguous is True: - return False - if character: - # GNU Fortran's CFI_section resets a character descriptor's elem_len to - # 1, so the callee reads len(a) == 1 and every element is truncated to - # its first character. Intel's is correct. A silently wrong width is - # worse than a refusal, so a character array is not sectioned at all - # until that is fixed or detected. - return False - return _ORDINARY_ARRAYS_CROSS_AS_DESCRIPTORS + return entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR and contiguous is not True def _array_handoff_python_layout( diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index c63a690ff..137e8a5bb 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -553,9 +553,10 @@ static inline int prik_array_validate_strided_axis( int signed_strides, const char *argument_name) { + int previous_axis; npy_intp stride = PyArray_STRIDE(array, axis); npy_intp itemsize = PyArray_ITEMSIZE(array); - npy_intp span; + npy_intp previous_extent; npy_intp previous; if (itemsize <= 0 || (stride % itemsize) != 0) { @@ -570,6 +571,10 @@ static inline int prik_array_validate_strided_axis( /* A repeated element: NumPy broadcasting, which Fortran has no form for. */ return prik_array_refuse_section(argument_name, axis, signed_strides); } + if (stride == NPY_MIN_INTP) { + /* Its magnitude is not representable by the signed descriptor index type. */ + return prik_array_refuse_section(argument_name, axis, signed_strides); + } if (!signed_strides && stride < 0) { PyErr_Format( PyExc_TypeError, @@ -579,11 +584,19 @@ static inline int prik_array_validate_strided_axis( axis); return -1; } - if (axis > 0 && PyArray_DIM(array, axis - 1) > 0) { - previous = PyArray_STRIDE(array, axis - 1); + previous_axis = axis - 1; + while (previous_axis >= 0 && PyArray_DIM(array, previous_axis) <= 1) { + previous_axis -= 1; + } + if (previous_axis >= 0) { + npy_intp current = stride < 0 ? -stride : stride; + previous = PyArray_STRIDE(array, previous_axis); + if (previous == NPY_MIN_INTP) { + return prik_array_refuse_section(argument_name, previous_axis, signed_strides); + } previous = previous < 0 ? -previous : previous; - span = previous * PyArray_DIM(array, axis - 1); - if ((stride < 0 ? -stride : stride) < span) { + previous_extent = PyArray_DIM(array, previous_axis); + if (previous != 0 && previous > current / previous_extent) { /* * This axis steps less far than the one before it covers, so the * axes are either in the wrong order for Fortran or they overlap. @@ -596,6 +609,10 @@ static inline int prik_array_validate_strided_axis( argument_name); return -1; } + if (previous == 0 || (current % previous) != 0) { + /* CFI_section needs an integral step relative to its contiguous parent. */ + return prik_array_refuse_section(argument_name, axis, signed_strides); + } } return 0; } diff --git a/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py b/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py index 22f0258f7..9341ade1f 100644 --- a/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py +++ b/tests/c/primitive_pointers/codegen/test_runtime_rank_pointer_lowering.py @@ -4,7 +4,7 @@ from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner from prik.policy.completion import complete_semantic_policies -from prik.policy.models import ArrayPythonLayout, NativeArraySourceKind +from prik.policy.models import ArrayEntrypointABI, ArrayPythonLayout, NativeArraySourceKind from prik.semantics.c2ir import c_file_to_semantic_module @@ -24,6 +24,7 @@ def test_direct_c_binding_keeps_pointer_abi_and_uses_completed_runtime_rank_boun assert plan.entrypoint.native_languages == ("c",) assert array.rank is None assert (array.minimum_rank, array.maximum_rank) == (0, 15) + assert array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS assert actual.accepted_sources == (NativeArraySourceKind.NDARRAY,) assert "double native_read(const double * input);" in binding assert array.python_layout is ArrayPythonLayout.ANY_STRIDED diff --git a/tests/c/primitive_pointers/policy/test_runtime_rank_pointer_policy.py b/tests/c/primitive_pointers/policy/test_runtime_rank_pointer_policy.py index e5b4ec126..47bc1bb26 100644 --- a/tests/c/primitive_pointers/policy/test_runtime_rank_pointer_policy.py +++ b/tests/c/primitive_pointers/policy/test_runtime_rank_pointer_policy.py @@ -2,7 +2,12 @@ from prik.pipeline.pyi import pyi_text_to_semantic_module from prik.policy.completion import complete_semantic_policies -from prik.policy.models import ArrayPythonLayout, EntrypointPassingConvention, EntrypointProjectionAction +from prik.policy.models import ( + ArrayEntrypointABI, + ArrayPythonLayout, + EntrypointPassingConvention, + EntrypointProjectionAction, +) from prik.semantics.native_contract import validate_pyi_native_contract @@ -28,6 +33,9 @@ def scale(values: Float64[...]) -> None: ... assert array.native_order == "ORDER_C" assert array.contiguous is None assert array.python_layout is ArrayPythonLayout.ANY_STRIDED + assert array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS + assert policy.native_call_slots[1].array.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS + assert policy.arguments[0].entrypoint_passing is EntrypointPassingConvention.POINTER_REFERENCE assert size_slot.semantic_type_name == "SizeT" assert size_slot.projection_action is EntrypointProjectionAction.COMPUTED_SIZE assert size_slot.entrypoint_passing is EntrypointPassingConvention.C_VALUE diff --git a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py index 3ba90828a..01f2900cb 100644 --- a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py +++ b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py @@ -16,7 +16,12 @@ TransferMode, ) from prik.policy.completion import complete_semantic_policies -from prik.policy.models import ArgumentHandoffMode, BridgeDataAction +from prik.policy.models import ( + ArgumentHandoffMode, + ArrayEntrypointABI, + BridgeDataAction, + EntrypointPassingConvention, +) from prik.pipeline.wrapper import WrapperGenerator from prik.planning import ArrayHandoffPlan, WrapperPlanner from prik.planning.models import DatatypeFamily @@ -59,6 +64,9 @@ def test_required_array_buffer_has_one_printable_editable_handoff_plan(): assert argument.array.shape == (":",) assert argument.array.axes == ("dense",) assert argument.array.contiguous is True + assert argument.array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + assert argument.entrypoint.passing is EntrypointPassingConvention.C_DESCRIPTOR_POINTER + assert argument.entrypoint.pass_array_metadata is False assert argument.array.flatten_python_storage is False assert argument.array.flat_axis is None assert argument.array.data_role == argument.entrypoint.handoff_role @@ -73,23 +81,13 @@ def test_required_array_buffer_dispatches_through_named_binding_and_bridge_metho c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") - assert "double bind_c_sum_values(void * values, int64_t values_extent_0);" in c_source - # One shared binder call carries the completed NumPy selectors; a generated - # native handle is resolved separately through its descriptor backend. - assert ( - "prik_bind_array(bound_values_obj, NPY_FLOAT64, 1, 1, 1, PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS, " - '1, 1, "numpy.float64", "values", 0, ' - "bound_values_bind_fixed, &bound_values, bound_values_bind_extents)" - ) in c_source - assert c_source.count("prik_bind_array(bound_values_obj") == 1 - assert "bound_values_bind_fixed[0] = -1;" in c_source - assert "bound_values_extent_0 = bound_values_bind_extents[0];" 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, contiguous, dimension(:) :: values" in bridge_source - assert "call c_f_pointer(bound_values, values, [values_extent_0])" in bridge_source + assert "double bind_c_sum_values(CFI_cdesc_t * values);" in c_source + assert "prik_describe_numpy_array((CFI_cdesc_t *)&bound_values_parent" in c_source + assert "call->result = bind_c_sum_values(call->descriptor_0);" in c_source + assert "bound_values_bind_fixed" not in c_source + + assert "real(c_double), dimension(:) :: values" in bridge_source + assert "call c_f_pointer(bound_values" not in bridge_source assert "result = native_sum_values(values)" in bridge_source diff --git a/tests/fortran/arrays/codegen/test_array_output_identity.py b/tests/fortran/arrays/codegen/test_array_output_identity.py index ff2085316..51c5eb82b 100644 --- a/tests/fortran/arrays/codegen/test_array_output_identity.py +++ b/tests/fortran/arrays/codegen/test_array_output_identity.py @@ -110,14 +110,13 @@ def test_mutable_bool_array_writeback_needs_no_normalization(): def test_high_rank_bool_array_bridge_stays_inside_the_fortran_line_limit(): """Free-form Fortran caps a line at 132 columns, whatever the rank. - A rank-15 array names one extent per axis, so its generated declarations and - calls are the longest prik emits and are where continuation would first be - missed. + A rank-15 descriptor dummy is the longest ordinary array declaration PRIK + emits and is where continuation would first be missed. """ artifacts = WrapperGenerator().generate(_high_rank_logical_output_plan()) bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") - assert "values_extent_14" in bridge_source + assert "dimension(:, :, :, :, :, :, :, :, :, :, :, :, :, :, :) :: values" in bridge_source assert max(map(len, bridge_source.splitlines())) <= 132 diff --git a/tests/fortran/arrays/codegen/test_array_result_lowering.py b/tests/fortran/arrays/codegen/test_array_result_lowering.py index ca852a2e3..5b10e3541 100644 --- a/tests/fortran/arrays/codegen/test_array_result_lowering.py +++ b/tests/fortran/arrays/codegen/test_array_result_lowering.py @@ -113,9 +113,10 @@ def test_array_property_results_reuse_input_array_extent_roles_in_both_backends( assert "npy_intp result_obj_dims[] = {bound_values_extent_0};" in c_source assert "npy_intp result_obj_dims[] = {bound_values_extent_0 * bound_values_extent_1};" in c_source - assert "real(c_double), dimension(values_extent_0) :: result_value" in bridge_source - assert "dimension(values_extent_0 * values_extent_1) :: result_value" in bridge_source - assert "real(c_double), dimension(values_extent_1) :: result_value" in bridge_source + assert bridge_source.count("real(c_double), allocatable, dimension(:) :: result_value") == 3 + assert "allocate(result_value(size(values, 1)))" in bridge_source + assert "allocate(result_value(size(values, 1) * size(values, 2)))" in bridge_source + assert "allocate(result_value(size(values, 2)))" in bridge_source @pytest.mark.parametrize( diff --git a/tests/fortran/arrays/codegen/test_specialized_array_roles.py b/tests/fortran/arrays/codegen/test_specialized_array_roles.py index 9f3d5893c..d1266b245 100644 --- a/tests/fortran/arrays/codegen/test_specialized_array_roles.py +++ b/tests/fortran/arrays/codegen/test_specialized_array_roles.py @@ -5,7 +5,12 @@ from tests.fortran._support.ownership_policy import parse_pyi_text from prik.policy.completion import complete_semantic_policies -from prik.policy.models import NativeArraySourceKind, OptionalMode +from prik.policy.models import ( + ArrayEntrypointABI, + EntrypointPassingConvention, + NativeArraySourceKind, + OptionalMode, +) from prik.codegen import CBindingGenerator from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner @@ -61,7 +66,11 @@ def test_optional_assumed_rank_and_character_arrays_have_explicit_distinct_roles assert optional.native_array_actual.accepted_sources == handle_sources assert assumed is not None assert assumed.rank is None - assert assumed.contiguous is True + assert assumed.contiguous is False + assert assumed.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + assert assumed.signed_strides is True + assert assumed_argument.entrypoint.passing is EntrypointPassingConvention.C_DESCRIPTOR_POINTER + assert assumed_argument.entrypoint.pass_array_metadata is False assert assumed.runtime_rank_role == "later_array_buffers.any_rank.values:rank" assert len(assumed.extent_roles) == 15 assert assumed_argument.native_array_actual is not None @@ -69,6 +78,7 @@ def test_optional_assumed_rank_and_character_arrays_have_explicit_distinct_roles assert assumed_argument.native_array_actual.accepted_sources == handle_sources assert character is not None assert character.rank == 1 + assert character.entrypoint_abi is ArrayEntrypointABI.RAW_ADDRESS assert character.itemsize == 8 assert character.itemsize_role == "later_array_buffers.labels.values:itemsize" assert character_argument.native_array_actual is not None @@ -85,22 +95,17 @@ def test_optional_assumed_rank_and_character_lowering_follow_named_plan_fields() assert "PyObject * bound_values_obj = Py_None;" in c_source assert "if (bound_values_obj != Py_None)" in c_source + assert ("prik_array_validate(bound_values_obj, NPY_FLOAT64, 1, 15, PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F") in c_source assert ( - "prik_array_validate_ndarray((PyArrayObject *)bound_values_obj, NPY_FLOAT64, 1, 15, " - "PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" - ) in c_source - assert ( - "prik_native_array_backend_for_actual(bound_values_backend_capsule, 1, 15, " + "prik_native_array_backend_for_actual(bound_values_capsule, 1, 15, " 'CFI_type_double, sizeof(double), "float64", "values")' ) in c_source - assert "NPY_FLOAT64, 1, 15, PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" in c_source + assert "NPY_FLOAT64, 1, 15, PRIK_ARRAY_LAYOUT_SIGNED_STRIDED_F" in c_source assert "bound_values_rank = (int64_t)PyArray_NDIM" in c_source assert "NPY_STRING, 1, 1, PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS" in c_source assert "bound_values_itemsize != 8" in c_source - assert "if (c_associated(bound_values)) then" in bridge_source - assert "select case (values_rank)" in bridge_source - assert "case (1)" in bridge_source - assert "case (15)" in bridge_source + assert "real(c_double), dimension(..) :: values" in bridge_source + assert "select case (values_rank)" not 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/codegen/test_strided_array_lowering.py b/tests/fortran/arrays/codegen/test_strided_array_lowering.py index 32523fd34..2fa0c788c 100644 --- a/tests/fortran/arrays/codegen/test_strided_array_lowering.py +++ b/tests/fortran/arrays/codegen/test_strided_array_lowering.py @@ -6,6 +6,7 @@ from tests.fortran._support.ownership_policy import parse_pyi_text from prik.policy.completion import complete_semantic_policies +from prik.policy.models import ArrayEntrypointABI, EntrypointPassingConvention from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner @@ -24,7 +25,7 @@ def strided(values: Float64[{dimensions}]) -> None: ... return WrapperPlanner().build(module) -def test_strided_array_plan_names_bounds_and_element_strides_explicitly(): +def test_strided_array_plan_selects_one_descriptor_without_parallel_stride_roles(): argument = _strided_plan().namespaces[0].functions[0].arguments[0] array = argument.array @@ -32,15 +33,13 @@ def test_strided_array_plan_names_bounds_and_element_strides_explicitly(): assert array.rank == 2 assert array.axes == ("strided", "strided") assert array.contiguous is False - assert array.upper_bound_roles == ( - f"{argument.owner_path}:upper-bound:0", - f"{argument.owner_path}:upper-bound:1", - ) - assert array.stride_roles == ( - f"{argument.owner_path}:stride:0", - f"{argument.owner_path}:stride:1", - ) - assert array.dense_actual_role == f"{argument.owner_path}:dense-actual" + assert array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + assert array.signed_strides is True + assert argument.entrypoint.passing is EntrypointPassingConvention.C_DESCRIPTOR_POINTER + assert argument.entrypoint.pass_array_metadata is False + assert array.upper_bound_roles == () + assert array.stride_roles == () + assert array.dense_actual_role is None def test_strided_array_lowering_hands_over_one_descriptor_from_either_source(): @@ -82,21 +81,21 @@ def test_strided_array_lowering_hands_over_one_descriptor_from_either_source(): assert max(map(len, bridge_source.splitlines())) <= 132 -def test_strided_role_edit_fails_before_backend_lowering(): +def test_descriptor_array_stride_role_edit_fails_before_backend_lowering(): plan = _strided_plan() array = plan.namespaces[0].functions[0].arguments[0].array assert array is not None - array.stride_roles = array.stride_roles[:1] + array.stride_roles = (f"{array.data_role}:stride:0",) - with pytest.raises(ValueError, match="invalid-array-stride-roles"): + with pytest.raises(ValueError, match="unexpected-array-descriptor-roles"): WrapperGenerator().generate(plan) -def test_strided_dense_actual_role_edit_fails_before_backend_lowering(): +def test_descriptor_array_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 + array.dense_actual_role = f"{array.data_role}:dense-actual" - with pytest.raises(ValueError, match="invalid-array-dense-actual-role"): + with pytest.raises(ValueError, match="unexpected-array-descriptor-roles"): WrapperGenerator().generate(plan) diff --git a/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py b/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py index 0d4b7ce43..2b54239be 100644 --- a/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py +++ b/tests/fortran/arrays/end_to_end/test_signed_stride_handoff.py @@ -76,6 +76,13 @@ end do end function checksum2 + function total3(a) result(t) + real(8), intent(in) :: a(:, :, :) + real(8) :: t + + t = sum(a) + end function total3 + subroutine negate1(a) real(8), intent(inout) :: a(:) @@ -219,6 +226,14 @@ def test_rank_two_numpy_views_keep_their_axis_order(signed, view): assert signed.checksum2(array) == pytest.approx(_checksum2(array)) +def test_a_singleton_axis_preserves_padding_between_later_sections(signed): + """An unobservable singleton stride does not collapse a later padded axis.""" + storage = np.arange(5.0) + view = np.ndarray((2, 1, 2), dtype=np.float64, buffer=storage, strides=(8, 0, 24)) + + assert signed.total3(view) == pytest.approx(float(view.sum())) + + def test_a_reversed_view_is_written_through_to_the_callers_storage(signed): """intent(inout) reaches the caller's own elements, in their own order.""" array = _base() @@ -295,29 +310,16 @@ def test_an_optional_descriptor_dummy_accepts_omitted_none_and_reversed(signed): def test_assumed_rank_dummies_read_rank_and_size_from_the_descriptor(signed): - """An assumed-rank dummy takes its rank and size from what it is handed. - - Its contract asserts contiguous storage, which a reversed axis is not, so - completed policy keeps that requirement here as it would for any other - dummy that asks for it. The rank still comes from the descriptor rather - than from anything the caller states. - """ + """An assumed-rank dummy accepts every supported rank and section direction.""" assert signed.rank_and_size(_base()) == np.int32(108) assert signed.rank_and_size(_matrix()) == np.int32(212) - - with pytest.raises(TypeError, match=r"contiguous|expected ordering"): - signed.rank_and_size(_base()[::-1]) + assert signed.rank_and_size(_base()[::-1]) == np.int32(108) + assert signed.rank_and_size(_matrix()[::-1, ::-1]) == np.int32(212) + assert signed.rank_and_size(signed.reversed_ptr) == np.int32(108) def test_a_character_dummy_reports_its_own_width_from_either_source(signed): - """Character arrays keep the address handoff, and keep their width with it. - - GNU Fortran's CFI_section resets a character descriptor's elem_len to 1, so - a sectioned character array would reach the callee with every element - truncated to one character. Intel's is correct. Completed policy therefore - does not section a character array at all, and a reversed one is refused - rather than silently mis-sized. - """ + """Character arrays keep their runtime element width on the portable path.""" assert signed.word_width(signed.words) == np.int32(4) assert signed.word_width(np.array([b"abcd", b"efgh"], dtype="S4")) == np.int32(4) @@ -380,6 +382,10 @@ def test_layouts_that_are_not_array_sections_stay_refused(signed): with pytest.raises(TypeError, match=r"expected ordering|not a Fortran array section"): signed.checksum2(overlapping) + indivisible = np.ndarray((2, 2), dtype=np.float64, buffer=np.arange(8.0), strides=(16, 40)) + with pytest.raises(TypeError, match=r"not a Fortran array section"): + signed.checksum2(indivisible) + def test_absent_and_mismatched_storage_stay_refused(signed): """State and type checks are unchanged by how the storage is handed over.""" diff --git a/tests/fortran/infrastructure/codegen/test_native_entrypoint_routing.py b/tests/fortran/infrastructure/codegen/test_native_entrypoint_routing.py index 00d75921d..1d958ee5b 100644 --- a/tests/fortran/infrastructure/codegen/test_native_entrypoint_routing.py +++ b/tests/fortran/infrastructure/codegen/test_native_entrypoint_routing.py @@ -5,6 +5,7 @@ from prik.planning import NativeGeneratedCodeGroupKind, WrapperPlanner from prik.policy import complete_semantic_policies from prik.policy.models import ( + ArrayEntrypointABI, EntrypointPassingConvention, EntrypointProjectionAction, NativeEntrypointAction, @@ -48,6 +49,40 @@ def test_direct_plan_keeps_one_projected_sequence_and_no_adapter_facets(): assert slot.passing is EntrypointPassingConvention.C_VALUE +def test_bind_c_descriptor_arrays_call_the_user_symbol_without_an_adapter(): + plan = _plan( + """ +module direct_descriptor_arrays + use iso_c_binding +contains + integer(c_int) function fixed_rank(values) bind(C, name="direct_fixed_rank") result(output) + real(c_double), intent(in) :: values(:) + output = int(size(values), c_int) + end function fixed_rank + + integer(c_int) function any_rank(values) bind(C, name="direct_any_rank") result(output) + real(c_double), intent(in) :: values(..) + output = int(rank(values), c_int) + end function any_rank +end module direct_descriptor_arrays +""" + ) + + assert plan.bridge is None + for function in plan.namespaces[0].functions: + argument = function.arguments[0] + assert function.entrypoint.action is NativeEntrypointAction.DIRECT_C_ABI + assert function.bridge is None + assert argument.array.entrypoint_abi is ArrayEntrypointABI.C_DESCRIPTOR + assert argument.entrypoint.passing is EntrypointPassingConvention.C_DESCRIPTOR_POINTER + + generated = WrapperGenerator().generate(plan) + binding = next(source.text for source in generated.sources if source.path.suffix == ".c") + assert generated.required_headers == ("ISO_Fortran_binding.h",) + assert "int32_t direct_fixed_rank(CFI_cdesc_t * values);" in binding + assert "int32_t direct_any_rank(CFI_cdesc_t * values);" in binding + + def test_adapted_plan_attaches_one_narrow_adapter_to_the_shared_projection(): plan = _plan( """ diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index dc2801e8b..b3e7200a5 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -352,7 +352,7 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") assert artifacts.required_headers == ("ISO_Fortran_binding.h",) - assert "prik_bind_array(" in c_source + assert "prik_describe_numpy_array(" in c_source assert '"_native_array_backend_for_binding_positional"' in c_source assert '"_native_array_handle_from_generated_dispatch"' in c_source assert '"_bind_contract_native_array_handle"' in c_source diff --git a/tests/fortran/optional_arguments/codegen/test_optional_lowering.py b/tests/fortran/optional_arguments/codegen/test_optional_lowering.py index fcdac1381..4f294cfab 100644 --- a/tests/fortran/optional_arguments/codegen/test_optional_lowering.py +++ b/tests/fortran/optional_arguments/codegen/test_optional_lowering.py @@ -18,6 +18,9 @@ OPTIONAL_FIXED_CONTRACT = ( Path(__file__).parents[1] / "end_to_end" / "fixtures" / "contracts" / "foptional_fixed" / "__init__.pyi" ) +OPTIONAL_MIXED_CONTRACT = ( + Path(__file__).parents[1] / "end_to_end" / "fixtures" / "contracts" / "foptional_f90" / "foptional_f90.pyi" +) def _artifacts(module): @@ -105,6 +108,22 @@ def optional_literal(value: Annotated[Float64, Immutable] | None = ...) -> Float assert "native_optional_literal(literal_0, value=value)" in fortran_source +def test_optional_descriptor_is_passed_into_contained_derived_dispatch(): + """A contained procedure receives, rather than host-associates, the descriptor.""" + module = pyi_file_to_semantic_module(OPTIONAL_MIXED_CONTRACT, module_name="foptional_f90") + fortran_source = _source(_artifacts(module), ".f90") + summarize = fortran_source.split("function bind_c_summarize", maxsplit=1)[1].split( + "end function bind_c_summarize", maxsplit=1 + )[0] + contained = summarize.split(" contains", maxsplit=1)[1] + + assert "if (present(values)) then" in fortran_source + assert "call prik_derived_optional_step_0(prik_optional_values=values)" in fortran_source + assert "real(c_double), dimension(:), optional :: prik_optional_values" in contained + assert "if (present(prik_optional_values)) then" in contained + assert "present(values)" not in contained + + def test_required_descriptor_keeps_python_presence_separate_from_native_state_and_copyout(): module = parse_pyi_text( """ diff --git a/tests/fortran/pointers/end_to_end/test_pointer_handles.py b/tests/fortran/pointers/end_to_end/test_pointer_handles.py index bc6ce37a3..b33c95872 100644 --- a/tests/fortran/pointers/end_to_end/test_pointer_handles.py +++ b/tests/fortran/pointers/end_to_end/test_pointer_handles.py @@ -480,7 +480,7 @@ def test_module_native_array_handles_use_canonical_plan(tmp_path: Path): contract = tmp_path / "pointer_handles" / "fpointer_handles_f90.pyi" contract.parent.mkdir() contract.write_text( - """from prik.contracts import Aliased, Allocatable, Annotated, Float64, Pointer, PointerAssociation, PointerPolicy + """from prik.contracts import Aliased, Allocatable, Annotated, Float64, Pointer, PointerAssociation, PointerPolicy, bind module_values: Annotated[ Pointer[Float64[:]], @@ -504,6 +504,8 @@ def associate_module_slice() -> None: ... def associate_module_contiguous() -> None: ... def allocate_module_values() -> None: ... def sum_values(values: Float64[:]) -> Float64: ... +@bind("sum_values") +def sum_four(values: Float64[4]) -> Float64: ... def sum_pointer_descriptor(values: Pointer[Float64[:]]) -> Float64: ... def sum_allocatable_descriptor(values: Allocatable[Float64[:]]) -> Float64: ... """, @@ -543,6 +545,8 @@ def sum_allocatable_descriptor(values: Allocatable[Float64[:]]) -> Float64: ... assert allocatable_handle.allocated is True np.testing.assert_allclose(allocatable_handle.to_numpy(), np.array([10.0, 20.0, 30.0])) assert module.sum_allocatable_descriptor(allocatable_handle) == np.float64(60.0) + with pytest.raises(TypeError, match="incompatible shape at axis 0"): + module.sum_four(allocatable_handle) allocatable_handle.deallocate() assert allocatable_handle.allocated is False From 795717c18ead67661dbeae7603736c6a9a4975b6 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 16:42:54 +0100 Subject: [PATCH 45/47] codex: Take a fixed-width character array by the only dummy that can receive it GNU Fortran 13 rejected every module with a character array behind a descriptor: an interoperable allocatable or pointer character dummy must declare deferred length, and the generated consumer declared assumed length beside the allocatable attribute. GNU Fortran 11 and Intel's ifx accept that combination silently, which is why it reached CI. Deferring the length is not open either: argument association requires the actual to declare deferred length exactly when the dummy does, so an array whose width is fixed has no allocatable dummy it may be associated with at all. It is therefore taken by an assumed-shape assumed-length dummy, which is the only form that can receive it -- and the form this consumer had before the attribute was added. The cost is the declared lower bound and reach of the allocation state, for fixed-width character arrays only; deferred-length ones and every numeric array keep the allocatable dummy. A codegen test now asserts no interoperable character dummy is allocatable or a pointer with assumed length. Nothing available here compiles that rule -- both local compilers accept it -- so the test reads the generated declaration instead, and fails if the attribute comes back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- prik/codegen/fortran/bridge.py | 20 +++++--- .../test_module_array_view_lowering.py | 46 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index e8047a5a0..7e40b6018 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -2743,12 +2743,18 @@ def _module_descriptor_consumer_value_declaration( renumber the bounds from zero, losing a declared lower bound, and GCC rejects an assumed-shape character one outright. - Argument association requires the actual to declare deferred length - exactly when the dummy does, so a character array that declares its own - width takes assumed length rather than deferred. The runtime never - reaches this operation while the array is unallocated: - ``AllocatableArray.to_numpy`` and ``shape`` both return early on - ``allocated``. + A character array that declares its own width is the exception, and has + to be. An interoperable allocatable or pointer character dummy must + declare deferred length, and argument association requires the actual to + declare deferred length exactly when the dummy does -- so for an actual + whose width is fixed, no allocatable dummy exists that it may be + associated with. Such an array is therefore taken by an assumed-shape + assumed-length dummy, which costs it the declared lower bound and leaves + allocation state out of reach, and is the only form that can receive it. + + The runtime never reaches this operation while the array is + unallocated: ``AllocatableArray.to_numpy`` and ``shape`` both return + early on ``allocated``. """ dimension = self._array_dimension_attribute(rank) handle = plan.native_array_handle @@ -2758,7 +2764,7 @@ def _module_descriptor_consumer_value_declaration( else "allocatable" ) if plan.datatype_family is DatatypeFamily.STRING and plan.character_length is not None: - return "character(kind=c_char, len=*)", (attribute, dimension, "intent(inout)") + return "character(kind=c_char, len=*)", (dimension, "intent(inout)") return self._module_native_array_element_type(plan), (attribute, dimension, "intent(inout)") def _module_native_array_operation_name(self, plan: ModuleVariablePlan, operation) -> str: diff --git a/tests/fortran/modules/codegen/test_module_array_view_lowering.py b/tests/fortran/modules/codegen/test_module_array_view_lowering.py index 1eaa5600a..a4bf09d7c 100644 --- a/tests/fortran/modules/codegen/test_module_array_view_lowering.py +++ b/tests/fortran/modules/codegen/test_module_array_view_lowering.py @@ -29,6 +29,16 @@ end module array_state """ +DESCRIPTOR_CHARACTER_SOURCE = """ +module char_state + implicit none + character(len=5), allocatable :: fixed_alloc(:) + character(len=5), pointer :: fixed_ptr(:) => null() + character(len=:), allocatable :: deferred_alloc(:) + real(8), allocatable :: numbers(:) +end module char_state +""" + def _plan(): parsed = parse_fortran_project({"array_state.f90": MODULE_ARRAY_SOURCE}) @@ -161,3 +171,39 @@ def test_module_array_view_lowering_requires_a_completed_address_mechanism(): with pytest.raises(ValueError, match="no completed address mechanism"): bridge.visit(undecided) + + +def _character_bridge_source(): + parsed = parse_fortran_project({"char_state.f90": DESCRIPTOR_CHARACTER_SOURCE}) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="char_state") + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + return FortranSourcePrinter().visit(FortranBridgeGenerator().visit(plan)) + + +def test_no_interoperable_character_dummy_is_allocatable_with_assumed_length(): + """An interoperable allocatable character dummy must declare deferred length. + + An allocatable or pointer character dummy of a BIND(C) procedure may not + have assumed length; and argument association requires the actual to have + deferred length exactly when the dummy does. So an array whose width is + fixed has no allocatable dummy it can be associated with, and is taken by + an assumed-shape one instead. + + GNU Fortran 13 rejects the combination; 11 and Intel's ifx accept it + silently, so nothing that compiles here would have caught it. + """ + source = _character_bridge_source() + offenders = [ + line.strip() + for line in source.splitlines() + if "character(" in line and "len=*" in line and ("allocatable" in line or "pointer" in line) + ] + + assert offenders == [] + # The fixed-width arrays keep their width and lose only the attribute. + assert "character(kind=c_char, len=*), dimension(:), intent(inout) :: value" in source + # A numeric array is unaffected: nothing stops its dummy being allocatable. + assert "allocatable" in source From 9b465745e16678ec6787a5a9455d284e775e90f6 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 19:46:35 +0100 Subject: [PATCH 46/47] codex: Distinguish native entities from descriptor projections --- CHANGELOG.md | 6 + docs/developer/packages/runtime.md | 20 +- docs/user/guide/allocatables.md | 6 + docs/user/guide/pointers.md | 6 + docs/user/language-support/feature-matrix.md | 4 +- prik/codegen/c/binding.py | 240 ++++++++++++++---- prik/codegen/fortran/bridge.py | 132 ++++++---- prik/planning/models.py | 2 + prik/planning/planner.py | 1 + prik/policy/completion.py | 34 +++ prik/policy/construction.py | 7 + prik/policy/models.py | 9 + prik/policy/native_array_handles.py | 2 + prik/runtime/native_support/prik_binding.h | 45 +++- tests/fortran/_support/ownership_policy.py | 2 + .../end_to_end/test_allocatable_handles.py | 34 +++ .../test_native_handle_array_forms.py | 19 ++ .../test_derived_array_field_lowering.py | 22 ++ .../runtime/test_native_support.py | 5 + .../codegen/test_native_handle_planning.py | 3 +- .../test_module_array_view_lowering.py | 32 +-- .../policy/test_module_variable_policy.py | 36 ++- .../policy/test_string_wrapper_policy.py | 21 ++ 23 files changed, 538 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b71eee5e..0ae34a7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ release tags add a leading `v` to the package version. versions or record layouts before reading the backend. Rebuild generated extensions together after upgrading PRIK. +- Fixed-width character module and field handles expose their storage through + an ordinary descriptor. They support inquiries, NumPy views, and ordinary + array arguments. Fixed-width character allocatable and pointer array + arguments are rejected because no interoperable descriptor interface carries + their allocation or association semantics. + - Generated Fortran allocatable and pointer handles can be passed directly to matching ordinary array arguments. Numeric assumed-shape and assumed-rank arguments accept representable forward or reversed Fortran sections from diff --git a/docs/developer/packages/runtime.md b/docs/developer/packages/runtime.md index b25e33d78..6e22dcd40 100644 --- a/docs/developer/packages/runtime.md +++ b/docs/developer/packages/runtime.md @@ -44,8 +44,8 @@ cross-extension ABI for an array handle: ```c typedef struct { - uint32_t struct_size; uint32_t descriptor_kind; + uint32_t descriptor_attribute; uint32_t rank; uint32_t descriptor_size; int32_t cfi_type; @@ -60,9 +60,11 @@ typedef struct { descriptor and runs the consumer on it: - **Borrowed** — a module variable or a derived-type field. The entry point - enters Fortran, which builds the descriptor for that call and copies back - what the consumer wrote. The descriptor is gone when the consumer returns and - must never be retained, copied, or serialized. + enters Fortran and supplies the plan-selected descriptor for that call. The + descriptor is gone when the consumer returns and must never be retained, + copied, or serialized. An ordinary projection does not invoke the consumer + while its allocatable or pointer entity has no storage; inquiries return the + corresponding absent value. - **Owned** — a native result, or a contract handle that has been given storage. The binding allocated a descriptor and keeps it for the handle's life, so the entry point hands that storage straight to the consumer. @@ -81,10 +83,12 @@ an incompatible producer is therefore refused before its fields are read. `descriptor_size` stays in the record because it attests the producer's `CFI_CDESC_T(rank)` layout, which is the compiler's, not this header's, and so -is not folded into the tag. A reader still validates `descriptor_kind`, `rank`, -`cfi_type` and `element_size` against the dummy it is filling. `element_size` -is `0` for widths determined at run time, such as deferred-length character -arrays. +is not folded into the tag. `descriptor_kind` identifies the native entity; +`descriptor_attribute` identifies the descriptor supplied to a consumer. A +descriptor-dummy call requires both to match, while ordinary-array consumers +can use a descriptor with the `other` attribute. Readers also validate `rank`, +`cfi_type` and `element_size`. `element_size` is `0` for widths determined at +run time, such as deferred-length character arrays. ### Inquiries Read The Descriptor diff --git a/docs/user/guide/allocatables.md b/docs/user/guide/allocatables.md index 1d75af20d..5c2d72bba 100644 --- a/docs/user/guide/allocatables.md +++ b/docs/user/guide/allocatables.md @@ -59,6 +59,12 @@ A plain NumPy array cannot satisfy an `Allocatable[T[...]]` parameter because it does not carry native allocation state. Use `to_numpy()` when Python needs the current array data held by an allocatable handle. +PRIK does not wrap `Allocatable[String[N][...]]` parameters: a fixed-width +character allocatable array has no interoperable allocatable descriptor +interface that preserves its allocation semantics. A fixed-width character +module or field handle can still be viewed with `to_numpy()` and passed to an +ordinary `String[N][...]` array parameter. + --- ## Allocatable Array Handle API diff --git a/docs/user/guide/pointers.md b/docs/user/guide/pointers.md index a844b2d6b..b9dee8aca 100644 --- a/docs/user/guide/pointers.md +++ b/docs/user/guide/pointers.md @@ -106,6 +106,12 @@ and its current element width, and can be nullified or deallocated. It cannot be passed as a pointer-descriptor argument or exposed with `to_numpy()`, because Fortran does not permit its descriptor form in a `bind(C)` interface. +PRIK does not wrap `Pointer[String[N][...]]` parameters: a fixed-width +character pointer array has no interoperable pointer descriptor interface that +preserves its association semantics. A fixed-width character module or field +pointer can still expose a NumPy view and satisfy an ordinary +`String[N][...]` array parameter. + --- ## Associate Two Pointers diff --git a/docs/user/language-support/feature-matrix.md b/docs/user/language-support/feature-matrix.md index 4e423566a..23e26f806 100644 --- a/docs/user/language-support/feature-matrix.md +++ b/docs/user/language-support/feature-matrix.md @@ -59,8 +59,8 @@ where they apply. | Defined operators and assignment overloads | Supported | [Defined operators](../guide/wrapping-derived-types.md#defined-operators) | [Defined operator tests](../../../tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py) | Supported operators are those covered by the wrapper guide and runtime tests. | | Output arguments and multiple results | Supported | [Subroutine projection](../guide/wrapping-subroutines.md) | [Calls and results tests](../../../tests/fortran/infrastructure/semantic_pyi/contracts/calls_and_results/end_to_end/test_edited_call_surfaces.py), [function result tests](../../../tests/fortran/functions/end_to_end/test_documented_function_journeys.py) | Tuple ordering and caller-provided array behavior follow the wrapper guide. | | Optional arguments | Supported | [Optional arguments](../guide/optional-arguments.md) | [Optional argument tests](../../../tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py) | Unsupported optional combinations fail during wrapper planning. | -| Allocatable array handles, descriptor arguments, and owned results | Supported | [Allocatables](../guide/allocatables.md) | [Allocatable runtime tests](../../../tests/fortran/allocatables/end_to_end/test_allocatable_handles.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Array module/field handles borrow their owner; result handles own persistent descriptor storage. Wrapper-owned scalar-derived allocatables use typed holders; module scalar allocatables use reversible `move_alloc` transactions for compatible dummies. | -| Pointer scalar projections and array handles | Partially supported | [Pointers](../guide/pointers.md) | [Pointer handle tests](../../../tests/fortran/pointers/end_to_end/test_pointer_handles.py), [pointer policy tests](../../../tests/fortran/pointers/policy/test_pointer_ownership_policy.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Descriptor arguments, module/field handles, strided views, wrapper-owned pointer-array results and outputs, scalar-derived pointer holders, and module pointer reassociation transactions are supported. Target deallocation and writable reassociation remain policy-gated. | +| Allocatable array handles, descriptor arguments, and owned results | Supported | [Allocatables](../guide/allocatables.md) | [Allocatable runtime tests](../../../tests/fortran/allocatables/end_to_end/test_allocatable_handles.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Array module/field handles borrow their owner; result handles own persistent descriptor storage. Fixed-width character descriptor arguments are unsupported; their module/field handles can still satisfy ordinary array arguments. Wrapper-owned scalar-derived allocatables use typed holders; module scalar allocatables use reversible `move_alloc` transactions for compatible dummies. | +| Pointer scalar projections and array handles | Partially supported | [Pointers](../guide/pointers.md) | [Pointer handle tests](../../../tests/fortran/pointers/end_to_end/test_pointer_handles.py), [pointer policy tests](../../../tests/fortran/pointers/policy/test_pointer_ownership_policy.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Descriptor arguments, module/field handles, strided views, wrapper-owned pointer-array results and outputs, scalar-derived pointer holders, and module pointer reassociation transactions are supported. Fixed- and deferred-width character descriptor arguments are unsupported. Target deallocation and writable reassociation remain policy-gated. | | Array-valued function results | Supported | [Array results](../guide/arrays.md#mutation-and-results) | [Array result tests](../../../tests/fortran/arrays/end_to_end/test_array_results.py) | Ownership and dtype/shape behavior are limited to documented array result forms. | | NumPy array argument contracts | Supported | [Arrays](../guide/arrays.md) | [Array contract tests](../../../tests/fortran/arrays/end_to_end/test_array_contract_validation.py), [multidimensional tests](../../../tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py) | Wrong dtype, rank, shape, contiguity, alignment, or mutability is rejected. | | Derived-type scalar boundaries and methods | Supported | [Derived types](../guide/wrapping-derived-types.md) | [Derived boundary tests](../../../tests/fortran/derived_types/end_to_end/test_derived_boundaries.py), [method tests](../../../tests/fortran/derived_types/end_to_end/test_type_bound_methods.py) | Derived-type arrays and some polymorphic forms are not included. | diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 2ef8cb6e5..98594bc25 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -38,6 +38,7 @@ ModuleObjectAccessMechanism, ModuleArrayAddressMechanism, ModuleGetterAction, + NativeArrayDescriptorAttribute, NativeArrayDescriptorKind, NativeArrayDescriptorInterop, NativeArrayDefaultConstruction, @@ -3132,7 +3133,8 @@ def _field_handle_backend_capsule_nodes( "PyObject *", CodeExpression( "prik_native_array_backend_capsule_new(" - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"{self._native_array_handle_kind_constant(handle)}, " + f"{self._native_array_descriptor_attribute_constant(handle)}, {handle.array.rank}, " f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, " f"{self._field_native_array_element_size(field)}, {address}, {forward}, NULL)" ), @@ -3936,7 +3938,13 @@ def _field_handle_operation_body(self, owner, field: DerivedFieldPlan, operation ) bridge = self._field_handle_bridge_name(owner, field, operation) if operation is NativeArrayOperation.ASSOCIATE: - return self._field_handle_associate_body(field, prefix, bridge, owner_args) + return self._field_handle_associate_body( + field, + prefix, + bridge, + self._field_handle_bridge_name(owner, field, NativeArrayOperation.NULLIFY), + owner_args, + ) if operation in {NativeArrayOperation.ALLOCATE, NativeArrayOperation.RESIZE}: return (*prefix, *self._field_handle_shape_mutation_nodes(field, bridge, owner_args)) if operation in {NativeArrayOperation.DEALLOCATE, NativeArrayOperation.NULLIFY}: @@ -3968,6 +3976,8 @@ def _field_handle_inquiry_nodes( operation, rank=handle.array.rank, numpy_type=self._field_native_array_numpy_type(field), + element_size=self._field_native_array_element_size(field), + descriptor_attribute=handle.descriptor_attribute, base="self", entry_point=self._field_handle_with_descriptor_name(self._field_handle_descriptor_callback(owner, field)), context=owner_args or "NULL", @@ -3984,16 +3994,33 @@ def _field_handle_associate_body( field: DerivedFieldPlan, prefix: tuple, bridge: str, + nullify_bridge: str, owner_args: str, ) -> tuple: """Associate one field pointer through its selected bridge operation.""" arguments = f"{owner_args}, source_descriptor" if owner_args else "source_descriptor" + nullify_arguments = owner_args + handle = field.native_array_handle + if handle is None: + raise ValueError(f"Pointer field {field.owner_path!r} has no descriptor policy") + association = ( + CIf( + CodeExpression("source_base_addr == NULL"), + body=(CExpressionStatement(CodeExpression(f"{nullify_bridge}({nullify_arguments})")),), + else_body=(CExpressionStatement(CodeExpression(f"{bridge}({arguments})")),), + ) + if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER + else CExpressionStatement(CodeExpression(f"{bridge}({arguments})")) + ) return ( *prefix, CDeclaration("source_packed", "PyObject *"), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &source_packed)) return NULL')), - *self._pointer_association_source_nodes(field), - CExpressionStatement(CodeExpression(f"{bridge}({arguments})")), + *self._pointer_association_source_nodes( + field, + establish_if_present_only=handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER, + ), + association, CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) @@ -4342,6 +4369,8 @@ def _module_native_array_inquiry_body( operation, rank=handle.array.rank, numpy_type=self._module_native_array_numpy_type(variable), + element_size=(str(variable.character_length) if variable.character_length is not None else "0"), + descriptor_attribute=handle.descriptor_attribute, # Module storage outlives any view of it, so a view retains nothing. base="NULL", entry_point=self._module_with_descriptor_name(variable), @@ -4363,15 +4392,37 @@ def _module_native_array_data_operation_body( ) -> tuple[CDeclaration | CExpressionStatement | CIf | CReturn, ...]: """Lower the mutations that must reach the module variable itself.""" if operation is NativeArrayOperation.ASSOCIATE: + handle = variable.native_array_handle + if handle is None: + raise ValueError(f"Module pointer handle {variable.owner_path!r} has no descriptor policy") + associate = CExpressionStatement( + CodeExpression( + f"{self._module_native_array_bridge_operation_name(variable, operation)}(source_descriptor)" + ) + ) + association = ( + CIf( + CodeExpression("source_base_addr == NULL"), + body=( + CExpressionStatement( + CodeExpression( + f"{self._module_native_array_bridge_operation_name(variable, NativeArrayOperation.NULLIFY)}()" + ) + ), + ), + else_body=(associate,), + ) + if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER + else associate + ) return ( CDeclaration("source_packed", "PyObject *"), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &source_packed)) return NULL')), - *self._pointer_association_source_nodes(variable), - CExpressionStatement( - CodeExpression( - f"{self._module_native_array_bridge_operation_name(variable, operation)}(source_descriptor)" - ) + *self._pointer_association_source_nodes( + variable, + establish_if_present_only=handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER, ), + association, CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) if operation in {NativeArrayOperation.ALLOCATE, NativeArrayOperation.RESIZE}: @@ -4392,9 +4443,8 @@ def _module_native_array_data_operation_body( def _uses_module_descriptor_backend(variable: ModuleVariablePlan) -> bool: """Return whether a handle reaches its descriptor through a consumer. - A module array hands its variable to a consumer rather than filling a - record supplied from C, so the descriptor that crosses is always one - this compiler built. Both allocatable and pointer variables do this. + A module array hands a plan-selected descriptor projection to a + consumer rather than filling a record supplied from C. """ handle = variable.native_array_handle return bool( @@ -4465,7 +4515,8 @@ def _module_native_array_backend_nodes( self._module_native_array_backend_name(variable), "static prik_native_array_backend", CodeExpression( - f"{{{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"{{{self._native_array_handle_kind_constant(handle)}, " + f"{self._native_array_descriptor_attribute_constant(handle)}, {handle.array.rank}, " f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, {element_size}, " f"NULL, {forward}, NULL}}" ), @@ -4864,6 +4915,8 @@ def _owned_native_array_inquiry_body( operation, rank=handle.array.rank, numpy_type=self._owned_native_array_numpy_type(result), + element_size=self._native_array_expected_element_size(result), + descriptor_attribute=handle.descriptor_attribute, base="owner_obj", entry_point="owner_backend->with_descriptor", context="owner_backend->context", @@ -4908,13 +4961,15 @@ def _owned_native_array_associate_body( def _pointer_association_source_nodes( self, plan: ArgumentTransferPlan | ResultPlan | ModuleVariablePlan | DerivedFieldPlan, - ) -> tuple[CDeclaration | CExpressionStatement, ...]: + *, + establish_if_present_only: bool = False, + ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: """Establish one call-local pointer descriptor from validated source facts.""" handle = plan.native_array_handle rank = handle.array.rank cfi_type = self._pointer_association_cfi_type(plan) expected_fields = 3 + 3 * rank - nodes: list[CDeclaration | CExpressionStatement] = [ + nodes: list[CDeclaration | CExpressionStatement | CIf] = [ CDeclaration("source_item", "PyObject *", CodeExpression("NULL")), CDeclaration("source_storage", f"CFI_CDESC_T({rank})"), CDeclaration("source_descriptor", "CFI_cdesc_t *", CodeExpression("NULL")), @@ -4949,33 +5004,33 @@ def _pointer_association_source_nodes( CExpressionStatement(CodeExpression(f"source_extents[{axis}] = source_extent_{axis}")), ) ) - nodes.extend( - ( - CExpressionStatement( - CodeExpression( - f"if (source_descriptor_rank != {rank}) {{ PyErr_Format(PyExc_ValueError, " - f'"pointer association source rank %d does not match destination rank {rank}", ' - "(int)source_descriptor_rank); return NULL; }" - ) - ), - CExpressionStatement( - CodeExpression( - "source_establish_status = CFI_establish((CFI_cdesc_t *)&source_storage, " - f"source_base_addr, CFI_attribute_pointer, {cfi_type}, source_elem_len, " - f"{rank}, source_extents)" - ) - ), - CExpressionStatement( - CodeExpression( - "if (source_establish_status != CFI_SUCCESS) { " - 'PyErr_SetString(PyExc_RuntimeError, "failed to establish pointer association source"); ' - "return NULL; }" - ) - ), + nodes.append( + CExpressionStatement( + CodeExpression( + f"if (source_descriptor_rank != {rank}) {{ PyErr_Format(PyExc_ValueError, " + f'"pointer association source rank %d does not match destination rank {rank}", ' + "(int)source_descriptor_rank); return NULL; }" + ) ) ) + establish = [ + CExpressionStatement( + CodeExpression( + "source_establish_status = CFI_establish((CFI_cdesc_t *)&source_storage, " + f"source_base_addr, CFI_attribute_pointer, {cfi_type}, source_elem_len, " + f"{rank}, source_extents)" + ) + ), + CExpressionStatement( + CodeExpression( + "if (source_establish_status != CFI_SUCCESS) { " + 'PyErr_SetString(PyExc_RuntimeError, "failed to establish pointer association source"); ' + "return NULL; }" + ) + ), + ] for axis in range(rank): - nodes.extend( + establish.extend( ( CExpressionStatement( CodeExpression( @@ -4992,7 +5047,11 @@ def _pointer_association_source_nodes( ), ) ) - nodes.append(CExpressionStatement(CodeExpression("source_descriptor = (CFI_cdesc_t *)&source_storage"))) + establish.append(CExpressionStatement(CodeExpression("source_descriptor = (CFI_cdesc_t *)&source_storage"))) + if establish_if_present_only: + nodes.append(CIf(CodeExpression("source_base_addr != NULL"), body=tuple(establish))) + else: + nodes.extend(establish) return tuple(nodes) @staticmethod @@ -10682,20 +10741,33 @@ def _native_array_projection_call_nodes( *, rank: int, numpy_type: str, + element_size: str, + descriptor_attribute: NativeArrayDescriptorAttribute, base: str, entry_point: str, context: str, ) -> tuple: """Run one inquiry through a handle's descriptor entry point. - The consumer reports absence itself, so the caller does not ask first; - a NULL result with no exception set means the descriptor was never - reached, which only a broken entry point can cause. + A true allocatable or pointer descriptor reports absence itself. An + ordinary projection has no descriptor while its native entity is + absent, so its entry point does not call the consumer and the binding + returns the operation's absent value. """ record = self.NATIVE_ARRAY_PROJECTION_RECORD consumer = self._native_array_projection_name( NativeArrayOperation.ALLOCATED if operation is NativeArrayOperation.ASSOCIATED else operation ) + if descriptor_attribute is NativeArrayDescriptorAttribute.OTHER: + missing = self._absent_native_array_projection_nodes(operation, rank, element_size) + else: + missing = ( + CExpressionStatement( + CodeExpression( + 'PyErr_SetString(PyExc_RuntimeError, "native array handle did not report a descriptor")' + ) + ), + ) return ( CDeclaration( "projection", @@ -10705,17 +10777,73 @@ def _native_array_projection_call_nodes( CExpressionStatement(CodeExpression(f"{entry_point}({context}, {consumer}, &projection)")), CIf( CodeExpression("projection.result == NULL && !PyErr_Occurred()"), - body=( - CExpressionStatement( - CodeExpression( - 'PyErr_SetString(PyExc_RuntimeError, "native array handle did not report a descriptor")' - ) - ), - ), + body=missing, ), CReturn(CodeExpression("projection.result")), ) + @staticmethod + def _absent_native_array_projection_nodes( + operation: NativeArrayOperation, + rank: int, + element_size: str, + ) -> tuple: + """Return one inquiry result when an ordinary projection has no storage.""" + if operation in { + NativeArrayOperation.ALLOCATED, + NativeArrayOperation.ASSOCIATED, + NativeArrayOperation.CONTIGUOUS, + }: + return (CExpressionStatement(CodeExpression("projection.result = PyBool_FromLong(0)")),) + if operation in {NativeArrayOperation.SHAPE, NativeArrayOperation.TO_NUMPY}: + return (CExpressionStatement(CodeExpression("projection.result = Py_NewRef(Py_None)")),) + if operation is NativeArrayOperation.ELEMENT_LENGTH: + return ( + CExpressionStatement( + CodeExpression(f"projection.result = PyLong_FromSize_t((size_t)({element_size}))") + ), + ) + if operation is NativeArrayOperation.DESCRIPTOR: + facts = "absent_descriptor_facts" + assignments = [ + CExpressionStatement(CodeExpression(f"PyTuple_SET_ITEM({facts}, 0, PyLong_FromLongLong(0))")), + CExpressionStatement( + CodeExpression(f"PyTuple_SET_ITEM({facts}, 1, PyLong_FromSize_t((size_t)({element_size})))") + ), + CExpressionStatement(CodeExpression(f"PyTuple_SET_ITEM({facts}, 2, PyLong_FromLong({rank}))")), + ] + for axis in range(rank): + offset = 3 + 3 * axis + assignments.extend( + ( + CExpressionStatement( + CodeExpression(f"PyTuple_SET_ITEM({facts}, {offset}, PyLong_FromLongLong(0))") + ), + CExpressionStatement( + CodeExpression(f"PyTuple_SET_ITEM({facts}, {offset + 1}, PyLong_FromLongLong(0))") + ), + CExpressionStatement( + CodeExpression( + f"PyTuple_SET_ITEM({facts}, {offset + 2}, PyLong_FromSize_t((size_t)({element_size})))" + ) + ), + ) + ) + return ( + CDeclaration(facts, "PyObject *", CodeExpression(f"PyTuple_New({3 + 3 * rank})")), + CIf(CodeExpression(f"{facts} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + *assignments, + CIf( + CodeExpression("PyErr_Occurred()"), + body=( + CExpressionStatement(CodeExpression(f"Py_DECREF({facts})")), + CReturn(CodeExpression("NULL")), + ), + ), + CExpressionStatement(CodeExpression(f"projection.result = {facts}")), + ) + raise ValueError(f"Native array operation {operation.value!r} has no absent projection") + def _array_actual_reader_functions(self, plan: ModulePlan) -> tuple: """Emit the record and reader for each array dummy a handle may reach. @@ -12459,6 +12587,15 @@ def _native_array_handle_kind_constant(handle: NativeArrayHandlePlan) -> str: return "PRIK_NATIVE_ARRAY_KIND_POINTER" return "PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE" + @staticmethod + def _native_array_descriptor_attribute_constant(handle: NativeArrayHandlePlan) -> str: + """Return the descriptor attribute the completed backend supplies.""" + return { + NativeArrayDescriptorAttribute.ALLOCATABLE: "PRIK_NATIVE_ARRAY_ATTRIBUTE_ALLOCATABLE", + NativeArrayDescriptorAttribute.POINTER: "PRIK_NATIVE_ARRAY_ATTRIBUTE_POINTER", + NativeArrayDescriptorAttribute.OTHER: "PRIK_NATIVE_ARRAY_ATTRIBUTE_OTHER", + }[handle.descriptor_attribute] + @staticmethod def _native_array_expected_element_size(plan: ArgumentTransferPlan | ResultPlan) -> str: """Return a fixed element-size check or zero for runtime-width strings.""" @@ -12486,7 +12623,8 @@ def _native_array_capsule_new_expression( ) return ( "prik_native_array_backend_capsule_new(" - f"{self._native_array_handle_kind_constant(handle)}, {handle.array.rank}, " + f"{self._native_array_handle_kind_constant(handle)}, " + f"{self._native_array_descriptor_attribute_constant(handle)}, {handle.array.rank}, " f"(uint32_t)sizeof(CFI_CDESC_T({handle.array.rank})), {cfi_type}, {element_size}, {descriptor}, " f"prik_native_array_owned_with_descriptor, {self._native_array_capsule_release_name(plan)})" ) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 7e40b6018..13742326e 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -47,6 +47,7 @@ ModuleObjectAccessMechanism, CharacterLocalRelease, NativeArrayDescriptorKind, + NativeArrayDescriptorAttribute, NativeArrayDescriptorInterop, NativeArrayDefaultConstruction, NativeArrayOperation, @@ -2570,7 +2571,7 @@ def _module_native_array_shape_operation(self, plan: ModuleVariablePlan) -> Fort def _module_native_array_descriptor_operation(self, plan: ModuleVariablePlan) -> FortranFunction | None: """Expose current module descriptor state through the selected mechanism.""" if self._uses_module_descriptor_backend(plan): - return self._module_allocatable_descriptor_callback_operation( + return self._module_descriptor_callback_operation( plan, NativeArrayOperation.DESCRIPTOR, ) @@ -2580,9 +2581,8 @@ def _module_native_array_descriptor_operation(self, plan: ModuleVariablePlan) -> def _uses_module_descriptor_backend(plan: ModuleVariablePlan) -> bool: """Return whether a handle reaches its descriptor through a consumer. - A module array hands its variable to a consumer rather than filling a - record supplied from C, so the descriptor that crosses is always one - this compiler built. Both allocatable and pointer variables do this. + A module array hands a plan-selected descriptor projection to a + consumer rather than filling a record supplied from C. """ handle = plan.native_array_handle return bool( @@ -2594,14 +2594,31 @@ def _uses_module_descriptor_backend(plan: ModuleVariablePlan) -> bool: } ) - def _module_allocatable_descriptor_callback_operation( + def _module_descriptor_callback_operation( self, plan: ModuleVariablePlan, operation: NativeArrayOperation, ) -> FortranFunction: - """Pass the current allocatable descriptor to a C callback without copying.""" + """Run a C callback on the current module-array descriptor projection.""" name = self._module_native_array_operation_name(plan, operation) interface_name = self._module_descriptor_callback_interface_name(plan) + callback = FortranCall( + "callback", + (CodeExpression(self._native_variable_name(plan)), CodeExpression("context")), + ) + handle = plan.native_array_handle + if handle is None: + raise ValueError(f"Module handle {plan.owner_path!r} has no descriptor policy") + invoke = ( + ( + FortranIf( + CodeExpression(self._module_native_array_presence_expression(plan)), + body=(callback,), + ), + ) + if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER + else (callback,) + ) return FortranFunction( name=name, parameters=( @@ -2615,10 +2632,7 @@ def _module_allocatable_descriptor_callback_operation( "c_f_procpointer", (CodeExpression("callback_address"), CodeExpression("callback")), ), - FortranCall( - "callback", - (CodeExpression(self._native_variable_name(plan)), CodeExpression("context")), - ), + *invoke, ), is_subroutine=True, ) @@ -2736,35 +2750,20 @@ def _module_descriptor_consumer_value_declaration( plan: ModuleVariablePlan, rank: int, ) -> tuple[str, tuple[str, ...]]: - """Return the type and attributes of one descriptor-consumer value dummy. - - The dummy is always ``allocatable`` so that the descriptor it receives - describes the module variable itself. An assumed-shape dummy would - renumber the bounds from zero, losing a declared lower bound, and GCC - rejects an assumed-shape character one outright. - - A character array that declares its own width is the exception, and has - to be. An interoperable allocatable or pointer character dummy must - declare deferred length, and argument association requires the actual to - declare deferred length exactly when the dummy does -- so for an actual - whose width is fixed, no allocatable dummy exists that it may be - associated with. Such an array is therefore taken by an assumed-shape - assumed-length dummy, which costs it the declared lower bound and leaves - allocation state out of reach, and is the only form that can receive it. - - The runtime never reaches this operation while the array is - unallocated: ``AllocatableArray.to_numpy`` and ``shape`` both return - early on ``allocated``. + """Return the plan-selected descriptor callback dummy. + + Allocatable and pointer descriptors preserve their entity semantics. + An ``other`` descriptor is the ordinary assumed-shape projection used + for fixed-width character storage; the callback is entered only while + that storage is present. """ dimension = self._array_dimension_attribute(rank) handle = plan.native_array_handle - attribute = ( - "pointer" - if handle is not None and handle.descriptor_kind is NativeArrayDescriptorKind.POINTER - else "allocatable" - ) - if plan.datatype_family is DatatypeFamily.STRING and plan.character_length is not None: + if handle is None: + raise ValueError(f"Module handle {plan.owner_path!r} has no descriptor policy") + if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER: return "character(kind=c_char, len=*)", (dimension, "intent(inout)") + attribute = handle.descriptor_attribute.value return self._module_native_array_element_type(plan), (attribute, dimension, "intent(inout)") def _module_native_array_operation_name(self, plan: ModuleVariablePlan, operation) -> str: @@ -7111,6 +7110,31 @@ def _native_handle_field_descriptor_procedure(self, owner, field) -> FortranFunc """Build the descriptor-export procedure for one native-array-handle field.""" name = self._native_handle_field_bridge_name(owner, field, NativeArrayOperation.DESCRIPTOR) interface = self._native_handle_field_callback_interface_name(owner, field) + callback = FortranCall( + "callback", + ( + CodeExpression(self._native_handle_field_expression(owner, field)), + CodeExpression("context"), + ), + ) + handle = field.native_array_handle + if handle is None: + raise ValueError(f"Native handle field {field.owner_path!r} has no descriptor policy") + invoke = ( + ( + FortranIf( + CodeExpression( + self._native_handle_field_presence( + field, + self._native_handle_field_expression(owner, field), + ) + ), + body=(callback,), + ), + ) + if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER + else (callback,) + ) return FortranFunction( name=name, parameters=( @@ -7129,13 +7153,7 @@ def _native_handle_field_descriptor_procedure(self, owner, field) -> FortranFunc "c_f_procpointer", (CodeExpression("callback_address"), CodeExpression("callback")), ), - FortranCall( - "callback", - ( - CodeExpression(self._native_handle_field_expression(owner, field)), - CodeExpression("context"), - ), - ), + *invoke, ), is_subroutine=True, ) @@ -8258,12 +8276,20 @@ def _native_handle_callback_interface( handle = field.native_array_handle if handle is None or handle.array.rank is None: raise ValueError(f"Native handle field {field.owner_path!r} has no callback rank") - attribute = "allocatable" if handle.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE else "pointer" - element_type = ( - "character(kind=c_char, len=:)" - if field.string_element - else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_fortran_type - ) + if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER: + element_type = "character(kind=c_char, len=*)" + attributes = (self._array_dimension_attribute(handle.array.rank), "intent(inout)") + else: + element_type = ( + "character(kind=c_char, len=:)" + if field.string_element + else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_fortran_type + ) + attributes = ( + handle.descriptor_attribute.value, + self._array_dimension_attribute(handle.array.rank), + "intent(inout)", + ) imports = (self._iso_symbol(field.semantic_type_name), "c_ptr") return FortranInterfaceProcedure( name=name, @@ -8272,12 +8298,10 @@ def _native_handle_callback_interface( FortranParameter( "value", element_type, - # intent(inout), so a callee reached through this descriptor - # can change the field's allocation and have the compiler - # copy that back. intent(in) leaves the copy-back - # unspecified, which happens to work on the compilers tested - # but is not something the standard obliges them to do. - (attribute, self._array_dimension_attribute(handle.array.rank), "intent(inout)"), + # A true descriptor attribute carries allocation or + # association changes. An ordinary projection writes only + # through the storage already present. + attributes, ), FortranParameter("context", "type(c_ptr)", ("value",)), ), diff --git a/prik/planning/models.py b/prik/planning/models.py index 71a5eead8..a9de7f462 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -70,6 +70,7 @@ ModuleGetterAction, ModuleObjectAccessMechanism, NativeArrayDescriptorInterop, + NativeArrayDescriptorAttribute, CharacterLocalRelease, NativeArrayDescriptorKind, NativeArrayDescriptorOwnership, @@ -569,6 +570,7 @@ class NativeArrayHandlePlan(StageRecord): """ descriptor_kind: NativeArrayDescriptorKind + descriptor_attribute: NativeArrayDescriptorAttribute handle_kind: NativeArrayHandleKind origin: NativeArrayHandleOrigin owner: OwnershipOwner diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 2a874807f..02902d32d 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -2238,6 +2238,7 @@ def _native_array_handle_plan( raise ValueError(f"Native array handle {owner_path!r} is missing its array data facet") return NativeArrayHandlePlan( descriptor_kind=policy.descriptor_kind, + descriptor_attribute=policy.descriptor_attribute, handle_kind=policy.handle_kind, origin=policy.origin, owner=policy.owner, diff --git a/prik/policy/completion.py b/prik/policy/completion.py index ffa4a1757..396e562ac 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -21,6 +21,7 @@ ObjectKind, SetterAction, default_ownership_policy, + declared_character_length, is_character_descriptor_update, ownership_context_for_argument, ) @@ -1374,6 +1375,15 @@ def _native_array_handle_policy( ) handle_kind = _native_array_handle_kind(descriptor_kind, context, optional_absent=optional_absent) blocker = _native_array_handle_blocker(descriptor_kind, handle_kind, decision) + if ( + blocker is None + and context.is_argument + and semantic_type.name == "String" + and declared_character_length(semantic_type.metadata) is not None + ): + blocker = ( + "fixed-width character allocatable and pointer array arguments have no interoperable descriptor interface" + ) descriptor_inquiries = _native_array_descriptor_inquiries(descriptor_kind, semantic_type) if not descriptor_inquiries and handle_kind not in { "borrowed_module_descriptor", @@ -1392,6 +1402,11 @@ def _native_array_handle_policy( default_construction = _native_array_default_construction(handle_kind, context, semantic_type) return NativeArrayHandlePolicy( descriptor_kind=descriptor_kind, + descriptor_attribute=_native_array_descriptor_attribute( + descriptor_kind, + handle_kind, + semantic_type, + ), handle_kind=handle_kind, origin=_native_array_handle_origin(context), owner=_native_array_handle_owner(handle_kind), @@ -1727,6 +1742,25 @@ def _native_array_descriptor_interop_requirement( return "none" +def _native_array_descriptor_attribute( + descriptor_kind: str, + handle_kind: str, + semantic_type: models.SemanticType, +) -> str: + """Complete the attribute a handle's descriptor callback can supply. + + A fixed-width character module variable or field cannot associate with an + interoperable allocatable or pointer character dummy, because those dummies + have to declare deferred length. Its callback therefore supplies an + ordinary assumed-shape descriptor. The native entity remains allocatable + or pointer; only the callback projection has the ``other`` attribute. + """ + fixed_character = semantic_type.name == "String" and declared_character_length(semantic_type.metadata) is not None + if fixed_character and handle_kind in {"borrowed_module_descriptor", "borrowed_field_descriptor"}: + return "other" + return descriptor_kind + + def _native_array_descriptor_inquiries( descriptor_kind: str, semantic_type: models.SemanticType, diff --git a/prik/policy/construction.py b/prik/policy/construction.py index a5245b39b..a584630e9 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -130,6 +130,7 @@ ClassSurfacePolicy, CharacterLocalPolicy, CharacterLocalRelease, + NativeArrayDescriptorAttribute, NativeArrayDescriptorKind, NativeArrayHandleKind, NativeDescriptorHandoffABI, @@ -6300,6 +6301,12 @@ def _native_array_handle_wrapper_policy( owner_path, "descriptor kind", ), + descriptor_attribute=_native_array_enum( + NativeArrayDescriptorAttribute, + completed.descriptor_attribute, + owner_path, + "descriptor attribute", + ), handle_kind=handle_kind, origin=_native_array_enum(NativeArrayHandleOrigin, completed.origin, owner_path, "origin"), owner=_native_array_enum(OwnershipOwner, completed.owner, owner_path, "owner"), diff --git a/prik/policy/models.py b/prik/policy/models.py index 1add7be85..fe4cfae05 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -770,6 +770,14 @@ class NativeArrayDescriptorKind(str, Enum): POINTER = "pointer" +class NativeArrayDescriptorAttribute(str, Enum): + """Attribute carried by the descriptor a handle backend supplies.""" + + ALLOCATABLE = "allocatable" + POINTER = "pointer" + OTHER = "other" + + class CharacterLocalRelease(str, Enum): """Completed release responsibility for one adapter-local character value. @@ -1138,6 +1146,7 @@ class NativeArrayHandleWrapperPolicy: """Typed wrapper-facing projection of completed native handle policy.""" descriptor_kind: NativeArrayDescriptorKind + descriptor_attribute: NativeArrayDescriptorAttribute handle_kind: NativeArrayHandleKind origin: NativeArrayHandleOrigin owner: OwnershipOwner diff --git a/prik/policy/native_array_handles.py b/prik/policy/native_array_handles.py index 4e52e9028..cba8c9465 100644 --- a/prik/policy/native_array_handles.py +++ b/prik/policy/native_array_handles.py @@ -37,6 +37,7 @@ class NativeArrayHandlePolicy: """Completed post-IR policy for a native allocatable or pointer array handle.""" descriptor_kind: str + descriptor_attribute: str handle_kind: str origin: str owner: str @@ -388,6 +389,7 @@ def _variable_native_array_policy( mark_native_array_handle(example_type, "pointer") example_policy = NativeArrayHandlePolicy( descriptor_kind="pointer", + descriptor_attribute="pointer", handle_kind="pointer", origin="module", owner="native", diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 137e8a5bb..3c50c6981 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -48,6 +48,9 @@ #define PRIK_NATIVE_ARRAY_BACKEND_CAPSULE_PREFIX "prik.native_array_backend.v1" #define PRIK_NATIVE_ARRAY_KIND_ALLOCATABLE 1u #define PRIK_NATIVE_ARRAY_KIND_POINTER 2u +#define PRIK_NATIVE_ARRAY_ATTRIBUTE_ALLOCATABLE 1u +#define PRIK_NATIVE_ARRAY_ATTRIBUTE_POINTER 2u +#define PRIK_NATIVE_ARRAY_ATTRIBUTE_OTHER 3u #if defined(_MSC_VER) #define PRIK_NO_INLINE __declspec(noinline) @@ -89,8 +92,9 @@ typedef void (*prik_native_array_descriptor_fn)(void *descriptor, void *context) /* * Enter the native entity and run `consumer` while its descriptor is live. - * `context` is whatever that entity needs to be reached; see the backend - * record below. + * An ordinary projection may return without calling `consumer` when its + * allocatable or pointer entity has no storage. `context` is whatever that + * entity needs to be reached; see the backend record below. */ typedef void (*prik_native_array_with_descriptor_fn)( void *context, @@ -120,26 +124,28 @@ typedef struct { * and is resolved once when the handle is built, so reaching the entity costs * one indirect call instead of a Python attribute lookup per operation. * - * A borrowed backend enters Fortran, which builds the descriptor for the call - * and copies back what the consumer wrote; the descriptor is gone when the - * consumer returns and must never be retained. An owned backend hands over the - * persistent storage it allocated, which stays valid for the handle's life. - * Consumers cannot tell the two apart, and must not try to. + * A borrowed backend enters Fortran, which supplies the plan-selected + * descriptor for the call; the descriptor is gone when the consumer returns + * and must never be retained. An owned backend hands over the persistent + * storage it allocated, which stays valid for the handle's life. Consumers + * cannot tell the two ownership forms apart, and must not try to. * * The metadata refuses an incompatible producer before any descriptor is * interpreted. The record's own layout is attested by the capsule name, so * nothing here restates it; what remains is what the name cannot know: * - descriptor_size attests the producer's CFI_CDESC_T(rank) layout, which * neither this record nor the descriptor itself can be read to establish; - * - descriptor_kind, rank, cfi_type and element_size are what a reader - * compares against the dummy it is filling, and reporting them here means - * a mismatch is refused without entering Fortran at all. + * - descriptor_kind identifies the native entity while descriptor_attribute + * identifies what `with_descriptor` supplies. Together with rank, cfi_type + * and element_size, they let a reader refuse a mismatch without entering + * Fortran at all. * element_size is 0 when the element width is only known at run time, as for * a deferred-length character array; such a reader takes it from the live * descriptor's elem_len instead. */ typedef struct { uint32_t descriptor_kind; + uint32_t descriptor_attribute; uint32_t rank; uint32_t descriptor_size; int32_t cfi_type; @@ -177,6 +183,7 @@ static inline uint64_t prik_native_array_backend_layout_tag(void) size_t width; } layout[] = { PRIK_NATIVE_ARRAY_BACKEND_FIELD(descriptor_kind), + PRIK_NATIVE_ARRAY_BACKEND_FIELD(descriptor_attribute), PRIK_NATIVE_ARRAY_BACKEND_FIELD(rank), PRIK_NATIVE_ARRAY_BACKEND_FIELD(descriptor_size), PRIK_NATIVE_ARRAY_BACKEND_FIELD(cfi_type), @@ -296,6 +303,7 @@ static inline void prik_native_array_backend_capsule_destructor(PyObject *capsul */ static inline PyObject *prik_native_array_backend_capsule_new( uint32_t descriptor_kind, + uint32_t descriptor_attribute, uint32_t rank, uint32_t descriptor_size, int cfi_type, @@ -312,6 +320,12 @@ static inline PyObject *prik_native_array_backend_capsule_new( PyErr_SetString(PyExc_ValueError, "invalid prik native array descriptor kind"); return NULL; } + if (descriptor_attribute != PRIK_NATIVE_ARRAY_ATTRIBUTE_ALLOCATABLE + && descriptor_attribute != PRIK_NATIVE_ARRAY_ATTRIBUTE_POINTER + && descriptor_attribute != PRIK_NATIVE_ARRAY_ATTRIBUTE_OTHER) { + PyErr_SetString(PyExc_ValueError, "invalid prik native array descriptor attribute"); + return NULL; + } if (with_descriptor == NULL) { PyErr_SetString(PyExc_ValueError, "prik native array backend needs a descriptor entry point"); return NULL; @@ -326,6 +340,7 @@ static inline PyObject *prik_native_array_backend_capsule_new( return NULL; } backend->descriptor_kind = descriptor_kind; + backend->descriptor_attribute = descriptor_attribute; backend->rank = rank; backend->descriptor_size = descriptor_size; backend->cfi_type = (int32_t)cfi_type; @@ -386,6 +401,7 @@ static inline prik_native_array_backend *prik_native_array_backend_for_descripto size_t expected_element_size) { prik_native_array_backend *backend; + uint32_t expected_descriptor_attribute; backend = prik_native_array_backend_from_capsule(capsule); if (backend == NULL) { @@ -395,6 +411,15 @@ static inline prik_native_array_backend *prik_native_array_backend_for_descripto PyErr_SetString(PyExc_TypeError, "incompatible Fortran descriptor storage size"); return NULL; } + expected_descriptor_attribute = expected_descriptor_kind == PRIK_NATIVE_ARRAY_KIND_POINTER + ? PRIK_NATIVE_ARRAY_ATTRIBUTE_POINTER + : PRIK_NATIVE_ARRAY_ATTRIBUTE_ALLOCATABLE; + if (backend->descriptor_attribute != expected_descriptor_attribute) { + PyErr_SetString( + PyExc_TypeError, + "native array handle does not expose the descriptor attribute required by the dummy argument"); + return NULL; + } if (backend->descriptor_kind != expected_descriptor_kind || backend->rank != expected_rank || backend->cfi_type != expected_cfi_type || (expected_element_size != 0 && backend->element_size != expected_element_size)) { diff --git a/tests/fortran/_support/ownership_policy.py b/tests/fortran/_support/ownership_policy.py index 39752a3cb..c87b6c8ce 100644 --- a/tests/fortran/_support/ownership_policy.py +++ b/tests/fortran/_support/ownership_policy.py @@ -122,6 +122,7 @@ def _hidden_output_context(**kwargs) -> OwnershipContext: def _native_array_policy( *, descriptor_kind: str = "allocatable", + descriptor_attribute: str | None = None, handle_kind: str = "borrowed_module_descriptor", to_numpy: str = "borrowed_view", descriptor_interop: str = "none", @@ -132,6 +133,7 @@ def _native_array_policy( ) -> NativeArrayHandlePolicy: return NativeArrayHandlePolicy( descriptor_kind=descriptor_kind, + descriptor_attribute=descriptor_attribute or descriptor_kind, handle_kind=handle_kind, origin="module_variable", owner="native", diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 63bb07047..5b240a95e 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -373,6 +373,8 @@ def test_plain_allocatable_module_array_exposes_current_live_view( real(real64), allocatable, target :: tgt_a(:) real(real64), allocatable, target :: defaulted(:) character(len=5), allocatable, target :: fixed_words(:) + character(len=5), allocatable :: missing_words(:) + character(len=5), pointer :: missing_pointer(:) => null() character(len=:), allocatable, target :: deferred_words(:) contains function lower_bound_of(x) result(bound) @@ -450,6 +452,38 @@ def test_module_allocatable_reports_its_real_lower_bound_with_or_without_target( assert module.deferred_word_bound_and_width(module.deferred_words) == np.int32(506) +def test_fixed_character_projection_reports_absence(tmp_path: Path): + module = _build_text_and_import( + LOWER_BOUND_SOURCE, + "falloc_lower_bounds_f90.f90", + tmp_path, + { + "bind_c_falloc_lower_bounds_f90_wrapper.f90", + "falloc_lower_bounds_f90_wrapper.c", + "falloc_lower_bounds_f90_wrapper.h", + }, + ) + module.setup() + + missing = module.missing_words + assert missing.allocated is False + assert missing.shape is None + assert missing.to_numpy() is None + assert missing.dtype == np.dtype("S5") + + missing_pointer = module.missing_pointer + assert missing_pointer.associated is False + assert missing_pointer.shape is None + missing_pointer.associate(missing_pointer) + assert missing_pointer.associated is False + + fixed = module.fixed_words + assert fixed.allocated is True + assert fixed.to_numpy().tolist() == [b"aaaaa"] * 4 + with pytest.raises(TypeError, match="descriptor attribute required by the dummy"): + module.deferred_word_bound_and_width(fixed) + + BORROWED_DESCRIPTOR_SOURCE = """\ module fallocatable_borrowed_f90 implicit none diff --git a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py index e2140e7da..dfded60d7 100644 --- a/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py +++ b/tests/fortran/arrays/end_to_end/test_native_handle_array_forms.py @@ -151,6 +151,9 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): type :: holder real(8), allocatable :: field_allocatable_values_with_long_name(:) logical(4), allocatable :: field_flags(:) + character(len=4), allocatable :: field_fixed_words(:) + character(len=4), allocatable :: field_missing_words(:) + character(len=4), pointer :: field_missing_pointer(:) => null() real(8), pointer :: field_ptr(:) => null() character(len=:), pointer :: field_words(:) => null() end type holder @@ -200,6 +203,7 @@ def test_generated_handles_cover_supported_ordinary_array_forms(tmp_path: Path): allocate(parent%field_allocatable_values_with_long_name(3)) parent%field_allocatable_values_with_long_name = 5.0_8 allocate(parent%field_flags(3)); parent%field_flags = [.true., .false., .true.] + allocate(parent%field_fixed_words(2)); parent%field_fixed_words = ['abcd', 'efgh'] parent%field_ptr => store(2:6:2) allocate(character(len=5) :: parent%field_words(2)) parent%field_words = ['alpha', 'beta '] @@ -430,6 +434,9 @@ def test_a_derived_type_field_view_retains_its_parent(descriptor_matrix): field = parent.field_allocatable_values_with_long_name pointer_field = parent.field_ptr logical_field = parent.field_flags + fixed_words_field = parent.field_fixed_words + missing_words_field = parent.field_missing_words + missing_pointer_field = parent.field_missing_pointer words_field = parent.field_words assert field.shape == (3,) @@ -438,6 +445,18 @@ def test_a_derived_type_field_view_retains_its_parent(descriptor_matrix): assert pointer_field.shape == (3,) assert logical_field.dtype == np.dtype(np.int32) np.testing.assert_array_equal(logical_field.to_numpy().astype(bool), [True, False, True]) + assert fixed_words_field.allocated is True + assert fixed_words_field.shape == (2,) + assert fixed_words_field.dtype == np.dtype("S4") + assert fixed_words_field.to_numpy().tolist() == [b"abcd", b"efgh"] + assert descriptor_matrix.word_width(fixed_words_field) == np.int32(4) + assert missing_words_field.allocated is False + assert missing_words_field.shape is None + assert missing_words_field.to_numpy() is None + assert missing_pointer_field.associated is False + assert missing_pointer_field.shape is None + missing_pointer_field.associate(missing_pointer_field) + assert missing_pointer_field.associated is False assert words_field.associated is True assert words_field.shape == (2,) assert words_field.dtype == np.dtype("S5") diff --git a/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py b/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py index 9ea1cb383..771edfbd1 100644 --- a/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py +++ b/tests/fortran/derived_types/codegen/test_derived_array_field_lowering.py @@ -112,3 +112,25 @@ def test_deferred_character_pointer_field_uses_only_legal_inquiry_entrypoints(): assert "result = associated(owner%words)" in shape assert "_words_consumer" not in source assert "character(kind=c_char, len=:), pointer, dimension(:), intent(inout)" not in source + + +def test_fixed_character_descriptor_fields_use_guarded_ordinary_projections(): + source = _bridge_source_for( + """ +module fixed_character_fields + implicit none + type :: box + character(len=5), allocatable :: words(:) + character(len=5), pointer :: aliases(:) => null() + end type box + type(box) :: plain_box +end module fixed_character_fields +""", + "fixed_character_fields", + ) + + assert source.count("character(kind=c_char, len=*), dimension(:), intent(inout) :: value") == 4 + assert "if (allocated(owner%words)) then" in source + assert "if (associated(owner%aliases)) then" in source + assert "if (allocated(native_plain_box%words)) then" in source + assert "if (associated(native_plain_box%aliases)) then" in source diff --git a/tests/fortran/infrastructure/runtime/test_native_support.py b/tests/fortran/infrastructure/runtime/test_native_support.py index 257b774fa..f1c78d128 100644 --- a/tests/fortran/infrastructure/runtime/test_native_support.py +++ b/tests/fortran/infrastructure/runtime/test_native_support.py @@ -42,6 +42,7 @@ def test_native_binding_support_is_header_only_and_exposes_the_small_prik_api(): BACKEND_RECORD = ( ("uint32_t", "descriptor_kind"), + ("uint32_t", "descriptor_attribute"), ("uint32_t", "rank"), ("uint32_t", "descriptor_size"), ("int32_t", "cfi_type"), @@ -113,6 +114,10 @@ def test_native_array_backend_capsule_exposes_one_entry_point_and_its_readers(): ): assert name in header + assert "invalid prik native array descriptor attribute" in header + assert "backend->descriptor_attribute != expected_descriptor_attribute" in header + assert "does not expose the descriptor attribute required by the dummy argument" in header + def test_native_array_backend_release_is_idempotent_and_never_frees_borrowed_storage(): """Owned storage is released once; borrowed storage is never released here. diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index b3e7200a5..9d97621c5 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -400,7 +400,8 @@ def test_generated_native_handle_artifacts_follow_one_typed_action_vocabulary(): assert "character(kind=c_char, len=:), allocatable, dimension(:) :: names" in bridge_source assert "result_owner_status = CFI_establish(result, NULL, CFI_attribute_pointer" in c_source assert ( - "PRIK_NATIVE_ARRAY_KIND_POINTER, 1, (uint32_t)sizeof(CFI_CDESC_T(1)), CFI_type_double, " + "PRIK_NATIVE_ARRAY_KIND_POINTER, PRIK_NATIVE_ARRAY_ATTRIBUTE_POINTER, 1, " + "(uint32_t)sizeof(CFI_CDESC_T(1)), CFI_type_double, " "sizeof(double), result" in c_source ) diff --git a/tests/fortran/modules/codegen/test_module_array_view_lowering.py b/tests/fortran/modules/codegen/test_module_array_view_lowering.py index a4bf09d7c..841c41f7c 100644 --- a/tests/fortran/modules/codegen/test_module_array_view_lowering.py +++ b/tests/fortran/modules/codegen/test_module_array_view_lowering.py @@ -183,27 +183,13 @@ def _character_bridge_source(): return FortranSourcePrinter().visit(FortranBridgeGenerator().visit(plan)) -def test_no_interoperable_character_dummy_is_allocatable_with_assumed_length(): - """An interoperable allocatable character dummy must declare deferred length. - - An allocatable or pointer character dummy of a BIND(C) procedure may not - have assumed length; and argument association requires the actual to have - deferred length exactly when the dummy does. So an array whose width is - fixed has no allocatable dummy it can be associated with, and is taken by - an assumed-shape one instead. - - GNU Fortran 13 rejects the combination; 11 and Intel's ifx accept it - silently, so nothing that compiles here would have caught it. - """ +def test_fixed_character_callbacks_use_an_ordinary_descriptor_projection(): + """The callback declaration matches the descriptor attribute policy selected upstream.""" source = _character_bridge_source() - offenders = [ - line.strip() - for line in source.splitlines() - if "character(" in line and "len=*" in line and ("allocatable" in line or "pointer" in line) - ] - - assert offenders == [] - # The fixed-width arrays keep their width and lose only the attribute. - assert "character(kind=c_char, len=*), dimension(:), intent(inout) :: value" in source - # A numeric array is unaffected: nothing stops its dummy being allocatable. - assert "allocatable" in source + + assert source.count("character(kind=c_char, len=*), dimension(:), intent(inout) :: value") == 2 + assert "character(kind=c_char, len=:), allocatable, dimension(:), intent(inout) :: value" in source + assert "real(c_double), allocatable, dimension(:), intent(inout) :: value" in source + assert "if (allocated(native_fixed_alloc)) then" in source + assert "if (associated(native_fixed_ptr)) then" in source + assert "call callback(native_deferred_alloc, context)" in source diff --git a/tests/fortran/modules/policy/test_module_variable_policy.py b/tests/fortran/modules/policy/test_module_variable_policy.py index 4145e2fd4..9ae5616f3 100644 --- a/tests/fortran/modules/policy/test_module_variable_policy.py +++ b/tests/fortran/modules/policy/test_module_variable_policy.py @@ -9,7 +9,12 @@ from prik.semantics.fortran2ir import fortran_project_to_semantic_modules from prik.semantics.models import RESOLVED_MODULE_VARIABLE_POLICY_METADATA from prik.policy.ownership import AssignmentMode -from prik.policy.models import ModuleArrayAddressMechanism, ModuleGetterAction, ModuleVariablePolicy +from prik.policy.models import ( + ModuleArrayAddressMechanism, + ModuleGetterAction, + ModuleVariablePolicy, + NativeArrayDescriptorAttribute, +) def test_scalar_module_variable_policy_completes_access_and_storage_before_planning(): @@ -52,6 +57,35 @@ def test_scalar_module_variable_policy_completes_access_and_storage_before_plann assert policies["selected_scale"].native_assignment is AssignmentMode.NONE +def test_fixed_character_handles_publish_only_the_descriptor_attribute_their_callback_can_supply(): + parsed = parse_fortran_project( + { + "character_arrays.f90": """ +module character_arrays + character(len=5), allocatable :: fixed_alloc(:) + character(len=5), pointer :: fixed_pointer(:) => null() + character(len=:), allocatable :: deferred_alloc(:) + real(8), allocatable :: numbers(:) +end module character_arrays +""" + } + ) + modules = fortran_project_to_semantic_modules(parsed) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="character_arrays") + complete_semantic_policies(module) + + handles = { + variable.name: variable.metadata[RESOLVED_MODULE_VARIABLE_POLICY_METADATA].native_array_handle + for variable in module.variables + } + + assert handles["fixed_alloc"].descriptor_attribute is NativeArrayDescriptorAttribute.OTHER + assert handles["fixed_pointer"].descriptor_attribute is NativeArrayDescriptorAttribute.OTHER + assert handles["deferred_alloc"].descriptor_attribute is NativeArrayDescriptorAttribute.ALLOCATABLE + assert handles["numbers"].descriptor_attribute is NativeArrayDescriptorAttribute.ALLOCATABLE + + def test_symbolic_source_parameters_use_native_getters_while_literals_stay_in_binding(): parsed = parse_fortran_project( { diff --git a/tests/fortran/strings/policy/test_string_wrapper_policy.py b/tests/fortran/strings/policy/test_string_wrapper_policy.py index 3a2bbbe49..eb2f46d05 100644 --- a/tests/fortran/strings/policy/test_string_wrapper_policy.py +++ b/tests/fortran/strings/policy/test_string_wrapper_policy.py @@ -325,6 +325,27 @@ def test_fixed_length_allocatable_string_update_takes_the_descriptor_result_lane assert policy.results[0].updates_argument is True +@pytest.mark.parametrize("attribute", ["allocatable", "pointer"]) +def test_fixed_length_character_array_descriptor_arguments_are_blocked(tmp_path: Path, attribute: str): + module = _semantic_module_from_text( + f""" +module fixed_array_descriptor + implicit none +contains + subroutine inspect(values) + character(len=5), {attribute}, intent(in) :: values(:) + end subroutine inspect +end module fixed_array_descriptor +""", + tmp_path, + module_name="fixed_array_descriptor", + ) + policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] + + assert policy.supported is False + assert any("no interoperable descriptor interface" in blocker for blocker in policy.blockers) + + def test_plain_fixed_length_string_update_keeps_copy_in_out_replacement(tmp_path: Path): """A dummy with no descriptor attribute keeps the caller-buffer replacement. From 22a2c810b859701484de949c0b09e32622f5d342 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 6 Sep 2026 20:25:39 +0100 Subject: [PATCH 47/47] codex: Take a default-kind LOGICAL array at its real width in the LAPACK example A default `LOGICAL` is four bytes wide under GFortran, and this branch aliases a logical array element for element instead of copying it through a one-byte representation. So `BWORK` and `SELECT` cross as `numpy.int32`, which is the dtype SciPy and f2py have always required here -- their calls in these very tests already spell `selection.astype(np.int32)`. Only the PRIK calls still passed a `numpy.bool_` buffer, and they are what CI refused. The inventory recorded this as a PRIK ABI adapter for `dtrsen` and `dtgsen`. No adapter is generated any more, for those or for anything else, so the entry is retired rather than reworded. Verified outside LAPACK, on a `logical, intent(inout) :: flags(*)` dummy and a `logical, intent(in) :: selection(*)` one: the binding reports `ndarray[int32]` and refuses `numpy.bool_`, GFortran writes 1 for `.true.` into that storage, and reads a caller-supplied 1 back as `.true.` -- one selected element out of `[False, True]`, which is the count `dtrsen` is asserted to return. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cx8Mtgc7Sw7BftrTmwP4Ce --- CHANGELOG.md | 5 ++++- examples/fortran/lapack/routine_inventory.py | 8 ++++---- examples/fortran/lapack/tests/test_eigen_generalized.py | 8 ++++++-- examples/fortran/lapack/tests/test_eigen_nonsymmetric.py | 8 ++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ae34a7ff..7ded88fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,10 @@ release tags add a leading `v` to the package version. `standard_logicals=False` only when linking Intel objects compiled without that option. Wider logical arrays are exposed with their matching integer dtype, including caller-created allocatable and pointer handles; - `logical(c_bool)` remains `numpy.bool_`. + `logical(c_bool)` remains `numpy.bool_`. A wider logical array argument is + now aliased rather than converted, so it takes that integer dtype where a + one-byte `numpy.bool_` buffer was previously copied in and out. Logical + scalars are unaffected and stay Python `bool` in every kind. ## 0.4.3 — 2026-08-31 diff --git a/examples/fortran/lapack/routine_inventory.py b/examples/fortran/lapack/routine_inventory.py index fa5278c1d..4cd99e2a0 100644 --- a/examples/fortran/lapack/routine_inventory.py +++ b/examples/fortran/lapack/routine_inventory.py @@ -443,10 +443,10 @@ "dgges": "NumPy f2py 2.5.1 generates an incomplete selctg callback declaration from the unannotated source", } -PRIK_ABI_ADAPTERS = { - "dtgsen": "GFortran default-LOGICAL selection storage is four bytes per element while PRIK accepts a NumPy bool buffer", - "dtrsen": "GFortran default-LOGICAL selection storage is four bytes per element while PRIK accepts a NumPy bool buffer", -} +# PRIK aliases every array argument at the width its elements really occupy, so +# a default-LOGICAL selection or workspace array crosses as the matching integer +# dtype and no routine here needs a generated representation adapter. +PRIK_ABI_ADAPTERS: dict[str, str] = {} @dataclass(frozen=True) diff --git a/examples/fortran/lapack/tests/test_eigen_generalized.py b/examples/fortran/lapack/tests/test_eigen_generalized.py index 295a6b286..8237bcec3 100644 --- a/examples/fortran/lapack/tests/test_eigen_generalized.py +++ b/examples/fortran/lapack/tests/test_eigen_generalized.py @@ -51,7 +51,8 @@ def test_dgges_computes_generalized_real_schur_form(prik_lapack, scipy_lapack): np.int32(2), np.empty(64), np.int32(64), - np.zeros(2, dtype=np.bool_), + # BWORK is a default-kind LOGICAL array, four bytes to an element. + np.zeros(2, dtype=np.int32), np.int32(0), ) scipy_a, scipy_b, scipy_sdim, scipy_ar, scipy_ai, scipy_beta, scipy_vsl, scipy_vsr, _work, scipy_info = ( @@ -342,8 +343,11 @@ def test_dtgexc_reorders_generalized_schur_blocks(prik_lapack, scipy_lapack, f2p def test_dtgsen_reorders_selected_generalized_eigenvalue(prik_lapack, scipy_lapack, f2py_lapack): a, b = _generalized_problem() identity = np.eye(2, dtype=np.float64, order="F") + # A default-kind Fortran LOGICAL is four bytes wide under gfortran, and an + # array of them is aliased element for element, so every binding here takes + # the matching integer dtype rather than a one-byte numpy.bool_ buffer. selection = np.array([False, True], dtype=np.bool_) - prik_selection = selection.copy() + prik_selection = selection.astype(np.int32) prik_a, f2py_a = a.copy(order="F"), a.copy(order="F") prik_b, f2py_b = b.copy(order="F"), b.copy(order="F") prik_q, f2py_q = identity.copy(order="F"), identity.copy(order="F") diff --git a/examples/fortran/lapack/tests/test_eigen_nonsymmetric.py b/examples/fortran/lapack/tests/test_eigen_nonsymmetric.py index 27f6b44ef..2b12e30df 100644 --- a/examples/fortran/lapack/tests/test_eigen_nonsymmetric.py +++ b/examples/fortran/lapack/tests/test_eigen_nonsymmetric.py @@ -72,7 +72,8 @@ def test_dgees_computes_real_schur_decomposition(prik_lapack, scipy_lapack): np.int32(2), np.empty(64), np.int32(64), - np.zeros(2, dtype=np.bool_), + # BWORK is a default-kind LOGICAL array, four bytes to an element. + np.zeros(2, dtype=np.int32), np.int32(0), ) scipy_t, scipy_sdim, scipy_wr, scipy_wi, scipy_vs, _work, scipy_info = scipy_lapack.dgees( @@ -202,8 +203,11 @@ def test_dtrsen_reorders_selected_schur_eigenvalue(prik_lapack, scipy_lapack, f2 prik_q, f2py_q = identity.copy(order="F"), identity.copy(order="F") prik_wr, prik_wi = np.empty(2), np.empty(2) f2py_wr, f2py_wi = np.empty(2), np.empty(2) + # A default-kind Fortran LOGICAL is four bytes wide under gfortran, and an + # array of them is aliased element for element, so every binding here takes + # the matching integer dtype rather than a one-byte numpy.bool_ buffer. selection = np.array([False, True], dtype=np.bool_) - prik_selection = selection.copy() + prik_selection = selection.astype(np.int32) prik_scalars = prik_lapack.dtrsen( "N",