Skip to content

Beating lxml at everything — the 1.6.1 accelerator

Cost accounting said the whole remaining gap to lxml was Python object construction. A ~180-line abi3 C accelerator closed it — every operation in the matrix now wins.

This morning the race, per language read like this in Python: six of nine operations won, three honestly lost — plain node-set queries, unions, and traversal, each within striking distance after the 1.4/1.5 batch-accessor sprint, but lost. As of leptris 1.6.1, the matrix has no losses left: every operation beats lxml.

operation leptris 1.6.1 lxml 6.0.2 margin
parse medium (12 KB) 15.2 µs 121 µs 8×
count(//book) 1.7 µs 8.1 µs 4.7×
//book[@id='50'] 3.8 µs 37 µs 10×
//book[price > 50] 9.7 µs 52 µs 5.3×
serialize 18.4 µs 67 µs 3.7×
//book (100 elems) 6.4 µs 12.0 µs 1.9×
union (200 elems) 19.2 µs 27.9 µs 1.5×
traversal (401 elems) 18.0 µs 21.6 µs 1.2×

(macOS arm64, Python 3.10, libleptris 1.3.0 — same harness as the Ruby matrix, run in CI, artifacts canonical.)

#Where the last gap actually was

The engine underneath already beat libxml2 on every XPath benchmark — lxml is libxml2 — so whatever remained had to live in the binding. Cost accounting found it in one place: Python object construction, about 0.10 µs per Element. Every nodeset query, every iteration, paid it per node — and on operations that touch hundreds of nodes, that was the entire deficit.

So allocation moved into C. The whole accelerator is ~180 lines of abi3 (_leptrisaccel): a C heap type that constructs elements at 0.026 µs each — a 4× cut — while the entire API surface stays in Python, its methods attached onto the C type. No API was re-expressed in C; only the one measured hotspot was. And because a build toolchain shouldn't be a runtime requirement, the package carries a tested pure-Python fallback — same class, same behavior, LEPTRIS_PURE=1 forces it — with the wheels shipping the compiled accelerator.

#Wheels, all of them

1.6.1 also closed the packaging story: five cp39-abi3 wheels plus the sdist are on PyPI — macOS arm64 and x86_64, manylinux x86_64 and aarch64, and Windows. The manylinux aarch64 wheel had silently gone missing in 1.6.0 (the emulated build skipped it); it now builds natively on an arm runner, and the accelerator is confirmed inside every wheel. pip install leptris is the whole setup.

#The ledger stays honest

The scoreboard page keeps its ledger of losses — attr-heavy parse still trails pugixml, and Ox still holds the Ruby traversal crown — but the Python rows are gone from it, replaced by a note that they fell, and when. If you want to see exactly how, the harness is in the tree: benchmarks/matrix.py in leptris-py, same fixtures as the Ruby matrix. Run it yourself.

Published at Leptris

By the leptris team

esc
↑ ↓ navigate   ↵ openSearch by Pagefind