Neovimの文字入力とカーソル移動を計測したら、改善点は別にあった

Neovimの設定を見直し、起動時間を短くしました。DAPは必要な時まで読み込まず、Windows IMEの切り替えも非同期にしています。

そこで次に気になったのが、普段の文字入力やカーソル移動です。設定を軽くしたのだから、入力も移動も速くなったと言えるのでしょうか。体感だけで判断せず、Neovimがキーを処理する時間を測りました。

さんざん調べて測ってみると、今回測った4操作の中央値は全部マイクロ秒単位でした。速っ。Neovim側がもっさりしていると思って始めたのに、この小さなテストでは大きな待ち時間は見つかりませんでした。

今回の再測定は、起動時間を短くする前後の比較ではありません。文字入力が実際に成功したことを検査しながら、現在の設定だけを5回測りました。設定なしのNeovimとの比較は、条件をそろえた原出力を保存するまで保留にします。起動時間と、ESCを押した時にIME処理の完了を待たなくなった点は、別の記事で扱っています。

まず「入力速度」と「入力処理時間」を分ける

キーボードで1秒間に何文字入力できるかは、キーボード、指の動き、キー配列、入力する文章で決まります。これは今回測っていません。

今回測ったのは、Neovimへキーを渡した後に、バッファやカーソルを更新する処理時間です。端末へ画面を描画する時間、ターミナルエミュレーターの遅延、キーボードからOSへ届くまでの時間も含めていません。

この区別をしないと、「1文字の処理が速い」ことを「タイピングが速くなった」と誤って書いてしまいます。記事で扱うのは、あくまでエディター内部の応答コストです。

計測方法

2026年9月16日にNeovim 0.12.1をWSL2上で--headless起動し、現在の設定を読み込んでから再測定しました。Neovim公式APIのnvim_feedkeys()でキー操作を送り、vim.uv.hrtime()でナノ秒単位の時計を読みます。

1つの操作につき、最初の100回をウォームアップにして、その後2,000回を計測しました。同じテスト用バッファの内容を操作ごとに入れ替え、次の4種類を測っています。

操作 計測内容
x ixEscで1文字を入力してNormalモードへ戻る1サイクル
j 行を1行下へ移動
w 次の単語へ移動
n 検索済みバッファで次の一致へ移動

同じ設定を5プロセスで繰り返し、各プロセス内の2,000回の中央値をさらに中央値でまとめました。p95も各プロセスの値を中央値にしています。入力後の文字数、行移動後の行番号、単語移動後の列、検索後の行番号が想定した位置と一致しなければ、計測を失敗として止めます。nだけはnvim_feedkeys()ではなく、同じ動きをするnormal! nで実行しています。

計測スクリプトは長いため、必要な時だけ開けるようにしています。

計測に使ったLuaスクリプトを表示する
-- Neovimの設定を読み込んだ後のキー処理時間を測る。
-- タイピング速度や、キー入力から画面描画までの時間は測らない。

local N = 2000
local warmup = 100
local uv = vim.uv or vim.loop

local function now()
  return uv.hrtime()
end

local function percentile(values, p)
  local sorted = vim.deepcopy(values)
  table.sort(sorted)
  local index = math.max(1, math.ceil(#sorted * p))
  return sorted[index]
end

local function summarize(values)
  local total = 0
  local max_value = 0
  for _, value in ipairs(values) do
    total = total + value
    max_value = math.max(max_value, value)
  end
  return {
    count = #values,
    median_us = percentile(values, 0.50) / 1000,
    p95_us = percentile(values, 0.95) / 1000,
    p99_us = percentile(values, 0.99) / 1000,
    max_us = max_value / 1000,
    total_ms = total / 1000000,
  }
end

local function feed(keys)
  vim.api.nvim_feedkeys(keys, "xt", false)
  if vim.env.NVIM_BENCH_REDRAW == "1" then
    vim.cmd("redraw")
  end
end

local function assert_state(condition, message)
  if not condition then
    error("BENCH_ASSERTION_FAILED: " .. message)
  end
end

local function set_lines(lines)
  vim.cmd("stopinsert")
  vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
  vim.api.nvim_win_set_cursor(0, { 1, 0 })
end

local function benchmark_char_input()
  set_lines({ "" })
  local input_cycle = vim.keycode("ix<Esc>")
  for _ = 1, warmup do
    feed(input_cycle)
  end

  local values = {}
  for i = 1, N do
    local start = now()
    feed(input_cycle)
    values[i] = now() - start
  end
  local length = vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]:len()
  assert_state(length == warmup + N, "char_input length=" .. length)
  return summarize(values), "chars=" .. length
end

local function benchmark_line_motion()
  local lines = {}
  for i = 1, N + warmup + 20 do
    lines[i] = string.format("line %05d: 0123456789", i)
  end
  set_lines(lines)
  feed("gg")
  for _ = 1, warmup do
    feed("j")
  end

  local values = {}
  for i = 1, N do
    local start = now()
    feed("j")
    values[i] = now() - start
  end
  local line = vim.api.nvim_win_get_cursor(0)[1]
  assert_state(line == warmup + N + 1, "line_j line=" .. line)
  return summarize(values), "line=" .. line
end

local function benchmark_word_motion()
  local words = {}
  for i = 1, N + warmup + 20 do
    words[i] = string.format("word%05d", i)
  end
  set_lines({ table.concat(words, " ") })
  feed("0")
  for _ = 1, warmup do
    feed("w")
  end

  local values = {}
  for i = 1, N do
    local start = now()
    feed("w")
    values[i] = now() - start
  end
  local column = vim.api.nvim_win_get_cursor(0)[2]
  local expected_column = (warmup + N) * #("word00001 ")
  assert_state(column == expected_column, "word_w column=" .. column)
  return summarize(values), "column=" .. column
end

local function benchmark_search_motion()
  local lines = {}
  for i = 1, N + warmup + 20 do
    lines[i] = string.format("needle %05d", i)
  end
  set_lines(lines)
  vim.cmd("silent! keepjumps 1,1/needle/")
  for _ = 1, warmup do
    vim.cmd("silent! normal! n")
    if vim.env.NVIM_BENCH_REDRAW == "1" then
      vim.cmd("redraw")
    end
  end

  local values = {}
  for i = 1, N do
    local start = now()
    vim.cmd("silent! normal! n")
    if vim.env.NVIM_BENCH_REDRAW == "1" then
      vim.cmd("redraw")
    end
    values[i] = now() - start
  end
  local line = vim.api.nvim_win_get_cursor(0)[1]
  local expected_line = warmup + N + 2
  assert_state(line == expected_line, "search_n line=" .. line)
  return summarize(values), "line=" .. line
end

local function print_result(name, result, state)
  print(string.format(
    "BENCH %s count=%d median_us=%.3f p95_us=%.3f p99_us=%.3f max_us=%.3f total_ms=%.3f state=%s",
    name,
    result.count,
    result.median_us,
    result.p95_us,
    result.p99_us,
    result.max_us,
    result.total_ms,
    tostring(state)
  ))
end

vim.cmd("enew")
vim.bo.buftype = "nofile"
vim.bo.swapfile = false
vim.bo.filetype = "lua"

-- 起動イベントとlazy.nvimの予約処理が落ち着くまで待つ。
vim.wait(250, function()
  return false
end, 10)

if vim.env.NVIM_BENCH_IGNORE_EVENTS == "1" then
  vim.opt.eventignore = "all"
end

if vim.env.NVIM_BENCH_NO_VISUALS == "1" then
  vim.opt.cursorline = false
  vim.opt.relativenumber = false
  vim.opt.foldmethod = "manual"
  vim.opt.foldenable = false
end

if vim.env.NVIM_BENCH_CURSORLINE_NUMBER == "1" then
  vim.opt.cursorline = true
  vim.opt.cursorlineopt = "number"
end

if vim.env.NVIM_BENCH_NO_CURSORLINE == "1" then
  vim.opt.cursorline = false
end

if vim.env.NVIM_BENCH_NO_RELATIVE == "1" then
  vim.opt.relativenumber = false
end

if vim.env.NVIM_BENCH_MANUAL_FOLD == "1" then
  vim.opt.foldmethod = "manual"
  vim.opt.foldenable = false
end

local char_result, char_state = benchmark_char_input()
local line_result, line_state = benchmark_line_motion()
local word_result, word_state = benchmark_word_motion()
local search_result, search_state = benchmark_search_motion()

print_result("char_input", char_result, char_state)
print_result("line_j", line_result, line_state)
print_result("word_w", word_result, word_state)
print_result("search_n", search_result, search_state)
print(string.format("BENCH nvim=%s", vim.version().major .. "." .. vim.version().minor .. "." .. vim.version().patch))

vim.cmd("qa!")

スクリプトをbenchmark_nvim_input.luaとして保存し、普段の設定を読み込んだ状態で次のように実行しました。

nvim --headless -i NONE "+lua dofile('benchmark_nvim_input.lua')"

現在の設定での結果

現在のLazyVim設定で、キー処理の中央値は次の結果になりました。単位はマイクロ秒です。1,000マイクロ秒が1ミリ秒で、4操作とも中央値は1ミリ秒未満でした。入力と行移動は0.1ミリ秒を超えています。

操作 中央値 p95 2,000回の処理時間
1文字を入力して戻る ixEsc 142.7µs 469.5µs 約789〜1,155ms
行移動 j 108.2µs 243.2µs 約215〜411ms
単語移動 w 81.3µs 197.4µs 約167〜539ms
検索移動 n 62.5µs 110.6µs 約121〜285ms

これは、どのキーでも同じ時間で処理されるという意味ではありません。jwはカーソル位置、行の長さ、設定されたイベントで変わります。最大値はOSの処理などで跳ねやすいため、主な比較値には中央値とp95を使いました。ここに載せた値だけで、端末の描画や普段の編集全体が速いとは言えません。

5回分の集計記録を表示する

各セルは中央値 / p95 / 2,000回の合計時間(µs / µs / ms)です。これは実行ごとの集計であり、2,000回分すべての生ログではありません。

実行 文字入力 x 行移動 j 単語移動 w 検索移動 n
1 142.7 / 438.8 / 916.9 108.2 / 223.9 / 255.4 76.7 / 157.9 / 166.9 54.8 / 91.7 / 121.5
2 205.2 / 622.2 / 1,155.5 106.9 / 243.2 / 250.4 81.3 / 162.7 / 169.4 116.9 / 242.3 / 234.4
3 153.8 / 469.5 / 949.2 111.6 / 281.4 / 297.8 116.5 / 259.8 / 262.3 58.3 / 110.6 / 136.0
4 142.5 / 422.6 / 862.9 101.4 / 199.1 / 215.1 77.4 / 197.4 / 181.0 62.5 / 107.9 / 140.2
5 136.7 / 480.4 / 789.3 189.3 / 410.5 / 410.8 242.2 / 567.2 / 538.7 122.6 / 291.9 / 284.7

設定なしとの比較は保留にする

設定なしのNeovimとの比較値は、今回の原出力を保存した試行とは別日です。同じ条件の比較として再取得するまで、本文の比較表から外します。今回確実に言えるのは、現在の設定で測った4操作の中央値がすべて1ミリ秒未満だったことまでです。

起動時間を短くしたことと、編集中のキー処理が速くなったことは別です。今回の設定変更について、以前の測定方法で取った前後比較は入力成功を検査していなかったため、この記事の根拠から外しました。

まだ測れていないもの

今回の測定は、実際の開発環境を完全に再現していません。特に検索記事で使っている<leader>sGは、SnacksやTelescopeからrgを起動する操作です。今回のnは、すでに検索が終わった後に次の一致へ移動する処理なので、rgの起動時間やファイルシステムの速度は含みません。

標準のmatchparencursorline、相対行番号、インデント折りたたみも確認しました。しかし、--headlessで再描画まで含めると結果がばらつきました。なので設定は変えていません。括弧強調やカーソル行表示は、コードを読む時に役立つため残しています。

次に測るなら、次の条件を別ベンチマークにします。

  • C#、TypeScript、Markdownの実ファイル
  • 数千行から数万行のバッファ
  • TreesitterとLSPを実際に起動した状態
  • 入力中の診断更新、補完、Git差分表示
  • Snacksのファイル検索とrg検索
  • WSL2のLinux側ファイルと、/mnt/c上のファイル
  • Windows 11版Neovimの同じ操作

ここまでを測っても差が小さい場合は、カーソル処理をさらに削るより、jを連打する場面をCtrl-dCtrl-uggG{}、検索、マークへ置き換えるほうが、操作完了までの時間を短くできます。これはNeovim内部の1キーを速くする話ではなく、目的地へ到達するためのキー数を減らす話です。

特に/mnt/c上のリポジトリでは、Neovimのキー処理ではなく、ファイル監視、検索、LSP、外部コマンドの入出力が待ち時間を作る可能性があります。これは推測なので、実ファイルを使った別測定で確認します。

今回、Neovim側は遅くなかった

今回、数字で確認できたことは3つです。

  1. 今回測った4操作の中央値は、マイクロ秒単位だった。
  2. 起動時間の短縮は、編集中の文字入力やカーソル移動が速くなったことを意味しない。
  3. 体感的な引っかかりを調べるには、キー処理、画面描画、LSP、検索、外部コマンドを分けて測る必要がある。

起動時の設定については、LazyVimの起動を見直し、DAPとIME設定を後回しにしたで扱いました。検索の使い分けはNeovimに検索機能を3つ入れたのに、Space sGしか使っていなかったに分けています。

確認した公式情報

確認日: 2026年9月15日

コメント

タイトルとURLをコピーしました