Two parameters

A list and a block of memory

The same traversal, over the same graph, examining the same edges in the same order, laid out two ways. Twelve thousand two hundred and eighty-eight edge slots either way; 11,812 modelled cache misses against 3,258. This is the site's largest gap between two counts of one run, and it exists because one of the layouts is a pointer chase and the other is a sweep.

Every comparison this site draws between two algorithms has a confound in it. Merge sort and heapsort have different comparison counts and different access patterns, so when the two counts rank them differently there are two variables moving and the attribution is an argument rather than a measurement.

A graph offers something cleaner. Hold the same graph two ways, run the same traversal over it, and the number of edges examined, the order in which they are examined, and the vertices reached are all identical — the gate requires it. The only thing that differs is where in memory each edge record sits.

At V=2,048V = 2{,}048 on a sparse random graph, both layouts examine 12,288 edge slots. Through a stated cache model, one takes 11,812 misses and the other takes 3,258.

Adjacency list against CSR: identical work, different addressesBreadth-first search over the same graphs, laid out two ways. An adjacency list places each edge record where the allocator put it, so a scan is a pointer chase; a compressed-sparse-row array places every edge of a vertex contiguously, so a scan is a sweep. Both examine 12,288 edge slots at V = 2048. The modelled misses differ by a factor of 3.6, and the sequentiality of the two traces is 0.001 against 0.834.10³10³10⁴Vmodelled missesadjacency listCSR array96% miss27% miss64 lines × 8 elements, fully associative, LRU3.6× between two layouts of one graph
Fig. 1 Breadth-first search over the same graphs, laid out two ways, with modelled misses on the vertical axis. The counted work is identical at every point on this plot and the two lines are a factor of 3.6 apart at the right-hand end. The sequentiality of the traces — the fraction of consecutive accesses that step to the adjacent slot — is 0.001 for one and 0.834 for the other.

The two layouts

An adjacency list is what a graph looks like when it is built the obvious way. Each vertex owns a list; adding an edge appends a record to two of them; each record sits wherever the allocator put it at the moment it was created. Walking a vertex’s neighbours means following a chain of records that were allocated at scattered times and therefore live at scattered addresses.

CSR — compressed sparse row — is what the same graph looks like when it is built once and then frozen. Every edge record is copied into a single flat array, sorted by source vertex, and a second array of V+1V + 1 offsets says where each vertex’s block begins. Walking a vertex’s neighbours means reading a contiguous run.

The model here assigns each edge record an address. In CSR it is the record’s index in the flat array. In list form it is the order in which the edge was created, which for a graph built from random pairs is uncorrelated with anything the traversal does. Both are models of what an allocator would produce and both are stated as such; neither is a claim about a specific runtime’s heap.

Feed the two address streams to the cache model — 64 lines of 8 elements, fully associative, LRU, parameters printed on the figure — and the miss counts are these:

graph edge slots examined list misses CSR misses ratio
sparse, V = 512 3,072 2,569 (83.6%) 742 (24.2%) 3.46
sparse, V = 2,048 12,288 11,812 (96.1%) 3,258 (26.5%) 3.63
dense, V = 512 132,400 83,086 (62.8%) 16,849 (12.7%) 4.93

Why 26% and not 12.5%

The CSR miss rate at V=2,048V = 2{,}048 is 26.5%, and a line holds eight elements, so a perfect sweep would miss once in eight — 12.5%. The gap is worth explaining because it is the whole difference between a graph and an array.

A CSR scan is contiguous within a vertex and jumps between vertices. Breadth-first search visits vertices in queue order, which is not index order, so consecutive vertex blocks are consecutive in the array only by accident. Each new vertex’s block starts with a fresh miss, and with average degree six a block is six slots — under one cache line — so nearly every vertex costs its own miss and there is not much sweeping to amortise it over.

On a dense graph the blocks are 260 slots long, and the miss rate falls to 12.7% — within a rounding error of the ideal 12.5%, and the sequentiality reaches 0.997. The layout gets better as the degrees get larger, which is the shape one would expect and is worth confirming rather than assuming.

