-
Notifications
You must be signed in to change notification settings - Fork 763
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
Added Rust initialisation of Python-allocated bytes #1074
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,
) -> &PyBytes where 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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_with
orPyTuple::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_with
is sufficient.
src/types/bytes.rs
Outdated
@@ -26,6 +26,21 @@ impl PyBytes { | |||
unsafe { py.from_owned_ptr(ffi::PyBytes_FromStringAndSize(ptr, len)) } | |||
} | |||
|
|||
/// Creates a new Python bytestring object. | |||
/// The uninitialised bytestring must be initialised in the `init` closure. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where and in what format should I add an example?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added examples in 533c105
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As this and the next 4 lines all contain unsafe, could perhaps just refactor to be a single unsafe block?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 20d27ff
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 533c105
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If so, the closure should take &mut [MaybeUninit<u8>]
as @programmerjake says.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed in 533c105
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't need #Safety
document for safe functions
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 533c105
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) -> &PyBytes
as suggested by @davidhewitt in #617. This allows initialising new PyBytes in Rust like such:Currently, it follows the semantics of
PyBytes::new
in that it panics if a memory error occurs in Python. Maybe my implementation of that could be improved.