Skip to main content

Performance Baselines

This page records measured memory and timing baselines. The allocation test is the regression gate; the CLI numbers are diagnostics for changes that cannot be measured inside the test process.

Allocation peak test

crates/litematic/tests/memory.rs measures peak heap allocation for LitematicFile::parse_bytes and parse_bytes + into_scene. It uses a process-global GlobalAlloc meter, so all scenarios live in one binary and run sequentially.
The fixtures are synthetic and compressed before the measured window. The 512^3 sparse fixture uses custom volume limits because the default 100M per-region limit rejects it. This is intentional: it measures the representation cost, not the admission policy. Baseline on 2026-10-28, Windows x86_64, 48 GB host, cargo test debug profile: Re-verified after the review fixes on 2026-10-28: all four rows are byte-identical. The new crates/nbt/tests/memory.rs also pins the decoder’s pre-allocation guard: a truncated NBT document that declares a 64M-element int array or a 1M-element list must stay below 1 MiB of allocation instead of reserving the declared size. The sparse peak scales with bounding-box volume: 512^3 is 8x 256^3, which is exactly the volume ratio. parse+scene adds no measurable peak because the packed array is moved into the scene rather than copied.

CLI diagnostics

Measured with the release binary on the same host. 000001 is the 9.39M-block, 211^3 fixture. render used a color-only catalog, 1024x1024, supersample 2, 12 threads, and --force-limits because the default 5M-quad cap rejects the exact mesh. The rows below were re-measured on 2026-10-28 after the review fixes; peak working set is sampled every 20 ms, so expect roughly 10-15% run-to-run variation on the short validate cases. The older table rows for validate 000002/000003/000004 (132/130/115 MB) and the 407 MB / 8.8 s color stream were from an earlier build and host state. Both the current build and the pre-review HEAD binary measure 176-178 MB for 000002 and 11.6-11.8 s for the color stream on this host, so the differences are not review regressions. The textured 000001 stream is 591 MB after the spill path was enabled; the earlier in-memory path peaked at 1.52 GB before spilling. The material set itself is about 227 MB; most of the remaining difference is the mesh chunk, resident tiles, and translucent triangles. The client-jar PNG SHA-256 is identical between HEAD and the review build. The 18.9 GB figure in docs/rendering.md is the client-jar full-mesh path with more quads, not the streamed render path. The re-measured streamed color path is 433-435 MB and the current full color-only path is 7.68 GB. This is why the sparse parser is a conditional spike rather than a committed rewrite.

CPU client-jar baseline (2026-11-06)

The color-only rows above are diagnostics, not a model of real use. On 000002 the jar path has 17.3x more triangles (2.30M to 39.82M); on 000001 it has 2.5x more translucent triangles (9.52M to 24.09M). It also spends about 1.7 s decoding the 227 MB material set before rendering starts. Measured on the R1 host with the release binary, CPU backend, --stream-mesh=on, 1024x1024, supersample 2, 12 threads, --force-limits, and fixtures/private/texture/26.2-Fabric.jar. Peak working set is sampled every 20 ms. Total is the CLI-reported total. For the jar rows it includes about 0.4 s (000002) and 1.7 s (000001) of material and texture loading that is not part of parse, mesh, render, or PNG. The peaks are 205 MB and 581 MB, not the multi-gigabyte full-mesh path. The jar triangle mix explains the render split: Stage timing from a temporary instrumentation build. The timed run is about 9% slower than the non-timed render on 000001; use the ratios, not the absolute sum. 000002 is tile-bound. 000001 spends about 7.0 s in the translucent path, more than half of its render time, and the spill write is serial on the main thread. The library path behind the older full-mesh benchmark rows is not a substitute for these streamed CLI numbers. The streamed CLI color-only 000002 render is 667 ms here, not the roughly 214 ms measured through the four-chunk library path, because the streamed mesh builder emits 432 32-page chunks for this fixture. That fixed per-chunk cost is another reason not to use color-only bench results as a jar proxy. --greedy-mesh with the jar was also measured: 000001 triangles changed by -0.06% while mesh time grew 35% and render time grew 1%; 000002 triangles fell 1.2% while mesh time grew 73% and render time fell 3.5%. It stays opt-in. Commands:

P0 CPU streaming changes (2026-11-06)

