4  Benchmarks

graphfast vs igraph

NoteThese numbers are real

Every table on this page is measured live when the book is rendered — the code below actually runs and the results are inserted automatically. They therefore reflect the exact machine, R build, and package versions used for this render (reported just below). Re-render on your own hardware to get figures for your setup; absolute timings depend heavily on CPU, memory bandwidth, and BLAS/threading.

This render was produced on:

4.1 What we compare, and why

Package Components Shortest paths Core implementation Typical ceiling
graphfast ✓ (BFS) C++ Union–Find on an edge list 100M+ edges
igraph C library, full graph object 10M–100M edges
network + sna ✓ (geodesics) R/S4, often dense structures ~10⁵–10⁶ edges

igraph is the right tool for breadth; the comparison here is narrow on purpose — the operations graphfast actually implements. network/sna build dense geodesic structures and are not designed for the edge counts targeted here, so they are omitted from the timed runs below (they fall over well before 10⁷ edges); they remain in the table above only to place graphfast in context.

The key structural difference: graphfast consumes a two-column edge list directly and returns plain vectors, while igraph first builds a graph object (vertex tables, attribute storage). At small sizes that overhead is irrelevant; at 10M–100M edges it becomes a large share of total time and memory.

4.2 Methodology

  • Graphs: random edge lists generated with a fixed seed, self-loops removed. Node count scales with edge count, producing many medium components — a realistic, slightly sparse regime.
  • Timed operations: (1) connected components on the whole graph; (2) hop-distance for a batch of query pairs.
  • Correctness: each run validates that graphfast agrees with igraph on a sample of pairs (same component?) — speed is meaningless if the answers differ.
  • Sizes: 1M, 10M, and 100M edges. graphfast is measured at all three; igraph is measured up to 10M and deliberately skipped at 100M, where building a full graph object needs far more RAM than graphfast (raise igraph_max_edges in the harness if your machine has the headroom).
  • Single pass: one timing per method (wall clock). For publication-grade numbers, wrap the calls in microbenchmark and report medians.

4.3 The harness

This is the live benchmark. It is parameterised over sizes and emits a tidy table; everything downstream is derived from results.

library(graphfast)
library(data.table)

# Edit this vector to change what gets measured. graphfast is measured at every
# size; igraph is only attempted up to `igraph_max_edges`, because building a
# full igraph object at 100M edges needs far more RAM than graphfast and can
# exhaust memory mid-render. Raise it if your machine has the headroom.
sizes           <- c(1e6, 1e7, 1e8)
igraph_max_edges <- 1e7

make_graph <- function(n_edges, sparsity = 5L, seed = 42L) {
  set.seed(seed)
  n_nodes <- as.integer(max(1e5, floor(n_edges / sparsity)))
  from <- sample.int(n_nodes, n_edges, replace = TRUE)
  to   <- sample.int(n_nodes, n_edges, replace = TRUE)
  keep <- from != to                       # drop self-loops
  matrix(c(from[keep], to[keep]), ncol = 2)
}

time_it <- function(expr) system.time(expr)[["elapsed"]]

bench_one <- function(n_edges) {
  edges   <- make_graph(n_edges)
  n_nodes <- max(edges)
  queries <- edges[seq_len(min(50L, nrow(edges))), , drop = FALSE]

  # ---- graphfast ----
  gf_cc <- time_it(cc <- find_connected_components(edges, n_nodes = n_nodes))
  gf_sp <- time_it(shortest_paths(edges, queries, n_nodes = n_nodes))

  out <- data.table(
    edges = n_edges, package = "graphfast",
    components_s = gf_cc, shortest_paths_s = gf_sp,
    n_components = cc$n_components
  )

  # ---- igraph (capped by igraph_max_edges; also guarded against allocation errors) ----
  if (requireNamespace("igraph", quietly = TRUE) && n_edges <= igraph_max_edges) {
    ig <- tryCatch({
      g <- igraph::graph_from_edgelist(edges, directed = FALSE)
      ig_cc <- time_it(comp <- igraph::components(g))
      ig_sp <- time_it({
        for (i in seq_len(nrow(queries)))
          igraph::distances(g, v = queries[i, 1], to = queries[i, 2])
      })
      # correctness spot-check: do the two agree on component membership?
      ok <- comp$membership[queries[, 1]] == comp$membership[queries[, 2]]
      gf_ok <- cc$components[queries[, 1]] == cc$components[queries[, 2]]
      stopifnot(all(ok == gf_ok))
      data.table(
        edges = n_edges, package = "igraph",
        components_s = ig_cc, shortest_paths_s = ig_sp,
        n_components = comp$no
      )
    }, error = function(e) {
      message(sprintf("igraph skipped at %g edges: %s", n_edges, conditionMessage(e)))
      NULL
    })
    out <- rbind(out, ig)
  }
  out
}

