Skip to content
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

Implement locked iteration for PyList #4789

Merged
merged 16 commits into from
Jan 8, 2025
Merged

Conversation

ngoldbaum
Copy link
Contributor

@ngoldbaum ngoldbaum commented Dec 10, 2024

Fixes #4571.

Re-enables get_item_unchecked on the free-threaded build (with a new free-threaded-specific note about safety), adds locked_for_each, and implements a number of iterator methods for BouldListIterator on the free-threaded build to amortize synchronization overhead where possible.

Largely follows the implementation and tests from #4439, along with fixes similar to the ones I implemented for #4788.

@ngoldbaum ngoldbaum force-pushed the pylist-locking branch 2 times, most recently from 2fd3e6d to 7d3fad0 Compare December 11, 2024 19:29
@ngoldbaum ngoldbaum force-pushed the pylist-locking branch 2 times, most recently from d1d1824 to 2692d89 Compare December 11, 2024 20:59
@ngoldbaum
Copy link
Contributor Author

I was chatting with @epilys on an IRC channel we both use and he had a suggestion to avoid the inner struct. I've included a commit from him implementing that.

Comment on lines 499 to 507
macro_rules! split_borrow {
($instance:expr, $index:ident, $length:ident, $list:ident) => {
let Self {
ref mut $index,
ref mut $length,
ref $list,
} = $instance;
};
}
Copy link
Member

@mejrs mejrs Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than just using this to split the borrow, maybe we should just have a macro that wraps

        crate::sync::with_critical_section(list, || {
            ...
        })

as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do this this afternoon and got stuck. I'm pretty novice at writing macros - do you know of any similar examples I could look at somewhere?

Here's what I tried, but this doesn't compile if I used it in e.g. the fold implementation: https://gist.github.com/ngoldbaum/13ac11629f042ae0bee84559e4e8bb31

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ngoldbaum was the error about identifiers not being found? If yes it's because Rust macros are hygienic, all referenced identifiers in a macro argument must be defined outside of the macro. What you can do is something like this:

/// Helper to manage mutable borrows below
macro_rules! split_borrow {
    ($instance:expr, $index:ident, $length:ident, $list:ident) => {
        let Self {
            ref mut $index,
            ref mut $length,
            ref $list,
        } = $instance;
    };
}

macro_rules! op_with_critical_section {
    ($instance:expr, $fn:expr) => {{
        split_borrow!($instance, index, length, list);
        crate::sync::with_critical_section(list, || $fn(index, length, list))
    }};
}

impl<'py> Iterator for BoundListIterator<'py> {
    type Item = Bound<'py, PyAny>;

    fn fold<B, F>(mut self, init: B, mut f: F) -> B
    where
        Self: Sized,
        F: FnMut(B, Self::Item) -> B,
    {
        op_with_critical_section!(self, |index: &mut Index, length: &mut Length, list| {
            let mut accum = init;
            while let Some(x) = unsafe { Self::next_unchecked(&mut *index, &mut *length, list) } {
                accum = f(accum, x);
            }
            accum
        })
    }
}

Though at this point I'm not sure the macro redirections makes it more readable anymore...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd drop the macros and suggest we have