The first CPU P0 slice targets the client-jar hot path:
  • Translucent spill writes run on a FIFO writer thread with a 16-part queue, so spill I/O overlaps opaque rasterization.
  • The spill bucket reader decodes the next bucket while the current bucket is sorted and rasterized, using one reader plus the normal tile workers.
  • Quads and triangles whose bounds lie entirely outside the image are dropped before binning, and tile rasterization no longer repeats the bounding-box intersection test for every binned candidate.
A temporary BLOCKSCOPE_SYNC_SPILL build switch disabled the writer overlap and bucket read-ahead so both paths could be timed on the same binary. It has been removed. 000001 jar, 1024x1024 ss2, 12 threads, two alternating runs per mode: The controlled difference is about 4.9% render and 5.2% wall time, with no measurable peak change. 000002 jar measured 2,701 / 3,011 ms serial versus 2,788 / 2,775 ms overlapped; its translucent layer is too small for a stable signal. The PNG SHA-256 values for both fixtures are unchanged. A post-change spot check on the same host measured 000001 jar render 9.42-9.79 s, total 16.86-17.79 s, peak 578-580 MB, and 000002 jar render 2.35 s, total 4.52 s, peak 205 MB. Both PNG hashes still match the pre-change files. Treat these as point-in-time diagnostics; the alternating A/B above is the attribution. Three experiments were measured and reverted:
  • Quad-level triangle setup: no render win and a slower 000002.
  • Adaptive mesh chunks sized by a quad budget: neutral speed, but peak rose by 26-90 MB depending on the target.
  • Batch texture preload with parallel PNG decode: no wall-time win even after preserving first-use texture ids; on 000001 the prefetch runs were 0.7-1.1 s slower than the clean run, because the model pre-scan offset the decode gain.
Absolute run times drift with host state; use the alternating A/B table for the P0 claim rather than comparing runs from different times.

Fast CPU projection (2026-11-07)

--precision fast stores the CPU screen-space quad table as f32 instead of f64. QuadRender drops from 128 to 80 bytes, so projection writes and tile reads shrink; the default exact path and its golden PNGs are unchanged. Same-process A/B with the client jar, alternating exact and fast iterations on one parsed scene and mesh: Peak working set on the CLI path: 000001 exact 575-579 MB, fast 546-549 MB; 000002 is about 206 MB in both modes. Fast mode is explicitly approximate. On the same 1024x1024 jar renders it changed 297 pixels (0.028%, max channel delta 21, no alpha changes) on 000002, and 66,978 pixels (6.388%, max delta 180, 75 alpha pixels) on 000001. It is opt-in; use exact for byte-for-byte comparisons. A hybrid fast point (f32 x/y with f64 depth) was also measured. It reduced the 000001 difference only from 6.39% to 5.94% while cutting the 000002 gain from 16.5% to 10.7%, so the simpler f32 point is the shipped fast path.

Compact mesh quads (2026-11-07)

Quad no longer stores four float vertices. Grid positions are packed as i16 with a per-quad scale, axis normals as i8 components, and UVs as four Q12 pairs. Fluid, rotated, or otherwise non-grid vertices use per-page float_positions and float_normals side tables. The packing only accepts a value when i16 / scale reproduces the original f32 value (signed zero is canonicalized), so rendered output is unchanged. Measured with the client jar, release build: The gain depends on how many quads are on the grid. 000001 is dominated by fractional plant and fluid geometry, which must stay in the float tables and is therefore larger than the old uniform quad. Ranked medians of three alternating runs measured mesh build CPU 20-25% higher. Streamed exact-render peaks were about 204 MB (000002) and 565 MB (000001), slightly below the main build; both PNG hashes match it exactly. Public API note: Quad.vertices and Quad.normal are gone. Read positions through MeshPage::quad_positions, normals through MeshPage::quad_normal, and UVs through Quad::uvs; Quad::new is the builder entry point.

Quantized mesh positions (2026-11-07)

--quantize-mesh keeps the compact 64-byte Quad layout but rounds every position to a 1/1440 block grid, so MeshPage::float_positions normally stays empty. 1440 is the least common multiple of common model denominators (2, 3, 4, 5, 8, 9, 16), so vanilla fluid and plant vertices remain exact; arbitrary rotations move by at most 1/2880 block. It is opt-in because those rotations can change edge pixels. Measured against the compact exact build with the client jar: 000002 renders the same PNG hash and gains little because most of its quads were already grid-packed. 000001 is fluid/plant-heavy: total mesh memory is 23.9% below the pre-compact build, and the streamed exact-render peak fell from 566.6 MB to 543.8 MB. The cost is 22,443 changed pixels (2.14%, max channel delta 180, 31 alpha pixels). Mesh build CPU stays within about 3% of the exact compact path. Use exact mode for golden or hash comparisons.

CPU streaming pipeline (2026-11-07)

The CPU streaming path now batches small mesh chunks before rendering, reuses per-tile bin allocations across chunks, and persists the page capacity hint across streamed chunks. render and the CPU serve path buffer small chunks until 150,000 quads and pass larger chunks through; the mesh builder still emits 32-page chunks, so the mesh peak does not grow just because the render batch is larger. Moving the hint out of build_page_chunk was the largest mesh change: the 000001 jar mesh median fell from 5.5 s to 3.1 s without increasing the resident page count. Bin lists reuse their allocations, but bins above 256 entries are shrunk to 128 on reuse so one dense chunk cannot pin the peak for the rest of the render. The mesh crate also exposes owned-chunk streaming variants, so the CLI moves pages instead of cloning every Quad. The translucent spill encodes each triangle once and only patches the tile index per record. Per-tile translucent sorting uses sort_unstable_by with the original bin position as the tie-break, so equal depth keys keep their previous order while the sort avoids the stable merge-sort allocation. Same-host A/B, baseline commit 219350b versus the current build, release, CPU, --stream-mesh=on, 1024x1024, supersample 2, 12 threads, --force-limits, and --no-file. Runs alternate baseline and current binaries; PNG hashes were checked in separate runs. 000002 color is the clearest case: the old streamed CLI path paid the projection and tile barriers once per 32-page chunk; batching cuts 432 render passes to roughly 16 at 150,000 quads. 000002 jar also benefits from the smaller retained bin pool. 000001 jar is dominated by the translucent spill and sort, but its mesh stage still drops from a 5.49 s median to 3.04 s, cutting total wall time by about 16.6 percent. Peak working set is 8-13 percent above the baseline because the render batch and bin pool are slightly larger; it remains far below the old spilled in-memory path: The unstable sort was isolated with two binaries built from the same tree, one with the stable sort and one with the tie-break sort. On 000001 jar: stable 9,490.1 / 10,305.4 ms, tie-break unstable 9,132.2 / 9,615.2 ms, a 5.3% median render reduction. PNG SHA-256 values are unchanged:

CPU texture sampling fast path (2026-11-07)

Nearest sampling now bypasses repeat and rem_euclid when both UV components are inside 0..1, which is the common case for model faces. The fast path keeps the same texel index and wrap behavior, including the rare f32 multiplication that rounds up to the texture width. Controlled A/B with the same tree before and after the change, 000001 jar, 12 threads, 1024x1024, supersample 2, default streaming: The 000001 render median fell by about 2.7 percent and the tile stage by about 4 percent. 000002 is within run noise. PNG SHA-256 values are unchanged. BLOCKSCOPE_STREAM_TIMING=1 prints the project, spill, tile, resolve, assembly, and downsample stage timings; BLOCKSCOPE_TRANSLUCENT_BUDGET overrides the in-memory translucent triangle budget for diagnostics. A stage trace on 000001 jar measured project 2.0 s, tile 3.4 s, and resolve 2.8 s, with only 0.04 s waiting for the spill writer. The spill writer is therefore not the critical path, and parallel spill writers were not pursued.

Rejected CPU experiments (2026-11-07)

A small-bbox triangle fast path was also proposed, but a prior measurement showed no gain and a slowdown, so it was not retried.

Review-fix performance check (2026-10-28)

The review fixes do not change the single-view render hot path. The pre-review HEAD binary (6b5a680) and the current build were compared on the same host with the same commands: The color-stream difference is within host noise; no path regressed. The 000001 client-jar image is byte-identical. The current full-mesh color path measured 7.68 GB / 11.9 s and produced the same PNG SHA-256 as the streamed path; it was not re-run on HEAD because the full-mesh path is unchanged and costs about 8 GB per run. Multi-view rendering now batches by a 256 MiB output-byte budget and writes each view before starting the next batch. At 1024x1024 (4 MiB RGBA) and 2048x2048 (16 MiB RGBA), the thread budget remains the limiting factor, so normal multi-view parallelism is unchanged. At 4096x4096 (64 MiB RGBA), a batch is capped at four views instead of retaining every output; this is the intended memory tradeoff and is covered by view_batch_budget unit tests. The supervisor hashes inputs on a dedicated thread instead of its event loop, and reuses a caller-supplied input.hash for routing. This is a latency improvement; no end-to-end render throughput table is claimed for it because the renderer remains the bottleneck.

