Under the hood#

An optional page: you don’t need any of this to use ergmx.

ergmx is a Python package, but the part that does the heavy work is written in Rust, compiled ahead of time to machine code. This page explains why, what Rust brings, and what the tools you meet when installing from source (cargo, rustup, PyO3, maturin) do.

Why not just Python?#

Fitting a dyad-dependent ERGM means simulating networks by Markov chain Monte Carlo (MCMC). Each step of the chain is tiny:

  1. pick a dyad to toggle (add the tie if it is absent, remove it otherwise);

  2. compute how the model’s statistics would change, the change statistics: for triangle, the number of partners the two vertices share;

  3. accept or reject the toggle, with a probability that depends on that change.

A single fit takes tens of millions of these steps: a fit of faux.mesa.high’s five-term model runs about 30 million for the estimate, and 37 million more for its log-likelihood. And each step depends on the network the previous one left, so they can’t be done all at once.

That is what makes Python slow here. NumPy is fast because it hands a whole array to compiled code, which loops over it without returning to Python. A chain of steps that each depend on the last has no array to hand over: the loop must run in Python, one step at a time, and each Python operation costs tens of nanoseconds of interpretation (looking up names, checking types, creating objects) before any arithmetic happens.

Here is the same sampler, for edges + triangle with random toggles, in plain Python:

import math
import random
import time

from ergmx import datasets

mesa = datasets.load("faux.mesa.high")
n, theta = mesa.vcount(), (-4.6, 0.2)  # edges, triangle


def python_sampler(steps, seed=1):
    rng = random.Random(seed)
    neighbors = [set(mesa.neighbors(v)) for v in range(n)]
    for _ in range(steps):
        i, j = rng.sample(range(n), 2)
        tie = j in neighbors[i]
        sign = -1 if tie else 1
        change = sign * (theta[0] + theta[1] * len(neighbors[i] & neighbors[j]))
        if change >= 0 or rng.random() < math.exp(change):
            if tie:
                neighbors[i].discard(j)
                neighbors[j].discard(i)
            else:
                neighbors[i].add(j)
                neighbors[j].add(i)


steps = 200_000
start = time.perf_counter()
python_sampler(steps)
python_ns = (time.perf_counter() - start) / steps * 1e9
print(f"Python: {python_ns:.0f} ns per step")
Python: 1618 ns per step

and ergmx’s Rust sampler on the same model, on one thread:

import ergmx

steps = 5_000_000
start = time.perf_counter()
ergmx.simulate(mesa, "edges + triangle", theta, burnin=steps, interval=1,
               triadic_weight=0, output="stats")
rust_ns = (time.perf_counter() - start) / steps * 1e9
print(f"Rust: {rust_ns:.0f} ns per step, {python_ns / rust_ns:.0f} times faster")
fit_steps = 67e6  # a fit of faux.mesa.high's model, with its log-likelihood
print(f"The steps of a fit: {fit_steps * python_ns / 1e9:.0f} s at Python's speed, "
      f"{fit_steps * rust_ns / 1e9:.1f} s at Rust's")
Rust: 151 ns per step, 11 times faster
The steps of a fit: 108 s at Python's speed, 10.1 s at Rust's

The numbers are those of the computer that built this page, and understate the difference: ergmx’s sampler does more than this bare loop on each step (it handles any combination of terms, keeps every statistic up to date and uses better proposals), and models with terms such as gwesp make each step more work in both languages. ergmx also splits the steps across parallel chains, so a fit takes a fraction of the single-thread time.

Why Rust?#

Several languages compile to machine code as fast as C’s. A benchmark of this sampler on a 1,000-vertex network, during the design of ergmx, gave:

Implementation

Nanoseconds per step

NumPy (one step at a time, on an adjacency matrix)

1,300

Pure Python (neighbor sets, as above)

445

Numba (Python compiled at run time)

28

C

19

Rust

19

Rust is as fast as C, and adds what made it the choice for ergmx:

  • Memory safety without a garbage collector. The compiler checks, before the program runs, that no memory is used after being freed and that no two threads change the same data at once. Bugs that crash C programs, or worse, silently corrupt results, don’t compile.

  • Parallelism that is safe by construction. ergmx runs its MCMC chains in parallel threads; the compiler guarantees they can’t interfere. Python’s global interpreter lock (GIL) is released while they run, so they use every CPU core.

  • Tools. One build tool, cargo, and a package registry, crates.io, for libraries, and PyO3 and maturin, which make a Rust library a Python package with little code.

