perf(binary): drop the per-node box around NodeRef content - #1216
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI (base), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| wacore/binary/src/node.rs | Inlines NodeContentRef within NodeRef and consistently updates constructors, conversions, accessors, serialization, and yoke-backed ownership. |
| wacore/binary/src/decoder.rs | Removes the content boxing step while retaining the same decoded content variants and indirect child-node recursion. |
| wacore/binary/src/encoder.rs | Replaces boxed-content dereferencing with direct borrowing without changing encoding behavior or traversal order. |
| wacore/binary/src/marshal.rs | Updates reservation and capacity estimation to inspect inline content while preserving existing thresholds and traversal. |
| wacore/src/xml.rs | Adapts borrowed content access without changing XML rendering behavior. |
Reviews (2): Last reviewed commit: "perf(binary): drop the per-node box arou..." | Re-trigger Greptile
There was a problem hiding this comment.
No issues found across 24 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Requires human review: The change removes an allocation but alters the type of the public NodeRef::content field (Option<Box<...>> to Option<...>), a breaking API contract for downstream consumers. A human should decide whether that break is acceptable; the mechanical fallout doesn't remove that decision.
Re-trigger cubic
NodeRef::content was Option<Box<NodeContentRef>>, so every node carrying any content allocated, including the common leaf whose content is a borrowed slice of the frame and allocates nothing itself. The box existed only to keep the field pointer-sized. A device fanout pays one per node: a native profile puts malloc, free and NodeRef's drop glue at 11.3% of that decode, against 3.9% on an ack, which is why #1214 measuring only the ack concluded the tree was nearly free. Dropping the box does not create an infinite type, because the recursion goes through Box<[NodeRef]>. NodeRef grows from 48 to 72 bytes, and the memcpy that buys back does not show up: every benchmark improves, with the gain tracking how many nodes carry content. Allocations per decode: fanout 45 to 27, large 50 to 26, ack unchanged at 1 since it has no content. Time over 2M iterations, pinned: fanout -10.8%, large -11.0%, ack -6.4%, small -5.4%. Instructions on the fanout drop 9.5% under callgrind. The field is public, so this reaches 22 files outside wacore/binary. All of it is as_deref() becoming as_ref(); no logic moves.
a395514 to
a8545c7
Compare
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
Stacked on #1215, which is stacked on #1214. Review those first; the diff against #1215 is one type change plus its mechanical fallout.
Summary
NodeRef::contentwasOption<Box<NodeContentRef>>, so every node carrying any content allocated. That includes the commonest leaf in a fanout, an<enc>whose content is aCow::Borrowedslice of the frame and allocates nothing itself: the box was the only allocation, and it existed to keep the field pointer-sized.#1214 refused a visitor API on the grounds that the tree is nearly free, measuring one allocation and 3.9% on a 30-byte ack. That was right for the ack and wrong as a generalization: the same PR reported 45 allocations for a fanout and never used the number. A native profile of the fanout puts
malloc,freeandNodeRef's drop glue at 11.3%, and a fanout is not a corner case, it is what the send path builds for every group message, one child per device.Measurement
Against #1215,
perf stat -r 5over 2M iterations, pinned to one core:Allocations per decode, from
AllocProfiler:Instructions on the fanout drop 9.5% under callgrind, which is the instrument CodSpeed uses, so CI should report the same order.
size_of::<NodeRef>()goes from 48 to 72 bytes. That is the cost this trades against, and it does not show up: every case improves, includingsmall, which has no content and therefore gains nothing from the removed allocation. The gain tracks how many nodes carry content, which is the shape the change predicts.What this does not cover. Native only. The 13.2% that motivated the proposal is wasm32, where
dlmallocis dearer, and #1214 already showed gains do not always cross targets.Design
Three directions were on the table. This is the first and cheapest: the type change. Dropping the box does not create an infinite type, because the recursion runs through
Box<[NodeRef]>, not throughcontent. The other two (an arena per decode, and the visitor) both change what the caller receives, so they are not optimizations of the same thing; the arena in particular would need a lifetime the currentunmarshal_refsignature cannot express.This reverses a decision that was made once before, and that deserves saying plainly. A previous sweep considered inlining
AttrsRefstorage and rejected it, on the grounds that inlining would fatten everyNodeRefand pay the tree back in memcpy. That reasoning is sound and it is the risk here too. Two things differ: that change was about 192 bytes per node, this one is 24; and the earlier rejection was reasoned, while this one is measured, in all four benchmark shapes, with none regressing. If the numbers had gone the other way forsmall, which is pure cost under this change, that would have settled it.The box itself was never a considered decision. It arrived with
NodeRefin the commit that created the zero-copy type, carries no comment, and nothing since has justified it.Why this is not the visitor
#1212 refused a derivation cache in the core, and #1214 refused a visitor API. Both stay refused, and this PR needs neither: it adds no state, no bound, no policy, no API.
unmarshal_refreturns the same tree with the same lifetimes; only a field's layout changed.The visitor would come back only if this and an arena together left most of the 11.3% on the table. This takes the allocation count down by 40%, so that is not the situation.
Blast radius
contentis a public field, so this reaches 22 files outsidewacore/binary, inwacore/src/iq/*,wacore/src/stanza/*andsrc/. Every one of them isas_deref()becomingas_ref(); no logic moves and no signature changes. The proposal scoped itself towacore/binaryalone, which turned out not to be possible for this direction, and that is worth weighing.NodeRefderivesyoke::Yokeable, andwacore-binaryowns the workspace's only load-bearingunsafe, socargo miri test -p wacore-binary --libwas run against the new layout: 116 passed.Validation
The roundtrip proptest passes unedited. Full matrix left to CI.
Follows #1211, #1212, #1213, #1214 and #1215.