Baseline performance measurements for OgEx 0.3.1 covering the Takumi native renderer, the complete request lifecycle, the generated-image cache, native memory retention, and direct-image metadata builds.

  • Benchmark code: bench/
  • Raw machine-readable logs: bench/results/*.log (per-version, e.g. render_20260826_233120.log)
  • Measurement tool: Benchee 1.5.1 (warmup 2 s, measurement 5–6 s per scenario, memory and reduction tracking enabled, single process, no parallelism)

This document reflects a single version. Each hexdocs release snapshots the benchmarks for that version; historical logs remain in bench/results/ but are not duplicated here.

Environment

ItemValue
Date2026-08-26 (UTC)
HostdarkPrimus
OSLinux 6.11.0
CPU13th Gen Intel Core i9-13900H, 20 logical cores
Memory48 GB
OTP28 (erts 16.3.1), JIT enabled
Elixir1.19.5
OgEx0.3.1, locally built native NIF (OG_EX_BUILD=1)
FontNimbusSans-Regular.otf (single configured font)

Reproducing

OG_EX_BUILD=1 mix run bench/render_bench.exs     # renderer formats/sizes
OG_EX_BUILD=1 mix run bench/lifecycle_bench.exs  # request lifecycle paths
OG_EX_BUILD=1 mix run bench/cache_bench.exs      # cache, memory, direct images

Each script prints environment metadata before its results. Save output with tee bench/results/<name>_$(date +%Y%m%d_%H%M%S).log.

Workload

Cards are realistic HEEx layouts modeled on the README example: gradient background, eyebrow line, title, and author at 1200x630, plus a compact 600x600 variant. Requests exercise the real Phoenix router integration (og_ex_routes()), controller declarations (og_card), card-local loaders, signature generation/verification, ETS caching, head injection, and the native Takumi render.

Glossary

TermMeaning
ipsIterations per second (Benchee's throughput measure); higher is faster.
average / median / 99th %Latency distribution statistics across all measured iterations. Median is the most noise-resistant single number; 99th % shows tail latency.
deviationRelative standard deviation. High values on this laptop reflect CPU frequency scaling and thermal variation between runs.
reduction countBEAM work units. Each function call costs roughly one reduction; a process is preempted after ~4000 reductions. Reductions approximate "how much code ran" independent of CPU speed, which is useful for comparing algorithmic work, but NIF/dirty-CPU time inside Rust does not consume reductions, so render-heavy scenarios show fewer reductions than their wall time suggests.
memory usageHeap words allocated by the calling process per iteration (BEAM allocations only; NIF-internal allocations are invisible to this counter).
coldFirst render for content not in the generated-image cache: full loader → HEEx → resource scan → Takumi layout/rasterize/encode path.
warmCache hit: signature verify + ETS lookup + response assembly, no rendering.

1. Renderer throughput by format (steady state)

1200x630 wide article card unless noted:

Formatipsaveragemedian99th %
SVG 600x6002303.330.43 ms0.39 ms1.30 ms
SVG 1200x630820.371.22 ms1.12 ms2.72 ms
PNG 600x600190.375.25 ms4.67 ms14.95 ms
PNG 1200x63048.9620.43 ms18.40 ms52.71 ms
WebP 1200x63042.5023.53 ms22.71 ms33.27 ms
JPEG 1200x63037.4526.70 ms25.57 ms52.72 ms

Encoded output size for one realistic wide card:

FormatBytes
WebP27,278
SVG22,360
PNG50,659
JPEG60,649

Cold first-render per format (includes lazy NIF/font initialization): PNG 23.9 ms, JPEG 65.3 ms, WebP 26.8 ms, SVG 1.7 ms.

Observations:

  • Rasterized bitmap formats cost roughly the same order of magnitude; encoding choice shifts the total by ~50%. SVG skips rasterization entirely and is ~20x faster than PNG at steady state.
  • The NIF runs on dirty CPU schedulers, so renders do not block normal schedulers; throughput scales with cores for concurrent crawler traffic.
  • Repeated identical inputs get measurably faster as the native side warms up; benchmark medians include this warm-up.
  • Absolute numbers vary noticeably between runs on this laptop (a pure-Takumi wide-card render measured anywhere from ~18 ms to ~62 ms median across sessions in 0.3.0 baselines) due to turbo/thermal states. Compare ratios within one log, not absolute values across logs. 0.3.1 steady-state renders measured ~3× faster than the 0.3.0 session on the same host — well within expected inter-session variance; ratios and memory/reduction profiles are stable.

2. Request lifecycle

Full path-mode dispatch through a real Phoenix router with og_card declarations:

Scenarioipsmedian99th %memory/op
Card load dispatch (__og_ex_load__)191.63 K4.12 µs17.55 µs2.30 KB
Endpoint plug pass-through (no card)175.78 K4.54 µs17.90 µs2.60 KB
Config build + signing52.28 K16.21 µs65.14 µs6.83 KB
HTML page request (card declared)3.32 K205.40 µs3855.46 µs33.84 KB
Image request warm cache2.21 K205.60 µs4851.20 µs136.67 KB
Head injection (isolated)0.96 K732.71 µs4592.91 µs445.80 KB
Image request cold cache0.0503 K17986.11 µs35210.76 µs150.27 KB

Observations:

  • Cold image requests are completely dominated by Takumi rendering: lifecycle cold (~18 ms median) ≈ renderer steady-state PNG time. Everything OgEx adds around a miss (routing, loading, signing, verification, HEEx, resource scan) costs under 2 ms combined.
  • Warm cache hits serve in ~200 µs (≈90× faster than a miss) because the hit path is verify + ETS lookup + header/body assembly.
  • Signing (two HMAC-SHA256 truncations per page render) costs tens of microseconds; it is not a bottleneck even on high-traffic HTML pages.
  • The endpoint plug candidate check adds ~5 µs to ordinary non-OgEx requests.
  • HTML page cost and head-injection cost are ~2× lower than the 0.3.0 session — consistent with inter-session turbo variance, not a code delta. Head injection remains the dominant page-path cost (see §6).

3. Generated-image cache

Implementation: OgEx.Cache.ETS, a public set table with read_concurrency: true, owned by a GenServer so the table dies with its owner. Reads bypass the GenServer mailbox entirely.

Hit latency

Scenarioipsmedianmemory/op
Direct OgEx.Cache.fetch/1 (ETS get)2729.29 K0.21 µs0.24 KB
Signature verify (HMAC compare)219.72 K3.87 µs0.68 KB
Full warm request through router2.17 K231.11 µs136.67 KB

The raw ETS fetch is effectively free; the warm-request cost above is almost entirely Plug conn construction, route resolution, config building, HMAC verification, and copying the ~41 KB image body into the response, not cache access.

Memory footprint per cached card

Measured with 1000 distinct entries of one realistic 41 KB PNG payload:

ComponentPer entry
ETS table overhead (key/tuple words)~163 bytes
Payload binary on the shared binary heap~41 KB (= encoded image size)
Total per distinct card~40 KB + image bytes

1000 distinct cached cards ≈ 39.5 MB RSS. Memory grows linearly with the number of distinct card versions ever rendered.

Eviction behavior

  • Generated-image cache: there is none. Inserting 1000 probe entries left all 1000 in place. There is no TTL, no LRU, no size bound, and no persistence. Entries live until the node stops or the cache GenServer restarts. Capacity planning is the application's responsibility; an unbounded stream of unique card versions will grow RSS without limit.
  • Resource cache (OgEx.ResourceCache, remote images only): bounded but blunt. Defaults are 128 entries / 25 MB. When an insertion would exceed either bound the entire table is cleared before inserting (demonstrated with max_entries: 4: after inserting 10 distinct resources only 2 survived (the ones inserted after the last reset). This is not LRU; expect thundering re-fetches after each reset.

4. Native (Takumi) memory retention

150 consecutive cold renders of slightly-varying wide-card documents, sampled via process RSS (captures native heap that BEAM statistics cannot see):

MetricValue
Average cold render18.4 ms
RSS before / after / peak133.5 MB / 134.4 MB / 134.4 MB
RSS delta over 150 renders0.9 KB total (~6.05 KB/render retained)
BEAM processes/binary/ets deltas−249 KB / +11 KB / 0 KB, no growth trend

No leak detected: repeated renders return their native allocations; RSS stays flat. Per-render garbage (layout trees, pixel buffers, encoded output) is freed by the native allocator.

One avoidable per-miss cost lives on the Elixir side: OgEx.Fonts.load/0 re-reads the configured font file on every cache miss (19.8 µs median, 1.05 KB BEAM-side; the file itself is ~1 MB passed into the NIF). A font registry that parses once would shave a little latency and some I/O from every cold render.

5. Direct-image metadata builds (HTML request path)

These strategies never invoke Takumi; they build metadata while rendering the normal page:

Strategyipsmedianmemory/op
Remote URL (no fetch by design)171.21 K4.90 µs3.17 KB
Private local file (read + inspect + sign)5.42 K164.18 µs26.27 KB
Public static file (read + inspect)4.60 K186.24 µs34.86 KB

Local files pay one filesystem read plus security checks (traversal/symlink walk) per image per page render (hundreds of microseconds, worth remembering for pages that declare several direct images).

6. Head-injection decomposition

Why is <meta> insertion measurably expensive? Broken down on a ~10.4 KB HTML document (0.3.1 medians in parens; 0.3.0 §6 for comparison):

StepMedianAllocated
Build the meta tags themselves (Meta.to_html)7.58 µs7.09 KB
String.downcase(body) + scan for </head>~140 µs (0.3.0)~171 KB
Split + rebuild body around the tags~186 µs (0.3.0)~171 KB
Same response, no OgEx (raw send_resp)222.07 µs2.68 KB
Full head injection732.71 µs (lifecycle) / 998.86 µs (isolated)445.80 KB

Findings:

  • Tag generation is trivial. Essentially all cost is whole-body rewriting: the document is lowercased and scanned twice (once for the contains? guard, once to locate the insertion point), and then rebuilt as a new binary. Each String.downcase/1 alone allocates ~16x the document size.
  • Net effect on a 10 KB page: ~3–4× response-path latency and ~165× memory versus the same response without OgEx. Cost scales linearly with page size.
  • Cheap wins if this ever matters: lowercase-search only once (reuse the downcased copy or search for both </head> casings with :binary.compile_pattern/1), and inject via iodata instead of binary concatenation to avoid the full rebuild.

Known sources of variance

  • Laptop turbo/thermal states moved pure-render medians up to ~3x between sessions. Within-run ratios are stable; cross-run absolutes are not. 0.3.1 measured faster than 0.3.0 on the same host — expected variance.
  • Benchee's memory counter covers the benchmarked process only. Native allocations require external observation (RSS sampling, as in section 4).
  • Cold-cache scenarios insert every rendered card into the unbounded ETS cache; long benchmark sessions grow RSS by design (see section 3).
  • Historical logs remain in bench/results/ (e.g. *_20260824_*.log for 0.3.0) but are not duplicated here; each version's document shows only that version's baseline.