10 Performance Model
tests/stress.typ (400 nodes, 760 edges) is the reproducible fixture behind every claim in this page. Benchmark on one machine and one Typst version at a time — a single timing is not a portable guarantee:
/usr/bin/time -p typst compile --root . tests/stress.typ /tmp/typograph-stress.pdftests/shape-stress.typ isolates repeated outline preparation, and tests/curve-stress.typ isolates clipped cubic paths with repeated labels, exercising curve/outline intersections and the shared label-distance table. bash tests/run.sh compiles all three as regression fixtures on every run, so a real performance regression fails CI the same way a correctness regression would.
10.1 The renderer’s passes correspond to real dependencies
- classify items and collect node endpoints;
- deduplicate nodes and prepare each one’s label/outline — edges and ports both need that silhouette before they can be planned;
- resolve paths and styles, accumulating complete bounds;
- emit edges/content first, then nodes on top, once the origin is known.
Combining the node and edge passes would either repeat node geometry work or make forward references and ports impossible (an edge can reference a node defined later in the same diagram body). Within those four necessarily sequential passes, the implementation avoids redundant work in a few specific, deliberate ways:
- One prepared outline drives everything. A node’s outline is computed once and reused for drawing, bounds, ports, and wire clipping — not recomputed per consumer, and not allowed to drift between them.
- Skip work whose precondition doesn’t hold. Unlabeled nodes skip label measurement. Endpoints at plain coordinates skip silhouette lookup entirely. Deferred-endpoint resolution is only entered when a deferred endpoint actually exists, and each edge is handed only the identity lookup bucket it needs — never the whole diagram-wide node index.
- Prefer analytic geometry over sampling when the shape allows it. Straight wires against polygon outlines use exact segment/edge intersection. Explicitly directed anchors (
from:/to:) and common single-segment straight clipping need no curve sampling at all. Other curved endpoints fall back to a bounded outline-intersection search with a physical-accuracy refinement pass, splitting the original Bézier directly rather than re-deriving it — and a distance-based label’s position and tangent samples share that same distance table instead of rebuilding it. - Compute once, apply per instance. The regular-polygon factory precomputes its unit points and trigonometry once at factory time, not per node;
group()computes itssin/cosonce per call and reuses them across every item in the fragment, rather than recomputing per point.
10.2 A concrete tradeoff: diagram.typ’s bounds accumulation
Bounds tracking (the running min/max x and y that determine the diagram’s final size) is implemented as four mutable scalar variables threaded through repeated inline if seen-point { .. } else { .. } blocks, rather than a small helper function that takes a point and updates a bounds value. This looks, at a glance, like duplicated code begging to be extracted — and it was deliberately left that way in review, for two compounding reasons:
- Typst closures can’t mutate captured outer-scope variables. A helper called once per point, per node, in the hottest loops in the renderer, cannot update
min-x/max-x/min-y/max-yin its caller’s scope by side effect — there is no&mutequivalent. The only closure-based alternative is a helper that returns an updated bounds value, which forces either a dictionary allocation per call or a fixed-size return tuple manually destructured at every call site — the latter reduces to exactly the inline code already there, minus the readability of an explicitif. - This loop runs thousands of times in realistic diagrams.
tests/ stress.typ’s 400-node, 760-edge fixture touches this path once per point across every node outline and every edge’s sampled geometry. A dictionary return per point is a real, measurable allocation cost at that scale, not a theoretical one.
The lesson generalizes: a pattern that looks like duplication in isolation can be a correctness-preserving, deliberately-chosen tradeoff once you know what loop it’s inside and what the language will and won’t let you do about it. When extending this file, don’t “clean up” these four blocks without re-checking both constraints against the current Typst version — the closure-mutation limitation in particular is a language behavior, not a style choice, and is worth re-verifying empirically (a minimal typst compile probe, not just re-reading old notes) before relying on it, since it’s exactly the kind of fact a language update could quietly change.