Skip to content

Commit 2fccb3b

Browse files
huntiemeta-codesync[bot]
authored andcommitted
Add idle frame support to Performance timeline (facebook#56400)
Summary: Pull Request resolved: facebook#56400 Implement Idle frame spans in Chrome DevTools, by emitting synthetic `NeedsBeginFrameChanged` + `BeginFrame` + `DrawFrame` events. An idle frame = a vsync where no new rendering occurred. Changelog: [Internal] Differential Revision: D97502569
1 parent 250e82a commit 2fccb3b

File tree

5 files changed

+181
-16
lines changed

5 files changed

+181
-16
lines changed

packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ @implementation RCTFrameTimingsObserver {
5757
std::optional<FrameData> _lastFrameData;
5858

5959
std::atomic<bool> _encodingInProgress;
60+
61+
// Offset (in nanoseconds) from mach_absolute_time (CLOCK_UPTIME_RAW) to
62+
// steady_clock (CLOCK_MONOTONIC_RAW). CADisplayLink timestamps use
63+
// mach_absolute_time which excludes sleep time, while HighResTimeStamp
64+
// uses steady_clock which includes it. Without this correction, all
65+
// CADisplayLink timestamps are shifted into the past by cumulative device
66+
// sleep time.
67+
int64_t _clockOffsetNanos;
6068
}
6169

6270
- (instancetype)initWithScreenshotsEnabled:(BOOL)screenshotsEnabled callback:(RCTFrameTimingCallback)callback
@@ -79,14 +87,25 @@ - (void)start
7987
_frameCounter = 0;
8088
_lastScreenshotHash = 0;
8189
_encodingInProgress.store(false, std::memory_order_relaxed);
90+
91+
// Compute the offset between steady_clock and mach_absolute_time once at
92+
// start, so we can correctly convert CADisplayLink timestamps.
93+
{
94+
auto steadyNow = std::chrono::steady_clock::now();
95+
CFTimeInterval machNow = CACurrentMediaTime();
96+
auto steadyNanos = std::chrono::duration_cast<std::chrono::nanoseconds>(steadyNow.time_since_epoch()).count();
97+
_clockOffsetNanos = steadyNanos - static_cast<int64_t>(machNow * 1e9);
98+
}
99+
82100
{
83101
std::lock_guard<std::mutex> lock(_lastFrameMutex);
84102
_lastFrameData.reset();
85103
}
86104

87-
// Emit initial frame event
105+
// Emit initial render frame with screenshot
88106
auto now = HighResTimeStamp::now();
89-
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now];
107+
auto initialFrameEnd = now + HighResDuration::fromNanoseconds(16'600'000);
108+
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:initialFrameEnd];
90109

91110
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkTick:)];
92111
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
@@ -105,11 +124,12 @@ - (void)stop
105124

106125
- (void)_displayLinkTick:(CADisplayLink *)sender
107126
{
108-
// CADisplayLink.timestamp and targetTimestamp are in the same timebase as
109-
// CACurrentMediaTime() / mach_absolute_time(), which on Apple platforms maps
110-
// to CLOCK_UPTIME_RAW — the same clock backing std::chrono::steady_clock.
111-
auto beginNanos = static_cast<int64_t>(sender.timestamp * 1e9);
112-
auto endNanos = static_cast<int64_t>(sender.targetTimestamp * 1e9);
127+
// CADisplayLink.timestamp and targetTimestamp use CACurrentMediaTime() /
128+
// mach_absolute_time() (CLOCK_UPTIME_RAW), which excludes system sleep time.
129+
// Apply the offset computed at start to convert to the steady_clock
130+
// (CLOCK_MONOTONIC_RAW) timebase used by HighResTimeStamp.
131+
auto beginNanos = static_cast<int64_t>(sender.timestamp * 1e9) + _clockOffsetNanos;
132+
auto endNanos = static_cast<int64_t>(sender.targetTimestamp * 1e9) + _clockOffsetNanos;
113133

114134
auto beginTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint(
115135
std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos)));
@@ -136,12 +156,10 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT
136156

137157
UIImage *image = [self _captureScreenshot];
138158
if (image == nil) {
139-
// Failed to capture (e.g. no window, duplicate hash) - emit without screenshot
140-
[self _emitFrameEventWithFrameId:frameId
141-
threadId:threadId
142-
beginTimestamp:beginTimestamp
143-
endTimestamp:endTimestamp
144-
screenshot:std::nullopt];
159+
// Screenshot unchanged (duplicate hash) or capture failed — don't emit
160+
// a frame event. The serializer will fill the resulting gap with an idle
161+
// frame, matching Chrome's native behavior where idle = vsync with no
162+
// new rendering.
145163
return;
146164
}
147165

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ internal class FrameTimingsObserver(
6666
lastFrameBuffer.set(null)
6767
isTracing = true
6868

69-
// Emit initial frame event
69+
// Emit initial render frame with screenshot
7070
val timestamp = System.nanoTime()
71-
emitFrameTiming(timestamp, timestamp)
71+
emitFrameTiming(timestamp, timestamp + INITIAL_FRAME_DURATION_NS)
7272

7373
currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler)
7474
}
@@ -263,6 +263,9 @@ internal class FrameTimingsObserver(
263263
}
264264

265265
companion object {
266+
// Spans ~one vsync at 60Hz.
267+
private const val INITIAL_FRAME_DURATION_NS = 16_600_000L
268+
266269
private const val SCREENSHOT_SCALE_FACTOR = 1.0f
267270
private const val SCREENSHOT_QUALITY = 80
268271

packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
#include "TraceEventGenerator.h"
1111
#include "TraceEventSerializer.h"
1212

13+
#include <algorithm>
14+
1315
namespace facebook::react::jsinspector_modern::tracing {
1416

1517
namespace {
@@ -20,6 +22,11 @@ namespace {
2022
*/
2123
constexpr int FALLBACK_LAYER_TREE_ID = 1;
2224

25+
// Minimum gap between consecutive frames before emitting an idle frame.
26+
// One 60 Hz vsync interval — smaller gaps are normal inter-frame time, not
27+
// genuine idle periods (e.g. Android TOTAL_DURATION < vsync interval).
28+
const auto MIN_IDLE_GAP = HighResDuration::fromNanoseconds(16'667'000);
29+
2330
} // namespace
2431

2532
/* static */ void HostTracingProfileSerializer::emitAsDataCollectedChunks(
@@ -104,11 +111,83 @@ constexpr int FALLBACK_LAYER_TREE_ID = 1;
104111
TraceEventSerializer::estimateJsonSize(serializedSetLayerTreeId);
105112
chunk.push_back(std::move(serializedSetLayerTreeId));
106113

114+
// Filter out frames that started before recording began. On Android,
115+
// FrameMetrics may deliver frames from app startup that predate the recording
116+
// session; on iOS the first CADisplayLink callback reports sender.timestamp
117+
// (the previous vsync) which can be before the recording start. These would
118+
// otherwise appear as large pre-recording render frames in the timeline.
119+
frameTimings.erase(
120+
std::remove_if(
121+
frameTimings.begin(),
122+
frameTimings.end(),
123+
[&recordingStartTimestamp](const FrameTimingSequence& ft) {
124+
return ft.beginTimestamp < recordingStartTimestamp;
125+
}),
126+
frameTimings.end());
127+
128+
if (frameTimings.empty()) {
129+
chunkCallback(std::move(chunk));
130+
return;
131+
}
132+
133+
// Sort frames by beginTimestamp to handle out-of-order arrivals caused by
134+
// async screenshot encoding. The initial synthetic frame may arrive in the
135+
// buffer after real frames because its screenshot encoding takes longer than
136+
// one vsync (~16ms). Sorting ensures the idle-gap detection loop below sees
137+
// frames in chronological order.
138+
std::sort(
139+
frameTimings.begin(),
140+
frameTimings.end(),
141+
[](const FrameTimingSequence& a, const FrameTimingSequence& b) {
142+
return a.beginTimestamp < b.beginTimestamp;
143+
});
144+
145+
// Compute the next available sequence ID for synthetic idle frames.
146+
FrameSequenceId nextIdleSeqId = 0;
147+
for (const auto& ft : frameTimings) {
148+
nextIdleSeqId = std::max(nextIdleSeqId, ft.id + 1);
149+
}
150+
151+
std::optional<HighResTimeStamp> prevEndTimestamp;
152+
107153
for (auto&& frameTimingSequence : frameTimings) {
108154
// Serialize all events for this frame.
109155
folly::dynamic frameEvents = folly::dynamic::array();
110156
size_t totalFrameBytes = 0;
111157

158+
// Detect idle period: gap between previous frame's end and this frame's
159+
// begin. Emit NeedsBeginFrameChanged + BeginFrame to fill the gap.
160+
// Chrome DevTools renders a BeginFrame without a corresponding DrawFrame
161+
// as an "Idle frame" in the Frames track.
162+
if (prevEndTimestamp.has_value() &&
163+
(frameTimingSequence.beginTimestamp - *prevEndTimestamp) >
164+
MIN_IDLE_GAP) {
165+
auto needsBeginFrameEvent =
166+
TraceEventGenerator::createNeedsBeginFrameChangedEvent(
167+
FALLBACK_LAYER_TREE_ID,
168+
*prevEndTimestamp,
169+
processId,
170+
frameTimingSequence.threadId);
171+
auto serializedNeedsBeginFrame =
172+
TraceEventSerializer::serialize(std::move(needsBeginFrameEvent));
173+
totalFrameBytes +=
174+
TraceEventSerializer::estimateJsonSize(serializedNeedsBeginFrame);
175+
frameEvents.push_back(std::move(serializedNeedsBeginFrame));
176+
177+
auto idleBeginEvent = TraceEventGenerator::createIdleBeginFrameEvent(
178+
nextIdleSeqId++,
179+
FALLBACK_LAYER_TREE_ID,
180+
*prevEndTimestamp,
181+
processId,
182+
frameTimingSequence.threadId);
183+
184+
auto serializedIdleBegin =
185+
TraceEventSerializer::serialize(std::move(idleBeginEvent));
186+
totalFrameBytes +=
187+
TraceEventSerializer::estimateJsonSize(serializedIdleBegin);
188+
frameEvents.push_back(std::move(serializedIdleBegin));
189+
}
190+
112191
auto [beginDrawingEvent, endDrawingEvent] =
113192
TraceEventGenerator::createFrameTimingsEvents(
114193
frameTimingSequence.id,
@@ -155,6 +234,8 @@ constexpr int FALLBACK_LAYER_TREE_ID = 1;
155234
chunk.push_back(std::move(frameEvent));
156235
}
157236
currentChunkBytes += totalFrameBytes;
237+
238+
prevEndTimestamp = frameTimingSequence.endTimestamp;
158239
}
159240

160241
if (!chunk.empty()) {

packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,47 @@ namespace facebook::react::jsinspector_modern::tracing {
3434
};
3535
}
3636

37+
/* static */ TraceEvent TraceEventGenerator::createNeedsBeginFrameChangedEvent(
38+
int layerTreeId,
39+
HighResTimeStamp timestamp,
40+
ProcessId processId,
41+
ThreadId threadId) {
42+
folly::dynamic data = folly::dynamic::object("needsBeginFrame", 1);
43+
44+
return TraceEvent{
45+
.name = "NeedsBeginFrameChanged",
46+
.cat = {Category::Frame},
47+
.ph = 'I',
48+
.ts = timestamp,
49+
.pid = processId,
50+
.s = 't',
51+
.tid = threadId,
52+
.args = folly::dynamic::object("layerTreeId", layerTreeId)(
53+
"data", std::move(data)),
54+
};
55+
}
56+
57+
/* static */ TraceEvent TraceEventGenerator::createIdleBeginFrameEvent(
58+
FrameSequenceId sequenceId,
59+
int layerTreeId,
60+
HighResTimeStamp timestamp,
61+
ProcessId processId,
62+
ThreadId threadId) {
63+
folly::dynamic args = folly::dynamic::object("frameSeqId", sequenceId)(
64+
"layerTreeId", layerTreeId);
65+
66+
return TraceEvent{
67+
.name = "BeginFrame",
68+
.cat = {Category::Frame},
69+
.ph = 'I',
70+
.ts = timestamp,
71+
.pid = processId,
72+
.s = 't',
73+
.tid = threadId,
74+
.args = std::move(args),
75+
};
76+
}
77+
3778
/* static */ std::pair<TraceEvent, TraceEvent>
3879
TraceEventGenerator::createFrameTimingsEvents(
3980
uint64_t sequenceId,

packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,29 @@ class TraceEventGenerator {
3535
HighResTimeStamp timestamp);
3636

3737
/**
38-
* Creates canonical "BeginFrame", "Commit", "DrawFrame" trace events.
38+
* Creates a "NeedsBeginFrameChanged" trace event to mark the start of an
39+
* idle frame period.
40+
*/
41+
static TraceEvent createNeedsBeginFrameChangedEvent(
42+
int layerTreeId,
43+
HighResTimeStamp timestamp,
44+
ProcessId processId,
45+
ThreadId threadId);
46+
47+
/**
48+
* Creates a single "BeginFrame" trace event for an idle frame (no
49+
* DrawFrame). Chrome DevTools renders a BeginFrame without a corresponding
50+
* DrawFrame as an "Idle frame" in the Frames track.
51+
*/
52+
static TraceEvent createIdleBeginFrameEvent(
53+
FrameSequenceId sequenceId,
54+
int layerTreeId,
55+
HighResTimeStamp timestamp,
56+
ProcessId processId,
57+
ThreadId threadId);
58+
59+
/**
60+
* Creates canonical "BeginFrame", "DrawFrame" trace events.
3961
*/
4062
static std::pair<TraceEvent, TraceEvent> createFrameTimingsEvents(
4163
FrameSequenceId sequenceId,

0 commit comments

Comments
 (0)