Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/graph-ar

docs/perf.md

graphar performance notes

How the hot paths work, what they cost, and what was measured. Every number here comes from bench/perf/src/main.rs driven by bench/perf/run.sh (one process per stage, so peak RSS and bytes read are per stage), on one host (AMD Threadripper PRO 3975WX, 32 cores, 499 GB RAM, Linux 7.0), 2026-11-04, --release, Rust 1.97.1 in a container. The host carried a load of 25-45 from other jobs during the runs, so parallel figures are conservative; single-thread figures repeated within a few percent. The page cache was warm (read_bytes 0): the numbers are decode and memory cost, not disk.

Datasets: a synthetic power-law graph written by the crate's own writers (gen stage; dst = floor(n · u³), dense ids 0..n, one string property per edge), at 10M edges / 1M vertices and 100M edges / 10M vertices, GraphAr default chunk sizes (vertex 2¹⁸ = 262 144, edge 2²² = 4 194 304), Parquet. "Before" is main 12485bf (ZSTD default, columnar write_edge_table), "after" is this branch (aad4ced).

Reproduce: bench/perf/run.sh out.md --vertices 1000000 --edges 10000000 --dir /some/dir (STAGES="..." picks stages, GRAPHAR_BENCH_THREADS=n caps decode threads).

Results

10M edges, 1M vertices

stage before after what changed
write-vertices (row API) 869 ms, 441 MiB 472 ms, 399 MiB Vec properties
write-vertices-table (columnar) 272 ms, 50 MiB 161 ms, 49 MiB delta encoding (less to compress)
write-edges (row API) 10 357 ms, 7 545 MiB 4 615 ms, 3 977 MiB Vec properties, no pseudo-vertex clones, counting sort
write-edges-table (columnar) 2 655 ms, 753 MiB 2 429 ms, 680 MiB counting sort, delta encoding
chunk files on disk 49 MB 28 MB delta-packed integer columns
read-vertices (read_merged) 99 ms 58 ms serial, 36 ms (8 threads) delta decode, parallel chunks
read-prop (read_by_property) 45 ms 12 ms delta decode
read-adj (full scan, streaming) 202 ms 65 ms delta decode
read-adj-keep (keeps all batches) 245 ms 89 ms serial, 62 ms parallel (read_all_adj_lists) delta decode, parallel chunks
read-offsets 14.6 ms 5.8 ms delta decode
neighbours of v, plain (whole chunk) 59 ms/query, 12.7 MB read 24 ms/query, 8.7 MB smaller files, same API
neighbours of v, neighbors() - 26 µs/query, 52 KB read pushdown + caches
validate 0.3 ms 0.3 ms -
vertex id → chunk/slot 9.9 ns 8.6 ns division, untouched
edge row → owning vertex (partition_point) 68 ns 66 ns untouched (not on a hot path)

The row-API floor (build 10M Edges with one string property and drop them, no writing at all) went from 3 326 ms (HashMap per edge) to 2 597 ms (Vec per edge); the writer's own work above the floor is now ~2.0 s, the same as the columnar write_edge_table on the same data.

100M edges, 10M vertices

stage before after
write-vertices (row API) 8 390 ms, 4.1 GB 5 204 ms, 3.8 GB
write-edges (row API) 95 242 ms, 75.4 GB 53 804 ms, 39.7 GB
write-edges-table (columnar) 32 025 ms, 7.3 GB 31 745 ms, 6.6 GB
chunk files on disk 833 MB 305 MB
read-vertices 866 ms 601 ms serial, 448 ms (8 threads)
read-prop 422 ms 140 ms
read-adj (streaming) 1 913 ms 850 ms
read-adj-keep - 1 155 ms serial, 1 442 ms parallel (slower on this loaded host; memory-bound, see 5)
read-offsets 139 ms 29.5 ms
neighbours, plain 63 ms/query 32.5 ms/query
neighbours, neighbors() - 83 µs/query

Lookup path, step by step (10M graph)

variant per query bytes read per query
plain: whole offset chunk + whole adjacency chunk (before) 59 ms 12.7 MB
row window + projection (read_rows), dictionary-encoded files 1.5 ms 2.9 MB
same, delta-packed files 70 µs 73 KB
+ cached footers and offset arrays (neighbors()) 26 µs 52 KB

(The two middle rows were measured on the earlier, uncompressed main; the mechanism is the same with ZSTD.)

Where the time went (perf, row-API write-edges, earlier main)

75 %  per-edge HashMap<String, PropertyValue>: malloc, rehash, clone into
      pseudo-vertices, free (glibc _int_free_* consolidating 30M chunks)
 6 %  sort over the edge structs
 4 %  column builders
<2 %  parquet encoding

After replacing the HashMap with a Vec the profile was still 40 % free, and 15 % of it came from the final clear(): the sort had permuted the structs, so their strings were freed in random address order and glibc spent its time merging chunks. Today's writer never moves the edges (the columns are gathered through a permutation), so that cost is gone on both the row and the columnar path.

Algorithms

1. Writer: counting-sort permutation (writer/edge_writer.rs, sort_edges)

Edges must be grouped by their alignment key k(e) (src or dst), and chunk c holds the edges with k ∈ [c·S, (c+1)·S), S = vertex chunk size. Keys are bounded: 0 ≤ k < V. Ordered lists also sort each vertex's edges by the other endpoint.

Formula (stable counting sort into a permutation):

deg[v]     = |{ e : k(e) = v }|                 pass 1 over E
starts[v]  = Σ_{u<v} deg[u]                     exclusive prefix sum, starts[V] = |kept|
perm[starts[k(e)] + rank(e)] = row(e)           pass 2 over E, rank = insertion order
ordered:   sort perm[starts[v] .. starts[v+1]] by other(e)   per vertex
chunk c    = perm[ starts[c·S] .. starts[min((c+1)·S, V)] ]
offsets_c[i] = starts[c·S + i] − starts[c·S]    i = 0 ..= chunk rows

Complexity: O(E + V) for the bucket passes plus O(Σ_v d_v log d_v) for the per-vertex runs (≪ E log E; most runs are short), versus O(E log E) for sorting (key, other, row) tuples. The columns are gathered once with take(perm); the offset chunk is a slice of starts rebased to 0 (the histogram the old code recomputed per chunk).

Dataflow:

edge table (src, dst, properties), never moved
   │ pass 1: deg[k(e)]++                 │ pass 2: perm[next[k(e)]++] = row
   ▼                                     ▼
deg ──prefix sum──▶ starts ───────────▶ perm (row permutation)
                      │                   │ per-vertex runs sorted by other
                      │ slice per chunk   │ take(perm) → sorted columns → slice per chunk
                      ▼                   ▼
                offset chunk        adj_list chunk, property chunks

Worked example: V = 4, S = 2, rows in insertion order (2→9), (0→5), (2→7), (1→3), (7→1) with k = src, ordered by source.

deg    = [1, 1, 2, 0]          (src 7 is out of range, dropped)
starts = [0, 1, 2, 4, 4]
perm   = [1, 3, 0, 2]          vertex 0: row 1; vertex 1: row 3; vertex 2: rows 0, 2
per-vertex sort by dst: vertex 2's run [0, 2] has dst 9, 7 → [2, 0]
perm   = [1, 3, 2, 0]
chunk 0 = perm[starts[0]..starts[2]] = [1, 3]  → rows (0→5), (1→3), offsets [0, 1, 2]
chunk 1 = perm[starts[2]..starts[4]] = [2, 0]  → rows (2→7), (2→9), offsets [0, 2, 2]

The property test counting_sort_matches_plain_sort pins sort_edges to (key, other, row).sort_unstable() and starts to the histogram offsets. The written chunks are identical to the old writer's (the checksum stage fingerprints agree: offsets exact, adjacency multiset equal).

2. Encoding: delta-packed integer columns (io/parquet.rs, writer_properties)

Parquet's default is dictionary encoding with a 1 MiB dictionary-page limit. An id column chunk of a million distinct values fills that page and then falls back to plain, and every read of any page of the column chunk must first read (and, with ZSTD, decompress) the dictionary page. For a point lookup that is a ~1 MiB preamble per column per file.

DELTA_BINARY_PACKED stores blocks of (first value, min delta, bit-packed deltas). For a sorted column with small deltas (_graphArSrcIndex, _graphArOffset) the cost per row is ⌈log₂(max delta − min delta + 1)⌉ bits; src in an ordered adjacency list has deltas of 0 or 1 (~1 bit/row), offset (a monotone prefix sum) a few bits. Random dst stays at ~log₂(V) bits. ZSTD then has less to compress, and the decoder has less to inflate: files 49 MB → 28 MB at 10M edges, scans 2.5-3.7x faster. Still plain Parquet: every Parquet reader (Arrow C++, Spark, DuckDB) reads it.

Page size trade-off (measured on the earlier, uncompressed main; data_page_row_count_limit):

rows per page read-adj scan neighbours lookup bytes per lookup
20 000 (Parquet default, kept) 58 ms 27 µs 52 KB
4 096 75 ms 13 µs 10.5 KB
1 024 101 ms 20 µs 3 KB

Smaller pages halve the lookup but cost 30 % on scans and make the page index itself the cost at 1 024; GraphAr is scan-first, so the default stays.

3. Lookup: row-window pushdown (io::read_chunk_rows, EdgeReader::edge_range / neighbors)

Edges of vertex v in an ordered adjacency list, with vertex chunk size S and edge chunk size C:

vc    = ⌊v / S⌋                              vertex chunk (a division, no search)
slot  = v mod S
[b, e) = offsets_vc[slot] .. offsets_vc[slot + 1]   rows within the vertex chunk
row r in [b, e) lives in edge chunk ⌊r / C⌋ at row r mod C

read_chunk_rows(path, offset, limit, columns) hands offset/limit and a column projection to the Parquet reader, which uses the row-group row counts and the offset index (page locations, written by default) to skip to the page(s) holding [offset, offset + limit) and decodes only the projected column. Cost per lookup: open + footer + one page of the one column, O(pages touched) instead of O(rows in the chunk).

Dataflow:

v ──÷S──▶ vc ──▶ offsets cache (Arc<Vec<i64>>, decoded once per vertex chunk)
   mod S ─▶ slot ─▶ [b, e)
                     │  split at edge-chunk borders (÷C, mod C)
                     ▼
         footer cache ──▶ parquet reader(offset, limit, projection = {_graphArDstIndex})
                                 │  row groups skipped by row count,
                                 │  pages skipped by offset index
                                 ▼
                         one data page → Int64Array → Vec<i64>

Worked example: S = 262 144, C = 4 194 304, v = 300 000: vc = 1, slot = 37 856; offsets of chunk 1 say [b, e) = [401 230, 401 241); all rows are in edge chunk 0 (401 230 / C = 0), rows 401 230 .. 401 241 of adj_list/part1/chunk0; that file's first row group holds rows 0 .. 1 048 576, page 20 of the _graphArDstIndex column holds rows 400 000 .. 420 000, so the reader reads that page (≈50 KB) and returns 11 values. The property test neighbors_pushdown_matches_plain_read pins this to the full read on random graphs with tiny chunks (ranges crossing edge-chunk borders).

4. Caches (io::parquet::MetadataCache, EdgeReader::offsets_of)

A parsed footer (schema, row groups, column and offset indexes) is a few KB per chunk file and immutable, so EdgeReader keeps the ones its lookups touch in a Mutex<HashMap<PathBuf, Arc<ArrowReaderMetadata>>>. The decoded offset array of a vertex chunk is S + 1 i64s (2 MiB at the default S), kept per (adjacency dir, vertex chunk) on first use; after that edge_range is two array reads. Worst case for a graph of V vertices: 8·V bytes of offsets plus footers. 70 µs → 26 µs per lookup.

5. Parallel chunk decode (reader::par_map)

Chunk files are independent, so multi-chunk reads fan out over scoped std::threads: thread t of T decodes chunk indices t, t+T, t+2T, …, results land in index order. No pool, no rayon, nothing shared but the inputs. T = min(available cores, 8, chunks); the readers' with_threads(n) overrides it (1 = serial).

Measured at 100M edges on the earlier main (load 30-45 on the host):

threads read-vertices (read_merged) read-adj (keep all batches)
serial 350 ms 765 ms
2 240 ms 635 ms
4 209 ms 554 ms
8 198 ms 728 ms
16 207 ms 637 ms

Gains flatten at 4-8 threads: decode is memory-bound (each chunk lands in fresh buffers; kernel_init_pages and page faults dominate the profile), and the host was shared. The streaming iter_vertex_chunks (one chunk in memory at a time) stays the cheapest full scan and keeps ~50 MiB RSS; use read_all_adj_lists when every chunk is wanted in memory anyway. The property test parallel_reads_match_serial pins parallel to serial.

6. Tried and not kept

The static search tree (Ragnar Groot Koerkamp's S+ tree)

Straight answer: it does not speed up GraphAr's own paths, because they contain no sorted-array search. Vertex id → chunk is a division (8.6 ns), edges of a vertex is an array index into the offset array, and the chunk → file mapping is arithmetic. The only sorted searches one could write are (a) edge row → owning vertex, a partition_point over the offsets (66 ns at 262k entries) that no reader path needs, and (b) exact lookup of an external key in a sorted key column (LDBC ids → row), which is an index a graph engine builds on top of GraphAr, not part of the format.

For (b) the tree is worth it. STree64 (the znippy-zoomies crate, his layout: 64-byte nodes of 8 i64 keys, branchless SIMD compare, O(log₉ n) node visits) against partition_point and a branchless Eytzinger layout, single exact lookups of present keys, 10M random queries:

keys partition_point Eytzinger STree64 STree64 build
262 144 (one vertex chunk) 64 ns 43 ns 34 ns 0.9 ms
1 048 576 130 ns 95 ns 74 ns 2.0 ms
16 777 216 687 ns 468 ns 183 ns 38 ms

1.8x at cache-resident sizes, 3.8x once the array is RAM-bound (where binary search pays a cache miss per level and the tree pays one per node); the batched, software-pipelined lookups skade uses on it go further, and that is where a vertex-key index would plug in. The stree bench stage reproduces the table.