Skip to content

Commit 3cf47df

Browse files
authored
Makes hl.Buffer <--> Python Buffer Protocol axis convention consistent throughout (#8953)
Halide interacts with Python in three place: - Python bindings - PyCallablle (`compile_to_callable()` via Python bindings) - Python extension This PR changes our axis ordering conventions to be consistent throughout: - Constructing a Python buffer object from an `hl.Buffer` reverses axes. - Constructing an `hl.Buffer` from any Python buffer object reverses axes. - Note that an `hl.Buffer` is also a Python buffer object. E.g., you can use `my_buffer[idx0, idx1, idx2]` to retrieve an element. Constructing an `hl.Buffer` from another `hl.Buffer` does *not* reverse axes because that would be surprising. - `hl.Buffer(my_array, "my_buffer_name", reverse_axes=False)` is explicitly supported. - Calling a PyCallable with: - A `hl.Buffer` does not reverse axes (it's considered a Halide Buffer, not a Python buffer object) - A Python buffer object does reverse axes. - Calling a pipeline function in a Python extension generated by a generator has the same conventions as a PyCallable. Because of the new consistent conventions, we now permit: - Non-contiguous views. Previously, Python-side C-contiguous buffers were reversed and F-contiguous buffers were not. Now we support views with arbitrary strides (so long as the Python-side stride in bytes divides the element size, and if the result is in `[INT_MIN, INT_MAX]`). - This includes negatives strides. - `hl.Buffer` also gained a `.numpy_view()` method that returns a `np.array` with an explicit `reverse_axes` argument (defaulting to `True`).
1 parent 2fad88f commit 3cf47df

8 files changed

Lines changed: 192 additions & 122 deletions

File tree

python_bindings/src/halide/halide_/PyBuffer.cpp

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ class PyBuffer : public Buffer<> {
311311
~PyBuffer() override = default;
312312
};
313313

314-
py::buffer_info to_buffer_info(Buffer<> &b, bool reverse_axes = true) {
314+
py::buffer_info to_buffer_info(Buffer<> &b, bool reverse_axes) {
315315
if (b.data() == nullptr) {
316316
throw py::value_error("Cannot convert a Buffer<> with null host ptr to a Python buffer.");
317317
}
@@ -351,33 +351,52 @@ void define_buffer(py::module &m) {
351351
py::class_<Buffer<>, PyBuffer>(m, "Buffer", py::buffer_protocol())
352352

353353
// Note that this allows us to convert a Buffer<> to any buffer-like object in Python;
354-
// most notably, we can convert to an ndarray by calling numpy.array()
354+
// most notably, we can convert to an ndarray by calling numpy.array(other_buffer). This
355+
// always reverses the axes.
355356
.def_buffer([](Buffer<> &b) -> py::buffer_info {
356357
return to_buffer_info(b, /*reverse_axes*/ true);
357358
})
358359

359-
// This allows us to use any buffer-like python entity to create a Buffer<>
360-
// (most notably, an ndarray)
361-
.def(py::init_alias<py::buffer, const std::string &, bool>(), py::arg("buffer"), py::arg("name") = "", py::arg("reverse_axes") = true)
360+
// Returns a NumPy array that is a view of (shares storage with) this Buffer.
361+
.def(
362+
"numpy_view", [](Buffer<> &self, bool reverse_axes) -> py::array {
363+
// base = py::cast(self) ensures that `self` outlives the returned view.
364+
return py::array(to_buffer_info(self, reverse_axes), /*base*/ py::cast(self));
365+
},
366+
py::arg("reverse_axes") = true, "Returns a NumPy array that is a view of this Buffer. By default the axes are "
367+
"reversed. Pass reverse_axes=False to preserve the original axis order.")
368+
362369
.def(py::init_alias<>())
363-
.def(py::init_alias<const Buffer<> &>())
370+
// keep_alive</*Nurse*/ 1, /*Patient*/ 2>: this copy shares the source's data pointer
371+
// without taking ownership (the source may itself wrap external memory, e.g. a numpy
372+
// array kept alive only by the source Buffer).
373+
//
374+
// In keep_alive<1, 2>, 1 refers to `this`, 2 refers to the argument `Buffer<> &` arg.
375+
.def(py::init_alias<const Buffer<> &>(), py::keep_alive<1, 2>())
376+
377+
// This py::init_alias allows us to use any buffer-like Python entity to create a
378+
// Buffer<> (most notably, an ndarray). reverse_axes = True by default.
379+
//
380+
// ! Note that the copy/default constructors are registered *above, before* the
381+
// py::buffer one before. This is because hl.Buffer also satisfies the buffer protocol,
382+
// and we want hl.Buffer(other_buffer) to be a "direct" copy (no axis reversal) rather
383+
// than routing through the axis-reversing buffer-protocol path.
384+
.def(py::init_alias<py::buffer, const std::string &, bool>(), py::arg("buffer"), py::arg("name") = "", py::arg("reverse_axes") = true)
385+
364386
.def(py::init([](Type type, const std::vector<int> &sizes, const std::string &name) -> Buffer<> {
365387
return Buffer<>(type, sizes, name);
366388
}),
367389
py::arg("type"), py::arg("sizes"), py::arg("name") = "")
368390

391+
// The default storage order is [0, 1, 2, ...], meaning store the first axis densest.
369392
.def(py::init([](Type type, const std::vector<int> &sizes, const std::vector<int> &storage_order, const std::string &name) -> Buffer<> {
370393
return Buffer<>(type, sizes, storage_order, name);
371394
}),
372395
py::arg("type"), py::arg("sizes"), py::arg("storage_order"), py::arg("name") = "")
373396

374397
// Note that this exists solely to allow you to create a Buffer with a null host ptr;
375398
// this is necessary for some bounds-query operations (e.g. Func::infer_input_bounds).
376-
.def_static(
377-
"make_bounds_query", [](Type type, const std::vector<int> &sizes, const std::string &name) -> Buffer<> {
378-
return Buffer<>(type, nullptr, sizes, name);
379-
},
380-
py::arg("type"), py::arg("sizes"), py::arg("name") = "")
399+
.def_static("make_bounds_query", [](Type type, const std::vector<int> &sizes, const std::string &name) -> Buffer<> { return Buffer<>(type, nullptr, sizes, name); }, py::arg("type"), py::arg("sizes"), py::arg("name") = "")
381400

382401
.def_static("make_scalar", static_cast<Buffer<> (*)(Type, const std::string &)>(Buffer<>::make_scalar), py::arg("type"), py::arg("name") = "")
383402
.def_static("make_interleaved", static_cast<Buffer<> (*)(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") = "")

python_bindings/src/halide/halide_/PyBuffer.h

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ Halide::Runtime::Buffer<T, Dims, InClassDimStorage> pybufferinfo_to_halidebuffer
2020
halide_dimension_t *dims = (halide_dimension_t *)alloca(info.ndim * sizeof(halide_dimension_t));
2121
_halide_user_assert(dims);
2222
for (int i = 0; i < info.ndim; i++) {
23-
if (INT_MAX < info.shape[i] || INT_MAX < (info.strides[i] / t.bytes())) {
23+
// Strides may be negative in both numpy and Halide. So check against both INT_MIN and
24+
// INT_MAX.
25+
const auto elem_stride = info.strides[i] / t.bytes();
26+
if (info.shape[i] > INT_MAX || elem_stride < INT_MIN || elem_stride > INT_MAX) {
2427
throw py::value_error("Out of range dimensions in buffer conversion.");
2528
}
26-
// Halide's default indexing convention is col-major (the most rapidly varying index comes first);
29+
// Reverse axes if requested.
2730
// Numpy's default is row-major (most rapidly varying comes last).
28-
// We usually want to reverse the order so that most-varying comes first.
31+
2932
const int dst_axis = reverse_axes ? (info.ndim - i - 1) : i;
30-
dims[dst_axis] = {0, (int32_t)info.shape[i], (int32_t)(info.strides[i] / t.bytes())};
33+
dims[dst_axis] = {0, (int32_t)info.shape[i], (int32_t)elem_stride};
3134
}
3235
return Halide::Runtime::Buffer<T, Dims, InClassDimStorage>(t, info.ptr, (int)info.ndim, dims);
3336
}

python_bindings/src/halide/halide_/PyCallable.cpp

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,6 @@ T cast_to(const py::handle &h) {
4747
}
4848
}
4949

50-
std::pair<bool, bool> is_any_contiguous(const py::buffer_info &info) {
51-
py::ssize_t c_stride = info.itemsize;
52-
py::ssize_t f_stride = info.itemsize;
53-
bool c_contig = true;
54-
bool f_contig = true;
55-
56-
for (size_t i = 0; i < info.ndim; ++i) {
57-
size_t c_idx = info.ndim - 1 - i;
58-
if (info.strides[c_idx] != c_stride) {
59-
c_contig = false;
60-
}
61-
c_stride *= info.shape[c_idx];
62-
63-
if (info.strides[i] != f_stride) {
64-
f_contig = false;
65-
}
66-
f_stride *= info.shape[i];
67-
}
68-
69-
return {c_contig, f_contig};
70-
}
71-
7250
} // namespace
7351

7452
class PyCallable {
@@ -105,28 +83,25 @@ class PyCallable {
10583

10684
const auto define_one_arg = [&argv, &scalar_storage, &buffers, &cci](const Argument &c_arg, py::handle value, size_t slot) {
10785
if (c_arg.is_buffer()) {
108-
// If the argument is already a Halide Buffer of some sort,
109-
// skip pybuffer_to_halidebuffer entirely, since the latter requires
110-
// a non-null host ptr, but we might want such a buffer for bounds inference,
111-
// and we don't need the intermediate HalideBuffer wrapper anyway.
11286
halide_buffer_t *raw_buffer;
11387
if (py::isinstance<Halide::Buffer<>>(value)) {
88+
// If the argument is already a Halide Buffer of some sort,
89+
// skip pybuffer_to_halidebuffer entirely, since the latter requires
90+
// a non-null host ptr, but we might want such a buffer for bounds inference,
91+
// and we don't need the intermediate HalideBuffer wrapper anyway.
92+
//
93+
// Do not reverse axes.
11494
auto b = cast_to<Halide::Buffer<>>(value);
11595
raw_buffer = b.raw_buffer();
11696
} else {
97+
// If it's a buffer-protocol object (e.g. a NumPy array), the convention
98+
// is to always reverse axes.
99+
constexpr bool reverse_axes = true;
117100
py::buffer py_buffer_value = cast_to<py::buffer>(value);
118101
const bool writable = c_arg.is_output();
119102

120103
const py::buffer_info value_buffer_info = py_buffer_value.request(writable);
121-
auto [c_contig, f_contig] = is_any_contiguous(value_buffer_info);
122-
123-
if (!c_contig && !f_contig) {
124-
throw Halide::Error("Invalid buffer: only C or F contiguous buffers are supported");
125-
}
126104

127-
// It is possible for a buffer to be both C and F contiguous
128-
// (e.g., a scalar or a 1D buffer).
129-
const bool reverse_axes = c_contig && !f_contig;
130105
buffers.buffers[slot] =
131106
pybufferinfo_to_halidebuffer<void, AnyDims, MaxFastDimensions>(
132107
value_buffer_info, reverse_axes);

python_bindings/test/correctness/addconstant_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test(addconstant_impl_func, offset):
4242
input_float = numpy.array([3.14, 2.718, 1.618], dtype=numpy.float32)
4343
input_double = numpy.array([3.14, 2.718, 1.618], dtype=numpy.float64)
4444
input_half = numpy.array([3.14, 2.718, 1.618], dtype=numpy.float16)
45-
input_2d = numpy.array([[1, 2, 3], [4, 5, 6]], dtype=numpy.int8, order="F")
45+
input_2d = numpy.array([[1, 2, 3], [4, 5, 6]], dtype=numpy.int8)
4646
input_3d = numpy.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=numpy.int8)
4747

4848
output_u8 = numpy.zeros((3,), dtype=numpy.uint8)
@@ -56,7 +56,7 @@ def test(addconstant_impl_func, offset):
5656
output_float = numpy.zeros((3,), dtype=numpy.float32)
5757
output_double = numpy.zeros((3,), dtype=numpy.float64)
5858
output_half = numpy.zeros((3,), dtype=numpy.float16)
59-
output_2d = numpy.zeros((2, 3), dtype=numpy.int8, order="F")
59+
output_2d = numpy.zeros((2, 3), dtype=numpy.int8)
6060
output_3d = numpy.zeros((2, 2, 2), dtype=numpy.int8)
6161

6262
addconstant_impl_func(

python_bindings/test/correctness/buffer.py

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import halide as hl
2-
import numpy as np
31
import gc
42
import sys
53

4+
import halide as hl
5+
import numpy as np
6+
67

7-
def test_ndarray_to_buffer(reverse_axes=True):
8+
# Tests constructing a hl.Buffer from an np.array.
9+
# `reverse_axes`: passed to hl.Buffer (default False).
10+
def test_ndarray_to_buffer(reverse_axes):
811
a0 = np.ones((200, 300), dtype=np.int32)
912

1013
# Buffer always shares data (when possible) by default,
11-
# and maintains the shape of the data source. (note that
12-
# the ndarray is col-major by default!)
14+
# and maintains the shape of the data source.
1315
b0 = hl.Buffer(a0, "float32_test_buffer", reverse_axes)
1416
assert b0.type() == hl.Int(32)
1517
assert b0.name() == "float32_test_buffer"
@@ -49,7 +51,7 @@ def test_ndarray_to_buffer(reverse_axes=True):
4951
assert a0[56, 34] == 12
5052

5153

52-
def test_buffer_to_ndarray(reverse_axes=True):
54+
def test_buffer_to_ndarray(reverse_axes):
5355
buf0 = hl.Buffer(hl.Int(16), [4, 6])
5456
assert buf0.type() == hl.Int(16)
5557
buf0.fill(0)
@@ -58,13 +60,12 @@ def test_buffer_to_ndarray(reverse_axes=True):
5860

5961
# This is subtle: the default behavior when converting
6062
# a Buffer to an np.array (or ndarray, etc) is to reverse the
61-
# order of the axes, since Halide prefers column-major and
62-
# the rest of Python prefers row-major. By calling reverse_axes()
63-
# before that conversion, we end up doing a *double* reverse, i.e,
64-
# not reversing at all. So the 'not' here is correct.
63+
# order of the axes. By calling reverse_axes() before that conversion,
64+
# we end up doing a *double* reverse, i.e, not reversing at all.
65+
# So the 'not' here is correct.
6566
buf = buf0.reverse_axes() if not reverse_axes else buf0
6667

67-
# Should share storage with buf
68+
# Should share storage with buf0.
6869
array_shared = np.array(buf, copy=False)
6970
assert array_shared.dtype == np.int16
7071
if reverse_axes:
@@ -74,7 +75,7 @@ def test_buffer_to_ndarray(reverse_axes=True):
7475
assert array_shared.shape == (4, 6)
7576
assert array_shared[1, 2] == 42
7677

77-
# Should *not* share storage with buf
78+
# copy=True -> should *not* share storage with buf.
7879
array_copied = np.array(buf, copy=True)
7980
assert array_copied.dtype == np.int16
8081
if reverse_axes:
@@ -84,7 +85,8 @@ def test_buffer_to_ndarray(reverse_axes=True):
8485
assert array_copied.shape == (4, 6)
8586
assert array_copied[1, 2] == 42
8687

87-
# Should affect array_shared but not array_copied
88+
# Modifying one element of buf0 should affect array_shared but not
89+
# array_copied.
8890
buf0[1, 2] = 3
8991
if reverse_axes:
9092
assert array_shared[2, 1] == 3
@@ -94,7 +96,8 @@ def test_buffer_to_ndarray(reverse_axes=True):
9496
assert array_copied[1, 2] == 42
9597

9698
# Ensure that Buffers that have nonzero mins get converted correctly,
97-
# since the Python Buffer Protocol doesn't have the 'min' concept
99+
# since the Python Buffer Protocol doesn't have the 'min' concept, the
100+
# numpy views will have the cropped extents but no min coordinate.
98101
cropped_buf0 = buf0.copy()
99102
cropped_buf0.crop(dimension=0, min=1, extent=2)
100103
cropped_buf = cropped_buf0.reverse_axes() if not reverse_axes else cropped_buf0
@@ -169,7 +172,7 @@ def test_bufferinfo_sharing():
169172
a0 = np.ones((20000, 30000), dtype=np.int32)
170173
b0 = hl.Buffer(a0)
171174
del a0
172-
for i in range(200):
175+
for _ in range(200):
173176
b1 = hl.Buffer(b0)
174177
b0 = b1
175178
b1 = None
@@ -539,13 +542,90 @@ def make_orig_buf():
539542
assert realized[row, col] == tag * 2
540543

541544

545+
def test_numpy_view():
546+
# numpy_view() always reverses axes by default, regardless of the Buffer's
547+
# storage order; reverse_axes=False keeps Halide's axis order. It works for
548+
# any layout, contiguous or not.
549+
550+
# Default storage order: dim 0 is densest (stride 1).
551+
buf = hl.Buffer(hl.Int(16), [4, 6])
552+
buf.fill(0)
553+
buf[1, 2] = 42
554+
555+
v = buf.numpy_view() # default reverse_axes=True
556+
assert v.dtype == np.int16
557+
assert v.shape == (6, 4) # axes reversed
558+
assert v[2, 1] == 42 # numpy[a0, a1] <-> halide[a1, a0]
559+
560+
v_no = buf.numpy_view(reverse_axes=False)
561+
assert v_no.shape == (4, 6) # Halide order preserved
562+
assert v_no[1, 2] == 42
563+
564+
# Both are views that share storage with the Buffer.
565+
v[0, 0] = 7
566+
assert buf[0, 0] == 7
567+
assert v_no[0, 0] == 7
568+
569+
# Allocate hl.Buffer with axis 1 densest instead.
570+
# numpy_view() doesn't care about storage order, just reverses indexing order.
571+
buf_f = hl.Buffer(hl.Int(16), [4, 6], [1, 0]) # store axis 1 densest
572+
buf_f.fill(0)
573+
buf_f[1, 2] = 24
574+
vf = buf_f.numpy_view()
575+
assert vf.shape == (6, 4)
576+
assert vf[2, 1] == 24
577+
578+
# Allocate a hl.Buffer that is neither C- nor F-contiguous.
579+
# numpy_view() still works but is a strided view.
580+
buf_nc = hl.Buffer(hl.Int(16), [4, 6, 8], [1, 0, 2])
581+
buf_nc.fill(0)
582+
buf_nc[1, 2, 3] = 99
583+
vnc = buf_nc.numpy_view()
584+
assert vnc.shape == (8, 6, 4)
585+
assert vnc[3, 2, 1] == 99
586+
587+
588+
def test_buffer_copy_no_reverse():
589+
# Constructing an hl.Buffer from another hl.Buffer must be a direct copy
590+
# (no axis reversal), even though hl.Buffer also satisfies the buffer protocol.
591+
b1 = hl.Buffer(hl.Int(16), [4, 6, 8], [1, 0, 2]) # non-square, non-default order
592+
b1.fill(0)
593+
b1[1, 2, 3] = 17
594+
595+
b2 = hl.Buffer(b1)
596+
assert b2.dimensions() == b1.dimensions()
597+
for i in range(b1.dimensions()):
598+
assert b2.dim(i).extent() == b1.dim(i).extent(), i
599+
assert b2.dim(i).stride() == b1.dim(i).stride(), i
600+
assert b2[1, 2, 3] == 17
601+
602+
# A bounds-query buffer (null host) can be copied without error.
603+
bq = hl.Buffer.make_bounds_query(hl.Int(16), [4, 6])
604+
bq_copy = hl.Buffer(bq)
605+
assert bq_copy.dim(0).extent() == 4
606+
assert bq_copy.dim(1).extent() == 6
607+
608+
609+
def test_ndarray_negative_strides():
610+
# NumPy arrays with negative strides should work.
611+
a = np.arange(6, dtype=np.int32)[::-1] # [5, 4, 3, 2, 1, 0], stride -1
612+
b = hl.Buffer(a)
613+
assert b.dim(0).extent() == 6
614+
assert b.dim(0).stride() == -1
615+
assert b[0] == 5
616+
assert b[5] == 0
617+
618+
542619
if __name__ == "__main__":
543620
test_make_interleaved()
544621
test_interleaved_ndarray()
545622
test_ndarray_to_buffer(reverse_axes=True)
546623
test_ndarray_to_buffer(reverse_axes=False)
547624
test_buffer_to_ndarray(reverse_axes=True)
548625
test_buffer_to_ndarray(reverse_axes=False)
626+
test_numpy_view()
627+
test_buffer_copy_no_reverse()
628+
test_ndarray_negative_strides()
549629
test_for_each_element()
550630
test_fill_all_equal()
551631
test_bufferinfo_sharing()

0 commit comments

Comments
 (0)