CloseTransaction hydrates task-free CHASM nodes it then discards

perfloop/temporal · LATE FILTERING

https://perfloop.ai/t/oss/case_rs5bd4jq64

Verdict

VERIFIED · settled 2026-06-25

What happened: By checking the task lists (SideEffectTasks and PureTasks) from the serialized component's attributes before deciding to hydrate/deserialize the component's node value during CloseTransaction, we avoid expensive unmarshaling and deserialization overhead (and its associated heap allocations) for cold components that did not change in the transaction and have no logical tasks to validate.

Empirical measurements over a tree with K=200 cold task-free components showed: - Execution time (ns/op) decreased from 1.02 ms to 0.42 ms, a 59.13% relative reduction (p-value < 10^-5). - Heap allocations (allocs/op) fell from 4,722 to 2,911, a 38.35% relative reduction (p-value < 10^-5). - All package unit tests, go vet, and race detectors passed perfectly.

Hypothesis

isActiveStateDirty is tree-global (chasm/tree.go:413-418): any single node mutation flags the whole tree dirty. closeTransactionUpdateComponentTasks (tree.go:1922-1940) then walks every node via andAllChildren and calls prepareComponentValue -> deserialize -> serialization.Decode, a full proto3 Unmarshal (tree.go:565-589, 1362), on each dirty-flagged node -- including cold nodes that did not change this transition and that carry zero tasks. The hydrated value is consumed only by closeTransactionCleanupInvalidTasks; a node with no SideEffectTasks/PureTasks has nothing to validate, so its unmarshal is pure waste, and the task lists are already available from Metadata without deserializing (the narrower form exists at the source). CloseTransaction runs on every state-machine transition; cost is O(untouched task-free nodes x their serialized payload size) per transition. Proof target: a Go benchmark over a tree with K cold task-free sub-components, mutate one leaf, measure CloseTransaction allocs/op and ns/op vs K.

Change to test: Before deserializing a dirty-flagged node for task cleanup, read its task lists from serializedNode.Metadata.GetComponentAttributes() (SideEffectTasks/PureTasks) without hydrating, and skip prepareComponentValue/deserialize entirely when both are empty, so cold task-free nodes are never proto-unmarshaled.

Where it lives

perfloop/temporal · chasm/tree.go

Evidence

Timeline