{"slug": "sagebrush-and-pyodide-measured", "title": "Sagebrush and Pyodide, Measured", "summary": "Sagebrush 0.5.0, a browser Python engine led by William Stein of SageMath, Inc., cold-started in 155 ms and transferred 0.38 MB, against 2,008 ms and 6.04 MB for Pyodide 314.0.7 running CPython 3.14.2, according to benchmarks measured on Oct 18, 2026 in headless Chrome 155 on a 48-core AMD EPYC 7B13 VM. Importing numpy took 149 ms in Sagebrush versus 886 ms in Pyodide, though Sagebrush compiles Python to JavaScript rather than running CPython and ships a subset standard library with reimplemented numpy and scipy and no C extensions. The comparison script, web/bench-compare.mjs, is published in the project repository so others can rerun the five-cold-start medians.", "body_md": "# Sagebrush and Pyodide, measured\n\nWritten by Claude Opus 5.5, an AI model made by Anthropic, from measurements of the Sagebrush project, led by William Stein (SageMath, Inc.).\n\nMeasured on Oct 18, 2026: headless Chrome 155 on a quiet Linux VM (48 cores of an AMD EPYC 7B13; one-minute load average at most 1.1 throughout). Sagebrush 0.5.0 at commit 8d3a5295 (build 4e24e8e68b5d5ada); 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.\n\nSagebrush runs Python in the browser without CPython. Its own compiler turns\nPython into JavaScript, which the browser's JIT runs, alongside a library\nwritten for it: numpy, a subset of scipy, and the Sage, MATLAB and Magma modes.\n[Pyodide](https://pyodide.org) is CPython itself, compiled to WebAssembly,\nwith real NumPy and much of the scientific stack built the same way.\n\nThey make different trade-offs. This page measures both the same way, with a\nscript anyone can run (`web/bench-compare.mjs` in the repository).\n\n**In the browser, Sagebrush is small and starts fast.** A cold start\ntransfers 0.38 MB against Pyodide's 6.04 MB and prints its first result\n155 ms after navigation, against 2,008 ms. `import numpy` then takes 149 ms\nagainst 886 ms. Running speed depends on the workload; the tables below\ninclude rows chosen because Sagebrush's design should be weakest there, and\nhow much memory each engine holds over a session.\n\n**What Sagebrush does not claim.** It does not run every Python program the\nway CPython does:\n\n- its standard library is a subset;\n- its numpy and scipy are reimplementations, close but not identical, and their documented differences are part of the catalog;\n- C extensions don't exist for it.\n\nOperations 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.\n\n## How it was measured\n\nThese 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.\n\n- Each cold start is a fresh browser profile, so the browser has nothing cached.\n- All files come from one local server, which compresses them as a CDN would (brotli). One discarded priming session per engine ran first, so the compressed copies already existed. After that the two engines alternated.\n- The byte counts are the response bodies the server sent: compressed where the file type allows, without HTTP overhead.\n- The times are local and end to end. The time to the first result counts from navigation and includes the measuring script's polling. A visit over a real network would add the transfer time of those bytes.\n\n## Downloading and starting\n\nEach cold start loads the interpreter and runs `print(1 + 1)`.\n\n|  | Sagebrush | Pyodide 314.0.7 | \n|---|---|---|\n| transferred / uncompressed | **0.38 / 1.46 MB** | 6.04 / 13.5 MB | \n| interpreter ready | **107 ms** (104–108) | 1,945 ms (1,931–1,991) | \n| first `print(1 + 1)` result, from navigation | **155 ms** (153–206) | 2,008 ms (1,974–2,045) | \n| `import numpy` : transferred / uncompressed | **0.34 / 2.05 MB** | 2.96 / 2.96 MB | \n| `import numpy` : time | **149 ms** (145–152) | 886 ms (881–904) | \n\n- **Sagebrush.** The interpreter is one 1.46 MB script (0.38 MB compressed):\nthe compiler and runtime. Its library comes in chunks, each fetched the\nfirst time something imports from it.\n- **Pyodide.** Its core is the CPython interpreter (`pyodide.asm.wasm` ,\n9.6 MB, 3.2 MB compressed) and its standard library\n(`python_stdlib.zip` , 2.5 MB, already compressed). Each package is a wheel,\nfetched when imported.\n\n## Running\n\nEach 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. Between workloads the script forces a garbage collection and reads the memory (see Memory, below).\n\nThe two multiplication rows time one product, `c = a * b`, and print its\nbit length and its remainder modulo 1,000,003. Before the warm-up, an\nuntimed setup step builds the operands, two integers of about  (or\n) decimal digits, from hex strings. That setup step's output is\nchecked as well.\n\n| workload | Sagebrush | Pyodide | \n|---|---|---|\n| the sum of 3,000,000 squares, in a loop | **172 ms** (172–182) | 731 ms (669–795) | \n| recursion, `fib(25)` | **6 ms** (6–6) | 18 ms (17–20) | \n| a dict of 200,000 string keys, sorted | **80 ms** (78–81) | 91 ms (87–100) | \n| big integers, `factorial(20000)` | **5 ms** (5–5) | 11 ms (11–12) | \n| big integers, one multiplication of two -digit numbers | **34 ms** (34–34) | 709 ms (707–718) | \n| big integers, one multiplication of two -digit numbers | **381 ms** (377–381) | 28,098 ms (28,027–28,126) | \n| a byte sieve to 2,000,000 | **17 ms** (16–17) | 29 ms (26–30) | \n| JSON, 30,000 small objects there and back | 143 ms (140–147) | 142 ms (141–149) | \n| numpy, a 300×300 matrix product | **13 ms** (12–13) | 29 ms (29–30) | \n| numpy, elementwise on 1,000,000 floats | 540 ms (537–549) | **13 ms** (13–13) | \n\nThese explanations are architectural context for these workloads, not separate measurements:\n\n- **Pure-Python code** (loops, function calls) is where compiling to\nJavaScript pays. The browser's JIT compiles hot code to machine code, while\nCPython interprets bytecode, and that interpreter is itself compiled to\nWebAssembly.\n- **Big integers.** Sagebrush's`int` is JavaScript's`BigInt` once a\nvalue outgrows a double's exact integers. The two engines multiply\nlarge integers with different algorithms, so the gap grows with size.\n  - **CPython** (`Objects/longobject.c` ) multiplies by schoolbook up to 70\nof its 30-bit digits (about 630 decimal digits), and by Karatsuba\nabove that. Karatsuba is .\nCPython has nothing faster.\n  - **V8's BigInt** (`src/bigint/` in V8's sources) also starts with\nschoolbook and Karatsuba. It switches to Toom–Cook 3-way,\n, once the smaller operand\nreaches 210 of its 64-bit digits (about 4,000 decimal digits). It\nswitches to Schönhage–Strassen FFT multiplication,\n, at 720 digits (about 14,000 decimal\ndigits). The thresholds are in`bigint-inl.h` ; the dispatch is`MultiplyLarge` in`bigint-internal.cc` .\n  - So multiplying two -digit numbers takes 381 ms in Sagebrush against 28.1 s in Pyodide, about 74 times less; at digits the ratio is about 21. For scale, outside the browser, native CPython 3.14.8 took 22.1 s for that product on one of these VMs, and Node 26 took 355 ms.\n  - `factorial(20000)` is a product of many small factors, so it depends\non how they are grouped as much as on the multiplication. Both engines\nuse CPython's algorithm, a balanced product of the odd parts and then\na shift, so that the multiplications are of numbers of similar size.\n  - **A caveat on phones:** V8 compiles the Toom–Cook and FFT algorithms\nonly where its build flag`v8_advanced_bigint_algorithms` is on, and it\ndefaults to off on Android (`gni/v8.gni` ) to save binary size. In Chrome\non Android, Sagebrush's large products are therefore Karatsuba, like\nCPython's, and the -digit row does not carry over.\n- **JSON** : Pyodide uses CPython's C accelerator,`_json` . Sagebrush now\nhas its own`_json` (`src/runtime/json.ts` ) under CPython's`json` package: the same scanner, encoder and string scanning, written for its\nruntime. The two engines now take about the same time; without it, in\nthe previous version of this page, Sagebrush took 1,031 ms.\n- **NumPy.** In Pyodide, NumPy's operations are C compiled to WebAssembly.\n  - In Sagebrush, the matrix product calls a WebAssembly SIMD kernel\nwritten in Rust (`dgemm` in`kernels/src/lib.rs` ), which does the same\nfloating-point operations, in the same order, as its JavaScript\nfallback.\n  - Elementwise arithmetic and most ufuncs are JavaScript loops over typed\narrays; only `exp` and`log` have WebAssembly SIMD kernels.\n  - The elementwise row, `np.sum(np.sin(x) * x + x ** 2)` on 1,000,000\nfloats, is about 40 times slower here than in Pyodide, the largest gap\nin the table. It is not the cost of dispatch. Timed one operation at a\ntime in Node on the same kind of VM:\n    - `x * x` ,`x + x` and`np.sum` take 0.8–1.9 ms, as fast as a plain\nJavaScript loop over the array (0.9–4.2 ms).\n    - `np.sin` takes 357 ms, against 9.7 ms for a loop of`Math.sin` .\nSagebrush's`sin` is correctly rounded, so that its results agree\nwith the C library that CPython and NumPy call wherever that library\nis correctly rounded. It is computed in double-double arithmetic, in\nJavaScript.\n    - `x ** 2` takes 106 ms. It goes through a port of glibc's`pow` ,\nwhile NumPy computes a square as`x * x` .\n  - Both are known and fixable without giving up the results: a square is\nexactly `x * x` , and a correctly rounded`sin` can be fast if it is\ncompiled to WebAssembly, as`exp` and`log` already are.\n- In Sagebrush, the matrix product calls a WebAssembly SIMD kernel\nwritten in Rust (\n\n## Where Sagebrush's design is weakest\n\nThese rows were chosen because they work against compiling Python to JavaScript.\n\n| workload | Sagebrush | Pyodide | \n|---|---|---|\n| 2,000 distinct small functions, each built with `exec()` and called once | 417 ms (402–424) | **128 ms** (127–130) | \n| one call site over objects of 50 classes with different layouts | 116 ms (114–118) | **72 ms** (71–74) | \n| the same loop over objects of one class (the control) | **13 ms** (13–13) | 50 ms (50–53) | \n\n- **`exec()` and `compile()`.** CPython compiles a small function to\nbytecode in microseconds. Sagebrush parses it, compiles it to JavaScript,\nand then V8 parses and compiles that JavaScript too, about 0.2 ms per\nfunction here. Code that generates and runs many small programs pays this\neach time. Pyodide is about 3 times faster on this row.\n- **Megamorphic code.** V8 makes attribute loads and method calls fast by\ncaching, at each place in the code, the object layouts it has seen. When\none place sees objects of 50 different layouts, those caches give up\n(\"megamorphic\"), and each access becomes a slower generic lookup.\nCPython's dictionary-based lookup does not depend as much on how many\nclasses pass through. Pyodide is about 1.6 times faster here, while on the\nsame loop over one class Sagebrush is almost 4 times faster.\n\n## Memory\n\nThe two engines manage memory in different ways, and it shows over a session.\n\n- **Sagebrush's** Python objects live in the JavaScript heap of its Web\nWorker. V8's garbage collector frees what is no longer reachable and gives\nthe freed pages back.\n- **Pyodide's** CPython lives inside one WebAssembly memory, a single block\nthat can grow and never shrinks. CPython frees an object as soon as its\nlast reference goes, and reuses that space for later objects. But the\nblock itself stays at the largest size it ever reached, for as long as\nthe page is open.\n\n**How it was measured.** The numbers are the private memory (USS, pages no\nother process shares) of the page's renderer process. That process holds\nthe page, Sagebrush's worker, both JavaScript heaps and Pyodide's\nWebAssembly memory. The browser's other processes hold about the same for\nboth engines and are not counted.\n\n- The script reads `/proc` every 50 ms during each workload's setup and\nwarm-up run, which do the same work as a timed run, and takes the peak\nof those readings.\n- It does not sample while a workload is timed: reading a process's memory map holds a lock that slows that process's own allocations. With sampling on, Sagebrush's allocation-heavy rows took 12–16% longer.\n- After each workload's five timed runs it forces a garbage collection\n(CDP's `HeapProfiler.collectGarbage` ) and reads again. Pyodide's memory\ncan still grow during the timed runs, so its reading after them can be a\nlittle above its warm-up peak.\n- The workloads run in one session, in the order of the table, and their variables stay alive, as in a notebook. So each \"after\" reading includes the data every earlier workload left behind.\n\n| the page's renderer, private memory (MB) | Sagebrush | Pyodide [its WebAssembly memory] | \n|---|---|---|\n| after `print(1 + 1)` | **27** (27–28) | 79 (78–79) [31] | \n| after `import numpy` | **38** (38–39) | 116 (116–116) [45] | \n| one multiplication of two -digit numbers: peak | 394 (393–394) | **194** (193–194) | \n| the same, after a collection | **78** (77–78) | 195 (194–195) [136] | \n| JSON, 30,000 small objects: peak | **180** (179–181) | 195 (195–196) | \n| the same, after a collection | **113** (113–113) | 195 (195–196) [136] | \n| numpy elementwise: peak | **161** (159–161) | 221 (221–221) | \n| the same, after a collection | **124** (124–125) | 221 (220–221) [163] | \n| after the last workload, then 30 s idle | **141** (140–142) | 220 (220–220) [163] | \n| every global deleted, `gc.collect()` , a collection | **76** (76–77) | 220 (220–220) [163] | \n\n- **Peaks.** V8 lets garbage pile up between collections, so Sagebrush's\npeaks can be higher: 394 MB while multiplying two -digit numbers,\nagainst Pyodide's 194 MB.\n- **After a collection,** Sagebrush gives most of it back: 78 MB after that\nmultiplication. Pyodide's footprint only ratchets up. Its WebAssembly\nmemory was 45 MB after`import numpy` , 79 MB after the dict, 136 MB after\nthe multiplication and 163 MB after the elementwise row, and it never\nwent down.\n- **At the end,** with every workload's variables deleted and collected,\nSagebrush's page is at 76 MB and Pyodide's at 220 MB. CPython has freed\nthose objects, but the WebAssembly memory still holds its 163 MB peak.\nFor a long-running notebook, this means one large computation leaves a\nPyodide tab at its high-water mark until the page is reloaded.\n- The full timeline, one sample per 250 ms with each phase marked, is in the raw data below.\n\n## How to reproduce\n\n```\nnode scripts/build-cli.mjs\nbun web/build.ts\nnode web/bench-compare.mjs --pyodide DIR --runs 5 --json out.json\n```\n\n`DIR` holds Pyodide's distribution (`pyodide.mjs`, `pyodide.asm.wasm`,\n`python_stdlib.zip`, `pyodide-lock.json`) and the numpy wheel. The script\nexits with an error if any execution printed something different, and\n`node web/bench-compare.mjs --check FILE.json` checks a saved run again. The\nmemory measurements need Linux, since they read `/proc`. The raw data of the\nrun above, with every cold start's and every timed run's time and output,\nevery memory reading, and each session's memory timeline, is\n[`articles/performance/data/sagebrush-pyodide-2026-10-18.json`](https://github.com/sagemathinc/sagebrush/blob/master/articles/performance/data/sagebrush-pyodide-2026-10-18.json).\nThe previous run, before the multiplication rows, the product trees and\nSagebrush's `_json`, is\n[`sagebrush-pyodide-2026-10-17.json`](https://github.com/sagemathinc/sagebrush/blob/master/articles/performance/data/sagebrush-pyodide-2026-10-17.json).", "url": "https://wpnews.pro/news/sagebrush-and-pyodide-measured", "canonical_source": "https://sagebrush.space/articles/performance/01-sagebrush-and-pyodide", "published_at": "2026-10-19 00:27:09+00:00", "updated_at": "2026-10-19 00:48:41.751670+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-research"], "entities": ["Sagebrush", "Pyodide", "William Stein", "SageMath, Inc.", "CPython", "NumPy", "Chrome"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/sagebrush-and-pyodide-measured", "markdown": "https://wpnews.pro/news/sagebrush-and-pyodide-measured.md", "text": "https://wpnews.pro/news/sagebrush-and-pyodide-measured.txt", "jsonld": "https://wpnews.pro/news/sagebrush-and-pyodide-measured.jsonld"}}