Amphora.PerformanceTests times Amphora's sorts and random generators against the framework's. Run Bin\Release\Amphora.PerformanceTests.exe for the 32-bit build the games ship on, and Amphora.PerformanceTests64.exe for the same code as 64-bit. Arguments: --quick for fewer rounds, --values, --items or --random for one part of it, and text to run only the rows with it in their name. It refuses to run under a debugger or from a Debug build, and warns when the machine is busy. Every row has a control that runs alongside what is under test, so read the speedups over it and not the nanoseconds. Each candidate also copies its input in, so a copy-only candidate is timed and taken off first, which matters below about 20 items. Each time is the fastest of the rounds. The --values rows sort an array of plain uint values. The control is Array.Sort. The candidates are Sort.CopyOfKeys, which returns a sorted copy, and Sort.Keys, which sorts in place and keeps its scratch space. Values are random over the int range, random in twelve bits, or already ascending. The --items rows put a batch of nodes in id order by an int key, the way ArcScheduler's wake batch has to be. The control is Array.Sort with a comparer. The other candidates are Array.Sort on a key array, Sort.Items, which moves the nodes through an int array that the caller owns, and Sort.Order, which only writes that array and leaves the nodes where they were. Batches of 1 to 16384, in several ascending runs, scattered, already ascending, or exactly descending. What they showed (2026-10-12, 32-bit, 8 logical processors, one busy at 25 to 35 percent). The sort that RadixSort used to be was timed against the new one in the same runs before it was replaced. It made three passes of eleven bits over any input, and allocated 24 KB of counters on every call. Values, speedups over Array.Sort, random in the int range: up to 512 values: Sort.Keys is within 10 percent of Array.Sort, which it hands small batches to. The old sort took 5 to 10 us whatever the size, so it was 100 times slower on 8 values. 2048 values and up: Sort.Keys is 3 to 6 times faster. The old sort was 3 to 6 times faster, too, and the new one is within 8 percent of it: +5 percent at 2048, +6 at 8192, -2 at 32768, +1 at 131072, +8 at 524288. Values already ascending: Sort.Keys is 2 to 19 times faster than Array.Sort, where the old sort was 0.3 to 0.8. Values in twelve bits: Sort.Keys is faster than the old sort at 2048 to 32768 and at 524288, and 16 percent slower at 131072. Where radix starts to beat Array.Sort depends on how many bits the keys differ over (2026-10-12, 32-bit, random keys masked to that many bits). Radix passes alone, the batch size where they first reach 1.0 times Array.Sort: 6 bits: 50. 8: 76. 10: 100. 12: 110. 14: 128. 16: 195. 20: 325. 24: 430. 31: about 920. Sort.RadixCrossover is a table of 33 * 1.12 ^ bits (the least squares fit of those), raised a little and floored at 96 keys, since below that narrow keys gain under 15 percent. Every extra bit moves the crossover up by 12 percent, so it doubles every 6 bits. Sort.Keys scans the keys for the bits they differ over, and uses radix when the batch reaches that table entry. It looks at the first 16 keys first, which already shows keys that are far too wide, so wide keys skip the full scan. Speedups over Array.Sort at 512 keys: 2 to 8 bits 2.5 to 3.3 times, 12 bits 2.1, 16 bits 1.7, 24 bits 1.1, 31 bits 1.0. At 1024 keys: 3.2 to 4.1, 2.8, 2.1, 1.8 and 1.1. Wide keys below their crossover cost up to 3 to 8 percent against calling Array.Sort directly, from the in-order check, the probe and the call. Reading a uint array as an int one with a cast takes about 30 ns, which was 12 percent of a 32 key sort and showed in every uint row. ReinterpretArray does it in 2 ns with a struct whose two array fields share an offset. Items, speedups over Array.Sort with a comparer (2026-10-17, 32-bit and then 64-bit in brackets): Sort.Items puts the nodes in order through positions. It writes where each key should go into an int array the caller owns, in a stable way, and then moves each node once by following the cycles of that order. The keys and the order are left as they are, so afterwards the order says where each node came from. Sort.Order stops before moving the nodes. 2 to 4 items: Sort.Items 2.7 to 3.7 times (3.1 to 4.4). Key array sort 1.0 to 1.5. 8 to 12 items: 1.8 to 2.2 (2.2 to 2.9). These sizes have an unrolled sort each, generated at build time by Amphora\GenerateUnrolledSorts.py, which writes the order and not the nodes. 16 to 40 items: 1.1 to 2.1 (1.5 to 2.5). One scattered batch of 24 ties the control, being nearly the worst order for the insertion that does this size. 48 to 128 items: 1.9 to 2.5 (2.5 to 3.4). 256 items: 2.9 (3.9). 1024 items: 4.3 to 5.5 (5.3 to 6.8). 4096 items: 5.4 to 6.8 (6.9 to 8.6). 16384 items: 5.3 to 6.6 (6.3 to 7.2). Sort.Order alone is 1.2 to 1.6 times as fast as Sort.Items from 256 items up, since no node moves: 8.3 to 10.9 (10.6 to 13.4) at 4096 items. Already in order, 4 to 5 times at 8 to 16 items, 10 at 48, 28 at 1024 and 4096, and 44 at 16384 (31 to 33 in 64-bit), since the order is checked before anything but the unrolled sorts of 8 items or fewer. Exactly descending is reversed instead of insertion sorted: 6.9 times (6.5) at 16 items and 2.9 to 3.1 (3.1 to 4.3) from 24 to 40. Without it insertion lost to the control at 0.8 times. A batch that is nearly descending still sorts by insertion and loses by the same amount, 0.75 to 0.9 times at 24 to 40 items. Run-merge and budgeted-insertion hybrids were tried and fixed that, but lost on nearly ascending batches or cost more code than the case is worth. Descending, past 40 items: 1.2 to 1.3 times (1.6 to 1.7) at 48 and 64, 3.0 (3.9) at 1024, 3.7 (4.8) at 4096. How the order is made above 40 items. The first pass deals the positions into buckets by the top bits the keys differ in, reading the keys front to back, which keeps equal keys in their order. It aims at 4 keys a bucket, up to 2048 buckets. A bucket of more than 40 is permuted in place by the next 8 bits, which does not keep equal keys in their order, and when the bits run out the equal keys are put back by sorting their positions. Everything else is insertion. All the counters are on the stack, so nothing grows with the batch except the caller's order. Buckets of 4 keys ran 1.6 times as fast as buckets of 16 at 4096 scattered keys (32-bit), and buckets of 2 gained nothing more. Moving the nodes by following cycles is a quarter to a third of the time at 1024 to 4096 items in 32-bit. Marking a node as done by complementing its order entry, then undoing that, costs 0 to 4 percent more than overwriting the order in 32-bit, and 2 to 18 percent in 64-bit. The sort before this one scattered the keys and the nodes into two scratch arrays and back, kept in an object every user had to hold. This one runs at 0.8 to 1.25 times its speed in 32-bit from 96 items up, and 0.9 to 1.7 times in 64-bit. Unrolled sorts, measured while choosing their size (2026-10-12, 32-bit and 64-bit, when they swapped the nodes as well as the keys, where now they swap the keys and where each came from): Unrolling to 8 items already takes a 4 item batch from 23 to 9 ns and an 8 item batch from 87 to 55. Each further size gains less: 1.5 times at 10 items, 1.3 at 16, 1.2 at 20, and nothing at 24, where a scattered batch gets slower on 64-bit. A bigger size also adds source (about n squared lines) and first-call JIT time (1.4 ms at 8, 3.3 at 16, 8.6 at 24). Amphora.csproj sets UnrolledSortLimit to 12. An unrolled sort also loads every key before comparing any, so above 8 items an in-order batch is checked for first. Tried and dropped: sorting by rank (the n squared compares lose from 4 items up), a struct-wrapped scratch array to skip the type checks on stores (10 to 30 percent slower), folding the in-order check into the bit scan (3 times the cost per key on 32-bit), and retuning the pass costs for small batches (no effect). Two things the framework makes expensive on the 32-bit runtime, found while tuning these: Array.Clear costs 2.4 ns an int, against 0.33 for a loop, so the sorts clear their counters with a loop. A loop that ends at the array's own length needs no bounds checks, so the passes use one when the array is exactly the size being sorted. Loops inside a larger method also compile slower, so each hot loop is a method of its own. The --random rows draw from each of Amphora's generators and from System.Random, the control, 4096 draws to a timed call, net of a loop that draws nothing (0.4 to 1 ns a draw). Each generator is held in a field of a class specialized for it, which is how Rand will hold the one it forwards to. The control has no 64-bit draw, so it joins two 31-bit ones, and no long or float range, so it scales a double. Rand's own row arrives with Rand. What they showed (2026-10-16, 8 logical processors, machine not busy). Nanoseconds a draw: 32-bit System.Random xoshiro256++ xoshiro128++ SFC64 SplitMix64 32 bits 7.26 6.91 3.38 4.55 6.42 64 bits 14.26 6.87 7.82 4.65 6.37 int below 1000 9.82 10.13 5.85 8.44 10.81 long below 10^12 11.35 20.96 21.74 19.82 21.75 double in [0, 1) 8.01 11.85 12.06 11.10 13.77 float in [0, 1) 8.98 7.31 4.12 5.22 7.23 double in [-3.5, 7.25) 8.33 58.71 59.14 59.03 59.48 float in [-3.5, 7.25) 8.27 23.12 20.13 22.55 23.35 64-bit System.Random xoshiro256++ xoshiro128++ SFC64 SplitMix64 32 bits 6.15 3.06 3.16 1.12 1.74 64 bits 13.08 3.18 6.58 1.12 1.60 int below 1000 8.61 4.66 4.40 4.10 4.39 long below 10^12 8.17 6.31 9.17 5.89 6.87 double in [0, 1) 6.52 2.48 6.36 1.39 2.04 float in [0, 1) 7.16 2.72 2.75 1.60 2.27 double in [-3.5, 7.25) 7.15 15.67 19.29 15.77 16.53 float in [-3.5, 7.25) 7.35 11.44 11.61 11.55 11.96 On the 32-bit JIT the raw draws split by the width of their arithmetic. For 32 bits xoshiro128++ takes 3.4 ns, SFC64 4.6, SplitMix64 6.4 and xoshiro256++ 6.9, which is level with the control's 7.3. For 64 bits SFC64 takes 4.7, xoshiro256++ 6.9 and xoshiro128++ 7.8, since that makes two draws. On the 64-bit JIT SFC64 takes 1.1 ns at either width, SplitMix64 1.6 to 1.7 and xoshiro256++ 3.1 to 3.2. What the generator doesn't decide costs more than what it does, on the 32-bit JIT. A double in [0, 1) takes 11 to 14 ns whichever generator makes it, 6 ns or more over its raw 64 bits, and is slower than the control's 8.0. A float in [0, 1) takes 4 to 7. Isolating which step of the double costs that much is not done. A long below 10^12 takes 20 to 22 ns, against the control's 11, and 5.9 to 9.2 on the 64-bit JIT, against 8.2. A double in a range, by the gamma-section, takes 59 ns, seven times the control's scaled double, and 15.7 to 19.3 on the 64-bit JIT, against 7.2. A float in a range takes 20 to 23 ns, against 8.3, and 11.4 to 12.0 on the 64-bit JIT, against 7.4. An int below 1000 beats the control clearly on the 32-bit JIT only with xoshiro128++ (5.9 ns against 9.8). On the 64-bit JIT all four take 4.1 to 4.7 against 8.6.