Making hypergen’s render path fast
Hypergen renders HTML by walking a tree of Python objects, backed by a
pyrsistent (immutable) context. That’s convenient but slow. Three commits,
in order.
1. Cache the pyrsistent lookups (~4.2x)
contextlist.data re-derived the active target_id from the pmap on
every append/extend/len/getitem, and base_element.__init__ re-read
c.hypergen from scratch 6+ times per element. Neither needed to —
nothing changes context mid-call.
# before
@property
def data(self):
return self.contexts[self._get_context_value()]
# after — cache keyed by context-snapshot identity (O(1) check)
@property
def data(self):
token = context.ctx
if token is not self._cache_ctx_token:
self._cache_ctx_token = token
self._cache_list = self.contexts[self._get_context_value()]
return self._cache_list
# before — c.hypergen re-walked on every access
def __init__(self, *children, **attrs):
with ExitStack() as stack:
...
for plugin in c.hypergen.plugins: ...
c.hypergen["ids"].add(id_)
self.i = len(c.hypergen.into)
c.hypergen.into.extend(self.start())
# after — fetch once, reuse
def __init__(self, *children, **attrs):
hg = c.hypergen
with ExitStack() as stack:
...
for plugin in hg.plugins: ...
hg["ids"].add(id_)
into = hg.into
self.i = len(into)
into.extend(self.start())
2000 rows / 100 cards: 925ms → 221ms.
2. Trim per-element dispatch (~1.1x more)
A dead assert, and two hot functions checked the rare case before the
common one.
# before — assert re-proves what the fetch above already proved
hg = c.hypergen
...
assert "hypergen" in c, "Element called outside hypergen context."
# after — dropped; a successful c.hypergen fetch already guarantees it
hg = c.hypergen
...
# before — str is the common case, checked last
def fmt(html):
for item in html:
if issubclass(type(item), base_element):
yield item.as_string()
elif callable(item):
yield item()
# after — str checked first
def fmt(html):
for item in html:
item_type = type(item)
if item_type is str:
yield item
elif issubclass(item_type, base_element):
yield item.as_string()
elif callable(item):
yield item()
# before
def make_string(s):
if s is None:
return ""
else:
return force_str(s)
# after — skip the cross-module call for the common case
def make_string(s):
if type(s) is str:
return s
elif s is None:
return ""
else:
return force_str(s)
Tried replacing html.escape()'s chained .replace() calls with a single
.translate() pass. Measured it as a regression: translate() always
copies; replace() on a no-op returns the input unchanged. Reverted.
221ms → 194ms (4.8x cumulative).
3. Fix UserList iteration (~19%)
UserList is a class from Python’s standard library (collections.UserList) — a wrapper around a plain list that’s designed to be subclassed. Instead of subclassing list directly, you subclass UserList, and it stores the actual data in a .data attribute (a real list) while UserList itself provides all the list-like methods (append, __getitem__, __len__, etc.) by delegating to self.data.
Why use it instead of subclassing list directly? Because UserList makes it trivial to override behavior — e.g. hypergen’s contextlist (in src/hypergen/context.py) overrides .data itself to be a property rather than a plain attribute, so that “the list” resolves to a different underlying list depending on the current pyrsistent context (the active target_id). You can’t do that cleanly with a raw list subclass, since list’s storage isn’t a separate overridable attribute.
UserList has no __iter__, so it fell through to collections.abc.Sequence’s mixin, which iterates via repeated __getitem__ — re-entering the .data
property (and its identity check) once per item.
# before — no __iter__ defined, falls back to Sequence mixin's
# per-index __getitem__ loop, re-entering .data every time
# after
def __iter__(self):
return iter(self.data)
Free 19% on typical page sizes.
Takeaway
Cache before you rewrite. All three steps got 4.8x+ cumulative out of the
existing architecture with no behavior change and no new dependencies —
verified with the existing test suite plus a byte-for-byte diff of
rendered output before/after.
No comments:
Post a Comment