Skip to content

✨ feat(pypy): run the C core on PyPy, publish 3.10 + 3.11 wheels#623

Merged
gaborbernat merged 10 commits into
tox-dev:mainfrom
gaborbernat:feat/pypy-support
Jul 9, 2026
Merged

✨ feat(pypy): run the C core on PyPy, publish 3.10 + 3.11 wheels#623
gaborbernat merged 10 commits into
tox-dev:mainfrom
gaborbernat:feat/pypy-support

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Jul 9, 2026

Copy link
Copy Markdown
Member

turbohtml ships no pure-Python fallback, so PyPy users get nothing today. The C core needs little of what makes most C extensions unportable: it names no private _Py* API, builds its types with PyType_FromModuleAndSpec, keeps its state in module state, and uses multi-phase init. Of 66 translation units, 65 compiled against cpyext on the first try. 🎯

The 66th exposed three cpyext contracts that differ from CPython's. None of them produces a compiler error, a CPython behavior change, or a byte of different CPython machine code. PyPy corrupts data or dies. PyUnicode_CopyCharacters does not exist. PyUnicode_FromFormat returns a string cpyext left in the legacy wstr representation, so PyUnicode_KIND, PyUnicode_DATA and PyUnicode_GET_LENGTH are undefined on it; under NDEBUG their assertions compile away and GET_LENGTH answers one past the code point count. That is how sanitize("<script>x</script>") came to weave NUL bytes into its escaped tags. A 2-byte buffer cannot reach cpyext. cpyext materializes one by decoding it as UTF-16, which eats a leading U+FEFF as a byte-order mark, byte-swaps the rest of the string after a leading U+FFFE, folds a surrogate pair into the single code point it encodes, and aborts the interpreter on a lone surrogate through the strict error handler. A CPython str carries a lone surrogate, and both HTML input and the WHATWG tokenizer have to preserve one. escape("\ud800<") took the process down with SIGABRT.

src/turbohtml/_c/core/pycompat.h holds the whole adaptation, and every name in it expands to the CPython spelling on CPython. That is what keeps the CPython build unchanged. 64 of the 65 translation units compile to byte-identical machine code against main at -O3, so the PGO and LTO layouts the release wheels depend on cannot shift. The odd one out is dom/node.c, which carries the bug fix below. On PyPy the core builds any result too wide for Latin-1 at 4-byte kind, where cpyext is exact, while CPython keeps the narrowest kind its str equality demands. escape() takes a separate 2-byte path there, because its block copies assume the output width matches the input's.

Two more gaps needed handling. cpyext hands sq_item a raw negative index instead of adding sq_length first, so node[-1] answered the first child. While chasing that I found a CPython bug, fixed in its own commit: PySequence_GetItem adds sq_length but never rechecks, so node[-len - 1] still arrived negative and node_item returned first_child where list raises IndexError.

cpyext also ignored Py_TPFLAGS_DISALLOW_INSTANTIATION until PyPy 7.3.21, which let Document() and its thirteen siblings construct without a tree and then segfault on first use, so pure Python code could crash the interpreter. 7.3.21 honors the flag and prints a debug line to stdout for every type that carries it. Both cost nothing to avoid, because cpyext seals a type through an explicit tp_new as well, the branch the flag would otherwise shadow. Setting that slot instead of the flag seals every PyPy with CPython's own error message and leaves 7.3.21 quiet, since its debug line fires only for a type carrying the flag. Element("div") and Text("hi") still build, because a subtype declaring its own tp_new overrides the inherited one. That removes the need for a version floor, and PyPy 3.10 joins the wheel matrix.

I rejected the approach the closest analogues take. MarkupSafe and msgpack detect PyPy at build time and ship a pure-Python fallback instead, because cpyext turns their PyUnicode_KIND fast path into a decode-and-allocate. Cython disables the same family of fast paths on PyPy with CYTHON_USE_UNICODE_INTERNALS=0. That trade does not exist here, since no Python implementation of this core exists to fall back to. HPy offers no kind-dispatched buffer access and has not released since 2023.

