Skip to content

Conversation

statham-arm
Copy link
Collaborator

Previously, the Quicksort implementation was written in the obvious way: after each partitioning step, it explicitly recursed twice to sort the two sublists. Now it compares the two sublists' sizes, and recurses only to sort the smaller one. To handle the larger list it loops back round to the top of the function, so as to handle it within the existing stack frame.

This means that every recursive call is handling a list at most half that of its caller. So the maximum recursive call depth is O(lg N). Otherwise, in Quicksort's bad cases where each partition step peels off a small constant number of array elements, the stack usage could grow linearly with the array being sorted, i.e. it might be Θ(N).

I tested this code by manually constructing a List Of Doom that causes this particular quicksort implementation to hit its worst case, and confirming that it recursed very deeply in the old code and doesn't in the new code. But I haven't added that list to the test suite, because the List Of Doom has to be constructed in a way based on every detail of the quicksort algorithm (pivot choice and partitioning strategy), so it would silently stop being a useful regression test as soon as any detail changed.

@llvmbot
Copy link
Member

llvmbot commented Oct 2, 2024

@llvm/pr-subscribers-libc

Author: Simon Tatham (statham-arm)

Changes

Previously, the Quicksort implementation was written in the obvious way: after each partitioning step, it explicitly recursed twice to sort the two sublists. Now it compares the two sublists' sizes, and recurses only to sort the smaller one. To handle the larger list it loops back round to the top of the function, so as to handle it within the existing stack frame.

This means that every recursive call is handling a list at most half that of its caller. So the maximum recursive call depth is O(lg N). Otherwise, in Quicksort's bad cases where each partition step peels off a small constant number of array elements, the stack usage could grow linearly with the array being sorted, i.e. it might be Θ(N).

I tested this code by manually constructing a List Of Doom that causes this particular quicksort implementation to hit its worst case, and confirming that it recursed very deeply in the old code and doesn't in the new code. But I haven't added that list to the test suite, because the List Of Doom has to be constructed in a way based on every detail of the quicksort algorithm (pivot choice and partitioning strategy), so it would silently stop being a useful regression test as soon as any detail changed.


Full diff: https://github.com/llvm/llvm-project/pull/110849.diff

2 Files Affected:

  • (modified) libc/src/stdlib/qsort_data.h (+6)
  • (modified) libc/src/stdlib/quick_sort.h (+26-10)
diff --git a/libc/src/stdlib/qsort_data.h b/libc/src/stdlib/qsort_data.h
index db045332708ae6..9d44d41f46580c 100644
--- a/libc/src/stdlib/qsort_data.h
+++ b/libc/src/stdlib/qsort_data.h
@@ -92,6 +92,12 @@ class Array {
   Array make_array(size_t i, size_t s) const {
     return Array(get(i), s, elem_size, compare);
   }
+
+  // Reset this Array to point at a different interval of the same items.
+  void reset_bounds(uint8_t *a, size_t s) {
+    array = a;
+    array_size = s;
+  }
 };
 
 using SortingRoutine = void(const Array &);
diff --git a/libc/src/stdlib/quick_sort.h b/libc/src/stdlib/quick_sort.h
index 89ec107161e3e5..a3fe1681591d26 100644
--- a/libc/src/stdlib/quick_sort.h
+++ b/libc/src/stdlib/quick_sort.h
@@ -59,17 +59,33 @@ static size_t partition(const Array &array) {
   }
 }
 
-LIBC_INLINE void quick_sort(const Array &array) {
-  const size_t array_size = array.size();
-  if (array_size <= 1)
-    return;
-  size_t split_index = partition(array);
-  if (array_size <= 2) {
-    // The partition operation sorts the two element array.
-    return;
+LIBC_INLINE void quick_sort(Array array) {
+  while (true) {
+    const size_t array_size = array.size();
+    if (array_size <= 1)
+      return;
+    size_t split_index = partition(array);
+    if (array_size <= 2) {
+      // The partition operation sorts the two element array.
+      return;
+    }
+
+    // Make Arrays describing the two sublists that still need sorting.
+    Array left = array.make_array(0, split_index);
+    Array right = array.make_array(split_index, array.size() - split_index);
+
+    // Recurse to sort the smaller of the two, and then loop round within this
+    // function to sort the larger. This way, recursive call depth is bounded
+    // by log2 of the total array size, because every recursive call is sorting
+    // a list at most half the length of the one in its caller.
+    if (left.size() < right.size()) {
+        quick_sort(left, depth+1);
+      array.reset_bounds(right.get(0), right.size());
+    } else {
+        quick_sort(right, depth+1);
+      array.reset_bounds(left.get(0), left.size());
+    }
   }
-  quick_sort(array.make_array(0, split_index));
-  quick_sort(array.make_array(split_index, array.size() - split_index));
 }
 
 } // namespace internal

Copy link

github-actions bot commented Oct 2, 2024

✅ With the latest revision this PR passed the C/C++ code formatter.

Previously, the Quicksort implementation was written in the obvious
way: after each partitioning step, it explicitly recursed twice to
sort the two sublists. Now it compares the two sublists' sizes, and
recurses only to sort the smaller one. To handle the larger list it
loops back round to the top of the function, so as to handle it within
the existing stack frame.

This means that every recursive call is handling a list at most half
that of its caller. So the maximum recursive call depth is O(lg N).
Otherwise, in Quicksort's bad cases where each partition step peels
off a small constant number of array elements, the stack usage could
grow linearly with the array being sorted, i.e. it might be Θ(N).

I tested this code by manually constructing a List Of Doom that causes
this particular quicksort implementation to hit its worst case, and
confirming that it recursed very deeply in the old code and doesn't in
the new code. But I haven't added that list to the test suite, because
the List Of Doom has to be constructed in a way based on every detail
of the quicksort algorithm (pivot choice and partitioning strategy),
so it would silently stop being a useful regression test as soon as
any detail changed.
if (array_size <= 2) {
// The partition operation sorts the two element array.
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That construction was unchanged from the previous version of the code – all I've done is to move it right by two spaces! But fine, I can fix it while I'm here.

if (array_size <= 1)
return;
size_t split_index = partition(array);
if (array_size <= 2)
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
if (array_size <= 2)
if (array_size == 2)

@@ -92,6 +92,12 @@ class Array {
Array make_array(size_t i, size_t s) const {
return Array(get(i), s, elem_size, compare);
}

// Reset this Array to point at a different interval of the same items.
void reset_bounds(uint8_t *a, size_t s) {
Copy link
Member

Choose a reason for hiding this comment

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

This method, and the others should probably have the LIBC_INLINE macro added.
https://libc.llvm.org/dev/code_style.html#inline-functions-and-variables-defined-in-header-files

Copy link
Contributor

@michaelrj-google michaelrj-google left a comment

Choose a reason for hiding this comment

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

LGTM, if possible please also file a bug for further qsort cleanup, specifically marking all the functions in headers with LIBC_INLINE.

@statham-arm statham-arm merged commit 58ef1eb into llvm:main Oct 8, 2024
8 checks passed
@statham-arm statham-arm deleted the qsort-log-stack branch October 8, 2024 07:45
@statham-arm
Copy link
Collaborator Author

LGTM, if possible please also file a bug for further qsort cleanup, specifically marking all the functions in headers with LIBC_INLINE.

Done: #111495.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants