Added Rust initialisation of Python-allocated bytes#1074
Added Rust initialisation of Python-allocated bytes#1074davidhewitt merged 8 commits intoPyO3:masterfrom juntyr:py_bytes_new_with
Conversation
|
I have also implemented a more specialised variant new_with_and_truncate<F: Fn(&mut [u8]) -> usize>(
py: Python<'_>,
len: usize,
init_and_truncate: F,
) -> &PyByteswhere the closure returns the truncated length of the final PyBytes. I think this might be too specific of a use case for this PR, but I can add it as well if you would be interested. This change can be viewed at master...MoritzLangenstein:py_bytes_new_with_and_truncate. |
|
With this function, we can get uninitialized fn do_nothing(_bytes: &mut [u8]) {}
let py_bytes = PyBytes::new_with(py, 10, do_nothing);So I think simply we should expose |
davidhewitt
left a comment
There was a problem hiding this comment.
Many thanks for this, looks great to me! A few general points:
- Can you check if any other objects should support the same API? For example maybe
PyString::new_withorPyTuple::new_with. I haven't thought about this too hard, so it might not make sense for them. If it does, would be great to support a uniform API across the whole library! - Regarding the truncate form, if you have examples of uses which are common enough then I'd be happy to consider it.
- Could you please also add a CHANGELOG entry? Something like "Add
PyBytes::new_withis sufficient.
src/types/bytes.rs
Outdated
| } | ||
|
|
||
| /// Creates a new Python bytestring object. | ||
| /// The uninitialised bytestring must be initialised in the `init` closure. |
There was a problem hiding this comment.
Maybe specifically say the entire uninitialised bytestring?
Also, if you're up for it, adding a little example would be very much appreciated.
There was a problem hiding this comment.
Where and in what format should I add an example?
src/types/bytes.rs
Outdated
| /// Panics if out of memory. | ||
| pub fn new_with<F: Fn(&mut [u8])>(py: Python<'_>, len: usize, init: F) -> &PyBytes { | ||
| let length = len as ffi::Py_ssize_t; | ||
| let pyptr = unsafe { ffi::PyBytes_FromStringAndSize(std::ptr::null(), length) }; |
There was a problem hiding this comment.
As this and the next 4 lines all contain unsafe, could perhaps just refactor to be a single unsafe block?
Ah, this is a very good point. Is it actually UB if we allow that? Might need to consult cpython docs further. What if we make this function |
Would it be possible to use MaybeUninit here? I’ve never tried wrapping an existing value in it (and it will unfortunately take until tonight before I get back to my computer), but maybe we could pass the bytes explicitly uninitialised into the closure and return an initialised slice with the same lifetime from the closure? One problem I could see with that, though, would be that you could then return subslices ... On second thought, default-initialising the bytes or making the function unsafe might be easier. |
It's basically
Both of them are OK, but maybe zero-initialization is better? |
|
Ok, let’s go with zero-initialisation then? In that case, should we explicitly add support for a zero- and / or an unsafe uninitialised constructor (neither of which would require a closure)? Personally, I can’t think of a use-case for that in Rust code right now, but maybe you might have some ideas. |
|
How about
|
|
I'm also happy only exposing the safe API if it's simpler. An extra write to the whole array can't be that bad, can it? :) |
I initially came across #617 after having implemented pickling for my wrapper class as suggested in https://gist.github.com/ethanhs/fd4123487974c91c7e5960acc9aa2a77. The data structure I am serialising to bytes is large enough that the copying from Vec to PyBytes can lead to OutOfMemory errors. Therefore, I have a small compacting algorithm which reduces the size of the serde + bincode output. For this algorithm I have an allocation upper bound, which allows me to do the compaction in place. Afterwards, I can then usually truncate the buffer. For me, pushing the allocation of the buffer to Python and being able to truncate it after its initialisation would make the entire serialisation zero-copy. This is probably a very specific use-case. |
@davidhewitt I have looked through the native types and think that only For the |
|
Many thanks, will give this all a thorough read through in the morning! For the example, you can just add an Line 56 in 51171f7 |
I don't think we need |
|
Technically, even making a I think you may want to use either |
src/types/bytes.rs
Outdated
| } | ||
| #[cfg(not(feature = "slice_fill"))] | ||
| { | ||
| slice.iter_mut().for_each(|x| *x = 0); |
There was a problem hiding this comment.
This is optimized by the compiler and not so slow.
I don't see any clear reason we should use (unstable) fill here. It's actually for loop.
src/types/bytes.rs
Outdated
| /// follows does not read uninitialised memory. | ||
| /// | ||
| /// Panics if out of memory. | ||
| pub unsafe fn new_with_uninit<F: Fn(&mut [u8])>( |
There was a problem hiding this comment.
If so, the closure should take &mut [MaybeUninit<u8>] as @programmerjake says.
src/types/bytes.rs
Outdated
| /// Creates a new Python bytestring object. | ||
| /// The `init` closure can initialise the bytestring. | ||
| /// | ||
| /// # Safety |
There was a problem hiding this comment.
We don't need #Safety document for safe functions
|
Thank you for the change. Let's make it simpler and add |
|
I have attempted to implement all of your suggestions. One everyone is satisfied with the current API, I would then implement it for
|
|
Why do we need |
|
On an unrelated note, could you mention |
We could also remove it entirely - I just included it so we could see what an interface using |
Agreed that
If you don't have any use case, I think it's better to remove it. |
Good suggestion, see #1075 - I also plan to extend CONTRIBUTING.md later today.
Yeah I was thinking when it was essentially |
src/types/bytes.rs
Outdated
| /// ``` | ||
| pub fn new_with<F: Fn(&mut [u8])>(py: Python<'_>, len: usize, init: F) -> &PyBytes { | ||
| let (slice, pybytes) = unsafe { Self::new_with_uninit_impl(py, len) }; | ||
| slice.iter_mut().for_each(|x| *x = MaybeUninit::zeroed()); |
There was a problem hiding this comment.
If you choose to remove new_with_uninit, you may find std::ptr::write_bytes a nice alternative to making a MaybeUninit slice.
There was a problem hiding this comment.
Thanks for the suggestion - I’ll keep that in mind.
|
Also, on assessment I agree For Many thanks for your continued revisions to this PR - it's looking really really good 👍 |
Mhm, maybe I was doing something wrong then. But I checked the docs and you are completely right that |
|
I am sorry that I have to follow up on this discussion, but I came accross an interesting problem. (How) Can we support failure inside the |
|
Ah, great question! We could perhaps modify the return type of Note that because Im also happy to leave the API it as-is, and let users who need error handling use a similar trick as suggested above for other return values. It'd be less ergonomic, so it depends if we expect users of this API to typically be doing fallible operations inside I guess we the answer is yes, because if they already had the data materialised then |
|
In fact, reflecting on the original truncate case further, perhaps the return type for
Also, if we change this function to be fallible, I think we should also remove the panic for out-of-memory from this API and related functions. Does the above seem reasonable? |
|
I also tried out the approach of mutating an outside variable - it just felt very un-Rusty to mutate a result variable inside a closure so it can be read outside. But for just returning some value, I think mutating an outside variable could be fine. |
That sounds like a very reasonable approach. Should we change the general |
Yes please :) I think when the allocation fails a |
|
I don't see why PyO3 should go out of its way not panicking for out-of-memory while the standard library does. |
|
After fiddling around with the required changes to the API, I agree that returning |
|
If the |
I think you would just need to call Py_DECREF on the pointer instead of |
That's a reasonable opinion, and I'm happy to leave this as-is if you feel strongly about so. My motivation was that the equivalent Python just raises From a quick check, at the moment it looks like our only constructors which return |
|
And |
Yeah, that's a tricky one. While Have you got examples where |
|
I think if we wanted to change the API such that out-of-memory panics are caught, it would have to be updated everywhere and would be a significant and breaking change. Allowing the initialisation of |
|
Agreed, it's a big change, so let's leave existing constructors as-is for now. Perhaps at some point we can review them across the library including the Regarding |
Right, although I would have expected that for the types that are allowed there no fancy
I was using some closure heavy code where it can be a pain to thread the Results through to the toplevel. But I don't think it's a big issue. |
This is true and I agree, though we might need to refactor some internals. I'll open an issue to discuss. |
|
One issue I have come across is that testing for a memory error is quite difficult cross-platform. On my machine, the OOM kicks in instead (because the virtual address space allows the allocation) and just kills the program instead. Should we just do a best effort error handling (i.e. if we are lucky enough to encounter the error in Rust we will return an |
|
In the test for a failing initialisation (i.e. |
Ah, that's a fair point. I think when we're hitting limits of the machine like this it's hard to guarantee particular execution, so I'm happy to just leave it as "we'll try to give you an error if possible". I'm willing to accept that there probably can't be a test written for this case.
Good question. I'm actually not aware of any C API for this. I wonder if you can call into Python's If writing such a test is massively complicated, again I'm willing to forgo a test as long as the implementation is appropriately commented (e.g. |
|
I have found two different ways of calling into
One slightly unrelated note: when I call |
Looks like you're using pyenv, so this discussion might be the cause of that: #763
That's surprising to me, I thought it would be 1. Will need to check the cpython source to figure that out. |
Thank you for the suggestion - in the end setting |
Maybe I was just measuring it wrong - anyways, I am now able to produce the behaviour where an allocation of 1MB bytestring without |
Adds
new_with<F: Fn(&mut [u8])>(py: Python<'_>, len: usize, init: F) -> &PyBytesas suggested by @davidhewitt in #617. This allows initialising new PyBytes in Rust like such:Currently, it follows the semantics of
PyBytes::newin that it panics if a memory error occurs in Python. Maybe my implementation of that could be improved.