Numba would also have been a reasonable choice, but it compiles when the program runs, is slower when the model’s settings aren’t known at compile time (28 ns rather than 20 here), and doesn’t give the same guarantees for threads.

What runs where#

ergmx keeps in Rust only what runs millions of times, and everything else in Python, where it is easier to read and change:

Python (python/ergmx/)

Rust (src/)

Formulas and terms: names, arguments, attributes

The network, as sorted neighbor lists

The estimation: MPLE, contrastive divergence, Monte Carlo MLE

Change statistics of every term

Log-likelihoods, standard errors, diagnostics

The MCMC sampler, its proposals and sample spaces

Summaries, goodness of fit, plots

Parallel chains, the MPLE’s data, goodness-of-fit distributions

They meet in a few large calls: once per iteration, Python asks the Rust core to run its chains (millions of steps) and gets back an array of the sampled statistics. Crossing between the two languages costs microseconds, which is nothing once per iteration, and would be too much once per step.

Speed also comes from the algorithms, which the language only makes cheap: change statistics update only what a toggle changes, sorted neighbor lists make shared partners a merge of two short lists (and, in denser networks, a cache of every pair’s count, as ergm keeps, a lookup), and triadic proposals help the chains explore clustered networks.

Memory grows with the ties, not with the pairs of vertices: nothing has a row or a cell per dyad. The MPLE’s data are the distinct rows of change statistics with their counts, built in parallel threads (3 kB rather than 2 GB for a network of 10,000 vertices); the sample space of many networks combined, or of a bipartite network, is described by its groups of vertices and lists of fixed dyads; and goodness of fit’s distances and shared partners come from breadth-first searches and counters. The proposal matters as much as the language: with plain tie/no-tie proposals, R’s ergm takes 128 s on faux.magnolia.high, against 14 s with its triadic default (both without the log-likelihood).

The tools#

If you know Python’s tools, Rust’s have a counterpart for each:

Rust

What it does

Python counterpart

rustc

the compiler: Rust source to machine code

(the interpreter)

cargo

builds the code and downloads its dependencies

pip and a build tool

crates.io

the registry of Rust libraries, crates

PyPI

Cargo.toml, Cargo.lock

the package’s settings and exact dependencies

pyproject.toml, uv.lock

rustup

installs and switches Rust versions

uv python install, pyenv

rust-toolchain.toml

the Rust version a project uses

.python-version

ergmx’s Rust core depends on four crates: PyO3, the bridge to Python; numpy, to exchange arrays without copying; rayon, for the parallel chains; and rustc-hash, a fast hash function for its tables.

PyO3 turns Rust into a Python module. A Rust structure marked #[pyclass] becomes a Python class, and its methods Python methods; PyO3 converts the arguments (lists, NumPy arrays, numbers) between the two languages, and turns Rust errors into Python exceptions. The core is the module ergmx._core; you never use it directly.

maturin is the bridge to Python packaging. pyproject.toml names it as the build backend, the tool that uv, pip or uv build call to turn the source into a wheel, as they would call setuptools or hatchling for a pure Python package:

[build-system]
requires = ["maturin>=1.9,<2"]
build-backend = "maturin"

When asked to build, maturin runs cargo with optimizations, takes the compiled library (_core.abi3.so, or _core.pyd on Windows), puts it in the ergmx folder next to the Python code, and zips them into a wheel. If cargo is missing, it downloads Rust first.

From source code to import ergmx#

        flowchart LR
    A["uv add ergmx"] --> B{"A wheel for<br>this platform?"}
    B -- yes --> C["download it<br>(compiled core inside)"]
    B -- no --> D["maturin"] --> E["cargo, rustc<br>(compile with optimizations)"] --> F["wheel"]
    C --> G["install: copy files"]
    F --> G
    G --> H["import ergmx"]
    

Published versions take the top path: the compiling was done once, on GitHub’s computers, for each platform. Building from source takes the bottom one, on your computer.

The optimized build is set in Cargo.toml: link-time optimization lets the compiler optimize across the whole program, inlining the change statistics into the sampler’s loop, at the cost of a slower build. An unoptimized (“debug”) build, which cargo makes by default for development, is about five times slower on faux.mesa.high’s model; when uv or pip build ergmx, maturin always optimizes.

[profile.release]
codegen-units = 1
lto = "fat"

Finally, the compiled core uses Python’s stable ABI: it only calls the part of Python’s C interface that is promised not to change between versions. That is why one wheel per platform serves Python 3.11, 3.12, 3.13, 3.14 and later versions.