diff --git a/TeXmacs/progs/kernel/gui/menu-widget.scm b/TeXmacs/progs/kernel/gui/menu-widget.scm index 377fc06c09..71c4edc191 100644 --- a/TeXmacs/progs/kernel/gui/menu-widget.scm +++ b/TeXmacs/progs/kernel/gui/menu-widget.scm @@ -1502,6 +1502,17 @@ ) ;cond ) ;tm-define +(tm-define (menu-cache-normalize r) + (:type (-> object object)) + (:synopsis "Normalize expanded menu @r for use as menu cache key") + ;; invisible 项的载荷(如 push-focus 注入的文档路径)不影响 widget 构建, + ;; 但会让展开结果随光标位置变化,毒化缓存判等,故归一化剔除。 + (cond ((and (pair? r) (== (car r) 'invisible)) (list 'invisible)) + ((pair? r) (cons (menu-cache-normalize (car r)) (menu-cache-normalize (cdr r)))) + (else r) + ) ;cond +) ;tm-define + (define-table menu-expand-table (--- ,(lambda (p) `(--- ,@(menu-expand-list (cdr p))))) (| ,(lambda (p) `(| ,@(menu-expand-list (cdr p))))) diff --git a/TeXmacs/tests/1145.scm b/TeXmacs/tests/1145.scm new file mode 100644 index 0000000000..c71729c289 --- /dev/null +++ b/TeXmacs/tests/1145.scm @@ -0,0 +1,107 @@ +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; MODULE : 1145.scm +;; DESCRIPTION : GUI 复现:连续输入与进出数学模式时观察 mode 工具栏重建 +;; COPYRIGHT : (C) 2026 Mogan STEM +;; +;; PURPOSE +;; 配合 C++ 侧 [1145] 诊断日志(get_menu_widget 的 EQUAL/CACHE_HIT/MISS +;; 计数、replaceButtons 的 SAME/REPLACE 计数与耗时),在真实 GUI 里驱动: +;; A. 纯文本连续输入 —— 展开结果应不变,期望全 EQUAL、零 REPLACE。 +;; B. 反复进出数学模式 —— 上下文切换,展开结果变化,观察每次切换是否 +;; 全量重建所有按钮(REPLACE n=...)。 +;; +;; USAGE +;; xmake b stem +;; MOGAN_TEST_GUI=1 xmake r 1145 +;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(texmacs-module (texmacs tests 1145)) + +;; 步骤间隔:给 typeset + idle update_menus 足够时间(idle >= 1/60s 才触发) + +(define step-delay-ms 1500) + +(define (log-step label) + (display "[1145-step] ") + (display label) + (newline) +) ;define + +;; 每步间隔 step-delay-ms:lambda 返回剩余毫秒表示继续等待,返回 #t 表示完成。 + +(define (run-chain steps on-done) + (if (null? steps) + (on-done) + (exec-delayed-pause (let ((start (texmacs-time))) + (lambda () + (let ((left (- (+ start step-delay-ms) (texmacs-time)))) + (if (> left 0) + left + (begin + (log-step (caar steps)) + ((cdar steps)) + ;; 同步触发 C++ update_menus,不依赖 idle/焦点 + (update-menus) + (run-chain (cdr steps) on-done) + #t + ) ;begin + ) ;if + ) ;let + ) ;lambda + ) ;let + ) ;exec-delayed-pause + ) ;if +) ;define + +(tm-define (test_1145) + (new-document) + (let* ((phase-a + ;; A: 纯文本输入 5 步,每步插 6 个字符 + (let loop + ((i 0) (acc '())) + (if (>= i 5) + (reverse acc) + (loop (+ i 1) + (cons (cons (string-append "A: type text " (number->string i)) + (lambda () (insert "abcdef")) + ) ;cons + acc + ) ;cons + ) ;loop + ) ;if + ) ;let + ) ;phase-a + (phase-b + ;; B: 5 轮 进出数学模式 + (let loop + ((i 0) (acc '())) + (if (>= i 5) + acc + (loop (+ i 1) + (append acc + (list (cons (string-append "B" (number->string i) ": insert math") + (lambda () (insert '(math "x"))) + ) ;cons + (cons (string-append "B" (number->string i) ": enter math") + (lambda () (go-left)) + ) ;cons + (cons (string-append "B" (number->string i) ": type in math") + (lambda () (insert "y")) + ) ;cons + (cons (string-append "B" (number->string i) ": exit math") + (lambda () (go-right)) + ) ;cons + ) ;list + ) ;append + ) ;loop + ) ;if + ) ;let + ) ;phase-b + ) ; + (run-chain (append phase-a phase-b) + (lambda () (display "[1145-step] done, quit") (newline) (quit-TeXmacs)) + ) ;run-chain + ) ;let* +) ;tm-define diff --git a/devel/1145.md b/devel/1145.md new file mode 100644 index 0000000000..64f853313e --- /dev/null +++ b/devel/1145.md @@ -0,0 +1,130 @@ +# [1145] 工具栏(replaceButtons)重复重建性能优化 + +## 1 相关文档 +- [dddd.md](dddd.md) - 任务文档模板 + +## 2 任务相关的代码文件 +- `src/Texmacs/Window/tm_window.cpp` — `get_menu_widget`:菜单展开、缓存、判等 +- `src/Plugins/Qt/qt_tm_widget.cpp` — `replaceButtons` / `same_actions` / `replaceActions` +- `src/Plugins/Qt/qt_ui_element.cpp` — `get_qactionlist`:QAction 创建 +- `src/Edit/Interface/edit_interface.cpp` — `update_menus` 触发时机 +- `TeXmacs/tests/1145.scm` — GUI 诊断驱动脚本 + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) +``` +xmake b stem +``` + +### 3.2 非确定性测试(文档验证) +``` +xmake b stem +MOGAN_TEST_GUI=1 xmake r 1145 # 观察 [1145] 诊断日志 +xmake r 1145 # headless:menu-expand 判等诊断 +``` + +## 4 如何提交 + +提交前执行以下最少步骤: + +```bash +gf fmt --changed-since=main +``` + +## 5 What +1. 建立菜单更新链路性能诊断:`get_menu_widget` / `replaceButtons` / + `get_qactionlist` 插桩(`[1145]` 前缀日志)+ `TeXmacs/tests/1145.scm` + GUI 驱动测试(`MOGAN_TEST_GUI=1 xmake r 1145`)。 +2. 实测定位瓶颈(见 7.2):按钮重建不是瓶颈,每次 update_menus 的 10 次 + scheme `menu-expand`(~75-110ms)才是。 +3. **修复 focus icons 缓存永远 MISS**:`get_menu_widget` 的缓存判等改用 + 归一化 key(新增 scheme 函数 `menu-cache-normalize`,剔除 `invisible` + 项载荷),重复进入数学模式时 which=2 从每次 MISS + make_menu_widget + ~47ms 变为 CACHE_HIT 零重建。 + +## 6 Why +mode 工具栏每次更新都通过 `replaceButtons` 全量重建按钮,是性能差的主要原因。 + +## 7 How + +### 7.1 现状链路(调查记录) + +1. `edit_interface_rep::apply_changes`:无待处理变更且 idle ≥ 1/60s 时调 + `update_menus()`(edit_interface.cpp:918-921),即每次输入/光标移动后 + 空闲即触发。 +2. `update_menus` 依次对主菜单、4 个图标栏、通知栏调 `menu_icons` / + `menu_main`(edit_interface.cpp:819-870)。 +3. `tm_window_rep::get_menu_widget`(tm_window.cpp:403): + - 每次都执行 scheme 侧 `menu-expand` 全量展开菜单; + - 展开结果 `xmenu` 与 `menu_current[which]` 用 `equal?` 判等,相同则 + 早退(不重建); + - 不同则查 `menu_cache`(按 xmenu 缓存 widget),命中复用 widget, + 未命中 `make_menu_widget` 全量新建 widget 树。 +4. Qt 侧 `SLOT_MODE_ICONS` → `mode_icons_widget->get_qactionlist()`: + 每个 widget 实例首次调用时创建全部 QAction(有 `cachedActionList`)。 +5. `replaceButtons`:`same_actions` 指针判等,相同早退;不同则 + hide → 全部 removeAction → 全部 addAction → 重设按钮样式 → show。 + +(结论待插桩数据补充) + +### 7.2 插桩实测结论(2026-07-19,GUI 驱动 TeXmacs/tests/1145.scm) + +插桩点:`get_menu_widget`(EQUAL/CACHE_HIT/MISS 计数 + menu-expand / +make_menu_widget 计时)、`replaceButtons`(SAME/REPLACE 计数 + 计时)、 +`get_qactionlist`(计时)。 + +**每次 update_menus(任何编辑后 idle ≥66ms 触发一次)的实测分解:** + +| 场景 | total | menu-expand×10 | make_menu_widget | replaceButtons | +|---|---|---|---|---| +| 纯文本输入(每步) | 77-102ms | ~75-95ms | 0 | 0 | +| 进入数学模式 | 166-173ms(首次315ms) | ~105ms | ~41ms | 1-4ms | +| 数学模式内输入 | 110-154ms | ~110-152ms | 0 | 0 | +| 退出数学模式 | 81-91ms | ~75ms | 0(CACHE_HIT) | 0-2ms | + +**核心结论:** + +1. **按钮重建不是瓶颈**:`replaceButtons` 全量 REPLACE 每次仅 0-2ms; + `same_actions` 早退 + `menu_current` equal? 判等 + `menu_cache` 三层 + 机制已经避免了绝大多数重建。纯文本输入时 mode 工具栏展开结果稳定, + 全程 EQUAL、零 REPLACE。 +2. **真正的瓶颈是每次 update_menus 都重新跑 10 次 scheme `menu-expand` + (~75-110ms)**,即使结果与上次完全相等——判等发生在展开之后,展开 + 本身的开销省不掉。大头是 which=0(主图标 ~20ms)、which=1(模式 + 图标 ~22ms)、which=2(焦点图标 ~11ms)。 +3. **次要浪费**:数学上下文下 which=2(focus icons)展开结果不稳定 + (每次 MISS),每轮进出数学都 make_menu_widget ~41ms;文本上下文 + 下展开稳定(CACHE_HIT)。 +4. which=4(tab 页)已有签名机制避免重建,但签名检查在 expand 之后, + 可先算签名、不变则跳过 expand(每次省 ~5ms)。 + +### 7.3 focus icons 缓存 MISS 根因与修复(2026-07-19) + +**根因**:`gui-make-push-focus`(menu-define.scm:58)给每个 `push-focus` +菜单项注入 `(invisible (tree->path pushed-tree))`——焦点树的**文档路径**。 +该载荷不参与 widget 构建(`menu-convert` 对 `invisible` 一律返回空表), +只被 `get_menu_widget` 的展开结果判等/缓存 key 使用。路径随光标位置变化 +(如数学模式里 `(invisible (2 0 2))` → `(2 0 3)`),导致展开结果永远 +`equal?` 失败,`menu_cache` 永远 MISS,每次进数学模式都全量 +`make_menu_widget`(~41ms)+ 重建 QAction(~6ms)。 + +**修复**:缓存 key 归一化——新增 scheme 函数 `menu-cache-normalize` +(menu-widget.scm,置于 `cache-menu?` 旁),递归把 `(invisible 载荷)` +替换为 `(invisible)`;`get_menu_widget` 用归一化后的 `xkey` 做 +`menu_current` 判等与 `menu_cache` 索引。`umenu`(构建 widget 用的未展开 +菜单)不受影响,行为零变化。 + +**效果**(TeXmacs/tests/1145.scm 实测): + +| 场景 | 修复前 | 修复后 | +|---|---|---| +| 重复进入数学模式(第 2 次起) | 166-173ms(which=2 MISS + make ~41ms + qact ~6ms) | 121-156ms(which=2 CACHE_HIT,零重建) | +| 首次进入数学模式(冷缓存) | ~315ms | ~350ms(不变,一次性) | + +### 7.4 遗留问题(后续任务) + +1. 每次 update_menus 仍全量跑 10 次 `menu-expand`(~75-110ms),即使 + 结果全 EQUAL。需要前置签名/降频机制,收益最大但有一定正确性风险。 +2. which=4 签名检查可前移到 expand 之前。 +3. 首次进入某上下文的冷缓存 MISS 无法避免(可做预 warmup)。 diff --git a/src/Plugins/Qt/qt_tm_widget.cpp b/src/Plugins/Qt/qt_tm_widget.cpp index a3971ef7c9..a788038330 100644 --- a/src/Plugins/Qt/qt_tm_widget.cpp +++ b/src/Plugins/Qt/qt_tm_widget.cpp @@ -71,6 +71,7 @@ bool in_presentation_mode (); #include "qt_menu.hpp" #include "qt_simple_widget.hpp" #include "qt_window_widget.hpp" +#include "tm_debug.hpp" #include "tm_server.hpp" #include "tm_sys_utils.hpp" #include "tm_url.hpp" @@ -179,11 +180,25 @@ same_actions (QWidget* dest, QList* src) { return true; } +// [1145] 临时诊断:统计 replaceButtons 的 same(早退)与 +// replace(全量重建)次数 +static int rb_diag_same= 0, rb_diag_replace= 0; + static void replaceButtons (QToolBar* dest, QList* src) { if (src == NULL || dest == NULL) TM_FAILED ("replaceButtons expects valid objects"); - if (same_actions (dest, src)) return; + if (same_actions (dest, src)) { + rb_diag_same++; + std_bench << "[1145] replaceButtons SAME (same=" << rb_diag_same + << " replace=" << rb_diag_replace << ")\n"; + return; + } + rb_diag_replace++; + std_bench << "[1145] replaceButtons REPLACE n=" << src->count () + << " (same=" << rb_diag_same << " replace=" << rb_diag_replace + << ")\n"; + bench_start ("replaceButtons"); dest->setUpdatesEnabled (false); bool visible= dest->isVisible (); if (visible) dest->hide (); // TRICK: to avoid flicker of the dest widget @@ -198,6 +213,7 @@ replaceButtons (QToolBar* dest, QList* src) { } if (visible) dest->show (); // TRICK: see above dest->setUpdatesEnabled (true); + bench_end ("replaceButtons"); } void diff --git a/src/Plugins/Qt/qt_ui_element.cpp b/src/Plugins/Qt/qt_ui_element.cpp index 0e8186bd6a..b0c85f7ec2 100644 --- a/src/Plugins/Qt/qt_ui_element.cpp +++ b/src/Plugins/Qt/qt_ui_element.cpp @@ -24,6 +24,7 @@ #include "message.hpp" #include "promise.hpp" #include "scheme.hpp" +#include "tm_debug.hpp" #include "widget.hpp" #include "QTMGuiHelper.hpp" @@ -265,6 +266,8 @@ QList* qt_ui_element_rep::get_qactionlist () { if (cachedActionList) return cachedActionList; + // [1145] 临时诊断:QAction 全量创建耗时 + bench_start ("get_qactionlist"); QList* list= new QList (); switch (type) { @@ -285,6 +288,7 @@ qt_ui_element_rep::get_qactionlist () { break; } cachedActionList= list; + bench_end ("get_qactionlist"); return list; } diff --git a/src/Texmacs/Window/tm_window.cpp b/src/Texmacs/Window/tm_window.cpp index 151fb52b9d..83692fadeb 100644 --- a/src/Texmacs/Window/tm_window.cpp +++ b/src/Texmacs/Window/tm_window.cpp @@ -16,6 +16,7 @@ #include "message.hpp" #include "preferences.hpp" #include "tm_data.hpp" +#include "tm_debug.hpp" #include "tm_url.hpp" #include @@ -399,6 +400,10 @@ tm_window_rep::refresh () { bool menu_caching= true; +// [1145] 临时诊断:统计 get_menu_widget 的 EQUAL/CACHE_HIT/MISS 次数 +static int menu_diag_equal[12]= {0}, menu_diag_cache[12]= {0}, + menu_diag_miss[12]= {0}; + bool tm_window_rep::get_menu_widget (int which, string menu, widget& w) { drd_info old_drd= the_drd; @@ -406,8 +411,13 @@ tm_window_rep::get_menu_widget (int which, string menu, widget& w) { tm_view vw= concrete_view (window_to_view (id)); if (vw != NULL) the_drd= vw->ed->drd; } + bench_start ("menu-expand"); object xmenu= call ("menu-expand", eval ("'" * menu)); - the_drd = old_drd; + bench_end ("menu-expand"); + the_drd= old_drd; + // 缓存判等用归一化 key:剔除 invisible 载荷(如 push-focus 注入的光标 + // 路径),避免缓存 key 随光标位置变化而永远 MISS。 + object xkey= call ("menu-cache-normalize", xmenu); // tab 栏(which==4):xmenu 含每次新建的 lambda,无法用 equal 比较,故用 // 稳定签名判等。签名不变(如切 tab)=> 跳过重建,保持上次 widget。 if (which == 4) { @@ -415,21 +425,46 @@ tm_window_rep::get_menu_widget (int which, string menu, widget& w) { if (sig == tab_menu_signature) return false; tab_menu_signature= sig; } - if (menu_cache->contains (xmenu)) { - if (menu_current[which] == xmenu) return false; + if (menu_cache->contains (xkey)) { + if (menu_current[which] == xkey) { + if (which >= 0 && which < 12) { + menu_diag_equal[which]++; + std_bench << "[1145] menu which=" << which + << " EQUAL (equal=" << menu_diag_equal[which] + << " cache=" << menu_diag_cache[which] + << " miss=" << menu_diag_miss[which] << ")\n"; + } + return false; + } if (which < 10) { - menu_current (which)= xmenu; - w = menu_cache[xmenu]; + if (which >= 0) { + menu_diag_cache[which]++; + std_bench << "[1145] menu which=" << which + << " CACHE_HIT (equal=" << menu_diag_equal[which] + << " cache=" << menu_diag_cache[which] + << " miss=" << menu_diag_miss[which] << ")\n"; + } + menu_current (which)= xkey; + w = menu_cache[xkey]; return true; } } - menu_current (which)= xmenu; + if (which >= 0 && which < 12) { + menu_diag_miss[which]++; + std_bench << "[1145] menu which=" << which + << " MISS (equal=" << menu_diag_equal[which] + << " cache=" << menu_diag_cache[which] + << " miss=" << menu_diag_miss[which] << ")\n"; + } + menu_current (which)= xkey; object umenu = eval ("'" * menu); + bench_start ("make_menu_widget"); if (which == 10 || which == 11) w= make_menu_widget (umenu, 400, 1000); else w= make_menu_widget (umenu); + bench_end ("make_menu_widget"); if (menu_caching) if (which >= 10 || as_bool (call ("cache-menu?", xmenu))) { - menu_cache (xmenu)= w; + menu_cache (xkey)= w; } return true; }