Skip to content

perf(web-ui): FlowChat 的 ::selection/::highlight() 规则使 WebKit 走慢速 CSS 匹配,渲染主线程持续满载 #3178

Closed

Description

@guhan33551

FlowChat 的 ::selection / ::highlight() 规则导致渲染主线程持续满载(Linux / WebKitGTK)

可直接提交到 https://github.com/GCWing/OpenBitFun/issues
标题建议:perf(web-ui): FlowChat 的 ::selection/::highlight() 规则使 WebKit 走慢速 CSS 匹配,渲染主线程持续满载

环境

项 值
OpenBitFun 1.0.1(RPM 安装,open-bit-fun-1.0.1-1.x86_64)
系统 Fedora 44 / KDE Plasma / Wayland
GPU Intel Arc B390(Panther Lake)+ Mesa(gallium 26.x)
WebKit 系统 webkit2gtk4.1-2.52.5-1.fc44(非 AppImage 内置版)
现象会话 单会话较大(约 9 MB,含数条 100–380 KB 的工具输出)

现象

  • 打开聊天界面后,渲染进程(WebKitWebProcess)的主线程长期占用 44% ~ 99% 单核(实测峰值 105% 整体),UI 明显卡顿、输入延迟。
  • 卡顿程度与会话长度 / 屏幕上同时渲染的文本量成正比 —— 越聊越卡;流式输出("思考中")时最重。
  • 即使不操作(仅窗口在重绘 / 焦点变化触发状态刷新),占用仍有 core ~25% + renderer 21%~50%;同期 ai.log 无增长(即没有任何模型调用、没有后台代理在跑)。
  • 与 GPU 无关:合成器线程仅占 ~6%,SkiaGPUWorker 正常,无 llvmpipe/swrast 软光栅。

根因(gdb 栈证据)

对渲染进程主线程反复采样,8 次采样中 6 次落在同一条路径:

#0-3  WebCore::Style::ElementRuleCollector::collectMatchingRulesForListSlow(...)
#4    WebCore::Style::Resolver::styleForPseudoElement(...)
#5    WebCore::RenderElement::getUncachedPseudoStyle(...)
#6    WebCore::MarkedText::collectForHighlights(WebCore::RenderText const&, ...)

另有样本落在:

#0    WebCore::CSS::mix(...)                              ← 每次重算 color-mix()
#1    WebCore::Style::BuilderCustom::applyValueColor(...)
#2-4  WebCore::Style::Builder::applyProperty / applyCascadeProperty

即:每个文本框、每次重绘,WebKit 都要未缓存地解析高亮伪元素样式,并因此对整套样式表做线性(慢速)规则匹配,还会重复求值 color-mix()。

触发这些规则的 CSS(assets/index-Dg5Vb1KW.css 33 条、assets/ChatPane-BtnTdl19.css 10 条等,共 46 条):

[data-flowchat-selection-root]::selection,
[data-flowchat-selection-root] ::selection,                                    /* ← 后代组合符 */
[data-openbitfun-product-component=conversation-excerpt]::selection,
[data-openbitfun-product-component=conversation-excerpt] ::selection {
  background: color-mix(in oklab, var(--…-accent) …);
}

::highlight(openbitfun-flowchat-excerpt)     { background-color: color-mix(…); }
::highlight(openbitfun-flowchat-annotations) { background-color: color-mix(…); }
::highlight(openbitfun-flowchat-search-match)   { … }
::highlight(openbitfun-flowchat-search-current) { … }

问题点:

  1. X ::selection(带后代组合符)无法被 WebKit 的规则分桶优化,只能落到 collectMatchingRulesForListSlow。该样式表总规模约 2.5 MB / ≈14 482 条规则(含 778 处 :is()/:where()/:not()),于是"文本框数量 × 规则总量"的线性扫描在每次重绘发生。
  2. color-mix() 在这些规则里每解析一次就要重新求值(栈中单独出现在 WebCore::CSS::mix)。
  3. ::highlight(...) 同理:只要应用注册了高亮范围(摘录/批注/搜索命中),每次重绘都会为每个文本框解析其伪元素样式。

实验验证(同一条件、8 个栈样本/组)

变体 collectForHighlights getUncachedPseudoStyle collectMatchingRulesForListSlow
原版 6 6 14
仅拦掉高亮注册(JS,注册点置为 no-op) 6 6 16
↑ + bind mount 覆盖 frontend/dist 4 4 13
剥离那 46 条 ::selection/::highlight() 规则 0 0 4(−71%)

剥离方式:按 CSS 规则(花括号配对)解析后丢弃选择器含 ::selection 或 ::highlight( 的规则(校验:花括号平衡、目标规则清零)。补丁落在
~/.config/openbitfun/data/frontend-workbench/revisions/<activeRevision>/assets/*.css(该副本即实际被加载的前端)。
代价:选中文本回落到系统默认底色,摘录/批注/搜索命中的高亮底色不再显示。

建议修法

  1. 不要对 ::selection / ::highlight() 使用后代组合符选择器(A ::selection)。改为在需要定制选区的元素上加一个类,用单个复合选择器(X::selection),或直接避免定制 ::selection。
  2. 这些规则里不要用 color-mix():换成预计算的静态颜色或 CSS 变量(var(--x-selection-bg)),避免每次解析都求值。
  3. 把这些规则移出大样式表:慢速匹配的代价与规则总量成正比,放进一个独立的小样式表(或按需注入)可显著降低成本。
  4. 可选:没有选区/高亮时不要保留活跃的 marked range,避免 MarkedText::collectForHighlights 每帧都有活干。

复现步骤

  1. 在 Linux(WebKitGTK)上启动 OpenBitFun 1.0.1,打开一个内容较多的会话;
  2. R=$(pgrep -f WebKitWebProcess | ...) 找到渲染进程,运行
    gdb -p $R -batch -ex "bt 8" 连续采样若干次;
  3. 多数样本会落在 MarkedText::collectForHighlights → getUncachedPseudoStyle → collectMatchingRulesForListSlow;
  4. 按上文剥离那 46 条规则并重启,栈中该路径消失、主线程占用明显下降。

English summary

On Linux/WebKitGTK, OpenBitFun 1.0.1's web process main thread stays pegged (44–99% of one core) whenever the FlowChat pane is open; the lag scales with transcript size. gdb sampling of the web process main thread repeatedly shows:

MarkedText::collectForHighlights
  → RenderElement::getUncachedPseudoStyle
    → Style::Resolver::styleForPseudoElement
      → ElementRuleCollector::collectMatchingRulesForListSlow

The trigger is 46 ::selection / ::highlight() rules in the bundled stylesheets (e.g. [data-flowchat-selection-root] ::selection, ::highlight(openbitfun-flowchat-excerpt)). Their descendant-combinator selectors cannot be bucketed, so WebKit falls back to a linear scan of the whole (~2.5 MB / ~14.5k rules) stylesheet for every text box on every paint, and color-mix() is re-evaluated each time. Stripping those 46 rules drops the hotspot from 6/8 to 0/8 samples and cuts slow rule matching by ~71%.

Suggested fix: avoid descendant combinators on ::selection/::highlight(), avoid color-mix() in them, and keep them in a small dedicated stylesheet.

Activity

  1. self-assigned this
    on Sep 22, 2026
  2. wsp1911 commented on Sep 22, 2026

    @wsp1911
    Collaborator
  3. guhan33551 commented on Sep 23, 2026

    @guhan33551
    Author

    对修复 3e9a392 的实测结果:在 Linux/WebKitGTK 上仍未消除热点

    感谢修复,我们按该提交自己构建并实测了(不是只看代码)。结论:空闲开销明显改善,但负载下热点路径依旧存在。

    怎么验的

    • 取 v1.0.1 源码 → git apply 该提交(3e9a392)→ pnpm install && pnpm -C src/web-ui run build
    • 只替换前端(/usr/lib/OpenBitFun/frontend/dist),后端仍是官方 1.0.1 RPM → 不存在前后端版本不匹配
    • 产物确认修复到位:元凶规则 …conversation-excerpt] ::selection 0 处;作用域版 [data-flowchat-highlight-*]::highlight(…) 10 处;JS 含 data-flowchat-highlight-* 6 处;这些规则里 color-mix 已消失

    实测数据

    场景(同一测法) 修复前 1.0.1 该修复构建 我们"整体剥离这些规则"的等效补丁
    空闲(重启后)主线程 25~50% 11.2%(改善) —
    负载下主线程 44.0% 97.5%(3 连测 97.6/97.5/97.4) 21.9%
    栈采样命中 collectForHighlights 6/8 6/10 0/8
    collectMatchingRulesForListSlow 14 15 0

    修复构建在负载下的栈(10 次采样,gdb -p <WebKitWebProcess> -ex bt):

     6  WebCore::MarkedText::collectForHighlights
     7  WebCore::RenderElement::getUncachedPseudoStyle
     7  WebCore::Style::Resolver::styleForPseudoElement
    15  WebCore::Style::ElementRuleCollector::collectMatchingRulesForListSlow
    13  WebCore::SelectorChecker::matchRecursively
    

    (WebCore::CSS::mix 已为 0 —— color-mix 那一条确实修好了 ✓)

    为什么还不够

    ::highlight(name) 是伪元素规则,右端不可分桶,所以每次解析伪元素样式时,ElementRuleCollector 都会退回
    collectMatchingRulesForListSlow,对整套 author 样式表(本机约 2.5 MB / 14 482 条规则)线性扫描。

    加属性作用域只减少了"真正匹配的规则数量",没有改变"每次解析都要扫全表"。只要注册了高亮范围
    (搜索命中 / 摘录 / 批注),绘制文本时每个文本框仍会走到这条路径,规则越多的样式表代价越大。

    建议(在这类 WebKitGTK 环境上真正去掉这条路径)

    1. 不要给 ::highlight() 写样式:把搜索命中 / 摘录 / 批注高亮改成真实 DOM(命中片段包 <span class="…"> 或 <mark>)来画;
      这样连伪元素样式解析都不需要,MarkedText::collectForHighlights 直接不参与。
    2. 若短期内必须保留 CSS Custom Highlight API:至少把这些规则移到一个独立的小样式表、并把注册的高亮范围数量压到最少
      (减少解析次数;每次解析的扫描成本仍与总规则数相关)。

    顺手发现(本 issue 之外的同类问题)

    PdfViewer-*.css 里的

    .openbitfun-pdf-viewer__text-layer ::selection { … }

    也是后代组合符写法 —— 打开 PDF 时会有同样的慢路径,建议一并改掉。

    我们的临时处理

    在 1.0.1 上把那 46 条 ::selection / ::highlight() 规则整体剥离(代价:选中/高亮底色消失)。
    实测栈命中 0/8、主线程 21.9%。正式版发布后我们会重新评估是否还需要这个补丁。


    English summary

    We built v1.0.1 + 3e9a392 ourselves (web-ui only, official backend) and measured on Linux/WebKitGTK.

    • Idle after restart: main thread 11.2% (improved).
    • Under load: main thread 97.5% (3 consecutive samples) — same hot path still present:
      MarkedText::collectForHighlights (6/10 samples) → getUncachedPseudoStyle → styleForPseudoElement
      → collectMatchingRulesForListSlow (15 stack lines).
    • WebCore::CSS::mix is now 0 — the color-mix part of the fix works.

    Root cause of the remainder: ::highlight(name) pseudo-element rules cannot be bucketed, so every pseudo-style
    resolution still linearly scans the whole author stylesheet (~2.5 MB / ~14.5k rules). Attribute scoping reduces the
    number of matched rules but not the scan cost; with any highlight registered, every painted text box still takes
    this path.

    Suggestion: paint search/excerpt/annotation highlights with real DOM elements/classes instead of styling
    ::highlight(). Also PdfViewer-*.css uses a descendant-combinator ::selection and has the same problem when a
    PDF is open.

  4. guhan33551 commented on Sep 23, 2026

    @guhan33551
    Author

    补充实验:作用域从属性选择器换成 class 选择器(改善但不足)

    接上一条。为了定位"加了作用域为什么还是慢",我们做了第二个实验。

    做法

    WebKit 的规则分桶键只有 id / class / 标签名,属性选择器会落进"通用桶"。于是把作用域从属性换成 class:

    • [data-flowchat-highlight-search-match]::highlight(…) → .obf-hl-search-match::highlight(…)(4 条选择器)
    • JS 侧 element.setAttribute('data-…','') → element.classList.add('obf-hl-…')(flowChatHighlights.ts 里 4 个定义 + 2 处调用)
    • 其余(删除 ::selection 定制、物化 color-mix)保持你们那版不变

    改动很小(完整 diff 见附件 实验-class作用域改造.diff)。

    结果(同一测法:10 次 gdb 栈采样 + 3×8 秒 CPU 采样)

    指标 1.0.1 原版 你们的修复(属性作用域) 属性域 → class 域
    渲染主线程 CPU 44% 97.5% 74 / 43 / 40%(中位 43%)
    MarkedText::collectForHighlights 6 6 2
    Style::Resolver::styleForPseudoElement 6 7 5
    ElementRuleCollector::collectMatchingRulesForListSlow 14 15 12
    SelectorChecker::matchRecursively – 13 7
    WebCore::CSS::mix 有 0 ✓ 0 ✓

    结论

    1. class 作用域确实有效:相对属性作用域,主线程 97.5% → ~43%、伪元素解析次数 6 → 2。
      所以"属性选择器落进通用桶"这条成立 —— 建议在你们那版里把 4 条选择器也改成 class。
    2. 但仍不足以把开销压回可接受范围:保留高亮底色后,主线程基本回到修复前水平(43% vs 44%),
      collectMatchingRulesForListSlow 仍出现在 12/10 个栈样本里。
    3. 根因是伪元素规则本身不可分桶:分桶键必须落在最右的复合选择器上,而最右是 ::highlight(name)。
      只要还用它上色,每次解析伪元素样式就要线性扫 author 样式表(本机 ~2.5 MB / 14 482 条规则)。
      选择器怎么写都救不回来。
    4. 因此"保留高亮 + 消除开销"只有一条路:换渲染方式 —— 把命中片段包成真实 DOM
      (<span class="…"> 或 <mark>)来上色,不再用 CSS Custom Highlight。这样
      MarkedText::collectForHighlights 完全不参与,伪元素解析也就不存在了。

    参考:我们本机的取舍

    性能优先,所以我们目前采用"把那 46 条 ::selection / ::highlight() 规则整体剥离"的方案
    (代价:高亮底色与自定义选区色消失)。实测主线程 36%、栈命中 0。
    等上游改成真实 DOM 方案后我们会立即撤掉该补丁。


    English summary

    Second experiment: change the highlight scope from attribute selectors to class selectors
    ([data-flowchat-highlight-x]::highlight(…) → .obf-hl-x::highlight(…)).

    • Class scoping clearly helps over attribute scoping: main thread 97.5% → ~43%, MarkedText::collectForHighlights
      6 → 2 samples, matchRecursively 13 → 7. (So attribute selectors falling into the universal bucket is real —
      worth changing in your implementation too.)
    • But it is not enough: with highlight colors kept, the main thread is back at pre-fix level (43% vs 44%) and
      collectMatchingRulesForListSlow still shows up in 12/10 stack samples.
    • Root cause: pseudo-element rules cannot be bucketed — the bucket key must live in the rightmost compound, and the
      rightmost compound is ::highlight(name). So every pseudo-style resolution linearly scans the author stylesheet
      (~2.5 MB / ~14.5k rules here). No selector-level scoping can avoid that.
    • The only way to keep highlights and remove the cost is to change how they are painted: wrap the matched text in
      real DOM (<span class="…"> / <mark>) instead of using the CSS Custom Highlight API.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions