Benchmarks and verification
Binomial random-intercept scale checks and performance
This benchmark fits the same Laplace marginal ML model in R lme4, an independent Python/SciPy reference implementation, and the native PHP extension. It uses shared deterministic binary data, correlated study/practice predictors, three fixed coefficients and 50 groups at 1,000, 10,000 and 100,000 observations.
The Python worker is our likelihood/optimizer reference, not statsmodels’ Bayesian mixed-model estimator and not an optimized Python GLMM package. These measurements compare the named implementations; they do not establish a general Rust-versus-Python speed ranking.
Timing boundaries and correctness
Each worker loads the same CSV, performs one warm-up fit, then runs three timed fits. PHP times the complete native call, including conversion and all returned inference/observation arrays. R/Python materialize covariance, standard errors, conditional modes and full fixed/conditional fitted values and residuals. Compact sample output and JSON serialization happen outside the timed fit. Process time includes startup, CSV load, warm-up and all timed fits; peak RSS covers the whole worker process. Threads are pinned to one.
Every report is published only after coefficients, covariance/standard errors, group SD/modes, normalized likelihood and common observation samples agree. Likelihood tolerance is absolute 1e-6 plus relative 1e-8; inference uses absolute 2e-5 plus relative 2e-3; estimates use absolute 2e-5 plus relative 2e-4. Group modes use absolute 5e-5 because large-sample flat-profile optimization produced shifts around 2e-5 in modes near zero; relative error there is not informative.
The installed lme4 version disables derivative checks automatically at large n. The benchmark explicitly enables the final joint Hessian. Its initial BOBYQA run also stopped short on the 10,000-row case; an R-only restart from that fit, with rhobeg=0.001 and nAGQ0initStep=FALSE, resolved it. Both R stages are included in its fit time. The report records these controls, versions and data/binary hashes. Native estimates never seed the R worker.
Results
from pathlib import Path
import json
import pandas as pd
from IPython.display import display
ROOT=next(p for p in [Path.cwd(),*Path.cwd().parents] if (p/'Cargo.toml').exists())
report=json.loads((ROOT/'docs/benchmarks/logistic-mixed-results.json').read_text())
ORDER=['R','SciPy reference','PHP']
rows=[]
for case in report['cases']:
for label in ORDER:
r=case['implementations'][label]
rows.append(dict(observations=case['n'],groups=case['n_groups'],implementation=label,
median_fit_seconds=r['median_fit_seconds'],CSV_load_seconds=r['load_seconds'],
whole_process_seconds=r['process_seconds'],
peak_process_RSS_MiB=r['peak_rss_bytes']/2**20 if r['peak_rss_bytes'] else None,
numerical_agreement=case['agreement_passed']))
table=pd.DataFrame(rows);display(table)
print('Machine:',report.get('cpu'),report['platform'])
print('Threads:',report['thread_environment'])
print('Versions:',{name:report['cases'][0]['implementations'][name]['version'] for name in ORDER})
import subprocess,os
subprocess.run([os.environ.get('RSCRIPT','Rscript'),str(ROOT/'docs/figures/render.R'),'logistic-mixed'],check=True)| observations | groups | implementation | median_fit_seconds | CSV_load_seconds | whole_process_seconds | peak_process_RSS_MiB | numerical_agreement | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1000 | 50 | R | 0.088000 | 0.000000 | 0.925934 | 262.281250 | True |
| 1 | 1000 | 50 | SciPy reference | 0.409742 | 0.000413 | 2.618622 | 174.656250 | True |
| 2 | 1000 | 50 | PHP | 0.006289 | 0.000952 | 0.217226 | 28.578125 | True |
| 3 | 10000 | 50 | R | 0.554000 | 0.007000 | 2.805439 | 325.312500 | True |
| 4 | 10000 | 50 | SciPy reference | 0.664116 | 0.002729 | 3.385617 | 180.671875 | True |
| 5 | 10000 | 50 | PHP | 0.068200 | 0.008335 | 0.324485 | 36.046875 | True |
| 6 | 100000 | 50 | R | 7.698000 | 0.068000 | 31.648079 | 524.109375 | True |
| 7 | 100000 | 50 | SciPy reference | 2.764936 | 0.026616 | 11.911836 | 308.078125 | True |
| 8 | 100000 | 50 | PHP | 0.785687 | 0.082690 | 3.290282 | 115.859375 | True |
Machine: Apple M5 Ultra macOS-27.0.1-arm64-arm-64bit-Mach-O
Threads: {'OPENBLAS_NUM_THREADS': '1', 'OMP_NUM_THREADS': '1', 'MKL_NUM_THREADS': '1', 'VECLIB_MAXIMUM_THREADS': '1', 'BLIS_NUM_THREADS': '1'}
Versions: {'R': 'R version 4.6.1 (2026-06-24) lme4 2.0.6', 'SciPy reference': 'SciPy 1.18.1', 'PHP': '8.5.10'}
benchmarks/figures/logistic-mixed-fit-time.svg
benchmarks/figures/logistic-mixed-memory.svg
CompletedProcess(args=['Rscript', '/Users/erik/.polyscope/clones/ac9532b1/serene-whale/docs/figures/render.R', 'logistic-mixed'], returncode=0)
These are 50-group, three-coefficient, one-intercept models using the one-node approximation. They do not benchmark 61-node integration, random slopes or multiple grouping terms. A larger dataset does not by itself establish approximation accuracy; see the independent integration checks. The full measurements and errors are in the saved report.
Reproduce
cargo build -p php-stats --release --locked
PHP=/opt/homebrew/bin/php .context/notebooks-venv/bin/python \
tests/benchmarks/run-logistic-mixed.py --sizes 1000,10000,100000 --repeats 3Use a matching PHP/php-config installation and .so on Linux. This is a developer command; unit tests do not execute performance benchmarks.
From phpstats/phit@76b22f0.