Specialize list.sort() comparisons for homogeneous lists - #8463
Specialize list.sort() comparisons for homogeneous lists#8463kangdora wants to merge 11 commits into
Conversation
Add crates/vm/src/sorting.rs implementing Tim Peters' timsort with powersort's merge-ordering policy (CPython 3.11+): run detection, binary insertion for short runs, galloping merge (merge_lo/merge_hi), and power-based merge ordering (powerloop). Comparison is passed in as a fallible `is_lt` closure, so the algorithm stays generic over the element type and free of interpreter details. Not yet wired into list.sort(); replaces rust-timsort in a follow-up.
Wire list sorting through crate::sorting::timsort and remove the rust-timsort dependency. Fixes the O(N^2) behavior on random input (1M random floats: ~16min -> ~0.8s), now within ~4x of CPython.
Scan the sort keys once before sorting; when every element is exactly str, int, or float, compare wtf8 bytes / BigInt / f64 directly instead of going through rich_compare_bool dispatch, mirroring CPython's pre-sort check in listsort.c (unsafe_latin_compare and friends). Subclasses and mixed-type lists keep the generic __lt__ path.
📝 WalkthroughWalkthroughThe VM replaces the external ChangesList sorting
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant list_sort
participant timsort_specialized
participant key_classifier
participant rich_comparison
list_sort->>timsort_specialized: sort elements or keys
timsort_specialized->>key_classifier: classify comparison values
key_classifier->>rich_comparison: use fallback when required
rich_comparison-->>timsort_specialized: comparison result or error
timsort_specialized-->>list_sort: sorted list
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (5)
crates/vm/src/sorting.rs (3)
232-237: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRename
b_winsinmerge_hi; the name states the opposite of the branch action.The comparison is
is_lt(buf[cursor_b], values[cursor_a]), so atrueresult means the A element is the larger one and the code copies from A. The nameb_winstherefore contradicts the branch body. Inmerge_lothe same name is correct, which makes the mismatch easy to misread during future maintenance. The behavior matches CPython'smerge_hi; only the name is wrong.♻️ Proposed rename
- let b_wins = match is_lt(&self.buf[cursor_b], &values[cursor_a]) { + let a_wins = match is_lt(&self.buf[cursor_b], &values[cursor_a]) { Ok(v) => v, Err(e) => break 'merging Err(e), }; - if b_wins { + if a_wins {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/vm/src/sorting.rs` around lines 232 - 237, In merge_hi, rename the boolean local b_wins to a name reflecting that a true comparison selects the A element, and update its corresponding conditional reference; leave merge_lo’s b_wins and all merge behavior unchanged.
1-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove or rewrite the stale TODO at the top of the file.
MERGESTATE_TEMP_SIZEdoes not exist in this file or in this crate. The comment refers to a CPython implementation detail that this port intentionally replaced with a dynamicVec. As written, the note describes a symbol that a reader cannot find.Do you want me to open an issue to track a small-list stack buffer optimization instead?
♻️ Proposed cleanup
-// TODO: MERGESTATE_TEMP_SIZE unused — buf is a dynamic Vec, not a fixed stack array. const MIN_GALLOP: usize = 7; const MAX_MINRUN: usize = 64;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/vm/src/sorting.rs` around lines 1 - 3, Remove the stale TODO preceding MIN_GALLOP and MAX_MINRUN in sorting.rs; do not replace it with a note about MERGESTATE_TEMP_SIZE, since this implementation intentionally uses a dynamic Vec and no corresponding symbol exists.Source: Coding guidelines
696-738: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd tests for comparator errors and for stability.
The current tests only use an infallible comparator (
Ok::<bool, ()>) and only assert final ordering ofi32values. Two behaviors that this engine guarantees are untested:
- Error propagation. The
Errarms inmerge_lo(lines 175-181) andmerge_hi(lines 350-354) copy the buffered run back intovaluesbefore returning the error. No test reaches those arms.list.sort()relies on this path whenever a Python__lt__raises, and a mistake there would silently corrupt list contents rather than fail loudly.- Stability.
list.sort()and the specialized comparators incrates/vm/src/builtins/list.rsdepend on stable ordering. No test asserts that equal elements keep their input order.Both need inputs of at least 64 elements to exercise the merge paths rather than the binary-insertion shortcut.
✅ Proposed additional tests
#[test] fn stability_preserved() { // (key, original_index) pairs; compare on key only. let mut v: Vec<(i32, usize)> = (0..200).map(|i| ((i % 5) as i32, i)).collect(); timsort(&mut v, &mut |a, b| Ok::<bool, ()>(a.0 < b.0)).unwrap(); let mut expected = v.clone(); expected.sort_by_key(|p| p.0); // std sort_by_key is stable assert_eq!(v, expected); } #[test] fn comparator_error_propagates_and_preserves_elements() { let mut v: Vec<i32> = (0..200).rev().collect(); let original: Vec<i32> = v.clone(); let mut calls = 0usize; let res = timsort(&mut v, &mut |a: &i32, b: &i32| { calls += 1; if calls > 150 { Err(()) } else { Ok(a < b) } }); assert!(res.is_err()); // The slice must still hold every original element exactly once. let mut got = v.clone(); let mut want = original; got.sort(); want.sort(); assert_eq!(got, want); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/vm/src/sorting.rs` around lines 696 - 738, Add tests in the existing sorting tests module for stability and comparator-error propagation. Use inputs of at least 64 elements: sort key/index pairs with a key-only comparator and verify equal-key elements retain input order; separately use a comparator that returns Err after several calls, assert timsort returns an error, and compare sorted copies to confirm the output still contains every original element exactly once.crates/vm/src/builtins/list.rs (1)
783-797: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winUse the specialized comparator for the first-element equality test.
Line 785 always calls the generic
rich_compare_bool(Eq), including fori == 0. The specialization at line 794 then applies only after that generic call has already run. For the common case of two tuples that differ at index 0, every comparison pays one full generic Python-levelEqdispatch, which is the cost the tuple specialization is meant to avoid.CPython's
unsafe_tuple_compareuses the cached specialized rich-comparison for the index-0 equality test as well, then the specialized less-than. Matching that recovers the intended speedup. This is a performance gap, not a correctness bug; results are unchanged.Add an
elem_eqcounterpart toelem_ltand use it at index 0.⚡ Proposed change
fn tuple_lt(elem: &Elem, a: &PyObjectRef, b: &PyObjectRef, vm: &VirtualMachine) -> PyResult<bool> { let a = a.downcast_ref::<PyTuple>().unwrap().as_slice(); let b = b.downcast_ref::<PyTuple>().unwrap().as_slice(); let mut i = 0; while i < a.len() && i < b.len() { - if !a[i].rich_compare_bool(&b[i], PyComparisonOp::Eq, vm)? { + let eq = if i == 0 { + elem_eq(elem, &a[0], &b[0], vm)? + } else { + a[i].rich_compare_bool(&b[i], PyComparisonOp::Eq, vm)? + }; + if !eq { break; } i += 1; }Add the counterpart helper next to
elem_lt:fn elem_eq(elem: &Elem, a: &PyObjectRef, b: &PyObjectRef, vm: &VirtualMachine) -> PyResult<bool> { match elem { Elem::Str => Ok(a.downcast_ref::<PyStr>().unwrap().as_bytes() == b.downcast_ref::<PyStr>().unwrap().as_bytes()), Elem::Int => Ok(a.downcast_ref::<PyInt>().unwrap().as_bigint() == b.downcast_ref::<PyInt>().unwrap().as_bigint()), Elem::Float => Ok(a.downcast_ref::<PyFloat>().unwrap().to_f64() == b.downcast_ref::<PyFloat>().unwrap().to_f64()), Elem::Object(_) | Elem::Generic => a.rich_compare_bool(b, PyComparisonOp::Eq, vm), } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/vm/src/builtins/list.rs` around lines 783 - 797, Add an elem_eq helper alongside elem_lt, using the specialized Str, Int, and Float comparisons and generic rich_compare_bool for Object and Generic elements. Update the tuple comparison loop to use elem_eq for the first-element equality check (i == 0), while preserving generic equality for subsequent elements and the existing less-than logic.extra_tests/snippets/builtin_list.py (1)
245-264: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for
reverse=True, forkey=, and for inputs larger than 64 elements.The new assertions cover element types well, but they miss three paths that this PR introduces:
reverse=True. Each of the six arms intimsort_specializedapplies its own operand swap. A missing swap in one arm would pass every assertion here.key=. The keyed path instantiatestimsort_specializedover(PyObjectRef, PyObjectRef)and classifies the key results rather than the elements. No assertion exercises it.- Input size. Every list here has fewer than 64 elements, so
timsortalways takes the binary-insertion shortcut.merge_lo,merge_hi, and the galloping code are never reached from Python.A combined reverse-plus-key case on a list of several hundred elements also verifies stability, which reverse ordering depends on.
✅ Proposed additional assertions
# reverse=True across each specialization assert sorted([3, 1, 2], reverse=True) == [3, 2, 1] assert sorted(["b", "a", "c"], reverse=True) == ["c", "b", "a"] assert sorted([2.5, 1.5, 3.5], reverse=True) == [3.5, 2.5, 1.5] assert sorted([b"b", b"a"], reverse=True) == [b"b", b"a"] assert sorted([(1, 2), (1, 1), (0, 9)], reverse=True) == [(1, 2), (1, 1), (0, 9)] # key= scans key results, not elements assert sorted(["bbb", "a", "cc"], key=len) == ["a", "cc", "bbb"] assert sorted([1, 2, 3], key=lambda x: -x) == [3, 2, 1] assert sorted(["bbb", "a", "cc"], key=len, reverse=True) == ["bbb", "cc", "a"] # inputs above the 64-element insertion-sort shortcut big = [(i * 7919) % 500 for i in range(500)] assert sorted(big) == list(range(500)) assert sorted(big, reverse=True) == list(range(499, -1, -1)) assert sorted([str(i) for i in big]) == sorted([str(i) for i in big]) # stability: equal keys keep input order, including with reverse=True pairs = [(i % 5, i) for i in range(200)] assert sorted(pairs, key=lambda p: p[0]) == sorted(pairs, key=lambda p: p[0]) by_key = sorted(pairs, key=lambda p: p[0]) assert [p[1] for p in by_key if p[0] == 0] == list(range(0, 200, 5)) rev = sorted(pairs, key=lambda p: p[0], reverse=True) assert [p[1] for p in rev if p[0] == 0] == list(range(0, 200, 5))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@extra_tests/snippets/builtin_list.py` around lines 245 - 264, Expand the sorted() assertions around the existing builtin sorting coverage to exercise reverse=True across supported specializations, key= with and without reverse, and lists larger than 64 elements that reach merge and galloping paths. Add a combined large reverse-plus-key case that verifies stable ordering of equal keys, preserving input order within each key group.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/vm/src/builtins/list.rs`:
- Around line 810-859: Refactor the six `PreSort` arms in the list-sorting
dispatch to share one common sorting closure that performs the `reverse` operand
swap once, while each arm supplies only its leaf comparator (`str_lt`, `int_lt`,
`float_lt`, `object_lt`, `tuple_lt`, or `rich_compare_bool`). Preserve stable
sorting and existing comparison behavior; use a generic helper instead of `dyn
FnMut` if needed to avoid per-comparison virtual dispatch.
---
Nitpick comments:
In `@crates/vm/src/builtins/list.rs`:
- Around line 783-797: Add an elem_eq helper alongside elem_lt, using the
specialized Str, Int, and Float comparisons and generic rich_compare_bool for
Object and Generic elements. Update the tuple comparison loop to use elem_eq for
the first-element equality check (i == 0), while preserving generic equality for
subsequent elements and the existing less-than logic.
In `@crates/vm/src/sorting.rs`:
- Around line 232-237: In merge_hi, rename the boolean local b_wins to a name
reflecting that a true comparison selects the A element, and update its
corresponding conditional reference; leave merge_lo’s b_wins and all merge
behavior unchanged.
- Around line 1-3: Remove the stale TODO preceding MIN_GALLOP and MAX_MINRUN in
sorting.rs; do not replace it with a note about MERGESTATE_TEMP_SIZE, since this
implementation intentionally uses a dynamic Vec and no corresponding symbol
exists.
- Around line 696-738: Add tests in the existing sorting tests module for
stability and comparator-error propagation. Use inputs of at least 64 elements:
sort key/index pairs with a key-only comparator and verify equal-key elements
retain input order; separately use a comparator that returns Err after several
calls, assert timsort returns an error, and compare sorted copies to confirm the
output still contains every original element exactly once.
In `@extra_tests/snippets/builtin_list.py`:
- Around line 245-264: Expand the sorted() assertions around the existing
builtin sorting coverage to exercise reverse=True across supported
specializations, key= with and without reverse, and lists larger than 64
elements that reach merge and galloping paths. Add a combined large
reverse-plus-key case that verifies stable ordering of equal keys, preserving
input order within each key group.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro Plus
Run ID: a3637bf1-78a6-4d20-b163-2bbc3295dbf8
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (6)
Cargo.tomlcrates/vm/Cargo.tomlcrates/vm/src/builtins/list.rscrates/vm/src/lib.rscrates/vm/src/sorting.rsextra_tests/snippets/builtin_list.py
💤 Files with no reviewable changes (2)
- Cargo.toml
- crates/vm/Cargo.toml
| match pre_sort_check(items.iter().map(&key), vm) { | ||
| PreSort::Str => timsort(items, &mut |a, b| { | ||
| let (a, b) = if reverse { | ||
| (key(b), key(a)) | ||
| } else { | ||
| (key(a), key(b)) | ||
| }; | ||
| Ok(str_lt(a, b)) | ||
| }), | ||
| PreSort::Int => timsort(items, &mut |a, b| { | ||
| let (a, b) = if reverse { | ||
| (key(b), key(a)) | ||
| } else { | ||
| (key(a), key(b)) | ||
| }; | ||
| Ok(int_lt(a, b)) | ||
| }), | ||
| PreSort::Float => timsort(items, &mut |a, b| { | ||
| let (a, b) = if reverse { | ||
| (key(b), key(a)) | ||
| } else { | ||
| (key(a), key(b)) | ||
| }; | ||
| Ok(float_lt(a, b)) | ||
| }), | ||
| PreSort::Object(cmp) => timsort(items, &mut |a, b| { | ||
| let (a, b) = if reverse { | ||
| (key(b), key(a)) | ||
| } else { | ||
| (key(a), key(b)) | ||
| }; | ||
| object_lt(cmp, a, b, vm) | ||
| }), | ||
| PreSort::Tuple(elem) => timsort(items, &mut |a, b| { | ||
| let (a, b) = if reverse { | ||
| (key(b), key(a)) | ||
| } else { | ||
| (key(a), key(b)) | ||
| }; | ||
| tuple_lt(&elem, a, b, vm) | ||
| }), | ||
| PreSort::Generic => timsort(items, &mut |a, b| { | ||
| let (a, b) = if reverse { | ||
| (key(b), key(a)) | ||
| } else { | ||
| (key(a), key(b)) | ||
| }; | ||
| a.rich_compare_bool(b, PyComparisonOp::Lt, vm) | ||
| }), | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Extract the duplicated reverse-swap prologue from the six dispatch arms.
All six arms repeat the same five-line block and differ only in the leaf comparison function. The coding guidelines require extracting the differing value and invoking the common logic once. Beyond style, the duplication is a correctness risk: one arm that loses the reverse swap would silently produce the wrong order, and only a reverse-specific test would catch it.
The ordering behavior itself is correct. Swapping the operands of a strict less-than comparator, combined with a stable sort, preserves the original relative order of equal elements, which matches CPython's reverse-sort-reverse result.
♻️ Proposed refactor
where
T: Clone,
K: Fn(&T) -> &PyObjectRef,
{
- match pre_sort_check(items.iter().map(&key), vm) {
- PreSort::Str => timsort(items, &mut |a, b| {
- let (a, b) = if reverse {
- (key(b), key(a))
- } else {
- (key(a), key(b))
- };
- Ok(str_lt(a, b))
- }),
- PreSort::Int => timsort(items, &mut |a, b| {
- let (a, b) = if reverse {
- (key(b), key(a))
- } else {
- (key(a), key(b))
- };
- Ok(int_lt(a, b))
- }),
- PreSort::Float => timsort(items, &mut |a, b| {
- let (a, b) = if reverse {
- (key(b), key(a))
- } else {
- (key(a), key(b))
- };
- Ok(float_lt(a, b))
- }),
- PreSort::Object(cmp) => timsort(items, &mut |a, b| {
- let (a, b) = if reverse {
- (key(b), key(a))
- } else {
- (key(a), key(b))
- };
- object_lt(cmp, a, b, vm)
- }),
- PreSort::Tuple(elem) => timsort(items, &mut |a, b| {
- let (a, b) = if reverse {
- (key(b), key(a))
- } else {
- (key(a), key(b))
- };
- tuple_lt(&elem, a, b, vm)
- }),
- PreSort::Generic => timsort(items, &mut |a, b| {
- let (a, b) = if reverse {
- (key(b), key(a))
- } else {
- (key(a), key(b))
- };
- a.rich_compare_bool(b, PyComparisonOp::Lt, vm)
- }),
- }
+ // Wraps a key-level comparison into an item-level one, applying `reverse`
+ // by swapping the operands.
+ let run = |items: &mut [T], lt: &mut dyn FnMut(&PyObjectRef, &PyObjectRef) -> PyResult<bool>| {
+ timsort(items, &mut |a: &T, b: &T| {
+ let (a, b) = if reverse {
+ (key(b), key(a))
+ } else {
+ (key(a), key(b))
+ };
+ lt(a, b)
+ })
+ };
+
+ match pre_sort_check(items.iter().map(&key), vm) {
+ PreSort::Str => run(items, &mut |a, b| Ok(str_lt(a, b))),
+ PreSort::Int => run(items, &mut |a, b| Ok(int_lt(a, b))),
+ PreSort::Float => run(items, &mut |a, b| Ok(float_lt(a, b))),
+ PreSort::Object(cmp) => run(items, &mut |a, b| object_lt(cmp, a, b, vm)),
+ PreSort::Tuple(elem) => run(items, &mut |a, b| tuple_lt(&elem, a, b, vm)),
+ PreSort::Generic => run(items, &mut |a, b| {
+ a.rich_compare_bool(b, PyComparisonOp::Lt, vm)
+ }),
+ }
}The dyn FnMut indirection adds one virtual call per comparison. If benchmarks show that cost is material, keep the monomorphized form but make run a generic inner fn instead:
fn run<T, K, L>(items: &mut [T], reverse: bool, key: &K, mut lt: L) -> PyResult<()>
where
T: Clone,
K: Fn(&T) -> &PyObjectRef,
L: FnMut(&PyObjectRef, &PyObjectRef) -> PyResult<bool>,
{
timsort(items, &mut |a: &T, b: &T| {
let (a, b) = if reverse { (key(b), key(a)) } else { (key(a), key(b)) };
lt(a, b)
})
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| match pre_sort_check(items.iter().map(&key), vm) { | |
| PreSort::Str => timsort(items, &mut |a, b| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| Ok(str_lt(a, b)) | |
| }), | |
| PreSort::Int => timsort(items, &mut |a, b| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| Ok(int_lt(a, b)) | |
| }), | |
| PreSort::Float => timsort(items, &mut |a, b| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| Ok(float_lt(a, b)) | |
| }), | |
| PreSort::Object(cmp) => timsort(items, &mut |a, b| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| object_lt(cmp, a, b, vm) | |
| }), | |
| PreSort::Tuple(elem) => timsort(items, &mut |a, b| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| tuple_lt(&elem, a, b, vm) | |
| }), | |
| PreSort::Generic => timsort(items, &mut |a, b| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| a.rich_compare_bool(b, PyComparisonOp::Lt, vm) | |
| }), | |
| } | |
| // Wraps a key-level comparison into an item-level one, applying `reverse` | |
| // by swapping the operands. | |
| let run = |items: &mut [T], lt: &mut dyn FnMut(&PyObjectRef, &PyObjectRef) -> PyResult<bool>| { | |
| timsort(items, &mut |a: &T, b: &T| { | |
| let (a, b) = if reverse { | |
| (key(b), key(a)) | |
| } else { | |
| (key(a), key(b)) | |
| }; | |
| lt(a, b) | |
| }) | |
| }; | |
| match pre_sort_check(items.iter().map(&key), vm) { | |
| PreSort::Str => run(items, &mut |a, b| Ok(str_lt(a, b))), | |
| PreSort::Int => run(items, &mut |a, b| Ok(int_lt(a, b))), | |
| PreSort::Float => run(items, &mut |a, b| Ok(float_lt(a, b))), | |
| PreSort::Object(cmp) => run(items, &mut |a, b| object_lt(cmp, a, b, vm)), | |
| PreSort::Tuple(elem) => run(items, &mut |a, b| tuple_lt(&elem, a, b, vm)), | |
| PreSort::Generic => run(items, &mut |a, b| { | |
| a.rich_compare_bool(b, PyComparisonOp::Lt, vm) | |
| }), | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/builtins/list.rs` around lines 810 - 859, Refactor the six
`PreSort` arms in the list-sorting dispatch to share one common sorting closure
that performs the `reverse` operand swap once, while each arm supplies only its
leaf comparator (`str_lt`, `int_lt`, `float_lt`, `object_lt`, `tuple_lt`, or
`rich_compare_bool`). Preserve stable sorting and existing comparison behavior;
use a generic helper instead of `dyn FnMut` if needed to avoid per-comparison
virtual dispatch.
Source: Coding guidelines
Summary
Implements CPython's pre-sort check for
list.sort(), as promised in the follow-ups of #8421.Before sorting, the keys are scanned once (O(n)); when they are homogeneous in exact type, the O(n log n) comparisons skip
rich_compare_bool's dispatch (recursion guard, subclass/reflected-op resolution, slot lookup, NotImplemented retry) and use a comparator specialized for that type. This mirrors theunsafe_latin_compare/unsafe_long_compare/unsafe_float_compare/unsafe_object_compare/unsafe_tuple_comparefamily in CPython'sObjects/listobject.c.What changed
crates/vm/src/builtins/list.rs:pre_sort_checkscans the keys (the key function results whenkey=is given, matching CPython'slo.keysscan) and picks a tier:Str,Int,Float,Object(cached richcompare slot),Tuple(with a nested tier for first elements), orGeneric.sorting.rsis untouched — specialization only decides whichis_ltclosure the existing powersort receives.timsortcall, so the comparator monomorphizes into the merge loop — no per-comparison indirect call, unlike CPython's function-pointer approach.Objecttier guards each comparison with a pointer check against the cached slot and falls back torich_compare_boolon mismatch orNotImplemented, so a stale cache can only cost speed, never correctness.Tupletier follows CPython exactly: elements are walked with==(identity shortcut included), only index 0 may use the specialized comparator (the scan verified nothing about later elements), later differences fall back to generic<, and nested tuples are not recursed into. The element-tier enum has no tuple variant, so that rule is enforced by the type system.extra_tests/snippets/builtin_list.py: non-ASCII code point order, huge ints,bool/subclass rejection, mixed-typeTypeError, NaN ordering, bytes, and five tuple cases (empty, nested, mixed first elements, ties at and past index 0).Differences from CPython, and why they hold
BigInts throughOrdis already dispatch-free, so all-int lists qualify regardless of magnitude.boolis excluded by the exact-type check (as in CPython), and subclasses never qualify since they may override__lt__.Results
Release build, best of 5, seed 42, vs CPython 3.14. "generic" is the same build with the scan defeated by one subclass element, i.e. the previous behavior on otherwise identical data.
1M elements:
300k elements, and the speedup CPython itself gets from the same trick on the same data as a cross-check that the port is faithful:
(For str/int/float the CPython-side ratios are ×1.33/×1.07/×1.53 — same pattern.) The gap vs CPython on random ints narrows from 2.4× to 1.7×.
Follow-ups
The remaining gap is dominated by two things outside this PR's scope: merges are clone-based (
T: Cloneinsorting.rs), so every element move pays an atomic refcount operation where CPython memmoves bare pointers — I plan to address this with move-based merges as a follow-up; andPyStrkeeps its bytes in a separate allocation (CPython inlines them since PEP 393), which is an object-representation question to be filed separately.Notes
Despite mirroring CPython's
unsafe_*comparator family, the port is entirely safe Rust: the invariants those comparators trust are still re-checked by cheap typed downcasts, so a violated invariant degrades to a panic or a fallback, never to undefined behavior.The
Objecttier compares function pointers, which rustc flags (unpredictable_function_pointer_comparisons). Both failure modes are harmless here: a duplicated function makes the guard fail into the fallback path (slower, still correct), and merged functions have identical bodies.Summary by CodeRabbit
New Features
Bug Fixes