Crinja
Inherits Crinja::Resolver < Reference < Object
=== 5+6. Loop-invariant fragment caching & iteration scope reuse =========
Both patches key off the same static property of a for-loop, which we call
a closed body: every identifier the body (and the optional if
condition) reads is bound by the loop itself (the item variables or
loop), the body contains no nested tags other than if/elif/else
with equally closed conditions, and no calls/filters/tests.
Such a body can neither observe per-page state nor capture the iteration
context (no {% block scoped %}, no macro definitions, no callables), so:
-
Fragment cache — the loop's entire output is a pure function of (loop AST node, collection contents, autoescape). Listing templates like
{% for p in site.pages %}<li>{{ p.title }}</li>{% endfor %}re-render byte-identical output once per page; on the harsh-5000 corpus that loop alone accounts for most of the render phase. Cache the flattened output per (tag node, collection object, autoescape) on the environment. Correctness leans on two facts: hwaro converts site/section collections to Crinja arrays once per build (stable object identity ⇒ stable contents — the site is frozen during render), and each render worker owns its env exclusively (no concurrent mutation; the env — and so the cache — lives for a single render pass). Entries pin their tag node and collection array, so an object_id can never be recycled into a false hit. The cache is size-capped: loops over per-page collections ({% for t in p.tags %}) insert entries that never hit again, so past the cap we render normally instead of inserting. -
Scope reuse — upstream run_loop allocates a fresh Context (scope Hash, macros Hash, four CallStacks) plus a bindings Hash per iteration. For a closed body nothing can retain the iteration context beyond its iteration, so one Context reused across iterations (item vars simply overwritten by unpack) is observationally identical. Non-closed bodies keep the allocating path.
Byte-compat verified like patches 1-4: diff -r of full output trees on harsh-5000, public-5000 and docs, plus a loop-shape torture site.
Constants
Slices of one list, memoized per environment: the same pages[:10] on
every page returns the SAME array, so a loop over it hits the for-loop
fragment cache (keyed by collection identity, crinja_render_perf.cr)
instead of filling it with one dead entry per page. Sound for the same
reason that cache is: a list's contents never change under a fixed
identity during a render pass (templates cannot mutate lists, and hwaro
builds its collections once per build). Entries pin their source list so
its object_id cannot be recycled into a false hit; past the cap, slices
are simply built fresh.
Class methods
First index and length of a step-1 slice.
Python's slice index walk (PySlice_AdjustIndices): yields each selected
index of a sequence of size items, in order.
Instance methods
Fragment cache: {tag node id, collection array id, autoescape} → flattened output. Lazy so envs that never render a cacheable loop pay nothing.
Templates entered through {% include %}, innermost last.
This lives on the environment because that is the object that stays
identical across a nested include: Tag::Include renders through
env.get_template(...), and TemplateCache::InMemory keys its entries
by env, so the template rendered inside an include always carries this
very instance. hwaro hands every render worker its own environment
(Render#create_fresh_crinja_env), so this array is never shared
between fibers.