Sagebrush · Articles · Performance

Sagebrush and Pyodide, measured

Written by Claude Opus 5.5, an AI model made by Anthropic, from measurements of the Sagebrush project, led by William Stein (SageMath, Inc.).

Measured on Oct 17, 2026: headless Chrome 155 on an otherwise idle Linux VM (48 cores of an AMD EPYC 7B13; load average 3.7 at the start and 1.9 at the end). Sagebrush 0.5.0 at commit 8e623937 (build de4ad3da624041bf); Pyodide 314.0.7 (CPython 3.14.2). Five cold starts per engine. The tables give the median, with the lowest and highest of the five in brackets.

Sagebrush runs Python in the browser without CPython. Its own compiler turns Python into JavaScript, which the browser's JIT runs, alongside a library written for it: numpy, a subset of scipy, and the Sage, MATLAB and Magma modes. Pyodide is CPython itself, compiled to WebAssembly, with real NumPy and much of the scientific stack built the same way.

They make different trade-offs. This page measures both the same way, with a script anyone can run (web/bench-compare.mjs in the repository).

What Sagebrush does not claim. It does not run every Python program the way CPython does:

Operations it does not support are intended to raise an error rather than return a guess. The workloads below are ones both engines run the same way. Every execution of each one, in every cold start of both engines, had to print the same result, or the measurement would have failed.

How it was measured

These are not full notebook visits. Each engine runs in a minimal page that starts its interpreter and nothing else: Sagebrush's notebook worker without the notebook around it, and Pyodide on the page's main thread, following its quickstart.

Downloading and starting

Each cold start loads the interpreter and runs print(1 + 1).

Sagebrush Pyodide 314.0.7
transferred / uncompressed 0.33 / 1.32 MB 6.04 / 13.5 MB
interpreter ready 100 ms (98–103) 1,945 ms (1,910–1,973)
first print(1 + 1) result, from navigation 179 ms (178–183) 2,012 ms (1,974–2,038)
import numpy: transferred / uncompressed 0.33 / 2.00 MB 2.96 / 2.96 MB
import numpy: time 132 ms (130–134) 850 ms (845–873)

Running

Each workload runs once to warm up, then five times in the same session. For each cold start the script takes the median of the five timed runs, timed inside the page. The table gives the median of those over the five cold starts, and their range.

workload Sagebrush Pyodide
the sum of 3,000,000 squares, in a loop 170 ms (169–175) 725 ms (661–776)
recursion, fib(25) 5 ms (5–6) 18 ms (18–20)
a dict of 200,000 string keys, sorted 85 ms (84–86) 88 ms (87–91)
big integers, factorial(20000) 52 ms (52–53) 11 ms (11–11)
a byte sieve to 2,000,000 15 ms (15–15) 29 ms (26–30)
JSON, 30,000 small objects there and back 1,031 ms (1,027–1,050) 139 ms (138–143)
numpy, a 300×300 matrix product 8 ms (8–9) 29 ms (29–30)
numpy, elementwise on 1,000,000 floats 536 ms (525–541) 13 ms (13–13)

These explanations are architectural context for these workloads, not separate measurements:

How to reproduce

bun web/build.ts
node web/bench-compare.mjs --pyodide DIR --runs 5 --json out.json

DIR holds Pyodide's distribution (pyodide.mjs, pyodide.asm.wasm, python_stdlib.zip, pyodide-lock.json) and the numpy wheel. The script exits with an error if any execution printed something different, and node web/bench-compare.mjs --check FILE.json checks a saved run again. The raw data of the run above, with every cold start's and every timed run's time and output, is articles/performance/data/sagebrush-pyodide-2026-10-17.json.

Updated 2026-10-17 · source on GitHub