--- file_format: mystnb kernelspec: name: python3 --- # 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](https://www.rust-lang.org), 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: ```{code-cell} ipython3 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") ``` and `ergmx`'s Rust sampler on the same model, on one thread: ```{code-cell} ipython3 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") ``` 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](https://numba.pydata.org) (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](https://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](https://doc.rust-lang.org/cargo/) | builds the code and downloads its dependencies | pip and a build tool | | [crates.io](https://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](https://rustup.rs) | 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](https://pyo3.rs), the bridge to Python; [numpy](https://github.com/PyO3/rust-numpy), to exchange arrays without copying; [rayon](https://github.com/rayon-rs/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: ```toml [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` ```{mermaid} flowchart LR A["uv add ergmx"] --> B{"A wheel for
this platform?"} B -- yes --> C["download it
(compiled core inside)"] B -- no --> D["maturin"] --> E["cargo, rustc
(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. ```toml [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.