From d339333d8d9bd58bd8cb63fb6f7c7130fd89f742 Mon Sep 17 00:00:00 2001 From: Jiawen Chen Date: Wed, 18 Feb 2026 12:42:53 -0800 Subject: [PATCH 1/4] Python bindings: adds numpy_view() to hl.Buffer. - numpy_view() with no arguments: - Always tries to returns a C-contiguous view of the buffer if possible. - If Halide Buffer is stored in the default order, will reverse axes. - If Halide Buffer is stored in the reverse order, will preserve axes. - numpy_view(reverse_axes: Bool): - Requires an explicit reverse_axes argument to be passed in. - It will do what was requested, and supports non-contiguous buffers. --- .../src/halide/halide_/PyBuffer.cpp | 86 ++++++++--- python_bindings/test/correctness/buffer.py | 138 +++++++++++++++++- 2 files changed, 201 insertions(+), 23 deletions(-) diff --git a/python_bindings/src/halide/halide_/PyBuffer.cpp b/python_bindings/src/halide/halide_/PyBuffer.cpp index 90606677ca36..a59b4b1f043b 100644 --- a/python_bindings/src/halide/halide_/PyBuffer.cpp +++ b/python_bindings/src/halide/halide_/PyBuffer.cpp @@ -311,7 +311,7 @@ class PyBuffer : public Buffer<> { ~PyBuffer() override = default; }; -py::buffer_info to_buffer_info(Buffer<> &b, bool reverse_axes = true) { +py::buffer_info to_buffer_info(const Buffer<> &b, bool reverse_axes) { if (b.data() == nullptr) { throw py::value_error("Cannot convert a Buffer<> with null host ptr to a Python buffer."); } @@ -335,6 +335,48 @@ py::buffer_info to_buffer_info(Buffer<> &b, bool reverse_axes = true) { ); } +// Returns a pair [c_contig, f_contig], where: +// - If c_contig is true, the buffer is stored in the default order on the Halide side. +// - The first dim has stride 1 (innermost first). +// - This is true if `b` was constructed without passing anything to `storage_order`, +// or equivalently, if `storage_order` was [0, 1, 2, ...]. +// - If f_contig is true, the buffer is stored in reversed order on the Halide side. +// - The last dim has stride 1 (innermost last). +// - This is true if `b` was constructed with `storage_order` of [d-1, d-2, ..., 0]. +// - It is possible for a Buffer to be both C and F contiguous (e.g., a scalar or a +// 1D vector), or for a Buffer to be neither (e.g., storage_order=[1, 0, 2] for a 3D +// buffer). +// ELEPHANT: maybe I should just call it [densest_first, densest_last]. But that +// doesn't imply "contiguous". contiguous_densest_first? +std::pair is_any_contiguous(const Buffer<> &b) { + if (b.dimensions() == 0) { + return {true, true}; + } + + const int d = b.dimensions(); + + int c_stride = 1; // stride in elements, not bytes + int f_stride = 1; + bool c_contig = true; + bool f_contig = true; + + for (int i = 0; i < d; ++i) { + const int c_idx = i; + const int f_idx = d - 1 - i; + if (b.raw_buffer()->dim[c_idx].stride != c_stride) { + c_contig = false; + } + c_stride *= b.raw_buffer()->dim[c_idx].extent; + + if (b.raw_buffer()->dim[f_idx].stride != f_stride) { + f_contig = false; + } + f_stride *= b.raw_buffer()->dim[f_idx].extent; + } + + return {c_contig, f_contig}; +} + } // namespace void define_buffer(py::module &m) { @@ -352,32 +394,40 @@ void define_buffer(py::module &m) { // Note that this allows us to convert a Buffer<> to any buffer-like object in Python; // most notably, we can convert to an ndarray by calling numpy.array() - .def_buffer([](Buffer<> &b) -> py::buffer_info { - return to_buffer_info(b, /*reverse_axes*/ true); + + // ELEPHANT: this always reverses axes, which might be surprising? + // We need to update the docs though. + // how about reverse axes only when "C", and does not when "F", otherweise, fail? + .def_buffer([](Buffer<> &self) -> py::buffer_info { + return to_buffer_info(self, /*reverse_axes*/ true); }) - // This allows us to use any buffer-like python entity to create a Buffer<> + .def("numpy_view", [](Buffer<> &self) -> py::array { + const auto [c_contig, f_contig] = is_any_contiguous(self); + if (!c_contig && !f_contig) { + throw py::value_error("Buffer is not contiguous in either C or F order; cannot create numpy view."); + } + const bool reverse_axes = c_contig && !f_contig; + // base = py::cast(self) ensures that `self` outlives the returned value. + return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); }, "Returns a NumPy array that is a view of this Buffer. If the Buffer is C-contiguous (innermost first), reverses the axes to produce a C-contiguous array (innermost last). If the Buffer is F-contiguous (innermost last), does not reverse the axes, producing an F-contiguous array. If the Buffer is not contiguous in either order, raises an error.") + + .def("numpy_view", [](Buffer<> &self, bool reverse_axes) -> py::array { + // base = py::cast(self) ensures that `self` outlives the returned value. + return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); }, py::arg("reverse_axes"), "Returns a NumPy array that is a view of this Buffer. The caller decides whether to reverse axis ordering.") + + // This allows us to use any buffer-like Python entity to create a Buffer<> // (most notably, an ndarray) .def(py::init_alias(), py::arg("buffer"), py::arg("name") = "", py::arg("reverse_axes") = true) .def(py::init_alias<>()) .def(py::init_alias &>()) - .def(py::init([](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { - return Buffer<>(type, sizes, name); - }), - py::arg("type"), py::arg("sizes"), py::arg("name") = "") + .def(py::init([](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, sizes, name); }), py::arg("type"), py::arg("sizes"), py::arg("name") = "") - .def(py::init([](Type type, const std::vector &sizes, const std::vector &storage_order, const std::string &name) -> Buffer<> { - return Buffer<>(type, sizes, storage_order, name); - }), - py::arg("type"), py::arg("sizes"), py::arg("storage_order"), py::arg("name") = "") + // The default storage order is [0, 1, 2, ...], meaning store the first axis densest. + .def(py::init([](Type type, const std::vector &sizes, const std::vector &storage_order, const std::string &name) -> Buffer<> { return Buffer<>(type, sizes, storage_order, name); }), py::arg("type"), py::arg("sizes"), py::arg("storage_order"), py::arg("name") = "") // Note that this exists solely to allow you to create a Buffer with a null host ptr; // this is necessary for some bounds-query operations (e.g. Func::infer_input_bounds). - .def_static( - "make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { - return Buffer<>(type, nullptr, sizes, name); - }, - py::arg("type"), py::arg("sizes"), py::arg("name") = "") + .def_static("make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, nullptr, sizes, name); }, py::arg("type"), py::arg("sizes"), py::arg("name") = "") .def_static("make_scalar", static_cast (*)(Type, const std::string &)>(Buffer<>::make_scalar), py::arg("type"), py::arg("name") = "") .def_static("make_interleaved", static_cast (*)(Type, int, int, int, const std::string &)>(Buffer<>::make_interleaved), py::arg("type"), py::arg("width"), py::arg("height"), py::arg("channels"), py::arg("name") = "") @@ -656,7 +706,7 @@ void define_buffer(py::module &m) { .def("__repr__", [](const Buffer<> &b) -> std::string { std::ostringstream o; if (b.defined()) { - o << ""; + o << ""; } else { o << ""; } diff --git a/python_bindings/test/correctness/buffer.py b/python_bindings/test/correctness/buffer.py index 73e00b62234d..cedfcafad52e 100644 --- a/python_bindings/test/correctness/buffer.py +++ b/python_bindings/test/correctness/buffer.py @@ -4,7 +4,7 @@ import sys -def test_ndarray_to_buffer(reverse_axes=True): +def test_ndarray_to_buffer(reverse_axes): a0 = np.ones((200, 300), dtype=np.int32) # Buffer always shares data (when possible) by default, @@ -49,7 +49,7 @@ def test_ndarray_to_buffer(reverse_axes=True): assert a0[56, 34] == 12 -def test_buffer_to_ndarray(reverse_axes=True): +def test_buffer_to_ndarray(reverse_axes): buf0 = hl.Buffer(hl.Int(16), [4, 6]) assert buf0.type() == hl.Int(16) buf0.fill(0) @@ -93,8 +93,9 @@ def test_buffer_to_ndarray(reverse_axes=True): assert array_shared[1, 2] == 3 assert array_copied[1, 2] == 42 - # Ensure that Buffers that have nonzero mins get converted correctly, - # since the Python Buffer Protocol doesn't have the 'min' concept + # Ensure that Buffers that have nonzero mins get converted correctly. + # Since the Python Buffer Protocol doesn't have the 'min' concept, the + # numpy views will have the cropped extents but no min coordinate. cropped_buf0 = buf0.copy() cropped_buf0.crop(dimension=0, min=1, extent=2) cropped_buf = cropped_buf0.reverse_axes() if not reverse_axes else cropped_buf0 @@ -131,6 +132,131 @@ def test_buffer_to_ndarray(reverse_axes=True): assert cropped_array_copied[0, 2] == 3 +def test_numpy_view_auto_reverse_axes(): + W = 16 + H = 12 + C = 3 + + x = 6 + y = 3 + c = 1 + + # Construct a hl.Buffer with the default storage order ([0, 1, 2]). + halide_buffer_default_order = hl.Buffer(hl.Int(16), [W, H, C]) + assert halide_buffer_default_order.type() == hl.Int(16) + halide_buffer_default_order.fill(0) + halide_buffer_default_order[x, y, c] = 42 + assert halide_buffer_default_order[x, y, c] == 42 + + # Its numpy_view() has: + # - axes reversed + # - The C_CONTIGUOUS flag set. + numpy_view_of_halide_buffer_default_order = halide_buffer_default_order.numpy_view() + assert numpy_view_of_halide_buffer_default_order.shape == (C, H, W) + assert numpy_view_of_halide_buffer_default_order.strides == (384, 32, 2) # (192, 16, 2) * sizeof(int16) + assert numpy_view_of_halide_buffer_default_order.dtype == np.int16 + assert numpy_view_of_halide_buffer_default_order.flags['C_CONTIGUOUS'] + assert numpy_view_of_halide_buffer_default_order[c, y, x] == 42 + + # Modifying the buffer should affect the view. + assert halide_buffer_default_order[x + 1, y + 1, c + 1] == 0 + halide_buffer_default_order[x + 1, y + 1, c + 1] = 99 + assert numpy_view_of_halide_buffer_default_order[c + 1, y + 1, x + 1] == 99 + + # Modifying the view should affect the buffer. + assert numpy_view_of_halide_buffer_default_order[c - 1, y - 1, x - 1] == 0 + numpy_view_of_halide_buffer_default_order[c - 1, y - 1, x - 1] = 100 + assert halide_buffer_default_order[x - 1, y - 1, c - 1] == 100 + + # Construct a hl.Buffer with the reversed storage order ([0, 1, 2]). + # Its shape (from Halide's perspective) is still (w, h, c), but its strides + # are reversed (c is densest). + halide_buffer_reversed_storage_order = hl.Buffer(hl.Int(16), [W, H, C], storage_order=[2, 1, 0]) + assert halide_buffer_reversed_storage_order.type() == hl.Int(16) + halide_buffer_reversed_storage_order.fill(0) + halide_buffer_reversed_storage_order[x, y, c] = 42 + assert halide_buffer_reversed_storage_order[x, y, c] == 42 + + # Its numpy_view() has: + # - axes preserved (i.e., its shape is also (w, h, c)). + # - The C_CONTIGUOUS flag set. This is not obvious: one would initially think that + # it might be F_CONTIGUOUS instead. But no, numpy considers an array to be + # C_CONTIGUOUS if the last axis is the densest (stride = itemsize). This is true + # because we asked hl.Buffer to allocate its last axis (c) to be the densest + # and did not reverse axes. + numpy_view_of_halide_buffer_reversed_storage_order = halide_buffer_reversed_storage_order.numpy_view() + assert numpy_view_of_halide_buffer_reversed_storage_order.shape == (W, H, C) + assert numpy_view_of_halide_buffer_reversed_storage_order.strides == (72, 6, 2) # (36, 3, 1) * sizeof(int16) + assert numpy_view_of_halide_buffer_reversed_storage_order.dtype == np.int16 + assert numpy_view_of_halide_buffer_reversed_storage_order.flags['C_CONTIGUOUS'] # This is not obvious. + assert numpy_view_of_halide_buffer_reversed_storage_order[x, y, c] == 42 + + # Modifying the buffer should affect the view. + assert halide_buffer_reversed_storage_order[x + 1, y + 1, c + 1] == 0 + halide_buffer_reversed_storage_order[x + 1, y + 1, c + 1] = 99 + assert numpy_view_of_halide_buffer_reversed_storage_order[x + 1, y + 1, c + 1] == 99 + + # Modifying the view should affect the buffer. + assert numpy_view_of_halide_buffer_reversed_storage_order[x - 1, y - 1, c - 1] == 0 + numpy_view_of_halide_buffer_reversed_storage_order[x - 1, y - 1, c - 1] = 100 + assert halide_buffer_reversed_storage_order[x - 1, y - 1, c - 1] == 100 + + # Construct a hl.Buffer with neither the first nor last as densest. + buf_neither_order = hl.Buffer(hl.Int(16), [W, H, C], storage_order=[1, 0, 2]) + assert buf_neither_order.type() == hl.Int(16) + buf_neither_order.fill(0) + buf_neither_order[x, y, c] = 42 + assert buf_neither_order[x, y, c] == 42 + + # numpy_view() without an explicit reverse_axes flag will fail. + try: + _ = buf_neither_order.numpy_view() + except ValueError as e: + assert "Buffer is not contiguous in either C or F order; cannot create numpy view" in str(e) + else: + assert False, "Did not see expected exception!" + +def test_numpy_view_explicit_reverse_axes(): + W = 16 + H = 12 + C = 3 + + x = 6 + y = 3 + c = 1 + + # Construct a hl.Buffer with neither the first nor last as densest. + buf_neither_order = hl.Buffer(hl.Int(16), [W, H, C], storage_order=[1, 0, 2]) + assert buf_neither_order.type() == hl.Int(16) + buf_neither_order.fill(0) + buf_neither_order[x, y, c] = 42 + assert buf_neither_order[x, y, c] == 42 + + # numpy_view(reverse_axes=False) should succeed and preserve axis order. + numpy_view_no_reverse = buf_neither_order.numpy_view(reverse_axes=False) + assert numpy_view_no_reverse.shape == (W, H, C) + assert numpy_view_no_reverse[x, y, c] == 42 + assert numpy_view_no_reverse.strides == (24, 2, 384) # (H, 1, H * W) * sizeof(int16) + + # numpy_view(reverse_axes=True) should succeed and reverse axis order. + numpy_view_reverse = buf_neither_order.numpy_view(reverse_axes=True) + assert numpy_view_reverse.shape == (C, H, W) + assert numpy_view_reverse[c, y, x] == 42 + assert numpy_view_reverse.strides == (384, 2, 24) # (H * W, 1, H) * sizeof(int16) + + # Modifying the buffer should affect both views. + assert buf_neither_order[x + 1, y + 1, c + 1] == 0 + buf_neither_order[x + 1, y + 1, c + 1] = 99 + assert numpy_view_no_reverse[x + 1, y + 1, c + 1] == 99 + assert numpy_view_reverse[c + 1, y + 1, x + 1] == 99 + + # Modifying one view should affect the other view and the buffer. + assert numpy_view_no_reverse[x - 1, y - 1, c - 1] == 0 + numpy_view_no_reverse[x - 1, y - 1, c - 1] = 100 + assert numpy_view_reverse[c - 1, y - 1, x - 1] == 100 + assert buf_neither_order[x - 1, y - 1, c - 1] == 100 + + def _assert_fn(e): assert e @@ -169,7 +295,7 @@ def test_bufferinfo_sharing(): a0 = np.ones((20000, 30000), dtype=np.int32) b0 = hl.Buffer(a0) del a0 - for i in range(200): + for _ in range(200): b1 = hl.Buffer(b0) b0 = b1 b1 = None @@ -546,6 +672,8 @@ def make_orig_buf(): test_ndarray_to_buffer(reverse_axes=False) test_buffer_to_ndarray(reverse_axes=True) test_buffer_to_ndarray(reverse_axes=False) + test_numpy_view_auto_reverse_axes() + test_numpy_view_explicit_reverse_axes() test_for_each_element() test_fill_all_equal() test_bufferinfo_sharing() From a29766b09d221dd85e02faafd47a481c294cdc24 Mon Sep 17 00:00:00 2001 From: Jiawen Chen Date: Wed, 24 Jun 2026 10:51:18 -0700 Subject: [PATCH 2/4] cleaner semantics --- .../src/halide/halide_/PyBuffer.cpp | 107 +++----- python_bindings/src/halide/halide_/PyBuffer.h | 11 +- .../src/halide/halide_/PyCallable.cpp | 43 +--- .../test/correctness/addconstant_test.py | 4 +- python_bindings/test/correctness/buffer.py | 232 +++++++----------- python_bindings/test/correctness/callable.py | 88 ++++--- .../tutorial/lesson_10_aot_compilation_run.py | 8 +- src/PythonExtensionGen.cpp | 40 +-- 8 files changed, 216 insertions(+), 317 deletions(-) diff --git a/python_bindings/src/halide/halide_/PyBuffer.cpp b/python_bindings/src/halide/halide_/PyBuffer.cpp index a59b4b1f043b..2eb991296d15 100644 --- a/python_bindings/src/halide/halide_/PyBuffer.cpp +++ b/python_bindings/src/halide/halide_/PyBuffer.cpp @@ -311,7 +311,7 @@ class PyBuffer : public Buffer<> { ~PyBuffer() override = default; }; -py::buffer_info to_buffer_info(const Buffer<> &b, bool reverse_axes) { +py::buffer_info to_buffer_info(Buffer<> &b, bool reverse_axes) { if (b.data() == nullptr) { throw py::value_error("Cannot convert a Buffer<> with null host ptr to a Python buffer."); } @@ -335,48 +335,6 @@ py::buffer_info to_buffer_info(const Buffer<> &b, bool reverse_axes) { ); } -// Returns a pair [c_contig, f_contig], where: -// - If c_contig is true, the buffer is stored in the default order on the Halide side. -// - The first dim has stride 1 (innermost first). -// - This is true if `b` was constructed without passing anything to `storage_order`, -// or equivalently, if `storage_order` was [0, 1, 2, ...]. -// - If f_contig is true, the buffer is stored in reversed order on the Halide side. -// - The last dim has stride 1 (innermost last). -// - This is true if `b` was constructed with `storage_order` of [d-1, d-2, ..., 0]. -// - It is possible for a Buffer to be both C and F contiguous (e.g., a scalar or a -// 1D vector), or for a Buffer to be neither (e.g., storage_order=[1, 0, 2] for a 3D -// buffer). -// ELEPHANT: maybe I should just call it [densest_first, densest_last]. But that -// doesn't imply "contiguous". contiguous_densest_first? -std::pair is_any_contiguous(const Buffer<> &b) { - if (b.dimensions() == 0) { - return {true, true}; - } - - const int d = b.dimensions(); - - int c_stride = 1; // stride in elements, not bytes - int f_stride = 1; - bool c_contig = true; - bool f_contig = true; - - for (int i = 0; i < d; ++i) { - const int c_idx = i; - const int f_idx = d - 1 - i; - if (b.raw_buffer()->dim[c_idx].stride != c_stride) { - c_contig = false; - } - c_stride *= b.raw_buffer()->dim[c_idx].extent; - - if (b.raw_buffer()->dim[f_idx].stride != f_stride) { - f_contig = false; - } - f_stride *= b.raw_buffer()->dim[f_idx].extent; - } - - return {c_contig, f_contig}; -} - } // namespace void define_buffer(py::module &m) { @@ -393,41 +351,52 @@ void define_buffer(py::module &m) { py::class_, PyBuffer>(m, "Buffer", py::buffer_protocol()) // Note that this allows us to convert a Buffer<> to any buffer-like object in Python; - // most notably, we can convert to an ndarray by calling numpy.array() - - // ELEPHANT: this always reverses axes, which might be surprising? - // We need to update the docs though. - // how about reverse axes only when "C", and does not when "F", otherweise, fail? - .def_buffer([](Buffer<> &self) -> py::buffer_info { - return to_buffer_info(self, /*reverse_axes*/ true); + // most notably, we can convert to an ndarray by calling numpy.array(m_buffer). This + // always reverses the axes. + .def_buffer([](Buffer<> &b) -> py::buffer_info { + return to_buffer_info(b, /*reverse_axes*/ true); }) - .def("numpy_view", [](Buffer<> &self) -> py::array { - const auto [c_contig, f_contig] = is_any_contiguous(self); - if (!c_contig && !f_contig) { - throw py::value_error("Buffer is not contiguous in either C or F order; cannot create numpy view."); - } - const bool reverse_axes = c_contig && !f_contig; - // base = py::cast(self) ensures that `self` outlives the returned value. - return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); }, "Returns a NumPy array that is a view of this Buffer. If the Buffer is C-contiguous (innermost first), reverses the axes to produce a C-contiguous array (innermost last). If the Buffer is F-contiguous (innermost last), does not reverse the axes, producing an F-contiguous array. If the Buffer is not contiguous in either order, raises an error.") - - .def("numpy_view", [](Buffer<> &self, bool reverse_axes) -> py::array { - // base = py::cast(self) ensures that `self` outlives the returned value. - return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); }, py::arg("reverse_axes"), "Returns a NumPy array that is a view of this Buffer. The caller decides whether to reverse axis ordering.") + // Returns a NumPy array that is a view of (i.e. shares storage with) this Buffer. + .def( + "numpy_view", [](Buffer<> &self, bool reverse_axes) -> py::array { + // base = py::cast(self) ensures that `self` outlives the returned view. + return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); + }, + py::arg("reverse_axes") = true, + "Returns a NumPy array that is a view of this Buffer. By default the axes are " + "reversed. Pass reverse_axes=False to preserve the original axis order.") - // This allows us to use any buffer-like Python entity to create a Buffer<> - // (most notably, an ndarray) - .def(py::init_alias(), py::arg("buffer"), py::arg("name") = "", py::arg("reverse_axes") = true) .def(py::init_alias<>()) .def(py::init_alias &>()) - .def(py::init([](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, sizes, name); }), py::arg("type"), py::arg("sizes"), py::arg("name") = "") + + // This py::init_alias allows us to use any buffer-like Python entity to create a + // Buffer<> (most notably, an ndarray). reverse_axes = True by default. + // + // ! Note that the copy/default constructors are registered *above, before* the + // py::buffer one before. This is because hl.Buffer also satisfies the buffer protocol, + // and we want hl.Buffer(other_buffer) to be a "direct" copy (no axis reversal) rather + // than routing through the axis-reversing buffer-protocol path. + .def(py::init_alias(), py::arg("buffer"), py::arg("name") = "", py::arg("reverse_axes") = true) + + .def(py::init([](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { + return Buffer<>(type, sizes, name); + }), + py::arg("type"), py::arg("sizes"), py::arg("name") = "") // The default storage order is [0, 1, 2, ...], meaning store the first axis densest. - .def(py::init([](Type type, const std::vector &sizes, const std::vector &storage_order, const std::string &name) -> Buffer<> { return Buffer<>(type, sizes, storage_order, name); }), py::arg("type"), py::arg("sizes"), py::arg("storage_order"), py::arg("name") = "") + .def(py::init([](Type type, const std::vector &sizes, const std::vector &storage_order, const std::string &name) -> Buffer<> { + return Buffer<>(type, sizes, storage_order, name); + }), + py::arg("type"), py::arg("sizes"), py::arg("storage_order"), py::arg("name") = "") // Note that this exists solely to allow you to create a Buffer with a null host ptr; // this is necessary for some bounds-query operations (e.g. Func::infer_input_bounds). - .def_static("make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, nullptr, sizes, name); }, py::arg("type"), py::arg("sizes"), py::arg("name") = "") + .def_static( + "make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { + return Buffer<>(type, nullptr, sizes, name); + }, + py::arg("type"), py::arg("sizes"), py::arg("name") = "") .def_static("make_scalar", static_cast (*)(Type, const std::string &)>(Buffer<>::make_scalar), py::arg("type"), py::arg("name") = "") .def_static("make_interleaved", static_cast (*)(Type, int, int, int, const std::string &)>(Buffer<>::make_interleaved), py::arg("type"), py::arg("width"), py::arg("height"), py::arg("channels"), py::arg("name") = "") @@ -706,7 +675,7 @@ void define_buffer(py::module &m) { .def("__repr__", [](const Buffer<> &b) -> std::string { std::ostringstream o; if (b.defined()) { - o << ""; + o << ""; } else { o << ""; } diff --git a/python_bindings/src/halide/halide_/PyBuffer.h b/python_bindings/src/halide/halide_/PyBuffer.h index 7d62d77b2983..20bf33823a41 100644 --- a/python_bindings/src/halide/halide_/PyBuffer.h +++ b/python_bindings/src/halide/halide_/PyBuffer.h @@ -20,14 +20,17 @@ Halide::Runtime::Buffer pybufferinfo_to_halidebuffer halide_dimension_t *dims = (halide_dimension_t *)alloca(info.ndim * sizeof(halide_dimension_t)); _halide_user_assert(dims); for (int i = 0; i < info.ndim; i++) { - if (INT_MAX < info.shape[i] || INT_MAX < (info.strides[i] / t.bytes())) { + // Strides may be negative in both numpy and Halide. So check against both INT_MIN and + // INT_MAX. + const auto elem_stride = info.strides[i] / t.bytes(); + if (info.shape[i] > INT_MAX || elem_stride < INT_MIN || elem_stride > INT_MAX) { throw py::value_error("Out of range dimensions in buffer conversion."); } - // Halide's default indexing convention is col-major (the most rapidly varying index comes first); + // Reverse axes if requested. // Numpy's default is row-major (most rapidly varying comes last). - // We usually want to reverse the order so that most-varying comes first. + const int dst_axis = reverse_axes ? (info.ndim - i - 1) : i; - dims[dst_axis] = {0, (int32_t)info.shape[i], (int32_t)(info.strides[i] / t.bytes())}; + dims[dst_axis] = {0, (int32_t)info.shape[i], (int32_t)elem_stride}; } return Halide::Runtime::Buffer(t, info.ptr, (int)info.ndim, dims); } diff --git a/python_bindings/src/halide/halide_/PyCallable.cpp b/python_bindings/src/halide/halide_/PyCallable.cpp index 4e1759c15fda..76bf34d6d240 100644 --- a/python_bindings/src/halide/halide_/PyCallable.cpp +++ b/python_bindings/src/halide/halide_/PyCallable.cpp @@ -47,28 +47,6 @@ T cast_to(const py::handle &h) { } } -std::pair is_any_contiguous(const py::buffer_info &info) { - py::ssize_t c_stride = info.itemsize; - py::ssize_t f_stride = info.itemsize; - bool c_contig = true; - bool f_contig = true; - - for (size_t i = 0; i < info.ndim; ++i) { - size_t c_idx = info.ndim - 1 - i; - if (info.strides[c_idx] != c_stride) { - c_contig = false; - } - c_stride *= info.shape[c_idx]; - - if (info.strides[i] != f_stride) { - f_contig = false; - } - f_stride *= info.shape[i]; - } - - return {c_contig, f_contig}; -} - } // namespace class PyCallable { @@ -105,28 +83,25 @@ class PyCallable { const auto define_one_arg = [&argv, &scalar_storage, &buffers, &cci](const Argument &c_arg, py::handle value, size_t slot) { if (c_arg.is_buffer()) { - // If the argument is already a Halide Buffer of some sort, - // skip pybuffer_to_halidebuffer entirely, since the latter requires - // a non-null host ptr, but we might want such a buffer for bounds inference, - // and we don't need the intermediate HalideBuffer wrapper anyway. halide_buffer_t *raw_buffer; if (py::isinstance>(value)) { + // If the argument is already a Halide Buffer of some sort, + // skip pybuffer_to_halidebuffer entirely, since the latter requires + // a non-null host ptr, but we might want such a buffer for bounds inference, + // and we don't need the intermediate HalideBuffer wrapper anyway. + // + // Do not reverse axes. auto b = cast_to>(value); raw_buffer = b.raw_buffer(); } else { + // If it's a buffer-protocol object (e.g. a NumPy array), the convention + // is to always reverse axes. + constexpr bool reverse_axes = true; py::buffer py_buffer_value = cast_to(value); const bool writable = c_arg.is_output(); const py::buffer_info value_buffer_info = py_buffer_value.request(writable); - auto [c_contig, f_contig] = is_any_contiguous(value_buffer_info); - - if (!c_contig && !f_contig) { - throw Halide::Error("Invalid buffer: only C or F contiguous buffers are supported"); - } - // It is possible for a buffer to be both C and F contiguous - // (e.g., a scalar or a 1D buffer). - const bool reverse_axes = c_contig && !f_contig; buffers.buffers[slot] = pybufferinfo_to_halidebuffer( value_buffer_info, reverse_axes); diff --git a/python_bindings/test/correctness/addconstant_test.py b/python_bindings/test/correctness/addconstant_test.py index 25b150ed9862..dc229ae0ea07 100644 --- a/python_bindings/test/correctness/addconstant_test.py +++ b/python_bindings/test/correctness/addconstant_test.py @@ -42,7 +42,7 @@ def test(addconstant_impl_func, offset): input_float = numpy.array([3.14, 2.718, 1.618], dtype=numpy.float32) input_double = numpy.array([3.14, 2.718, 1.618], dtype=numpy.float64) input_half = numpy.array([3.14, 2.718, 1.618], dtype=numpy.float16) - input_2d = numpy.array([[1, 2, 3], [4, 5, 6]], dtype=numpy.int8, order="F") + input_2d = numpy.array([[1, 2, 3], [4, 5, 6]], dtype=numpy.int8) input_3d = numpy.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=numpy.int8) output_u8 = numpy.zeros((3,), dtype=numpy.uint8) @@ -56,7 +56,7 @@ def test(addconstant_impl_func, offset): output_float = numpy.zeros((3,), dtype=numpy.float32) output_double = numpy.zeros((3,), dtype=numpy.float64) output_half = numpy.zeros((3,), dtype=numpy.float16) - output_2d = numpy.zeros((2, 3), dtype=numpy.int8, order="F") + output_2d = numpy.zeros((2, 3), dtype=numpy.int8) output_3d = numpy.zeros((2, 2, 2), dtype=numpy.int8) addconstant_impl_func( diff --git a/python_bindings/test/correctness/buffer.py b/python_bindings/test/correctness/buffer.py index cedfcafad52e..2e49062c27df 100644 --- a/python_bindings/test/correctness/buffer.py +++ b/python_bindings/test/correctness/buffer.py @@ -1,15 +1,17 @@ -import halide as hl -import numpy as np import gc import sys +import halide as hl +import numpy as np + +# Tests constructing a hl.Buffer from an np.array. +# `reverse_axes`: passed to hl.Buffer (default False). def test_ndarray_to_buffer(reverse_axes): a0 = np.ones((200, 300), dtype=np.int32) # Buffer always shares data (when possible) by default, - # and maintains the shape of the data source. (note that - # the ndarray is col-major by default!) + # and maintains the shape of the data source. b0 = hl.Buffer(a0, "float32_test_buffer", reverse_axes) assert b0.type() == hl.Int(32) assert b0.name() == "float32_test_buffer" @@ -58,13 +60,12 @@ def test_buffer_to_ndarray(reverse_axes): # This is subtle: the default behavior when converting # a Buffer to an np.array (or ndarray, etc) is to reverse the - # order of the axes, since Halide prefers column-major and - # the rest of Python prefers row-major. By calling reverse_axes() - # before that conversion, we end up doing a *double* reverse, i.e, - # not reversing at all. So the 'not' here is correct. + # order of the axes. By calling reverse_axes() before that conversion, + # we end up doing a *double* reverse, i.e, not reversing at all. + # So the 'not' here is correct. buf = buf0.reverse_axes() if not reverse_axes else buf0 - # Should share storage with buf + # Should share storage with buf0. array_shared = np.array(buf, copy=False) assert array_shared.dtype == np.int16 if reverse_axes: @@ -74,7 +75,7 @@ def test_buffer_to_ndarray(reverse_axes): assert array_shared.shape == (4, 6) assert array_shared[1, 2] == 42 - # Should *not* share storage with buf + # copy=True -> should *not* share storage with buf. array_copied = np.array(buf, copy=True) assert array_copied.dtype == np.int16 if reverse_axes: @@ -84,7 +85,8 @@ def test_buffer_to_ndarray(reverse_axes): assert array_copied.shape == (4, 6) assert array_copied[1, 2] == 42 - # Should affect array_shared but not array_copied + # Modifying one element of buf0 should affect array_shared but not + # array_copied. buf0[1, 2] = 3 if reverse_axes: assert array_shared[2, 1] == 3 @@ -93,8 +95,8 @@ def test_buffer_to_ndarray(reverse_axes): assert array_shared[1, 2] == 3 assert array_copied[1, 2] == 42 - # Ensure that Buffers that have nonzero mins get converted correctly. - # Since the Python Buffer Protocol doesn't have the 'min' concept, the + # Ensure that Buffers that have nonzero mins get converted correctly, + # since the Python Buffer Protocol doesn't have the 'min' concept, the # numpy views will have the cropped extents but no min coordinate. cropped_buf0 = buf0.copy() cropped_buf0.crop(dimension=0, min=1, extent=2) @@ -132,131 +134,6 @@ def test_buffer_to_ndarray(reverse_axes): assert cropped_array_copied[0, 2] == 3 -def test_numpy_view_auto_reverse_axes(): - W = 16 - H = 12 - C = 3 - - x = 6 - y = 3 - c = 1 - - # Construct a hl.Buffer with the default storage order ([0, 1, 2]). - halide_buffer_default_order = hl.Buffer(hl.Int(16), [W, H, C]) - assert halide_buffer_default_order.type() == hl.Int(16) - halide_buffer_default_order.fill(0) - halide_buffer_default_order[x, y, c] = 42 - assert halide_buffer_default_order[x, y, c] == 42 - - # Its numpy_view() has: - # - axes reversed - # - The C_CONTIGUOUS flag set. - numpy_view_of_halide_buffer_default_order = halide_buffer_default_order.numpy_view() - assert numpy_view_of_halide_buffer_default_order.shape == (C, H, W) - assert numpy_view_of_halide_buffer_default_order.strides == (384, 32, 2) # (192, 16, 2) * sizeof(int16) - assert numpy_view_of_halide_buffer_default_order.dtype == np.int16 - assert numpy_view_of_halide_buffer_default_order.flags['C_CONTIGUOUS'] - assert numpy_view_of_halide_buffer_default_order[c, y, x] == 42 - - # Modifying the buffer should affect the view. - assert halide_buffer_default_order[x + 1, y + 1, c + 1] == 0 - halide_buffer_default_order[x + 1, y + 1, c + 1] = 99 - assert numpy_view_of_halide_buffer_default_order[c + 1, y + 1, x + 1] == 99 - - # Modifying the view should affect the buffer. - assert numpy_view_of_halide_buffer_default_order[c - 1, y - 1, x - 1] == 0 - numpy_view_of_halide_buffer_default_order[c - 1, y - 1, x - 1] = 100 - assert halide_buffer_default_order[x - 1, y - 1, c - 1] == 100 - - # Construct a hl.Buffer with the reversed storage order ([0, 1, 2]). - # Its shape (from Halide's perspective) is still (w, h, c), but its strides - # are reversed (c is densest). - halide_buffer_reversed_storage_order = hl.Buffer(hl.Int(16), [W, H, C], storage_order=[2, 1, 0]) - assert halide_buffer_reversed_storage_order.type() == hl.Int(16) - halide_buffer_reversed_storage_order.fill(0) - halide_buffer_reversed_storage_order[x, y, c] = 42 - assert halide_buffer_reversed_storage_order[x, y, c] == 42 - - # Its numpy_view() has: - # - axes preserved (i.e., its shape is also (w, h, c)). - # - The C_CONTIGUOUS flag set. This is not obvious: one would initially think that - # it might be F_CONTIGUOUS instead. But no, numpy considers an array to be - # C_CONTIGUOUS if the last axis is the densest (stride = itemsize). This is true - # because we asked hl.Buffer to allocate its last axis (c) to be the densest - # and did not reverse axes. - numpy_view_of_halide_buffer_reversed_storage_order = halide_buffer_reversed_storage_order.numpy_view() - assert numpy_view_of_halide_buffer_reversed_storage_order.shape == (W, H, C) - assert numpy_view_of_halide_buffer_reversed_storage_order.strides == (72, 6, 2) # (36, 3, 1) * sizeof(int16) - assert numpy_view_of_halide_buffer_reversed_storage_order.dtype == np.int16 - assert numpy_view_of_halide_buffer_reversed_storage_order.flags['C_CONTIGUOUS'] # This is not obvious. - assert numpy_view_of_halide_buffer_reversed_storage_order[x, y, c] == 42 - - # Modifying the buffer should affect the view. - assert halide_buffer_reversed_storage_order[x + 1, y + 1, c + 1] == 0 - halide_buffer_reversed_storage_order[x + 1, y + 1, c + 1] = 99 - assert numpy_view_of_halide_buffer_reversed_storage_order[x + 1, y + 1, c + 1] == 99 - - # Modifying the view should affect the buffer. - assert numpy_view_of_halide_buffer_reversed_storage_order[x - 1, y - 1, c - 1] == 0 - numpy_view_of_halide_buffer_reversed_storage_order[x - 1, y - 1, c - 1] = 100 - assert halide_buffer_reversed_storage_order[x - 1, y - 1, c - 1] == 100 - - # Construct a hl.Buffer with neither the first nor last as densest. - buf_neither_order = hl.Buffer(hl.Int(16), [W, H, C], storage_order=[1, 0, 2]) - assert buf_neither_order.type() == hl.Int(16) - buf_neither_order.fill(0) - buf_neither_order[x, y, c] = 42 - assert buf_neither_order[x, y, c] == 42 - - # numpy_view() without an explicit reverse_axes flag will fail. - try: - _ = buf_neither_order.numpy_view() - except ValueError as e: - assert "Buffer is not contiguous in either C or F order; cannot create numpy view" in str(e) - else: - assert False, "Did not see expected exception!" - -def test_numpy_view_explicit_reverse_axes(): - W = 16 - H = 12 - C = 3 - - x = 6 - y = 3 - c = 1 - - # Construct a hl.Buffer with neither the first nor last as densest. - buf_neither_order = hl.Buffer(hl.Int(16), [W, H, C], storage_order=[1, 0, 2]) - assert buf_neither_order.type() == hl.Int(16) - buf_neither_order.fill(0) - buf_neither_order[x, y, c] = 42 - assert buf_neither_order[x, y, c] == 42 - - # numpy_view(reverse_axes=False) should succeed and preserve axis order. - numpy_view_no_reverse = buf_neither_order.numpy_view(reverse_axes=False) - assert numpy_view_no_reverse.shape == (W, H, C) - assert numpy_view_no_reverse[x, y, c] == 42 - assert numpy_view_no_reverse.strides == (24, 2, 384) # (H, 1, H * W) * sizeof(int16) - - # numpy_view(reverse_axes=True) should succeed and reverse axis order. - numpy_view_reverse = buf_neither_order.numpy_view(reverse_axes=True) - assert numpy_view_reverse.shape == (C, H, W) - assert numpy_view_reverse[c, y, x] == 42 - assert numpy_view_reverse.strides == (384, 2, 24) # (H * W, 1, H) * sizeof(int16) - - # Modifying the buffer should affect both views. - assert buf_neither_order[x + 1, y + 1, c + 1] == 0 - buf_neither_order[x + 1, y + 1, c + 1] = 99 - assert numpy_view_no_reverse[x + 1, y + 1, c + 1] == 99 - assert numpy_view_reverse[c + 1, y + 1, x + 1] == 99 - - # Modifying one view should affect the other view and the buffer. - assert numpy_view_no_reverse[x - 1, y - 1, c - 1] == 0 - numpy_view_no_reverse[x - 1, y - 1, c - 1] = 100 - assert numpy_view_reverse[c - 1, y - 1, x - 1] == 100 - assert buf_neither_order[x - 1, y - 1, c - 1] == 100 - - def _assert_fn(e): assert e @@ -665,6 +542,80 @@ def make_orig_buf(): assert realized[row, col] == tag * 2 +def test_numpy_view(): + # numpy_view() always reverses axes by default, regardless of the Buffer's + # storage order; reverse_axes=False keeps Halide's axis order. It works for + # any layout, contiguous or not. + + # Default storage order: dim 0 is densest (stride 1). + buf = hl.Buffer(hl.Int(16), [4, 6]) + buf.fill(0) + buf[1, 2] = 42 + + v = buf.numpy_view() # default reverse_axes=True + assert v.dtype == np.int16 + assert v.shape == (6, 4) # axes reversed + assert v[2, 1] == 42 # numpy[a0, a1] <-> halide[a1, a0] + + v_no = buf.numpy_view(reverse_axes=False) + assert v_no.shape == (4, 6) # Halide order preserved + assert v_no[1, 2] == 42 + + # Both are views that share storage with the Buffer. + v[0, 0] = 7 + assert buf[0, 0] == 7 + assert v_no[0, 0] == 7 + + # Allocate hl.Buffer with axis 1 densest instead. + # numpy_view() doesn't care about storage order, just reverses indexing order. + buf_f = hl.Buffer(hl.Int(16), [4, 6], [1, 0]) # store axis 1 densest + buf_f.fill(0) + buf_f[1, 2] = 24 + vf = buf_f.numpy_view() + assert vf.shape == (6, 4) + assert vf[2, 1] == 24 + + # Allocate a hl.Buffer that is neither C- nor F-contiguous. + # numpy_view() still works but is a strided view. + buf_nc = hl.Buffer(hl.Int(16), [4, 6, 8], [1, 0, 2]) + buf_nc.fill(0) + buf_nc[1, 2, 3] = 99 + vnc = buf_nc.numpy_view() + assert vnc.shape == (8, 6, 4) + assert vnc[3, 2, 1] == 99 + + +def test_buffer_copy_no_reverse(): + # Constructing an hl.Buffer from another hl.Buffer must be a direct copy + # (no axis reversal), even though hl.Buffer also satisfies the buffer protocol. + b1 = hl.Buffer(hl.Int(16), [4, 6, 8], [1, 0, 2]) # non-square, non-default order + b1.fill(0) + b1[1, 2, 3] = 17 + + b2 = hl.Buffer(b1) + assert b2.dimensions() == b1.dimensions() + for i in range(b1.dimensions()): + assert b2.dim(i).extent() == b1.dim(i).extent(), i + assert b2.dim(i).stride() == b1.dim(i).stride(), i + assert b2[1, 2, 3] == 17 + + # A bounds-query buffer (null host) can be copied without error. + bq = hl.Buffer.make_bounds_query(hl.Int(16), [4, 6]) + bq_copy = hl.Buffer(bq) + assert bq_copy.dim(0).extent() == 4 + assert bq_copy.dim(1).extent() == 6 + + +def test_ndarray_negative_strides(): + # NumPy arrays with negative strides should work. + a = np.arange(6, dtype=np.int32)[::-1] # [5, 4, 3, 2, 1, 0], stride -1 + b = hl.Buffer(a) + assert b.dim(0).extent() == 6 + assert b.dim(0).stride() == -1 + assert b[0] == 5 + assert b[5] == 0 + + if __name__ == "__main__": test_make_interleaved() test_interleaved_ndarray() @@ -672,8 +623,9 @@ def make_orig_buf(): test_ndarray_to_buffer(reverse_axes=False) test_buffer_to_ndarray(reverse_axes=True) test_buffer_to_ndarray(reverse_axes=False) - test_numpy_view_auto_reverse_axes() - test_numpy_view_explicit_reverse_axes() + test_numpy_view() + test_buffer_copy_no_reverse() + test_ndarray_negative_strides() test_for_each_element() test_fill_all_equal() test_bufferinfo_sharing() diff --git a/python_bindings/test/correctness/callable.py b/python_bindings/test/correctness/callable.py index e29173b7c09c..6a128042e944 100644 --- a/python_bindings/test/correctness/callable.py +++ b/python_bindings/test/correctness/callable.py @@ -1,8 +1,7 @@ import halide as hl import numpy as np - -from simplepy_generator import SimplePy import simplecpp_pystub # noqa: F401 - needed for create_callable_from_generator("simplecpp") to work +from simplepy_generator import SimplePy def test_callable(): @@ -181,17 +180,25 @@ class EchoDims: def generate(self): g = self + # Relax dim(0).stride == 1 constraint so that we can echo arbitrary strides. + g.input.dim(0).set_stride(hl.Expr()) d = hl.Var("d") - g.output_extents[d] = hl.mux(d, [ - g.input.dim(0).extent(), - g.input.dim(1).extent(), - g.input.dim(2).extent() - ]) - g.output_strides[d] = hl.mux(d, [ - g.input.dim(0).stride(), - g.input.dim(1).stride(), - g.input.dim(2).stride() - ]) + g.output_extents[d] = hl.mux( + d, + [ + g.input.dim(0).extent(), + g.input.dim(1).extent(), + g.input.dim(2).extent(), + ], + ) + g.output_strides[d] = hl.mux( + d, + [ + g.input.dim(0).stride(), + g.input.dim(1).stride(), + g.input.dim(2).stride(), + ], + ) with hl.GeneratorContext(hl.Target("host-debug")): echo_dims = EchoDims() @@ -199,10 +206,11 @@ def generate(self): output_extents = hl.Buffer(hl.Int(32), [3]) output_strides = hl.Buffer(hl.Int(32), [3]) - - # C-contiguous input reverses dimensions. - # Note that numpy defaults to `order='C'`. - input_c = np.zeros((16, 12, 3), dtype=np.int32, order='C') + + # Test that buffer protocol arguments always reverse axes, regardless of storage order. + + # C-contiguous input (numpy's default order; order="C" is superfluous). + input_c = np.zeros((16, 12, 3), dtype=np.int32, order="C") echo_dims_callable(input_c, output_extents, output_strides) assert output_extents[0] == 3 assert output_extents[1] == 12 @@ -211,33 +219,47 @@ def generate(self): assert output_strides[1] == 3 assert output_strides[2] == 36 - # F-contiguous input preserves dimensions. - input_f = np.zeros((16, 12, 3), dtype=np.int32, order='F') + # F-contiguous input reverses axes too (it is no longer left as-is). + input_f = np.zeros((16, 12, 3), dtype=np.int32, order="F") echo_dims_callable(input_f, output_extents, output_strides) - assert output_extents[0] == 16 + assert output_extents[0] == 3 assert output_extents[1] == 12 - assert output_extents[2] == 3 - assert output_strides[0] == 1 + assert output_extents[2] == 16 + assert output_strides[0] == 192 assert output_strides[1] == 16 - assert output_strides[2] == 192 + assert output_strides[2] == 1 - # Non-contiguous inputs are rejected. - input_noncontig = np.zeros((16, 12, 3), dtype=np.int32) - input_noncontig = np.transpose(input_noncontig, (1, 0, 2)) - try: - echo_dims_callable(input_noncontig, output_extents, output_strides) - except hl.HalideError as e: - assert "Invalid buffer: only C or F contiguous buffers are supported" in str(e) - else: - assert False, "Did not see expected exception!" + # Non-contiguous inputs are allowed. + # input_noncontig has shape (12, 16, 3) and strides (0, 36, 1) (in elements). + # Halide will reverse axes. + input_noncontig = np.transpose(np.zeros((16, 12, 3), dtype=np.int32), (1, 0, 2)) + echo_dims_callable(input_noncontig, output_extents, output_strides) + assert output_extents[0] == 3 + assert output_extents[1] == 16 + assert output_extents[2] == 12 + assert output_strides[0] == 1 + assert output_strides[1] == 36 + assert output_strides[2] == 3 + + # Negative strides are allowed. + # Halide reverses axes. + input_neg = np.zeros((16, 12, 3), dtype=np.int32)[::-1, :, :] + echo_dims_callable(input_neg, output_extents, output_strides) + assert output_extents[0] == 3 + assert output_extents[1] == 12 + assert output_extents[2] == 16 + # Reversing axis 0 makes its stride negative; after axis reversal that + # axis becomes Halide's last dimension (dim 2). + assert output_strides[0] == 1 + assert output_strides[1] == 3 + assert output_strides[2] == -36 if __name__ == "__main__": # test_callable() def via_simplecpp_pystub(target, generator_params): - return hl.create_callable_from_generator(target, "simplecpp", - generator_params) + return hl.create_callable_from_generator(target, "simplecpp", generator_params) def via_simplepy(target, generator_params): with hl.GeneratorContext(target): diff --git a/python_bindings/tutorial/lesson_10_aot_compilation_run.py b/python_bindings/tutorial/lesson_10_aot_compilation_run.py index df0f8473d1b3..adfcb33bf2ac 100644 --- a/python_bindings/tutorial/lesson_10_aot_compilation_run.py +++ b/python_bindings/tutorial/lesson_10_aot_compilation_run.py @@ -7,7 +7,6 @@ # Instead, it depends on the header file that lesson_10_generate # produced when we ran it: import lesson_10_halide - import numpy as np @@ -27,14 +26,15 @@ def main(): # In other words, you can pass numpy arrays directly to the generated # code. - # Let's make some input data to test with: - input = np.empty((640, 480), dtype=np.uint8, order="F") + # Let's make some input data to test with. Note that when a numpy array is + # passed to the generated code, its axes are always reversed. + input = np.empty((640, 480), dtype=np.uint8) for y in range(480): for x in range(640): input[x, y] = (x ^ (y + 1)) & 0xFF # And the memory where we want to write our output: - output = np.empty((640, 480), dtype=np.uint8, order="F") + output = np.empty((640, 480), dtype=np.uint8) offset_value = 5 diff --git a/src/PythonExtensionGen.cpp b/src/PythonExtensionGen.cpp index e6cac20d9dd7..e4b899521c5f 100644 --- a/src/PythonExtensionGen.cpp +++ b/src/PythonExtensionGen.cpp @@ -165,7 +165,7 @@ bool unpack_buffer(PyObject *py_obj, needs_device_free = false; memset(&py_buf, 0, sizeof(py_buf)); - if (PyObject_GetBuffer(py_obj, &py_buf, PyBUF_FORMAT | PyBUF_STRIDED_RO | PyBUF_ANY_CONTIGUOUS | py_getbuffer_flags) < 0) { + if (PyObject_GetBuffer(py_obj, &py_buf, PyBUF_FORMAT | PyBUF_STRIDED_RO | py_getbuffer_flags) < 0) { PyErr_Format(PyExc_ValueError, "Invalid argument %s: Expected %d dimensions, got %d", name, dimensions, py_buf.ndim); return false; } @@ -175,44 +175,22 @@ bool unpack_buffer(PyObject *py_obj, PyErr_Format(PyExc_ValueError, "Invalid argument %s: Expected %d dimensions, got %d", name, dimensions, py_buf.ndim); return false; } - /* We'll get a buffer that's either: - * C_CONTIGUOUS (last dimension varies the fastest, i.e., has stride=1) or - * F_CONTIGUOUS (first dimension varies the fastest, i.e., has stride=1). - * The latter is preferred, since it's already in the format that Halide - * needs. It can can be achieved in numpy by passing order='F' during array - * creation. However, if we do get a C_CONTIGUOUS buffer, flip the dimensions - * (transpose) so we can process it without having to reallocate. - */ - int i, j, j_step; - if (PyBuffer_IsContiguous(&py_buf, 'F')) { - j = 0; - j_step = 1; - } else if (PyBuffer_IsContiguous(&py_buf, 'C')) { - j = py_buf.ndim - 1; - j_step = -1; - } else { - /* Python checks all dimensions and strides, so this typically indicates - * a bug in the array's buffer protocol. */ - PyErr_Format(PyExc_ValueError, "Invalid buffer: neither C nor Fortran contiguous"); - return false; - } - for (i = 0; i < py_buf.ndim; ++i, j += j_step) { + // Always reverse axes. + // TODO(jiawen): Can probably consolidate this with similar code in PyCallable.cpp and + // pybufferinfo_to_halidebuffer() in PyBuffer.h. + for (int i = 0; i < py_buf.ndim; ++i) { + const int j = py_buf.ndim - 1 - i; // numpy axis j maps to Halide dim i halide_dim[i].min = 0; - halide_dim[i].stride = (int)(py_buf.strides[j] / py_buf.itemsize); // strides is in bytes + halide_dim[i].stride = (int)(py_buf.strides[j] / py_buf.itemsize); // Python strides are in bytes halide_dim[i].extent = (int)py_buf.shape[j]; halide_dim[i].flags = 0; - if (py_buf.suboffsets && py_buf.suboffsets[i] >= 0) { + if (py_buf.suboffsets && py_buf.suboffsets[j] >= 0) { // Halide doesn't support arrays of pointers. But we should never see this - // anyway, since we specified PyBUF_STRIDED. + // anyway, since we did not specify PyBUF_INDIRECT. PyErr_Format(PyExc_ValueError, "Invalid buffer: suboffsets not supported"); return false; } } - if (halide_dim[py_buf.ndim - 1].extent * halide_dim[py_buf.ndim - 1].stride * py_buf.itemsize != py_buf.len) { - PyErr_Format(PyExc_ValueError, "Invalid buffer: length %ld, but computed length %ld", - py_buf.len, py_buf.shape[0] * py_buf.strides[0]); - return false; - } halide_buf = {}; needs_device_free = true; From 06e118e8dd0ae9e1bc71e1df7fb40ad1d076cd63 Mon Sep 17 00:00:00 2001 From: "halide-ci[bot]" <266445882+halide-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:41:14 +0000 Subject: [PATCH 3/4] Apply pre-commit auto-fixes --- python_bindings/src/halide/halide_/PyBuffer.cpp | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/python_bindings/src/halide/halide_/PyBuffer.cpp b/python_bindings/src/halide/halide_/PyBuffer.cpp index cfb603e52d90..95ce7bb42b60 100644 --- a/python_bindings/src/halide/halide_/PyBuffer.cpp +++ b/python_bindings/src/halide/halide_/PyBuffer.cpp @@ -363,9 +363,8 @@ void define_buffer(py::module &m) { // base = py::cast(self) ensures that `self` outlives the returned view. return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); }, - py::arg("reverse_axes") = true, - "Returns a NumPy array that is a view of this Buffer. By default the axes are " - "reversed. Pass reverse_axes=False to preserve the original axis order.") + py::arg("reverse_axes") = true, "Returns a NumPy array that is a view of this Buffer. By default the axes are " + "reversed. Pass reverse_axes=False to preserve the original axis order.") .def(py::init_alias<>()) .def(py::init_alias &>()) @@ -392,11 +391,7 @@ void define_buffer(py::module &m) { // Note that this exists solely to allow you to create a Buffer with a null host ptr; // this is necessary for some bounds-query operations (e.g. Func::infer_input_bounds). - .def_static( - "make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { - return Buffer<>(type, nullptr, sizes, name); - }, - py::arg("type"), py::arg("sizes"), py::arg("name") = "") + .def_static("make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, nullptr, sizes, name); }, py::arg("type"), py::arg("sizes"), py::arg("name") = "") .def_static("make_scalar", static_cast (*)(Type, const std::string &)>(Buffer<>::make_scalar), py::arg("type"), py::arg("name") = "") .def_static("make_interleaved", static_cast (*)(Type, int, int, int, const std::string &)>(Buffer<>::make_interleaved), py::arg("type"), py::arg("width"), py::arg("height"), py::arg("channels"), py::arg("name") = "") From db92fd82adeef0a229d8dec47d7231e949bf946f Mon Sep 17 00:00:00 2001 From: Jiawen Chen Date: Wed, 24 Jun 2026 17:32:59 -0700 Subject: [PATCH 4/4] pre-commit formatting --- python_bindings/src/halide/halide_/PyBuffer.cpp | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/python_bindings/src/halide/halide_/PyBuffer.cpp b/python_bindings/src/halide/halide_/PyBuffer.cpp index cfb603e52d90..95ce7bb42b60 100644 --- a/python_bindings/src/halide/halide_/PyBuffer.cpp +++ b/python_bindings/src/halide/halide_/PyBuffer.cpp @@ -363,9 +363,8 @@ void define_buffer(py::module &m) { // base = py::cast(self) ensures that `self` outlives the returned view. return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self)); }, - py::arg("reverse_axes") = true, - "Returns a NumPy array that is a view of this Buffer. By default the axes are " - "reversed. Pass reverse_axes=False to preserve the original axis order.") + py::arg("reverse_axes") = true, "Returns a NumPy array that is a view of this Buffer. By default the axes are " + "reversed. Pass reverse_axes=False to preserve the original axis order.") .def(py::init_alias<>()) .def(py::init_alias &>()) @@ -392,11 +391,7 @@ void define_buffer(py::module &m) { // Note that this exists solely to allow you to create a Buffer with a null host ptr; // this is necessary for some bounds-query operations (e.g. Func::infer_input_bounds). - .def_static( - "make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { - return Buffer<>(type, nullptr, sizes, name); - }, - py::arg("type"), py::arg("sizes"), py::arg("name") = "") + .def_static("make_bounds_query", [](Type type, const std::vector &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, nullptr, sizes, name); }, py::arg("type"), py::arg("sizes"), py::arg("name") = "") .def_static("make_scalar", static_cast (*)(Type, const std::string &)>(Buffer<>::make_scalar), py::arg("type"), py::arg("name") = "") .def_static("make_interleaved", static_cast (*)(Type, int, int, int, const std::string &)>(Buffer<>::make_interleaved), py::arg("type"), py::arg("width"), py::arg("height"), py::arg("channels"), py::arg("name") = "")