docs/explanation/interpreters.rst states what PyPy costs, from a table the bench harness generates rather than from figures typed into prose. tox -e bench -- interpreters builds the working tree under CPython 3.14 and PyPy 3.11, runs the same seven operations over the same vendored corpora in each, and writes docs/explanation/bench/interpreters.json for the existing bench-table directive to render. Parsing a whole document stays within a small factor. Anything that hands Python an object per node runs an order of magnitude slower and past it: walking every descendant, collecting visible text, serializing a tree. The JIT recovers none of it, because the time goes to cpyext rather than to bytecode. turbohtml is a poor reason to choose PyPy, and the page says so.

Reviewers should look hardest at core/pycompat.h and at the codegen-neutrality claim. I checked it by compiling every translation unit from main and from this branch at -O3 -g0 -DNDEBUG and comparing normalized objdump -d output. cibuildwheel's pypy and pypy-eol groups together select pp310 and pp311. The PyPy wheels skip PGO, since training a profile by driving turbohtml from PyPy measures cpyext instead of the C hot paths. On PyPy 7.3.19, 7.3.21 and 7.3.23 the library produces byte-identical output to CPython 3.13 across the operations I compared, down to lone surrogates, byte-order marks and surrogate pairs, and every sealed type refuses construction with the same message on all four interpreters. ⚙️

Three upstream cpyext defects fell out of this work. Each reproduces on the current 7.3.23, violates a documented CPython contract, and is filed with a standalone reproducer: pypy#5524 for the non-canonical PyUnicode_FromFormat result, pypy#5525 for the UTF-16 materialization of 2-byte buffers, and pypy#5526 for the unadjusted negative sq_item index. The sealing flag and the 7.3.21 debug line were already reported as pypy#5318 and pypy#5388, and both are fixed upstream. The tp_new seal here works whichever release a user runs. Every one of the five is linked from the code that works around it, in core/pycompat.h, dom/node.c and docs/explanation/interpreters.rst.

node[-len - 1] answered the first child instead of raising. CPython's
PySequence_GetItem adds sq_length to a negative subscript before handing it to
sq_item, but it never rechecks the result, so a subscript further back than the
child count still arrives negative. The walk then took zero steps and returned
first_child.

Reject a still-negative index up front, matching what list does for the same
subscript. The check costs one predictable compare on a path that already walks
a linked list.
The extension already used nothing but the public, version-portable C API, so
PyPy builds it almost unchanged. Three cpyext contracts differ from CPython's,
and each one is silent: the CPython tests pass, the CPython machine code is
untouched, and PyPy corrupts data or dies.

PyUnicode_CopyCharacters does not exist in cpyext. PyUnicode_FromFormat returns
a string cpyext left in the legacy wstr representation, so PyUnicode_KIND,
PyUnicode_DATA and PyUnicode_GET_LENGTH are undefined on it; with NDEBUG their
assertions are gone and GET_LENGTH answers one past the code point count, which
is how sanitize() came to weave NUL bytes into escaped tags. Worst, cpyext
materializes a 2-byte buffer by decoding it as UTF-16: a leading U+FEFF is eaten
as a byte-order mark, a leading U+FFFE byte-swaps the rest, a surrogate pair
folds into the code point it encodes, and a lone surrogate aborts the
interpreter through the strict error handler. escape("\ud800<") took the process
down with SIGABRT.

core/pycompat.h holds the whole adaptation. Every name in it expands to the
CPython spelling on CPython, so 64 of the 65 translation units compile to
byte-identical machine code against main and the PGO and LTO layouts cannot
shift; the odd one out carries an unrelated bounds check. Results too wide for
Latin-1 are built at 4-byte kind on PyPy, where cpyext is exact, while CPython
keeps the narrowest kind its str equality demands. escape() takes a separate
2-byte path there because its block copies assume the output width matches the
input's.

Two more gaps needed handling. cpyext hands sq_item a raw negative index rather
than adding sq_length first, so node[-1] answered the first child. And cpyext
ignored Py_TPFLAGS_DISALLOW_INSTANTIATION until PyPy 7.3.21, which let
Document() construct without a tree and segfault on first use, while 7.3.21
itself prints a debug line to stdout for every type carrying the flag. A wheel
tag cannot express a PyPy floor, so the module refuses to import below 7.3.22.

Rejected the alternative the closest analogues take. MarkupSafe and msgpack
detect PyPy at build time and ship a pure-Python fallback instead, because
cpyext turns their PyUnicode_KIND fast path into a decode-and-allocate. That
trade does not exist here: there is no Python implementation of this core to
fall back to, and a parse crosses the boundary once for the whole document, so
it costs 1.1x on PyPy against 5x for a per-node DOM walk. HPy exposes no
kind-dispatched buffer access at all and has not released since 2023.
Enabling cibuildwheel's "pypy" group selects pp311 alone, which is the whole
supported set: it classes PyPy 3.10 as end of life, and 3.10 never shipped a
cpyext honoring Py_TPFLAGS_DISALLOW_INSTANTIATION, so the module refuses to
import there anyway.

The PyPy wheels skip PGO. The Linux hook trains the profile by driving turbohtml
from the interpreter being built for, and under PyPy that run is dominated by
cpyext boundary crossings rather than the C hot paths, so -Db_pgo=use would lay
the object out for the wrong workload. LTO stays, being interpreter-independent.
Only the Linux builds need the override, since matching pp*-macosx or pp*-win
would clobber setup-args those platforms set for their own toolchains.

The PyPy job stands outside the test matrix. uv manages no PyPy newer than
7.3.21, one release below the floor, so it takes its interpreter from
setup-python, and it runs no coverage gate: gcovr reaches cpyext only through
lxml, which publishes no PyPy wheels, and there is no gcov for a cpyext build to
feed it. Both gates keep running on the CPython matrix.
Readers choosing an interpreter need to know that turbohtml is a poor reason to
pick PyPy: parse() costs 1.1x there while walking a tree node by node costs 5x,
and the JIT cannot recover the difference because the time goes to cpyext rather
than to bytecode. The page carries the measurements, the three behaviors that do
not carry over (uncollected cycles through a C object, SystemError on deep
recursion, thinner introspection), and why the version floor exists.
@gaborbernat gaborbernat added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request labels Jul 9, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 91 untouched benchmarks
⏩ 18 skipped benchmarks1


Comparing gaborbernat:feat/pypy-support (a152763) with main (c80b7bc)

Open in CodSpeed

Footnotes

  1. 18 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

cpyext ignores Py_TPFLAGS_DISALLOW_INSTANTIATION before PyPy 7.3.21, so
Document() and its thirteen siblings constructed with no tree attached and
segfaulted on first use. PyPy 3.10 reached end of life without ever honoring the
flag, which is why the previous commit shipped no wheel for it and refused the
import below 7.3.22.

cpyext also seals a type through an explicit tp_new, the branch the flag would
otherwise shadow. Setting that slot instead of the flag seals every supported
PyPy with CPython's own error message, and it silences 7.3.21 for free, since
the debug line that release prints fires only for a type carrying the flag. The
version guard existed to refuse interpreters this makes work, so it goes, and
PyPy 3.10 joins the wheel matrix behind cibuildwheel's pypy-eol group.

Both macros stay identities on CPython: TH_SEALED expands to the flag and
TH_SEALED_NEW to nothing, so the 64 translation units that already matched main
byte for byte still do. A subtype declaring its own tp_new overrides the
inherited one, so Element, Text, Comment, CData, and ProcessingInstruction build
as before, while Document, Doctype, and ShadowRoot inherit the seal.

Dropping the floor lets uv manage both interpreters again, so the PyPy job
rejoins the ordinary tox pattern rather than borrowing setup-python.
@gaborbernat gaborbernat changed the title ✨ feat(pypy): run the C core on PyPy, publish 3.11 wheels ✨ feat(pypy): run the C core on PyPy, publish 3.10 + 3.11 wheels Jul 9, 2026
TH_SEALED_NEW expanded to nothing on CPython, so clang-format glued it to the
array terminator and every sealed slot table ended in the unreadable
`TH_SEALED_NEW{0, NULL},`. Folding the terminator into the macro gives
TH_SEALED_END, one token per line, and the CPython expansion is the same `{0,
NULL},` the arrays carried before.

The portability guard only covered the three wrapped calls. A new sealed type
that reached for Py_TPFLAGS_DISALLOW_INSTANTIATION would compile, pass on
CPython, and segfault on a PyPy older than 7.3.21, which is the bug the wrapper
exists to prevent. Guard the flag with the same rule.

The two PyPy tox environments were a copy of each other because tox cannot
resolve a `ref` into a dotted environment name. Giving the shared body a plain
name and splicing it in removes the duplicate.

Each cpyext defect now carries its upstream report, so the next reader can see
whether it still stands rather than rediscovering it: 5318 and 5388 for the
sealing flag, 5524 for the non-canonical PyUnicode_FromFormat result, 5525 for
the UTF-16 materialization of 2-byte buffers, and 5526 for the unadjusted
negative sq_item index.
@gaborbernat gaborbernat marked this pull request as ready for review July 9, 2026 17:14
Every bench command died before it measured anything. corpus.py promises in its
own docstring to import under the standard library alone, so that it loads in
any worker venv, then imports httpfetch at module scope, which imports httpx2. A
worker venv carries pyperf, turbohtml, and at most one competitor, so the import
failed and took the run with it.

Moving the import to the cache miss that needs it is not enough on its own: the
first run on a fresh clone still reaches the network from a worker, and still
finds no client there. The orchestrator has one, so it fills the download cache
before it provisions the first venv, and a worker only ever reads a hit.
The interpreter comparison was a table typed by hand from a synthetic document,
and it was wrong. Measured against the real corpora it understated PyPy badly:
serializing a tree is 10x to 13x, not 3.5x, and collecting a document's visible
text is 19x to 24x, not 2.7x. Figures written into prose cannot survive the next
change to the C core, and nobody would have caught these drifting.

The bench harness already runs one turbohtml-only venv per target and writes the
feeds its bench-table directive renders, so the comparison slices that matrix by
interpreter rather than by competitor: the same turbohtml, the same vendored
corpora, one column each for CPython 3.14 and PyPy 3.11. Regenerate it with
`tox -e bench -- interpreters`.

Measuring rather than guessing also corrected the explanation. Reading a str C
has already seen costs nothing on PyPy, because cpyext caches the buffer on the
shell, so the scanners run at CPython's speed over it. What costs is handing a
new string back, since cpyext transcodes it into PyPy's UTF-8 form, and handing
back a wrapper per node. That is why serialize and text trail by an order of
magnitude while doing no per-node work in Python at all, which is not what the
page used to claim.

The page also served two audiences at once. What cpyext costs a user stays in
explanation; the compat header, the sealing mechanism, the codegen-neutrality
invariant, and the upstream defects move to development/cpyext, where a
maintainer looks for them.
@gaborbernat gaborbernat enabled auto-merge (squash) July 9, 2026 18:48
The bench-table directive suppressed the (Nx) ratio for any party whose label
contained "turbohtml", which is how it recognized the baseline. That reads the
label to answer a question about position, and it silently blanks the ratio when
a feed compares turbohtml against itself: the interpreter table showed PyPy's
raw times with nothing to read them against, and node-paths hid what xpath_path
costs over css_path.

_order_columns already guarantees the baseline sits first, so key on that. The
value every ratio divides by is unchanged, since it was already taken from the
leading turbohtml column of the reordered list.

Across all 116 committed feeds only those two change, and both only gain a
column: no ratio anywhere is removed or recomputed.
@gaborbernat gaborbernat merged commit 68b3e2e into tox-dev:main Jul 9, 2026
51 checks passed
gaborbernat added a commit to gaborbernat/turbohtml that referenced this pull request Jul 9, 2026
tox-dev#623 landed the same fix from the other side: corpus._cached imports the
HTTP client at the miss, and corpus.prefetch fills every cached file from
the orchestrator before it dispatches, which is before the profile-guided
build trains. So _warm_corpus and its lazy import were two mechanisms for
one job, and prefetch is the better one -- it primes the cache without
building a single case, so it neither trips over sax having no INPUTS entry
nor over a corpus submodule that is not checked out.

Verified against a cold cache: prefetch alone fetches what pgo_train reads.
@gaborbernat gaborbernat deleted the feat/pypy-support branch July 10, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant