20_concurrent_reads

command
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 19 Imported by: 0

README

Example 20 — Concurrent reads over an immutable CSR

What it demonstrates

The lock-free read contract of a frozen csr.CSR: once a graph is snapshotted into an immutable CSR, any number of goroutines may traverse it simultaneously with zero synchronisation on the snapshot itself. A pool of worker goroutines runs the same mixed read workload — Dijkstra, BFS, and PageRank — concurrently over one shared CSR, and the example measures how read throughput scales with the worker count while proving that every concurrent read returns the same answer as a single-threaded read.

It also certifies GoGraph's intra-query parallel algorithm variants: search.WCCParallel, search.CountTrianglesParallel, and centrality.BetweennessParallel are each run against their serial counterparts and asserted to produce the same result — exactly for the canonical partition and integer counts, and within a tiny floating-point tolerance for betweenness (whose parallel reduction sums pair-dependencies in a different order). The measured serial-vs-parallel speedup is reported as telemetry. Cross-query safety (many readers, one snapshot) and intra-query parallel correctness are the two halves of the concurrency story.

By default the read sweep is capped at GOMAXPROCS; pass -cap-to-cpus=false to drive the reliability mandate's high-concurrency levels (64/256/1024 readers over one immutable snapshot), which the lock-free contract must survive regardless of the core count.

Domain / scenario

A Barabási-Albert preferential-attachment network — the canonical model of a social / web graph, where a few high-degree hubs dominate and the degree distribution is heavy-tailed. The seeded generator builds it single-threaded so the RNG draws in a fixed order:

  • It starts from a connected path core of seed-core nodes.
  • Each subsequent node attaches attach edges to existing nodes chosen with probability proportional to their current degree (the standard repeated-node-list method), rejecting self-loops and parallel targets.
  • Every edge carries an integer weight in [1, weight-max].

Because every new node attaches to the already-connected component, the graph is connected by construction — so BFS reaches every node and its reach count is a constant. The hub structure gives PageRank a well-separated top-k, and the high-degree fan-out makes each read do real CPU work. Integer weights keep Dijkstra free of NaN/Inf concerns and make distance sums exact.

The graph is built once into a mutable adjlist, then frozen into a single csr.CSR snapshot that every worker reads. The CSR is never mutated after it is built, so the readers need no lock on it. The only shared mutable state is per-level bookkeeping (an atomic read counter and an atomic mismatch flag) — never the snapshot.

How to run

go run ./examples/20_concurrent_reads                          # small deterministic default
go run ./examples/20_concurrent_reads -nodes 200000 -attach 8 -workers 16  # observable-scale run

Scale and flags

Flag Meaning Default Large example
-nodes number of nodes in the scale-free network 4000 200000
-attach BA attachment degree m (edges each new node adds) 4 8
-seed-core size of the connected seed core (must exceed -attach) 8 16
-weight-max edge weights are drawn from [1, weight-max] 10 100
-workers maximum worker count the scaling sweep climbs to 8 1024
-cap-to-cpus cap the sweep at GOMAXPROCS; set false to climb to -workers true false
-iterations Dijkstra SSSPs each worker runs per round 16 64
-top-k PageRank top-k set size pinned as an invariant 10 10
-seed RNG seed (fixes the deterministic data shape) 1 any

The sweep climbs worker counts 1, 2, 4, 8, … up to -workers, capped at GOMAXPROCS unless -cap-to-cpus=false. The default completes in well under a second; the large run does enough work per read for the scaling curve to be clearly observable.

Expected output

The deterministic fact lines below are stable for a fixed -seed. The # -prefixed telemetry lines (heap, throughput, durations) vary per run and per machine and are never pinned by the test.

config.nodes=4000
config.attach=4
config.seed_core=8
config.weight_max=10
config.iterations=16
config.top_k=10
config.seed=1
nodes.count=4000
edges.directed=31950
# mem.heap_alloc=755.83 KiB
# mem.heap_growth=552.22 KiB
# mem.num_gc=2
ref.dijkstra_dist=17
ref.bfs_reached=4000
ref.pagerank_topk=[230,7,73,65,15,213,32,205,139,165]
# scale.workers_1.reads=16
# scale.workers_1.elapsed=27.229ms
# scale.workers_1.throughput=588 reads/s
# scale.workers_2.throughput=1124 reads/s
# scale.workers_4.throughput=1638 reads/s
# scale.workers_8.throughput=2312 reads/s
parallel.wcc_matches_serial=true
parallel.triangles_matches_serial=true
parallel.betweenness_matches_serial=true
# parallel.betweenness.speedup=6.37x
reads.agree=true

ref.bfs_reached equals nodes.count (the graph is connected), ref.dijkstra_dist and ref.pagerank_topk are reproducible for the seed, and reads.agree=true is the headline correctness fact: every concurrent read at every worker count returned the same answer as the single-threaded reference.

Evidence it collects

