Skip to content

Replace quicksort with stable merge sort in array.sort() - #629

Merged
nbeerbower merged 2 commits into
mainfrom
claude/h2-array-sort-quadratic-mue9qh
Jul 28, 2026
Merged

Replace quicksort with stable merge sort in array.sort()#629
nbeerbower merged 2 commits into
mainfrom
claude/h2-array-sort-quadratic-mue9qh

Conversation

@nbeerbower

Copy link
Copy Markdown
Collaborator

Summary

Replaces the O(n²) quicksort implementation in array.sort() with a stable bottom-up merge sort that guarantees O(n log n) comparisons on every input. This fixes performance degradation on presorted and reverse-sorted arrays, and eliminates stack overflow risk on large arrays.

Changes

Interpreter backend (src/backends/interpreter/io/array_methods.c):

  • Added sort_default_compare() to centralize default comparison logic
  • Added sort_compare() wrapper that handles both default and custom comparators, with mid-sort resize detection
  • Added sort_merge_runs() to merge two adjacent sorted runs with a fast-path optimization for already-ordered data
  • Added array_merge_sort() implementing iterative bottom-up merge sort with scratch buffer allocation
  • Replaced 50-line insertion sort with a call to array_merge_sort()

Compiled backend (runtime/src/builtins_array.c):

  • Replaced Lomuto quicksort (partition() and quicksort()) with merge_runs() and merge_sort()
  • Updated SortContext to track array pointer and expected length for resize detection
  • Merge sort is iterative (no recursion depth issues) and stable (equal elements preserve order)

Documentation (docs/reference/array-api.md):

  • Updated sort() documentation to describe the merge sort algorithm, its O(n log n) guarantee, and behavior when comparators throw or resize the array

Tests (tests/parity/methods/array_sort.hml and .expected):

  • Added comprehensive regression test covering:
    • Default ordering and custom comparators
    • Edge cases (empty, single, two elements, all equal)
    • Stability verification with 15-element object array
    • Presorted input (2000 elements ascending)
    • Reverse-sorted input (2000 elements descending)
    • Shuffled input (2000 pseudo-random elements)
    • Throwing comparator (verifies array remains valid permutation)

Implementation Details

  • Stability: Uses <= 0 in merge comparisons to take from the left run on ties, preserving original order
  • Fast path: Detects when two runs are already in order and skips the merge, keeping O(n) comparisons for presorted input
  • Resize detection: Comparators that call push(), pop(), etc. are caught and throw "sort() comparator resized the array being sorted" rather than causing use-after-free
  • Memory: Allocates a single scratch buffer of n values for the entire sort; on comparator exception, the buffer is leaked (acceptable since hml_throw() does not unwind)
  • Overflow safety: Uses 64-bit width counter to prevent doubling overflow on large arrays

https://claude.ai/code/session_01GqFtipMZWUEAWQ8534MuBk

claude added 2 commits July 28, 2026 00:57
…sort (H-2)

array.sort() degraded to quadratic time on the inputs programs produce most
often, and the compiled backend crashed outright on large ones.

The compiled runtime used a Lomuto-partition quicksort with a last-element
pivot and no randomization, recursing on both halves. Sorted or reverse-sorted
input hit the worst case: O(n^2) comparisons and O(n) recursion depth. A
presorted 200k-element array segfaulted from stack exhaustion; 2000 elements
took ~5ms instead of ~0ms. Sorted input is not an exotic case — it is what
incremental workloads such as depth-sorting a scene each frame naturally
produce.

The interpreter used an insertion sort, which is fine on presorted input but
O(n^2) on shuffled input. The two backends also disagreed on equal-comparing
elements, since quicksort is unstable and insertion sort is stable.

Both backends now use the same stable bottom-up merge sort:

- O(n log n) comparisons on every input, with no quadratic worst case.
- Iterative, so no array size can exhaust the C stack.
- Stable, so both backends agree on ties and sort() gains a documented
  guarantee it did not previously have.
- Each merge first checks whether the two runs are already in order and skips
  the copy if so, keeping fully sorted input at O(n) comparisons.

Measured on a presorted array (compiled backend): 200k elements now sorts in
1ms where it previously segfaulted, and 2000 elements drops from ~5ms to ~0ms.
Interpreter: 50k shuffled elements sorts in 7ms.

Two correctness properties come with the rewrite. Each merge builds its output
in scratch space and copies back only once complete, so a comparator that
throws leaves the array a valid permutation of its elements rather than a
buffer with duplicated slots and corrupted reference counts. And a comparator
that resizes the array being sorted — which would dangle the element pointer
the merge is walking — now raises a catchable error instead of a
use-after-free.

Adds tests/parity/methods/array_sort.hml covering default and custom
comparators, stability, presorted/reversed/shuffled input, edge cases, and the
throwing-comparator path. Verified clean under valgrind on both backends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqFtipMZWUEAWQ8534MuBk
Patch release for the array.sort() fix: bumps HEMLOCK_VERSION to 2.9.1 and
records the change in CHANGELOG.md and the version.h history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqFtipMZWUEAWQ8534MuBk
@nbeerbower
nbeerbower merged commit e41c08c into main Jul 28, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants