Repository navigation
Discussion: dtype system and integrating record types #254
Copy link
Copy link
Closed
Description
I've been looking at how record types can be integrated in rust-numpy and here's an unsorted collection of thoughts for discussion.
Let's look at Element:
pub unsafe trait Element: Clone + Send {
const DATA_TYPE: DataType;
fn is_same_type(dtype: &PyArrayDescr) -> bool;
fn npy_type() -> NPY_TYPES { ... }
fn get_dtype(py: Python) -> &PyArrayDescr { ... }
}npy_type()is used inPyArray::new()and the like. Instead, one should usePyArray_NewFromDescr()to make use of the custom descriptor. Should all places wherenpy_type()is used split between "simple type, useNew" and "user type, useNewFromDescr"? Or, alternatively, should arrays always be constructed from descriptor? (in which case,npy_type()becomes redundant and should be removed)- Why is
same_type()needed at all? It is only used inFromPyObject::extractwhere one could simply usePyArray_EquivTypes(like it's done in pybind11). Isn't it largely redundant? (or does it exist for optimization purposes? In which case, is it even noticeable performance-wise?) DATA_TYPEconstant is really only used to check if it's an object or not in 2 places, like this:Isn't this redundant as well? Given that one can always doif T::DATA_TYPE != DataType::Object
T::get_dtype().get_datatype() != Some(DataType::Object) // or, can add something like: T::get_dtype().is_object()
- With all the notes above,
Elementessentially is justpub unsafe trait Element: Clone + Send { fn get_dtype(py: Python) -> &PyArrayDescr; }
- For structured types, do we want to stick the type descriptor into
DataType? E.g.:Or, alternatively, just keep it asenum DataType { ..., Record(RecordType) }
DataType::Void? In which case, how does one recover record type descriptor? (it can always be done through numpy C API of course, viaPyArrayDescr). - In order to enable user-defined record dtypes, having to return
&PyArrayDescrwould probably require:- Maintaining a global static thread-safe registry of registered dtypes (kind of like it's done in pybind11)
- Initializing this registry somewhere
- Any other options?
Elementshould probably be implemented for tuples and fixed-size arrays.- In order to implement structured dtypes, we'll inevitably have to resort to proc-macros. A few random thoughts and examples of how it can be done (any suggestions?):
-
#[numpy(record)] #[derive(Clone, Copy)] #[repr(packed)] struct Foo { x: i32, u: Bar } // where Bar is a registered numpy dtype as well // dtype = [('x', '<i4'), ('u', ...)]
- We probably have to require either of
#[repr(C)],#[repr(packed)]or#[repr(transparent)] - If repr is required, it can be an argument of the macro, e.g.
#[numpy(record, repr = "C")]. (or not) - We also have to require
Copy? (or not? technically, you could have object-type fields inside) - For wrapper types, we can allow something like this:
-
#[numpy(transparent)] #[repr(transparent)] struct Wrapper(pub i32); // dtype = '<i4'
- For object types, the current suggestion in the docs is to implement a wrapper type and then impl
Elementfor it manually. This seems largely redundant, given that theDATA_TYPEwill always beObject. It would be nice if any#[pyclass]-wrapped types could automatically implementElement, but it would be impossible due to orphan rule. An alternative would be something like this:#[pyclass] #[numpy] // i.e., #[numpy(object)] struct Foo {}
- How does one register dtypes for foreign (remote) types? I.e.,
OrderedFloat<f32>orWrapping<u64>or somePyClassFromOtherCrate? We can try doing something like what serde does for remote types.
-
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
aldanor commentedon Jan 9, 2022
Related: #234, #186.
adamreichold commentedon Jan 9, 2022
W.r.t. the question on why
Elementlooks it does, I can only answerfor lack of involvement in the other design decisions. Here, we discussed that a simple
const TRIVIALLY_COPYABLE: bool;could serve the same purpose but having the enum does not really hurt either. In any case, this being a compile-time constant is certainly nice as it will completely remove one of the two branches from the generated code where this is checkedI think having a GIL-protected lazily initialized static like in
rust-numpy/src/slice_container.rs
Lines 109 to 112 in eb1068b
might be preferable to a single global data structure?
While the overhead of producing a
PyArrayDescfrom a suitably synchronized data structure is probably small, I suspect that not having to produce it is still faster. Or does NumPy resort toPyArray_DescrFromTypeinternally in any case?adamreichold commentedon Jan 9, 2022
That seems to be the case indeed:
https://github.com/numpy/numpy/blob/1798a7d463590de6dc0dfdad69735d92288313f3/numpy/core/src/multiarray/ctors.c#L1105
So we can probably replace our calls to
PyArray_New. The only remaining difference I see is that we have to go throughPY_ARRAY_APIwhich implies an additional acquire load.I think it would actually be helpful to ignore this part completely for now and design the necessary types without any macro support first. The proc-macros should essentially only relieve programmers from writing repetitive and error prone code, shouldn't they?
aldanor commentedon Jan 9, 2022
Then my initial guess was correct. I wonder though whether it's actually noticeable, i.e. has anyone tried to measure it? With all the other Python cruft on top, I would strongly suspect there'd be no visible difference at all. I understand where it comes from, it's just there's another implicit logical requirement in the
Elementtrait - and it's thatSelf::get_dtype(py).get_datatype() == Some(Self::DATA_TYPE)- and this requirement is imposed only (?) to haveDATA_TYPEas a compile time thing instead of runtime (where at runtime it's a single loaddtype_ptr->type_num).With record types, it's pretty much a requirement; anything else would be extremely boilerplate-ish and unsafe (with the user having to provide all offsets and descriptors for all fields manually). It might be nice to leave an option to construct descriptors manually at runtime, but I can't see a single real use case for it off top of my head. So, the necessary types and the infrastructure have to be designed in a way that would make it trivial to integrate them into proc macros, from the get go.
Yes, I think it does (and so does pybind11).
Yep - thanks for pointing that out, something like that should be sufficient.
aldanor commentedon Jan 9, 2022
Just checked it out: if one replaces
rust-numpy/src/array.rs
Line 850 in eb1068b
with something runtime like
the difference in
from_sliceruntime for an array of 5 elements is 1ns (20ns vs 21ns).For comparison, creating arange(1, 5, 1) is 31ns.
So yea... you could say it's noticeable (but only if you create hundreds millions of arrays).
adamreichold commentedon Jan 9, 2022
ATM, the
DATA_TYPEmember is used to generate default implementations for the other methods, i.e. it is a utility to simplify the implementation. Ifsame_typeandnpy_typeare dropped in favour of usingget_dtypewe could certainly remove remove this member. But I also do not really see a significant cost to having at least aconst TRIVIAL: bool;member.I think if anything has measurable overhead, then it is acquiring the reference to a
PyArrayDescitself, either by accessingPY_ARRAY_API.PyArray_DescrFromTypeor by accessing a lazily initialized static.proc-macros generate code that is injected into the dependent crate, i.e. they have to use this crate's public API and all the code they produce must have been possible to be written by hand. The point is not that I expect user code to manually produce those trait implementations (but avoiding the build time overhead of proc-macros would be one reason to do so), but that we can focus the discussion on the mechanisms and leave the proc-macro UI for later.
adamreichold commentedon Jan 9, 2022
One possible caveat with a targeted benchmark here is that deciding this at compile time is about removing code and thereby reducing instruction cache pressure which could only be noticeable when other code shares the address space.
adamreichold commentedon Jan 9, 2022
As a first actionable result of your findings, why not start with a PR to simplify the
Elementtrait to avoid havingsame_typeandnpy_typeand directly producing a descriptor viaPyArrayDescr::from_npy_type? I think that would certainly be a nice simplification. Personally, I would like this combined with aconst TRIVIAL: boolflag, but I understand that I am most likely not the only one with an opinion on this.adamreichold commentedon Jan 9, 2022
I fear that it might actually be unsound: During extraction, we only check the data type to be object, but not whether all the elements are of type
Py<T>for the givenT: PyClass. So e.g..readonly().as_array()would enable safe code to call methods onTeven though completely different - possibly heterogeneously typed - objects are stored in the array.I fear we need to remove this suggestion and limit ourselves to
Py<PyAny>as the only reasonableElementimplementation for now. I also wonder how checking for homogeneity during extraction is best handled if we do want to properly supportPyArray<Py<T>, D>.aldanor commentedon Jan 9, 2022
While I'm often guilty in micro-optimizing Rust code down to every single branch hint myself, I just don't think it matters here, the last thing you're going to want to be doing in numpy bindings is instantiate millions of array objects in a loop - that was kind of my point.
Here's a few more thoughts on what would need to happen if
const DATA_TYPEremains in place:DATA_TYPE != DataType::Objectwould no longer be sufficient - because record dtypes can be both trivially copyable (requires all fields to be trivially copyable) and not.DataType::Record(<...>)would need to be const-constructible as well. This implies that e.g. all fields names need to be&'static, all offsets are const and so on. It's probably doable but will be tough (see below).std::ptr::addr_of!()has been only stabilized in the same release (1.51.0), if I remember correctly.Oh yea, and also, record types can be nested - that would be fun to implement as const as well (at runtime we can just box it all on the heap).
aldanor commentedon Jan 9, 2022
The note above only applies if it remains as
DATA_TYPE, of course.If it's changed to
IS_POD: bool, thenDataTypecan become a complex non-const type (to host the record dtype descriptor), etc.aldanor commentedon Jan 9, 2022
Yea, I was asking myself whether I was reading that code correctly - it only checks for
Objectdtype but not the type of objects themselves.There's three options:
Py<PyAny>, in which case it's always good and safe.Py<T>, in which case, unfortunately, we need toisinstance-check every single element in order to keep it safe.Py<T>which would be nice to keep available.aldanor commentedon Jan 9, 2022
Yes, I've already started on this, as a first step, will try to push something shortly. There's actually a few more subtle dtype-related bugs I've uncovered in the process that I'll fix as well (the tests are very sparse so I guess noone noticed anything so far...).
aldanor commentedon Jan 10, 2022
(This is a bit of a wall of text, so thanks in advance to whoever reads through it in its entirety. I tried to keep it as organized as possible.)
tagging @adamreichold @kngwyu @davidhewitt
Ok, now that step 1 (#256) is ready to be merged, let's think about next steps. I'll use pybind11 as reference here since that's what I'm most familiar with having implemented a big chunk of that myself in the past.
Descriptor (format) strings and PEP 3118
In pybind11, dtypes can be constructed from buffer descriptor strings (see
PEP 3118for details). It actually calls back to NumPy's Python API to do that (numpy.core._internal._dtype_from_pep3118), but it's possible to reimplement it manually if we want to - I would actually learn towards the latter, provided we borrow and verify all the related tests from numpy itself.The benefit of using descriptor strings - they're easy to hash if a registry is used, there's no nesting, etc - it's just a simple string that can cover any dtype possible, including scalar types, object types, multi-dimensionals subarrays, recarrays, and any combination of the above. We can also generate them with proc-macros at compile time and make them
&'staticif need be.Now, if we go this route, we'll have to delve into "format chars", "type kinds", "buffer descriptor strings" etc. There's obviously a big overlap with
pyo3::buffermodule as there'sElementType::from_formatand all the stuff around it, but it won't be sufficient without some reworking. Which raises the question: do we want to copy/paste stuff inpyo3::bufferand reimplement it to suit our needs? Or do we want to make it pybind11-like so that numpy arrays and buffers are tightly bound together, sharing the same infrastructure? See the next section for more details.Buffers,
pyo3::bufferandrust-numpyIn pybind11, buffers and arrays are bound together (like they should be), i.e. you can instantiate one from another (both ways) while controlling who owns the data. There's no such thing in rust-numpy and I think it's a pretty big gap.
There's
pyo3::bufferthat we can try to integrate with, but, off top of my head, there's some issues with it. But before we continue, consider an example:Here,
arr.datais the matching Python buffer (which you can also get viamemoryview(arr)). Normally, you can donp.array(arr.data)and get the identical array back, but funnily enough, this exact example triggers the padding bug I have already reported in numpy upstream 6 years ago (numpy/numpy#7797) and which was then patched manually in pybind11 by hand.Back to
pyo3::buffer:Here's what's wrong with this enum:
Float(3)It's basically a distant relative of
DataTypethat we have just removed from rust-numpy in favor of a single dtype pointer. With these limitations, there's no way we can make proper use ofpyo3::bufferand we'll end up duplicating lots of its logic in rust-numpy.And here's the trait:
Note that this is similar to
numpy::dtype::Elementandis_compatible_formatis equivalent tosame_type()which we have just cut out. And again, there's some issues with it:Copyis unnecessary as it blocks object typesCopybound is removed, then some methods likePyBuffer::copy_to_slice()are no longer valid and should be rewritten same way as in rust-numpy (takingIS_COPYinto account)u32) - kind of like we can do withnumpy::dtype::<u32>(py).One thing that could be done to improve it would be this:
And then
ElementTypecan be removed. Alternatively, it can be replaced withFormatString(&CStr)which would then be returned byElement::format().Note how close it is now to the newly reworked
numpy::Element- the only difference being in nul-terminated string pointer being returned instead of the dtype pointer.Note also that any valid
T: pyo3::buffer::Elementis (logically speaking) also a validnumpy::dtype::Elementsince dtype ptr can always be built from a valid format string (the inverse is not always true, e.g. due toMandmtypes; but in most cases it can be worked around if need be).What to do next
Should the buffers be touched up in pyo3 first, so we can then use that in rust-numpy to add support for record types, subarrays, etc? In which case, what exactly should be done?
One interesting point for discussion may be - could we share some of the functionality between buffers and numpy arrays? (in pybind11,
arrayis derived frombufferandarray_tis derived fromarray- which is quite nice and avoid lots of duplication) Just some random thinking, e.g. maybe buffer-like types like the buffer itself or numpy array could deref to some common base type which would provide shared functionality. Or maybe we could somehow try and connect the twoElementtraits together.adamreichold commentedon Jan 10, 2022
Just my first thoughts after reading:
derive(Hash)and while nesting is usually handled viaBox, I think it could also be handled via references to produce static data.)Elementtrait here with theElementtrait frompyo3::buffer, even though I would prefer to keep using a strongly-typed representation of the format likeElementType.30 remaining items
aldanor commentedon Jan 24, 2022
Some more progress updates (this is kinda where all of this has been heading from the start) - here's one of the most recent tests.
This compiles and runs:
There's still lots of edge cases and bugs to be squashed and tests to be written etc, but on the surface, the core of it seems to be working quite nicely.
adamreichold commentedon Jan 27, 2022
If the semantics of the same type definition with and without the flag are different on the Python side, maybe we should give the user access to it, e.g. make it a parameter to
dtype_from_type_descriptor()? Or do we need it is part ofTypeDescriptorwhen converting a dtype into it?I think we should definitely add this and do it in the same way as std or PyO3: Add impls up to length 32 using macros on older compilers and use const generics on newer ones dropping the small array macros when our MSRV includes support for const generics.
Personally, I would very much like this crate to provide only a minimal and efficient conversion API without providing any operations on the wrapper types itself. (I think this also applies to arrays itself and we should aim to focus the API to getting from/to ndarray and nalgebra types.)
aldanor commentedon Jan 27, 2022
Yea, I had the same feeling. For now I've just added transparent wrapper types themselves that can only do two things (aside from being registered in the dtype system) - be constructed from
i64and be converted intoi64; the rest is up to whoever's using them.I've added the min-const-generics part of it, but I can also add the macro-based impl for up-to-length-32 arrays for older compiler versions. Personally, I'm not a huge fan of
min_const_generics-like features all over the place, as I think it's cleaner to add a temporary dependency (until MSRV is high enough) and do it like so:The main benefit is that we won't break some dependent crates the day we kill the
min_const_genericsfeature, as all of this logic stays completely internal to our crates and can be viewed as an implementational detail.I think, first of all, we need to categorise how a type descriptor can be attached to a type. There will be two ways:
#[derive(numpy::Record)]- this is probably what would be used 99% of time.unsafe implmanually. Not sure why you'd want to do that, but perhaps there's some reason (e.g. you're codegen-ing something).There's an
alignment: Option<usize>field inRecordDescriptorwhich we allow to be set toNone- this should cover case 3 as you're in control of that yourself. In cases 1 and 2 the derive macro would automatically set it toSome(std::mem::align_of::<T>()). So perhaps, if you really want a numpy dtype withalign = False, we can allow something like a#[record(align = false)]attribute in the derive macro - this would cover cases 1 and 2. All that's left then is to ensureNPY_ALIGNED_STRUCTis not set whenalignment.is_none()?adamreichold commentedon Jan 27, 2022
Yeah, I think using a crate like
rustversioninstead of a public feature is fine.I guess this where I am not sure ATM. Reading the NumPy C API, I get the impression that the idea is that
PyArray_Descr::alignment(that one stored infieldsor parent) should always be set to the necessary alignment even if the field offset does not respect that. Which is why I am not sure whether tyingalignment == NonetoNPY_ALIGNED_STRUCTmakes sense (or actually whetheralignmentshould be anOptionat all).I think we should always have an alignment (even if trivial, i.e. one) and we should set the
NPY_ALIGNED_STRUCTfield whenever the contents of aRecordDescriptorare indeed aligned (by checking thealignmentof the field type descriptors against the offsets of the fields).My understanding of the
alignargument to thedtypeconstructor is at this moment basically like "here is a record type but I can't be bothered to work out the correct offsets so please do the padding for me" which would be a courtesy thatTypeDescriptorjust does not need to provide.aldanor commentedon Jan 27, 2022
The way it works in numpy itself (
_convert_from_dict),IIUC,
descr->alignmentwill be set to something different from 1 only ifalign=Trueis passed in explicitly, and in that caseNPY_ALIGNED_STRUCTflag will always be set.There's 4 cases in numpy constructor itself:
So, the following must hold true: "if
descr->alignment != 1, thenNPY_ALIGNED_STRUCTis set". The reverse, obviously, does not need to hold trueBack to our business - I guess we can do something like this?
Where
is_alignedcan be:Some(true)= setdescr->flag; setdescr->alignment; when creating dtype, verify that all fields are indeed C-alignedSome(false)= don't set the flag; don't check anything; (setdescr->alignmentor not? probably set it to 1?)None= current behaviour and the default: if all fields are C-aligned, then set the flag and alignment field; otherwise, don't set the flag (and leavedescr->alignmentas 1?)aldanor commentedon Jan 27, 2022
Here's an illustration:
which outputs
adamreichold commentedon Jan 27, 2022
The semantics look reasonable to me. I do still wonder whether
is_alignedshould be part ofRecordDescriptorinstead of an argument todtype_from_type_descriptor? For round-tripping from dtype to dtype viaTypeDescriptor?So this means that NumPy treats this like a
repr(packed)struct even if the fields are indeed aligned? Weird. (And does this mean the relevant fields will be read using the moral equivalent ofread_unaligned, e.g. through achar*pointer?)aldanor commentedon Jan 27, 2022
align=Trueor not from theisalignedstructflag.RecordDescriptor::alignmentfrom a numpy dtype though. Ifdescr->alignment == 1, it does not mean a single thing, it's simply an alignment ofchar.alignment: Option<usize>which is what I suggested initially 😄 Ifisalignedstructis false, thendescr->alignmentis surely 1 which corresponds toNonein our world. Otherwise, it'sSome(descr->alignment)which is now a meaningful value. And vice versa, so it should roundtrip, no?Yes, it sort of does. But I don't think it makes a distinction between aligned and unaligned reads, all that is left to the compiler. You can see that the usage of
NPY_ALIGNED_STRUCTdoesn't go too far beyonddescriptor.cand creating dtypes.aldanor commentedon Jan 27, 2022
Ok, re: the last comment, maybe I wasn't entirely clear. In some cases, NumPy does distinguish between aligned and unaligned arrays.
array_method.c:Whether the array is aligned or not, e.g. if you select a field like
arr['y']from one of the examples above, does not depend onNPY_ALIGNED_STRUCTflag, but rather on the actual pointer offsets, strides, etc.So, in the example above, examples 2, 3 and 4 will have
arr.flags.aligned = Truefor field'y'(regardless of what thedescr->alignmentis set to), whereas 1 and 5 will havearr.flags.aligned = False.adamreichold commentedon Jan 27, 2022
My impression is that indeed this means that NumPy will store these records unaligned? So resulting type descriptor would match a Rust type with
repr(packed)if the offsets are indeed packed or something that cannot be written down as a struct (basically "repr(packed)" but with offsets as if without it). (Which seems to be true generally, i.e. there a lot of NumPy dtypes andTypeDescriptorinstances which cannot be produced via#[derive(numpy::record)]on any Rust struct.)adamreichold commentedon Jan 27, 2022
(Sorry, didn't mention that: Personally, I don't think we need roundtripping. I am basically only interested in the
TypeDescriptortodtypedirection which is why I am asking if we can simplify things by ignoring the reverse mapping.)aldanor commentedon Jan 27, 2022
Well, to start with... NumPy allows overlapping fields. As long as neither of them contains
PyObject*.I think we have to do the reverse mapping, which will be used in
FromPyObjectand the like. Instead of pre-existing "EquivTypes" logic in numpy API (which is overburdened, slow and extremely spaghetti), we can do whatever we want. So, it could work like this:What exactly do you mean by that? You can pass offsets
[0, 8]withalign=Falseas in example above - and the resulting dtype will havedescr->alignment=1, but, in reality, it will be aligned. Also, the array made of this dtype will haveNPY_ARRAY_ALIGNEDflag on. And both of the field slices will have this flag on.It will only be 'packed' if you don't specify offsets. In this case
alignflag lets you choose either way of computing them (this is the part we really don't care about as we have our own offsets).adamreichold commentedon Jan 28, 2022
I have to think about this. My first instinct is that this would rather be an argument for improving the NumPy code instead of rolling our own.
From your example in #254 (comment), I got the impression that
align=Trueis not necessarily recursive. So if you have a record type with actually aligned offsets butalignment=1and put that into an outer record and setalign=Truethat this subrecord could be placed misaligned thereby also misaligning its fields whereas if the subrecord type itself hadalignment > 1, padding would be added as necessary and the alignment of the subrecord field would imply alignment of its fields.