The other axis

Measuring what an algorithm keeps

Four counters measure what an algorithm does and none of them measures what it holds. An in-place sort and an out-of-place one with identical comparison counts are different algorithms, and until this phase the site had no way to say so. Two primitives close the gap, and the second of them counts something no array counter can ever see.

The instrumented array this site is built on counts four things: reads, writes, comparisons and swaps. Every claim on the site descends from those four, and there is a whole class of true statements about algorithms that none of them can express.

Take merge sort and heapsort at n=4,096n = 4{,}096 on random input. Merge sort makes 43,976 comparisons, heapsort makes 85,733; merge sort makes 49,152 writes, heapsort makes 91,158. Four counters, four numbers each, and by all eight of them merge sort is the better algorithm and heapsort is the one with the worse constants.

Merge sort also needs a second array of 4,096 elements and heapsort needs nothing. That is not a small difference — it decides whether either of them can be run at all on a machine with the input and a little slack — and it appears in none of the eight numbers.

This is a gap in the instrument, not in the coverage. No amount of measuring more carefully with the existing counters produces it, because nothing that merge sort does to its buffer goes through them.

Peak auxiliary space, random input, n = 4,096The largest number of slots live at once — scratch buffers plus stack frames — for each sort. The axis is logarithmic because the range is: 5 of these hold one slot at their peak, 3 hold a stack of about log₂ n frames, and the rest hold a second copy of the array. All of them are routinely described with the same two words.peak slots held at once, logarithmic18645124096Insertion sort11Selection sort11Bubble sort11Heapsort11Shellsort11Quicksort, median of three22log nQuicksort, random pivot28log nQuicksort, first-element29log nMerge sort with a cutoff4,106nMerge sort4,110nn = 4,096, random inputone slot = one array element or one stack frame
Fig. 1 Peak auxiliary space for every sort on the site, at n = 4,096, on a logarithmic axis. The axis has to be logarithmic because the range is: five of these hold one slot at their peak, three hold about twenty, and two hold a copy of the array. All ten are commonly described using the same vocabulary.

Two primitives

The counted array gains two pairs of operations, and each pair exists because one specific thing was invisible.

alloc(k) and free(k). Scratch space taken and given back. Merge sort’s buffer is now a line of code that costs something rather than a new Array(n) that costs nothing. The counters record both the live total and the running sum, for reasons the next section is about.

enter() and leave(). A call entered and returned from. This is the one that matters more, and it is worth being clear about why: a call stack is auxiliary space. Quicksort allocates nothing, touches no scratch array, and holds a frame per level of recursion for the duration of the run. Every account of the algorithm calls it in-place; the recursion is real memory, it is proportional to the depth, and no counter that watches the array will ever see it because the stack is not in the array.

Both pairs charge in slots — one array element, or one stack frame. That is a cost model and it is the wrong one for any particular machine: a frame holding four locals and a return address is not the same size as one element of an array of 200-byte records. It is the right one for comparing algorithms, because it is the only unit that does not depend on the element type, and every figure that reports these numbers says what it is counting.

One further decision, small and load-bearing. The call into the algorithm is itself charged as a frame, so an in-place sort’s peak is 1 rather than 0. Reporting zero would be defensible and would make the constant class unfittable — a ratio of 0/10/1 is not a constant, it is a missing measurement — and with the outer frame charged, heapsort’s peak is 1 at every nn and the class fits exactly.

Peak and total are different questions

The counters keep two numbers where one would have been simpler, and the reason is that they answer different questions and neither answers the other’s.

Peak is the most held at one moment. It is the number a memory limit is about: a program that peaks at 2 GB needs 2 GB, whatever it did before or after.

Total is everything ever taken. It is the number an allocator’s workload is about, and a garbage collector’s, and anything charging per allocation.

They can differ by any factor. The site carries two merge sorts to make this concrete — one allocating a single buffer at the start, one allocating a buffer per merge and freeing it — and they have byte-identical comparison, read and write counts. At n=16,384n = 16{,}384:

peak slots total slots allocated
one buffer, allocated once 16,400 16,384
a buffer per merge 16,386 229,376

The peaks agree to within the depth of the recursion. The totals are a factor of 14 apart, and 14 is log216,384\log_2 16{,}384.

This is the amortised-versus-worst-case distinction one level down, applied to a resource rather than to an operation. A single number called “space complexity” answers whichever of the two questions its author had in mind, and the reader has no way to tell which.

What the measurement immediately shows

With the instrument built, the sorts fall into three bands, and the bands are not what the vocabulary suggests.

band algorithms peak at n = 4,096
Θ(1)\Theta(1) insertion, selection, bubble, heapsort, Shellsort 1
Θ(logn)\Theta(\log n) the three quicksorts 22 to 29
Θ(n)\Theta(n) merge sort, the hybrid 4,106 to 4,110

The classes are fitted, not asserted. Peak auxiliary space is put through the same ratio test the comparison counts get: across nn from 128 to 32,768, heapsort’s peak ÷ 1 has a spread of 1.00, quicksort’s peak ÷ logn\log n has a spread of 1.20, and merge sort’s peak ÷ nn has a spread of 1.07. An algorithm whose space claim failed would stop the build in exactly the way one whose time claim failed does — and one of them does fail, on one input.

The middle band is the interesting one, because it has no name. The vocabulary offers “in place” and “not in place”, and the three quicksorts are neither. They allocate nothing and hold a stack of about log2n\log_2 n frames, which is 22 slots at n=4,096n = 4{,}096 and 4,097 slots on an input that makes the recursion go badly. Calling that Θ(1) is the standard description and it is wrong; calling it Θ(n) would also be wrong; there is no third word.

The same instrument, on a graph

Sorting is the easy case for a space instrument, because a sort’s auxiliary space is a buffer and a stack and both are countable at the point they are taken. A traversal is harder, and it is where the instrument earns its place rather than merely working.

Breadth-first and depth-first search over the same graph perform identical counted work — the same adjacency scans, the same visits, exactly. Every counter this site had before this phase reports them as one algorithm. What separates them is how many vertices are held at once, and on a 32 × 32 grid that is 32 for breadth-first against 496 for depth-first: a factor of fifteen, in a quantity that had no instrument.

Two things about that measurement are worth carrying back to the sorting case.

It is not a peak of allocations, it is a peak of a data structure’s occupancy. Nothing here calls alloc; the queue and the stack are ordinary arrays that grow and shrink. Counting their occupancy needs the algorithm to report it, which the traversals do, and that is a different instrument from alloc/free even though it measures the same resource.

It is a property of the graph as much as of the algorithm. On a random sparse graph of 1,024 vertices the two frontiers are 569 and 553 — indistinguishable. The grid’s factor of fifteen comes from the grid’s shape: a small frontier and a large diameter, which is exactly the combination that separates the two traversals. The received advice that breadth-first search uses more memory than depth-first is true on a grid, false on a random graph, and stated about neither.

The general lesson is the one this whole field is an instance of: a resource that nothing measures is a resource about which the folklore is unchecked. Space was not measured here before this phase, and the two claims the measurement immediately contradicted — that quicksort sorts in place, and that depth-first search is the memory-frugal traversal — were both things everybody knew.

The naming problem, recorded because it nearly caused a bug

The plan for this phase called the recursion primitive frame(). That name was already taken: the counted array has had a frame() method since the foundation, which records a snapshot of the array for the trace figures, and every step-by-step picture on the site depends on it.

Two meanings of “frame” — a stack frame and a frame of animation — in one class, one of them about to be added silently. Adding it would not have failed to compile. It would have overwritten the snapshot method, and every trace figure would have started producing empty panels while every space measurement worked perfectly.

The pair is enter/leave for that reason, and the collision is worth recording rather than quietly avoiding, because it is the shape of mistake this whole site is organised against: a change that breaks something silently and elsewhere.

