What an interaction costs on an application

Conditions, method and the corrected harness are in README.md. Every figure below is MEASURED unless the line says INFERRED. Every configuration is three runs; ranges are given, not means alone.


1. The headline: the fixed term is ~0.1 ms, not 2–3 ms

The single most consequential number in the programme was the fixed term. On 26-ui-showcase it was extrapolated from two points as ≈2.5 ms, and a 2.5 ms floor against a 9–11 ms interaction caps any element-count lever at about 4×. One of those two points was an invalid run.

Measured on skyforum at HEAD, GOMAXPROCS=1, seven view sizes from 30 to 1614 elements, three runs each (g1.tsv, harness/fit.sh):

ALL POINTS (n=21)          cost_ms = -0.147 + 0.01971 x elements   R2 = 0.9983
                           fixed term  -0.147 ms   95%CI -0.418 .. +0.123
                           per element  19.71 us

3 SMALLEST SIZES (30-94)   cost_ms = +0.124 + 0.01827 x elements   R2 = 0.9899
                           fixed term  +0.124 ms   95%CI +0.032 .. +0.216
                           per element  18.27 us

3 LARGEST SIZES (382-1614) cost_ms = -0.953 + 0.02035 x elements   R2 = 0.9971

The fixed term is 0.12 ms and its confidence interval is 0.03–0.22 ms. That is the figure from the three smallest sizes, where the intercept is interpolated rather than extrapolated, and it is the one to use. Across the whole range the intercept is indistinguishable from zero.

The measurement that makes this hard to argue with is direct rather than fitted: a 30-element view costs 0.643–0.675 ms per interaction and serves 1,493–1,510 interactions/sec on one core. Whatever is fixed about an interaction has to fit inside 0.67 ms, and 0.55 ms of that is element-proportional.

elementsms/interaction (3 runs)interactions/sec, 1 core
300.643 / 0.668 / 0.6751493 / 1508 / 1510
621.217 / 1.301 / 1.314771 / 779 / 799
94 (stock skyforum)1.784 / 1.786 / 1.924527 / 545 / 545
2063.776 / 3.817 / 4.051253 / 259 / 260
3826.995 / 7.149 / 7.189138 / 142 / 143
97417.767 / 18.479 / 18.68354 / 55 / 55
161431.702 / 31.866 / 32.91331 / 31 / 32

No two adjacent size ranges overlap.

The relation is mildly superlinear, and that is why the old extrapolation failed. Per-element cost rises from 18.3 µs over 30–94 elements to 20.4 µs over 382–1614. Fit a straight line across the whole range and the curvature is absorbed by the constant, which goes negative. Extrapolating a constant from two points 94 and 384 elements apart — as the showcase figure did — puts the intercept 94 elements outside the data, where exactly this curvature lives.

INFERRED, mechanism: the superlinearity is GC. Each of the 50 concurrent sessions retains a prevTree proportional to the view, so live heap scales with sessions × elements while allocation rate scales with elements; mark cost per interaction therefore grows slightly faster than linearly.

What this does to the ceiling

The ceiling a fixed term imposes is total / fixed. On the stock 94-element skyforum that is 1.855 / 0.124 ≈ 15×; at 382 elements ≈ 58×; at 974 ≈ 150×. On showcase's published numbers it was ~4×.

So: the ~4–6× ceiling does not transfer. It was an artefact of a fixed term that is not there. The floor on this app is the transport plus HTTP plumbing, and that is under 0.2 ms — under 2% of the cost of rendering a showcase-sized view.

This does not say a 25–50× improvement is available. It says the fixed term does not prevent one. What prevents it is the 18–20 µs per element, and §3 says where that goes.


2. Allocation is the cost, and it is purely per-element

Objects and bytes per interaction, from MemStats deltas across the load window (memstats-idle.jsonmemstats-loaded.json), same 21 runs:

elementsobjects / interactionkB / interaction
307,384 – 7,417319 – 321
6215,330 – 15,361708 – 709
9423,267 – 23,3071,109 – 1,111
20651,350 – 51,5012,543 – 2,551
38295,972 – 96,6464,816 – 4,850
974255,349 – 255,87913,170 – 13,201
1614440,768 – 442,12520,926 – 22,952

Repeatability is 0.2% — an order of magnitude better than anything in the CPU profile, which is why the allocation attribution carries the weight in §3.

Fitted the same way:

objects  = -34 + 248 x elements       (30-94 el)   R2 = 0.99999
         = -3858 + 273 x elements     (all)        R2 = 0.99937
bytes    = -51 kB + 12.3 kB x element (30-94 el)   R2 = 0.99991

~250 allocations per rendered element, and a fixed allocation term indistinguishable from zero (−34 objects, CI −64 .. −3). Every object allocated in an interaction is allocated on behalf of an element.

For scale: the archived minimal-Go control server allocates ~50 objects for a whole interaction. Stock skyforum allocates 23,300.


3. Where the cost sits — the erased list-helper round trip

The emitted shape

src/View/Posts.sky:17 — the line 90.6% of the page hangs off — lowers to (sky-out/main.go:802, reformatted):

rt.AsListT[Std_Ui_Element](rt.List_indexedMap(
    any(func(_p0 any, _p1 any) Std_Ui_Element {
        return View_Posts_postRow(v_0, rt.AsInt(_p0), rt.Coerce[State_Post_R](_p1))
    }),
    any(v_1)))                       // v_1 is []State_Post_R — a TYPED slice

rt.List_indexedMap (runtime-go/rt/rt.go:8629) then does, per element:

items := asList(list)                // []State_Post_R -> reflect arm ->
                                     // fresh []any, every element boxed
result := make([]any, len(items))
for i, item := range items {
    step := SkyCall(fn, i)           // arity 2, one arg -> PARTIAL application:
                                     // a fresh curried closure per element
    result[i] = SkyCall(step, item)  // reflect.Value.Call
}
return result                        // then AsListT walks []any back to
                                     // []Std_Ui_Element, asserting per element

rt.SkyCall (rt.go:10565) takes reflect.ValueOf(f), and skyCallDirect allocates a []reflect.Value plus a reflect.ValueOf per argument before rv.Call. The partial-application step is an extra per-element closure the "~7n+2 allocations" sketch does not include.

The emitted Go for forumbench carries 19 erased list-helper calls (11 List_mapAny, 5 List_filterMap, 2 List_filterAny, 1 List_indexedMap) and 71 rt.AsListT sites.

Its share of allocation — MEASURED, and flat across a 10× view size

