Repository navigation
osr: External BeginFrames carry the time of the call, no deadline and a fixed 60 Hz interval #4311
Description
Describe the issue
With CefWindowInfo::external_begin_frame_enabled, CefBrowserHost::SendExternalBeginFrame() builds the
viz::BeginFrameArgs with frame_time = base::TimeTicks::Now() (the time of the call, so the wake-up jitter of
the client's tick thread), a null deadline and interval = viz::BeginFrameArgs::DefaultInterval() (16.67 ms
whatever the external clock is). Chromium uses those values as they are:
cc/layers/video_frame_provider_client_impl.ccpicks the video frame to show in
[frame_time + interval, frame_time + 2 * interval]: wrong by a sixth on a 50 Hz clock, and sliding with the
jitter of the call;cc/scheduler/scheduler.ccsubtracts its draw estimate from the deadline: null, it lands in the past and the
compositor draws without waiting for the main thread's frame, which lands sometimes before, sometimes after;- viz (
external_begin_frame_source_mojo.cc,begin_frame_source.cc) forwards all three values unchanged.
Observable effect, windowless browser clocked by a hardware tick (SDI card or vsync) on Windows: a 30 fps
<video> on a 60 Hz tick stutters, and a 60 fps video next to a CSS animation stutters as soon as the main
thread works; the same pages are smooth without external BeginFrames. Counters show nothing (one paint per
tick), only the content does.
Also, viz accepts a single IssueExternalBeginFrame in flight per controller: a tick that arrives while the
previous one is pending is dropped by CefRenderWidgetHostViewOSR (begin_frame_pending_), which loses one
tick in two for a video whose frames take a tick to acknowledge.
Proposed change (pull request to follow)
Add CefBrowserHost::SendExternalBeginFrameWithTiming(frame_time_offset_us, interval_us, deadline_offset_us):
the age of the tick at the time of the call (an offset, since the TimeTicks base is not exposed by the C API),
the period of the external clock, and the deadline offset (<= 0 = frame_time + interval, what Chromium's own
BeginFrame sources use). Rewrite the external BeginFrame path on the model of ui::ExternalBeginFrameAdapter,
the platform BeginFrame source Chromium added for Wayland: sequence numbers from
viz::BeginFrameSource::BeginFrameArgsGenerator, wait_for_all_frame_sinks = false, vsync parameters following
the interval, and a tick that arrives while another is in flight kept one deep and issued at the acknowledgement
if its interval is not half over. SendExternalBeginFrame() keeps its behavior except that the interval follows
windowless_frame_rate and the deadline equals the interval. Experimental API.
A natural follow-up, once this is settled: report the outcome of each external BeginFrame to the client
(CefRenderHandler::OnExternalBeginFrameComplete, drawn / nothing to draw / dropped, from the BeginFrameAck
that viz already returns to the controller, as headless returns hasDamage to DevTools). It lets a client know
that nothing will be painted for a tick instead of inferring it from a timeout. I have it working on top of this
change and can submit it separately.
Version
CEF master a875f407 (Chromium 156.0.8078.0 compatibility); measured on a build of branch 8078
(156.0.7+g47dbc1d+chromium-156.0.8078.17), Windows 11, MSVC 2022 17.13 / SDK 10.0.28000, NVIDIA RTX 4060 and
Quadro P4000, D3D12 shared textures.
Steps to reproduce
- Windowless browser with
external_begin_frame_enabled,shared_texture_enabled,windowless_frame_rate = 60,
SendExternalBeginFrame()from a thread woken by the monitor vsync (IDXGIOutput::WaitForVBlank). - Page with a
<video>at 30 fps (or at 60 fps plus a CSS animation on the main thread). - Watch the texture: the video stutters. Same page with
external_begin_frame_enabled = false: smooth. - Count the frames presented per video frame with
requestVideoFrameCallback: holes and doubles, no counter
on the CEF side shows it.
Activity
osr: Add SendExternalBeginFrameWithTiming (see chromiumembedded#4311)
aisuvakov commentedon Oct 14, 2026
Confirming this on a 50 Hz external clock (broadcast playout, 1080p50 → tick every 20 ms; CEF 151.3.18, Windows 11 / RTX 3060 and macOS arm64). Single
<video>test page, counted withgetVideoPlaybackQuality():This matches the analysis: with
interval = DefaultInterval()the frame-selection window is 16.67 ms per 20 ms tick, leaving a 3.3 ms gap, and frames timestamped in the gap are dropped. Workarounds on the page side (canvas copy,display:none, etc.) don't help. Under load we also see thebegin_frame_pending_drop described here.Happy to test #4312 on 50 Hz SDI (DeckLink) once it lands on a branch with builds.
magreenblatt commentedon Oct 14, 2026
Using windowless_frame_rate for the existing SendExternalBeginFrame method, along with setting deadline = frame_time + interval, seems reasonable. It’s less clear whether a new timing API is needed. Can you provide measurements showing the remaining timing jitter and its impact on video playback after those changes?
kin4stat commentedon Oct 15, 2026
@magreenblatt
Some numbers from our side on the timing question.
We run a CEF fork (Chromium 150, branch 7871) with an equivalent API,
SendExternalBeginFrameAt(frame_time_us, interval_us): the client passes the tick's own timestamp on thebase::TimeTickstimebase and the interval it actually achieves;0falls back toNow()on the UI thread and 16.67 ms. Our host is a game loop rather than a hardware clock, and we measured animation and scrolling rather than video, but the A/B isolates the same two fields.Setup: Windows 11, AMD RX 9070 XT, two windowless browsers driven at 160 Hz by a pump thread on a high-resolution waitable timer, shared textures with a non-blocking paint callback, the client compositing once per tick on its own thread, and the page scrolling while it records
requestAnimationFrametimestamps. "Tick time" is the grid boundary the pump has just crossed, independent of when its thread woke up. Skip is the share of rAF callbacks that arrive two or more ticks after the previous one; σ noise is the deviation of the rAF interval from a whole number of ticks, i.e. the timestamp jitter CSS/WAAPI animations see.Now()on the UI thread, 16.67 ms intervalframe_timestillNow()on the UI threadframe_timeIn this workload the interval fix alone removes most of the damage, and passing the tick's own time then takes skip from 23.0% to 7.0% and cuts the timestamp jitter about five-fold. The two compound rather than add: the deadlines cc derives under load (LATE, WAIT_FOR_SCROLL) and Blink's idle period are all
frame_timeplus a multiple ofinterval, so a jitteredframe_timemoves every one of them.deadlinewas null in all three rows. We also swept real deadline values and saw no measurable change here: under load the scheduler lands in LATE or WAIT_FOR_SCROLL, which compute their own deadline fromframe_time + interval. So in our case setting the deadline does not stand in for theframe_timepart. We have not measured video.On the one-in-flight gate: the acknowledgement round trip was 200-300 µs against a 6.25 ms tick, and the gate refused 0-7 ticks per 600. We tried keeping a refused tick and issuing it on the acknowledgement, as #4312 does, and also pipelining two BeginFrames; neither changed anything measurable in this workload. It probably matters only when the acknowledgement takes a large part of the tick, as in the video case above.
One more thing #4312 touches: in the current code, if the reply callback passed to
IssueExternalBeginFrameis dropped without running (we hit this on context loss and restore),begin_frame_pending_is never cleared and every later tick is dropped for good. We fixed it withmojo::WrapCallbackWithDropHandler. #4312 resetsin_flight_when the controller client is recreated; it is worth confirming that every path that drops the reply also recreates it.^^^
Generated by Claude from my manual measurements and conclusions after running the tests with different approaches on our workload, sorry for AI text
kin4stat commentedon Oct 15, 2026
Just retested all the configs
Measurements for the question above. Fork of CEF on Chromium 150 (branch 7871), Windows 11, AMD RX 9070 XT, one windowless browser with shared textures, external BeginFrames from a thread on a high-resolution waitable timer (so
Now()carries its wake-up jitter plus the hop to the UI thread). Our fork hasSendExternalBeginFrameAt(frame_time_us, interval_us); for this we added two test switches: deadline =frame_time + interval, and a one-deep queue that keeps a tick refused bybegin_frame_pending_and issues it at the acknowledgement if less than half an interval has passed, as #4312 does.Configs:
Now()on the UI thread, 16.67 ms, null deadlineNow(), the real interval, deadline =frame_time + intervalframe_timeq: with the one-deep queuePage: VP9 1280x720 test videos (WebM, 1 ms timestamps). For each video frame,
requestVideoFrameCallbackexpectedDisplayTimegives how many ticks it stayed on screen; "on cadence" is the share shown for exactly hz/fps ticks. Holes are frames the media clock passed but that were never presented (mediaTimevspresentedFrames). rAF skip is the share of rAF callbacks two or more ticks apart; σ noise is the deviation of the rAF interval from whole ticks. Gate drops are ticks refused while one was pending. 30 s per run after warm-up, 3 runs per cell, mean (min-max). "css" adds a non-composited CSS animation (left) and 6 ms of JS per frame.What this says, in our setup:
The interval change is needed whenever the clock is not 60 Hz: at 50 Hz the stock 16.67 ms leaves about 185 of 1500 frames of a 50 fps video unpresented, with or without the queue, and a 25 fps video lands between 67% and 100% on cadence depending on phase.
The deadline change is needed for main-thread content under load. With a null deadline, rAF skip under the css load is bimodal across runs: A 0-67%, D 0-90% at 60 Hz, D 0-13% at 50 Hz. With the deadline it is 0.0-0.2% in every run. Video frames themselves are not affected.
Those two changes alone do not fix video when some ticks have nothing new to draw: with B, the 30 and 25 fps videos are on cadence 3% and 7% of the time, because the one-in-flight gate refuses 26-29% of the ticks. When a tick produces no new frame, its acknowledgement comes back about one declared interval later, so it races the next tick. That is visible directly at 50 Hz: with the stock 16.67 ms interval those acknowledgements arrive before the 20 ms tick and the gate refuses only 0.5%. A one-deep queue fixes it (99.1-99.6%).
With interval, deadline and queue in place, what passing the tick's own time still changes: σ of the rAF timestamps drops from 0.4-0.7 ms to 0.06 ms, and video goes from 99.1-99.9% to 100.0% on cadence. At 60 fps under load single runs showed about 100 holes in B and in D but never in C; three runs are not enough to attribute that. So for video at 50/60 Hz the queue matters far more than the tick time. At high rates the tick time matters more: in our game-loop workload at 160 Hz it took rAF skip from 23% to 7% with an honest interval.
Separately: in 3 of 96 runs the view froze at startup. The acknowledgement for the fifth BeginFrame never arrived (the callback was not destroyed, it simply was not run), and with one in flight nothing was drawn for the rest of the run, with or without the queue. We have not root-caused it, but a gate that only reopens on an acknowledgement or on a new controller can wait forever.
super-ienien commentedon Oct 15, 2026
@aisuvakov Thank you for the measurements, they confirm the analysis. Your future measurements will help confirm that the fix proposed in this PR works on your setup. I will ping you once the reshaped commit is on the PR branch.
@kin4stat Thank you, this is exactly the kind of A/B that was missing, and your numbers line up with ours.
On the two code points:
Dropped reply: agreed. The PR only reopens the gate on the acknowledgement or when the controller client is recreated. The next revision wraps the reply with mojo::WrapCallbackWithDropHandler, so the gate also reopens when the reply is dropped.
Acknowledgement that never comes: a client-side watchdog cannot re-issue, viz reports a bad message on any overlapping IssueExternalBeginFrame. So the gate can only reopen on an acknowledgement or a new controller. We have not seen the startup freeze (about 110 two-minute runs), but the next revision logs a warning after a few intervals without an acknowledgement, so that the case is at least visible.
osr: Fix external BeginFrame timing, optional tick age (see chromiume…
super-ienien commentedon Oct 15, 2026
@magreenblatt Thanks for the review. I agree:
SendExternalBeginFrameWithTimingmakes the API more complex, and its arguments are hard to use.After measuring your proposal (
interval = 1 / windowless_frame_rate,deadline = frame_time + interval), the API can be simplified: keepSendExternalBeginFrame()and add a single optional argument, the age of the tick at the time of the call. This is the only value that cannot be derived by CEF, and the only one needed for a smooth output under load. The interval is derived from the successive tick times (median of the last few,windowless_frame_ratefor the first tick) and the deadline isframe_time + interval. Callers that do not pass the age get the current behaviour, with the interval and deadline fixes.Measurements. DeckLink SDI output, 1080i50, external BeginFrame driven by the card's frame callback, 6 CPU burner processes (about 62 % system CPU), 8 interleaved passes of 120 s per arm, same run, same binary. Idle, the arms are indistinguishable.
SendExternalBeginFrame(), your proposal<video>25 fps frame holds ≠ 2 fields (of ~22 000)droppedVideoFramesHow to read the rows:
requestAnimationFrame, records which tick it is on (rAF timestamp divided by the tick period). Consecutive callbacks should advance by exactly one tick; a step of 0 or 2 is a visible hiccup. About 46 000 callbacks per arm over the 8 passes.<video>frame holds ≠ 2 fields: a 25 fps video on a 50 Hz (1080i50) output should show each frame for exactly 2 fields, measured withrequestVideoFrameCallback. A frame held for 1 or 3 fields is a visible judder. About 22 000 video frames per arm.droppedVideoFrames:getVideoPlaybackQuality().droppedVideoFramesof the<video>element.The remaining jitter with the time of the call is the wake-up jitter of the client's tick thread (up to a full field period under load), and it goes straight into the animation timeline. A client-side filtered grid gives the same result as the hardware time (8 / 16 / 0 / 13), so the gain comes from an accurate tick date, not from any filtering. kin4stat's measurements above, on a different setup, show the same thing.
A side benefit: since the interval follows the tick dates, a variable refresh rate is supported. I have no device to test it, but on paper it works; once the direction of the fix is agreed, I can set up a test for it.
A follow-up revision will handle a reply dropped before the frame sink exists or on GPU process loss
One more thing: I also have a proposal for a completion callback,
CefRenderHandler::OnExternalBeginFrameComplete, one callback perSendExternalBeginFramecall, saying whether the frame was drawn, not drawn, or dropped. Today the application has to guess the state of the viz compositor. Do you think it is relevant? If so, should it go in this PR or in a new one?super-ienien commentedon Oct 16, 2026
Hi !
Pushed the follow-up revision to #4312: it handles a dropped reply.
Tested by killing the GPU process during playback (Windows, D3D12, DeckLink 1080i50, 3 kills per binary). BeginFrames now resume about 0.2 s after the GPU process restarts. Before this change, they stayed blocked until the client was recreated: 38 to 71 s
of frozen output. 20 cold starts were clean, and the timing measurements above did not regress.
Hope you'll find the SendExternalBeginFrame with the new optional argument interesting because it's the only way I found for us to get CEF compose in sync with an external hardware clock like the ones that drives professionnal video SDI cards.