Benchmark for interactive Zsh
zsh-bench
交互式zsh的基准测试工具。
概览
zsh-bench测量互动式zsh的用户可见延迟:输入延迟、命令延迟等。你可以用它来基准测试你的外壳。human-bench测量使用互动式zsh时的人体感知延迟。它可以用来检查特定延迟下使用zsh的感觉,或者测试是否能区分5毫秒和0毫秒的延迟。- 我使用
human-bench对自己进行了盲测,以找出无法与零区别的最大延迟值。例如,低于10毫秒的命令延迟感觉就像0毫秒一样,但超过这个值就会开始觉得迟钝。 - 我使用这些阈值对基准测试结果进行标准化,以便查看什么是快,什么是慢。
- 凭借这套工具,我优化了两个zsh项目: powerlevel10k 和 zsh4humans。它们过去相当快,但现在在人类感知上实际上与即时无异。
- 我对许多zsh技术、插件、框架和插件管理器进行了基准测试,并在此文档中分享了我的观察结果,以及一个简短的总结。
安装
克隆仓库:
git clone https://github.com/romkatv/zsh-bench ~/zsh-bench
使用方法
使用方式: zsh-bench [选项]... [配置文件名]...
选项:
-h,--help
-i,--iters <数目> [默认=16]
-l,--login <是|否> [默认=是]
-g,--git <是|否|空> [默认=是]
-c,--config-dir <目录> [默认=<zsh-bench-dir>/configs]
-d,--scratch-dir <目录>
-I,--isolation <docker|user>
-s,--standalone
-r,--raw
在你的机器上基准测试zsh
~/zsh-bench/zsh-bench
这需要zsh版本大于等于5.8,并且它必须是你的登录shell。
如果zsh启动文件开始运行tmux,除非你的tmux具有此修复
针对此问题,否则基准测试可能会挂起。
如果你的zsh启动文件启用了历史记录,但未设置histignorespace,运行zsh-bench后,你可能会在历史记录中找到随机命令。
预定义的zsh配置基准测试
~/zsh-bench/zsh-bench --isolation docker -- <名称> [名称]...
这需要docker。预定义的zsh配置名是configs下的目录。
使用--isolation user,则在主机上的zsh-bench用户身份下进行基准测试。
工作原理
zsh-bench创建了一个虚拟TTY并在这个里面启动一个登录shell。然后向TTY发送按键,并测量shell响应所需的时间。例如,zsh-bench可以连续两次向TTY发送echo hello和echo goodbye,并测量“hello”和“goodbye”被打印出来所需的时间。
测量什么
zsh-bench的部分输出示例:
creates_tty=1
has_compsys=1
has_syntax_highlighting=1
has_autosuggestions=1
has_git_prompt=1
first_prompt_lag_ms=14.331
first_command_lag_ms=56.500
command_lag_ms=2.518
input_lag_ms=5.195
exit_time_ms=5.886
前几个字段列出了检测到的shell功能;其余的是测量的延迟时间。
shell功能(0或1):
| 名称 | 含义 |
|---|---|
| 创建TTY | shell通过调用tmux或screen自建TTY |
| 启用补全系统 | shell通过调用compinit初始化“新”补全系统 |
| 语法高亮 | 用户输入(命令行)由zsh-syntax-highlighting进行高亮处理 |
| 自动建议 | zsh-autosuggestions 自动提供命令完成建议 |
| Git提示 | 提示符显示Git分支 |
延迟时间(以毫秒计):
| 名称 | 测量内容 | 若过高 |
|---|---|---|
| 首次提示延迟(ms) | 从shell启动到提示符出现在屏幕上所花费的时间 | 打开终端时需盯着空白屏幕一段时间 |
| 首次命令延迟(ms) | 从shell启动到第一个交互式命令开始执行所花的时间 | 输入ls之类的命令时,若在打开终端后很快键入,会有等待输出的情况 |
| 命令延迟(ms) | 从在空命令行按下Enter到下一个提示符出现的时间;等同于zsh-prompt-benchmark(我的项目) | 所有命令看起来执行速度较慢;可能发生在按下Enter之后,命令开始执行之前,或命令执行完毕和下一个提示符出现之间 |
| 输入延迟(ms) | 按下一个普通键到相应字符出现在命令行上的时间;当当前命令行已经较长时进行此测试 | 键盘输入感觉迟钝,如同在SSH连接中工作,网络延迟较高 |
| 退出时间(ms) | 执行zsh -lic "exit"所花费的时间;就测量交互式shell延迟而言,意义不大 |
此延迟没有基线值,因此不会“过高” |
快速意味着什么
5毫秒的输入延迟多吗?100毫秒的首次提示延迟呢?这些延迟减半/加倍会怎样?为了回答这类问题,我编写了human-bench。这是一个小型工具,可以模拟具有你选择的延迟时间的zsh。
使用方式: human-bench [选项]..
选项:
-h,--help
-s,--shell-command <字符串> [默认="zsh"]
-f,--first-prompt-lag-ms <数字> [默认=0]
-c,--first-command-lag-ms <数字> [默认=0]
-p,--command-lag-ms <数字> [默认=0]
-i,--input-lag-ms <数字> [默认=0]
事实证明,100毫秒的首次提示延迟会使启动zsh时的第一个提示有一定的延迟,而5毫秒的输入延迟几乎察觉不到。还是说察觉不出来?当我运行human-bench --input-lag-ms 5时,我期待输入会出现延迟,这可能会影响我的观察。为了消除这种偏见,我扩展了human-bench,使其接受相同延迟的多个值:
human-bench --input-lag-ms 0 --input-lag-ms 5
human-bench会在启动zsh之前随机选择其中一个延迟值。然而,直到退出测试环境,它才透露所选的延迟。有了这个工具,我对我自己进行了一项盲测,并发现我无法区分这两个延迟。就我的感官而言,5毫秒的输入延迟与零没什么区别。
我使用这种掩蔽方法找到了所有延迟在我的zsh使用中的阈值。任何低于阈值的值对我来说都是无法辨别的。我可以比50%的准确性更高地区分高于阈值的值与零。
| 延迟(毫秒) | 无法分辨与零的最高值 |
|---|---|
| 首次提示延迟 | 50 |
| 首次命令延迟 | 150 |
| 命令延迟 | 10 |
| 输入延迟 | 20 |
前两种延迟与zsh启动时间相关。我并不是在现有shell内直接键入zsh来启动zsh。我会打开一个新的终端,新建一个标签页,或者分割现有的标签页。最后一种是最常见的,所以我为测试启动延迟修改了human-bench来分割一个标签页。对于其他两种延迟,我则是输入并执行简单的命令。
请注意,这些阈值可能因人、机器、终端等不同而变化。我认为大致范围应该是相同的。
性能测试结果
我开发了zsh-bench来优化zsh-powerlevel10k和zsh4humans。在此过程中,我对多种zsh技术、插件、框架和插件管理器进行了基准测试。这里分享一些我的发现。
建议从头到尾阅读本节,而不是跳读。您也可以直接跳到结论部分。
本节中的所有性能测试结果都已根据阈值标准进行标准化。25毫秒的“初次提示延迟”变成了50%,而100毫秒变成了200%。分别用绿色(🟢),黄色(🟡)和橙色(🟠)标记50%,100%和200%的延迟。超过200%的延迟用红色(🔴)标出。值得注意的是,黄色实际上是非常好的,意味着延迟几乎无法察觉。一个所有延迟都是绿灯或黄灯的zsh配置,表现得就像没有任何延迟一样。我保留绿色用于低于雄心勃勃的阈值一半的延迟,因为留有一定的余量是不错的。我可能会在我的zsh配置中添加额外的东西,或者也许会在较慢的机器上运行zsh,不希望这将我的延迟推到不可感知范围之外。因此,绿色的延迟不仅是不可感知的,而且还留有足够的未使用延迟预算。
基础设置
| 配置 | tmux | 补全系统 | 语法高亮 | 自动建议 | Git提示 | 初始提示延迟 | 第一次命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| no-rcs | ✖️ | ✖️ | ✖️ | ✖️ | ✖️ | 3% 🟢 |
1% 🟢 |
1% 🟢 |
1% 🟢 |
| tmux | ✔️ | ✖️ | ✖️ | ✖️ | ✖️ | 8% 🟢 |
3% 🟢 |
1% 🟢 |
1% 🟢 |
| compsys | ✖️ | ✔️ | ✖️ | ✖️ | ✖️ | 37% 🟢 |
12% 🟢 |
1% 🟢 |
1% 🟢 |
| zsh-syntax-highlighting | ✖️ | ✖️ | ✔️ | ✖️ | ✖️ | 23% 🟢 |
14% 🟢 |
6% 🟢 |
57% 🟡 |
| zsh-autosuggestions | ✖️ | ✖️ | ✖️ | ✔️ | ✖️ | 32% 🟢 |
13% 🟢 |
96% 🟡 |
3% 🟢 |
| git-branch | ✖️ | ✖️ | ✖️ | ✖️ | ✔️ | 32% 🟢 |
11% 🟢 |
50% 🟢 |
1% 🟢 |
no-rcs代表zsh最纯净的形式,没有使用任何rc文件。它非常快!即使速度再慢十倍,我也难以察觉差异。
其余条目展示了能够提供每种功能的最简单配置。例如,这是zsh-autosuggestions的.zshrc示例:
source ~/zsh-autosuggestions/zsh-autosuggestions.zsh
只有一个命令行,简洁明了。~/zsh-autosuggestions应当手动在zsh配置文件外创建。在基准测试中,通过如下这样的setup脚本完成:
git clone -q --depth=1 https://github.com/zsh-users/zsh-autosuggestions.git ~/zsh-autosuggestions
这些基础模块是可以组合的。我们可以轻松地通过组合它们创造新的配置。稍后我们将这样做。组合配置的延迟是其所有组成部分延迟的总和。例如,“初次提示延迟”对于tmux+compsys就是tmux的“初次提示延迟”加上compsys的“初次提示延迟”。您可能已经可以看出,将所有组件结合在一起可能会使某些延迟超出阈值。我们的目标是在仍获得所有便利的同时避免这种情况。git-branch以牺牲48%的“命令延迟”预算为我们带来了Git提示。让我们看看是否可以做得更好。
提示栏
我测试了几种不同的Git提示样式。
| 配置 | tmux | compsys | 语法高亮 | 自动建议 | Git提示 | 初次提示延迟 | 第一次命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| git-branch | ✖️ | ✖️ | ✖️ | ✖️ | ✔️ | 32% 🟢 |
11% 🟢 |
50% 🟢 |
1% 🟢 |
| agnoster | ✖️ | ✖️ | ✖️ | ✖️ | ✔️ | 65% 🟡 |
22% 🟢 |
244% 🔴 |
1% 🟢 |
| starship | ✖️ | ✖️ | ✖️ | ✖️ | ✔️ | 82% 🟡 |
28% 🟢 |
354% 🔴 |
1% 🟢 |
| powerlevel10k | ✖️ | ✖️ | ✖️ | ✖️ | ✔️ | 4% 🟢 |
14% 🟢 |
19% 🟢 |
1% 🟢 |
测试使用的Git仓库包含1000个目录和10000个文件,数量既不过少也不过多。所有的基准测试都启用了未跟踪缓存。git status的实时执行时间稳定在16毫秒。
git-branch仅显示当前分支名称,其延迟与仓库大小无关。
agnoster配置采用经典的agnoster zsh主题,会扫描整个仓库以查看是否有未跟踪的文件、未暂存更改等。这导致每次命令时都有延迟,延迟能推至红线。该延迟能随着仓库中的文件和目录数量线性增长。您不会希望在真正大型的Git仓库(拥有数十万或数百万文件)中使用此主题。
starship配置使用跨shell的starship提示符。它遭受与agnoster相同甚至更糟的性能问题。Starship作为一个外部二进制程序实现,因此相比原生zsh提示符,每一命令都要承担至少多一个额外的fork+exec开销。单个fork+exec不能解释starship表现出的高延迟,那么原因何在?在测试条件下,starship执行了158次克隆操作!这是相当昂贵的。
powerlevel10k配置采用了我开发的powerlevel10k zsh主题。它像agnoster和starship那样扫描Git仓库,但并不调用git来完成任务,而是使用了gitstatus——我的另一个项目。这让powerlevel10k无论在大还是小的仓库中都能得到显著的速度提升。此外,powerlevel10k在gitstatus扫描仓库时不会阻塞zsh提示,因此即使在庞大的仓库中,“命令延迟”也能保持不变。当我们开始构建真实的zsh配置时,我们将探索powerlevel10k的其他几个有趣的性能相关特性。
预制配置
让我们看看一些流行的预制zsh配置默认提供了什么。
| 配置 | tmux | compsys | 语法高亮 | 自动建议 | Git提示 | 初次提示延迟 | 第一次命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| prezto | ✖️ | ✔️ | ✖️ | ✖️ | ✖️ | 97% 🟡 |
35% 🟢 |
13% 🟢 |
1% 🟢 |
| ohmyzsh | ✖️ | ✔️ | ✖️ | ✖️ | ✔️ | 187% 🟠 |
64% 🟡 |
366% 🔴 |
2% 🟢 |
| zim | ✖️ | ✔️ | ✔️ | ✔️ | ✔️ | 122% 🟠 |
53% 🟡 |
191% 🟠 |
64% 🟡 |
| zsh4humans | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | 19% 🟢 |
36% 🟢 |
27% 🟢 |
25% 🟢 |
这些配置的名字与它们分别来自的公共项目的名称匹配:ohmyzsh,prezto,zim以及zsh4humans。后者是我的项目。所有配置均未经修改使用。
prezto速度快,但默认提供的功能不多。没有语法高亮、自动建议或Git提示。大多数需要这些特性的用户应明确启用它们。
ohmyzsh和zim默认使用类似于agnoster的主题,因而在较大的Git仓库中具有较高的“命令延迟”。zim是其中唯一不检测未跟踪文件的配置,这似乎是zim开发者出于性能考虑的有意决策。我们看到这对zim有益:zim的“命令延迟”大约是ohmyzsh的一半。
zim默认开启语法高亮和自动建议,所以自然具有比那些不开这些功能的项目更高的“输入延迟”。快速的zsh启动是zim的一个重要明确目标,我们可以看到它在“初次提示延迟”和“第一次命令延迟”方面胜过ohmyzsh。
zsh4humans涵盖了所有功能,并且所有延迟都舒适地保持在绿色区间内。这不应令人惊讶。在优化游戏中,测量本身就是一半的工作。得益于zsh-bench,我能优化zsh4humans,使其在这个基准测试上有良好的表现。在我创建zsh-bench之前,我知道zsh4humans中的“输入延迟”和“第一次命令延迟”有时是可感知的,但在那时我不仅无法测量这些延迟,甚至连清晰的概念都没有,评估潜在优化的有效性是很困难的。
请注意,所有这些项目都有表中未反映的额外功能,比如持久历史记录、定义别名、设置shell选项等。
自己动手配置
让我们暂时不考虑预设的配置,尝试从零开始构建一个 zsh 配置。由于已有高质量的模块,这应该并不复杂。
| 配置 | tmux | 完成系统 | 语法高亮 | 自动建议 | Git 提示 | 第一次提示延迟 | 第一条命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| diy | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | 118%\n🟠 | 47%\n🟢 | 155%\n🟠 | 61%\n🟡 |
| diy+ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | 10%\n🟢 | 51%\n🟡 | 24%\n🟢 | 63%\n🟡 |
| diy++ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | 10%\n🟢 | 42%\n🟢 | 24%\n🟢 | 64%\n🟡 |
diy 是最简单的配置,提供了所有功能。我通过组合 基础模块 的配置文件创建了它。这是整个 .zshrc 文件:
# 如果不在 tmux 中,启动 tmux。
if [[ -z ${TMUX+X}${ZSH_SCRIPT+X}${ZSH_EXECUTION_STRING+X} ]]; then
exec tmux
fi
# 启用新的“完成”系统(compsys)。
autoload -Uz compinit && compinit
# 配置提示符以显示当前工作目录和 Git 分支。
autoload -Uz vcs_info add-zsh-hook
add-zsh-hook precmd vcs_info
PS1='%~ $vcs_info_msg_0_'
setopt prompt_subst
# 启用语法高亮。
source ~/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
# 启用自动建议。
source ~/zsh-autosuggestions/zsh-autosuggestions.zsh
两个延迟超过了阈值,因此可能会感觉到一些滞后。然而,在使用此配置之前,您可能希望向其添加更多内容——键绑定、环境变量、别名、完成选项等。所有这些都会使 zsh 变得更慢。如果不小心,速度会下降很多。
diy+ 对 diy 进行改进,将其提示替换为 powerlevel10k。这对 第一次提示延迟 和 命令延迟 产生了显著的正面影响。此外,第一次提示延迟 现在是常量,即使向配置中添加更多内容也不会增加。这意味着打开终端时,您不必盯着空白屏幕——提示符一开始就显示出来。额外的初始化代码只会影响 第一条命令延迟,这个延迟具有所有延迟中的最高阈值,并对 zsh 性能感知的影响最小。我还让这个配置的 .zshrc 自启动,以便消除为克隆 zsh 插件仓库维护单独的 install 或 setup 脚本的需要。这样做主要是为了将来与插件管理器进行比较时更加方便。
diy++ 添加了一个额外的优化——编译大型 zsh 文件到字码。这略微减少了 第一条命令延迟。这个配置表现良好,并且仍然相对简洁:github.com/romkatv/zsh-bench/blob/master/configs/diy%2B%2B/skel/.zshrc。
权衡取舍
有些优化可以加速 zsh 启动,但也容易适得其反。diy++unsafe 在 diy++ 基础上添加了三个这样的优化,以减少 第一条命令延迟 到预算的 5%。我不推荐它们。
| 配置 | tmux | 完成系统 | 语法高亮 | 自动建议 | Git 提示 | 第一次提示延迟 | 第一条命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| diy++unsafe | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | 9%\n🟢 | 37%\n🟢 | 24%\n🟢 | 63%\n🟡 |
第一个优化是将 .zshrc 编译成字码。如果做如下操作,这会导致诸多不便:
% cp ~/.zshrc ~/.zshrc.bak # 备份 .zshrc 以免弄乱
% vi ~/.zshrc # 修改
% exec zsh # 重启 zsh 试试新更改
% mv ~/.zshrc.bak ~/.zshrc # 恢复备份
% exec zsh # 重启 zsh
此时,您可能会惊讶地发现最后一个命令似乎没有效果。zsh 仍在使用您在 vi 中修改的 .zshrc。这是因为 mv 保留了文件的修改时间,而 zsh 会根据这个时间来判断字码是否匹配源代码。
将 .zshrc 编译成字码还会阻止您在同一个文件中定义的别名在 .zshrc 中使用。例如:
alias ll='ls -l'
lll() { ll "$@" | less; }
如果将这段代码放入 .zshrc,您会注意到当 .zshrc 编译后,lll 不会工作。
另一个危险的优化是使用 -C 参数调用 compinit。如果您这样做了,并通过喜爱的包管理器安装了新工具,该工具的补全功能可能在 zsh 即使重启动后也仍不可见。您需要手动删除缓存文件。省去几毫秒的 zsh 启动时间不值得,因为后来您可能要花一小时来搞清楚为什么补全不起作用。
最后一种急功近利的优化是在检查插件是否需要安装之前打印第一个提示。关于 即时提示 的部分解释了为什么这是一个坏主意。
Powerlevel10k
在继续之前,让我们仔细看看 powerlevel10k。这个主题可以在提示符中显示大量信息:磁盘使用情况、公共 IP 地址、VPN 状态、当前 Kubernetes 上下文、Taskwarrior 任务数量等。powerlevel10k 的配置对其延迟有影响。所有基准测试的 powerlevel10k 配置都采用相同的简单配置,仅在提示符中显示当前工作目录和 Git 状态。除此之外,我还测量并优化了(这就是 zsh-bench 工作的重点)powerlevel10k 的性能,使其 “开启所有功能”。
| 配置 | tmux | 完成系统 | 语法高亮 | 自动建议 | Git 提示 | 第一次提示延迟 | 第一条命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| powerlevel10k | ❌ | ❌ | ❌ | ❌ | ✔️ | 4%\n🟢 | 14%\n🟢 | 19%\n🟢 | 1%\n🟢 |
| powerlevel10k-full | ❌ | ❌ | ❌ | ❌ | ✔️ | 8%\n🟢 | 27%\n🟢 | 64%\n🟡 | 6%\n🟢 |
powerlevel10k-full 的 命令延迟 显著增加,但仍低于 100%,意味着提示几乎瞬间可见。但是,剩下的 命令延迟 预算不多,所以如果要使用开启所有功能的 powerlevel10k,您需要谨慎处理您的 zsh 配置。实际上,没有人会在实际中启用全部功能。以下是一个荒谬繁复的提示:

它有 18 个段落。完整配置启用了 64!
powerlevel10k-full 相比较小配置增加了 1.2 毫秒的 输入延迟。Powerlevel10k 根据正在输入的当前命令动态更新提示。以下是 powerlevel10k 文档中的例子,展示了只在相关命令时显示当前 Kubernetes 上下文和 gcloud 凭证。
Powerlevel10k 根据命令显示

这项功能需要解析随时间变化的命令行,因此导致额外的 输入延迟。虽然对延迟影响小,不会造成问题。
即时提示
我前面提到,powerlevel10k 使得 第一次提示延迟 很小并且独立于 zsh 启动文件中的其他内容。这一特性在 powerlevel10k 文档中称为 即时提示(更恰当的名字可能是 即时 首次 提示),值得了解其工作原理。
当您在浏览器中打开谷歌主页时,即使页面内置了许多高级功能,看起来加载几乎是瞬间完成的。如果我们深入了解,整个页面确实加载了很长时间,但大部分加载发生在 UI 渲染之后。只需很短的时间就能渲染查询输入框和搜索按钮,所以感觉是瞬时的。初始界面可能看起来像真正的东西,但它只是一个占位符。如果您快速输入查询并点击按钮,搜索结果不会立即出现。因为按钮还没有必要的逻辑,所以它只会记住被点击了。查询将在页面完全加载后执行。
这种技巧非常有效,因为您可以立即开始输入。按照机器标准,输入非常缓慢,所以在您完成输入时,页面几乎总是已经完全加载,点击按钮时不会察觉延迟。
Powerlevel10k 使用了相同的方法,但在 zsh 中,启动时看到的界面就是提示。一旦打开终端,powerlevel10k 就会打印提示。这个最初的提示只有可以快速计算的信息(当前工作目录、用户名、主机名、当前时间、Python 虚拟环境等),但没有需要大量时间的内容,如 Git 状态。在您输入第一条命令期间,zsh 继续初始化——加载插件、设置补全、定义别名、启用键绑定、获取 Git 状态等。一旦 zsh 完全初始化,原有的有限提示被完整的提示替换,您已输入的内容会在 Zsh 行编辑器 (zle) 中重新播放。如果您启用了语法高亮,这时命令行会被高亮。下面是它的样子:
Powerlevel10k 即时提示

在zsh尚未完全初始化时按下Enter,命令不会执行。这是因为命令的运行依赖于别名、环境变量等因素,而这些还没定义。一旦zsh完成启动,命令就会执行。在此之前,看起来就像延迟了一样,仿佛命令需要很长时间才能开始执行。如果你使用如Tab进行补全或Ctrl+R进行交互式历史搜索,也会注意到相同的“延迟”。
当输入第一个命令时,同时启动zsh会带来问题。如果.zshrc中的某个部分向终端打印信息,那么输出将会出现在你认为是命令行的中间位置,非常不美观。如果.zshrc询问你问题呢?
磁盘使用量似乎很高,要删除你的家目录吗?[y/N]
用于Zsh行编辑器的缓冲输入可能会被磁盘清理程序读取。如果你正在输入的第一个命令以“y”开头,那你可能就要跟文件说再见了。
为了解决这些问题,在zsh初始化期间,powerlevel10k将标准输入重定向到/dev/null,并将标准输出和错误输出重定向到一个临时文件。一旦zsh完全初始化,标准文件描述符将恢复,并打印出临时文件的内容。这些内容会在第一个提示符上方显示,这比让输出与命令行交错好得多,但仍不够理想。
为了得到最佳效果,使用即时提示(Instant Prompt)时,.zshrc应按以下结构编写:
-
首部分应包含以下命令:
- 从标准输入或TTY读取的命令
- 向标准输出、标准错误或TTY写入的命令
- 偶尔但很少会执行时间不可预测的命令
# 如果不在tmux中,启动tmux:读写TTY。 if [[ -z ${TMUX+X}${ZSH_SCRIPT+X}${ZSH_EXECUTION_STRING+X} ]]; then exec tmux fi # 克隆不存在的git仓库:打印并可能耗时较长。 if [[ ! -e ~/zsh-autosuggestions ]]; then print -r -- '安装zsh-autosuggestions ...' git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions.git ~/zsh-autosuggestions fi # 打印。 print -Pr -- '你好,%n。今天是 %D{%A}。' # ... -
激活即时提示。必须用这个确切的命令:
# 输出第一个提示符并重定向文件描述符。 if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" fi -
最后一部分是主要的初始化工作。这部分命令不应:
- 从标准输入或TTY读取
- 向标准输出、标准错误或TTY写入
- 耗时较长
autoload -Uz compinit && compinit source ~/zsh-autosuggestions/zsh-autosuggestions.zsh # ...
首部分的命令应该快速,避免延迟首次提示符。
最后一部分的命令在发生错误时可以输出,假设你会修复这些错误,因此正常操作下不会有输出。
如果在最后一部分需要使用TTY,你可以使用$TTY。确保不从中读取任何内容,只写入不会在屏幕上显示的“隐形”内容。这是允许的:
# 让gpg知道我们的TTY是什么。
export GPG_TTY=$TTY
# 改变光标形状为“竖线”。
print -n '\e[5 q' >$TTY
# 在终端标题中显示当前工作目录。
printf '\e]0;%s\a' ${(V)${(%):-%1~}} >$TTY
插件管理器
diy++ 是一个稳固的基础,为我们提供了对初始化过程的全面控制。通过使用插件管理器,我们可以为了便利性放弃一些这种控制。我已经对几个插件管理器和框架进行了基准测试。所有配置都具备核心功能。
| 配置 | tmux | compsys | 语法高亮 | 自动建议 | Git 提示 | 首次提示延迟 | 首个命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| diy++ | √ | √ | √ | √ | √ | 10% 绿 |
42% 绿 |
24% 绿 |
64% 黄 |
| diy++unsafe | √ | √ | √ | √ | √ | 9% 绿 |
37% 绿 |
24% 绿 |
63% 黄 |
| zcomet | √ | √ | √ | √ | √ | 10% 绿 |
44% 绿 |
25% 绿 |
64% 黄 |
| zinit | √ | √ | √ | √ | √ | 10% 绿 |
78% 黄 |
24% 绿 |
64% 黄 |
| zplug | √ | √ | √ | √ | √ | 108% 橙 |
100% 黄 |
24% 绿 |
64% 黄 |
| ohmyzsh+ | √ | √ | √ | √ | √ | 10% 绿 |
56% 黄 |
29% 绿 |
64% 黄 |
| prezto+ | √ | √ | √ | √ | √ | 10% 绿 |
47% 绿 |
34% 绿 |
68% 黄 |
| zim+ | √ | √ | √ | √ | √ | 10% 绿 |
38% 绿 |
24% 绿 |
64% 黄 |
| zsh4humans | √ | √ | √ | √ | √ | 19% 绿 |
36% 绿 |
27% 绿 |
25% 绿 |
diy++ 和 diy++unsafe 这里列出作为比较延迟的基础。
接下来的三个配置使用了“纯”插件管理器:zcomet,zinit 和 zplug。它们允许你安装和加载插件,但不自行配置zsh。
ohmyzsh+,prezto+ 和 zim+ 基于相应的标准配置。我只启用了实现所有功能所需的插件,禁用了其余的。
zsh4humans 可以安装和加载任意插件,但默认配置已经启用了我们关心的所有内容。所以这里基准测试的是未修改的股票配置。
列表中的大多数配置将compinit、zsh-syntax-highlighting、zsh-autosuggestions 和 powerlevel10k 视为黑盒。在基准测试上,它们无法超过 diy++unsafe。
zplug,ohmyzsh 和 prezto 为了性能牺牲了易用性,通过 -C 参数调用 compinit。我个人认为这不是一个好的选择。微小的速度提升并不值得。zsh4humans 和 zim 则走了相反的路 —— 它们为了提高补全系统的可用性执行更多的检查。例如,如果你在已有#compdef指令的文件中添加一个完成函数,除 zsh4humans 和 zim 外的其他配置只有手动删除 .zcompdump 文件才会检测到这一变化。重启zsh甚至重启计算机都无法解决这个问题。尽管增加了生活质量改进,zsh4humans 仍然设法实现了比其他所有配置更低的“首个命令延迟”。这表明,为了达到低延迟目标,没有必要妥协。
由于powerlevel10k,所有配置的“首次提示延迟”都很低,除了 zplug。zplug 提供了一个友好的API,与即时提示很好地配合。它有一个安装插件的函数和另一个加载插件的函数。插件安装可能会打印状态消息和进行网络I/O,因此应在输出第一个提示符之前执行。不幸的是,zplug 中这个函数速度较慢,导致“首次提示延迟”较高。zcomet 和 zinit 因没有提供此类API而躲过了这个问题,因此这些配置是在走捷径。但这并不是说 zcomet 和 zinit 在这方面表现得鲁莽,而是它们的局限性不允许我干净地使用“即时提示”。使用这些插件管理器时,你要么放弃“即时提示”,接受“首次提示延迟”超过阈值,要么走捷径,得到较差的用户体验。
zsh4humans 的“首个命令延迟”和“输入延迟”低于其他所有配置。它通过实现核心shell特性之间的紧密集成来实现这一点:提示符、语法高亮和自动建议。你可以在zsh4humans中启用额外的插件,但核心部分作为一个单一单元存在。
zsh4humans 的“首次提示延迟”比 diy++ 高9%(绝对值4.7毫秒)。很多功能被压缩到了这段时间内,但此处不适合详细介绍。结果得到的“首次提示延迟”仍只是感知阈值的20%,所以我很有信心这种延迟不会被察觉。重要的是,当用户向他们的zsh启动文件添加额外初始化代码时,“首次提示延迟”不会增加,只会增加“首个命令延迟”,而zsh4humans 的这个值较低。总的来说,我对zsh4humans 的表现非常满意。
如果你不在乎tmux,可以从表中每一行的心理上减去其延迟。鉴于 zplug 在两个指标上的值略高于100%,扣除 tmux 就会让所有延迟进入绿色或黄色区域。一切都相当快!理解不同功能的差异对于明智的选择至关重要。不过,这份文档只关注性能,所以我不会深入讨论。
延迟初始化
可以推迟Zsh的部分初始化工作,直到Zsh没有其他事情要做时再执行。这可以通过zinit的涡轮模式或zsh-defer实现。后者是我的项目。
| 配置 | tmux | compsys | 语法高亮 | 自动建议 | Git提示 | 第一次提示延迟 | 第一条命令延迟 | 命令延迟 | 输入延迟 |
|---|---|---|---|---|---|---|---|---|---|
| zinit涡轮 | ✔️ | ✔️ | ❌ | ❌ | ✔️ | 9%\n🟢 | 39%\n🟢 | 24%\n🟢 | 62%\n🟡 |
| zsh-defer | ✔️ | ✔️ | ❌ | ❌ | ✔️ | 10%\n🟢 | 22%\n🟢 | 28%\n🟢 | 65%\n🟡 |
在这些配置中,语法高亮和自动建议的初始化被推迟了。当推迟某些功能的初始化时,要准备好一段时间内不使用这些功能。基准测试结果显示交互式shell的第一个命令没有语法高亮或自动建议。这是合理的,因为Zsh正忙于处理Zsh行编辑器中的第一个命令,在命令开始执行前还没空闲下来。
大部分功能的初始化不宜推迟。任何修改环境变量、定义命令或改变部件行为的功能都不应推迟。
我知道唯一可以安全推迟初始化的功能是语法高亮。自动建议必须在语法高亮之后初始化,所以要么两者都推迟,要么都不推迟。不幸的是,推迟自动建议的初始化是不安全的,因为它会改变某些键的行为,所以如果推迟语法高亮,就无法使用自动建议。
延迟初始化是在Zsh行编辑器(zle)的上下文中运行的。有些插件不期望在zle中加载,可能会初始化失败。从zle加载这样的插件需要依赖插件实现细节的解决方法,这会使用户在更新插件时面临更高的破坏风险。
延迟初始化仅能减少“第一条命令延迟”。如果正确实施,对其他延迟无影响。考虑到有许多低于感知阈值的“第一条命令延迟”配置可选择,延迟初始化并没有解决实际问题,反而带来了新的问题。
综上所述,我不推荐使用延迟初始化。
如何避免错误的基准测试
如果你在网上搜索如何基准测试Zsh启动速度,你会找到以下命令或其变体:
time zsh -lic "exit"
为了完整性,zsh-bench也测量这个指标,它在原始输出中显示为exit_time_ms。让我们看看一些与Zsh启动速度相关的原始基准测试结果。
| 配置 | 第一次提示延迟(ms) | 第一条命令延迟(ms) | 退出时间(ms) |
|---|---|---|---|
| agnostic | 32 | 33 | 2 |
| powerlevel10k | 2 | 21 | 6 |
在使用agnostic时,exit只用2毫秒完成。然而当你打开终端时,你会看到一个空白屏幕长达32毫秒。在2毫秒标记处发生了什么算作“启动”?
对比一下powerlevel10k。这个配置下exit比agnostic慢,但Zsh启动更快:当你打开终端时,提示符几乎立即出现,第一条命令执行得更快。
许多Zsh插件管理器已经优化了快速exit并将其作为有意义的性能指标展示。普遍认为zinit是最快的插件管理器,这一观点基于exit的时间。由zinit涡轮模式开创的延迟初始化在实践中可能不太有用,但在这个指标上极其有效。不出所料,zinit在这方面已经被优化。
但这并不意味着开发者有意欺骗。很容易不知不觉落入陷阱。exit的时间在早期简单配置的Zsh启动时间和“第一次提示延迟”、“第一条命令延迟”非常接近。它“过去”是衡量Zsh启动性能的适当指标。随着这些延迟逐渐分化,基准失去意义,但旧习惯保留了下来。
zsh4humans在exit上的计时是6毫秒。如果我能声称zsh4humans初始化这么快,我会很高兴,但实际上对于有意义的初始化定义来说,这个说法并不成立。
time zsh -lic "exit"的输出告诉你执行zsh -lic "exit"所需的时间,仅此而已。如果你不习惯运行zsh -lic "exit",那么这个数字对你来说没有任何实际意义。
自从发布zsh-bench以来,有几个项目已从针对exit转向有意义的性能指标,我知道的有zcomet、zim和antidote。希望这种趋势能继续下去。
完整基准数据
- 快速桌面电脑(所有嵌入到文档中的基准结果来自这个运行):
- MacBook Air(M1,2020):
- Raspberry Pi 4 Model B:
结论
- powerlevel10k是降低启动和每条命令延迟的有效工具。
- diy++是构建自引导Zsh配置的一个高性能、相对较简单的基础,如果你想从零开始搭建。
- 除非牺牲,否则插件管理器不能在性能上超越d iy++。一个快速的插件管理器是那种不会显著减慢速度的管理器。插件管理器提供的价值在于便利性,而非速度。
- 正确配置后,所有插件管理器和框架都有良好的性能。这包括[ohmyzsh](https://github.com/romkatv/zsh-bench/blob/master/configs/ohmyzsh%2B/skel/.zshrc),尽管普遍认为它很慢。
- 测试过的“纯粹”插件管理器中,zplug具有最佳API,但它也是最慢的。减速很小,大多数用户不会注意到。
- 并非所有插件管理器都能干净地使用即时提示——这是提高启动速度的最接近银弹的功能。
- zsh4humans具有与其他同类功能相比更快的速度。得益于核心功能的紧密集成,它在基准测试中甚至胜过d iy++,这些功能无法用第三方插件替代。
- 使用zinit涡轮模式或zsh-defer进行Zsh初始化延迟是不值得的。
time zsh -lic "exit"的输出并不能告诉你关于交互式Zsh的性能信息。
回应
本部分列出了被分析项目开发者们的公开回应。
zcomet
从 README.md:
同样在 reddit 上提到:
我听取了你的建议:
zcomet现在不再编译rc文件,并且zcomet compinit默认不再是使用compinit -C。接下来,我将调查与你的即时提示兼容性的问题。
zim
这个总结指出:
感谢@romkatv的反馈和他在 zsh-bench中的见解,我们已在 Zim中进行了以下改进:
- 我们调整了基准测试,以测量首次提示出现的时间(而非执行
exit的时间)。我们采用了不同的工具(expect)而非zsh-bench使用的script来计时,@romkatv帮助我们确保不同方法间的时间记录一致。- 我们不再编译Zsh启动脚本,也不再后台编译任何脚本。前者被视为zsh-bench中的“走捷径”,而后者被认为是不可靠的。事实上,现在Zim在你的shell体验过程中不会在后台运行任何东西。
- 我们在完成模块中采用了与zsh4humans相似的改进后的完成转储文件检查逻辑,因为单独使用
compinit -C也被视为zsh-bench中的“走捷径”。- 我们还更新了模板,设置
ZSH_AUTOSUGGEST_MANUAL_REBIND=1并将zsh-users/zsh-autosuggestions模块置于最后加载。
调试与验证
zsh-bench包含了几种工具,以帮助调试和验证基准测试结果。
执行一次登录shell基准测试的迭代并保留临时测试数据:
~/zsh-bench/zsh-bench --iters 1 --scratch-dir /tmp/zsh-bench
回放基准测试期间zsh-bench所作用的TTY屏幕:
~/zsh-bench/dbg/replay --scratch-dir /tmp/zsh-bench
以10%的速度回放,并在TTY更新之间最多延迟1秒:
~/zsh-bench/dbg/replay --scratch-dir /tmp/zsh-bench --delay-multiplier 10 --max-delay-ms 1000
运行zsh-bench后请勿调整终端大小,以确保正确回放。
首先,您会看到zsh-bench发出一条简短命令,该命令会加载一个脚本。此命令立即发送到TTY,而不等待首次提示。被加载的文件会打印输出,其结果标志着第一条命令的执行。
接着,zsh-bench开始测量输入延迟,它打印出由许多转义后的abc令牌组成的相当长的命令,输入一个额外的字符并等待这字符被ZLE处理。这一过程重复几次,然后清除命令行而不执行。
随后,zsh-bench通过猛击Enter键来衡量命令滞后,看能接收到多少个提示。
打印一个带有时间戳的制表符分隔的TTY原始写入表格:
~/zsh-bench/dbg/timeline --scratch-dir /tmp/zsh-bench
查看特定时间戳发生的情况:
~/zsh-bench/dbg/replay --scratch-dir /tmp/zsh-bench --pause-at-ms 10.149
最后一个参数是毫秒级的时间戳。回放会在该时间点前后暂停。如果您传递由zsh-bench报告的first_prompt_lag_ms或first_command_lag_ms值作为时间戳,您将分别看到zsh-bench认为的“首次提示”及其后输出的“首个命令”的情况。如果zsh-bench工作正常,在first_prompt_lag_ms之前不应有提示出现,之后则应显示;类似地,在first_command_lag_ms之前不应有ZB*-msg出现,之后则应有,其中*代表随机数字。只有当首次提示显示了git分支时,has_git_prompt特性才应被设置。
许可证
MIT。
常见问题解答
为什么基准测试我的shell很重要?这是爱好者的事吗?
基准测试shell本身并不重要。对我来说,重要的是对发布的zsh插件和配置进行基准测试,以便我的代码使用者能够拥有快速的shell环境。无论用户是否为爱好者,所有人都偏好快速的软件。
壳牌用户或其他任何人,都倾向于选择速度更快的工具。对我而言,确保我分享的zsh插件和配置能让用户的交互体验加速至关重要。