Skip to content

Specialize list.sort() comparisons for homogeneous lists - #8463

Closed
kangdora wants to merge 11 commits into
RustPython:mainfrom
kangdora:powersort-type-specialize
Closed

Specialize list.sort() comparisons for homogeneous lists#8463
kangdora wants to merge 11 commits into
RustPython:mainfrom
kangdora:powersort-type-specialize

Conversation

@kangdora

@kangdora kangdora commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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 the unsafe_latin_compare / unsafe_long_compare / unsafe_float_compare / unsafe_object_compare / unsafe_tuple_compare family in CPython's Objects/listobject.c.

What changed

  • crates/vm/src/builtins/list.rs: pre_sort_check scans the keys (the key function results when key= is given, matching CPython's lo.keys scan) and picks a tier: Str, Int, Float, Object (cached richcompare slot), Tuple (with a nested tier for first elements), or Generic. sorting.rs is untouched — specialization only decides which is_lt closure the existing powersort receives.
  • Each tier dispatches through its own timsort call, so the comparator monomorphizes into the merge loop — no per-comparison indirect call, unlike CPython's function-pointer approach.
  • The Object tier guards each comparison with a pointer check against the cached slot and falls back to rich_compare_bool on mismatch or NotImplemented, so a stale cache can only cost speed, never correctness.
  • The Tuple tier 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.
  • Tests in extra_tests/snippets/builtin_list.py: non-ASCII code point order, huge ints, bool/subclass rejection, mixed-type TypeError, 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

  • No latin-1 restriction for str. CPython stores strings as UCS-1/2/4, so memcmp only matches code point order for 1-byte strings. RustPython stores WTF-8, and generalized UTF-8 byte order equals code point order across the whole range (lone surrogates included), so every homogeneous str list qualifies.
  • No machine-word restriction for int. CPython needs single-digit longs to shortcut its digit-array walk; comparing BigInts through Ord is already dispatch-free, so all-int lists qualify regardless of magnitude.
  • bool is 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:

workload generic specialized CPython 3.14
1M random ints 0.72s 0.53s (×1.35) 0.30s
1M random floats 0.69s 0.45s (×1.54) 0.18s
1M random strs 1.07s 0.83s (×1.29) 0.35s
1M sorted ints 0.044s 0.035s

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:

workload speedup here speedup in CPython
random bytes (object tier) ×1.05 ×1.06
random (int, int) tuples ×1.23 ×1.17

(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: Clone in sorting.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; and PyStr keeps 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 Object tier 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

    • Improved list sorting with stable ordering and optimized handling for strings, numbers, tuples, and custom comparisons.
    • Added broader support for sorting mixed and Unicode values, large integers, booleans, bytes, NaN, and incomparable types.
  • Bug Fixes

    • Improved sorting behavior for descending runs, duplicate values, and lexicographic tuple ordering.
    • Preserved appropriate fallback behavior when custom comparisons cannot determine an order.

kangdora added 11 commits August 1, 2026 02:10
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.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The VM replaces the external timsort dependency with an in-tree powersort-based implementation. List sorting now uses type-specialized comparators for supported keys and retains generic comparison fallbacks. Tests cover numeric, string, tuple, and edge-case ordering.

Changes

List sorting

Layer / File(s) Summary
In-tree timsort engine
crates/vm/src/sorting.rs
Adds stable adaptive timsort with run detection, powersort merge ordering, galloping merges, comparator error propagation, and unit tests.
Specialized list comparators
crates/vm/src/builtins/list.rs, crates/vm/Cargo.toml
Classifies sort keys and uses specialized string, integer, float, tuple, object, or generic comparisons. Removes the external timsort dependency.
Module wiring and behavior coverage
crates/vm/src/lib.rs, extra_tests/snippets/builtin_list.py
Exposes the sorting module and adds builtin sorted() coverage for supported values and edge cases.

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
Loading

Possibly related PRs

Suggested labels: z-ca-2026

Suggested reviewers: youknowone, shaharnaveh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement pre-scan specialization for exact-type strings, integers, floats, tuples, keys, and generic fallback paths.
Out of Scope Changes check ✅ Passed The sorting implementation, dependency cleanup, and tests directly support the linked issue objectives.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: specialized comparisons for homogeneous lists in list.sort().
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch powersort-type-specialize
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kangdora kangdora closed this Aug 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
crates/vm/src/sorting.rs (3)

232-237: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename b_wins in merge_hi; the name states the opposite of the branch action.

The comparison is is_lt(buf[cursor_b], values[cursor_a]), so a true result means the A element is the larger one and the code copies from A. The name b_wins therefore contradicts the branch body. In merge_lo the same name is correct, which makes the mismatch easy to misread during future maintenance. The behavior matches CPython's merge_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 value

Remove or rewrite the stale TODO at the top of the file.

MERGESTATE_TEMP_SIZE does not exist in this file or in this crate. The comment refers to a CPython implementation detail that this port intentionally replaced with a dynamic Vec. 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 win

Add tests for comparator errors and for stability.

The current tests only use an infallible comparator (Ok::<bool, ()>) and only assert final ordering of i32 values. Two behaviors that this engine guarantees are untested:

  1. Error propagation. The Err arms in merge_lo (lines 175-181) and merge_hi (lines 350-354) copy the buffered run back into values before 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.
  2. Stability. list.sort() and the specialized comparators in crates/vm/src/builtins/list.rs depend 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 win

Use the specialized comparator for the first-element equality test.

Line 785 always calls the generic rich_compare_bool(Eq), including for i == 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-level Eq dispatch, which is the cost the tuple specialization is meant to avoid.

CPython's unsafe_tuple_compare uses 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_eq counterpart to elem_lt and 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 win

Add coverage for reverse=True, for key=, and for inputs larger than 64 elements.

The new assertions cover element types well, but they miss three paths that this PR introduces:

  1. reverse=True. Each of the six arms in timsort_specialized applies its own operand swap. A missing swap in one arm would pass every assertion here.
  2. key=. The keyed path instantiates timsort_specialized over (PyObjectRef, PyObjectRef) and classifies the key results rather than the elements. No assertion exercises it.
  3. Input size. Every list here has fewer than 64 elements, so timsort always 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

📥 Commits

Reviewing files that changed from the base of the PR and between bb75624 and fc58bdf.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • Cargo.toml
  • crates/vm/Cargo.toml
  • crates/vm/src/builtins/list.rs
  • crates/vm/src/lib.rs
  • crates/vm/src/sorting.rs
  • extra_tests/snippets/builtin_list.py
💤 Files with no reviewable changes (2)
  • Cargo.toml
  • crates/vm/Cargo.toml

Comment on lines +810 to +859
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)
}),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 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.

Suggested change
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

@youknowone youknowone added the z-ca-2026 Tag to track Contribution Academy 2026 label Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

z-ca-2026 Tag to track Contribution Academy 2026

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use type-specialized comparators in list.sort() like CPython's listsort.c

2 participants