Textured render memory

Texture memory is not the dominant cost. For 000001 with the client jar:
  • the material set is about 227 MB;
  • 12.59M translucent triangles were retained for the per-tile depth sort;
  • each Triangle was 112 bytes, so the retained triangles were about 1.41 GB;
  • streamed rendering peaked at 2.28 GB before the compact layout.
Triangle now stores projected screen points as f32 and no longer stores the always-translucent layer tag. The depth key stays f64, so the sort order is unchanged; the struct is 72 bytes and the regression test in crates/render/src/rasterizer/project.rs pins that ceiling. The in-memory translucent path measured 1.52 GB before spilling was implemented. Above 262,144 retained triangles, the streamed renderer spills exact records to temporary bucket files and processes one bucket at a time. Re-measured after the review fixes, 000001 with the client jar peaks at 591 MB in 17.0 s with the same PNG SHA-256 as HEAD (the previous 618 MB / 16.2 s row was from an earlier host/build state). The temporary directory is removed when the render finishes. render defaults to --stream-mesh=auto, which streams like on; --stream-mesh=off restores the full-mesh path. This is what avoids the 18.9 GB full-mesh peak without requiring a flag.

Reproducing the CLI peak

Windows PowerShell, sampling the child working set every 20 ms:
Use bench with the same file and without --stream-mesh to record the full-mesh path. That path is intentionally not part of the default CI gate; it is a diagnostic for deciding whether streaming should become the default.

Sparse spike

The prototype is crates/litematic/examples/sparse_probe.rs. It scans the gzip stream twice: the first pass records region headers and BlockStates offsets, and the second pass decodes the packed words without retaining the decompressed document or the long array. It counts non-zero palette values; it does not build a scene or validate palette indices yet.
Measured on 2026-10-28 with the release example: The probe peak is constant at about 142 KB. The sparse case is about 2x slower because it decodes every value from a second gzip pass; the dense fixture is faster because it avoids building the NBT tree. Decision: go for the integration spike follow-up. The peak reduction is far above the 50 percent bar and the sparse parse time is under 2x. This is not yet a production parser: the next step is a scoped implementation plan for RegionStates, canonical scene hashing, and parity tests. The probe’s occupied_nonzero counter is not the same as non_air when the palette has a non-zero default entry; the production path must use the palette default and preserve diagnostics.

GPU backend baseline (2026-11-05)

This is the pre-G2 baseline for tasks/gpu-performance-spec.md. It records why the current GPU path is not yet a net win on a strong CPU. The host is the 12-core / 48 GB Windows machine with an NVIDIA GTX 1650 SUPER (4 GB), release build, D3D12, color-only materials, 1024x1024 output, supersample 2, 12 threads, --force-limits. The CLI default backend was still cpu when measured; the spec changes it to auto in G6. Build and run:
End-to-end: 000001 used 87.9M quads in this build. The GPU path requires the full mesh, so it also chooses the 7.68 GB color-only memory peak instead of the 433 MB streamed peak. GPU stage timings from the last iteration of a three-iteration 000002 run: The GPU draw itself is under 1% of render time. CPU projection, vertex expansion, and upload account for about 85%. This is the bottleneck G2 removes. Current GPU parity on the 96x96 reference scenes: The 0.1% gate is a target for the second-generation path, not a property of the current path. G2 and G5 own closing it or recording the measured exception.

Shared texture storage (2026-11-05)

TextureSet now stores Arc<Texture> entries, so cloning it for a material snapshot only bumps refcounts. The 000001 client-jar stream still peaks at 581.4 MB, within the 591 MB baseline’s run-to-run variance; the live material set is dropped before rendering, so the steady peak does not include a second copy. The change removes the transient texture copy during snapshot creation and is required for the process-wide GPU atlas cache. The measured command is the same streamed client-jar render used for the 591 MB row in the summary table. The GPU atlas cache is keyed by a weak texture-set identity. Its byte cap is one quarter of the 60% VRAM working set, clamped to 64-512 MiB; the session also includes the atlas in its admission check. On 000002 --client-jar, the first GPU render spent 1,037 ms building/uploading the atlas; the next render reported atlasCacheHit=true and 4 microseconds. The second view of the same material snapshot therefore reuses the atlas instead of repacking 227 MB of textures.

