Render-pipeline optimization — Std.Ui renderNodeAs append-on-empty

Cuts per-element allocations in the Sky-side Element→Html pass (sky-stdlib/Std/Ui.sky), the largest remaining render allocation share (73% of render objects per the HEAD re-baseline in render-opt-htmltovnode-20260818/, after typed twins and the HtmlToVNode fast path landed). Output is byte-identical — an allocation/CPU change, not a behaviour change.

What the profile said (HEAD, before)

renderNodeAs traverses each element's attribute list and builds its attribute + child lists with ++. List.append (rt.List_appendT) always makes a fresh slice and copies both operands — even when one operand is []. The common element carries no extra attrs, no nearby children, and no pseudo/transition/animation attrs, so several of those ++ are appends onto [] that copy the other operand for nothing. In the HEAD flat profile (render-opt-htmltovnode-20260818/after-alloc-profile-showcase.txt), rt.List_appendT is a top-12 site at 3.67% of all render objects and rt.List_cons a further 2.92%.

Per common element, renderNodeAs paid:

site++-on-[] in the common case
allAttrs = extraAttrs ++ attrsextraAttrs is [] for every non-root element (the recursive renderElement renderCtx []) → 1 wasted copy of attrs
attrList = … :: (pseudoAttrs ++ transitionAttrs ++ animationAttrs ++ htmlAttrs)all three special lists empty → 3 wasted copies of htmlAttrs
renderedChildren = renderedMain ++ renderedNearbyno nearby children → 1 wasted copy of the whole main-child list
collectTransitionsbuilds String.join ", " [] (boxes its args + allocates) + a second List.any scan on every element, transition or not

The change (sky-stdlib/Std/Ui.sky)

Four guards, each preserving the emitted bytes exactly:

The 2nd markerFlags propagatedAttrs was left in place: markerFlagStep lowers to a value-struct accumulator (_u := v_1; _u.Row = true), so it heap- allocates ~nothing, and removing it would be a CPU-only change against a deliberately-cautious comment.

Attribute order in attrList is not output-sensitive — Std.Html's renderer sorts an element's attributes alphabetically before emit (verified: a reordered-attrList mutant produced byte-identical HTML). The guards therefore preserve order trivially; the falsification below uses a content-dropping mutant instead.

The win (allocation, grounded; CPU secondary with spread)

Forum-shaped Element tree (Main_forumView, ~16 Std.Ui elements/post) at the re-baseline's view sizes, rendered through the full Std.Ui pass (Ui.layout [] el |> Html.render). BenchmarkRenderForum (render_bench_test.go), 3 repeats, one binary each for before/after (compiler re-embeds the stdlib); allocation reproduces to the object across all repeats.

viewallocs beforeallocs afterΔ allocsbytes beforebytes afterΔ bytes
~94 sky-id el (6 posts)3,5263,174−352 (−9.98%)179,477162,836−16,641 (−9.27%)
~974 sky-id el (60 posts)34,20630,722−3,484 (−10.19%)1,779,6091,615,015−164,594 (−9.25%)

The saving is a per-element fixed cost, so it scales with element count (−352 at 94 el, −3,484 at 974 el ≈ 3.6 allocs/element). CPU on this shared M1 moved −3.0% to −3.5% across these runs; treat it as secondary against the ±37% interaction-CPU swing the re-baseline documents — the allocation figure is the stable one.

Relating to the HEAD interaction

This is a −10% reduction of the Element→Html pass at both re-baseline view sizes. The re-baseline established that pass as 73% of render objects, so this is on the order of ~7% of total render objects per interaction — an estimate, because the harness isolates Element→Html (its Html.render serialisation stands in for the downstream HtmlToVNode, which this change does not touch). The absolute per-element saving (3.6 allocs/el) is the measured, app-independent quantity; it lands on every element on every first paint and every full-replace patch.

Byte-identical correctness gate

Conditions

HostApple M1, 8 core, 16 GB, macOS 26.x — arm64 (shared machine)
Commitfeat/config-perf-followup @ 7fdd73e3 + this change
Go1.26.1
Methodin-package testing.B -benchmem on the real compiled Std.Ui pass; forum-shaped tree at the re-baseline's 94/974-element view sizes; allocation is the grounded metric, CPU quoted with spread