The adjacency list gets worse as the graph grows: 83.6% missing at V=512V = 512 and 96.1% at V=2,048V = 2{,}048. At the smaller size a fair number of records still happen to be resident; at the larger size the working set has gone over the cliff and essentially every record is cold.

Adjacency list against CSR: identical work, different addressesBreadth-first search over the same graphs, laid out two ways. An adjacency list places each edge record where the allocator put it, so a scan is a pointer chase; a compressed-sparse-row array places every edge of a vertex contiguously, so a scan is a sweep. Both examine 132,400 edge slots at V = 512. The modelled misses differ by a factor of 4.9, and the sequentiality of the two traces is 0.004 against 0.997.10010³10⁴10⁵Vmodelled missesadjacency listCSR array63% miss13% miss64 lines × 8 elements, fully associative, LRU4.9× between two layouts of one graph
Fig. 2 The same comparison on dense graphs, where the CSR layout is at its best — 12.7% missing against a theoretical floor of 12.5% for a line of eight, and a sequentiality of 0.997. The gap reaches a factor of 4.9. The list layout has not changed its behaviour at all; it never had any locality to lose.

Where the advantage disappears

A figure showing a consistent factor of four would be a figure with nothing to say about its own limits. The grid is where it stops.

graph edge slots list misses CSR misses ratio
grid, V = 512 2,024 270 (13.3%) 275 (13.6%) 0.98
grid, V = 2,048 8,098 1,862 (23.0%) 1,058 (13.1%) 1.76

At V=512V = 512 the adjacency list is very slightly better, and at V=2,048V = 2{,}048 the CSR advantage is 1.76 rather than 3.6.

Two things are happening and both are worth having.

The small grid fits. 512 vertices at degree four is 2,048 edge records, and the modelled cache holds 512 elements. That is a working set four times the capacity rather than twenty-four times, and at that ratio a lot of the list’s scattered records are still resident when they are needed again. The layout matters least when the data nearly fits, which is the general lesson of the working-set cliff restated.

A grid’s edges are created in a helpful order. The generator emits edges by scanning the lattice row by row, so an adjacency list built from it is already nearly sorted by source vertex — accidentally, and only because the generator happens to work that way. A grid loaded from an edge list in arbitrary order would not have this property. That is a warning about the measurement as much as a result: the list layout’s cost depends on the insertion order, which is not part of the graph.

So the honest summary is not “CSR is 3.6 times better”. It is: CSR’s locality is a property of the structure and the list’s is a property of the history, so CSR’s number is reproducible and the list’s is whatever the construction happened to leave behind. On a graph built by random insertion the list is 3.6 times worse; on one built by a scan it is level; on one built by an adversary it would be worse still.

What this costs to have

CSR is not free and the price is exactly the thing the graph field is otherwise about.

It cannot be modified. Adding an edge means inserting into the middle of the flat array and shifting everything after it, which is Θ(E)\Theta(E). An adjacency list appends in constant time. So CSR is for graphs that are built once and traversed many times, and every real graph library offers both for this reason.

It costs a build pass. Constructing CSR from an edge list means counting degrees, prefix-summing the offsets and scattering the edges — two passes and a full copy. On a graph traversed once, the copy costs more than the locality saves.

It fixes the order. Sorting edges by source is one choice; sorting them by a space-filling curve over the vertex coordinates is another and is better still on a grid. CSR makes the layout explicit, which means it also makes it something that has to be chosen.

The trade is the same shape as a growth factor: a knob with no dominant setting, where the right answer depends on the ratio of reads to writes in a workload the library cannot see. What is different here is the magnitude. A growth factor moves the amortised append cost by a factor of three; a layout moves the miss count by a factor of five.

