-
-
Notifications
You must be signed in to change notification settings - Fork 645
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
[internal] Switch from Rust-Cpython to PyO3 #13526
Conversation
9e29e72
to
0544e75
Compare
…3586) Prework for #13526. Before, we were parsing the Python objects (which requires the GIL) in the same iterator that created async blocks. That won't work when we port to PyO3 - iterating over the `file_items` collection uses `&'py PyAny` values, where the `'py` lifetime corresponds to how long we are holding the GIL (as represented by the `pyo3::Python` type). The `Python` token is not safe in async blocks because it does not implement `Send`, so we can't create the futures in this iterator. This fix makes the code much more understandable as well. In the future, we are unblocked from defining `CreateDigest` in Rust with `PyO3` because parsing of the code is now completely decoupled from using it to create async futures. [ci skip-build-wheels]
[ci skip-build-wheels]
[ci skip-build-wheels]
This will allow us to more clearly start porting things to PyO3 without confusing types. # Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
[ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
[ci skip-build-wheels]
[ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
Don't pass the GIL over a future. Also stop using the obfuscation of `Value` Down to 7 errors! # Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
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.
Looks awesome! Thanks a lot.
def __eq__(self, other: PySnapshot | Any) -> bool: ... | ||
def __hash__(self) -> int: ... | ||
def __repr__(self) -> str: ... |
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.
When is it necessary to declare these?
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.
Not strictly necessary. MyPy was not complaining about anything, I was being proactive so users of this API know what is expected to work without having to look at Rust code.
I believe __repr__
and __eq__
are always meant to be implemented, just with defaults though that is the object instance.
Wdyt? Too noisy?
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 not necessary, would leave them off probably. But doesn't really matter.
src/rust/engine/src/python.rs
Outdated
/// count, but that type only works when we are holding the GIL. While we could clone the | ||
/// `PyObject` without the GIL, that would be an expensive deep clone. |
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.
Yea, possibly. Sounds like it is effectively already equivalent to an Arc
.
pub fn getattr<T>(value: &PyObject, field: &str) -> Result<T, String> | ||
pub fn getattr<'py, T>(value: &'py PyAny, field: &str) -> Result<T, String> | ||
where | ||
for<'a> T: FromPyObject<'a>, | ||
T: FromPyObject<'py>, |
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.
Yea, makes sense. Before, T
was not going to have its own lifetime (it was implicitly 'static
), but now it does.
Others have the review covered so I'll step away. Thanks for checking coarse perf is not worse. |
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
Prework for #13526. We were reacquiring the GIL directly inside `py.allow_threads(||)`. Rust-cpython allows that, but PyO3 does not. (PyO3 has the benefit of instilling much stronger confidence in reasoning when the GIL is held or not.) We can easily work around this by having `WorkunitStore.with_latest_workunits` return `Vec<Workunit>` instead of `&[Workunit]`. No new clones are happening! We already were making this Vec. Only now we move the value out, rather than referencing it via a closure passed in as a callback. That change allows for operating on the `Vec<Workunit>` safely outside of the `py.allow_threads(||)` to convert it into the FFI boundary. ## Benchmark These benchmarks use the Toolchain BuildSense plugin + anonymous telemetrics streaming workunit handlers. ### No pantsd Before: ``` ❯ hyperfine -w 1 -r 10 './pants -ldebug --no-pantsd list ::' Time (mean ± σ): 4.611 s ± 0.206 s [User: 2.940 s, System: 0.474 s] Range (min … max): 4.419 s … 5.153 s 10 runs ``` After: ``` ❯ hyperfine -w 1 -r 10 './pants -ldebug --no-pantsd list ::' Time (mean ± σ): 4.655 s ± 0.113 s [User: 2.975 s, System: 0.509 s] Range (min … max): 4.430 s … 4.827 s 10 runs ``` ### Pantsd Before: ``` ❯ hyperfine -w 1 -r 10 './pants -ldebug --concurrent --pantsd list ::' Time (mean ± σ): 2.641 s ± 0.021 s [User: 2.026 s, System: 0.323 s] Range (min … max): 2.602 s … 2.670 s 10 runs ``` After: ``` ❯ hyperfine -w 1 -r 10 './pants -ldebug --concurrent --pantsd list ::' Time (mean ± σ): 2.651 s ± 0.023 s [User: 2.036 s, System: 0.334 s] Range (min … max): 2.611 s … 2.693 s 10 runs ``` [ci skip-build-wheels]
# Conflicts: # src/rust/engine/src/externs/interface.rs # Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
I can still get Pants to crash on Linux by running This was happening in CI and locally on Linux (not macOS) this morning, and the core dump isolated it to line 380 from https://github.com/PyO3/pyo3/blob/47747444c76c9fc6bdd7800c55f3d6dad28a3c9b/src/python.rs#L365-L380. I tried fixing that with #13609, so that we don't reacquire the GIL inside
The most recent core dump is this, which I am not sure what to make sense of:
This is almost certainly an issue with our "streaming workunit handler" being async, as indicated by the core dump. I ran @davidhewitt @mejrs, do you expect it to be safe to use Edit: I have an idea how to isolate this failure to something more specific. Edit 2: tentatively looks like the problem is after exiting |
Hmm, as far as I'm aware you're not doing something fundamentally impossible. A quick search for "exception not rethrown" reaches this thread, which is "resolved", but it looks like the fix requires the nightly-only I suspect that some C code is forcing unwinding (maybe The difference between PyO3 and Rust-CPython on that line you mark is that Rust-CPython doesn't use I wonder if I change to a guard pattern (which should be enough) it will fix your issue. |
src/rust/engine/src/python.rs
Outdated
/// count, but that type only works when we are holding the GIL. While we could clone the | ||
/// `PyObject` without the GIL, that would be an expensive deep clone. |
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.
That entire comment block is more confusing/irrelevant than it is helpful, I think. I'd probably omit it entirely or, as you say, do away with the entire newtype.
So I'm honestly not sure what the value of wrapping in an Arc<> is?
In this context there is no value, I think.
It's already cheap to clone
&PyAny
as it simply increases Python's refcount, but that type only works when we are holding the GIL.
Copying and passing around &PyAny
doesn't involve reference counts, it's a borrowed reference. Having it bound to the gil lifetime allows for cheap and safe working with borrowed references. But this struct needs to own a reference to it, which is what Py<T>
represents.
While we could clone the
PyObject
without the GIL, that would be an expensive deep clone.
Cloning a Py<T>
without the GIL just increases a reference count (deferred into the future, when the gil is next acquired). No "deep copying" takes place.
Seems like we're adding unnecessary locking.
Arc
doesn't involve locking, just some math on atomic integers. Or I'm misunderstanding your comment.
# Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
No need for two distinct extensions anymore post #13526.
This reverts commit 021c3c0.
This includes a fix we were waiting on in pantsbuild#13526: PyO3/pyo3#1990 Changelog here: https://pyo3.rs/v0.15.1/changelog.html # Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
This includes a fix we were waiting on in #13526: PyO3/pyo3#1990 Changelog here: https://pyo3.rs/v0.15.1/changelog.html
`PyObject` (aka `Py<PyAny>`) already provides a GIL-independent reference to a Python object. Wrapping it in `Arc` is not necessary, as described at #13526 (comment). Using `PyObject` directly removes a layer of indirection.
See #12110 for the original motivation.
Why migrate
Beyond the APIs requiring much less boilerplate and being more ergonomic (e.g. the
.call0()
andcall1()
variants of.call()
), I think the biggest benefit is clearer modeling for when the GIL is held.With PyO3, the two core types are
&PyAny
andPyObject
(akaPy<PyAny
).&PyAny
is always a reference with the same lifetime as thePython
token, which represents when the GIL is used. Whenever you want to do Python operations in Rust, like.getattr()
, you use&PyAny
.PyObject
and anyPy<T>
are GIL-independent.In contrast, rust-cpython only had
PyObject
and&PyObject
. It passed aroundPython
as an argument to any Python functions like.getattr()
.PyO3 is more disciplined about when we should have the GIL and when we should not. For example, #13586 and #13609 were necessary to get PyO3 working. This allows us to better reason about performance because we can know when the GIL is held or not.
--
An additional benefit is that using proc macros instead of declarative macros means PyCharm no longer hangs for me when working in the
engine/
crate! PyCharm does much better w/ proc macros, and things feel zippy now like autocomplete working.Migration strategy
I tried originally doing an incremental strategy, most recently with #12451, which proved technically infeasible.
This PR completely swaps Rust-Cpython with PyO3, but tries to minimize the diff. It only makes changes when it was too difficult to get working with PyO3. For example,
interface.rs
is far too big, but this keeps the same organization as before.For now, we still have the
native_engine_pyo3
extension along withnative_engine
. That will be consolidated in a followup.Benchmark
Before:
After: