✨ feat(pypy): run the C core on PyPy, publish 3.10 + 3.11 wheels#623
Merged
Conversation
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.
Merging this PR will not alter performance
Comparing Footnotes
|
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.
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.
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.
258077c to
f798c97
Compare
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withPyType_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_CopyCharactersdoes not exist.PyUnicode_FromFormatreturns a string cpyext left in the legacy wstr representation, soPyUnicode_KIND,PyUnicode_DATAandPyUnicode_GET_LENGTHare undefined on it; underNDEBUGtheir assertions compile away andGET_LENGTHanswers one past the code point count. That is howsanitize("<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 CPythonstrcarries 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.hholds 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 againstmainat-O3, so the PGO and LTO layouts the release wheels depend on cannot shift. The odd one out isdom/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 itsstrequality 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_itema raw negative index instead of addingsq_lengthfirst, sonode[-1]answered the first child. While chasing that I found a CPython bug, fixed in its own commit:PySequence_GetItemaddssq_lengthbut never rechecks, sonode[-len - 1]still arrived negative andnode_itemreturnedfirst_childwherelistraisesIndexError.cpyext also ignored
Py_TPFLAGS_DISALLOW_INSTANTIATIONuntil PyPy 7.3.21, which letDocument()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 explicittp_newas 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")andText("hi")still build, because a subtype declaring its owntp_newoverrides 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_KINDfast path into a decode-and-allocate. Cython disables the same family of fast paths on PyPy withCYTHON_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.rststates what PyPy costs, from a table the bench harness generates rather than from figures typed into prose.tox -e bench -- interpretersbuilds the working tree under CPython 3.14 and PyPy 3.11, runs the same seven operations over the same vendored corpora in each, and writesdocs/explanation/bench/interpreters.jsonfor the existingbench-tabledirective 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.hand at the codegen-neutrality claim. I checked it by compiling every translation unit frommainand from this branch at-O3 -g0 -DNDEBUGand comparing normalizedobjdump -doutput. cibuildwheel'spypyandpypy-eolgroups together selectpp310andpp311. 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_FromFormatresult, pypy#5525 for the UTF-16 materialization of 2-byte buffers, and pypy#5526 for the unadjusted negativesq_itemindex. The sealing flag and the 7.3.21 debug line were already reported as pypy#5318 and pypy#5388, and both are fixed upstream. Thetp_newseal here works whichever release a user runs. Every one of the five is linked from the code that works around it, incore/pycompat.h,dom/node.canddocs/explanation/interpreters.rst.