bevy_react/reconcile/stats.rs
1//! Live instrumentation of the op-apply hot path, the per-batch side
2//! channels feeding it, and the `SystemParam` bundles that keep
3//! [`apply_js_ops`](crate::reconcile::apply_js_ops) under Bevy's per-system
4//! parameter limit.
5
6use bevy::ecs::system::SystemParam;
7use bevy::prelude::*;
8
9use crate::ui_map::AtlasLayoutCache;
10
11/// Live instrumentation of the [`apply_js_ops`](crate::reconcile::apply_js_ops)
12/// hot path. Updated once per frame
13/// that applies at least one reconciler op (empty frames leave it untouched), so
14/// a benchmark driver — or any consumer — can poll `applied_count` to detect
15/// "my flushed batch has landed" and read the timing of the most recent batch.
16///
17/// Note `last_translate` measures only the op→command *queuing* in
18/// [`apply_js_ops`](crate::reconcile::apply_js_ops); the queued `Commands`
19/// (entity spawn / component insert /
20/// hierarchy) execute later at a sync point, and `bevy_ui` layout later still —
21/// neither is included here. `last_apply_end` is exposed so a downstream timer
22/// can bracket those phases (e.g. up to `UiSystems::Layout`).
23///
24/// Timings are wall-clock, measured on native only; on web they stay zero/`None`
25/// (`std::time::Instant` is unavailable on wasm).
26#[derive(Resource, Default, Debug, Clone, Copy)]
27pub struct OpApplyStats {
28 /// Count of non-empty op batches applied since startup (one increment per
29 /// frame that applied at least one op).
30 pub applied_count: u64,
31 /// Count of [`Op::Reset`](crate::protocol::op::Op::Reset)s applied (a cold
32 /// hot-reload tears the tree down).
33 /// Devtools uses it to clear its warning-dedup state, so a reloaded app's
34 /// re-decoded invalid values flag again (the JS mirror was also reset).
35 pub reset_count: u64,
36 /// Like `applied_count`, but only counting applies that included at least
37 /// one APP flush (per-batch origin flags — see [`FlushFlags`]). The
38 /// devtools panel's own repaints bump only `applied_count`; batch-stats
39 /// emission keys off this so the panel never reports (and re-triggers
40 /// itself with) its own commits. With no flags channel wired (headless
41 /// tests), every apply counts as app.
42 pub app_applied_count: u64,
43 /// Number of ops in the most recently applied batch.
44 pub last_ops: usize,
45 /// How long the most recently applied ops idled in the channel across the
46 /// frame boundary: the OLDEST coalesced batch's [`FlushStamps`] stamp →
47 /// this frame's [`FrameStamp`]. Structural queue wait, typically ~one
48 /// vsync period (a Bevy-triggered commit always lands just after that
49 /// frame's drain); can exceed one frame when batches coalesce. Zero when
50 /// the stamp channel or frame stamp is missing (headless tests) and on web.
51 pub last_frame_wait: std::time::Duration,
52 /// The in-frame leg of the same span: max(batch stamp, frame start) →
53 /// the start of [`apply_js_ops`](crate::reconcile::apply_js_ops) — time
54 /// eaten by schedules/systems that
55 /// ran before the drain this frame. With no [`FrameStamp`] present the
56 /// whole send→apply span lands here. Zero when no stamp channel is wired
57 /// (headless tests) and on web.
58 pub last_pre_apply: std::time::Duration,
59 /// Time spent translating the most recent batch into ECS commands — the
60 /// [`apply_js_ops`](crate::reconcile::apply_js_ops) body only. Excludes
61 /// command execution and layout.
62 pub last_translate: std::time::Duration,
63 /// The instant [`apply_js_ops`](crate::reconcile::apply_js_ops) finished
64 /// queuing the most recent batch
65 /// (native only). A later system can subtract this from a post-layout instant
66 /// to time command execution + layout.
67 pub last_apply_end: Option<std::time::Instant>,
68}
69
70/// Receiver of per-batch send instants, stamped by the JS host's `op_flush`
71/// right before each batch enters the ops channel (see `js_thread.rs`). Both
72/// FIFOs are aligned (stamp sent first), so draining one stamp per received
73/// batch keeps them in lockstep. Feeds [`OpApplyStats::last_frame_wait`] and
74/// [`OpApplyStats::last_pre_apply`].
75#[derive(Resource)]
76pub struct FlushStamps(pub(crate) crossbeam_channel::Receiver<std::time::Instant>);
77
78/// The instant Bevy's `First` schedule ran this frame (native only; stays
79/// `None` on web and in headless tests that never add [`mark_frame_start`]).
80/// The frame boundary that splits [`OpApplyStats::last_frame_wait`] from
81/// `last_pre_apply`.
82#[derive(Resource, Default, Debug, Clone, Copy)]
83pub struct FrameStamp(pub Option<std::time::Instant>);
84
85/// Stamp the frame's start. Registered in `First` (native only).
86#[cfg(not(target_arch = "wasm32"))]
87pub(crate) fn mark_frame_start(mut stamp: ResMut<FrameStamp>) {
88 stamp.0 = Some(std::time::Instant::now());
89}
90
91/// Receiver of per-batch devtools-origin flags (`true` = the devtools panel's
92/// own React container flushed the batch), sent by the JS host's `op_flush`
93/// with the same aligned-FIFO discipline as [`FlushStamps`]. Feeds
94/// [`OpApplyStats::app_applied_count`].
95#[derive(Resource)]
96pub struct FlushFlags(pub(crate) crossbeam_channel::Receiver<bool>);
97
98/// The per-batch side channels (`Option`: absent in headless unit tests),
99/// bundled as one `SystemParam` so
100/// [`apply_js_ops`](crate::reconcile::apply_js_ops) stays within Bevy's
101/// 16-parameter limit.
102#[derive(SystemParam)]
103pub struct FlushMeta<'w> {
104 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
105 pub(super) stamps: Option<Res<'w, FlushStamps>>,
106 pub(super) flags: Option<Res<'w, FlushFlags>>,
107 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
108 pub(super) frame: Option<Res<'w, FrameStamp>>,
109}
110
111/// The asset stores + caches the op-apply path builds components from: the
112/// `<image atlas>` `TextureAtlasLayout`s. Bundled as one `SystemParam`
113/// so [`apply_js_ops`](crate::reconcile::apply_js_ops) stays under Bevy's
114/// per-system parameter limit.
115#[derive(SystemParam)]
116pub struct UiAssets<'w> {
117 pub(super) layouts: ResMut<'w, Assets<TextureAtlasLayout>>,
118 pub(super) atlas_cache: ResMut<'w, AtlasLayoutCache>,
119}
120
121/// Split "op_flush send → apply start" into the cross-frame queue wait and the
122/// in-frame leg at the frame-start boundary. Saturating: a stamp landing
123/// mid-frame (after frame start, e.g. a JS-timer commit) clamps the wait to
124/// zero; jitter never panics. A `None` frame start puts the whole span in the
125/// in-frame leg.
126#[cfg(not(target_arch = "wasm32"))]
127pub(super) fn split_pre_apply(
128 stamp: std::time::Instant,
129 frame_start: Option<std::time::Instant>,
130 apply_start: std::time::Instant,
131) -> (std::time::Duration, std::time::Duration) {
132 let boundary = frame_start.map_or(stamp, |fs| fs.max(stamp));
133 (
134 boundary.saturating_duration_since(stamp),
135 apply_start.saturating_duration_since(boundary),
136 )
137}
138
139#[cfg(test)]
140mod tests {
141 use super::super::test_util::op_app;
142 use super::*;
143 use crate::protocol::{NodeId, op::Op};
144
145 /// The per-batch origin flags attribute applies: a devtools-flagged batch
146 /// bumps `applied_count` but not `app_applied_count`, so devtools batch
147 /// stats (keyed off the app counter) skip the panel's own repaints —
148 /// otherwise stats → panel repaint → new batch → stats… self-observes at
149 /// frame rate.
150 #[test]
151 fn devtools_flagged_batches_skip_app_applied_count() {
152 let (mut app, ops_tx) = op_app();
153 let (flags_tx, flags_rx) = crossbeam_channel::unbounded::<bool>();
154 app.insert_resource(FlushFlags(flags_rx));
155 let create = |id: NodeId| Op::Create {
156 id,
157 kind: "node".into(),
158 props: Box::default(),
159 text: None,
160 };
161
162 // A devtools-flagged batch (the panel's own commit): applied, but not
163 // an APP apply.
164 flags_tx.send(true).unwrap();
165 ops_tx.send(vec![create(1)]).unwrap();
166 app.update();
167 let stats = *app.world().resource::<OpApplyStats>();
168 assert_eq!((stats.applied_count, stats.app_applied_count), (1, 0));
169
170 // An app batch bumps both — even when a devtools batch coalesces into
171 // the same apply.
172 flags_tx.send(false).unwrap();
173 ops_tx.send(vec![create(2)]).unwrap();
174 flags_tx.send(true).unwrap();
175 ops_tx.send(vec![create(3)]).unwrap();
176 app.update();
177 let stats = *app.world().resource::<OpApplyStats>();
178 assert_eq!((stats.applied_count, stats.app_applied_count), (2, 1));
179 }
180
181 #[test]
182 fn split_pre_apply_splits_wait_and_in_frame() {
183 use std::time::Duration;
184 let t0 = std::time::Instant::now();
185 let t1 = t0 + Duration::from_millis(12);
186 let t2 = t1 + Duration::from_millis(3);
187 assert_eq!(
188 split_pre_apply(t0, Some(t1), t2),
189 (Duration::from_millis(12), Duration::from_millis(3))
190 );
191 // A stamp landing mid-frame (after frame start, e.g. a JS-timer
192 // commit) clamps the wait to zero — the whole span is in-frame.
193 assert_eq!(split_pre_apply(t1, Some(t0), t2), (Duration::ZERO, t2 - t1));
194 // No frame stamp (headless): the whole span is the in-frame leg.
195 assert_eq!(split_pre_apply(t0, None, t2), (Duration::ZERO, t2 - t0));
196 }
197
198 /// The send→apply span splits at the frame boundary: the cross-frame queue
199 /// wait lands in `last_frame_wait`, the in-frame remainder in
200 /// `last_pre_apply`.
201 #[test]
202 fn flush_stamp_splits_frame_wait_from_pre_apply() {
203 use std::time::{Duration, Instant};
204 let (mut app, ops_tx) = op_app();
205 let (stamps_tx, stamps_rx) = crossbeam_channel::unbounded::<Instant>();
206 app.insert_resource(FlushStamps(stamps_rx));
207 // Both boundaries in the past so ordering is stamp < frame start <
208 // apply start (a future-dated frame stamp would saturate the in-frame
209 // leg to zero instead).
210 let now = Instant::now();
211 let stamp = now - Duration::from_millis(30);
212 let frame_start = now - Duration::from_millis(10);
213 app.insert_resource(FrameStamp(Some(frame_start)));
214
215 stamps_tx.send(stamp).unwrap();
216 ops_tx
217 .send(vec![Op::Create {
218 id: 1,
219 kind: "node".into(),
220 props: Box::default(),
221 text: None,
222 }])
223 .unwrap();
224 app.update();
225
226 let stats = *app.world().resource::<OpApplyStats>();
227 // Both endpoints are fixed instants, so the wait is exact.
228 assert_eq!(stats.last_frame_wait, Duration::from_millis(20));
229 // The in-frame leg runs to the real apply start — at least the fixed
230 // 10ms between the frame stamp and `now`.
231 assert!(stats.last_pre_apply >= Duration::from_millis(10));
232 }
233}