BitGenerator support - #499
Conversation
Icxolu
left a comment
There was a problem hiding this comment.
This looks like a useful addition! Thanks for working on it. I'm definitely not an expert here, but I left a few comment about things that stood out to me. Let me know what you think.
Also, are there any differences between numpy v1 and v2 that we need to consider?
There was a problem hiding this comment.
sure, will do when I’m done. I like working on multiple machines, and I don’t like re-doing settings for individual projects
|
I may have found a problem: This fails as intended: Python::with_gil(|py| {
let obj = get_bit_generator(py)?;
let a = obj.lock()?;
let b = obj.lock()?;
Ok::<_, PyErr>(())
})
.unwrap();returning But this does not fail: Python::with_gil(|py| {
let a = get_bit_generator(py)?.lock()?;
let b = get_bit_generator(py)?.lock()?;
Ok::<_, PyErr>(())
})
.unwrap();and crucially it gives the same pointers: So when using multiple threads, for example multiple tests running in parallel, we have a data race on the state. I think we need a lock across all instances to make this work. |
This comment was marked as outdated.
This comment was marked as outdated.
|
Maybe we should skip the guard part and just lock and unlock within the RngCore implementation itself. Can you give an example for why you'd want this, and why the api has this form? Why would someone want to use this rather than the RngCore impl that rand ships with? Maybe we can come up with a better design. |
|
When implementing Python-facing APIs, having a |
|
Would it also possible to go the other way, i.e. provide a rand rng from Rust to Python as a numpy BitGenerator? |
|
Yes, that's part of numpy's API as well! |
# Conflicts: # Cargo.toml
|
OK, done. UB fixedturns out the double locking issue fixed@Icxolu as you found in #499 (comment), the lock is re-entrant, so now we just keep a HashSet of locked bitgens so same-thread guards are unique. PS: MSRV is 1.83, and in 1.84 |
|
And done! Please take a look! @Icxolu I followed your initial advice and converted it from a I have gathered some experience using numpy’s generators and figured that using outstanding questions & tasks
|
|
Thanks for picking this back up! It might take me a bit to get my head back into this, but I will try to give this a read as soon as I find some time. |
|
I’m quite happy with it now :D Only caveat (apart from naming): There’s a lot of nit-picking possible with the |
| pub fn into_shared(self) -> Py<PyBitGenerator> { | ||
| self._bit_generator | ||
| } |
There was a problem hiding this comment.
could alternatively be an Into implementation.
Icxolu
left a comment
There was a problem hiding this comment.
I gave this a first pass. For now I mostly looked at the technical stuff and did not really think hard about naming etc. Unfortunately I think there is still a potential soundness issue with lock, see that comment for more details. I believe spawn does not have the same issue, so even if we can't get lock working, this one should be possible.
| // SAFETY: we hold the lock until the end of this scope (the `LockGuard` releases it), | ||
| // and reject reentrant re-locking below, so `generator`’s access stays exclusive. |
There was a problem hiding this comment.
I think the API as currently implemented is still unsound. f is allowed to call into Python, so I can do something like this
bit_gen.lock(|b| {
bit_gen.getattr("lock")?.call_method0("release")?;
Ok::<_, PyErr>(b.next_double())
})and release the lock early. We could prevent this by requiring the closure to be Ungil like detach, but I'm still not certain that arbitrary Python code could not be a problem as well.
There was a problem hiding this comment.
making it Ungil means we can no longer have these tests:
- lock_keeps_bit_generator_alive
- double_lock_*
But maybe we don’t need them?
I did this in 6c0f4c7.
Now the closure and its return value are required to be Ungil + Send, and we detach ourselves. This makes the examples actually nicer, although I’m not sure why the Send bound is necessary now and wasn’t before.
There was a problem hiding this comment.
The Send bound is needed, because the closure to detach has to be Ungil as well. Ungil should be an auto trait, but given that these are still unstable, PyO3 piggy backs on Send as it matches the semantics quite closely most of the time. Given that implementing the Fn traits is also unstable, the only way to satisfy the Ungil bound is to rely on the blanket which requires the captures to be Send.
Unfortunately I found another issue with the API 😞 The mutable reference we pass into the closure allows users to smuggle out the generator with std::mem::swap:
Python::attach(|py| {
// Possibly shared generator
let py_generator = get_bit_generator(py)?;
// Exclusive generator
let mut exclusive_gen = BitGenerator::from_numpy(py, NumpyBitGenerator::PCG64)?;
// This allows swapping the generator, allowing unsynchronized access the the shared generator below
py_generator
.lock(|shared_locked_gen| std::mem::swap(shared_locked_gen, &mut exclusive_gen))?;
// This is a shared generator now, so unsynchronized access is unsound
exclusive_gen.next_double();
Ok(())
})| release_on_drop: true, | ||
| }) | ||
| } | ||
| fn release(&mut self) -> PyResult<()> { |
There was a problem hiding this comment.
I think it would make more sense if this consumes the guard, so it can't be released twice.
We can use ManuallyDrop to prevent Drop from running as well in that case, no need for a field.
| // SAFETY: we hold the lock until the end of this scope (the `LockGuard` releases it), | ||
| // and reject reentrant re-locking below, so `generator`’s access stays exclusive. |
There was a problem hiding this comment.
The Send bound is needed, because the closure to detach has to be Ungil as well. Ungil should be an auto trait, but given that these are still unstable, PyO3 piggy backs on Send as it matches the semantics quite closely most of the time. Given that implementing the Fn traits is also unstable, the only way to satisfy the Ungil bound is to rely on the blanket which requires the captures to be Send.
Unfortunately I found another issue with the API 😞 The mutable reference we pass into the closure allows users to smuggle out the generator with std::mem::swap:
Python::attach(|py| {
// Possibly shared generator
let py_generator = get_bit_generator(py)?;
// Exclusive generator
let mut exclusive_gen = BitGenerator::from_numpy(py, NumpyBitGenerator::PCG64)?;
// This allows swapping the generator, allowing unsynchronized access the the shared generator below
py_generator
.lock(|shared_locked_gen| std::mem::swap(shared_locked_gen, &mut exclusive_gen))?;
// This is a shared generator now, so unsynchronized access is unsound
exclusive_gen.next_double();
Ok(())
})| /// `.spawn` exists since numpy 1.25+, which needs Python 3.9 | ||
| #[cfg(Py_3_9)] |
There was a problem hiding this comment.
PyO3 actually dropped support for 3.8 PyO3/pyo3#6128 recently (lands in 0.30 I think), so rust-numpys next release will drop it as well.
See
Fixes #498
The idea is to have a safe wrapper around the
npy_bitgenstruct that implementsrand::RngCore. That way pyo3 functions could be passed anp.random.Generator, get that wrapper from it, and pass it to Rust APIs, which could then call its methods repeatedly.The way it’s implemented, the workflow would look like this:
castanp.random.BitGeneratorinstance into anumpy::random::PyBitGenerator..lock()on it to get anumpy::random::PyBitGeneratorGuard.TODO:
Safety
If somebody releases the threading lock of the
BitGeneratorwhile we’re using it, this isn’t safe 🤔API design options
I could make this more complex by adding a new trait that is implemented by both
PyBitGeneratorandPyBitGeneratorGuard, allowing to choose if someone wants toPyBitGenerator’srandom_*methods directly on that object while holding the GIL and without locking itnp.random.BitGeneratorand returning a GIL-free object that can be used.but for now I just implemented the use case that’s actually desired.