Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
f2d0bd5
⚡ perf(urls): move tracker and dot-segment logic to C
gaborbernat Jul 21, 2026
0042225
✨ feat(bench): scope caveats to rows and explain gaps
gaborbernat Jul 21, 2026
f14cd5b
docs(bench): note trafilatura gives up on dates
gaborbernat Jul 21, 2026
fd08512
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
99265f6
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
13983f4
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
0ad9584
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
cb94b1f
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
78c1fab
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
502f962
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
8708600
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
fc65465
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
08172d4
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
1efc5b7
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
cbd50e7
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
21a479d
🐛 fix(stubs): declare the new URL entry points
gaborbernat Jul 21, 2026
3b20882
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
407b10b
⚡ perf(saxparse): dispatch events from C
gaborbernat Jul 21, 2026
00060d7
Merge port/sax-dispatch: C event dispatch for saxparse
gaborbernat Jul 21, 2026
3114ed3
⚡ perf(query): escape identifiers in C
gaborbernat Jul 21, 2026
7d03dac
📝 docs: add the changelog fragment for #692
gaborbernat Jul 21, 2026
021f1d4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 21, 2026
013fabb
perf(validate): cache schema node qnames
gaborbernat Jul 21, 2026
ccaf4e8
perf(links): skip wasted wraps and absolute joins
gaborbernat Jul 21, 2026
e1be808
fix(bench): render xpath labels as inline code
gaborbernat Jul 21, 2026
4db791e
fix(bench): explain lxml's absent XPath 2.0 functions
gaborbernat Jul 21, 2026
0e8aa68
test(bench): cover row-note superscripts in the table
gaborbernat Jul 21, 2026
5f99da3
docs(migration): explain the absent benchmark tables
gaborbernat Jul 21, 2026
80c56c1
build: make the directive test pass the gates
gaborbernat Jul 21, 2026
64d5fa1
perf(css): gate ieq dispatch on first byte
gaborbernat Jul 21, 2026
bdb0d3d
docs(bench): restate the CSS stripper caveat
gaborbernat Jul 21, 2026
d5dde62
perf(encoding): bulk-skip inert prescan bytes
gaborbernat Jul 21, 2026
db898b6
perf(decode): skip the gb18030 astral search
gaborbernat Jul 21, 2026
f14742c
Merge validate agent: compile-time schema qname cache
gaborbernat Jul 21, 2026
d5288c2
Merge links agent: lazy wraps and identity-join skip
gaborbernat Jul 21, 2026
78891d4
perf(linkify): classify URL scheme in the scan
gaborbernat Jul 21, 2026
56cd8aa
👷 ci(codspeed): gate the CSS identifier escape
gaborbernat Jul 21, 2026
510825e
✅ test: cover the C tracker and dot-segment arms
gaborbernat Jul 21, 2026
ba7c3e8
Merge linkify agent: scheme kind from the C scan
gaborbernat Jul 21, 2026
8fcd052
⚡ perf(dates): fuse the meta stage into a C walk
gaborbernat Jul 21, 2026
7462a77
⚡ perf(validate): build the qname cache for XSD only
gaborbernat Jul 21, 2026
fa2a0c0
Merge dates agent: meta stage in C, trafilatura caveat
gaborbernat Jul 21, 2026
a18a60f
⚡ perf(urls): port the cleaner per-item loops to C
gaborbernat Jul 21, 2026
ba6ecbd
Merge urls agent: per-item URL logic in C
gaborbernat Jul 21, 2026
573ef45
✨ feat(bench): show shared measurements in both columns
gaborbernat Jul 21, 2026
bd6c78a
✅ test: cut adverb from resolve-links note
gaborbernat Jul 21, 2026
97f8f84
🎨 style: cut adverbs and a stale comment
gaborbernat Jul 21, 2026
f8b1c0d
Extend #692 changelog to the whole campaign
gaborbernat Jul 21, 2026
18152d8
Merge test-style sweep
gaborbernat Jul 21, 2026
1097744
Merge src-style sweep
gaborbernat Jul 21, 2026
7318e66
Merge changelog: campaign-wide 692 fragment
gaborbernat Jul 21, 2026
5a8951a
fix(bench): narrow migration row cell types
gaborbernat Jul 21, 2026
8712c3d
🎨 style: apply formatter fixes
gaborbernat Jul 21, 2026
696f990
Merge type fix: narrow migration row cells
gaborbernat Jul 21, 2026
53eafce
🐛 fix(tests): pin the scrub oracle's whitespace
gaborbernat Jul 21, 2026
941b97a
✅ test: cover the escape identifier type guard
gaborbernat Jul 21, 2026
746d8e3
🔥 refactor(url): drop dead zero-segment guard
gaborbernat Jul 21, 2026
b16fa81
🔥 refactor(validate): drop unused xml qname arm
gaborbernat Jul 21, 2026
4841ef1
✅ test(validate): cover xmlns-scope growth
gaborbernat Jul 21, 2026
f9d6bfe
Merge coverage agent: xmlns-scope and guard tests
gaborbernat Jul 21, 2026
90ee66e
✅ test: reject the decoy namespace attribute first
gaborbernat Jul 21, 2026
fdca240
✅ test(query): cover escape_identifier non-str
gaborbernat Jul 21, 2026
b1a9773
Merge coverage follow-up: escape guard arms
gaborbernat Jul 21, 2026
16218c0
✅ test: close the branch gate's last four arms
gaborbernat Jul 21, 2026
46ab54c
📊 bench(docs): re-sweep impacted feeds, add escape-id
gaborbernat Jul 21, 2026
d57aca9
📝 docs(bench): reconcile prose with re-swept numbers
gaborbernat Jul 21, 2026
e81a55d
Merge re-sweep: regenerated feeds and re-verified prose
gaborbernat Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 73 additions & 25 deletions docs/_ext/bench_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Final

