From fed4ad5238ca6695476047f819a199777514849e Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Mon, 27 Jul 2026 21:44:03 -0700 Subject: [PATCH] perf(cef_host): drop Chromium's spare renderer by default (~23 MB per profile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium keeps one pre-warmed spare renderer per profile so the next navigation can skip process startup. It is why renderer count measures N+1 at EVERY N — including N=1, where a board with a single tile pays for a second renderer it never uses. Measured on an always-live bench (N mounted CefWebViews, no canvas, no lazy-spawn gate), phys_footprint via `top -stats mem`, shared profile, N in {1,2,4,8,16}: renderers N+1 -> exactly N at every N fixed term 149.5 -> 126.2 MB (~23 MB back, per CEF PROFILE) marginal 33.02 -> 34.22 MB/view (unchanged within noise) r² 0.9999 The saving is per PROFILE, so it compounds with however many a host app declares. Cost is one cold renderer launch for the NEXT browser created: first paint 38 -> 69 ms (~+31 ms), 7/7 valid rounds. Nothing already rendering is touched and no browser shares a process with another, so there is no neighbour-jank trade. Opt back in with FLUTTER_CEF_SPARE_RENDERER=1 where first-paint latency of a newly created browser matters more than the memory. "0" and "" read as off, so a wrapper can neutralize an inherited var without unsetting it. Also here: - `--disable-features` is now accumulated into one comma-joined value. Chromium's CommandLine REPLACES on a repeated switch rather than unioning, so adding a second one would have silently re-enabled the MachPortRendezvous* bypass on ad-hoc builds — which blanks every tile. - An unconditional one-line startup log naming the effective configuration, so a measurement run can prove which binary it actually exercised. Two sweeps in this investigation were invalidated by silently measuring the wrong configuration; one stderr write per launch is the cheapest cure. - A comment recording a NEGATIVE result so the next person doesn't repeat it: `--renderer-process-limit` does not work here. Chromium treats it as a soft limit and still gives every CEF windowless browser a dedicated renderer — at N=16 under a cap of 8 there were 16 renderers. Its only enforced effect is declining to warm the spare, a strictly weaker version of this change. The feature name was verified against this build's vendored binary rather than from docs: "SpareRendererForSitePerProcess" appears verbatim in the shipped framework beside the source path that gates on it. Co-Authored-By: Claude Opus 5 (1M context) --- .../flutter_cef_macos/native/cef_host/main.mm | 111 +++++++++++++++++- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index 791ab5f..68423b4 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -1618,6 +1618,19 @@ bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, } void OnBeforeCommandLineProcessing( const CefString&, CefRefPtr command_line) override { + // Chromium parses --disable-features ONCE: a second occurrence of the + // switch REPLACES the first rather than unioning with it, so appending our + // own would silently re-enable whatever an earlier block turned off (most + // dangerously the ad-hoc MachPortRendezvous bypass below, whose loss + // -67030s the multi-process GPU→browser handoff and blanks every tile). + // Every disable therefore accumulates here and is emitted as ONE + // comma-joined value at the bottom of this hook. Do not call + // AppendSwitchWithValue("disable-features", …) anywhere else. + std::vector disabled_features; + // Effective memory-lever configuration, captured for the one-line startup + // log at the bottom (see there for why it is unconditional). + bool no_spare_renderer = false; + // OSR establishment-latency: OSR views have no real OS window, so Chromium's scheduler // treats every renderer as backgrounded/occluded and LOWERS its process priority during // the critical first load — delaying the first frame of a tile that's actually visible @@ -1631,6 +1644,67 @@ void OnBeforeCommandLineProcessing( command_line->AppendSwitch("disable-renderer-backgrounding"); command_line->AppendSwitch("disable-backgrounding-occluded-windows"); } + + // ---- Memory levers (both opt-in; unset == today's behaviour) ----------- + // Measured shape of this process tree on macOS (phys_footprint, not summed + // RSS — the 307 MB Chromium framework is mapped into every process, so + // summed RSS overstates by ~3.4x): 158 MB fixed + 33-42 MB per live + // webview, r²=0.9997, with processes = N+5 and renderers = N+1 for N views. + // The renderers are the whole per-view slope: at N=16 that is 17 x ~28 MB + // = ~476 MB of a 689 MB total. Each lever below trades a specific, + // user-visible property for part of that slope, so each is env-gated and + // OFF by default — they can be A/B'd on one binary without a rebuild. + + // NOT A LEVER: --renderer-process-limit. Recorded because it is the obvious + // next idea and it does NOT work here, so the next person should not spend + // the day we spent. The intent would be that above the cap Chromium reuses + // renderers instead of spawning, trading isolation for memory. MEASURED on + // this build (CEF 144 / chromium-144.0.7559.254): Chromium treats it as a + // SOFT limit and still gives every CEF windowless browser a DEDICATED + // renderer. At N=8 browsers the renderer count was 8 for caps of 1, 2, 4 + // and 8 alike — never the cap; at N=16 under a cap of 8 there were 16. + // The value does reach Chromium — cap=99 kept the spare (9 renderers), + // cap<=8 dropped it (8) — so its ONLY enforced effect is declining to warm + // the spare, which is a strictly weaker version of the lever below. It + // recovers none of the per-view slope, so there is nothing here to ship. + + // THE LEVER — drop Chromium's SPARE renderer. The browser process keeps one + // extra pre-warmed renderer per profile so the next navigation can skip + // process startup; it is why renderers measure N+1 at EVERY N, including + // N=1, and it costs ~23-24 MB permanently and PER PROFILE (so it compounds + // with profile count, and we spawn one cef_host per profile). THE COST IS + // FIRST-PAINT LATENCY on the NEXT tile created: that navigation now pays a + // cold renderer launch instead of adopting a warm one. Nothing already + // rendering is affected. + // + // Feature name verified against THIS build's vendored Chromium + // (CEF 144.0.27 / chromium-144.0.7559.254, see native/build_cef_host.sh): + // "SpareRendererForSitePerProcess" appears verbatim in the shipped + // "Chromium Embedded Framework" binary, next to the source path + // content/browser/renderer_host/spare_render_process_host_manager_impl.cc + // that gates on it — i.e. it is this framework's own feature string, not a + // name carried over from an older branch. + // DEFAULT ON. Measured on the always-live bench (phys_footprint, shared + // profile, N in {1,2,4,8,16}): renderer count drops from N+1 to exactly N at + // every N, and the fitted fixed term falls 149.5 -> 126.2 MB — ~23 MB back + // per CEF PROFILE, so it compounds with however many profiles a host app + // declares. The cost is one cold renderer launch for the NEXT browser + // created: first paint 38 -> 69 ms (~+31 ms). Nothing already rendering is + // touched, and no browser shares a process with another, so there is no + // neighbour-jank trade here. + // + // Opt BACK IN with FLUTTER_CEF_SPARE_RENDERER=1 when first-paint latency of + // a newly created browser matters more than ~23 MB. + const char* spare_env = std::getenv("FLUTTER_CEF_SPARE_RENDERER"); + const bool keep_spare = + spare_env != nullptr && spare_env[0] != '\0' && + std::strcmp(spare_env, "0") != 0; + if (!keep_spare) { + no_spare_renderer = true; + disabled_features.push_back("SpareRendererForSitePerProcess"); + } + // ------------------------------------------------------------------------ + #ifdef CEF_HOST_ADHOC // Dev / ad-hoc-only (CEF_HOST_ADHOC is ON by default; a signed release sets // -DCEF_HOST_ADHOC=OFF). Mock keychain + basic password store so a launch @@ -1657,11 +1731,11 @@ void OnBeforeCommandLineProcessing( // shared-texture GPU OSR path this lets the accelerated path run // multi-process (crash-isolated) WITHOUT Developer-ID signing. A signed // release omits this and enforces validation, which then requires correct - // inside-out Developer-ID signing of the cef_host tree. - command_line->AppendSwitchWithValue( - "disable-features", - "MachPortRendezvousValidatePeerRequirements," - "MachPortRendezvousEnforcePeerRequirements"); + // inside-out Developer-ID signing of the cef_host tree. Accumulated (not + // appended) so a later --disable-features can't clobber it — see the + // disabled_features declaration at the top of this hook. + disabled_features.push_back("MachPortRendezvousValidatePeerRequirements"); + disabled_features.push_back("MachPortRendezvousEnforcePeerRequirements"); #endif // Verbose Chromium logging to /tmp only when explicitly debugging; off by // default so a shipped build doesn't write logs behind the user's back. @@ -1701,6 +1775,33 @@ void OnBeforeCommandLineProcessing( command_line->AppendSwitchWithValue("disable-blink-features", "AutomationControlled"); } + + // The ONE --disable-features emission (see the top of this hook: a second + // occurrence would replace, not extend, this one). + std::string disabled_features_value; + for (const std::string& feature : disabled_features) { + if (!disabled_features_value.empty()) disabled_features_value += ','; + disabled_features_value += feature; + } + if (!disabled_features_value.empty()) { + command_line->AppendSwitchWithValue("disable-features", + disabled_features_value); + } + + // Unconditional, one line, once per browser process (CEF calls this hook + // only for process_type == "", see the CDP note above). Deliberately NOT + // behind FLUTTER_CEF_DEBUG: an A/B memory sweep needs positive in-band + // proof of which configuration the process ACTUALLY started with — + // including the control arm, where "no levers" is itself the claim being + // measured. Silently measuring the wrong build is the recurring failure + // mode, and one stderr write per process launch is the cheapest cure. + // Unlike --enable-logging this writes no file and leaks nothing about the + // user's browsing — it names only our own switch configuration. + fprintf(stderr, + "[cef_host] mem-levers spare-renderer=%s disable-features=%s\n", + no_spare_renderer ? "disabled" : "kept", + disabled_features_value.empty() ? "(none)" + : disabled_features_value.c_str()); } // No browser is created here. We only announce readiness; the host then drives // browser creation on demand via kOpCreateBrowser (one per CefWebView sharing