TreeNode weak-parent side tables cost ~9% of parse: needs a design decision #8
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Status (2026-07-12)
Open. Found by sample-profiling the parse path during the #139 pass 27-28
rounds; documented in
docs/benchmarks.mdunder "Known structure of the gap".Filed as its own issue because recovering the cost needs a design decision,
not an optimization pass, and the optimization log is the wrong place for
that conversation.
The finding
TreeNode.init(adopting:)'schild.parent = selfrunsswift_weakAssignonce per child, and the first weak reference to any object makes the Swift
runtime allocate a side table on it. So during tree building every parent
node pays a side-table allocation, every child pays the weak-assign runtime
call (~9% of total parse time in the pass-28 profile, the largest single
non-scanning cost), and at teardown every parent releases through the slow
side-table path (
doDecrementSlow, visible as ~20% of a parse-and-discardprofile's samples).
Why it is not an optimization pass
TreeNode.parentis public API declaredweak, and its nil-after-releasebehavior is documented and load-bearing: "a detached subtree is freed once no
one references it" and "a child held on its own loses its ancestry the moment
the rest of the tree is released" (TreeNode.swift's doc comments). The
alternatives all change observable semantics or safety:
unownedavoids the side table (it uses the object header's unownedrefcount) but trades nil-after-release for a trap: any consumer that today
legitimately reads
parent == nilafter dropping the tree would crashinstead. A documented public behavior change.
unowned(unsafe)is a dangling pointer: memory-unsafe, contradicts thepackage's never-crash posture outright.
Options to evaluate
parentis an index; no per-node weak machinery at all, teardown is onedeallocation. Largest win, largest change:
TreeNodeis a reference typewhose identity semantics (
===, mutation through shared references) arepublic API, so this likely means a parallel type or a major version.
unownedwith a documentedmigration: cheap to implement, but every existing consumer relying on
nil-after-release breaks at runtime, the worst kind of break.
ergonomic design; keep it, and point performance-critical consumers at the
event stream (
EventReader) which builds no tree.Option 3 is the current de-facto state and is defensible; this issue exists
so the choice is made deliberately against measurements, not by default.
Measurements and profile details:
docs/benchmarks.md, "Known structure ofthe gap".
Decision (2026-07-12): option 3, accept for now; option 1 is the designed future path; option 2 rejected outright.
Reasoning, from the measurements rather than preference:
Option 2 (
unowned) is rejected permanently. A trap onparentaccess after the tree is released contradicts the package's never-crash posture (docs/production-readiness.md, stopper 4) and breaks the documented nil-after-release contract that existing consumers may legitimately rely on. A runtime break of that kind is the worst failure mode this project defines for itself; no performance win at this size justifies it.Option 1 (arena/index-based tree) is right but mis-sequenced today. It recovers this finding's ~9% plus the side-table teardown cost, and it is the honest Swift equivalent of libxml2's memory model. But it is a large, correctness-sensitive change to the package's most load-bearing public type, and the same profile shows scanning carrying ~47% of parse. Taking the big risky change for the smaller lever, while the bigger lever is untouched, is the wrong order. The arena becomes worth designing when the scanning-architecture work has landed and the residual gap still justifies it.
Option 3 (accept and document) is therefore the standing state. The cost is recorded in
docs/benchmarks.mdunder Known structure of the gap, together with this decision and the explicit reopening condition. Performance-critical consumers who need no tree already have the event stream (EventReader), which never pays this cost.Closing as decided. Reopen when the scanning-architecture lever (epic #6) has landed and the remaining parse gap makes the arena's cost/benefit favorable.