Skip to content

Add the buffer interface for wrapped STL vectors #488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 14, 2017

Conversation

patstew
Copy link
Contributor

@patstew patstew commented Nov 8, 2016

Allows use of vectors as python buffers, so for example they can be adopted without a copy by numpy.asarray
Allows faster conversion of numeric buffers to vectors with memcpy instead of individually casting the elements

@aldanor
Copy link
Member

aldanor commented Nov 8, 2016

FYI: note that you can already wrap vectors as numpy arrays without copying, kind of like so:

struct Data {
    std::vector<int> vec;
};

py::class_<Data>(m, "Data")
    .def("vec", [](Data& self) -> py::array {  // no copying
        return {self.vec.data(), self.vec.size(), py::cast(self);
    });

Untested, but something like this should work.

@patstew patstew force-pushed the master branch 3 times, most recently from e8da38b to de72c33 Compare November 8, 2016 16:38
@patstew
Copy link
Contributor Author

patstew commented Nov 8, 2016

That's cool, I didn't know that. But going that way would require me to write a load of little wrapping lambdas for my functions, or change my C++ API to return those proxy objects. This way seems easier to me, and the buffer interface has more general use without necessarily using numpy.

@wjakob
Copy link
Member

wjakob commented Nov 13, 2016

Neat!

@aldanor: the advantage of this version is that the original instance exposes all the necessary interfaces. (i.e. no need to call an extra function like .vec()).

The restriction to arithmetic-only types if of course the main disadvantage - @aldanor's NumPy interface can handle much fancier structured data types.

I wonder if there is a way to expose a NumPy interface (analogous to the buffer interface) that works for more complex structured types, and without calling an extra conversion function?

@patstew
Copy link
Contributor Author

patstew commented Nov 13, 2016

I guess it would be possible to write some template machinery to let you explicitly declare members of a struct, or use a std::tuple, and match the types to the python format. But I'd guess that you'd run into problems with C++ and python disagreeing about alignment in those cases? I guess it'd be OK for a Point { float x, float y } sort of thing, but once you start mixing types is the memory layout guaranteed?

@aldanor
Copy link
Member

aldanor commented Nov 15, 2016

@patstew That's exactly what "record array" part of <pybind11/numpy.h> does :) E.g. figures out the exact offsets, alignment, supports nested structs, etc, but you have to call a macro and pass it the field names. A fairly good demo of supported types is in the tests.

Why the "is_arithmetic" restriction? I don't why this would work with structured dtypes. PYBIND11_NUMPY_DTYPE registeres both npy_format_descriptor<> and format_descriptor<>, so it should work in this case just as well?

Re: "not bool", this bit looks a tad flaky; I guess what you really want to check is that the container type is not a proxy and underlying memory is contiguous. Simply checking that .data() returns a pointer is sufficient for STL containers, I think (vector<bool> doesn't have it in the specialization).

@patstew
Copy link
Contributor Author

patstew commented Nov 15, 2016

There we go, it now goes to and from structured arrays too. I've stopped pybind11 from declaring aligned types as unaligned in the format string and added a function to compare format strings, so that I can tell when a numpy structured dtype and a c++ one are the same, even when the format strings are not literally the same. numpy tends to declare padding as 'xxx' rather than '3x' and doesn't add trailing padding to the format string. At least for me on windows, numpy and c++ don't agree on whether int32 is int or long so I allow 'i' == 'l' when appropriate. I'm also ignoring the field names.

@patstew patstew force-pushed the master branch 2 times, most recently from 5dce001 to a8e1fb7 Compare November 16, 2016 03:33
@aldanor
Copy link
Member

aldanor commented Nov 16, 2016

Ok, so it looks like this is now 2 (3?) separate issues, and as such it may even make sense to break it down into separate PRs.

  1. Recording alignment of fields for structured dtypes. Makes sense to have -- however it would be also nice to add a test that actually fails without this (i.e. at the current master branch) at least on one platform. (We also ignore byte ordering at the moment btw)

  2. The actual vector/buffer interface

Re: compare_format_descriptor(), congrats, you've started partially reimplementing functionality from numpy.core._internal :) While your code is likely to be correct (not clear; needs extensive tests if this is to be merged in), tbh I wouldn't be a huge fan of maintaining it. Buffer format strings are horrible, and there's always a myriad of weird corner cases.

One alternative option would be to rely on numpy for comparing structured types (you have to register them through numpy anyway, so this makes sense). You'll still need special-casing even for arithmetic types, but now that would be a lot simpler. Another option is to use numpy dtypes for comparing all underlying value types; this imposes reliance on numpy but would be a bit cleaner.

There's already a py::dtype(const buffer_info&) ctor, and there's py::dtype::of<T>() for all registered types. Dtypes can then be compared for equivalence (not equality), which should handle the corner cases like sizeof(int) == sizeof(long) on Windows platforms (will still need tests).

@aldanor
Copy link
Member

aldanor commented Nov 16, 2016

@wjakob @patstew

@aldanor: the advantage of this version is that the original instance exposes all the necessary interfaces. (i.e. no need to call an extra function like .vec()).

Well, there's NumPy array interface which is very simple to implement and doesn't require dancing around Python buffer protocol and its format strings. Then the original instance would also "expose all the necessary interfaces" as you could just np.asarray(vec), or even just pass it in places where numpy array is expected.

@patstew
Copy link
Contributor Author

patstew commented Nov 16, 2016

Ok, I've cut out some of the extra stuff for this PR. It basically means that the fast init path won't work as often, but exposing vectors works fine. I've added some non-numpy tests too.
My original reason for adding this was so that passing around big vector<char>s (from file/network/etc) wasn't doing quite so much unnecessary work. The numpy aspect was a nice to have, and handy for testing across py2/py3. I'd prefer not to tie the whole thing to numpy.
Perhaps we could have a simple compare_format_descriptor() (perhaps just string ==) and overload it if py::dtype::of<T> exists?
I've split the unaliged buffer bit into a separate PR, and can do a new PR for the compare_format_descriptor() later with some guidance on what form is preferable.

@aldanor
Copy link
Member

aldanor commented Nov 21, 2016

@patstew So, it looks like the cases are:

  1. T is a non-structured type. In this case you can compare format strings, and even handle some platform-dependent weirdness manually (like i and l on Windows). Maybe it'd make sense to have a normalize_scalar_format() function that would handle all that instead of a comparison function? With a bit of effort, you could probably even make checks like sizeof(int) == sizeof(long) compile-time.

  2. T is a structured type (basically, format string is not a single character, or it contains '{'?). Currently, you can only register those via PYBIND11_NUMPY_DTYPE, so we know that NumPy is present, and we can compare dtypes for equivalence, namely py::dtype::of<T>() and dtype constructed from buffer info object.

Note that you could still use NumPy dtype comparison in (1), but if comparing format strings for simple scalars is sufficient then it's fine.

@aldanor
Copy link
Member

aldanor commented Nov 21, 2016

Here's another thought, maybe there could be a separate type for format strings, e.g. buffer_format, implicitly convertible from/to std::string so as not to break existing code, and implementing comparison op following the logic above. This way it could be more reusable, since it's not stl-vector-specific inherently.

@patstew
Copy link
Contributor Author

patstew commented Nov 21, 2016

Using the numpy comparison function seems to fail when there's trailing padding required, because when you create a dtype from the buffer string numpy returns there is no trailing padding, so EquivTypes returns false. I suspect it's to do with this numpy/numpy#7798
Code with failing test here: https://github.com/patstew/pybind11/tree/compare_buffer_numpy

I still think this PR is useful on its own. Are there any issues with it? Making it accept more struct types can be added as a new PR.

@aldanor
Copy link
Member

aldanor commented Nov 21, 2016

@aldanor
Copy link
Member

aldanor commented Nov 21, 2016

(as in, if the padding doesn't get stripped properly by py::dtype, it's a bug we have to fix regardless; I'll try to take a look at that branch)

@@ -326,6 +327,36 @@ template <typename Vector, typename Class_> auto vector_if_insertion_operator(Cl
);
}

// Provide the buffer interface for vectors if we have data() and we have a format for it
// GCC seems to have "void std::vector<bool>::data()" - doing SFINAE on the existence of data() is insufficient, we need to check it doesn't return void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this? Are we treating GCC specially here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell just doing SFINAE with decltype(std::declval<Vector>().data()) does what I expect on MSVC, but it gets enabled for vector<bool> on GCC (which shouldn't have a data member). !is_same_v<decltype(...), void> works for both, which suggests to me that GCC sees void data(), though I may be misunderstanding.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is just to detect the bool special case, wouldn't it be more transparent to SFINAE on that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did that originally, aldanor said he'd prefer checking for data().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, GCC has void std::vector<bool>::data(). If vector's data() returns a pointer type, it's not a proxy, I guess this was my initial point. But if the consensus is to just check for bool so the sfinae is simpler so be it....

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of checking that it isn't void, why don't you check that it is a Vector::value_type *? (Ultimately that seems to align better with what actually matters in the code)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing, in theory, to prevent this from working with some std::vector-like custom class (just like bind_vector), so I think the check is worthwhile, but it should really just check for what we want instead of checking for not being what the current stdlibc++ implementation does.


try {
//numpy.h declares this for arbitrary types, but it may raise an exception if PYBIND11_NUMPY_DTYPE hasn't been called
py::format_descriptor<T>::format();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what happens if stl_bind.h is included but not numpy.h?

Copy link
Contributor Author

@patstew patstew Nov 22, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works for the ordinary arithmetic types declared in common.h, or fails to compile because the default implementation of py::format_descriptor is empty. numpy.h provides a specialisation for all POD types, which may throw at runtime if the type isn't declared properly.
The py::format_descriptor<typename Vector::value_type>::format() SFINAE bit means it never hits the 'fail to compile' case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok -- this sounds good to me then.

@wjakob
Copy link
Member

wjakob commented Nov 22, 2016

Note: this PR is currently conflicted and needs to be rebased on top of master.

@patstew patstew force-pushed the master branch 2 times, most recently from 870dca5 to 6ac109f Compare November 22, 2016 13:32
@patstew
Copy link
Contributor Author

patstew commented Nov 22, 2016

I've fixed the issue with padding in that branch (https://github.com/patstew/pybind11/tree/compare_buffer_numpy) now. I'll submit it as a new PR once this has gone in, unless you'd like me to add it to this PR. I suspect it warrants its own discussion though.

@aldanor
Copy link
Member

aldanor commented Nov 22, 2016

@patstew Yes, the strip_padding fix/hack looks correct to me; I actually fixed it almost exactly the same way locally while looking at why your branch fails.

Would you mind opening a separate PR just for that first? (+ a test in test_numpy_dtypes that would be broken on the current master but would get fixed with the strip_padding fix)

@patstew
Copy link
Contributor Author

patstew commented Nov 22, 2016

Appveyor seems to have taken an hour and not actually started building at all...

@jagerman
Copy link
Member

No, it's more of a you-'re-on-your-own as far as lifetime management, essentially the same thing you get with rvp::reference.

@patstew
Copy link
Contributor Author

patstew commented Feb 7, 2017

This was broken by the addition of py::buffer_protocol(), I've fixed that and a merge conflict. I'm not sure why AppVeyor is refusing to build it?

@dean0x7d
Copy link
Member

dean0x7d commented Feb 8, 2017

The CI hooks may not have triggered correctly. Try pushing a new commit and see if it recovers.

@patstew patstew force-pushed the master branch 3 times, most recently from 7aab954 to c49459e Compare February 9, 2017 19:31
@patstew
Copy link
Contributor Author

patstew commented Feb 15, 2017

@wjakob @aldanor @jagerman Are there any outstanding issues with this?

@jagerman
Copy link
Member

@patstew: the PR currently disables (comments out) most of the travis-ci builds, which obviously need to be undone.

if (info.ndim != 1)
throw pybind11::type_error("Only 1D buffers can be copied to a vector");
if (info.strides[0] != sizeof(T))
throw pybind11::type_error("Item size mismatch (Python: " + std::to_string(info.strides[0]) + " C++: " + std::to_string(sizeof(T)) + ")");
Copy link
Member

@jagerman jagerman Feb 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this strides[0] == sizeof(T) requirement is desirable—it seems only to be a requirement of the vector constructor you are using, not of the type itself. It should be easy enough to support arbitrary strides, which let slices (e.g. single rows/columns of numpy matrices) work as vector buffer inputs. Something like this (untested) as a replacement for the Vector constructor ought to work:

new (&vec) Vector();
vec.reserve(info.shape[0]);
for (void *ptr = info.ptr, void *end = info.ptr + info.strides[0] * info.shape[0]; ptr != end; ptr += info.strides[0])
    vec.push_back(*static_cast<T *>(ptr));

(A small test to see that this really does work would be good as well).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added with test

@patstew patstew force-pushed the master branch 4 times, most recently from d5e2839 to 2930aeb Compare February 16, 2017 12:27
m[2] = 5
assert v[2] == 5

v = VectorInt(a[:, 1])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍

@wjakob
Copy link
Member

wjakob commented Feb 26, 2017

I took a brief look at the latest iteration of this PR. I realize that it has been in the works for quite a while. Right now I'm still not 100% excited about it, for these reasons:

  1. It's a quite heavy addition to add the buffer interface to every std::vector binding. I'm concerned about codebases which use many different kinds of vectors and potentially don't even need this feature. It would be good if this is opt-in.

  2. Some parts of this patch could be implemented more elegantly building on top of the existing array_t<>.I'm talking about stuff like

for (char *p = static_cast<char*>(info.ptr), *end = static_cast<char*>(info.ptr) + info.shape[0] * info.strides[0]; p < end; p += info.strides[0])
            vec.push_back(*reinterpret_cast<T*>(p));

In practice, I suppose that non-trivial types will require NumPy support in any case.

Thoughts?

@patstew patstew force-pushed the master branch 2 times, most recently from 7b51cc7 to 736a59f Compare February 27, 2017 00:57
@patstew
Copy link
Contributor Author

patstew commented Feb 27, 2017

I've made it conditional on the existing py::buffer_protocol() option.
I've only just added that particular monstrosity to deal with strided arrays as @jagerman requested. I'd prefer not to make this wholly tied to numpy or array_t, my original motivation was that passing vectors of several MBs for IO was taking a noticeable amount of time going through all the cast functions. The more advanced numpy structs and strides stuff is there because you guys requested it :).

@jagerman
Copy link
Member

That monstrosity could probably be made much more readable if the *end = static_cast<char*>(info.ptr) + info.shape[0] * info.strides[0] was before the for loop instead of as a double initializer. (I take the blame; that was copied directly from my suggestion).

Allows use of vectors as python buffers, so for example they can be adopted without a copy by numpy.asarray
Allows faster conversion of buffers to vectors by copying instead of individually casting the elements
Allows equivalent integral types and numpy dtypes
@patstew
Copy link
Contributor Author

patstew commented Feb 27, 2017

I've got rid of the reinterpret_cast too, by assuming (and checking) that strides[0] is a multiple of itemsize (Python docs say it should be) and itemsize == sizeof(T) (which it should if the format matched).

@dean0x7d
Copy link
Member

This seems like a very useful feature and it's been cooking for a while. Are there any blockers left?

@dean0x7d dean0x7d mentioned this pull request Mar 13, 2017
@wjakob
Copy link
Member

wjakob commented Mar 14, 2017

No, I think it looks great now! Thank you @patstew!

@wjakob wjakob merged commit 0b6d08a into pybind:master Mar 14, 2017
@wjakob
Copy link
Member

wjakob commented Mar 14, 2017

(I've added you to the acknowledgements in README.md)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants