#1913·weasel

在新打开的程序里第一次输入时,拼音和候选窗一起明显延迟出现,将TSF 候选窗的 D2D 渲染目标由 DEFAULT 改用 SOFTWARE 后实测延迟明显缩短

Author: surumnvCreated Aug 22, 2026Updated Sep 5, 2026
LabelsBug

提交前检查

  • 我遇到的问题没有其他人在 issue 里提到过
  • 我的小狼毫版本于 rime/weasel 下载
  • 我在使用小狼毫的最新发布版本,或最新发布版本后的 CI 构建

操作系统信息

  • OS 详细版本: Windows 11 家庭版 25H2 (Build 26200.7309)
  • 小狼毫版本: 0.17.4

描述遇到的问题

问题概述

在新打开的程序里第一次输入时,拼音和候选窗一起明显延迟出现;同一个程序里之后就正常了。

WeaselUI/DirectWriteResources.cpp:45 把候选窗的渲染目标类型硬编码为 D2D1_RENDER_TARGET_TYPE_DEFAULT。由于 weasel.dll 注册为 InprocServer32, TSF 候选窗画在正在打字的那个应用进程里,于是这一个调用会在每一个应用进程中 建立一个真实的 D3D11 设备,并把整套显卡用户态驱动映射进去。

本机实测:这一个 COM 调用 210 ms / +100 MB;改成 SOFTWARE(WARP)后 57 ms / +10 MB。 候选窗最终是 ID2D1DCRenderTarget,本来就要 BindDC blit 回 GDI DC, 只画几十个字,硬件设备在这里换不到任何东西。

我在本机以二进制补丁形式改了这一个枚举值,真机首次组字延迟从 260 至 820 ms 降到 42 至 98 ms(中位数 73)

复现步骤

  1. 打开一个此前没有加载过小狼毫 TSF 候选窗的程序,例如 Chrome、Typora、7zFM、WinRAR 或记事本。
  2. 将输入法切换到小狼毫,并输入任意拼音。
  3. 观察第一次组字时拼音和候选窗的出现时间。
  4. 在同一个程序中继续输入,比较后续按键与第一次输入的延迟。
  5. 关闭程序并换一个新的宿主程序重复步骤 1 至 4;问题会在新进程第一次输入时再次出现。

预期行为

候选窗应在新宿主程序第一次输入时及时出现,不应因为创建候选窗渲染目标而在宿主进程中加载大体积硬件显卡用户态驱动或产生数百毫秒的同步延迟。

用户文件

本报告暂未附上用户文件夹内容。问题可由独立的 rt-bench.cpp 最小程序复现,不依赖具体词库、方案或 Lua.

截图

本问题主要由首次输入延迟和进程模块映射量体现,当前未附截图;报告正文已附基准程序、计时结果和模块列表。

其他补充说明

以下内容用于说明归因、复现数据、已有 issue 对照、建议修法及风险评估。

一、为什么是"每个应用进程一次"

三处代码合起来决定了这一点:

位置 内容
WeaselTSF/xmake.lua:5 TSF dll 链入了 WeaselUI
WeaselTSF/CandidateList.h:80 std::unique_ptr<weasel::UI> _ui —— 面板对象在客户端
RimeWithWeasel/RimeWithWeasel.cpp:582/592/595 三个 !is_tsf 门,服务端对 TSF 会话什么都不显示

也就是说 TSF 路径下的候选窗完全不经过 WeaselServer,而 weasel.dll 是 in-proc server, 会被加载进每一个你打字的程序。因此 WeaselUI 里任何一次性初始化成本, 每开一个新程序就要重付一次

WeaselUI/WeaselPanel.cpp:181-186 的 guard 确认了这个形状 —— DirectWrite 资源只在 pDWR == NULL / style 变化 / DPI 变化时重建, 所以是每进程一次,不是每次按键一次,正好对应"新窗口第一下卡、之后就好"。

二、成本 100% 集中在一个调用上

全新进程里逐项计时(不经过 Weasel,直接调底层 API):

调用 耗时
CreateDCRenderTargetDEFAULT 186.1 ms
CreateDCRenderTargetSOFTWARE 49.1 ms
D2D1CreateFactory(MULTI_THREADED) 3.1 ms
DWriteCreateFactory(SHARED) 2.3 ms
CreateTextFormat 2.7 ms
_SetFontFallback(含 4 次 AddMappings(system) 8.1 ms
强制 3 个字体族真的加载(FindFamilyNameCreateFontFaceHasCharacter 8.6 ms
GdiplusStartup 3.0 ms
SetTextAntialiasMode / CreateSolidColorBrush / BindDC 0.5 / 0.8 / 0.8 ms
BeginDraw / Clear / EndDraw 2.4 / 0.5 / 2.7 ms

除这一个调用之外,其余每一项都是个位数毫秒。 (顺便说明:我一开始怀疑的是字体枚举。GetSystemFontCollection(checkForUpdates=TRUE) 确实要 192~277 ms,但 DirectWriteResources.cpp:89 故意给 CreateTextFormatL"_InvalidFontName_",真字体全靠手工建的 font fallback,那条路实测只要 8 ms —— 字体不是原因。)

三、可复现的最小程序

下面这个程序只建一个渲染目标,然后打印因此被映射进本进程的图形模块。 必须在全新进程里各跑一次(成本是每进程一次的,同进程里测第二个类型只会测到第一个的残留)。

rt-bench.cpp(点开)
cpp
// MSVC : cl /EHsc /O2 rt-bench.cpp d2d1.lib psapi.lib
// MinGW: g++ -O2 -o rt-bench.exe rt-bench.cpp -ld2d1 -lpsapi -municode
//   rt-bench.exe 0   ->  D2D1_RENDER_TARGET_TYPE_DEFAULT  (current behaviour)
//   rt-bench.exe 1   ->  D2D1_RENDER_TARGET_TYPE_SOFTWARE (WARP)
#include <windows.h>
#include <d2d1.h>
#include <psapi.h>
#include <chrono>
#include <cstdio>
#include <string>
#include <vector>
#include <algorithm>

static bool IsGraphicsModule(const std::string& n) {
  static const char* kPrefixes[] = {"d2d1", "d3d", "dxgi", "dcomp", "dwrite",
                                    "dxcore", "ig", "nv", "amd", "atid"};
  std::string lower = n;
  std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
  for (const char* p : kPrefixes)
    if (lower.rfind(p, 0) == 0)
      return true;
  return false;
}

struct Mod { std::string name; SIZE_T size; };

static std::vector<Mod> GraphicsModules() {
  std::vector<Mod> out;
  HMODULE mods[1024];
  DWORD needed = 0;
  if (!EnumProcessModules(GetCurrentProcess(), mods, sizeof(mods), &needed))
    return out;
  for (DWORD i = 0; i < needed / sizeof(HMODULE); ++i) {
    char name[MAX_PATH] = {0};
    if (!GetModuleBaseNameA(GetCurrentProcess(), mods[i], name, MAX_PATH))
      continue;
    if (!IsGraphicsModule(name))
      continue;
    MODULEINFO mi = {0};
    if (!GetModuleInformation(GetCurrentProcess(), mods[i], &mi, sizeof(mi)))
      continue;
    out.push_back({name, mi.SizeOfImage});
  }
  std::sort(out.begin(), out.end(),
            [](const Mod& a, const Mod& b) { return a.size > b.size; });
  return out;
}

static void Report(const char* tag, const std::vector<Mod>& mods) {
  double total = 0;
  for (const Mod& m : mods) total += (double)m.size;
  std::printf("%s: %zu modules, %.1f MB\n", tag, mods.size(), total / 1048576.0);
  for (const Mod& m : mods)
    std::printf("    %-28s %8.1f MB\n", m.name.c_str(), m.size / 1048576.0);
}

int wmain(int argc, wchar_t** argv) {
  const bool software = (argc > 1 && argv[1][0] == L'1');
  const D2D1_RENDER_TARGET_TYPE type =
      software ? D2D1_RENDER_TARGET_TYPE_SOFTWARE
               : D2D1_RENDER_TARGET_TYPE_DEFAULT;
  std::printf("=== D2D1_RENDER_TARGET_TYPE_%s ===\n",
              software ? "SOFTWARE" : "DEFAULT");

  ID2D1Factory* factory = nullptr;
  if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_MULTI_THREADED, &factory)))
    return 1;
  Report("before CreateDCRenderTarget", GraphicsModules());

  D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties(
      type, D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM,
                              D2D1_ALPHA_MODE_PREMULTIPLIED));
  ID2D1DCRenderTarget* rt = nullptr;
  auto t0 = std::chrono::steady_clock::now();
  HRESULT hr = factory->CreateDCRenderTarget(&props, &rt);
  auto t1 = std::chrono::steady_clock::now();
  if (FAILED(hr)) { std::printf("failed: 0x%08lx\n", hr); return 1; }
  std::printf("\n>>> CreateDCRenderTarget %8.1f ms\n\n",
              std::chrono::duration<double, std::milli>(t1 - t0).count());

  Report("after  CreateDCRenderTarget", GraphicsModules());
  rt->Release();
  factory->Release();
  return 0;
}

本机输出(MinGW 编译,6 次采样):

DEFAULT   223.7  209.4  210.9  208.1  213.6  198.2   ms
SOFTWARE   58.9   55.5   62.4   56.8   56.2   58.5   ms

模块差异(同一台机器,两个全新进程):

=== DEFAULT (0) ===                     === SOFTWARE (1) ===
>>> CreateDCRenderTarget  223.7 ms      >>> CreateDCRenderTarget   58.9 ms

after: 8 modules, 106.3 MB              after: 6 modules, 16.0 MB
    igc64.dll          70.4 MB              d2d1.dll            6.2 MB
    igd10um64xe.DLL    17.9 MB              D3D10Warp.dll       5.7 MB
    d2d1.dll            6.2 MB              d3d11.dll           2.4 MB
    igdgmm64.dll        4.0 MB              dxgi.dll            1.2 MB
    igd10iumd64.dll     3.9 MB              dxcore.dll          0.3 MB
    d3d11.dll           2.4 MB
    dxgi.dll            1.2 MB
    dxcore.dll          0.3 MB

即:为了给候选窗建一个渲染目标,往宿主进程里塞了 96 MB 的 Intel 用户态显卡驱动。

一个不需要跑程序就能看到的旁证:在我这台机器上 Get-Process 7zFM | % Modules 里躺着 igc64.dll igd10um64xe.DLL igdgmm64.dll igd10iumd64.dll dxgi d3d11。 一个压缩包管理器没有任何理由加载 Direct3D。

四、真机效果(改成 SOFTWARE 后)

我用二进制补丁把 System32\weasel.dll(x64) 与 SysWOW64\weasel.dll(x86) 里这个枚举值改成 1, 然后用一个探针测"按下第一个键 → 候选窗出现":

程序 DLL 首次组字 show_ms 其中建渲染目标 拉进来的图形栈
msedge (PID 8396) 旧(未打补丁) 238 156 nvwgf2umx.dll 84.1MB + nvgpucomp64.dll 33.9MB = 124.9 MB
chrome 98 44 D3D10Warp.dll 5.7MB
Typora 73 48 D3D10Warp.dll
7zFM 74 43 D3D10Warp.dll
WinRAR 42 42 D3D10Warp.dll
记事本 69 39 D3D10Warp.dll
改前:首次组字 260 ~ 820 ms
改后:n=6  min=42  median=73  max=238(就是那个还揣着旧 DLL 的 Edge)

那个 Edge 进程是天然对照组,不是我挑的 —— 补丁安装时它已经在运行, 所以仍映射着旧 DLL,于是成了全场唯一走硬件路径、唯一 create_ms=156 的进程。 同一次运行、同一台机器、同一个探针,唯一变量是 DLL 版本。

五、一个补充事实:抓哪块显卡是由用户设置决定的

上表里 Edge 拉的是 NVIDIA(124.9 MB),而离线基准里拉的是 Intel(106 MB)。 这不是随机的:DEFAULT 继承宿主应用的 GPU 偏好,存在 HKCU\SOFTWARE\Microsoft\DirectX\UserGpuPreferences (= 设置 → 系统 → 屏幕 → 显示卡 → 图形性能首选项)。本机该键有 15 项, msedge.exe 的值正是 SwapEffectUpgradeEnable=0;GpuPreference=2;(高性能 → 3060 Laptop)。

换句话说:用户把某个程序设成"高性能",就等于把独显那套 119 MB 用户态驱动 也塞进了这个程序的输入法路径,而这个设置跟输入法毫无关系。 SOFTWARE 把两块卡都躲开。

六、这条归因能解释哪些已有报告

我遍历了本仓库全部 1469 个 issue 与 355 个 PR 的标题和首帖, D2D1_RENDER_TARGET_TYPE / RenderTargetProperties / WARP / D3D10Warp / 软件渲染 零命中,所以据我所知"渲染目标类型 → 每进程一套显卡驱动"这条具体归因此前没有被提出过。 但这不是说维护者不知道 D2D 跟显卡驱动有关系 —— #994 里已经明确说过,见下。

最直接的一条是 #1804DOAXVV 游戏里中文模式崩溃,目前 0 回复)。 报告者附的 WinDbg 结论是:

Failure.Bucket: INVALID_POINTER_WRITE_c0000005_d3d11.dll!Unknown
  d2d1!DelayLoadedProc<long (__cdecl*)(IDXGIAdapter*, D3D_DRIVER_TYPE, ...)>::Invoke+0xe4
  d3d11!D3D11CreateDevice+0x1d4
  d3d11!D3D11CoreRegisterLayers+0x3e5
  d3d11!D3D11CoreRegisterLayers+0xb4      <- Access Violation

这条栈就是 CreateDCRenderTarget 内部延迟加载 D3D11CreateDevice 的那一跳, 而它是在游戏自己的进程里崩的。也就是说:DEFAULT 不只是慢, 它还让 Weasel 在每一个宿主进程(包括独占全屏的 D3D 游戏进程)里去创建 D3D11 设备, 从而把输入法的稳定性绑到宿主进程的图形状态上。

已经被维护者归因过、而且方向一致的一条是 #994(更新显卡驱动后输入框完全没字、 必须重启该应用;closed/completed)。fxliang 当时的回复是:

因为使用的是 direct2d 相关的一些接口,显卡驱动更新有可能会引入影响的。

这个判断是对的。而且那份报告里有个很说明问题的细节:Chrome、notepad 复发,WeChat 不受影响 —— 逐进程的差异,正是"每个宿主进程各自持有一套显示设备状态"的直接表现。 最终修法是设备失效后重建资源(tf2 分支 → PR #1037;#1434 是同一族), 也就是从失效中恢复。改成 SOFTWARE 是另一种性质的解决: 宿主进程里根本没有硬件设备可丢,换驱动 / GPU 重置 / TDR 都影响不到候选窗。 我不是说恢复路径没用(它必须有),而是说这一整类暴露面本来可以不存在。

其余形状吻合、但尚未归因的(我不断言必然同因):

issue 现象 与本归因的关系
#1500(open,17 条回复) ff14 中文模式按字母键狂掉帧,狂按降到 1 帧 在独占全屏 D3D 游戏进程里创建并驱动 D3D 设备
#1732 CSGO 全屏首字卡死 同上
#1793 WeaselServer 显存占用 2 GB+ GPU 资源相关,无人分析

下面几条我核对过之后主动排除,写在这里免得别人再走一遍

  • #991(neovide 首次输入约 1.2 s,报告者还指出微软输入法在同一个程序里没这个问题)—— 形状很像,但它最后是报告者换到更晚的 master 后降到 40 ms 而结案,结案时并没有指认具体改动; 而他所建的 60c7a71 本身只是 librime 1.8.5 → 1.9.0 + boost 1.83 的依赖升级(PR #998), 一行渲染代码都没动。1.2 s 这个量级也更接近 librime 冷启动那根轴 (我这台机器上冷 engine 构造曾经是 837~870 ms)。所以我不把它算作本 issue 的佐证。
  • #1478(小狼毫 + 白霜打字卡顿)—— 根因在评论区已经查清,且与本 issue 无关: 方案的 aux_code.lua 有性能问题、加上方案自带的暴力 GC,严重时 librime 崩溃拖死 WeaselServer(报告者附了 .dmp,另见 #1487)。升级 aux_code.lua 即缓解。

七、请不要把它归入另外几类已知卡顿

这几类的原因互不相同,混在一起会互相掩盖:

  1. CPU 降频(#1250,46 条回复)—— 笔记本空闲降到 0.5 GHz,切高性能模式即缓解。 本 issue 的数据是全新进程里的离线 API 基准(上面那个程序,不经过 Weasel), 与 CPU 频率策略无关。
  2. IPC 在宿主 UI 线程上同步阻塞(#1909、#1878、#1417、#1335、#1623)—— 表现为切窗口/切焦点卡 3~5 s。那是另一条路径(PipeChannel),与渲染目标无关。
  3. 方案的 Lua 拖慢、乃至 librime 崩溃拖死 WeaselServer(#1478、#1487)—— #1478 的评论区已经把它查清了:aux_code.lua 的性能问题加上方案自带的暴力 GC。 特征是跟方案相关、换掉那个 lua 就缓解,而且严重时日志目录里会留 .dmp
  4. librime 释放词典缓存之后的冷启动(也在 #1478 评论区,wzv5 / eusru 的观察)—— "隔一会儿不打字,再打就要等两秒",而一直挂着一个记事本、保持会话不断就能缓解。 这是 session 引用计数归零后 closed db、下一个 session 重新构造 engine 的成本 (我这台机器上冷 215 ms、热 3~5 ms,日志里 engine disposed 之后 5 ms 就 closed db)。 它和本 issue 是完全不同的两根轴 —— 我先按这条把 OpenCC 从 837 ms 优化到 215 ms, 结果新程序里的首次输入延迟一点没变,这才是我最后去查渲染路径的原因。
  5. 本 issue:每个应用进程一次的 D3D 设备创建 + 驱动映射。 特征是只有每个程序的第一次输入慢、同一个程序里之后就正常, 而且与方案、词库、lua 全都无关(离线基准不经过 Weasel 也能复现)。

八、建议的修法

按侵入性从小到大:

  1. 直接改成 D2D1_RENDER_TARGET_TYPE_SOFTWARE 一处枚举值。候选窗是 ID2D1DCRenderTarget,终点必然是 BindDC + blit 到 GDI DC, 内容是几十个字加圆角矩形,WARP 完全够用(实测 paint_ms 25~54 ms,肉眼无差)。
  2. 做成配置项(如 style/render_target: default|software),默认 software。 仓库里已经有 style/antialias_mode 这个先例(#910 之后加的),放在它旁边很自然。 保留退路,也方便让上面几个游戏/掉帧 issue 的报告者直接验证。
  3. 若希望保留硬件路径:至少不要在 WeaselPanel 首次绘制的同步路径上创建它 (参见 PR #1886 的预热思路),并考虑在检测到宿主进程已有 D3D 设备时才用硬件。

我更倾向 1 或 2 。

九、我知道的风险,先自己认领

WARP 下的文字渲染质量我没有做过严格的视觉验证。 我的离线基准只 Clear 了渲染目标、 没有真正画字;真机使用中肉眼没看出差别,但没有做像素级对比。

不过把 #910("在 100% 縮放沒有了次像素反鋸齒",closed/completed)整条讨论读完之后, 我认为这个风险比我原先估计的,而且可以收窄成一个很具体的问题:

  • #910 的成因是 0.15.0 把文字绘制从 GDI 换成了 DirectWrite,与渲染目标类型无关。 当时的默认是 ANTIALIAS_MODE_GRAYSCALE,fxliang 的回复是"大多数人的反馈是更清了"。
  • 也就是说现在的默认本来就不是次像素抗锯齿,改渲染目标类型不会拿走用户手上正有的东西。
  • 而且 #910 之后已经加了配置项 style/antialias_mode: "default" | "cleartype" | "grayscale" | "aliased" | "force_dword", 想要 ClearType 的用户可以自己开。

十、与 PR #1886 和 fxliang:pb 分支的关系

  • PR #1886(目前 open、未合并)在同一个调用上量到 CreateDCRenderTarget 247.77 ms / 280.56 ms166.99 ms / 192.03 ms —— 独立第三方数据,与本机同一量级。 但它的做法是在获得焦点时预热只挪时机、不减开销: 100 MB 驱动映射照旧发生,只是不再落在第一次按键上。 两者互补,不冲突;如果同时采纳,剩下那 40~50 ms 也能挪走。
  • fxliang:pb 分支DirectWriteResources 换成了 d2d.cpp, 改用 DComposition + swap chain 架构。它依然先尝试硬件设备d2d.cpp:41 D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, ...), 仅在失败时才回退 D3D_DRIVER_TYPE_WARP),而且现在是显式且提前调用的 (d2d.cpp:127-133D2D::D2D() 构造函数里就 EnsureInitialized()), 额外还创建了 DXGI factory、DComposition device、swap chain 和 WIC factory。 所以这个问题在 pb 上不会自动消失,只是换了形式: pb 的 DeviceResources 是进程内单例,把成本从"每 TSF UI 线程"降到"每进程一次" —— 但宿主进程仍然是应用自己的进程,每个应用一次这笔账没有变

附:我的本机改法(仅供参考,不建议他人照做)

我没有 C++ 工具链,所以是直接改字节:System32\weasel.dll(x64) 偏移 205006 起 22 字节 等长改写(把两条多余的 movsd 换成 incl 0x70(%rsp) + 一条 mov %rdx,..., 利用此处 rdx 已为 0),SysWOW64\weasel.dll(x86) 偏移 186047 一个字节 0001。 偏移绑死 0.17.4 这个构建,仅用于验证本 issue 的结论,不是给用户的方案