Instruction file imported from YUKIKEDA/XylemUI (
.cursor/rules/perf.mdc). Copyright stays with the author.
Performance rules
The hot path is the frame path (docs/design.ja.md §2.10, §11.5): input dispatch, Batch flush, invalidation, materialization, layout, DisplayList and HitTestList build, upload, present. It also covers pointer move and hover handling. Construction of nodes, styles, and resources may allocate; the steady-state frame does not.
These rules come from the shape of Cysharp's R3, ZLinq, ZString, MemoryPack, and MagicOnion (read in .repos/), applied to this framework. A claim that a rule was followed is a measurement, not a reading of the code (.cursor/rules/bench.mdc).
Allocation
- The frame path does not allocate in steady state. The Zero-Alloc tests (design §11.3) assert it; an allowed exception needs an Issue and a design entry.
- No LINQ,
paramsarrays,stringconcatenation or interpolation intostring, boxing (objectparameters, non-generic interfaces on structs,Enum.HasFlagon old paths,foreachover an interface), or lambdas that capture on the frame path. Astaticlambda is fine: its delegate is cached. foreachover aList<T>field goes throughCollectionsMarshal.AsSpan; over aDictionaryit uses the struct enumerator directly, neverIEnumerable<T>.- A cache or pool that grows on demand has a hard upper bound and a counter of the requests it refused at the bound. Its steady-state limit is not enough: in S8b the glyph atlas added pages while every plot was in use in the frame, and a caller that never started a new frame would have added pages without end (
GlyphAtlas.PageLimit,RefusedCount). - Buffers that live across frames are owned and reused: reset the used length, grow only when full (
DisplayListBuffer, design §5.0.1). Short-lived scratch comes fromArrayPool<T>.Sharedorstackalloc(fixed small size only), and is returned in afinally. Clear a returned array only whenRuntimeHelpers.IsReferenceOrContainsReferences<T>(). - A
[ThreadStatic]scratch buffer may back a builder that is disposed before the method returns (ZString, MagicOnion). In debug builds the rent takes the buffer out of the slot, so nested use throws instead of corrupting it. - Text that changes every frame uses
Text(TextBuilder)and writes into the node's own buffer (design §2.13), notText(Func<string>). Whether a text update joins the Zero-Alloc scenarios is decided after S8 measures shaping. - An
asyncAPI on a repeated path returnsValueTaskbacked by a pooledIValueTaskSource(ManualResetValueTaskSourceCore<T>), not a newTaskeach time.
Collections of subscribers and handles
- A subscriber list that changes during notification is an intrusive linked list whose node is the subscription itself (R3
Subject): no separateIDisposableper subscription, O(1) removal, and a version stamp so nodes added during a notification are skipped without copying the list. - An add/remove set keyed by slot uses a free list: removal nulls the slot, the index is the removal key, and the next add reuses the first empty slot (R3
FreeListCore). Do not useImmutableArrayor copy-on-write arrays for lists that change at run time. - A fixed small number of items is held in fields, not an array (R3
Disposable.Combineup to eight).
Dispatch and generics
- No interface or virtual dispatch per element on the frame path. Dispatch once per call (a
switchon a type tag, a sealed class hierarchy matched once), or use a generic method constrained to a struct (where T : struct, IFoo) so the JIT and NativeAOT specialize it. - Per-type data is a
static class Cache<T>field, filled by generated code or a static constructor without reflection, never aDictionary<Type, ...>lookup on the frame path. JIT-constant checks (typeof(T) == typeof(int),RuntimeHelpers.IsReferenceOrContainsReferences<T>()) choose fast paths at no cost. - A struct walker over a tree (
ITraverser-style, ZLinq) iterates the arena without allocating; its explicit stack comes from a pool and is returned onDispose. - Iteration over a sequence the framework owns uses a single
TryGetNext(out T)call per element rather thanMoveNext+Current(ZLinq).
Fast path and slow path
- Throwing lives in a separate method marked
[DoesNotReturn](and[StackTraceHidden]when the frame is noise), so the caller stays small enough to inline. Throw helpers in this repository areThrowHelper.*. - Rare work (growing a buffer, requesting a new segment) lives in a
[MethodImpl(MethodImplOptions.NoInlining)]method; the common check stays inline (MemoryPackGetSpanReference/RequestNewBuffer). [MethodImpl(MethodImplOptions.AggressiveInlining)]only where a measurement shows an effect, and the PR says which.- A writer over a buffer may be a
ref structholding areffield and a remaining length instead of aSpanfield (MemoryPackMemoryPackWriter). Bounds are checked once per reservation, not per byte.
Memory layout
- Data read together is stored together. The arena stores
UiNodestructs and is walked by index; GPU-bound data is SoA and copied withMemoryMarshal.AsBytes(design §5.0.1). - Unmanaged structs that cross to native code or the GPU have
[StructLayout(LayoutKind.Sequential)]; others may useLayoutKind.Auto. - A
refinto an arena is never held across an operation that can grow the arena (design §2.5, Invariant 12). - Explicit SIMD (
Vector128<T>,Vector<T>) only where a measurement shows the path is the bottleneck, with a scalar path that gives the same result, and a test that compares them.
Claims
A speed or memory improvement is claimed with numbers against a named baseline (.cursor/rules/bench.mdc). Not with a hunch, an IL reading, or a micro-benchmark of a different path.
// BAD — allocates a closure, a delegate, an enumerator, and a list every frame
var dirty = nodes.Where(n => n.Dirty.SelfDirty != DirtyFlags.None && n.Id != skip).ToList();
// GOOD — no allocation in steady state
foreach (ref readonly var node in CollectionsMarshal.AsSpan(nodes))
{
if (node.Dirty.SelfDirty == DirtyFlags.None || node.Id == skip) continue;
// ...
}