Self-allocation (each site's own objects, so the column sums without double-counting), from the alloc profiles bracketing the CPU window:

site94 elements974 elements
reflect.Value.call8.59%8.56%
rt.asList3.52%3.61%
rt.AsListT[any]2.90%2.68%
rt.AsListT[SkyADT]2.43%2.43%
rt.List_mapAny1.47%1.32%
rt.List_cons1.45%1.45%
rt.List_filterMap0.30%0.21%
skyCallOne (partial application)0.037%0.021%
skyCallDirect + List_indexedMap + other AsListT[T]0.09%0.05%
erasure round trip, total20.8%20.3%

And cumulatively — the share of all allocation that happens underneath the reflective dispatch:

94 el974 el
under reflect.Value.call89.2%91.1%
under rt.SkyCall87.0%90.9%
under rt.List_mapAny78.2%81.1%
under Std_Ui_layout79.5%81.1%

One fifth of every object allocated is the erasure round trip's own bookkeeping, and nine tenths of all allocation happens inside a reflect.Value.Call. The share is constant across a 10× change in view size, so it is structural, not a small-view artefact.

Its share of time — MEASURED

At 974 elements, where the profiler is stable on this host (§4), cumulative CPU over three runs:

framerange over 3 runs
reflect.Value.call64.9 – 66.5%
rt.SkyCall64.8 – 66.3%
rt.List_mapAny61.8 – 63.8%
main.Std_Ui_layout42.9 – 44.1%
main.Main_view42.6 – 44.0%
liveApp.handleEvent38.6 – 40.4%
liveApp.safeViewCall36.1 – 37.5%
rt.List_filterMap34.7 – 35.5%
runtime.mallocgc28.6 – 29.3%
runtime.gcBgMarkWorker10.1 – 11.1%
Std_Ui_buildStyleStringWith6.2 – 6.4%
rt.asList6.0 – 6.3%
rt.HtmlToVNode5.3 – 5.8%
rt.renderVNode5.3 – 5.7%
rt.AsListT[any]4.8 – 6.5%
syscall.write2.9 – 3.5%
rt.List_indexedMap2.4 – 2.9%
rt.diffTrees1.1 – 1.2%
rt.applyStyleInjections~0.9%

These rows OVERLAP by construction — handleEvent contains view, view contains List_mapAny, List_mapAny contains SkyCall. Read them as "this share of samples had that frame on the stack", never as a partition.

Two thirds of CPU samples have a reflective higher-order call on the stack. That is the single largest structural fact in this profile.

The passes after view, and the one that is thrown away

passCPUallocations
HtmlToVNode — the ElementHtmlVNode second tree build5.3 – 5.8%4.1 – 4.2%
renderVNode — the full-page HTML string5.3 – 5.7%2.9 – 3.1%
applyStyleInjections — the style walks~0.9%below profile resolution
diffTrees — the only output the interaction needs1.1 – 1.2%0.09 – 0.13%

The reply on the wire is 411–413 bytes and two patches. To produce it the server rebuilds the whole Element tree through reflective dispatch, converts it to Html, converts that to VNode, renders the entire page to a string, and then diffs — and the diff is 1% of the cost. The full-page HTML string is built on every interaction and is not what is sent.


4. A measurement defect: CPU self-time attribution is unreliable here

Found by having three runs rather than one. It changes what may be claimed from a profile on this host.

At 94 elements — 527–546 interactions/sec, so a very high syscall rate — the three repeats put syscall.rawsyscalln at 42.7%, 88.7% and 87.0% of self-time, while the runtime GC bucket moves the opposite way (27.8%, 4.4%, 4.7%). Throughput across those same three runs agrees to 3.4%, allocations to 0.2%, total process CPU to 0.6%. The work is identical; only its attribution moves. At 30 elements all three runs read 93.5–94.5% syscall, which cannot be reconciled with a measured fixed term of 0.12 ms.

The instability tracks interaction rate. At 974 and 1614 elements — 31–55 interactions/sec — the syscall bucket is 2.6–5.1% and every bucket repeats to within a percentage point:

bucket (disjoint self-time, 974 elements)r1r2r3
GC + allocator49.6%49.4%49.0%
reflect machinery23.3%23.9%24.5%
Sky runtime (sky-app/rt)6.1%7.0%6.6%
write syscall4.7%4.7%5.1%
scheduler + other3.8%4.0%4.0%
compiled Sky logic (main.)4.1%3.9%2.7%
map + hash3.4%3.2%3.8%
memmove3.7%3.3%3.6%
netpoll1.3%0.7%0.7%

Half the machine is the garbage collector and its allocator. A quarter is reflection. Under 4% is the user's compiled Sky.

Consequences, and they are binding:

Profiler overhead: not measurable

Unprofiled control (PROFILE=0, the plain app binary, noprof-g1/) against the profiled runs, same sizes, three each:

elementsunprofiledprofiled
94525.9 / 541.4 / 542.5 /s527.3 / 544.7 / 545.5 /s
97453.6 / 54.2 / 54.3 /s53.8 / 54.5 / 54.6 /s

Ranges overlap completely; the profiled arm is 0.5% faster at both sizes. No profiler overhead is claimed — it is below the ±1.6% run-to-run spread. (The archived showcase figure was 2.3%.)


5. GOMAXPROCS 1 and 8

Both are reported because a 1-core profile makes GC appear inline and distorts the shape. Same sizes, same three repeats (g8.tsv):

elementsint/sec, 1 coreint/sec, 8 coresscalems/int, 1 corems/int, 8 cores
301493 – 15104021 – 42642.7×0.64 – 0.681.18 – 1.20
62771 – 7992622 – 27533.4×1.22 – 1.311.99 – 2.17
94527 – 5451515 – 19983.3×1.78 – 1.922.48 – 3.13
206253 – 260965 – 9893.8×3.78 – 4.056.25 – 6.42
382138 – 143536 – 5553.9×7.00 – 7.1911.76 – 12.09
97454 – 55212 – 2153.9×17.8 – 18.729.7 – 30.2
161431 – 32130 – 1324.1×31.7 – 32.949.7 – 52.5

Eight cores buy 2.7–4.1×, and per-interaction CPU rises 1.6–1.8× — the usual multicore GC and scheduling tax, consistent with the archived showcase scaling sweep (1.6× from 1 to 8 cores). The fixed term at GOMAXPROCS=8 is 0.43 ms (95%CI 0.10–0.76) from the three smallest sizes: larger than the 1-core figure, still far under 1 ms.

The one outlier in the whole matrix is cpu-g8/p5-r3, 1515/s against 1956 and 1998. It is left in.


6. 26-ui-showcase at the same commit — the poster costs less

Same harness, same validity gates, same commit, so this is the first like-for-like comparison of the two apps (showcase-g1/, showcase-g8/; all six runs "patch_rate": 1).

showcase, 384 elskyforum, 382 elratio
ms / interaction, 1 core4.14 / 4.26 / 4.407.00 / 7.15 / 7.191.67×
interactions/sec, 1 core235 / 235 / 246138 / 142 / 1431.71×
interactions/sec, 8 cores844 / 945 / 949536 / 555 / 5551.68×
objects / interaction44,566 – 44,89295,972 – 96,6462.15×
kB / interaction2,828 – 2,8494,816 – 4,8501.70×
µs / element11.118.61.67×
objects / element1162522.17×

Ranges do not overlap on any row.

At equal element counts, an application costs 1.67× more time and allocates 2.17× more objects per element than the poster. The showcase is not merely a different app: as a per-element cost model it understates a real one by a factor of two on the quantity — allocation — that drives the cost.

The mechanism is the one the architecture consult identified. 99.3% of showcase's construction sites are model-independent, so its per-element work is largely a constant tree walk; skyforum routes 90.6% of its nodes through List.indexedMap with a first-class function value, which is the asList → per-element reflect.Value.Call[]anyAsListT round trip of §3.

Also worth recording: HEAD is 2.2× faster on showcase than the published baseline (240 vs 108.6 interactions/sec, same 50-session closed-loop config at GOMAXPROCS=1), against the 1.80× claimed for eta-expansion plus typed list accessors. Not attributed further here — the intervening commits were not bisected.


7. Memory under sustained load, on the postgres session store

mem-pg/ — n = 100/300/500, three runs each, SKY_LIVE_STORE=postgres against a real PostgreSQL 14 cluster (shared_buffers = 32MB, sized like the small instance the capacity question is about), sustained load at -think 1s, GOMAXPROCS=8, SKY_LIVE_IDLE_EVICT at its 5 m default. Every run "patch_rate": 1; every run's store.txt records store_opened postgres; 2,845 rows landed in sky_sessions.

nRSS under load, no forced GC (MB)after forced GC20 s after load stopsHeapAlloc under load (MB)goroutines
10096.8 / 98.3 / 99.3identical97.6 / 99.2 / 100.123.4 / 27.0 / 31.44.1 / session
300207.7 / 209.6 / 211.0identical208.4 / 210.3 / 211.855.4 / 57.6 / 63.64.04 / session
500300.5 / 338.4 / 341.5identical301.0 / 339.2 / 342.392.1 / 116.7 / 117.34.02 / session
RSS_MB = 39.96 + 0.5717 x sessions      n = 9, R2 = 0.9866
  base         40.0 MB   (se 8.6)
  per session  585 kB    (se 26)

The sizing input a capacity table should use

Base 40 MB, plus 585 kB per concurrent session, of RSS under load. Not either of the two numbers already in circulation:

Two supporting facts. RSS after a forced GC is byte-identical to RSS before one at every n — Go does not return the spans — and RSS 20 s after the load stops is higher than during it, never lower. And the durable representation of a session is 2,080 bytes (mean over 2,845 sky_sessions rows, max 2,086): 0.36% of the 585 kB it occupies resident. Almost none of a session's footprint is its model.

idleEvict changes nothing at bench timescales, and that is the honest finding

mem-pgevict/ repeats the sweep with SKY_LIVE_IDLE_EVICT=15s so the feature demonstrably engages rather than sitting at its unreachable 5 m default:

ndefault (5 m), 3 runs15 s, 1 run
10096.8 / 98.3 / 99.3 MB98.2 MB
300207.7 / 209.6 / 211.0 MB212.8 MB
500300.5 / 338.4 / 341.5 MB323.5 MB

Every 15 s figure sits inside the default arm's range. No effect is claimed. Nor should one be expected: under sustained load at -think 1s no session is idle for 15 s, so the eviction predicate (now - lastSeen > idleEvict AND no live SSE) is never true for a session that is being used. The tiered cache is a lever for idle sessions, and a sustained-load sweep is the wrong instrument for it. Recorded so that "idleEvict was allowed to fire" is not mistaken for "idleEvict was shown to help".

Does 500 sessions fit an e2-small's 2 GB alongside embedded PostgreSQL?

Yes, with roughly 3× headroom. MEASURED on this host, plus one INFERRED architecture adjustment:

Sky app, 500 sessions (MEASURED, arm64)40 + 0.585 × 500 = 333 MB
the same on x86 (INFERRED — ../../skylive-remote-validation.md found the memory figure ~30% higher)~430 MB
PostgreSQL, shared_buffers = 32MB + ~10 backends (from AGENTS.md's sizing table)~100 MB
Minimal Linux (AGENTS.md)~250 MB
Total~780 MB of 2,048 MB

The memory question is not close. CPU decides the instance, exactly as the existing remote validation says — this run measured 487–492 interactions/sec sustained at n = 500 on 8 M1 cores, and an e2-small has 2 shared vCPU. What this run does change is the per-interaction cost that sizing rests on: 1.78–1.92 ms at 94 elements against the 9.15 ms the e2-small guidance was derived from. That is a 5× cheaper interaction, and INFERRED, it should move the e2-small knee proportionally — but it is an inference across an architecture and a machine class, and it is not measured here. Re-running the GCE arm at HEAD, with this harness, is the honest way to settle it.


8. Defects, and one non-defect I nearly published

Reported per the brief; no fix attempted.

  1. NOT A DEFECT — SKY_LIVE_STORE_PATH handles postgres:// correctly. Recorded because it was nearly published as one. A first probe had the app log five connect attempts against user=anzel database= on the default unix socket and fall back to memory, which reads exactly like a dropped URL. It was my harness: pg-up.sh emitted the DSN on stdout after pg_ctl's waiting for server to start.... done / server started, and DSN=$(pg-up.sh) captured all three lines. Re-probed with a clean value, all five spellings open the storepostgres://…?sslmode=disable, postgres://… bare, postgresql://…, the libpq keyword form, and DATABASE_URL. The claim is withdrawn.

  2. A harness hazard worth naming, in the runtime's favour. Sky.Live's dev fallback — an unreachable durable store logs a warning and degrades to memory, and is a hard failure only when ENV is set — is right for a developer and a trap for a benchmark: the app then serves every request correctly and the run comes out valid, patch-bearing, repeatable and about a different system. It caught me twice (the DSN above, and a word-split DSN in my own driver), and produced a complete set of "valid": true numbers labelled postgres while running on memory. forumrun.sh now reads the store banner out of app.log and rejects any run whose opened store differs from the one it asked for (store.txt in every memory run).

  3. skyliveload's handler choice was the corpus defect, described in README.md. Recorded here so the findings live in one list.


9. What this run did NOT measure

Named rather than left to be assumed covered.