Interpreters

turbohtml runs on CPython 3.10 and newer, on the free-threaded build, and on PyPy 3.10 and 3.11. The same C core serves all of them: there is no pure-Python fallback, and no separate PyPy backend. What differs is the layer underneath, and it differs enough to be worth understanding before you choose PyPy for an HTML workload. How the core adapts to that layer is a maintenance concern, covered in Building on cpyext.

What cpyext costs you

PyPy runs C extensions through cpyext, an emulation of CPython’s C API. A compacting garbage collector moves PyPy’s own objects, and PyPy stores its strings as UTF-8. A C extension expects neither. So the first time a Python object crosses into C, cpyext allocates a non-moving PyObject shell for it and keeps the two views in sync, and the first time a str is read through the PEP 393 buffer macros, cpyext transcodes its UTF-8 storage into the UCS1/2/4 buffer those macros hand out and caches it on the shell.

Both costs are per-object and paid once, and that decides where PyPy is cheap. Reading a str that C has already seen costs nothing extra, because the buffer is cached on the shell: turbohtml’s scanners run at CPython’s speed over it. Handing a new string back does cost, because cpyext transcodes it into PyPy’s own UTF-8 form. So does handing back a wrapper per node.

operation

turbohtml on CPython 3.14

turbohtml on PyPy 3.11

parse to a tree — wpt tiny (0.6 kB)

1.82 µs

3.3 µs (1.9x)

parse to a tree — wpt small (4 kB)

13 µs

17.2 µs (1.4x)

parse to a tree — wpt medium (9.6 kB)

31.5 µs

43.2 µs (1.4x)

parse to a tree — wpt large (92 kB)

284 µs

327 µs (1.2x)

parse to a tree — wpt CJK (124 kB)

578 µs

658 µs (1.2x)

parse to a tree — whatwg spec (235 kB)

540 µs

730 µs (1.4x)

escape — tiny plain (64 B)

68.3 ns

251 ns (3.7x)

escape — medium markup (4 KiB)

2.54 µs

8.17 µs (3.3x)

escape — no-op prose (4 MiB)

130 µs

2.2 ms (17.0x)

escape — book text (3 MiB)

661 µs

3.78 ms (5.8x)

escape — book HTML (4 MiB)

1.43 ms

11.3 ms (7.9x)

escape — spec HTML, dense (4 MiB)

5.41 ms

25.8 ms (4.8x)

escape — UCS-2 plain (4 MiB)

936 µs

2.67 ms (2.9x)

escape — UCS-2 markup (4 MiB)

4.06 ms

28.4 ms (7.0x)

escape — UCS-4 plain (4 MiB)

1.23 ms

1.44 ms (1.2x)

escape — UCS-4 markup (4 MiB)

4.29 ms

21.3 ms (5.0x)

unescape — tiny plain (64 B)

40.1 ns

218 ns (5.5x)

unescape — medium dense refs (4 KiB)

8.75 µs

18.6 µs (2.2x)

unescape — numeric refs (4 KiB)

6.04 µs

15.1 µs (2.5x)

unescape — book HTML, real refs (4 MiB)

2.85 ms

13.8 ms (4.9x)

unescape — escaped book HTML (5 MiB)

2.02 ms

4.44 ms (2.3x)

unescape — dense refs (4 MiB)

10.7 ms

20.4 ms (2.0x)

unescape — UCS-2 refs (4 MiB)

3.02 ms

14.4 ms (4.8x)

serialize a parsed tree — daring fireball (10 kB)

9.06 µs

113 µs (12.5x)

serialize a parsed tree — ars technica (56 kB)

49.5 µs

642 µs (13.0x)

serialize a parsed tree — mozilla blog (95 kB)

108 µs

1.1 ms (10.2x)

serialize a parsed tree — whatwg spec (235 kB)

271 µs

2.78 ms (10.3x)

collect visible text — daring fireball (10 kB)

2.94 µs

63 µs (21.5x)

collect visible text — ars technica (56 kB)

14.6 µs

344 µs (23.7x)

collect visible text — mozilla blog (95 kB)

25.8 µs

479 µs (18.6x)

collect visible text — whatwg spec (235 kB)

89.7 µs

1.89 ms (21.1x)

select div a[href] — daring fireball (10 kB)

760 ns

3.75 µs (5.0x)

select div a[href] — ars technica (56 kB)

1.82 µs

8.87 µs (4.9x)

select div a[href] — mozilla blog (95 kB)

2.55 µs

12.5 µs (5.0x)

select div a[href] — whatwg spec (235 kB)

2.22 µs

9.12 µs (4.2x)

walk every descendant — daring fireball (10 kB)

3.49 µs

32.4 µs (9.3x)

walk every descendant — ars technica (56 kB)

13.9 µs

149 µs (10.7x)

walk every descendant — mozilla blog (95 kB)

29.6 µs

321 µs (10.9x)

walk every descendant — whatwg spec (235 kB)

102 µs

1.09 ms (10.7x)

Parsing pays both costs once: one string in, one tree out. Serializing a tree or collecting its visible text walks it in C just as fast as on CPython, then pays for the long string it hands back, which is why they trail by an order of magnitude while doing no per-node work in Python at all. Walking every descendant pays a shell per node. A CSS query costs less than either, because the match stays in C and only the elements it finds cross.

The JIT recovers none of it. The time goes to cpyext rather than to your bytecode, so no amount of warm-up helps.

None of this makes PyPy the wrong choice. It makes turbohtml a poor reason to choose it. Pick PyPy because the rest of your program is Python that the JIT speeds up, and accept that the HTML layer is slower than it is on CPython.

Regenerate the table with tox -e bench -- interpreters, which builds the working tree under each interpreter and measures the same corpus in each.

Behavior that differs on PyPy

The public API behaves the same on both interpreters. The conformance suites, the tokenizer state machine, the selector and XPath engines, and every serializer produce byte-identical output. Three things do not carry over.

Reference cycles through a C object are never collected. cpyext does not break a cycle that runs through both a Python object and a C extension object, even though every turbohtml type implements tp_traverse and tp_clear. A cycle like document -> your callback -> document leaks on PyPy, where CPython reclaims it. Break such cycles yourself, or hold the C object through a weakref.

Deep recursion may raise SystemError. The schema validator, the XSLT processor, and the XPath engine cap their own recursion and report a clean error past it. On PyPy, RPython’s stack check can trip on the C recursion first and surface as SystemError. The recursion stays bounded either way, since neither interpreter crashes, but the exception you catch differs.

Introspection is thinner. inspect.signature() builds no signature for a C type on PyPy, even though __text_signature__ is present, so inspect.signature(turbohtml.Minify) raises ValueError there. Functions and methods are unaffected. gc.is_tracked() does not exist on PyPy at all.