results <- rbindlist(lapply(sizes, bench_one), use.names = TRUE)
results[]
edges package components_s shortest_paths_s n_components
1e+06 graphfast 0.02 0.06 12
1e+06 igraph 0.06 3.03 12
1e+07 graphfast 0.33 1.22 81
1e+07 igraph 1.09 40.12 81
1e+08 graphfast 8.02 16.17 910

4.4 Measured results — connected components

Wall-clock seconds to compute all connected components, on the graphs generated above. Lower is better.

Table 4.1
Edges graphfast igraph
1M 0.020 s 0.060 s
10M 0.330 s 1.090 s
100M 8.020 s

On this render graphfast computed components 3.0×–3.3× faster than igraph across the sizes where both ran.

Both libraries are near-linear in edges, so the curves are roughly straight on a log–log plot. The gap is the constant factorgraphfast avoids constructing and populating a full graph object.

4.5 Measured results — shortest paths (batch of pairs)

Wall-clock seconds for a batch of pairwise hop-distance queries on the same graphs.

Table 4.2
Edges graphfast igraph
1M 0.060 s 3.030 s
10M 1.220 s 40.120 s
100M 16.170 s

graphfast builds the adjacency list once (degrees pre-reserved) and reuses a single distance buffer across queries via a visited-version stamp, so per-query overhead is small — the win grows with the number of queries.

4.6 Scaling behaviour across many sizes (atime)

The tables above sample a handful of sizes. atime sweeps many more, timing each expression repeatedly per size and stopping once the median time crosses seconds.limit — so a slow contender doesn’t blow out the render — then fits the closest power-law/log/exponential reference curve to each series.

atime_result <- atime::atime(
  N = unique(as.integer(10^seq(3, 7, length.out = 15))),
  setup = {
    edges   <- make_graph(N)
    n_nodes <- max(edges)
  },
  seconds.limit = 1,
  times = 3,
  graphfast = find_connected_components(edges, n_nodes = n_nodes, compress = TRUE),
  igraph    = igraph::components(igraph::graph_from_edgelist(edges, directed = FALSE))
)
# atime also measures memory via R's allocator (Rprofmem), but that only sees
# memory allocated through R's own heap. igraph's graph structures are
# allocated with raw malloc() inside its C library, invisible to that
# tracker — so an atime memory comparison here would understate igraph and
# can't be trusted. Time is unaffected by that blind spot, so we keep only
# the time series here; real peak memory is measured separately below.
atime_result$unit.col.vec <- "median"
atime_refs <- atime::references_best(atime_result)
plot(atime_refs)

Both curves are consistent with linear growth in edge count; the lines never cross — graphfast’s constant factor stays smaller across the whole range, and it keeps going to sizes where igraph has already crossed the time budget.

4.7 Varying the graph shape

Runtime is not just a function of edge count; structure matters. The sweep below holds the edge count fixed and varies sparsity (edges per node), measured live.

fixed_edges <- 1e7
sweep <- rbindlist(lapply(c(2L, 5L, 20L, 100L), function(s) {
  edges <- make_graph(fixed_edges, sparsity = s)
  data.table(
    sparsity = s,
    nodes = max(edges),
    seconds = system.time(
      find_connected_components(edges, n_nodes = max(edges))
    )[["elapsed"]]
  )
}))
sweep[]
sparsity nodes seconds
2 5e+06 0.84
5 2e+06 0.27
20 5e+05 0.20
100 1e+05 0.22

What to watch for across the sweep:

  • Sparsity — many small components (few edges per node) vs. one giant component (dense). Union–Find cost is dominated by the number of union calls (= edges) either way, but cache behaviour differs.
  • Component count — the relabelling pass is O(n_nodes); graphs with huge node spaces pay more there.
  • Query depth (shortest paths) — distant or unreachable pairs explore more of the graph; use max_distance to bound it.

4.8 Memory

graphfast’s connected-components memory is essentially:

\[ \underbrace{2 \times 4\text{ bytes} \times n_{\text{nodes}}}_{\text{parent + rank}} \; + \; \underbrace{8\text{ bytes} \times n_{\text{edges}}}_{\text{the edge list itself}} \]

so ~12 bytes per node plus the input. For sparse/large id spaces, prefer find_connected_components_safe(), which remaps ids so the arrays are sized by the number of distinct nodes, not the maximum id — the function reports the memory it saved in $memory_info. No adjacency matrix is ever formed, which is what makes 100M-edge graphs tractable on commodity hardware.

4.8.1 Measured peak memory

R-level memory profilers (Rprofmem, bench::bench_memory, and therefore atime’s memory metric) only see allocations made through R’s own heap. They miss memory a compiled library allocates itself with malloc()/calloc() — which is exactly how igraph’s C core manages its graph structures. Using one of those tools to compare graphfast against igraph would silently understate igraph’s real usage.

To get a number that’s actually comparable, each run below executes in its own fresh OS process — one package, one size, nothing else loaded — and reads back the peak working set the operating system recorded for that process (Windows’ PeakWorkingSetSize, via the ps package). That figure reflects everything the process touched, regardless of which allocator made the call.

peak_worker_src <- '
args <- commandArgs(trailingOnly = TRUE)
pkg <- args[[1]]; n_edges <- as.integer(args[[2]])
sparsity <- as.integer(args[[3]]); seed <- as.integer(args[[4]])
set.seed(seed)
n_nodes <- as.integer(max(1e5, floor(n_edges / sparsity)))
from <- sample.int(n_nodes, n_edges, replace = TRUE)
to   <- sample.int(n_nodes, n_edges, replace = TRUE)
keep <- from != to
edges <- matrix(c(from[keep], to[keep]), ncol = 2)
n_nodes <- max(edges)
if (pkg == "graphfast") {
  suppressMessages(library(graphfast))
  invisible(find_connected_components(edges, n_nodes = n_nodes, compress = TRUE))
} else {
  suppressMessages(library(igraph))
  invisible(igraph::components(igraph::graph_from_edgelist(edges, directed = FALSE)))
}
cat(ps::ps_memory_info()[["peak_wset"]])
'
worker_path <- tempfile(fileext = ".R")
# Prepend the *current* session's library paths so the subprocess sees the
# same installed packages, regardless of its own (possibly different)
# default per-version library location.
lib_paths_call <- sprintf(".libPaths(%s)", paste(deparse(.libPaths()), collapse = " "))
writeLines(c(lib_paths_call, peak_worker_src), worker_path)
rscript_bin <- file.path(R.home("bin"), "Rscript")

peak_memory_kb <- function(pkg, n_edges, sparsity = 5L, seed = 42L) {
  out <- system2(rscript_bin, c(shQuote(worker_path), pkg, n_edges, sparsity, seed),
                 stdout = TRUE, stderr = FALSE)
  as.numeric(out[length(out)]) / 1024
}

mem_sizes <- c(1e5, 1e6, 1e7)
mem_results <- rbindlist(lapply(mem_sizes, function(n) {
  data.table(
    edges     = n,
    graphfast = peak_memory_kb("graphfast", n),
    igraph    = peak_memory_kb("igraph", n)
  )
}))
mem_results[]
edges graphfast igraph
1e+05 87332 96944
1e+06 135440 198360
1e+07 475508 1252172
Table 4.3
Edges graphfast igraph
100,000 85 MB 95 MB
1M 132 MB 194 MB
10M 464 MB 1223 MB

This includes the cost of generating the graph itself (sampling, filtering self-loops) and R’s own baseline footprint — the same for both packages at a given size — so the difference between columns is the fair comparison, not the absolute numbers. igraph’s peak is consistently higher because building a full graph object (vertex table, attribute storage, internal adjacency structures) costs more than the union–find arrays graphfast works with directly.

4.9 Reproducing

# Install comparison packages as needed
install.packages(c("igraph", "microbenchmark"))

# The benchmark ships with the installed package — no need to clone the repo.
# Runs 1e5, 1e6, 1e7 edges by default.
source(system.file("benchmark.R", package = "graphfast"))

# The scaling-behaviour and peak-memory sections on this page additionally use:
install.packages(c("atime", "ggplot2", "ps"))

To regenerate the tables on this page, just re-render the book — the chunks above run automatically. Edit the sizes vector in the harness to push to 100M+ (and note your CPU, RAM, OS, R version, and threading, so the figures are comparable).