Instanced opaque/cutout path (2026-11-05)

The GPU path now uploads one 92-byte GpuQuad instance per opaque/cutout quad instead of six projected 36-byte vertices. The camera transform, UV decode, AO, and quad expansion run in the vertex shader; translucent geometry still uses the CPU-projected path until G5. 000002, color-only, 1024x1024, ss2, 12 threads, median of 3: GPU stage timings for the same run: encode 70.4 ms, translucent projection 11.5 ms, upload 11.3 ms, draw 1.0 ms, readback 5.3 ms, resolve 2.1 ms, total 113.1 ms. The GPU is now faster than 12-thread CPU on this fixture. 000001, full mesh, color-only, one iteration: On the large fixture the remaining GPU cost is dominated by CPU instance encoding (2.94 s), translucent projection (1.13 s), and the old translucent sort/draw (2.81 s). The full-mesh 7.68 GB peak is still the reason G3 streaming is next.

GPU streaming (2026-11-05)

render --backend gpu --stream-mesh=on now builds page chunks and feeds GpuRenderSession; the full mesh is not retained. 000001, color-only, 1024x1024, ss2, 12 threads, --force-limits: The 2 GB memory gate passes. The 10 s wall gate is not met yet: the remaining cost is the 32-page mesh chunk policy (P0) and the 9.5M retained translucent triangles in the old GPU path (G5/WBOIT). --stream-mesh=auto and on produce byte-identical PNGs to the full-mesh GPU path on the CLI fixture. Multi-view GPU jobs use one GpuRenderSession and one mesh build: Views are batched by the 60 percent VRAM target budget minus atlas and staging headroom. On this host that permits the 12 views at 1024x1024 ss2 in two sessions, with one mesh build and one upload per session.

GPU translucent depth buckets (2026-11-05)

Translucent triangles above 262,144 entries spill into 128 camera-depth bucket files instead of being retained in one vector. 000001 GPU streaming, color-only, 1024x1024, ss2, 12 threads: The 9.5M translucent triangles are written to depth buckets and read far-to-near in bounded sub-batches. The 10 s wall gate is still mesh-bound (5.08 s); the bucket reader and per-bucket sort are not the bottleneck.

WBOIT capture (2026-11-05)

Translucent buckets now draw into Rgba16Float accumulation and revealage targets with depth test on and depth write off; a full-screen composite pass resolves them over the opaque color target. Revealage is RGBA rather than R16Float because WebGPU needs a per-fragment source alpha for OneMinusSrcAlpha. 000001 GPU streaming, color-only, 1024x1024, ss2, 12 threads: The 9,216-pixel parity scene keeps a maximum channel delta of 1 but still has 1,039 differing blended pixels, so the 0.1% tolerance remains a calibration item for Task 17. The extra ~39 MB and ~0.2 s are the two half-float targets plus the composite pass. 000001 with the client jar, GPU streaming, 1024x1024, ss2, 12 threads: The 3 GB memory gate passes; the 15 s wall gate does not. The gap is the 24.1M translucent triangles (projection + WBOIT fill) and the 6.3 s streamed mesh. This is recorded as a target-not-met rather than hidden. Calibration defaults are 128 depth buckets, a 262,144-triangle in-memory/read cap, and the WBOIT weight 0.03 / (1e-5 + depth^4) clamped to 1e-5..3000.

GPU performance path final results (2026-11-06)

auto is now the default backend for the CLI, bench, and serve. The policy is deterministic for a machine description and chooses the GPU only when the calibrated estimate predicts a win:
  • CPU cost per quad and per internal pixel is scaled by (12 / per_view_threads)^0.35 from the reference host; multi-view jobs split the thread budget across views.
  • GPU cost includes device/target setup, readback, per-quad encode/upload/draw, and a 10 percent streaming overhead.
  • The GPU is rejected without a hardware adapter, with a software adapter, above the f32-safe scene span, above the 60 percent VRAM working set, or for meshes below 10,000 quads.
  • When the process-wide GPU context is not initialized, a single-view job on a CPU with eight or more threads is kept on the CPU. Shader compilation and device setup cost a few hundred milliseconds, which a one-shot CLI render cannot amortize; multi-view jobs amortize it across views, and a warm process that already paid setup can still choose the GPU.
  • A predicted win requires a 5 percent margin, so setup noise cannot flip the decision.
The 60 percent VRAM budget uses the platform probe where available and a conservative floor otherwise: 2 GiB for discrete adapters and 1 GiB for integrated or virtual adapters. BLOCKSCOPE_GPU_VRAM_BYTES overrides the probe. Linux reads /sys/class/drm/*/device/mem_info_vram_total; Windows and macOS currently use the floor. The session admits targets, readback, one 32 MiB staging batch times three slots, and the atlas inside that budget. Larger page chunks are split into bounded instance uploads. R1 is the 12-thread Ryzen 5 5500 / GTX 1650 SUPER host, Windows D3D12, release build, color-only, 1024x1024, supersample 2, --force-limits. Render and total medians are from three iterations; total includes PNG. On 12 threads auto keeps the one-shot render exact on the CPU: the warm GPU render is 190.2 ms, but the first GPU render in a fresh process is 535.5 ms because it pays shader compilation and device setup. On 2 threads the GPU wins even with setup. auto reports backendUsed, so the caller can always tell. Commands:
Explicit GPU full-mesh render finished a single view in 1.84 s and two views in 1.71 s wall, inside the 1.3x two-view gate. Twelve auto views were batched by the VRAM budget and finished in 3.42 s with a 1006.7 MB peak. Multi-view jobs amortize setup across views, so the policy uses the GPU even on a strong CPU; single-view auto keeps the exact CPU on 12 threads. 000001 auto streaming (--backend auto, default --stream-mesh=auto) on the 12-thread host selects the CPU for exact one-shot output: Explicit --backend gpu measured 14.64 s / 502 MB for color-only and 19.83 s / 655 MB for the client jar. The difference is within run-to-run noise, so auto keeps the byte-exact CPU on this strong host. The 2 GB and 3 GB memory gates pass. The 10 s and 15 s wall gates do not; the remaining cost is page-chunk meshing and CPU/WBOIT translucent work. These are recorded as not met. Parity on the 96x96 GPU scenes, D3D12, max channel delta 1: Alpha coverage matches exactly in all three. The spec’s 0.1 percent differing-pixel target is therefore not met on this driver; the maximum channel delta target (2) is met. The parity tests keep a 15 percent regression cap and record the measured values instead of hiding them. The CPU backend remains the byte-exact reference and the explicit exactness override.

GPU frame session (2026-11-06)

GpuFrameSession is the first resident-geometry slice for video export. It uploads every opaque/cutout quad once and keeps the vertex buffers for the life of the session; each frame rewrites only the camera uniform and re-projects the translucent layer. This is the session shape that avoids re-uploading the full mesh for every video frame. Measured with the release build, 12-thread host, GTX 1650 SUPER, D3D12, color-only materials, 256x256, supersample 1, 12 yaw frames, sphere fit on: Both time columns are totals for the 12-frame loop; divide by 12 for the per-frame average. The resident geometry is a hard VRAM constraint: 000001 color-only needs about 7.27 GB for the instance table plus targets, above the 1.29 GB working-set budget on this host, so the session returns BACKEND_UNAVAILABLE instead of attempting an allocation. Large or textured scenes keep using the streaming GpuRenderSession (or a future bounded frame batch) rather than a resident session. Run the diagnostic with:
This is a diagnostic, not a CI gate. The existing GPU parity and frame-loop tests protect correctness.

Animate CLI (2026-11-06)

blockscope animate drives the resident frame session end to end. Measured on the same host and release build, 000002 color-only, 128x128, supersample 1, 12 threads, 60 frames at 4 fps, sphere fit on, ffmpeg 9.0.2: The GPU session renders the frame loop 1.86x faster and the complete command is 1.29x faster. Short clips can still be faster on the CPU because the resident session pays its upload/setup cost up front: 12 frames at the same size measured 1,861 ms on CPU versus 2,089 ms on GPU. 000001 color-only cannot hold the resident instance table and --backend auto falls back to the CPU; explicit --backend gpu reports BACKEND_UNAVAILABLE. The command uses an external ffmpeg. MP4 is H.264/yuv420p with an opaque background and even dimensions; GIF is a two-pass palette encode with optional 1-bit transparency.

Video frame-loop memory and speed (2026-11-07)

Measured on the same host, release build, ffmpeg 2026-03-12-git, with a 41,127-block private fixture and the 26.2 client jar: 1024x1024, supersample 2, 60 fps, 600 frames, GPU backend and NVENC via --encoder auto. Readback reuse (GpuFrameSession owns one readback buffer and resolves directly from the mapped range): Resident translucent geometry (pre-extract translucent quads instead of scanning every page per frame), interleaved 3x A/B under the same load: After both changes, three runs measured a median 183.9 MiB peak RSS and 370.0 MiB peak private bytes. GPU VRAM delta stayed in the 260-330 MiB range, within measurement noise; the readback and resident changes target host memory and CPU-side per-frame work, not VRAM. External ffmpeg peaked around 190 MiB working set. Rejected video-path experiments: Large fixture limit: 000001 (211x211x211, 9.39M blocks) builds about 128.6M triangles and exceeds the default mesh limit. A 10-frame probe at 1024x1024, supersample 2, with the 26.2 client jar and --sphere-fit measured 12.0 s per frame on the CPU, 5.6 s mesh time, 10.55 GiB peak RSS, and 11.98 GiB peak private bytes. Extrapolated 600 frames would take about two hours; the resident GPU session cannot hold the instance table. The full run was not executed. NVENC CQ is not equivalent to x264 CRF. Calibrating low/medium/high to CQ 32/28/23 gives: Medium output dropped from 21.2 MB to 13.3 MB (-37%) while matching libx264 medium visual quality. SSIM values are from a static frame against the GPU render; the libx264 medium size is the full 600-frame output, while the low and high libx264 sizes were not recorded. Video quality varies with motion and content.

Model UV cache (2026-11-07)

PaletteTables now caches the Q12-encoded UVs of every model quad once per resolved palette entry. Mesh building still computes block-local positions, but it no longer calls encode_uv for every emitted block quad. Full-mesh 000002 with the client jar, CPU, 12 threads, 16 alternating bench runs: The cached path is 9.3% lower at the median. The two fixtures checked (000002 and 000001, client jar, 1024x1024, supersample 2) produce byte-identical PNG SHA-256 values before and after the change. Caching the scaled corner offsets as well was also measured. It added 48 bytes per model quad to the working set and reduced the combined gain to 1.3%, so only the UV cache is kept.

SIMD investigation (2026-11-07)

The perf/simd-rasterizer branch measured whether explicit AVX2 kernels help the CPU render path. The answer for the current workload is no: the hot loops are dominated by per-triangle and per-quad setup, not by wide arithmetic over long contiguous runs. The experimental kernels were reverted rather than kept as neutral complexity.

Where the time goes

000002 with the client jar, CPU backend, --stream-mesh=on, 1024x1024, supersample 2, 12 threads, and --force-limits. Stage timings came from a temporary instrumentation build; the render column is the CLI total. Mesh building in the same run is about 1.4 s wall. The sampled page breakdown shows the inner block loop is dominated by model iteration and culling (41%), quad push and allocation (19%), ambient occlusion (17%), and vertex creation (12.5%). Each of those is branch-heavy, and the largest SIMD-able share is well under half of the mesh stage.

Why per-pixel SIMD does not fill

The rasterizer was instrumented to count triangle invocations, rows, and non-empty rows on 000001 with the client jar: That is 1.79 tested pixels per non-empty row. A four-lane row kernel almost always falls through to its scalar tail, so it cannot repay its setup cost. The same distribution holds on 000002 (133.0M tested pixels over 33.7M triangles). SIMD across triangles would need a batched setup that preserves draw order; that is a rasterizer redesign, not a local kernel change.

Rejected attempts

Follow-up options

The measurements point at algorithmic work rather than wider vectors:
  • Keep explicit SIMD for bulk image paths only if they grow beyond the current assembly and downsample costs, which are under 50 ms per render.
  • Do not retry four-triangle batching without a copy-free design that keeps the scalar setup inlined; the measured version regressed tile time by 33-47%.
  • Do not retry a full six-face cull mask without a vectorized mask build; the scalar version does more neighbour work than the block loop saves.

Server preview latency spike

The server-preview latency spike, the textured resident-session result, and the color-only blockout follow-up are recorded in performance-preview.md. Short version: textured preview is not viable at 384x384 on the 4 GB reference host; color-only 000002 meets the 300 ms gate; 000001 stays batch-only.