Found while verifying a round-4 fix, outside the audit corpus.
The call chain
render_frame_task (per frame)
→ render_scene_frame_scaled_with_prev_bg
→ prepare_scene (engine/render/scene.rs:549)
→ deserialize_children (engine/render/scene.rs:534)
→ for each child: serde_json::from_value::<ChildComponent>(v.clone())
prepare_scene is three lines long and has no cache. Its result depends only on scene, never on frame_in_scene — it is identical for every frame of a scene, and recomputed in full for each one.
The cost
Per frame, per child:
- a
.clone() of the whole serde_json subtree;
- a deserialization into
ChildComponent, whose component field is an untagged enum with 57 variants — serde tries them in order, re-parsing the same object on every failed attempt.
On a 1200-frame render with 50 nodes, that is 60,000 clones and 60,000 deserializations of a 57-variant enum, for a strictly constant result.
Unmeasured. I am not quoting a figure because I have not profiled it — but the order of magnitude deserves profiling, especially since the round-3 paint workstream (PR #154) already found a comparable factor on another per-frame path (unbounded save_layer: 42-60s → 0.5s over 60 frames).
Direction
Deserialize once per scene, ahead of the frame loop, and pass the Vec<ChildComponent> to the render functions. The render_frame_v2 / render_frame_v2_scaled entry points already take &[ChildComponent]; it is the render_scene_frame* wrappers that call prepare_scene each time. The fix is mostly hoisting the call one level, not re-architecting.
One detail to watch: deserialize_children emits a stderr warning per unreadable child. Today that fires once per frame; hoisting it naturally reduces it to once per scene, which is also the desirable behaviour.
Suggested verification
A before/after benchmark on a realistic scenario (examples/mega-showcase.json), comparing total render time. Plus a test asserting prepare_scene is no longer called from the per-frame path.
Found while verifying a round-4 fix, outside the audit corpus.
The call chain
render_frame_task(per frame)→
render_scene_frame_scaled_with_prev_bg→
prepare_scene(engine/render/scene.rs:549)→
deserialize_children(engine/render/scene.rs:534)→ for each child:
serde_json::from_value::<ChildComponent>(v.clone())prepare_sceneis three lines long and has no cache. Its result depends only onscene, never onframe_in_scene— it is identical for every frame of a scene, and recomputed in full for each one.The cost
Per frame, per child:
.clone()of the wholeserde_jsonsubtree;ChildComponent, whosecomponentfield is an untagged enum with 57 variants — serde tries them in order, re-parsing the same object on every failed attempt.On a 1200-frame render with 50 nodes, that is 60,000 clones and 60,000 deserializations of a 57-variant enum, for a strictly constant result.
Unmeasured. I am not quoting a figure because I have not profiled it — but the order of magnitude deserves profiling, especially since the round-3 paint workstream (PR #154) already found a comparable factor on another per-frame path (unbounded
save_layer: 42-60s → 0.5s over 60 frames).Direction
Deserialize once per scene, ahead of the frame loop, and pass the
Vec<ChildComponent>to the render functions. Therender_frame_v2/render_frame_v2_scaledentry points already take&[ChildComponent]; it is therender_scene_frame*wrappers that callprepare_sceneeach time. The fix is mostly hoisting the call one level, not re-architecting.One detail to watch:
deserialize_childrenemits a stderr warning per unreadable child. Today that fires once per frame; hoisting it naturally reduces it to once per scene, which is also the desirable behaviour.Suggested verification
A before/after benchmark on a realistic scenario (
examples/mega-showcase.json), comparing total render time. Plus a test assertingprepare_sceneis no longer called from the per-frame path.