Skip to main content

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}