From 9347ebc45bfcb396ecdea59b4fcfc4f9b93f6d0d Mon Sep 17 00:00:00 2001 From: said Date: Fri, 28 Aug 2026 12:15:04 +0100 Subject: [PATCH] Bind array arguments through one shared helper, in one translation unit Building a large project with optimizing compiler flags spent most of its time compiling the generated C binding. Every ordinary array argument emitted the whole sequence inline -- validate the ndarray, check each declared extent, take the data pointer, fold the trailing axes, and on the other branch build an expected-shape tuple for the native-handle handoff. The logic is identical at every site; only the dtype, rank, layout, extents, and names differ. The reference BLAS repeated it 200 times, so the compiler optimized the same shape 200 times over. A wrapper now passes those differing values to one prik_bind_array helper and receives the pointer and extents back, so the logic is optimized once. Plans that also carry runtime rank, itemsize, stride, upper-bound, or dense-actual roles keep their own inline sequence, as do strings, front-flattened storage, and any argument whose direct and handoff routes disagree on an axis. Bindings are also no longer split across translation units. A project of 128 or more procedures previously generated _wrapper_001.c and siblings so those units could compile concurrently. Splitting never reduced the work: each unit re-parsed Python.h and the NumPy headers, which measured as a 14% rise in total compiler time, repaid only when cores sat idle. A project's own sources rarely leave them idle, and on a four-core machine the split lost outright. One file also leaves one file to read when inspecting generated output. For the 155-source reference BLAS with -O3 -march=native, the binding emits a third less code and its compile drops from 5.77s to 4.16s. A 150-procedure project that did split goes from six units and 5.87s to one unit and 2.54s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QDeRuWHDwVLK8aeQARe3iT --- CHANGELOG.md | 19 ++ prik/codegen/c/binding.py | 311 ++++++++++-------- prik/pipeline/wrapper.py | 8 +- prik/runtime/native_support/prik_binding.h | 140 ++++++++ .../test_exact_native_scalar_lowering.py | 5 +- .../codegen/test_array_buffer_lowering.py | 21 +- .../test_dense_array_shape_lowering.py | 23 +- .../pipeline/test_wrapper_generator.py | 41 +-- .../codegen/test_native_handle_planning.py | 2 +- 9 files changed, 377 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a51eb53..55e33c0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ release tags add a leading `v` to the package version. ## Unreleased +- Reduced clean-build time for large projects under optimizing compiler flags. + Generated bindings now bind each ordinary array argument through one shared + `prik_bind_array` helper instead of emitting the whole validate, extract, and + native-handle sequence at every array argument of every wrapper. A wrapper + carries one call and a small table of required extents in place of the + sequence, so the compiler optimizes the binding logic once rather than once + per argument per wrapper. Building the 155-source reference BLAS with + `-O3 -march=native` emits about a third less binding code and compiles it + about 1.4x faster. + +- A binding is always one generated C file. Large procedure-only projects were + previously split across `_wrapper_001.c` and siblings so those units + could compile concurrently; every project now generates only + `_wrapper.c`. Splitting raised total compiler work — each unit + re-parsed `Python.h` and the NumPy headers — and paid off only where cores + were idle, which a project's own sources rarely leave. Removing it lowers + total build work and leaves one file to read when inspecting generated + output. + ## 0.4.1 — 2026-08-27 - Fixed README links and the logo for PyPI rendering. Documentation links now diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 002b05a39..309f2a4a2 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -11,7 +11,6 @@ from collections.abc import Mapping from dataclasses import dataclass, replace -import math import re from prik.utilities.declaration_expressions import declaration_extent_uses_power, render_declaration_extent @@ -186,15 +185,13 @@ class _COverloadDispatch: class CBindingGenerator(ClassVisitor): """Build the CPython C half of a wrapper from validated binding-plan views. - Use :meth:`require_supported` followed by :meth:`visit` for a single - C module/header pair, or :meth:`binding_modules` when a plan qualifies - for independent wrapper shards. The returned nodes are normally consumed + Use :meth:`require_supported` followed by :meth:`visit` for the + C module/header pair. The returned nodes are normally consumed by the C source printer. Completed semantic policy remains outside this class; unsupported plan actions fail instead of being reinterpreted here. """ - _SHARD_MIN_FUNCTIONS = 128 - _SHARD_TARGET_FUNCTIONS = 32 + _RUNTIME_EXTENT_MARKERS = frozenset({":", "::Strided", "Flat"}) _SHARED_OUTPUT_CLEANUP_MIN_RESULTS = 4 def require_supported(self, plan: ModulePlan) -> None: @@ -396,17 +393,14 @@ def _support_procedure_c_type(value: NativeEntrypointABIValuePlan) -> str: return f"{prefix}{base}{' *' * value.pointer_depth}" def binding_modules(self, plan: ModulePlan) -> tuple[CModule, ...]: - """Build one implementation module or independently compilable wrapper shards. + """Build the binding translation units for one plan. - Use this public entrypoint when compilation can benefit from sharding. - Plans with coupled runtime support intentionally return one module so - helper state and declarations remain shared. + A binding is one implementation module; a plan that adapts colliding + native symbols adds the separate forwarder unit those adapters need. """ module = self.binding_module(plan) - function_groups = self._binding_function_shards(plan) - modules = (module,) if not function_groups else self._sharded_binding_modules(plan, module, function_groups) adapters = self._collision_adapter_module(plan) - return (*modules, adapters) if adapters is not None else modules + return (module, adapters) if adapters is not None else (module,) def _collision_adapter_module(self, plan: ModulePlan) -> CModule | None: """Build the translation unit that forwards collision-adapted symbols. @@ -465,108 +459,6 @@ def _collision_adapter_function(self, plan: FunctionPlan) -> CFunction: storage=COLLISION_ADAPTER_STORAGE, ) - def _sharded_binding_modules( - self, - plan: ModulePlan, - module: CModule, - function_groups: tuple[tuple[FunctionPlan, ...], ...], - ) -> tuple[CModule, ...]: - """Move independently planned wrappers into balanced worker units.""" - wrapper_names = {self._binding_function_name(function) for function in self._functions(plan)} - wrappers = self._external_binding_wrappers(module, wrapper_names) - main_module = replace( - module, - functions=tuple(function for function in module.functions if function.name not in wrapper_names), - ) - worker_defines = tuple( - definition for definition in module.defines if definition.name != "PRIK_BINDING_IMPORT_ARRAY" - ) - workers = self._binding_worker_modules(module, function_groups, wrappers, worker_defines) - return (main_module, *workers) - - @staticmethod - def _external_binding_wrappers(module: CModule, wrapper_names: set[str]) -> dict[str, CFunction]: - """Return externally linked copies of the selected wrapper functions.""" - return { - function.name: replace(function, storage=None) - for function in module.functions - if function.name in wrapper_names - } - - def _binding_worker_modules( - self, - module: CModule, - function_groups: tuple[tuple[FunctionPlan, ...], ...], - wrappers: dict[str, CFunction], - worker_defines: tuple[CMacroDefinition, ...], - ) -> tuple[CModule, ...]: - """Assemble the independently compilable wrapper worker units.""" - return tuple( - CModule( - name=f"{module.name}_{index:03d}", - defines=worker_defines, - includes=module.includes, - declarations=tuple(self._entrypoint_prototype(function) for function in group), - functions=tuple(wrappers[self._binding_function_name(function)] for function in group), - ) - for index, group in enumerate(function_groups, start=1) - ) - - def _binding_function_shards(self, plan: ModulePlan) -> tuple[tuple[FunctionPlan, ...], ...]: - """Return balanced groups when wrappers are safe to compile independently.""" - functions = self._functions(plan) - if not self._can_shard_binding_functions(plan, functions): - return () - shard_count = max(2, math.ceil(len(functions) / self._SHARD_TARGET_FUNCTIONS)) - base_size, larger_groups = divmod(len(functions), shard_count) - groups = [] - offset = 0 - for index in range(shard_count): - size = base_size + (index < larger_groups) - groups.append(functions[offset : offset + size]) - offset += size - return tuple(groups) - - def _can_shard_binding_functions( - self, - plan: ModulePlan, - functions: tuple[FunctionPlan, ...], - ) -> bool: - """Keep runtime-coupled surfaces in one binding translation unit.""" - if len(functions) < self._SHARD_MIN_FUNCTIONS: - return False - if not self._has_shardable_namespace(plan): - return False - if self._module_has_shard_runtime_state(plan): - return False - return not self._functions_use_native_array_handles(functions) - - @staticmethod - def _has_shardable_namespace(plan: ModulePlan) -> bool: - """Return whether one root procedure namespace owns the module.""" - if len(plan.namespaces) != 1: - return False - namespace = plan.namespaces[0] - runtime_surfaces = ( - namespace.python_path, - namespace.variables, - namespace.classes, - namespace.derived_types, - namespace.overloads, - ) - return not any(runtime_surfaces) - - def _module_has_shard_runtime_state(self, plan: ModulePlan) -> bool: - """Return whether wrappers call helpers that must share one unit.""" - return any( - ( - self._module_needs_allocator(plan), - self._module_uses_callbacks(plan), - self._module_uses_derived_calls(plan), - self._module_uses_extent_power(plan), - ) - ) - def _extent_expression_support_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: """Emit the integer-power helper only when a completed extent uses it.""" if not self._module_uses_extent_power(plan): @@ -642,31 +534,16 @@ def _array_uses_extent_power(array: ArrayHandoffPlan | None) -> bool: """Return whether one optional completed array shape contains power.""" return bool(array) and any(declaration_extent_uses_power(expression) for expression in array.shape) - @staticmethod - def _functions_use_native_array_handles(functions: tuple[FunctionPlan, ...]) -> bool: - """Return whether persistent descriptor helpers couple the wrappers.""" - arguments_use_handles = any( - argument.native_array_handle is not None for function in functions for argument in function.arguments - ) - results_use_handles = any( - result.native_array_handle is not None for function in functions for result in function.results - ) - return any((arguments_use_handles, results_use_handles)) - def binding_header(self, plan: ModulePlan) -> CHeader: """Build the C header that declares wrappers from the validated plan. - The header mirrors whether wrapper functions are externally linked for - sharding. It is paired with :meth:`binding_module` or - :meth:`binding_modules` output. + It is paired with :meth:`binding_module` or :meth:`binding_modules` + output. """ - external_wrappers = bool(self._binding_function_shards(plan)) return CHeader( guard=f"{plan.binding.owner_path.upper()}_WRAPPER_H", includes=(CInclude("Python.h"),), - prototypes=tuple( - self._binding_prototype(function, external=external_wrappers) for function in self._functions(plan) - ), + prototypes=tuple(self._binding_prototype(function) for function in self._functions(plan)), ) @staticmethod @@ -6863,6 +6740,10 @@ def _lower_argument_required_array_actual( *self._array_shape_checks(plan, context, array_object), *self._array_extraction_nodes(plan, names, array_object), ) + # Both routes are shared, so neither is repeated at every call site. + outlined = self._outlined_array_bind_nodes(plan, context, names) + 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"), @@ -6878,6 +6759,164 @@ def _lower_argument_required_array_actual( ), ) + def _outlined_array_bind_nodes( + self, + plan: ArgumentTransferPlan, + context: _CFunctionContext, + names: _CArgumentNames, + ) -> tuple[CDeclaration | CExpressionStatement, ...] | None: + """Return the shared binder call when the plan needs no extra ABI roles. + + A plan that also carries runtime rank, itemsize, stride, upper-bound, or + dense-actual roles still needs its own inline sequence; only the plain + pointer-and-extents shape is routed through prik_bind_array. + """ + fixed = self._outlined_array_bind_fixed_extents(plan, context) + 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) + minimum_rank, maximum_rank = self._array_rank_bounds(array) + selectors = ", ".join( + str(value) + for value in ( + numpy_type, + rank, + minimum_rank, + maximum_rank, + self._array_layout_selector(array), + 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 + # references another argument is assigned here, at the binding site. + return ( + CDeclaration(f"{prefix}_bind_fixed[{rank}]", "long long"), + CDeclaration(f"{prefix}_bind_extents[{rank}]", "int64_t"), + *( + 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" + ) + ), + *( + CExpressionStatement(CodeExpression(f"{names.extent_names[axis]} = {prefix}_bind_extents[{axis}]")) + for axis in range(rank) + ), + ) + + def _outlined_array_bind_fixed_extents( + self, + plan: ArgumentTransferPlan, + context: _CFunctionContext, + ) -> tuple[str, ...] | None: + """Return one required-extent expression per axis, or None when unsupported.""" + if not self._array_bind_is_outlinable(plan, context): + return None + fixed = [] + for axis in range(plan.array.rank): + supported, extent = self._outlined_array_bind_axis_extent(plan, context, axis) + if not supported: + return None + fixed.append("-1" if extent is None else f"(long long)({extent})") + return tuple(fixed) + + def _array_bind_is_outlinable( + self, + plan: ArgumentTransferPlan, + context: _CFunctionContext, + ) -> bool: + """Return whether one array argument needs no ABI role beyond pointer and extents.""" + array = plan.array + actual = plan.native_array_actual + if array is None or actual is None or array.rank is None: + return False + rank = array.rank + return not any( + ( + plan.datatype_family is DatatypeFamily.STRING, + array.contiguous is False, + array.runtime_rank_role is not None, + array.itemsize_role is not None, + array.dense_actual_role is not None, + bool(array.stride_roles), + bool(array.upper_bound_roles), + actual.rank != rank, + len(array.shape) != rank, + len(actual.shape) != rank, + len(context.arguments[plan.owner_path].extent_names) < rank, + array.flatten_python_storage and array.flat_axis != rank - 1, + ) + ) + + def _outlined_array_bind_axis_extent( + self, + plan: ArgumentTransferPlan, + context: _CFunctionContext, + axis: int, + ) -> tuple[bool, str | None]: + """Return one axis as (supported, required extent) with None for a free axis. + + The direct route checks a declared extent and the handoff route restates + it as the expected shape. Both are folded into one table, so an axis + whose two routes disagree keeps the whole argument on its inline + sequence rather than silently binding one of the two. + """ + array = plan.array + actual = plan.native_array_actual + array_object = f"(PyArrayObject *){context.arguments[plan.owner_path].object_name}" + if self._array_actual_axis_expression(array, array_object, axis) != str(axis): + return False, None + direct = self._outlined_array_bind_axis_value(array, context, axis, array.shape[axis], flattened=False) + handoff = self._outlined_array_bind_axis_value( + array, + context, + axis, + actual.shape[axis], + flattened=bool(actual.flatten_storage and axis == actual.flat_axis), + ) + if direct != handoff: + return False, None + return True, direct + + def _outlined_array_bind_axis_value( + self, + array: ArrayHandoffPlan, + context: _CFunctionContext, + axis: int, + expression: str, + *, + flattened: bool, + ) -> str | None: + """Lower one axis extent, or None when the axis carries no declared extent.""" + if flattened or expression in self._RUNTIME_EXTENT_MARKERS: + return None + if array.extent_evaluation[axis] == "bridge": + return None + return self._array_extent_expression(array, axis, expression, context) + def _native_array_actual_call_nodes( self, plan: ArgumentTransferPlan, @@ -11347,13 +11386,13 @@ def _module_property_support( entries=entries, ) - def _binding_prototype(self, plan: FunctionPlan, *, external: bool = False) -> CFunctionPrototype: - """Return the binding-local binding prototype derived from the supplied completed binding records; this helper preserves completed policy.""" + def _binding_prototype(self, plan: FunctionPlan) -> CFunctionPrototype: + """Return the binding-local prototype for one completed function plan.""" return CFunctionPrototype( self._binding_function_name(plan), "PyObject *", self._binding_parameters(plan), - None if external else "static", + "static", ) @staticmethod diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 5fcc5c512..0699dcd27 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -5665,10 +5665,10 @@ def _generated_wrapper( """Package rendered source text with the filenames owned by build integration. Each binding translation unit is named for the C module it renders, so - the primary file is followed by its zero-padded worker shards and then - any collision-adapter unit. The returned wrapper places bridge, C - sources, and header text in that stable order; this helper does not - write files or freeze the newly assembled source records. + the binding file is followed by any collision-adapter unit. The + returned wrapper places bridge, C sources, and header text in that + stable order; this helper does not write files or freeze the newly + assembled source records. """ # Name bridge, binding, and header files before pairing each with rendered text. binding_sources = tuple(Path(f"{name}.c") for name in c_module_names) diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index 085d9b24b..4b1868f7e 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -523,6 +523,146 @@ static inline int prik_array_validate( argument_name); } +/* + * Bind one ordinary array argument, replacing the sequence a generated wrapper + * used to emit inline at every array argument of every wrapper. + * + * 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. + * + * object the Python argument to bind + * numpy_type NPY_* element selector the plan chose for this array + * rank number of contract axes, and the length of `fixed` + * and `extents` + * minimum_rank smallest accepted runtime rank; equals `rank` unless + * the plan flattens Python storage + * maximum_rank largest accepted runtime rank + * layout PRIK_ARRAY_LAYOUT_* ordering the plan requires + * 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 + * data receives the pointer passed to the native entrypoint + * extents receives one extent per contract axis + * + * Returns 0 on success, or -1 with a Python exception set. + */ +#ifdef PRIK_BINDING_NATIVE_ARRAY_ACTUAL +PRIK_NO_INLINE static int prik_bind_array( + PyObject *object, + int numpy_type, + int rank, + int minimum_rank, + int maximum_rank, + int layout, + 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) +{ + int axis; + if (PyArray_Check(object)) { + PyArrayObject *array = (PyArrayObject *)object; + if (prik_array_validate_ndarray( + array, numpy_type, minimum_rank, maximum_rank, layout, + require_contiguous, require_writeable, python_type, argument_name) < 0) { + return -1; + } + for (axis = 0; axis < rank; ++axis) { + if (fixed[axis] >= 0 && PyArray_DIM(array, axis) != (npy_intp)fixed[axis]) { + PyErr_Format( + PyExc_TypeError, + "Argument %s has incompatible shape at axis %d", + argument_name, + axis); + return -1; + } + } + *data = PyArray_DATA(array); + for (axis = 0; axis < flatten_axis; ++axis) { + extents[axis] = (int64_t)PyArray_DIM(array, axis); + } + extents[flatten_axis] = 1; + for (axis = flatten_axis; axis < PyArray_NDIM(array); ++axis) { + extents[flatten_axis] *= (int64_t)PyArray_DIM(array, axis); + } + 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; + } +} +#endif + /* Exact typed scalar input conversion. A mismatch deliberately sets no error. */ static inline int prik_bool_unpack_exact(PyObject *value, bool *destination) { 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 44f0cd282..a0adafbbc 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 @@ -121,5 +121,6 @@ def update(values: {annotation}[:]) -> None: ... assert function.binding.docstring is not None assert f"Accepts exact {numpy_name} element storage" in function.binding.docstring assert f"void update({c_type} * values);" in binding - assert f"prik_array_validate_ndarray((PyArrayObject *)bound_values_obj, {numpy_macro}," in binding - assert f'"{numpy_name}", "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 diff --git a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py index 830538f0d..4c7c36390 100644 --- a/tests/fortran/arrays/codegen/test_array_buffer_lowering.py +++ b/tests/fortran/arrays/codegen/test_array_buffer_lowering.py @@ -74,20 +74,17 @@ 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 - assert "prik_array_actual bound_values_actual;" 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. assert ( - 'prik_array_actual_unpack(bound_values_obj, "float64", 1, bound_values_shape, NULL, ' - "1, 1, 1, 0, 0, 0, 1, 0, -1, &bound_values_actual)" + "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, ' + "bound_values_bind_fixed, &bound_values, bound_values_bind_extents)" ) in c_source - assert "bound_values = bound_values_actual.data;" in c_source - assert "bound_values_extent_0 = bound_values_actual.extents[0];" in c_source - assert "if (PyArray_Check(bound_values_obj)) {" in c_source - assert c_source.count("PyArray_Check(bound_values_obj)") == 1 - assert ( - "prik_array_validate_ndarray((PyArrayObject *)bound_values_obj, NPY_FLOAT64, 1, 1, " - 'PRIK_ARRAY_LAYOUT_ANY_CONTIGUOUS, 1, 1, "numpy.float64", "values")' - ) in c_source - assert "bound_values = PyArray_DATA((PyArrayObject *)bound_values_obj);" 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 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 db9b04b86..a308a2811 100644 --- a/tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py +++ b/tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py @@ -174,19 +174,20 @@ def test_dense_array_lowering_uses_planned_shape_checks_and_bridge_orientation() 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 "PyTuple_SET_ITEM(bound_values_shape, 0, PyLong_FromLongLong((long long)(bound_rows)))" in c_source - assert "PyTuple_SET_ITEM(bound_values_shape, 1, PyLong_FromLongLong((long long)(bound_cols)))" in c_source - assert 'prik_array_actual_unpack(bound_values_obj, "float64", 2, bound_values_shape, "F"' in c_source - assert 'prik_array_actual_unpack(bound_values_obj, "float64", 2, bound_values_shape, "C"' in c_source - assert "bound_values_shape = PyTuple_New(1)" in c_source - assert "PyTuple_SET_ITEM(bound_values_shape, 0, Py_None)" in c_source + assert "bound_values_bind_fixed[0] = (long long)(bound_rows);" in c_source + assert "bound_values_bind_fixed[1] = (long long)(bound_cols);" in c_source + assert "prik_bind_array(bound_values_obj, NPY_FLOAT64, 2, 2, 2, PRIK_ARRAY_LAYOUT_F_CONTIGUOUS" in c_source + assert "prik_bind_array(bound_values_obj, NPY_FLOAT64, 2, 2, 2, PRIK_ARRAY_LAYOUT_C_CONTIGUOUS" in c_source + assert "bound_values_bind_fixed[0] = -1;" in c_source assert ( - 'prik_array_actual_unpack(bound_values_obj, "float64", 1, bound_values_shape, NULL, ' - "1, 1, 1, 0, 0, 0, 1, 1, 0, &bound_values_actual)" + "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, ' + "bound_values_bind_fixed, &bound_values, bound_values_bind_extents)" ) in c_source assert ( - 'prik_array_actual_unpack(bound_values_obj, "float64", 2, bound_values_shape, "F", ' - "1, 1, 1, 0, 0, 0, 1, 1, 1, &bound_values_actual)" + "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, ' + "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 assert "call c_f_pointer(bound_values, values, [values_extent_1, values_extent_0])" in bridge_source @@ -210,7 +211,7 @@ def test_external_interface_declares_late_extent_before_dependent_array(): "late_extent_external.late_extent.values", ) assert c_source.index("prik_int32_unpack_exact(bound_n_obj, &bound_n)") < c_source.index( - "Argument values has incompatible shape at axis 0" + "bound_values_bind_fixed[0] = (long long)(bound_n);" ) signature = "subroutine late_extent(values, n)" interface = bridge_source.split(signature, maxsplit=1)[1].split("end subroutine late_extent", maxsplit=1)[0] diff --git a/tests/fortran/infrastructure/pipeline/test_wrapper_generator.py b/tests/fortran/infrastructure/pipeline/test_wrapper_generator.py index 52581bf1d..8d5c2c970 100644 --- a/tests/fortran/infrastructure/pipeline/test_wrapper_generator.py +++ b/tests/fortran/infrastructure/pipeline/test_wrapper_generator.py @@ -133,37 +133,24 @@ def test_public_generator_reports_each_rendering_operation_in_execution_order(): assert all(elapsed >= 0.0 for _, elapsed in progress if elapsed is not None) -def test_large_procedure_only_binding_is_split_into_balanced_compile_units(): +def test_procedure_only_binding_stays_one_compile_unit_at_any_size(): declarations = "\n".join(f"def value_{index:03d}(x: Float64) -> Float64: ..." for index in range(128)) + generated_wrapper = WrapperGenerator().generate(_plan(declarations, module_name="large_binding")) - binding_sources = [ - source for source in generated_wrapper.sources if source.path.name.startswith("large_binding_wrapper") - ] - main_source, *worker_sources, header_source = binding_sources - - assert generated_wrapper.binding_sources == ( - Path("large_binding_wrapper.c"), - Path("large_binding_wrapper_001.c"), - Path("large_binding_wrapper_002.c"), - Path("large_binding_wrapper_003.c"), - Path("large_binding_wrapper_004.c"), + binding_source = next( + source for source in generated_wrapper.sources if source.path.name == "large_binding_wrapper.c" + ) + header_source = next( + source for source in generated_wrapper.sources if source.path.name == "large_binding_wrapper.h" ) - assert "#define PRIK_BINDING_IMPORT_ARRAY 1" in main_source.text - assert "PyMODINIT_FUNC PyInit_large_binding(void)" in main_source.text - assert "PyObject * wrap_value_000(" not in main_source.text - assert all("PRIK_BINDING_IMPORT_ARRAY" not in source.text for source in worker_sources) - assert all("PyInit_large_binding" not in source.text for source in worker_sources) - assert sum(source.text.count("PyObject * wrap_value_") for source in worker_sources) == 128 - assert "static PyObject * wrap_value_000" not in header_source.text - assert "PyObject * wrap_value_000(PyObject * self, PyObject * args, PyObject * kwargs);" in header_source.text - - -def test_procedure_only_binding_below_sharding_threshold_keeps_one_compile_unit(): - declarations = "\n".join(f"def value_{index:03d}(x: Float64) -> Float64: ..." for index in range(127)) - - generated_wrapper = WrapperGenerator().generate(_plan(declarations, module_name="unsharded_binding")) - assert generated_wrapper.binding_sources == (Path("unsharded_binding_wrapper.c"),) + assert generated_wrapper.binding_sources == (Path("large_binding_wrapper.c"),) + assert "#define PRIK_BINDING_IMPORT_ARRAY 1" in binding_source.text + assert "PyMODINIT_FUNC PyInit_large_binding(void)" in binding_source.text + assert binding_source.text.count("static PyObject * wrap_value_") == 128 + assert "static PyObject * wrap_value_000(PyObject * self, PyObject * args, PyObject * kwargs);" in ( + header_source.text + ) @pytest.mark.parametrize( 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 e09ca175e..10d823028 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -345,7 +345,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_array_actual_unpack(" in c_source + assert "prik_bind_array(" in c_source assert '"_native_array_descriptor_argument_for_binding_positional"' in c_source assert '"_native_array_descriptor_handoff_for_binding_positional"' in c_source assert '"_native_array_handle_from_generated_ops"' in c_source