This is a concurrency example, so it reports (per the taxonomy in docs/examples-standard.md):

  • Aggregate throughput — reads/s of the mixed workload at each level.
  • Per-worker-count scaling — the identical workload run at 1, 2, 4, 8 … workers. Throughput that climbs with the worker count shows the read path admits genuine parallelism — no global lock serialises the readers — but the curve is a performance observation, not the proof of lock-freedom (the correctness evidence below is). Its ceiling is set by memory bandwidth and the machine's core mix: the mixed workload, and especially its PageRank component, is largely memory-bound, so on a bandwidth-limited or hybrid performance/efficiency-core CPU the curve typically flattens well before the worker count reaches GOMAXPROCS rather than scaling linearly. Scale -nodes/-attach up to do more work per read and the climb extends further before it saturates.
  • Live heap — one immutable snapshot is shared, not copied per worker, so the heap does not grow with the worker count.

The correctness evidence — reads.agree=true plus the constant ref.* facts — is the proof that the lock-free read path is sound: many concurrent readers compute exactly what one reader computes.

Key APIs

  • graph/adjlist.New / AdjList.AddEdge — build the mutable weighted undirected graph.
  • graph/adjlist.AdjList.Mapper — resolve node values to stable NodeIDs for the fixed source/target.
  • graph/csr.BuildFromAdjList — freeze the builder into an immutable CSR snapshot, the shared read surface for all goroutines.
  • search.Dijkstra — single-source shortest paths; safe to call concurrently on a snapshot CSR.
  • search.BFS — breadth-first traversal with a visit callback; allocation-free on the hot path after the first call.
  • search/centrality.PageRank / DefaultPageRankOptions — power-iteration PageRank, safe to invoke from any number of goroutines on a snapshot CSR.
  • search.WCCParallel / search.CountTrianglesParallel / search/centrality.BetweennessParallelCtx — intra-query parallel variants, each cross-checked here against its serial counterpart over the same snapshot.

Further reading

Documentation

Overview

Example 20_concurrent_reads — the lock-free read contract of a frozen CSR snapshot, exercised by many concurrent readers.

A single immutable csr.CSR is built once from a seeded, realistic scale-free network and then read concurrently by a pool of worker goroutines. Each worker runs the same mixed read workload — a batch of Dijkstra single-source shortest paths, a BFS reach count, and a PageRank to convergence — over the one shared snapshot. None of them takes a lock on the snapshot: an immutable CSR is safe for any number of concurrent readers with zero synchronisation on the hot path (Mehlhorn-Sanders, GraphBLAS). That is the contract this example demonstrates and measures.

Model

The graph is a Barabási-Albert preferential-attachment network — the canonical model of a social / web graph, where a few high-degree hubs dominate and the degree distribution is heavy-tailed. It is the right shape here for three reasons:

  • It is connected by construction (a connected seed core, and every new node attaches at least one edge to the already-connected component), so BFS reaches every node and its reach count is a constant, regardless of the seed.
  • The hub structure gives PageRank a meaningful, well-separated top-k, so "the top-k set is constant" is a robust invariant.
  • The high-degree hubs create heavy adjacency fan-out, so each read does real CPU work — the point of a concurrency benchmark.

Edges are undirected (an adjlist.AdjList with Directed:false mirrors every insertion) and carry an integer weight in [1, weightMax] drawn from the seeded RNG. Integer weights keep Dijkstra free of NaN/Inf concerns and make distance sums exact.

Evidence — the lock-free read contract

The example reports the evidence that matters for a concurrency subject (see docs/examples-standard.md):

  • Aggregate read throughput (reads/s) of the mixed workload.
  • Per-worker-count scaling: the identical workload is run at 1, 2, 4, 8 … workers (capped at GOMAXPROCS by default; pass -cap-to-cpus=false to climb to -workers, e.g. 64/256/1024), and the throughput at each level is printed as telemetry. Throughput that climbs with the worker count is the observable evidence that readers do not contend on the snapshot.
  • Intra-query parallel correctness: the parallel variants search.WCCParallel, search.CountTrianglesParallel and centrality.BetweennessParallel are cross-checked against their serial counterparts (exact for the partition and the triangle count, within a float tolerance for betweenness), with the speedup reported as telemetry.
  • Live heap, so a reader can see the immutable snapshot is shared, not copied per worker.

All telemetry lines are prefixed with "# " and vary per run and machine. The correctness evidence is printed as bare deterministic fact lines: every concurrent read returns the SAME answer a single reader computes. Specifically — for a fixed seed — every concurrent Dijkstra from the fixed source yields the same distance to the fixed target, the BFS reach count is constant, and the PageRank top-k node set is constant. The headline fact, reads.agree=true, asserts that concurrent reads agreed with the single-threaded reference across every worker count.

Scale

Run with no flags, the example builds a small, deterministic default (a few thousand nodes) that a test pins and that completes well under a second. Every dimension is a flag, so the same binary scales up to a size where concurrent reads do enough work for the scaling curve to be observable:

go run ./examples/20_concurrent_reads -nodes 200000 -attach 8 -workers 16

The data shape is reproducible for a fixed -seed; only the telemetry (lines prefixed with "# ") varies between runs and machines.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL