Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8062161
feat: give cmd-shift-f its own action instead of falling through to find
tkc Oct 28, 2026
2f54a9b
fix: keep shifted symbol keys working on the physical key path
tkc Oct 28, 2026
fb2c3ff
feat: count the lines pushed into history so a row can be dated
tkc Oct 28, 2026
428c235
perf: take the line delta once per advance instead of per character
tkc Oct 28, 2026
35dfeb8
fix: pick the counting rule by the cap, not by whichever count is larger
tkc Oct 28, 2026
9dd51a2
fix: count past the cap in the read that reaches it, and date bursts …
tkc Oct 28, 2026
7db52ea
fix: mark the ticks only on a read that pushed a line
tkc Oct 28, 2026
c1a70db
feat: match a line case-insensitively and keep the hit centered
tkc Oct 28, 2026
cfefdf4
feat: scan every session newest first, under a per-session cap
tkc Oct 28, 2026
da858b6
fix: date every line hit and leave alt-screen hits unnumbered
tkc Oct 28, 2026
ce00013
fix: date a quiet session's rows from its construction time
tkc Oct 28, 2026
6f94d24
feat: run the scan off the UI thread and drop stale generations
tkc Oct 28, 2026
9a09ef8
fix: drop the hits when the word is erased, and test the debounce
tkc Oct 28, 2026
c681312
feat: put the search box in the left pane and list the hits there
tkc Oct 28, 2026
eaa79af
fix: say searching while a scan runs, keep the box inside its bounds,…
tkc Oct 28, 2026
3b251db
fix: keep the tail of a long preedit visible in the search box
tkc Oct 28, 2026
d6aee94
fix: let the preedit use all the room the query left unused
tkc Oct 28, 2026
feb015a
feat: jump to a hit by its serial and fall back to find
tkc Oct 28, 2026
9ca0cb2
fix: leave the viewport alone when a hit's line has fallen out of his…
tkc Oct 28, 2026
00c745e
docs: measure the scan and record the alt-screen hole
tkc Oct 28, 2026
5f9dca5
docs: tighten three claims in the scan notes
tkc Oct 28, 2026
8133abd
fix: mark the tick where the read starts writing
tkc Oct 28, 2026
831971f
fix: publish the pushed total under the term lock
tkc Oct 28, 2026
52fcd3e
fix: hand a case-insensitive pattern from a hit to the screen search
tkc Oct 28, 2026
a32aedf
fix: keep the counting when a read clears the scrollback
tkc Oct 28, 2026
003ce2d
refactor: make the tick lookups total
tkc Oct 28, 2026
52cd6a2
refactor: enter the cross-session search through one door
tkc Oct 28, 2026
1365104
docs: record the wrap hole, rename a test, reuse the scan's needle
tkc Oct 28, 2026
0476e3a
test: cover the un-flagged history shrink, and fix two stale names
tkc Oct 28, 2026
82896a6
docs: measure what the line counting costs on the read path
tkc Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ takes nothing, because neither shells nor agents use Cmd.
| `^R` | Search command history |
| `⌘N` / `⌘D` / `⌘E` | New / fork / fork with a chosen profile |
| `⌘F` | Find in the screen and scrollback |
| `⌘⇧F` | Search every session's screen and scrollback from the left pane; `Enter` jumps to the hit |
| `⌘I` (or `⌘⇧R`) | Rename the session |
| `⌘K` | Clear the screen and scrollback |
| `⌘[` / `⌘]` | Select the previous / next session |
Expand All @@ -132,6 +133,20 @@ takes nothing, because neither shells nor agents use Cmd.

Everything else goes to the child process untouched.

**Searching across sessions** (`⌘⇧F`) walks every session on a worker thread,
so typing never waits on it. It has one hole: a session sitting in the
alternate screen (`vim`, `less`, or any other full-screen program) can only be
searched over the screen it is showing right now. Its real scrollback is parked
in alacritty's `inactive_grid`, and nothing outside the crate can read it. The
history comes back to the search when the program exits. A hit on such a
screen switches to the session and hands the word to `⌘F`; it cannot jump to
the line.

It reads one grid row at a time, so a word broken across a wrap — the line ran
past the right edge and continued on the next row — is invisible to it. `⌘F`
walks the grid as a stream and does find such a word, so the same search finds
it once you are in the session.

**The dot on a row** is filled while the session is alive and hollow once it
has ended. Its colour is the session's state:

Expand Down
139 changes: 139 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,9 @@ alacritty の [vtebench](https://github.com/alacritty/vtebench) は端末の中

163 桁 x 47 行、履歴 10,000 行、1 種類あたり 16MiB。

次の表は `Counting`(行の数え上げ)が読みの経路に入る**前**の数字である。
入ったあとの測り直しは 7.16 にある。

| 負荷 | 合計 | 走査 | 解釈 | 速さ |
|---|---|---|---|---|
| light_cells(短い行) | 153ms | 0.3ms | 153ms | 104 MiB/s |
Expand Down Expand Up @@ -578,6 +581,141 @@ glyphon の `prepare` は `impl IntoIterator<Item = TextArea>` を取る。

13〜16% 減。1 行に収まる変更である。

## 7.15 横断検索の走査を測った(2026-10-29 追記)

`⌘⇧F` は全セッションの画面と履歴を走る。仕様 §10 は「先に測る」を求めているので、
`--bench` に走査の節を足した。

**測り方。** 14 本の `Term` を、シェルを起動せずに直接組み立てる(14 本ぶんの本物の出力は遅すぎる)。
1 本は 80 桁 x 24 行、履歴 10000 行を満たしてある。行は 80 桁に収まり、本ごと・行ごとに中身が違う。
語は 1 本に 1 つだけ置いた。`Redactor` は既定の式を入れてある。
`scan::scan` を `Limits::default()`(1 本 20 件、全体 200 件、chunk 256)で呼び、1 回の所要時間を測る。
最初の 2 回を捨て、15 回の中央と p90 を出す。1 回が約 50ms かかるので、フレームの測定(20 回)より少なくした。

**数字。** M4、macOS 26.5.1、`--release`。3 回走らせた。

| 走り | 中央 | p90 |
|---|---|---|
| 1 | 49.34ms | 50.35ms |
| 2 | 49.04ms | 50.16ms |
| 3 | 48.41ms | 49.47ms |

14 本 x 10000 行 = 14 万行を約 49ms で歩く。1 行あたり約 0.35µs である。

**この数字は最悪の場合である。** 当たりは 14 件しかなく、全体の上限 200 に届かないので、走査は早じまいせず
全部の行を歩く。普通は当たりが多く、1 本 20 件に達した本は、そこで遡りを止める。
語が全く当たらない場合も同じ費用になる。当たった行だけ `Redactor` を通すので、
当たりが多いほど 1 行の費用は上がるが、採用は 200 件までに収まる。

**フレームは落とさないはずである。ただし実機では未確認。** 約 49ms は 1 フレーム(16.7ms)より長い。
だから走査は別スレッドで走る。落とさない理由は、コードに次の 3 つがあることである。

1. **スレッドへ渡すのは `Arc` の複製と短い文字列だけ。** `start_scan`(`src/main.rs:2270`)が組む `Target` は、
`term`・`pushed`・`ticks` の `Arc` を複製し、名前・題名・枝の文字列と語を複製する(`src/main.rs:2295-2310`)。
`Redactor` も `Arc` の複製である(`src/main.rs:2310`)。グリッドは複製しない。
スレッドを作るのは `std::thread::Builder::spawn` の 1 回で、描画の経路に走査の中身は入らない(`src/main.rs:2311`)。
2. **描画の関数は走査も `Term` の錠も呼ばない。** 描画は結果の `Vec<Hit>`(最大 200 件)のうち、
窓に見える数件だけを組む(`across_blocks`、`src/main.rs:2732-2750`)。
`draw_across_hits`(`src/main.rs:3083`)から `draw_sidebar`(`src/main.rs:3201`)の範囲に、
`scan::scan` の呼び出しも `term.lock()` も無い。履歴の長さに比例する仕事は無い。
`start_scan` はイベントループがフレームの最後に呼ぶ(`src/main.rs:2454`)が、することはスレッドへ仕事を渡すだけで、1 がその費用を述べている。
3. **走査は `Limits::chunk` 行ごとに `Term` の錠を放す。** `scan_lines`(`src/scan.rs:214`)は、
錠を取る `let term = target.term.lock()`(`src/scan.rs:242`)を塊ごとの内側の区画に入れ、
`walked < chunk` で歩く行数を切る(`src/scan.rs:274`)。区画を抜けると錠が放れ、
`ticks` はその後で取る(`src/scan.rs:300`)。1 本の履歴を取りっぱなしで走ることは無い。
1 塊は 256 行で、塊は本ごとに切るので、全体で 14 x ceil(10024 / 256) = 560、約 550 塊になる。約 49ms を約 550 で割ると、1 塊は約 90µs になる。
これは割り算の見積もりで、錠の保持時間を直接測ったものではない。

**未了:実際のウィンドウでフレームの記録を見る確認。** この節を書いたのは窓も画面も無い環境で、
`TERMIT_FRAME_LOG` を実機で見ていない。上の 3 つはコードを読んだ論証であり、
「フレームが落ちなかった」という観測ではない。閉じるには、人が次を実行する。

```sh
RUST_LOG=info TERMIT_FRAME_LOG=1 cargo run --release
```

セッションを数本開き、それぞれに数千行を出してから `⌘⇧F` を押し、語を打って検索する。
走査が走っているあいだの毎フレームの記録に、落ちたフレームが無いことを見る。
あわせて、検索中も各セッションの出力が止まらないことを目で確かめる。

## 7.16 行の数え上げが読みの経路に足した費用を測った(2026-10-29 追記)

`Counting`(`src/lines.rs`)は、横断検索が「新しい順」を出すために履歴へ押し出した行を数える。
置いた場所がパーサと `Term` のあいだなので、**誰も検索しなくても全セッションの読みが必ず通る**。
7.13 の数字はこれが入る前のものなので、測り直した。

**測り直した理由。** 最初の査読は `--throughput` を 3 回走らせて `scrolling` が
202.6 / 196.5 / 187.7ms(7.13 の記録は 179ms)になることを見たが、同じ 3 回で
`light_cells` が 114〜136ms に振れていた。振れが差を飲んでしまい、
「遅くなった」とも「変わらない」とも言えなかった。記録が無いことが問題で、
遅くなったことが分かっていたわけではない。

**測り方。** 同じ機械・同じ条件で比べるため、`main` を別の worktree に出し、両方を
`--release` で建てた。`--throughput` を 2 本のバイナリで**交互に**走らせる
(片方を全部走らせてから他方、ではない。熱のずれが片側に乗らないようにする)。
11 周を 2 セット、計 22 組。組ごとの差を取るので、周のあいだの機械の状態は差に乗らない。
`main` 側の `throughput.rs` は `parser.advance(&mut term, ..)`、枝側は
`Counting` で包む。harness の違いはそこだけである。

**数字。** M4、macOS 26.5.1、`--release`、22 組の中央値。
「遅い組」は、枝のほうが遅かった組の数である。

| 負荷 | `main` | この枝 | 差 | 割合 | 遅い組 | 組ごとの差の四分位範囲 |
|---|---|---|---|---|---|---|
| light_cells(短い行) | 110.7ms | 117.2ms | +6.5ms | +5.9% | 17/22 | 14.6ms |
| medium_cells(半幅) | 97.6ms | 101.3ms | +3.7ms | +3.8% | 18/22 | 6.4ms |
| dense_cells(コマごとに色) | 60.2ms | 58.6ms | -1.6ms | -2.7% | 2/22 | 1.5ms |
| scrolling(送りだけ) | 181.8ms | 203.3ms | **+21.5ms** | **+11.8%** | **22/22** | 7.2ms |
| scrolling_region(領域を切って送り) | 644.5ms | 659.0ms | +14.5ms | +2.2% | **22/22** | 10.0ms |
| cursor_motion(飛ばして 1 字) | 67.9ms | 67.0ms | -0.9ms | -1.3% | 0/22 | 1.1ms |
| unicode(多バイト) | 72.2ms | 70.8ms | -1.4ms | -2.0% | 3/22 | 2.1ms |
| sync_cells(DECSET 2026 で囲む) | 92.2ms | 93.9ms | +1.7ms | +1.8% | 13/22 | 4.6ms |
| osc_heavy(シェル統合の通知が毎行) | 152.8ms | 150.6ms | -2.2ms | -1.4% | 6/22 | 4.8ms |

**費用は「送り 1 回あたり」で、コマには付かない。** これが表の形をそのまま説明する。

`Counting::linefeed` は送りごとに `at_bottom()` を呼ぶ。見るのはカーソル行と画面行数の 2 つで、
比べて `hooked` を 1 増やすだけである。いっぽう `input`(コマに字を置く)は `forward!` が
`#[inline]` でそのまま渡すので、何も足さない。だから**送りの多い負荷だけが遅くなる**。

`scrolling` は 16MiB が全部素の `\n` で、送りが 16,777,216 回ある。
+21.5ms をその数で割ると **1 回あたり約 1.3ns**、3GHz なら 4 サイクルほどで、
上に書いた仕事量とちょうど合う。

`scrolling_region` は DECSTBM を張ってからカーソルを最下行へ置くので、
送りの数(約 16M 回)も 1 回あたりの仕事(`at_bottom()` は最初の送りから真)も
`scrolling` と同じはずである。それなのに差は 14.5ms で、7ms 小さい。
この 7ms は分解していない。組ごとの差の四分位範囲 10.0ms の中に収まるので、
領域送りの経路との当たり方か、測りの振れかを分けられなかった。
割合が +2.2% なのは、下地が 3.5 倍遅いからである。

`dense_cells`・`cursor_motion`・`unicode`・`osc_heavy` は 1 行が長く、送りの数が桁違いに少ないので
差は出ない(むしろ少し速いが、符号が揃っていないので配置の揺れである)。

`light_cells`(1 行 10 バイト、送り約 168 万回)と `medium_cells`(1 行 83 バイト、約 20 万回)に
1.3ns を当てると 2.2ms と 0.3ms になる。測った差(+6.5ms・+3.7ms)はそれより大きいが、
どちらも組ごとの振れの中なので、この 2 つから 1 回あたりの費用は出せない。
1 回あたりの数字が出せるのは `scrolling` だけである。

**言えること。**

- **`scrolling` の +11.8% は本物である。** 22 組すべてで枝が遅く、差 21.5ms は
組ごとの差の四分位範囲 7.2ms の 3 倍ある。89 MiB/s が 79 MiB/s になった。
- **ただしこれは最悪の形で、普通の出力の形ではない。** 1 バイトが 1 回の送りという負荷である。
字を書く負荷(`dense_cells` 以下)では差が出ない。
- **`scrolling_region` も 22 組すべてで遅い。** 送りの数も 1 回あたりの仕事も `scrolling` と
同じはずなのに、差は 14.5ms で 7ms 小さい。その 7ms は分解できていない(上に書いた)。
- **`light_cells` と `medium_cells` は向きだけ言える。** 17/22・18/22 なので遅くなる向きは
たしかだが、差(+6.5ms・+3.7ms)が組ごとの差の四分位範囲(14.6ms・6.4ms)に収まっていない/
同程度なので、**大きさは決まっていない**。最初の査読が振れに悩んだのはこの負荷である。
- 1 台の機械の数字である。

**受け入れる理由。** 79 MiB/s は、pty が実際に届けてくる速さよりずっと速い。
7.13 が書いているとおり、ここで測っているのは「パーサとグリッドが受け取れる速さ」であって、
重さの原因が読み取りだと分かっているわけではない。送り 4 サイクルを削るには、
数えを 1 文字ごとの経路へ入れるか(7.13 が `input` の 1 割として却下した道)、
行に時刻を持たせる別の仕組みが要る。横断検索の「新しい順」が要る以上、この 4 サイクルは払う。

## 8. 参考にした資料

手法ごとの採否は [references/performance-techniques.md](references/performance-techniques.md) にまとめた。
Expand All @@ -604,3 +742,4 @@ glyphon の `prepare` は `impl IntoIterator<Item = TextArea>` を取る。
| 常駐メモリ(1 ペイン) | 100MB 以内 | 84.7MB |
| 起動から子プロセスの生成まで | 200ms 以内 | 224ms |
| バイナリ | 単一ファイル | 9.4MB |
| 横断検索の走査(14 本 x 10000 行) | 別スレッド | 49ms(実機のフレーム確認は未了、7.15) |
108 changes: 107 additions & 1 deletion src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,32 @@
//! 遅さの原因を「描画の指示を組み立てる費用(CPU)」と
//! 「GPU へ投げて待つ費用」に切り分けるために使う。

use std::time::Duration;
use std::sync::atomic::AtomicU64;
use std::sync::mpsc::channel;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use alacritty_terminal::event::WindowSize;
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::vte::ansi::{Processor, StdSyncHandler};

use crate::config::ScreenConfig;
use crate::lines::Ticks;
use crate::render::Renderer;
use crate::scan::{self, Limits, Target};
use crate::secret::Redactor;
use crate::term::{EventProxy, TermSize, UiSender};
use crate::theme::Theme;

const FRAMES: usize = 20;
const WARMUP: usize = 4;

/// 走査の測定回数。1 回が数十 ms から数百 ms かかるので、フレームの `FRAMES` より
/// 少なくする。p90 は 15 回なら上から 2 番目の値になる。
const SCAN_RUNS: usize = 15;
/// 捨てる最初の回数。ページと分岐予測を温める。
const SCAN_WARMUP: usize = 2;

pub fn run() {
let theme = Theme::default();
let sizes = [(1280u32, 800u32), (1920, 1200)];
Expand Down Expand Up @@ -62,6 +80,7 @@ pub fn run() {
}
}
row_estimate();
scan_cost();
println!("\n60fps の予算は 16.7ms。入力から表示までの体感は、これに vsync 待ちが加わる。");
}

Expand Down Expand Up @@ -117,3 +136,90 @@ fn row_estimate() {
bm + pm
);
}

/// 横断検索の走査(`scan::scan`)の費用。仕様 §10 の「先に測る」。
///
/// 14 本 × 10000 行の履歴を、実際の `Term` に流して作る。シェルは起動しない
/// (14 本ぶんの本物の出力は遅すぎる)。行の中身は毎行変え、当たりは 1 本に 1 つだけ
/// 置く。当たりは上限(全体 200)に遠く届かないので、走査は早じまいせず
/// **全部の行を歩く**。これは最悪の場合であり、普通の場合ではない。
fn scan_cost() {
const SESSIONS: usize = 14;
const HISTORY: usize = 10_000;
const COLS: usize = 80;
const LINES: usize = 24;
// 1 本に 1 行だけ現れる語。当たりが全体 200 件の上限に届かず、早じまいせずに全部歩く最悪の場合が測れる。
const WORD: &str = "zebraquux";

let targets: Vec<Target> = (0..SESSIONS)
.map(|s| {
let (pty_tx, _pty_rx) = channel();
let (ui_tx, _ui_rx) = channel();
let ws = Arc::new(FairMutex::new(WindowSize {
num_lines: LINES as u16,
num_cols: COLS as u16,
cell_width: 8,
cell_height: 16,
}));
let proxy = EventProxy::new(s as u32, pty_tx, UiSender::Channel(ui_tx), ws);
let mut term = crate::term::new_term(TermSize::new(COLS, LINES), HISTORY, proxy);
let mut parser: Processor<StdSyncHandler> = Processor::new();
// 画面の行数ぶん余分に流し、履歴をちょうど満たす。
let total = HISTORY + LINES;
let mut play = String::new();
// 1 行は 80 桁に収める。折り返すと履歴の行数が減り、当たりが履歴の外へ出る。
for i in 0..total {
// 本ごと・行ごとに違う文字列にする。
let mix = ((i as u64).wrapping_mul(2_654_435_761)
^ (s as u64).wrapping_mul(40_503))
& 0xffff_ffff;
if i == 5_000 + s {
play.push_str(&format!("[{s:02}] {i:05} found {WORD} {mix:x}\r\n"));
} else {
play.push_str(&format!(
"[{s:02}] {i:05} $ cargo build -p crate{} {mix:x} warning: unused\r\n",
i % 97
));
}
}
parser.advance(&mut term, play.as_bytes());
Target {
id: s as u32,
term: Arc::new(FairMutex::new(term)),
pushed: Arc::new(AtomicU64::new(0)),
ticks: Arc::new(Mutex::new(Ticks::new())),
name: format!("session-{s}"),
title: None,
branch: None,
}
})
.collect();
let redactor =
Redactor::new(&ScreenConfig::default().redact).expect("既定の式は組み立てられる");
let limits = Limits::default();

println!(
"\n=== 横断検索の走査({SESSIONS} 本 x {HISTORY} 行 = {} 行、当たりは 1 本に 1 つ)===",
SESSIONS * HISTORY
);
let mut times = Vec::new();
let mut found = 0usize;
for run in 0..SCAN_WARMUP + SCAN_RUNS {
let t0 = Instant::now();
let hits = scan::scan(&targets, WORD, &redactor, limits);
let d = t0.elapsed();
found = hits.len();
if run >= SCAN_WARMUP {
times.push(d);
}
}
// 当たりが 1 本に 1 つ見つからなければ、早じまいか行の取り違えで、測りたいものを測っていない。
if found != SESSIONS {
println!(" 警告: 当たりが {found} 件で {SESSIONS} 件にならない。この数字は使えない");
}
let (m, p90) = stats(&times);
println!(
" 走査 当たり {found:>3} 件 1 回あたり 中央 {m:7.2}ms p90 {p90:7.2}ms ({SCAN_RUNS} 回, 捨て {SCAN_WARMUP} 回, chunk {})",
limits.chunk
);
}
Loading
Loading