Replace quadratic array sorting with stable merge sort (#296)

* Fix Sandbox command transport and pinned native setup

Build the Linux LLVM helper and runtime pack before running both test lanes, and align native cache contracts with the current target policy. Full managed Sandbox gate passed.

* Replace quadratic array sorting with stable merge sort

Cover sort snapshots, consistent-comparator results, stable ties, and comparator complexity with Node differential fixtures. Document differences from V8 TimSort.
This commit is contained in:
Chris Tate
2026-09-12 16:05:16 -05:00
committed by GitHub
parent 8227a5ad9c
commit 4b241f77d7
7 changed files with 728 additions and 392 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ const who = process.argv.length > 2 ? process.argv[2] : "world";
**Process shape** — `process.argv[0]` is `"scriptc"` and `argv[1]` is the binary's path (positions line up with Node; `argv[2]` onward are your args). The uncaught-exception stderr line reads `Uncaught <value>` instead of Node's stack-trace block (exit code and pre-throw stdout are identical). Runtime errors carry `message` and Node's `code`, but not `errno`/`syscall`/`path`.
**Comparator call sequences differ in `sort` and `toSorted`** (stable insertion sort here, TimSort in V8 — sorted results are byte-identical for consistent comparators), and **`localeCompare` compares code units**, not ICU collation.
**Comparator call sequences differ in `sort` and `toSorted`** (scriptc uses a stable bottom-up merge sort, while V8 uses TimSort). Sorted results are byte-identical for consistent comparators. Ordered inputs use a linear number of comparator calls, but merge-buffer movement remains O(n log n) even when every boundary is already ordered. **`localeCompare` compares code units**, not ICU collation.
## Dynamic-tier limits
@@ -0,0 +1,587 @@
import {
BOOL,
BYTES_U8,
F64,
IrExpr,
IrFunction,
IrLocal,
IrParam,
IrStmt,
IrType,
JSVAL,
SrcLoc,
arrayOf,
funcOf,
} from "../../ir/ir.js";
import { numLit, varRef } from "../../ir/build.js";
function readArrayLength(arrT: IrType, loc: SrcLoc): IrStmt {
return {
kind: "varDecl",
localId: "n.0",
init: {
kind: "arrIntrinsic",
method: "length",
receiver: { kind: "varRef", localId: "a.0", type: arrT, loc },
args: [],
type: F64,
loc,
},
loc,
};
}
/** A stable bottom-up merge sort built from existing IR nodes.
*
* The first implementation used insertion sort because it was compact and
* made the stable tie rule obvious. Its worst case was quadratic, though,
* which made an ordinary descending input unusable. This implementation
* snapshots the receiver, merges runs between two buffers, and copies the
* final snapshot back into the receiver. The boundary check skips a merge
* when adjacent runs are already ordered, so naturally ordered inputs use
* only a linear number of comparator calls; the buffer copies still do
* O(n log n) data movement. Arbitrary inputs have an O(n log n) bound.
*
* The snapshot is also important for sort mutation behavior: comparator
* calls see the values captured before sorting starts, while the final copy
* preserves Array.sort receiver identity. toSorted starts by copying the
* receiver and therefore leaves that receiver untouched. The merge schedule
* intentionally differs from V8 TimSort, so exact comparator call order and
* count parity is not claimed; stable results, comparator exceptions, and
* mutations of referenced values still follow the normal IR call/ownership
* rules. */
export function buildArraySortFn(
name: string,
elem: IrType,
arity: number,
copyFirst: boolean,
undefinedTag: number | null,
loc: SrcLoc,
): IrFunction {
const arrT = arrayOf(elem);
const fnT = funcOf([elem, elem].slice(0, arity), F64);
const a = varRef("a.0", arrT, loc);
const src = varRef("src.0", arrT, loc);
const dst = varRef("dst.0", arrT, loc);
const n = varRef("n.0", F64, loc);
const width = varRef("width.0", F64, loc);
const start = varRef("start.0", F64, loc);
const mid = varRef("mid.0", F64, loc);
const right = varRef("right.0", F64, loc);
const left = varRef("left.0", F64, loc);
const r = varRef("r.0", F64, loc);
const k = varRef("k.0", F64, loc);
const add = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "+", left, right, type: F64, loc });
const sub = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "-", left, right, type: F64, loc });
const mul = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "*", left, right, type: F64, loc });
const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "<", left, right, type: BOOL, loc });
const not = (value: IrExpr): IrExpr => ({ kind: "unary", op: "!", operand: value, type: BOOL, loc });
const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: src, index, type: elem, loc });
const isUndefined = (value: IrExpr): IrExpr | null => {
if (elem.kind === "union" && undefinedTag !== null) {
return {
kind: "unionIsTag",
unionId: elem.unionId,
tag: undefinedTag,
negated: false,
value,
type: BOOL,
loc,
};
}
if (elem.kind === "jsval") {
return {
kind: "jsOp",
op: "eq",
args: [
value,
{ kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc },
],
type: BOOL,
loc,
};
}
return null;
};
const shouldTakeRight = (leftValue: IrExpr, rightValue: IrExpr): IrExpr => {
const compareGreater: IrExpr = {
kind: "bin",
op: ">",
left: {
kind: "callValue",
callee: varRef("f.0", fnT, loc),
args: [leftValue, rightValue].slice(0, arity),
type: F64,
loc,
},
right: numLit(0, loc),
type: BOOL,
loc,
};
const leftUndefined = isUndefined(leftValue);
const rightUndefined = isUndefined(rightValue);
// CompareArrayElements: undefined always sinks and never reaches the
// user comparator. Ternaries preserve that callback suppression.
return leftUndefined !== null && rightUndefined !== null
? {
kind: "ternary",
cond: leftUndefined,
then: not(rightUndefined),
else_: {
kind: "ternary",
cond: rightUndefined,
then: { kind: "boolLit", value: false, type: BOOL, loc },
else_: compareGreater,
type: BOOL,
loc,
},
type: BOOL,
loc,
}
: compareGreater;
};
const copyRange: IrStmt = {
kind: "while",
cond: lt(k, right),
body: [
{ kind: "varDecl", localId: "v.0", init: at(k), loc },
{ kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc },
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
};
const mergeBody: IrStmt[] = [
{
kind: "while",
cond: {
kind: "logical",
op: "&&",
left: lt(left, mid),
right: lt(r, right),
type: BOOL,
loc,
},
body: [
{ kind: "varDecl", localId: "vL.0", init: at(left), loc },
{ kind: "varDecl", localId: "vR.0", init: at(r), loc },
{
kind: "if",
cond: shouldTakeRight(varRef("vL.0", elem, loc), varRef("vR.0", elem, loc)),
then: [
{ kind: "arraySet", arr: dst, index: k, value: varRef("vR.0", elem, loc), loc },
{ kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc },
],
else_: [
{ kind: "arraySet", arr: dst, index: k, value: varRef("vL.0", elem, loc), loc },
{ kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc },
],
loc,
},
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
},
{
kind: "while",
cond: lt(left, mid),
body: [
{ kind: "varDecl", localId: "v.0", init: at(left), loc },
{ kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc },
{ kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc },
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
},
{
kind: "while",
cond: lt(r, right),
body: [
{ kind: "varDecl", localId: "v.0", init: at(r), loc },
{ kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc },
{ kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc },
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
},
];
const boundaryLeft = varRef("boundaryL.0", elem, loc);
const boundaryRight = varRef("boundaryR.0", elem, loc);
const mergeOrCopy: IrStmt = {
kind: "if",
cond: lt(mid, right),
then: [
{ kind: "varDecl", localId: "boundaryL.0", init: at(sub(mid, numLit(1, loc))), loc },
{ kind: "varDecl", localId: "boundaryR.0", init: at(mid), loc },
{
kind: "if",
cond: shouldTakeRight(boundaryLeft, boundaryRight),
then: mergeBody,
else_: [copyRange],
loc,
},
],
else_: [copyRange],
loc,
};
const mergePass: IrStmt = {
kind: "for",
init: { kind: "varDecl", localId: "start.0", init: numLit(0, loc), loc },
cond: lt(start, n),
update: { kind: "assign", localId: "start.0", value: add(start, add(width, width)), loc },
body: [
{
kind: "varDecl",
localId: "mid.0",
init: {
kind: "ternary",
cond: lt(add(start, width), n),
then: add(start, width),
else_: n,
type: F64,
loc,
},
loc,
},
{
kind: "varDecl",
localId: "right.0",
init: {
kind: "ternary",
cond: lt(add(add(start, width), width), n),
then: add(add(start, width), width),
else_: n,
type: F64,
loc,
},
loc,
},
{ kind: "varDecl", localId: "left.0", init: start, loc },
{ kind: "varDecl", localId: "r.0", init: mid, loc },
{ kind: "varDecl", localId: "k.0", init: start, loc },
mergeOrCopy,
],
loc,
};
const body: IrStmt[] = [
...(copyFirst
? [{
kind: "assign" as const,
localId: "a.0",
value: {
kind: "arrIntrinsic" as const,
method: "slice" as const,
receiver: varRef("a.0", arrT, loc),
args: [],
type: arrT,
loc,
},
loc,
}]
: []),
readArrayLength(arrT, loc),
{ kind: "varDecl", localId: "src.0", init: { kind: "arrIntrinsic", method: "slice", receiver: a, args: [], type: arrT, loc }, loc },
// The destination is filled from index zero upward on every merge pass.
// An empty literal is therefore dense and works for scalar arrays too;
// arrayNewLen is reserved for the separate absent-slot semantics.
{ kind: "varDecl", localId: "dst.0", init: { kind: "arrayLit", elems: [], type: arrT, loc }, loc },
{ kind: "varDecl", localId: "width.0", init: numLit(1, loc), loc },
{
kind: "while",
cond: lt(width, n),
body: [
mergePass,
{ kind: "varDecl", localId: "tmp.0", init: src, loc },
{ kind: "assign", localId: "src.0", value: dst, loc },
{ kind: "assign", localId: "dst.0", value: varRef("tmp.0", arrT, loc), loc },
{ kind: "assign", localId: "width.0", value: mul(width, numLit(2, loc)), loc },
],
loc,
},
{
kind: "for",
init: { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc },
cond: lt(varRef("i.0", F64, loc), n),
update: { kind: "assign", localId: "i.0", value: add(varRef("i.0", F64, loc), numLit(1, loc)), loc },
body: [
{ kind: "varDecl", localId: "v.0", init: { kind: "arrayGet", arr: src, index: varRef("i.0", F64, loc), type: elem, loc }, loc },
{ kind: "arraySet", arr: a, index: varRef("i.0", F64, loc), value: varRef("v.0", elem, loc), loc },
],
loc,
},
{ kind: "return", value: a, loc },
];
return {
name,
params: [
{ localId: "a.0", name: "a", type: arrT },
{ localId: "f.0", name: "f", type: fnT },
],
returnType: arrT,
locals: [
{ id: "a.0", name: "a", type: arrT, mutable: true },
{ id: "f.0", name: "f", type: fnT, mutable: true },
{ id: "n.0", name: "n", type: F64, mutable: false },
{ id: "src.0", name: "src", type: arrT, mutable: true },
{ id: "dst.0", name: "dst", type: arrT, mutable: true },
{ id: "width.0", name: "width", type: F64, mutable: true },
{ id: "start.0", name: "start", type: F64, mutable: true },
{ id: "mid.0", name: "mid", type: F64, mutable: true },
{ id: "right.0", name: "right", type: F64, mutable: true },
{ id: "left.0", name: "left", type: F64, mutable: true },
{ id: "r.0", name: "r", type: F64, mutable: true },
{ id: "k.0", name: "k", type: F64, mutable: true },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "tmp.0", name: "tmp", type: arrT, mutable: true },
{ id: "v.0", name: "v", type: elem, mutable: false },
{ id: "vL.0", name: "vL", type: elem, mutable: false },
{ id: "vR.0", name: "vR", type: elem, mutable: false },
{ id: "boundaryL.0", name: "boundaryL", type: elem, mutable: false },
{ id: "boundaryR.0", name: "boundaryR", type: elem, mutable: false },
],
body,
loc,
};
}
export function buildBytesSortFn(
name: string,
arity: number,
hasComparator: boolean,
loc: SrcLoc,
): IrFunction {
const bytesT = BYTES_U8;
const fnT = funcOf([F64, F64].slice(0, arity), F64);
const a = varRef("a.0", bytesT, loc);
const src = varRef("src.0", bytesT, loc);
const dst = varRef("dst.0", bytesT, loc);
const n = varRef("n.0", F64, loc);
const width = varRef("width.0", F64, loc);
const start = varRef("start.0", F64, loc);
const mid = varRef("mid.0", F64, loc);
const right = varRef("right.0", F64, loc);
const left = varRef("left.0", F64, loc);
const r = varRef("r.0", F64, loc);
const k = varRef("k.0", F64, loc);
const add = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "+", left, right, type: F64, loc });
const sub = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "-", left, right, type: F64, loc });
const mul = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "*", left, right, type: F64, loc });
const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "<", left, right, type: BOOL, loc });
const at = (receiver: IrExpr, index: IrExpr): IrExpr => ({
kind: "bytesIntrinsic",
method: "get",
receiver,
args: [index],
type: F64,
loc,
});
const shouldTakeRight = (leftValue: IrExpr, rightValue: IrExpr): IrExpr => ({
kind: "bin",
op: ">",
left: hasComparator
? {
kind: "callValue",
callee: varRef("f.0", fnT, loc),
args: [leftValue, rightValue].slice(0, arity),
type: F64,
loc,
}
: {
kind: "bin",
op: "-",
left: leftValue,
right: rightValue,
type: F64,
loc,
},
right: numLit(0, loc),
type: BOOL,
loc,
});
const copyRange: IrStmt = {
kind: "while",
cond: lt(k, right),
body: [
{ kind: "varDecl", localId: "v.0", init: at(src, k), loc },
{ kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc },
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
};
const mergeBody: IrStmt[] = [
{
kind: "while",
cond: {
kind: "logical",
op: "&&",
left: lt(left, mid),
right: lt(r, right),
type: BOOL,
loc,
},
body: [
{ kind: "varDecl", localId: "vL.0", init: at(src, left), loc },
{ kind: "varDecl", localId: "vR.0", init: at(src, r), loc },
{
kind: "if",
cond: shouldTakeRight(varRef("vL.0", F64, loc), varRef("vR.0", F64, loc)),
then: [
{ kind: "bytesSet", arr: dst, index: k, value: varRef("vR.0", F64, loc), loc },
{ kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc },
],
else_: [
{ kind: "bytesSet", arr: dst, index: k, value: varRef("vL.0", F64, loc), loc },
{ kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc },
],
loc,
},
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
},
{
kind: "while",
cond: lt(left, mid),
body: [
{ kind: "varDecl", localId: "v.0", init: at(src, left), loc },
{ kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc },
{ kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc },
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
},
{
kind: "while",
cond: lt(r, right),
body: [
{ kind: "varDecl", localId: "v.0", init: at(src, r), loc },
{ kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc },
{ kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc },
{ kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc },
],
loc,
},
];
const mergeOrCopy: IrStmt = {
kind: "if",
cond: lt(mid, right),
then: [
{ kind: "varDecl", localId: "boundaryL.0", init: at(src, sub(mid, numLit(1, loc))), loc },
{ kind: "varDecl", localId: "boundaryR.0", init: at(src, mid), loc },
{
kind: "if",
cond: shouldTakeRight(varRef("boundaryL.0", F64, loc), varRef("boundaryR.0", F64, loc)),
then: mergeBody,
else_: [copyRange],
loc,
},
],
else_: [copyRange],
loc,
};
const mergePass: IrStmt = {
kind: "for",
init: { kind: "varDecl", localId: "start.0", init: numLit(0, loc), loc },
cond: lt(start, n),
update: { kind: "assign", localId: "start.0", value: add(start, add(width, width)), loc },
body: [
{
kind: "varDecl",
localId: "mid.0",
init: {
kind: "ternary",
cond: lt(add(start, width), n),
then: add(start, width),
else_: n,
type: F64,
loc,
},
loc,
},
{
kind: "varDecl",
localId: "right.0",
init: {
kind: "ternary",
cond: lt(add(add(start, width), width), n),
then: add(add(start, width), width),
else_: n,
type: F64,
loc,
},
loc,
},
{ kind: "varDecl", localId: "left.0", init: start, loc },
{ kind: "varDecl", localId: "r.0", init: mid, loc },
{ kind: "varDecl", localId: "k.0", init: start, loc },
mergeOrCopy,
],
loc,
};
const params: IrParam[] = [
{ localId: "a.0", name: "a", type: bytesT },
...(hasComparator ? [{ localId: "f.0", name: "f", type: fnT }] : []),
];
const locals: IrLocal[] = [
{ id: "a.0", name: "a", type: bytesT, mutable: true },
...(hasComparator ? [{ id: "f.0", name: "f", type: fnT, mutable: true }] : []),
{ id: "n.0", name: "n", type: F64, mutable: false },
{ id: "src.0", name: "src", type: bytesT, mutable: true },
{ id: "dst.0", name: "dst", type: bytesT, mutable: true },
{ id: "width.0", name: "width", type: F64, mutable: true },
{ id: "start.0", name: "start", type: F64, mutable: true },
{ id: "mid.0", name: "mid", type: F64, mutable: true },
{ id: "right.0", name: "right", type: F64, mutable: true },
{ id: "left.0", name: "left", type: F64, mutable: true },
{ id: "r.0", name: "r", type: F64, mutable: true },
{ id: "k.0", name: "k", type: F64, mutable: true },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "tmp.0", name: "tmp", type: bytesT, mutable: true },
{ id: "v.0", name: "v", type: F64, mutable: false },
{ id: "vL.0", name: "vL", type: F64, mutable: false },
{ id: "vR.0", name: "vR", type: F64, mutable: false },
{ id: "boundaryL.0", name: "boundaryL", type: F64, mutable: false },
{ id: "boundaryR.0", name: "boundaryR", type: F64, mutable: false },
];
const slice = (receiver: IrExpr): IrExpr => ({
kind: "bytesIntrinsic",
method: "slice",
receiver,
args: [],
type: bytesT,
loc,
});
const body: IrStmt[] = [
{ kind: "assign", localId: "a.0", value: slice(a), loc },
{ kind: "varDecl", localId: "n.0", init: { kind: "bytesIntrinsic", method: "length", receiver: a, args: [], type: F64, loc }, loc },
{ kind: "varDecl", localId: "src.0", init: slice(a), loc },
{ kind: "varDecl", localId: "dst.0", init: slice(a), loc },
{ kind: "varDecl", localId: "width.0", init: numLit(1, loc), loc },
{
kind: "while",
cond: lt(width, n),
body: [
mergePass,
{ kind: "varDecl", localId: "tmp.0", init: src, loc },
{ kind: "assign", localId: "src.0", value: dst, loc },
{ kind: "assign", localId: "dst.0", value: varRef("tmp.0", bytesT, loc), loc },
{ kind: "assign", localId: "width.0", value: mul(width, numLit(2, loc)), loc },
],
loc,
},
{
kind: "for",
init: { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc },
cond: lt(varRef("i.0", F64, loc), n),
update: { kind: "assign", localId: "i.0", value: add(varRef("i.0", F64, loc), numLit(1, loc)), loc },
body: [
{ kind: "varDecl", localId: "v.0", init: at(src, varRef("i.0", F64, loc)), loc },
{ kind: "bytesSet", arr: a, index: varRef("i.0", F64, loc), value: varRef("v.0", F64, loc), loc },
],
loc,
},
{ kind: "return", value: a, loc },
];
return { name, params, returnType: bytesT, locals, body, loc };
}
@@ -11,6 +11,7 @@ import { droppableStatic, isRequireMainFilename, lowerDynObjectLiteral, probeLow
import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js";
import { isJsSourceFile, locOf } from "../program.js";
import { islandPrimitiveExit, lowerDynDispatchMethodCall } from "./lower-calls.js";
import { buildArraySortFn, buildBytesSortFn } from "./lower-array-sort.js";
import { typeKey } from "../type-mapper.js";
import { dynUndefinedExpr, own, WidthLift } from "./lowerer.js";
import { boolLit, countedFor, numLit, varRef } from "../../ir/build.js";
@@ -1618,19 +1619,20 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr {
* function like the other array HOFs. sort mutates and returns the receiver;
* toSorted takes its shallow snapshot INSIDE the helper, after the receiver
* and comparator expressions have both been evaluated, then sorts and
* returns that copy without touching the receiver. The loop is a
* binary-free INSERTION sort: stable (equal-comparing elements keep their
* source order, which is what Node's stable TimSort produces for any
* consistent comparator) and JS-faithful on the comparator contract — an
* element moves left only while cmp(left, v) > 0, so a NaN or 0 result holds
* position exactly like the spec's "treat as equal". The SEQUENCE of
* comparator calls differs from V8's TimSort (SEMANTICS.md); results are
* identical for consistent comparators. The comparator-less form lowers for
* STRING elements only — JS's default converts every element to string and
* compares UTF-16 units, so the interned synthesized comparator selects the
* runtime's code-unit ordering rather than scriptc's documented code-point
* relational operators. For numbers that default is the notorious string
* sort ([10, 9, 1] → [1, 10, 9]), deliberately fenced toward an explicit
* returns that copy without touching the receiver. The helper uses a stable
* bottom-up merge sort with an ordered-boundary check: ordered inputs use a
* linear number of comparator calls, but buffer movement remains O(n log n)
* even when every boundary is already ordered. During a merge, an element
* moves right only while cmp(left, right) > 0, so a NaN or 0 result keeps
* the left element first. Undefined values sink without reaching the user
* comparator. The callback sequence differs from V8's TimSort, so exact
* order and count parity are not claimed; results agree for consistent
* comparators. The comparator-less form lowers for STRING elements only —
* JS's default converts every element to string and compares UTF-16 units,
* so the interned synthesized comparator selects the runtime's code-unit
* ordering rather than scriptc's documented code-point relational
* operators. For numbers that default is the notorious string sort
* ([10, 9, 1] → [1, 10, 9]), deliberately fenced toward an explicit
* comparator. */
function lowerArraySortCall(lowerer: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,
@@ -1745,183 +1747,11 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr {
return name;
}
/** The insertion-sort loop, from existing IR nodes:
*
* n = a.length;
* for (i = 1; i < n; i++) {
* v = a[i]; j = i - 1;
* while (j >= 0) {
* if (CompareArrayElements(a[j], v, f) > 0) {
* a[j + 1] = a[j]; j = j - 1;
* } else break;
* }
* a[j + 1] = v;
* }
* return a;
*/
function buildArraySortFn(
name: string,
elem: IrType,
arity: number,
copyFirst: boolean,
undefinedTag: number | null,
loc: SrcLoc,
): IrFunction {
const arrT = arrayOf(elem);
const fnT = funcOf([elem, elem].slice(0, arity), F64);
const j = varRef("j.0", F64, loc);
const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: varRef("a.0", arrT, loc), index, type: elem, loc });
const jPlus1: IrExpr = { kind: "bin", op: "+", left: j, right: numLit(1, loc), type: F64, loc };
const isUndefined = (value: IrExpr): IrExpr | null => {
if (elem.kind === "union" && undefinedTag !== null) {
return {
kind: "unionIsTag",
unionId: elem.unionId,
tag: undefinedTag,
negated: false,
value,
type: BOOL,
loc,
};
}
if (elem.kind === "jsval") {
return {
kind: "jsOp",
op: "eq",
args: [
value,
{ kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc },
],
type: BOOL,
loc,
};
}
return null;
};
const compareGreater: IrExpr = {
kind: "bin",
op: ">",
left: {
kind: "callValue",
callee: varRef("f.0", fnT, loc),
args: [at(j), varRef("v.0", elem, loc)].slice(0, arity),
type: F64,
loc,
},
right: numLit(0, loc),
type: BOOL,
loc,
};
const leftUndefined = isUndefined(at(j));
const valueUndefined = isUndefined(varRef("v.0", elem, loc));
// CompareArrayElements: undefined always sinks and never reaches the
// user comparator. Ternaries preserve that callback suppression.
const shouldShift: IrExpr =
leftUndefined !== null && valueUndefined !== null
? {
kind: "ternary",
cond: leftUndefined,
then: {
kind: "unary",
op: "!",
operand: valueUndefined,
type: BOOL,
loc,
},
else_: {
kind: "ternary",
cond: valueUndefined,
then: { kind: "boolLit", value: false, type: BOOL, loc },
else_: compareGreater,
type: BOOL,
loc,
},
type: BOOL,
loc,
}
: compareGreater;
const shiftLoop: IrStmt = {
kind: "while",
cond: { kind: "bin", op: ">=", left: j, right: numLit(0, loc), type: BOOL, loc },
body: [
{
kind: "if",
cond: shouldShift,
then: [
{ kind: "arraySet", arr: varRef("a.0", arrT, loc), index: jPlus1, value: at(j), loc },
{ kind: "assign", localId: "j.0", value: { kind: "bin", op: "-", left: j, right: numLit(1, loc), type: F64, loc }, loc },
],
else_: [{ kind: "break", loc }],
loc,
},
],
loc,
};
const body: IrStmt[] = [
...(copyFirst
? [{
kind: "assign" as const,
localId: "a.0",
value: {
kind: "arrIntrinsic" as const,
method: "slice" as const,
receiver: varRef("a.0", arrT, loc),
args: [],
type: arrT,
loc,
},
loc,
}]
: []),
readLenStmt(arrT, loc),
countedFor(
loc,
{ kind: "bin", op: "-", left: varRef("n.0", F64, loc), right: numLit(1, loc), type: F64, loc },
(index) => [
{
kind: "varDecl",
localId: "v.0",
init: {
kind: "arrayGet",
arr: varRef("a.0", arrT, loc),
index: { kind: "bin", op: "+", left: index, right: numLit(1, loc), type: F64, loc },
type: elem,
loc,
},
loc,
},
{ kind: "varDecl", localId: "j.0", init: index, loc },
shiftLoop,
{ kind: "arraySet", arr: varRef("a.0", arrT, loc), index: jPlus1, value: varRef("v.0", elem, loc), loc },
],
),
{ kind: "return", value: varRef("a.0", arrT, loc), loc },
];
return {
name,
params: [
{ localId: "a.0", name: "a", type: arrT },
{ localId: "f.0", name: "f", type: fnT },
],
returnType: arrT,
locals: [
{ id: "a.0", name: "a", type: arrT, mutable: true },
{ id: "f.0", name: "f", type: fnT, mutable: true },
{ id: "n.0", name: "n", type: F64, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "v.0", name: "v", type: elem, mutable: false },
{ id: "j.0", name: "j", type: F64, mutable: true },
],
body,
loc,
};
}
/** Uint8Array.prototype.toSorted. The receiver/comparator expressions are
* evaluated before entering the helper; the helper snapshots with
* TypedArray.prototype.slice before its first comparison, then performs
* the same stable insertion walk as Array.toSorted. Uint8Array's default
* the same stable merge walk as Array.toSorted. Uint8Array's default
* comparator is numeric ascending, so its comparator-less form needs no
* string-conversion machinery. */
function lowerBytesToSortedCall(
@@ -2003,211 +1833,6 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr {
};
}
function buildBytesSortFn(
name: string,
arity: number,
hasComparator: boolean,
loc: SrcLoc,
): IrFunction {
const bytesT = BYTES_U8;
const fnT = funcOf([F64, F64].slice(0, arity), F64);
const j = varRef("j.0", F64, loc);
const at = (index: IrExpr): IrExpr => ({
kind: "bytesIntrinsic",
method: "get",
receiver: varRef("a.0", bytesT, loc),
args: [index],
type: F64,
loc,
});
const jPlus1: IrExpr = {
kind: "bin",
op: "+",
left: j,
right: numLit(1, loc),
type: F64,
loc,
};
const compare: IrExpr = hasComparator
? {
kind: "callValue",
callee: varRef("f.0", fnT, loc),
args: [at(j), varRef("v.0", F64, loc)].slice(0, arity),
type: F64,
loc,
}
: {
kind: "bin",
op: "-",
left: at(j),
right: varRef("v.0", F64, loc),
type: F64,
loc,
};
const shiftLoop: IrStmt = {
kind: "while",
cond: {
kind: "bin",
op: ">=",
left: j,
right: numLit(0, loc),
type: BOOL,
loc,
},
body: [
{
kind: "if",
cond: {
kind: "bin",
op: ">",
left: compare,
right: numLit(0, loc),
type: BOOL,
loc,
},
then: [
{
kind: "bytesSet",
arr: varRef("a.0", bytesT, loc),
index: jPlus1,
value: at(j),
loc,
},
{
kind: "assign",
localId: "j.0",
value: {
kind: "bin",
op: "-",
left: j,
right: numLit(1, loc),
type: F64,
loc,
},
loc,
},
],
else_: [{ kind: "break", loc }],
loc,
},
],
loc,
};
const params: IrParam[] = [
{ localId: "a.0", name: "a", type: bytesT },
...(hasComparator
? [{ localId: "f.0", name: "f", type: fnT }]
: []),
];
const locals: IrLocal[] = [
{ id: "a.0", name: "a", type: bytesT, mutable: true },
...(hasComparator
? [{ id: "f.0", name: "f", type: fnT, mutable: true }]
: []),
{ id: "n.0", name: "n", type: F64, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "v.0", name: "v", type: F64, mutable: false },
{ id: "j.0", name: "j", type: F64, mutable: true },
];
const body: IrStmt[] = [
{
kind: "assign",
localId: "a.0",
value: {
kind: "bytesIntrinsic",
method: "slice",
receiver: varRef("a.0", bytesT, loc),
args: [],
type: bytesT,
loc,
},
loc,
},
{
kind: "varDecl",
localId: "n.0",
init: {
kind: "bytesIntrinsic",
method: "length",
receiver: varRef("a.0", bytesT, loc),
args: [],
type: F64,
loc,
},
loc,
},
{
kind: "for",
init: {
kind: "varDecl",
localId: "i.0",
init: numLit(1, loc),
loc,
},
cond: {
kind: "bin",
op: "<",
left: varRef("i.0", F64, loc),
right: varRef("n.0", F64, loc),
type: BOOL,
loc,
},
update: {
kind: "assign",
localId: "i.0",
value: {
kind: "bin",
op: "+",
left: varRef("i.0", F64, loc),
right: numLit(1, loc),
type: F64,
loc,
},
loc,
},
body: [
{
kind: "varDecl",
localId: "v.0",
init: at(varRef("i.0", F64, loc)),
loc,
},
{
kind: "varDecl",
localId: "j.0",
init: {
kind: "bin",
op: "-",
left: varRef("i.0", F64, loc),
right: numLit(1, loc),
type: F64,
loc,
},
loc,
},
shiftLoop,
{
kind: "bytesSet",
arr: varRef("a.0", bytesT, loc),
index: jPlus1,
value: varRef("v.0", F64, loc),
loc,
},
],
loc,
},
{ kind: "return", value: varRef("a.0", bytesT, loc), loc },
];
return {
name,
params,
returnType: bytesT,
locals,
body,
loc,
};
}
/** `n = a.length` — the once-up-front length read every array HOF loop
* starts with (locals a.0/n.0 by convention). */
@@ -5682,12 +5682,24 @@
],
"diags": []
},
"<repo>/tests/corpus/2700-array-sort-complexity.ts": {
"order": [
"<repo>/tests/corpus/2700-array-sort-complexity.ts"
],
"diags": []
},
"<repo>/tests/corpus/2700-wasi-core.ts": {
"order": [
"<repo>/tests/corpus/2700-wasi-core.ts"
],
"diags": []
},
"<repo>/tests/corpus/2701-array-sort-snapshot.ts": {
"order": [
"<repo>/tests/corpus/2701-array-sort-snapshot.ts"
],
"diags": []
},
"<repo>/tests/corpus/2701-import-meta/main.mjs": {
"order": [
"<repo>/tests/corpus/2701-import-meta/module with space.mjs",
@@ -5695,6 +5707,12 @@
],
"diags": []
},
"<repo>/tests/corpus/2702-uint8array-to-sorted.ts": {
"order": [
"<repo>/tests/corpus/2702-uint8array-to-sorted.ts"
],
"diags": []
},
"<repo>/tests/corpus/300-if-else.ts": {
"order": [
"<repo>/tests/corpus/300-if-else.ts"
@@ -0,0 +1,44 @@
// Stable sort must stay scalable across ordered, adversarial, random, and
// duplicate-heavy inputs. The budget is deliberately loose for a merge sort
// but rejects the old quadratic insertion sort on the larger cases.
function checkBudget(label: string, input: number[], budget: number): void {
let comparisons = 0;
const sorted = input.slice();
sorted.sort((a, b) => {
comparisons++;
if (comparisons > budget) throw new Error(label + " exceeded comparison budget");
return a - b;
});
for (let i = 1; i < sorted.length; i++) {
if (sorted[i - 1]! > sorted[i]!) throw new Error(label + " was not sorted");
}
console.log(label, sorted.length, sorted[0], sorted[sorted.length - 1]);
}
const size = 4096;
const ascending: number[] = [];
const descending: number[] = [];
const random: number[] = [];
const duplicates: number[] = [];
let seed = 123456789;
for (let i = 0; i < size; i++) {
ascending.push(i);
descending.push(size - i - 1);
seed = (seed * 1664525 + 1013904223) % 4294967296;
random.push(seed % size);
duplicates.push((i * 17) % 31);
}
checkBudget("ascending", ascending, size * 4);
checkBudget("descending", descending, size * 32);
checkBudget("random", random, size * 32);
checkBudget("duplicates", duplicates, size * 32);
const ties = [
{ key: 1, order: "a" },
{ key: 0, order: "b" },
{ key: 1, order: "c" },
{ key: 0, order: "d" },
];
ties.sort((a, b) => a.key - b.key);
console.log(ties.map((entry) => entry.order).join(","));
+46
View File
@@ -0,0 +1,46 @@
const receiver = [3, 1, 2];
const returned = receiver.sort((a, b) => a - b);
console.log(returned === receiver, receiver.join(","), returned.join(","));
const mutatedReceiver = [3, 1, 2];
let changedReceiver = false;
mutatedReceiver.sort((a, b) => {
if (!changedReceiver) {
changedReceiver = true;
mutatedReceiver[0] = 99;
}
return a - b;
});
console.log(mutatedReceiver.join(","));
const source = [3, 1, 2];
let changed = false;
const sorted = source.toSorted((a, b) => {
if (!changed) {
changed = true;
source[0] = 99;
source.push(4);
}
return a - b;
});
console.log(sorted.join(","));
console.log(source.join(","));
const stable = [
{ key: "b", order: 1 },
{ key: "a", order: 2 },
{ key: "b", order: 3 },
{ key: "a", order: 4 },
];
const byKey = stable.toSorted((a, b) => a.key.localeCompare(b.key));
console.log(byKey.map((entry) => entry.order).join(","));
console.log(stable.map((entry) => entry.order).join(","));
const throwing = [3, 1, 2];
try {
throwing.sort((a, b): number => {
throw new Error("stop");
});
} catch (error) {
console.log(error instanceof Error, throwing.join(","));
}
+16
View File
@@ -0,0 +1,16 @@
const source = new Uint8Array(256);
for (let i = 0; i < source.length; i++) source[i] = 255 - i;
let comparisons = 0;
const sorted = source.toSorted((a, b) => {
comparisons++;
if (comparisons > 5000) throw new Error("Uint8Array sort exceeded comparison budget");
return a - b;
});
console.log(sorted[0], sorted[255], source[0], source[255]);
const descending = source.toSorted((a, b) => b - a);
console.log(descending[0], descending[255], source[0], source[255]);
const defaults = new Uint8Array([9, 1, 7, 1, 5, 2]).toSorted();
console.log(defaults[0], defaults[1], defaults[2], defaults[3], defaults[4], defaults[5]);