Why the counters are in the array

A design decision worth stating, because the alternative is more obvious and is wrong.

The space counters could have lived outside the algorithms — a wrapper that inspects memory before and after, or a profiler that samples. Instead they are methods on the counted array, and every algorithm that wants scratch has to ask for it by calling alloc.

That is intrusive. It means merge sort’s implementation contains a line whose only purpose is to be counted, and it means an algorithm can lie by allocating without saying so.

It is the right trade for the same reason the operation counters are inside the array rather than beside it. A measurement taken from outside is a measurement of the runtime — of V8’s heap, of this machine’s allocator, of whatever the garbage collector felt like doing — and would change between builds and between machines. A measurement taken at the point of use is a property of the algorithm and the input, exact and identical everywhere.

The lying is handled the way it is everywhere else here: not by preventing it, which is impossible, but by cross-checking. assertPeakAndTotalAreIndependent requires the two merge sorts to agree in every time-flavoured count and to differ by exactly log2n\log_2 n in total allocation. An implementation that forgot an alloc would fail that, because the ratio would be wrong.

What this cannot say

Four limits, and the third is the one that most affects how the numbers should be read.

A slot is not a byte. Everything here counts slots. Comparing merge sort’s nn buffer slots against quicksort’s logn\log n stack slots as though they were the same unit is a simplification, and on a real machine a stack frame for a two-argument recursive call is perhaps five to ten words while an element might be one word or fifty. The direction of the error is knowable: it understates the stack relative to the buffer, so quicksort’s disadvantage against heapsort is a little larger than these figures show.

Auxiliary means auxiliary. None of these numbers includes the input. That is the standard convention and it is the right one for comparing algorithms, and it means an in-place sort of nn elements is not using one slot of memory, it is using n+1n+1. When the question is “will this fit”, the input is most of the answer and the auxiliary space is the part that decides whether the answer is nearly.

The allocator is not modelled. Real allocations have headers, alignment and fragmentation, and a program that takes 229,376 slots in 16,383 separate allocations is doing something quite different from one that takes them in a hundred. The total column counts slots and not allocations, and the two rankings would not agree.

Nothing here is a duration. As everywhere on this site, the count is not the time, and space is if anything further from time than the operation counts are — a large buffer is free if it is written once sequentially and ruinous if it competes for cache with the array being sorted. The interaction between the two axes is real and neither instrument sees it.

There is a third number, and its ratio is a thousand times the second’s

The limit stated above — that the total column counts slots rather than allocations, and that the two rankings would not agree — can be filled in from the same pair of merge sorts, and the result is sharper than the caution suggests.

A merge sort’s recursion over nn elements has nn leaves and n1n-1 internal nodes, and the per-merge version takes a buffer at each internal node. So at n=16,384n = 16{,}384 it makes 16,383 allocations where the one-buffer version makes one. The slots are the same 229,376 either way, spread over those calls at a mean of fourteen slots each — which is log2n\log_2 n, and is the same logarithm arriving for the third time in this comparison.

Set the three ratios beside each other. Peak: 1.001. Total slots: 14. Allocation count: 16,383. Three quantities describing the same difference between the same two implementations, spanning four orders of magnitude, and all three are covered by the phrase space complexity Θ(n)\Theta(n).

Which of the three is the cost depends entirely on what is underneath, and the three runtimes want three different answers.

A bump allocator — a pointer moved forward, freed all at once — makes an allocation nearly free, so the count is irrelevant and the total slots are the cost, paid in the cache lines that have to be touched and zeroed.

A general-purpose allocator puts a header on every block, walks a free list and may take a lock. Sixteen thousand allocations of fourteen slots is the case it is worst at: two words of header against fourteen of payload is a fourteen per cent overhead at this size, and at n=128n = 128 the mean allocation is seven slots and the overhead is twenty-nine per cent. Here the count is the cost and the slots barely matter.

A garbage collector charges by what survives and by how much has been allocated since the last collection, so the total slots drive the collection frequency and the count drives nothing. Here the middle number is the one.

So the three numbers are not a refinement of each other. Each is the right measurement for a different implementation of the same primitive, and an algorithm that is a factor of fourteen worse on one and a factor of sixteen thousand worse on another is not “worse on space” in any single sense.

The pair of merge sorts is therefore a better instrument than it looked. It was built to show that peak and total are independent; it also shows that total slots and allocation count are independent, in the same direction and by a much larger factor. And it is the same shape of question choosing a growth factor asks of a dynamic array from the other side — there the algorithm decides how often it allocates and how much it wastes, and the answer depends on which of those the runtime charges for.

None of which changes the amortised argument. Both implementations move the same data the same number of times, and the extra 229,375 allocations buy nothing at all — which is what amortised means applied to a resource: the total is what an allocator sees, and the per-operation figure is what hides it.

Why the axis was worth adding

Three things become sayable that were not, and each has an essay.

“Sorts in place” becomes a claim that can fail. It fails for quicksort, in a specific and measurable way, and most spectacularly on the input everybody tests with.

The recursion becomes visible. A stack is not free, it has a size limit that is not a soft one, and an algorithm can be killed by its auxiliary space rather than slowed by it.

“Which sorting algorithm” gets an honest answer. Comparisons on one axis and peak space on the other gives a Pareto frontier, and the frontier has four or five algorithms on it and five below it. The ones below it are dominated — beaten on both counts at once — which is a much stronger statement than being slower.

Comparisons against peak auxiliary space, n = 8,192One point per sort, both axes logarithmic, cheapest and smallest towards the bottom left. The line joins the Pareto frontier — the 5 algorithms that nothing else beats on both counts at once. The 5 points off it are dominated, and being dominated is a stronger statement than being slower: there is no weighting of these two costs under which they are the right choice. This is the honest answer to "which sorting algorithm", and it is a shape rather than a name.10⁵10⁶10⁷11010010³10⁴comparisonspeak auxiliary slotsInsertion sortSelection sortBubble sortMerge sortHeapsortQuicksortQuicksortQuicksortShellsortMerge sort with a cutoffn = 8,192, random input5 on the frontier, 5 dominated
Fig. 2 Both axes at once, at n = 8,192. The dashed line joins the algorithms that nothing else beats on both counts; the points off it are dominated, and being dominated means there is no weighting of these two costs under which the algorithm is the right choice. This picture is what “which sorting algorithm” actually has for an answer.

The profile at four settings

The instrument is a peak over one run, so the useful thing is to run it on four of them.

Peak auxiliary space, random input, n = 2,048The largest number of slots live at once — scratch buffers plus stack frames — for each sort. The axis is logarithmic because the range is: 5 of these hold one slot at their peak, 3 hold a stack of about log₂ n frames, and the rest hold a second copy of the array. All of them are routinely described with the same two words.peak slots held at once, logarithmic1864512Insertion sort11Selection sort11Bubble sort11Heapsort11Shellsort11Quicksort, median of three22log nQuicksort, first-element26log nQuicksort, random pivot28log nMerge sort with a cutoff2,057nMerge sort2,061nn = 2,048, random inputone slot = one array element or one stack frame
Fig. 3 Two thousand elements on random input. Five of the sorts hold one slot at their peak, some hold a stack of about log2n\log_2 n frames, and the rest hold a second copy of the array.
Peak auxiliary space, nearly sorted input, n = 4,096The largest number of slots live at once — scratch buffers plus stack frames — for each sort. The axis is logarithmic because the range is: 5 of these hold one slot at their peak, 2 hold a stack of about log₂ n frames, and the rest hold a second copy of the array. All of them are routinely described with the same two words.peak slots held at once, logarithmic18645124096Insertion sort11Selection sort11Bubble sort11Heapsort11Shellsort11Quicksort, median of three23no claimQuicksort, random pivot27log nQuicksort, first-element3,703nMerge sort with a cutoff4,106nMerge sort4,110nn = 4,096, nearly sorted inputone slot = one array element or one stack frame
Fig. 4 Twice the array, nearly sorted. The algorithms whose recursion reads the data move and the others do not.

The two below hold the size fixed and change only the order, which is the parameter no description of an algorithm’s space ever mentions.

Peak auxiliary space, already sorted input, n = 4,096The largest number of slots live at once — scratch buffers plus stack frames — for each sort. The axis is logarithmic because the range is: 5 of these hold one slot at their peak, 2 hold a stack of about log₂ n frames, and the rest hold a second copy of the array. All of them are routinely described with the same two words.peak slots held at once, logarithmic18645124096Insertion sort11Selection sort11Bubble sort11Heapsort11Shellsort11Quicksort, median of three14log nQuicksort, random pivot32log nQuicksort, first-element4,097nMerge sort with a cutoff4,106nMerge sort4,110nn = 4,096, already sorted inputone slot = one array element or one stack frame
Fig. 5 Already sorted input.
Peak auxiliary space, reversed input, n = 4,096The largest number of slots live at once — scratch buffers plus stack frames — for each sort. The axis is logarithmic because the range is: 5 of these hold one slot at their peak, 2 hold a stack of about log₂ n frames, and the rest hold a second copy of the array. All of them are routinely described with the same two words.peak slots held at once, logarithmic18645124096Insertion sort11Selection sort11Bubble sort11Heapsort11Shellsort11Quicksort, random pivot28log nQuicksort, median of three29log nQuicksort, first-element4,097nMerge sort with a cutoff4,106nMerge sort4,110nn = 4,096, reversed inputone slot = one array element or one stack frame
Fig. 6 And reversed. Four plates, one set of algorithms, and the phrase “in place” describes all four identically.

The pattern this is an instance of

The site’s other fields arrived at the same place from different directions, and it is worth naming the pattern because it is now the third time.

The comparison count was the only measurement, and it turned out to be one of four — reads, writes, comparisons and swaps rank the same algorithms in different orders. Then the four operation counts turned out to be one side of a second quantity, because modelled cache misses disagree with all of them. Now all five turn out to be measurements of one resource, and there is a second resource that none of them touches.

Each step has the same shape: a number that seemed complete, a second number that is independent of it, and a ranking that changes. There is no reason to expect this to be the last one, and the honest reading of the site’s whole cost model is not “here are the numbers that matter” but “here are the numbers that have been found so far, and each of the last three was invisible from inside the previous one”.

Recursion depth on already sorted input, against a stack of 512 framesStack frames held at the deepest point, on logarithmic axes, with a stated stack drawn across. Quicksort with a first-element pivot recurses once per element on this input and crosses the line; the other rules stay logarithmic and never approach it. The algorithm that crosses is the one universally described as sorting in place. The stack is a stated model, because a real one runs out at a size that depends on the engine and is not reproducible between runs.10010³1010010³nstack framesa stack of 512 framespivot: first-elementpivot: median of threepivot: random pivotMerge sortalready sorted input, n from 64 to 4,096one frame charged as one slot
Fig. 7 The primitive that no array counter reaches, drawn. Recursion depth against n on sorted input, with a stated stack drawn across. One of these lines crosses it. The algorithm that crosses it is the one universally described as sorting in place.

What it cost to add

Two methods on a class, a few alloc calls in three algorithms, and a decision about what a slot is. The instrument is smaller than any of the arguments it makes possible, which is the usual proportion here: the counted array is a hundred lines and the counting field is four essays long.

What the additions bought is the ability to say a number where the vocabulary previously offered a phrase, and the first three numbers it produced all disagreed with something everybody knew.

What this makes readable

Essays that name this one as a prerequisite.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

What links here

The 8 essays that link to this one and share the most of its objects, of 26 that link here.

The objects this essay names

Each one links to every other essay that touches it.

AllocatorAuxiliary spaceCost modelIn placeInstrumentationPeak and totalQuicksortRecursion depthTraversal