Thursday, 16 July 2026

Optimizing Django-hypergen

Untitled Document.md

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