LEPTRIS

leptris 1.2.0 — the race, per language

One C core, four language surfaces, three races. What 1.2.0 shipped, what the benchmarks say in C, Ruby, and Python — and where we still lose.

Three hares chase each other in a circle, sharing three ears — each hare looks fully equipped, yet nothing in the pattern is redundant. That image, from the Dunhuang cave ceilings to parish churches across Europe, is the design brief of this library in seven letters: speed in the chase, containment in the circle. Today, with libleptris and the leptris gem at v1.2.0, it’s a good moment to show what that buys you in each language — and where it doesn’t.

What 1.2.0 shipped

The headline is namespace-bound XPath: leptris_xpath_eval_ns with a LeptrisXPathNsSet resolves prefixed name tests by namespace, so an element matches when it carries the bound namespace through any prefix or the default namespace. Alongside it: a fix for //t:*, which was namespace-blind and matched everything; serializer round-trip fixes (CDATA sections containing ]]> now split the way libxml2 does, and names emit prefix:name so bindings survive a reparse); and a use-after-free on programmatic roots found through moxml’s adapter contract — the kind of bug you only meet when someone actually embeds you.

The Ruby gem, in lockstep, gained Leptris::XML::Document.create — an empty document with its own pool, no sentinel parse — plus Document#root= and Attr#to_xml. And the Python binding moved into its own home, leptris-py, publishing to PyPI as leptris (pip install leptris) — and then, in its first real release (1.3.x), it adopted an lxml-shaped API: the Ruby binding mirrors Nokogiri, so the Python binding mirrors lxml; read-path code ports with an import change. With the Rust crate that shipped in 1.1.2, that makes four bindings over one ABI, every one of them gated in CI against drift from the C surface.

The race, per language

Different audiences race against different libraries, so we publish the numbers that way — the full detail is on the benchmarks page, and every number reproduces from a harness in the tree.

C and C++ — the embedder’s race, against libxml2 (the de facto standard under Chrome, Firefox, and lxml) and pugixml (the C++ speed king): 2.5–3.6× faster SAX than libxml2, 1.4–3.6× on parse and DOM reads, ahead on all ten XPath benchmarks, and since 1.1.2, union deduplication is ~140× faster on large merged sets.

Ruby — the migrant’s race, against Nokogiri, Ox, and Oga. The current matrix: leptris wins 8 of 9 operations — 4.5× on small parses, 16× on medium, 18× on ID predicates, 3× on serialize — and now wins traversal against Nokogiri too. Ox keeps the traversal crown; its C-level walk is unbeaten.

Python — the incumbent’s race, against lxml, ElementTree, and minidom. The binding ships its own matrix (the Python twin of the Ruby harness) and runs it in CI — and the 1.4/1.5 line was aimed squarely at its losses: batch nodeset accessors halved plain queries, and engine-side subtree walks cut traversal 299 → 51 µs. Current numbers (leptris 1.5.0, libleptris 1.3.0, Python 3.10, arm64, lxml 6.0):

operation leptris lxml ElementTree winner
parse small (431 B) 2.1 µs 5.8 µs 7.9 µs leptris
parse medium (12 KB) 15.2 µs 127.0 µs 161.6 µs leptris
count(//book) 1.8 µs 8.0 µs leptris
//book[@id='50'] 3.9 µs 37.3 µs 13.9 µs leptris
serialize 23.8 µs 62.0 µs 627.2 µs leptris
//book (100 nodes) 15.3 µs 12.0 µs 8.2 µs ElementTree
//author | //title 31.2 µs 28.0 µs lxml
traverse 51.4 µs 20.0 µs 3.4 µs ElementTree

Six of nine, including a 26× serialize gap over the stdlib — and the three losses are now within striking distance, each of them cut by half or better since the first measurement. (Update: as of leptris 1.6.1, those three fell too — beating lxml at everything.)

The ledger of losses

A speed page that only lists wins is marketing. Where leptris loses today, in public: attr-heavy parse is ~1.5× behind pugixml (the parse wall is at a compiler-global optimum — eleven failed experiments say so); raw child append pays 1.3× for the O(1) duplicate-rejecting attribute index; in Python, plain nodeset queries materialize a wrapper per node (//book loses to both lxml and ElementTree — but the batch accessors already cut that gap to 1.3× vs lxml) and traversal is per-node FFI no longer: engine-side subtree walks took it from 15× behind lxml to 2.6×, past minidom. Each loss has a reason and an owner; the tombstones of measured dead ends (32-byte split-stream attributes, two-pass SIMD) are in the perf ledger for anyone tempted to retry them.

Fast as hares. Contained as a circle. And when the hares stumble, you’ll read about it here.


← More from the blog ·The benchmarks ·The changelog