Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<module>_wrapper_001.c` and siblings so those units
could compile concurrently; every project now generates only
`<module>_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
Expand Down
311 changes: 175 additions & 136 deletions prik/codegen/c/binding.py

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions prik/pipeline/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
140 changes: 140 additions & 0 deletions prik/runtime/native_support/prik_binding.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 9 additions & 12 deletions tests/fortran/arrays/codegen/test_array_buffer_lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 12 additions & 11 deletions tests/fortran/arrays/codegen/test_dense_array_shape_lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
41 changes: 14 additions & 27 deletions tests/fortran/infrastructure/pipeline/test_wrapper_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading