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)

  1. 把 search_backward 也用 KMP 重写(倒序匹配版本),保证双向速度对等;
  2. 给 scan_c_line 补上真正的状态机(跨行字符串/块注释),删掉正文里那段含 in 占位的残缺骨架;
  3. 给事件总线加"合并重绘":任意 16ms 内多次 CursorMoved 只触发一次 render;
  4. 挑战:用 perf record/valgrind --tool=callgrind 对打开 10MB 文件做一次热力图,指出你下一个要击破的瓶颈。
  5. 重现 3.5 的事故再修复:把 ensure_capacity 临时改回"无条件 if (extra <= gap_size_) return; 被删掉"的版本,用 1MB 文本连续插入 1000 个字符,观察内存与耗时呈等比爆炸(可用 /usr/bin/time -v);再改回正解,确认回到常量级。

下一节 31-跨平台适配CrossPlatform.md:让 Vicarus 不只在 Linux 上活得好。

posted @ 2026-09-08 13:51  IcarusLee  阅读(14)  评论(0)    收藏  举报