Expand Down Expand Up @@ -260,12 +261,53 @@ def _mark_entry(ordinal: int) -> nodes.entry:
return entry


def _table_frame(ncols: int) -> tuple[nodes.table, nodes.tgroup]:
"""Build the table and its column group: a wide first column for the row label, then one per metric column."""
table = nodes.table(classes=["bench-table"])
group = nodes.tgroup(cols=ncols)
table += group
group += nodes.colspec(colwidth=2)
for _ in range(ncols - 1):
group += nodes.colspec(colwidth=1)
return table, group


@dataclass(frozen=True)
class _Layout:
"""What every row of one table shares: its columns, its metrics, and the numbered reasons its cells key into."""

parties: list[str]
metrics: list[str]
reasons: dict[str, int]


def _row_caveats(feed: dict[str, Any], taken: int) -> tuple[dict[str, int], dict[int, int]]:
"""Assign each distinct per-row caveat an ordinal after those already taken, and map each row to its ordinal."""
notes: dict[str, int] = {}
marks: dict[int, int] = {}
for index, note in sorted((int(key), value) for key, value in (feed.get("row_notes") or {}).items()):
notes.setdefault(note, taken + len(notes) + 1)
marks[index] = notes[note]
return notes, marks


def _ordered_notes(feed: dict[str, Any], parties: list[str]) -> list[str]:
"""Return the noted parties in the order their columns appear, so ordinals read left to right."""
notes = feed.get("notes") or {}
return [party for party in parties if party in notes]


def _merge_legend(
reasons: dict[str, int], notes: dict[str, int], row_notes: dict[str, int], feed: dict[str, Any]
) -> dict[str, int]:
"""Gather the empty-cell reasons, the column notes and the row caveats into one numbered legend."""
legend = dict(reasons)
for party, ordinal in notes.items():
legend[f"{party} {feed['notes'][party]}"] = ordinal
legend.update(row_notes)
return legend


def _legend(reasons: dict[str, int]) -> nodes.container:
"""Spell each numbered reason out under the table; reason text is literal so a message cannot inject markup."""
legend = nodes.container(classes=["bench-legend"])
Expand All @@ -291,30 +333,34 @@ def run(self) -> list[nodes.Node]:
metrics = feed["metrics"] or ["time"]
parties, rows, spread = _order_columns(feed["parties"], metrics, feed["rows"], feed.get("spread") or [])
ncols = 1 + len(parties) * len(metrics)
table = nodes.table(classes=["bench-table"])
group = nodes.tgroup(cols=ncols)
table += group
group += nodes.colspec(colwidth=2)
for _ in range(ncols - 1):
group += nodes.colspec(colwidth=1)
reasons = _reason_map(rows)
table, group = _table_frame(ncols)
layout = _Layout(parties, metrics, _reason_map(rows))
# notes number on from the empty-cell reasons so one legend carries both without colliding ordinals
notes = {party: len(reasons) + offset for offset, party in enumerate(_ordered_notes(feed, parties), start=1)}
notes = {
party: len(layout.reasons) + offset for offset, party in enumerate(_ordered_notes(feed, parties), start=1)
}
row_notes, row_marks = _row_caveats(feed, len(layout.reasons) + len(notes))
group += self._head(feed["label"], parties, feed["metrics"], notes)
group += self._body(rows, spread, layout, row_marks, ncols)
legend = _merge_legend(layout.reasons, notes, row_notes, feed)
return [table] if not legend else [table, _legend(legend)]

