30-性能优化Performance
30 · 性能优化 Performance(Measure, then Optimize)
本节目标(Goals):给编辑器做一版性能体检与升级——搜索改 KMP、语法高亮丢正则换手写扫描、渲染不再整库拷贝。原则:先测后改,改到瓶颈为止。
前置知识(Prerequisites):教程 22(search.cpp)、21(syntax_highlight.cpp)、16(Screen)、08(GapBuffer)。
1. 优化第一定律:Profile 是一切的地基
不要猜"哪里慢"。给搜索、高亮、渲染各包一层计时:
// infra 加一个极简 stopwatch(或直接用 <chrono>)
template <typename F>
double ms_of(F&& f, int repeats = 100) {
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < repeats; ++i) f();
const auto t1 = std::chrono::steady_clock::now();
return std::chrono::duration<double, std::milli>(t1 - t0).count() / repeats;
}
// 用法
std::printf("search x1000: %.3f ms\n", ms_of([&]{ search_forward(buf, "foo", {0,0}); }, 1000));
结论路径(在 1MB 文本上实测):search 用朴素逐字节扫描 → O(n·m) 秒级;std::regex 高亮 → 每行上千微秒;render 里 to_string() 整库拷贝 → 帧率灾难。这三处就是我们必须动刀的地方。
2. 搜索:朴素扫描 → KMP
KMP(Knuth-Morris-Pratt)把"模式串"冗余比较消除在预处理里,整体 O(n+m),且不需要回退:
// search.cpp 内部新增
namespace {
// 前缀函数(对 pat 预计算,区分大小写开关透传)
std::vector<int> kmp_pi(std::string_view pat, bool ignore_case) {
std::vector<int> pi(pat.size(), 0);
for (size_t i = 1; i < pat.size(); ++i) {
int j = pi[i - 1];
char p = pat[i], pc = pat[(size_t)j];
if (ignore_case) { if (p>='A'&&p<='Z') p+=32; if (pc>='A'&&pc<='Z') pc+=32; }
while (j > 0 && p != pc) { j = pi[(size_t)(j - 1)]; pc = pat[(size_t)j];
if (ignore_case && pc>='A'&&pc<='Z') pc+=32; }
if (p == pc) ++j;
pi[i] = j;
}
return pi;
}
// 在 all[off..] 中找 pat 首次出现,返回全局下标或 -1
long kmp_find(const std::string& all, std::size_t off, std::string_view pat, bool ignore_case) {
if (pat.empty() || off >= all.size()) return -1;
const auto pi = kmp_pi(pat, ignore_case);
long j = 0;
for (std::size_t i = off; i < all.size(); ++i) {
char c = all[i];
if (ignore_case && c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
while (j > 0 && c != (pat[(size_t)j])) {
if (ignore_case) { char pc = pat[(size_t)j]; if (pc>='A'&&pc<='Z') pc+=32; if (c==pc) break; }
j = pi[(size_t)(j - 1)];
}
if (c == pat[(size_t)j] || (ignore_case &&
c == (pat[(size_t)j]>='A'&&pat[(size_t)j]<='Z'? pat[(size_t)j]+32 : pat[(size_t)j])))
++j;
if (j == (long)pat.size()) return (long)i - (long)pat.size() + 1;
}
return -1;
}
} // namespace
search_forward 的循环体改成调用 kmp_find(返回值 <0 回卷重扫):
std::optional<SearchHit> search_forward(const Buffer& buf, std::string_view query,
Position from, bool wrap, bool ignore_case) {
if (query.empty()) return std::nullopt;
const std::string all = buf.to_string();
std::size_t off = buf.byte_offset(buf.clamp(from)) + 1;
long hit = kmp_find(all, off, query, ignore_case);
if (hit < 0 && wrap) hit = kmp_find(all, 0, query, ignore_case);
if (hit < 0) return std::nullopt;
return SearchHit{buf.position_of((std::size_t)hit), (int)query.size()};
}
KMP 是"教科书级但照抄就懂"的算法;关键收益在大文件搜索上从 O(n·m) 到 O(n+m)。小文件差异无感——优化永远服务真实瓶颈。
3. 语法高亮:正则 → 手工扫描 + 行缓存
std::regex 是出了名的慢。对 C 系这种"词法简单"文件,用一次性扫描器更快,还能顺带处理 ////* *//字符串跨行:
// HighlightScanner:一次遍历一行的极简词法器(不再用 regex)
std::vector<HighlightSpan> scan_c_line(std::string_view line, HighlightState& st) {
using S = HighlightStyle;
std::vector<HighlightSpan> out;
enum St { Normal, InStr, InChr, InComment } mode = st.mode;
int i = 0;
std::vector<uint8_t> in; // 占位勿用:下面的状态机才是本体
...
return out;
}
缓存行高亮,逐行失效(收益最大的一个点):
// syntax_highlight.hpp 新增:行号 → 高亮结果 的 LRU 缓存
struct LineCache { int dirty_epoch; std::vector<HighlightSpan> spans; };
std::vector<LineCache> line_cache_; // 下标=行号
int edit_epoch_ = 0; // Buffer 每次改动 +1
// refresh() 时机:Buffer 编辑后、或光标落入新行
std::vector<HighlightSpan> highlight_line_cached(const Buffer& buf, int row) {
if (row < (int)line_cache_.size() && line_cache_[row].dirty_epoch == edit_epoch_)
return line_cache_[row].spans; // 未变,直接命中
auto spans = highlight_line(buf.line_text(row));
if (row >= (int)line_cache_.size()) line_cache_.resize(row + 1);
line_cache_[row] = { edit_epoch_, std::move(spans) };
return line_cache_[row].spans;
}
渲染期间"逐行重算高亮" → 只有被编辑的行重算。"脏行逐行失效"这一招在巨大文件上能把高亮开销压到忽略不计。
3.5 GapBuffer 扩容:让编辑器"越写越卡、最后崩掉"的真凶(真实事故)
性能问题的原型不止于"道理上慢",还有一类臭名昭著却极难发现的:看似正确的分支条件。Vicarus 曾在 GapBuffer 的扩容判断里栽过一次,症状和用户报告一字不差——"编辑打字越来越卡,最后整个程序卡死退出"。根因顺着源码长这样:
// 错误版本(曾在 src/core/gap_buffer.cpp 中)
void GapBuffer::ensure_capacity(std::size_t extra) {
const std::size_t used = size();
const std::size_t need = used + extra + gap_size_; // ⚠️ 画蛇添足
std::size_t new_cap = data_.size() * 2; // 无条件翻倍
while (new_cap < need) new_cap *= 2;
...
}
问题在代数上:need = used + extra + gap_size_,而 capacity = used + gap_size_(内容 + 间隙=真容量),所以:
need = (used + gap_size_) + extra = capacity + extra → 永远满足 new_cap < need
于是每一次插入都触发扩容翻倍 + 全量重排,间隙还越撑越大。假设一开始容量 32,
每敲一个字符容量就 ×2:32→64→128→… 敲到第 24 个字符容量已是 2^27≈134MB,编辑器随之
整体搬运 8 万余字节×每次按键。内存无限膨胀 → "越写越卡 → OOM 退出"。
修法一句话:间隙本身就是空闲区。
// 正确版本(当前源码)
void GapBuffer::ensure_capacity(std::size_t extra) {
if (extra <= gap_size_) return; // 间隙够放,什么都不做
const std::size_t need = size() + extra;
std::size_t new_cap = data_.size() ? data_.size() * 2 : kInitialGap;
while (new_cap < need) new_cap *= 2;
... // 扩容后:内容连续占 [0, used),间隙从 used 起
}
顺带一记:扩容后的布局重建同样容易被写错。旧实现把
gap_start_设成了pre
(间隙左文本长度),导致右文本被包进间隙区域、内容静默丢失——正确的做法是
gap_start_ = used(内容连续占满头部,间隙整体挪到尾部)。这类 bug 在"小文件、
单行文本"下完全无症状,只有换行/跨间隙删除时才会现形——单测里如果不刻意打散
左/右文本布局,就永远测不出来。教程 32 的随机差分测试(每次编辑前随机移动光标,
反复制造"间隙在左、间隙在右、间隙居中"三种布局)正是为此设计的。
4. 渲染:别整库拷贝
Screen::set_text 逐行写、line_text(row) 只拷一行,而 render 绝不调用 buffer_.to_string():
// 错误示范(已见过多次):
// const std::string all = buffer_.to_string(); // O(全文) 拷贝 ×每帧!
// 正确:按可视行取,只读需要的那几行
for (int screen_row = 0; screen_row < win.height(); ++screen_row) {
const int text_row = win.viewport.top_row + screen_row;
if (text_row >= buf.line_count()) break;
const std::string_view line = buf.line_text(static_cast<size_t>(text_row));
draw_line(screen_row, line); // line_text 只借 string_view(GapBuffer 安全:无改动期合法)
}
配合 LineCache:改动使 B 标记的"重绘区"最小化,notify_edit() 时只标记受影响的几行,而非 screen_.clear() 全刷。
line_text(row) 的背后(教程 08 的两个配套改动,缺一不可):
GapBuffer::substr:只拷贝[行起始, 行结束)那一小段,代替"整库content()后切片";- 增量行索引:
insert/erase就地平移受影响行的起始(adjust_line_index_insert/erase),
编辑结束后无需全文扫描也能回答"我落在第几行"。配合渲染,光标在黑压压 5 万行文件里
逐键输入时,每一帧都只付出"可视行数/受影响行数"的代价,而不是 O(全文)。
5. 一张"优化收益表"
| 位置 | 手段 | 复杂度 | 收益 |
|---|---|---|---|
| search | 朴素 → KMP | ★★☆ | 大文件搜索 O(n·m)→O(n+m) |
| 高亮 | regex → 手写扫描 | ★★★ | 每行数百 μs→数十 μs |
| 高亮 | 行缓存+脏行失效 | ★☆☆ | 渲染期只算改动行 |
| 渲染 | to_string → line_text |
★☆☆ | 消除每帧整库拷贝 |
| 渲染 | line_text 由 GapBuffer::substr 实现 |
★☆☆ | 逐行只拷一行 |
| 行索引 | 惰性重建 → 增量维护 | ★★☆ | 编辑后不再全文重扫 |
| GapBuffer | 修正扩容判断 + 布局重建 | ★★★ | 修复"越写越卡→OOM" 的真崩溃 |
| 撤销 | 插入会话批量合并(教程 13) | ★☆☆ | 几百字输入只存一整段快照,撤销栈不爆炸 |
| 事件 | 高频 CursorMoved 节流(合并重绘) |
★☆ | 减少重复 render |
| I/O | 读文件一次性缓冲、新行懒解析 | ★★ | 打开大文件不卡死 |
每一条背后都有一个可测的基准。不要在没实测前上手三条——一次只改一处,编基准再比对。
6. 加个基准的验收方式
在根目录建一个 bench/bench_search.cpp,把 timing 输出成可读表格,提交进 CI(教程 34 会在 CI 里跑它防止回归)。输入:1MB 文本、模式 "needle",打印 KMP 对比朴素版本的时间。
朴素搜索 1MB x100 : 830.21 ms
KMP 搜索 1MB x100 : 12.45 ms
从"秒级"到"毫秒级",就是这章的意义。
本节小结(Summary)
- Profile 先行:
ms_of基准函数是三张优化手段的共同裁判; - KMP:搜索从 O(n·m) 提到 O(n+m),一次预处理、匹配不回退;
- 高亮:正则扫描器退役为手写状态机 + 行缓存/脏行失效双保险;
- 渲染:
line_text逐行视图取代to_string整库拷贝,底层由GapBuffer::substr+ 增量行索引撑住; - 扩容判断是性能 bug 的重灾区:间隙判断"够不够"只该比
extra ≤ gap_size_,多写一项就全串味(见 3.5),且必须用跨间隙的用例钉住; - 一次改一处 + CI 基准,保证性能是"可观测的持续改善"而非玄学。
动手练习(Exercise)
- 把
search_backward也用 KMP 重写(倒序匹配版本),保证双向速度对等; - 给
scan_c_line补上真正的状态机(跨行字符串/块注释),删掉正文里那段含in占位的残缺骨架; - 给事件总线加"合并重绘":任意 16ms 内多次
CursorMoved只触发一次 render; - 挑战:用
perf record/valgrind --tool=callgrind对打开 10MB 文件做一次热力图,指出你下一个要击破的瓶颈。 - 重现 3.5 的事故再修复:把
ensure_capacity临时改回"无条件if (extra <= gap_size_) return;被删掉"的版本,用 1MB 文本连续插入 1000 个字符,观察内存与耗时呈等比爆炸(可用/usr/bin/time -v);再改回正解,确认回到常量级。
下一节
31-跨平台适配CrossPlatform.md:让 Vicarus 不只在 Linux 上活得好。

浙公网安备 33010602011771号