4,096 accesses, five ordersEvery row performs exactly 4,096 array accesses — the same count an operation-counting analysis would assign them all — and the modelled miss counts differ by a factor of 8. Reading backwards is as cheap as reading forwards, because a cache line is a line whichever end you enter it from. Stepping by 8 elements touches a new line every time and is as expensive as random. Model: fully associative · 32 lines × 8 elements · LRU.cache missesstraight through512100% sequentialbackwards5120% sequentialevery 8th element4,0960% sequentialevery 97th element4,0960% sequentialuniformly random3,8460% sequentialfully associative · 32 lines × 8 elements · LRU8× between best and worst order
Fig. 3 The underlying effect, isolated on an array rather than a graph. Five access patterns over the same number of accesses: stepping to the next element is the cheapest thing in the picture and jumping is the most expensive. A CSR scan is the first row and an adjacency-list walk is close to the last, and the entire content of this essay is that a graph traversal can be either one without changing a single count.
Adjacency list against CSR: identical work, different addressesBreadth-first search over the same graphs, laid out two ways. An adjacency list places each edge record where the allocator put it, so a scan is a pointer chase; a compressed-sparse-row array places every edge of a vertex contiguously, so a scan is a sweep. Both examine 12,288 edge slots at V = 2048. The modelled misses differ by a factor of 3.7, and the sequentiality of the two traces is 0.001 against 0.834.10³10³10⁴Vmodelled missesadjacency listCSR array98% miss27% miss32 lines × 8 elements, fully associative, LRU3.7× between two layouts of one graph
Fig. 4 The same comparison through a cache half the size. Every miss count rises and the gap between the two layouts does not close — which is what it means for the effect to be about locality rather than about capacity. The model’s parameters are printed because a miss count without them is not a measurement.

A third layout, and the floor

CSR fixes where a vertex’s edges live relative to each other. It says nothing about where the vertices live relative to each other, and that is a second decision with its own effect.

Relabel the vertices in breadth-first order — vertex 0 becomes the source, its neighbours become 1, 2, 3, and so on — then build the CSR from the relabelled edge list. Nothing about the graph has changed; the same vertices are joined by the same edges and the traversal examines the same slots in the same order. Only the integers naming them are different.

graph layout misses miss rate sequentiality
sparse, V = 2,048 adjacency list 11,812 96.1% 0.001
CSR 3,258 26.5% 0.834
CSR, relabelled 1,537 12.5% 0.925
grid, V = 2,070 adjacency list 1,862 23.0% 0.005
CSR 1,058 13.1% 0.745
CSR, relabelled 1,013 12.5% 1.000

12.5% is not an arbitrary number. A cache line here holds eight elements, so a perfect sequential sweep misses exactly once every eight accesses, and 12.5% is the floor. Relabelling reaches it on both families — on the grid with a sequentiality of 1.000, meaning every single access after the first in each line steps to the adjacent slot.

So the ordinary CSR result is not the end of the story. Plain CSR gets from 96% to 26%; relabelling gets from 26% to the theoretical minimum, and the second step is another factor of two on the sparse family.

Three things follow.

The full gap is a factor of 7.7, not 3.6. An adjacency list built by random insertion against a CSR whose vertices are numbered in traversal order is 11,812 misses against 1,537, on identical counted work. That is the widest separation between two counts anywhere in this collection by some distance.

The improvement is only available for a known traversal. BFS order is optimal for BFS from that source, and it is a different ordering for a different source and a different traversal. Real libraries approximate it with an ordering that is good for many traversals at once — a space-filling curve over vertex coordinates for a mesh, or a recursive bisection for a general graph — and the approximation gets most of the way.

The floor exists and is reachable, which is unusual. Merge sort comes within 2% of the comparison floor and cannot close the rest. Here the floor is a property of the line size, the gap to it is entirely a question of layout, and one relabelling pass closes it exactly.

Why this is the widest gap on the site

The sorting field’s cleanest instance of two counts disagreeing is heapsort against merge sort: comparison counts within a factor of two, modelled miss counts much further apart, and the rankings differ. It is a good example and it has a confound, because the two algorithms are doing different work.

Here the confound is gone. Not reduced — gone, and the gate enforces it. The two layouts must report the same edge scans, the same visits and the same trace length, and must differ in the addresses; if they ever agreed on the addresses the assertion fails, on the grounds that a comparison between two identical traces is not a comparison.

That leaves a single-variable experiment: hold the algorithm fixed, hold the input fixed, hold the operation sequence fixed, vary only the addresses, and the modelled cost moves by a factor of five.

It is the strongest form of the field’s claim available anywhere in this collection. The count is not the cost, and here it is not the cost while being exactly, provably, the same count.

Two more settings of the same model say what the factor is a function of, and neither of them is the graph.

Adjacency list against CSR: identical work, different addressesBreadth-first search over the same graphs, laid out two ways. An adjacency list places each edge record where the allocator put it, so a scan is a pointer chase; a compressed-sparse-row array places every edge of a vertex contiguously, so a scan is a sweep. Both examine 132,400 edge slots at V = 512. The modelled misses differ by a factor of 4.9, and the sequentiality of the two traces is 0.004 against 0.997.10010³10⁴10⁵Vmodelled missesadjacency listCSR array63% miss13% miss32 lines × 8 elements, fully associative, LRU4.9× between two layouts of one graph
Fig. 5 The dense comparison through a cache half the size. Both layouts examine 132,400 edge slots at V = 512 and the modelled misses now differ by 4.9× rather than 3.4×: a smaller cache does not change the work and does change how much of the sequential layout’s advantage survives. The sequentiality of the two traces is 0.004 against 0.997 either way, because that is a property of the layout alone.
Adjacency list against CSR: identical work, different addressesBreadth-first search over the same graphs, laid out two ways. An adjacency list places each edge record where the allocator put it, so a scan is a pointer chase; a compressed-sparse-row array places every edge of a vertex contiguously, so a scan is a sweep. Both examine 12,288 edge slots at V = 2048. The modelled misses differ by a factor of 4.5, and the sequentiality of the two traces is 0.001 against 0.834.10³10010³10⁴Vmodelled missesadjacency listCSR array92% miss20% miss64 lines × 16 elements, fully associative, LRU4.5× between two layouts of one graph
Fig. 6 And the sparse comparison with the line twice as long. The same 12,288 edge slots at V = 2048, and the gap widens from 3.4× to 4.5× — a longer line is more prefetching to waste on a pointer chase and more to collect on a sweep. The two parameters that move the number are the cache’s, and the two that do not are the graph’s.

What CSR is actually for

The layout is not a graph-library curiosity. Compressed sparse row is the standard representation for sparse matrices, and a sparse matrix and an adjacency structure are the same object — the non-zeros of row ii are the neighbours of vertex ii, and a matrix-vector product is a traversal that touches every edge once.

That is worth knowing because it means the measurement here is about a much larger body of code than graph traversal. Every sparse linear solver, every iterative eigenvalue method and every graph-neural-network kernel spends most of its time in the operation this figure is measuring, and every one of them uses CSR for the reason this figure gives.

It also explains why the relabelling result matters more than it might appear. Reordering the rows and columns of a sparse matrix to improve locality is a whole small field — reverse Cuthill–McKee, nested dissection, space-filling curves — and what those methods are buying is the factor of two between plain CSR and the relabelled CSR above.

Where the work goes, sparse, fixed average degree, V = 2048Each bar is one algorithm's counted work at V = 2048, split into the four primitives. The published bounds describe whichever segment the author had in mind, and which segment dominates is a property of the graph rather than of the algorithm: Dijkstra's queue comparisons are 64.1% of its work here.adjacency scansrelaxationsqueue comparisonsvisitsBreadth-first14,336Dijkstra, all V queued2,118,656Dijkstra, binary heap56,973Bellman–Ford, all passes50,309,120Prim64,884Kruskal74,008V = 2048, E = 6,144, sparse, fixed average degreeevery segment counted exactly
Fig. 7 What the traversals underneath are actually doing. Every bar’s adjacency-scan segment is identical between the two layouts, because the layout changes no count — and it is the segment this essay’s whole factor of 3.6 comes out of.

The list costs the memory as well

Everything above holds the counted work fixed and varies only the addresses, which is what makes it a single-variable experiment. It also hides a second difference, and the second difference makes the first one worse.

An adjacency-list record has to say where the next record is. Whether that is a pointer, an index or an object header depends on the language, but something has to be stored beyond the neighbour’s identity, so an edge slot costs at least two words. A CSR edge slot costs one — the neighbour — plus the shared offsets array of V+1V+1 entries.

At V=2,048V = 2{,}048 on the sparse family that is 12,288 slots either way, so:

words
adjacency list, two words a slot 24,576
CSR, one word a slot plus offsets 14,337

The list is about 1.7 times the memory for the same graph, and the ratio worsens as the average degree falls, because the offsets array amortises over fewer edges while the per-record overhead does not amortise at all.

That compounds with the locality result rather than sitting beside it. A structure occupying more bytes occupies more cache lines, so it fits in a given cache at a smaller graph size and reaches the cliff sooner. The model in this essay charges by the element rather than by the word, so it does not see any of this — which is a fourth item for the list below and, like the other three, one that favours the layout already losing.

The order that helps is the answer to the question

The relabelling result is the largest single improvement on the page and it has a circularity in it worth stating plainly, because it decides when the technique is available.

Numbering the vertices in breadth-first order requires a breadth-first traversal. So the ordering that makes a traversal cheap is produced by the traversal, and the first one cannot benefit from it. What the relabelling buys is every traversal after the first, from that source, and nothing at all if the graph is traversed once.

That places it exactly where CSR itself sits. Both are investments paid at build time and recovered over repeated traversals; both are worthless on a graph read once; and both are worth measuring as a break-even in traversals rather than as a speedup. The relabelling costs one traversal plus a scatter, and it saves 1,721 misses per traversal on the sparse family — so it pays for itself on the second or third pass and is pure profit after that.

It also explains why real systems do not use breadth-first order. An ordering tuned to one source is an ordering that helps that source’s traversal and no other, and a graph is usually queried from many sources. What the reordering literature supplies instead — reverse Cuthill–McKee, recursive bisection, a space-filling curve over vertex coordinates — are orderings that are good for every traversal at once, by making neighbours have near-by numbers rather than by following any particular walk. They reach a worse number than the 12.5% floor above and they reach it for all sources, which on a real workload is the better trade.

The floor in the table is therefore an upper bound on what any general reordering can deliver, reached by an ordering that knows the question in advance. That is a useful thing for a benchmark to have: it says how much of the remaining gap is available to be closed at all.

Three things the model still does not see

The usual caveats, and one of them is specific enough to change the number.

Prefetching. A hardware prefetcher detects a forward stride and fetches ahead, so a CSR sweep’s remaining 12.7% of misses would largely be hidden on a real processor. The adjacency list’s would not, because there is no stride to detect. The model has no prefetcher, so it understates the gap, and understating it is the safe direction.

Dependent loads. An adjacency-list walk reads the next pointer out of the current record, so the address of the next miss is not known until the current one returns. Those misses cannot overlap. A CSR scan’s addresses are all known in advance and its misses can be in flight together. This is memory-level parallelism and the model is blind to it, and again it is blind in the direction that makes the list look better than it is.

The vertex array. Both layouts also touch a per-vertex structure — the visited flags, the distance array, the queue — and none of that is in the trace here, which counts only edge-record accesses. On a sparse graph those per-vertex touches are a comparable number of accesses with their own locality, and including them would move both numbers up and the ratio down. The measurement is of the edge scanning specifically, and the caption says so.

All three point the same way as the earlier honest limits in the machine field: the simplifications are not neutral between the two things being compared, they favour the loser, and so the measured gap is a lower bound on the real one.

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 9 that link here.

The objects this essay names

Each one links to every other essay that touches it.

AdjacencyAdjacency listCacheCSR (compressed sparse row)LocalityMemory layoutMiss ratePointer chasingTraceTraversal