def _body(
self,
rows: list[list[Any]],
spread: list[Any],
layout: _Layout,
row_marks: dict[int, int],
ncols: int,
) -> nodes.tbody:
body = nodes.tbody()
group += body
for row_index, cells in enumerate(rows):
for index, cells in enumerate(rows):
if len(cells) != ncols:
msg = f"bench-table row has {len(cells)} cells, expected {ncols}: {cells!r}"
raise self.error(msg)
noise = spread[row_index] if row_index < len(spread) else None
body += self._body_row(cells, parties, metrics, reasons, noise)
legend = dict(reasons)
for party, ordinal in notes.items():
legend[f"{party} {feed['notes'][party]}"] = ordinal
if not legend:
return [table]
return [table, _legend(legend)]
noise = spread[index] if index < len(spread) else None
body += self._body_row(cells, layout, noise, row_marks.get(index))
return body

def _head(self, label: str, parties: list[str], metrics: list[str], notes: dict[str, int]) -> nodes.thead:
head = nodes.thead()
Expand Down Expand Up @@ -352,15 +398,17 @@ def _head(self, label: str, parties: list[str], metrics: list[str], notes: dict[
return head

def _body_row(
self,
cells: list[Any],
parties: list[str],
metrics: list[str],
reasons: dict[str, int],
spread: list[Any] | None = None,
self, cells: list[Any], layout: _Layout, spread: list[Any] | None = None, caveat: int | None = None
) -> nodes.row:
parties, metrics, reasons = layout.parties, layout.metrics, layout.reasons
row = nodes.row()
row += self._entry(str(cells[0]))
label = self._entry(str(cells[0]))
# a caveat that belongs to one operation marks that row, not the library's whole column
if caveat is not None:
superscript = nodes.superscript()
superscript += nodes.Text(str(caveat))
label.children[0] += superscript
row += label
rendered: dict[int, tuple[str, str | None]] = {}
marks: dict[int, int] = {}
for metric_index, metric in enumerate(metrics):
Expand Down
20 changes: 20 additions & 0 deletions docs/changelog/692.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Move the read path's per-item loops into C. :func:`turbohtml.saxparse.sax_parse` built a tuple, a typed record, and an
isinstance chain for every event; the tokenizer now binds the six handler methods once and drives them off the tree
walk, for 43.7% fewer instructions with instruction-cache misses down 71% on the WHATWG specification. URL cleaning
(:func:`turbohtml.extract.clean_url`, :func:`~turbohtml.extract.normalize_url`,
:func:`~turbohtml.extract.extract_links`), the :func:`~turbohtml.extract.dates` ``<meta>`` stage,
:func:`turbohtml.query.escape_identifier`, and :func:`turbohtml.clean.linkify`'s scheme classification each moved their
loops to C as well, for 3.5% to 56.8% fewer instructions on those operations. Differential runs against the retired
Python across 1,484,640, 809,481, 360,282, 302,762, 38,590, and 4,828 inputs found one divergence: the URL resolver
matches ``%2e`` in either case as the `WHATWG URL path state <https://url.spec.whatwg.org/#path-state>`_ specifies,
which no public call reaches because percent-encoding uppercases escape hex first.
:func:`~turbohtml.saxparse.iter_events` keeps its typed records, since those are its public product.

Other hot paths keep their Python entry and shed work. XSD validation resolves each schema element's qname once at
compile time into a binary-searched table for 17.2% fewer instructions, built for XSD only after the eager cache slowed
RELAX NG compilation by 8.15%. The link walk mints element wrappers on first use (14.8% fewer) and skips the URL join
that already returns an absolute reference (67.8% fewer); the ``<meta>`` encoding prescan jumps inert bytes with
``memchr`` (8.67% fewer on ASCII); the gb18030 decoder maps astral pointers by arithmetic (13.4% fewer); and CSS
property dispatch gates on the first byte (9.3% fewer on ``bootstrap.css``). The migration and benchmark tables also
stopped over-claiming: a caveat marks only the rows of the operation it describes, a shared measurement shows in both
columns, XPath labels render as inline code, and the CSS-stripper note gives the measured 1-3% size difference.
Loading
Loading