impl<'py> BoundListIterator<'py> {
    fn with_critical_section<R>(&mut self, f: impl FnOnce(&mut Index, &mut Length, &Bound<'py, PyList>) -> R) -> R {
        let Self { index, length, list } = self;
        crate::sync::with_critical_section(list, || $fn(index, length, list))
}


crate::sync::with_critical_section(list, || {
let mut accum = init;
while let Some(x) = unsafe { Self::next_unchecked(index, length, list) } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really safe to call next_unchecked here (and elsewhere)? Can't the closure modify the list?

Copy link
Contributor Author

@ngoldbaum ngoldbaum Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More thoughts about thread safety here:

  • It is possible for another thread to try to acquire a critical section on the list, but we hold a critical section here so that thread will block until this thread exits the critical section.
  • That means the index and length are correct going into f, despite us getting them without any synchronization in next_unchecked.
  • 99% of the time, the critical section never getd released. It does get released if f creates a new innermost critical section on the list, but then only this thread can access the list still. It is possible that fold is getting called recursively, in which case this thread would create new innermost critical sections until the recursion terminates.

(did some edits of the text above to drop irrelevant references to the GIL)

length: &mut Length,
list: &Bound<'py, PyList>,
) -> Option<Bound<'py, PyAny>> {
let length = length.0.min(list.len());
Copy link
Contributor Author

@ngoldbaum ngoldbaum Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mejrs if a closure updates the length and the old length stored in the iterator is out-of-bounds, this step means the index.0 < length check below is False so the iterator returns None and the iteration terminates.

We hold a critical section so other threads can't modify the list between here and the next get_item_unchecked call.

Does that make sense? Are you worried about other scenarios?

In any case, I'll try to add a test where the closure modifies the list to see what happens....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See test_iter_fold_out_of_bounds added in the last commit.

Copy link
Member

@davidhewitt davidhewitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks like it's needed before we can land #4810, additionally it'd be great to include this in 0.23.4. Sorry for the very slow review!

I think main suggestions from me is that we can simplify away the macros and also we should probably use the _unchecked paths in PyPy.

#[cfg(not(any(Py_LIMITED_API, Py_GIL_DISABLED)))]
/// On the free-threaded build, caller must verify they have exclusive access to the list
/// via a lock or by holding the innermost critical section on the list.
#[cfg(not(any(Py_LIMITED_API)))]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[cfg(not(any(Py_LIMITED_API)))]
#[cfg(not(Py_LIMITED_API))]

src/types/list.rs Show resolved Hide resolved
Comment on lines 499 to 507
macro_rules! split_borrow {
($instance:expr, $index:ident, $length:ident, $list:ident) => {
let Self {
ref mut $index,
ref mut $length,
ref $list,
} = $instance;
};
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd drop the macros and suggest we have

impl<'py> BoundListIterator<'py> {
    fn with_critical_section<R>(&mut self, f: impl FnOnce(&mut Index, &mut Length, &Bound<'py, PyList>) -> R) -> R {
        let Self { index, length, list } = self;
        crate::sync::with_critical_section(list, || $fn(index, length, list))
}

@@ -493,14 +604,21 @@ impl<'py> Iterator for BoundListIterator<'py> {

#[inline]
fn next(&mut self) -> Option<Self::Item> {
let length = self.length.min(self.list.len());
split_borrow!(self, index, length, list);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Especially if we use my suggestion from above to avoid macros on the critical sections, I think this can just be:

Suggested change
split_borrow!(self, index, length, list);
let Self { index, length, list } = self;

@davidhewitt davidhewitt mentioned this pull request Jan 3, 2025
@ngoldbaum ngoldbaum added this pull request to the merge queue Jan 8, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 8, 2025
@ngoldbaum ngoldbaum enabled auto-merge January 8, 2025 20:29
@ngoldbaum ngoldbaum added this pull request to the merge queue Jan 8, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 8, 2025
@ngoldbaum ngoldbaum enabled auto-merge January 8, 2025 22:44
@ngoldbaum ngoldbaum added this pull request to the merge queue Jan 8, 2025
Merged via the queue into PyO3:main with commit c0f08c2 Jan 8, 2025
45 of 46 checks passed
@ngoldbaum ngoldbaum deleted the pylist-locking branch January 8, 2025 23:32
davidhewitt pushed a commit that referenced this pull request Jan 10, 2025
* implement locked iteration for PyList

* fix limited API and PyPy support

* fix formatting of safety docstrings

* only define fold and rfold on not(feature = "nightly")

* add missing try_fold implementation on nightly

* Use split borrows for locked iteration for PyList

Inline ListIterImpl implementations by using split borrows and
destructuring let Self { .. } = self destructuring inside
BoundListIterator impls.

Signed-off-by: Manos Pitsidianakis <[email protected]>

* use a function to do the split borrow

* add changelog entries

* fix clippy on limited API and PyPy

* use a macro for the split borrow

* add a test that mutates the list during a fold

* enable next_unchecked on PyPy

* fix incorrect docstring for locked_for_each

* simplify borrows by adding BoundListIterator::with_critical_section

* fix build on GIL-enabled and limited API builds

* fix docs build on MSRV

---------

Signed-off-by: Manos Pitsidianakis <[email protected]>
Co-authored-by: Manos Pitsidianakis <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add locked iterations APIs for dicts and lists
4 participants