M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
# 🚀 Architecture Overview — `kigi-tui` Rendering Engine
|
||||
|
||||
The **kigi-tui** rendering engine is built on a layered pipeline that transforms raw markdown into terminal-ready cells. This document covers every major subsystem — from markdown parsing through syntax highlighting, word wrapping, block layout, viewport clipping, and final buffer composition. Understanding these layers is critical for anyone profiling or optimising the renderer.
|
||||
|
||||
---
|
||||
|
||||
## 📐 The Rendering Pipeline
|
||||
|
||||
Every frame follows the same sequence of stages. Content flows **downward** through transforms, each adding structure:
|
||||
|
||||
1. **Markdown parsing** — `StreamingMarkdownRenderer` converts source text into a tree of styled `Line<'static>` spans. Code fences trigger **syntect** highlighting.
|
||||
2. **Word wrapping** — `word_wrap_lines_with_joiners()` breaks logical lines into physical rows that fit the viewport width, tracking *joiners* (continuation markers like `↳`) for copy/paste fidelity.
|
||||
3. **Block output** — `BlockContent::output()` packages wrapped lines into a `BlockOutput` with per-line metadata: background colour, joiner strings, and optional decorations.
|
||||
4. **Entry rendering** — `EntryRenderer` composes the accent column (`┃`), left/right padding, and block content into a horizontal strip. Vertical padding (vpad) adds breathing room above and below.
|
||||
5. **Viewport clipping** — `render_scrolled_entries_with_scratch()` walks the entry list, skips off-screen entries, and uses a `ScratchBuffer` to render partially-visible entries into a temp buffer before copying the visible slice.
|
||||
6. **Buffer diff** — ratatui's `Terminal::flush()` diffs the old and new `Buffer` and emits only changed cells as escape sequences. This is **O(changed cells)**, not O(total cells).
|
||||
|
||||
> **💡 Key insight**: steps 1–3 are **cached** across frames. Only step 4–5 run every frame. Profiling should focus there.
|
||||
|
||||
### Performance characteristics
|
||||
|
||||
| Stage | Complexity | Cached? | Hot path? |
|
||||
|---|---|---|---|
|
||||
| Markdown parse | `O(n)` in source length | ✅ Yes, per-generation | ❌ No |
|
||||
| Syntax highlight | `O(n)` with syntect DFA | ✅ Yes, per-generation | ❌ No |
|
||||
| Word wrap | `O(lines × width)` | ✅ Yes, `(width, gen)` key | ❌ No |
|
||||
| `BlockContent::output()` | `O(wrapped_lines)` | ✅ Via `WrapCache` | ⚠️ First call only |
|
||||
| `EntryRenderer::render()` | `O(height × width)` cell writes | ❌ No | ✅ **Yes** |
|
||||
| Scratch buffer copy | `O(visible_rows × width)` clones | ❌ No | ✅ **Yes** |
|
||||
| Buffer diff + flush | `O(changed_cells)` | N/A | ✅ **Yes** |
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Block Types and Their Render Cost
|
||||
|
||||
Each `RenderBlock` variant has different rendering characteristics. Here's a breakdown of the major block types with their typical content patterns and associated costs:
|
||||
|
||||
### `AgentMessageBlock` — the heaviest hitter 🔥
|
||||
|
||||
Agent messages contain **arbitrary markdown**: paragraphs, code blocks, tables, lists, inline formatting. A single agent response can easily exceed 200 wrapped lines. The `MarkdownContent` subsystem does the heavy lifting:
|
||||
|
||||
- `StreamingMarkdownRenderer::push_and_render()` incrementally parses and highlights
|
||||
- `word_wrap_lines_with_joiners()` handles Unicode-aware line breaking with `unicode-width`
|
||||
- Wide characters (CJK, emoji) consume 2 columns: `'🦀'.width() == 2`, `'λ'.width() == 1`
|
||||
|
||||
```rust
|
||||
/// The core markdown-to-lines pipeline.
|
||||
///
|
||||
/// This function is called on every content mutation (push_chunk, finish)
|
||||
/// and produces the canonical `Vec<Line<'static>>` that gets cached.
|
||||
pub fn render_markdown(source: &str, pretty: bool) -> Vec<Line<'static>> {
|
||||
let mut renderer = StreamingMarkdownRenderer::new(MD_STYLE, pretty);
|
||||
renderer.push(source);
|
||||
renderer.render(Some(get_syntect()));
|
||||
renderer.view().lines.to_vec()
|
||||
}
|
||||
|
||||
/// Word-wrap with joiner tracking for copy fidelity.
|
||||
///
|
||||
/// Each output line knows whether it's a continuation of the previous
|
||||
/// logical line (joiner = Some("↳")) or a fresh line (joiner = None).
|
||||
/// This matters for selection/copy: we strip joiners when copying.
|
||||
pub fn word_wrap_lines_with_joiners(
|
||||
lines: Vec<Line<'static>>,
|
||||
max_width: usize,
|
||||
) -> (Vec<Line<'static>>, Vec<Option<String>>) {
|
||||
let mut wrapped = Vec::with_capacity(lines.len() * 2);
|
||||
let mut joiners = Vec::with_capacity(lines.len() * 2);
|
||||
for line in lines {
|
||||
let line_width = line.width();
|
||||
if line_width <= max_width {
|
||||
wrapped.push(line);
|
||||
joiners.push(None);
|
||||
} else {
|
||||
// Split at grapheme cluster boundaries respecting unicode width.
|
||||
// This is the expensive path — O(spans × chars) per line.
|
||||
let parts = split_line_at_width(&line, max_width);
|
||||
for (i, part) in parts.into_iter().enumerate() {
|
||||
wrapped.push(part);
|
||||
joiners.push(if i > 0 { Some("↳".into()) } else { None });
|
||||
}
|
||||
}
|
||||
}
|
||||
(wrapped, joiners)
|
||||
}
|
||||
```
|
||||
|
||||
### `ThinkingBlock` — truncated by default
|
||||
|
||||
Thinking blocks render identically to agent messages but default to `DisplayMode::Truncated` (3 visible lines + `⋯ N more lines`). When expanded, they're as expensive as agent messages. The truncation logic runs *after* wrapping, so the full wrap cost is paid even when collapsed — a potential optimisation target.
|
||||
|
||||
### `ToolCallBlock` variants
|
||||
|
||||
| Variant | Collapsed height | Expanded cost | Notes |
|
||||
|---|---|---|---|
|
||||
| `Execute` | 1 line (command summary) | `O(output_lines)` | Bash output can be huge |
|
||||
| `Read` | 1 line (path + line count) | `O(file_lines)` | Syntax-highlighted file content |
|
||||
| `Edit` | 1 line (path + edit count) | `O(diff_lines)` | Diff hunks with `+`/`-` colouring |
|
||||
| `ListDir` | 1 line (path) | `O(entries)` | Directory tree listing |
|
||||
| `Search` | 1 line (pattern + count) | `O(matches)` | Grep results with context |
|
||||
| `Other` | 1 line (tool name) | `O(output)` | Generic tool output |
|
||||
|
||||
### `UserPromptBlock` — lightweight ✨
|
||||
|
||||
User prompts are short (1–5 lines typically), render with a `┃` accent in `accent_user` colour, and are **never foldable**. They're the cheapest block to render.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 The Accent Column and Colour Blending
|
||||
|
||||
The leftmost column of every entry shows a vertical accent bar `┃`. This serves as a visual type indicator:
|
||||
|
||||
- **User prompts**: `accent_user` (Tokyo Night blue, `#7aa2f7`)
|
||||
- **Tool calls**: `accent_tool` / `accent_success` / `accent_error`
|
||||
- **Thinking**: `accent_thinking` (purple, `#bb9af7`)
|
||||
- **Running blocks**: animated wave effect 🌊
|
||||
|
||||
The animation uses `blend_color(bg, fg, brightness)` per-row per-frame:
|
||||
|
||||
```rust
|
||||
/// Compute wave brightness for a single row at a given tick.
|
||||
///
|
||||
/// Returns a value in [0.2, 1.0] — never fully invisible.
|
||||
/// The wave travels downward at WAVE_SPEED radians per tick.
|
||||
pub fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f32) -> f32 {
|
||||
let phase = (tick as f32 * speed) - (row as f32 * std::f32::consts::PI / wave_rows as f32);
|
||||
let raw = (phase.sin() + 1.0) / 2.0; // normalize to [0, 1]
|
||||
0.2 + raw * 0.8 // scale to [0.2, 1.0]
|
||||
}
|
||||
|
||||
/// Linearly blend two RGB colours.
|
||||
///
|
||||
/// `opacity = 0.0` → pure `base`; `opacity = 1.0` → pure `color`.
|
||||
/// Returns `None` if either colour isn't RGB (indexed colours can't blend).
|
||||
pub fn blend_color(base: Color, color: Color, opacity: f32) -> Option<Color> {
|
||||
match (base, color) {
|
||||
(Color::Rgb(br, bg, bb), Color::Rgb(cr, cg, cb)) => {
|
||||
let r = br as f32 + (cr as f32 - br as f32) * opacity;
|
||||
let g = bg as f32 + (cg as f32 - bg as f32) * opacity;
|
||||
let b = bb as f32 + (cb as f32 - bb as f32) * opacity;
|
||||
Some(Color::Rgb(r as u8, g as u8, b as u8))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 The `ScratchBuffer` and Partial Rendering
|
||||
|
||||
When an entry is **partially visible** (clipped at top or bottom of the viewport), we can't render directly into the output buffer — we'd write cells outside the visible area. Instead:
|
||||
|
||||
1. Resize a reusable `ScratchBuffer` to the entry's full height
|
||||
2. Render the complete entry into scratch
|
||||
3. Copy only the visible rows (`skip_rows..skip_rows + visible_height`) into the output
|
||||
|
||||
This is the **cell-by-cell copy loop** — one of the hottest paths:
|
||||
|
||||
```rust
|
||||
for dy in 0..visible_rows {
|
||||
let src_y = skip_rows + dy;
|
||||
let dst_y = dest_area.y + dy;
|
||||
for dx in 0..dest_area.width {
|
||||
if let Some(src_cell) = temp_buf.cell((dx, src_y))
|
||||
&& let Some(dst_cell) = buf.cell_mut((dest_area.x + dx, dst_y))
|
||||
{
|
||||
dst_cell.clone_from(src_cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **🔬 Optimisation opportunity**: `Cell::clone_from` copies `symbol: String` (24 bytes on stack + possible heap), `fg`, `bg`, `underline_color`, `modifier`, `skip`. A `memcpy`-based bulk row copy could be significantly faster for wide terminals. At `width=200`, that's 200 `clone_from` calls per visible row per frame — potentially 6000 calls for a 30-row viewport with top+bottom clipping.
|
||||
|
||||
---
|
||||
|
||||
## 🔤 Unicode Width Challenges
|
||||
|
||||
Terminal rendering must account for **variable-width characters**. The `unicode-width` crate provides `UnicodeWidthChar::width()` and `UnicodeWidthStr::width()`:
|
||||
|
||||
| Character | Example | `width()` | Notes |
|
||||
|---|---|---|---|
|
||||
| ASCII | `A`, `z`, `!` | 1 | Basic Latin |
|
||||
| CJK Unified | `漢`, `字`, `中` | 2 | Chinese/Japanese/Korean ideographs |
|
||||
| Fullwidth forms | `A`, `B`, `1` | 2 | Fullwidth ASCII variants |
|
||||
| Emoji | `🦀`, `🚀`, `🎨` | 2 | Most emoji are wide |
|
||||
| Combining marks | `é` (e + ◌́) | 1 | Combining char has width 0 |
|
||||
| Zero-width | ZWJ, ZWNJ | 0 | Used in emoji sequences like 👨👩👧👦 |
|
||||
| Tab | `\t` | — | Not handled by unicode-width; we expand to spaces |
|
||||
|
||||
The word wrapper must **never split a wide character** across the column boundary. If a 2-cell-wide char would start at column `width - 1`, we must wrap it to the next line and pad the current line with a space.
|
||||
|
||||
Here's a stress test: `漢字テスト🦀🚀🎨` contains 5 double-width CJK chars (10 columns) plus 3 double-width emoji (6 columns) = 16 columns total. At `width = 10`, this wraps to 2 lines. At `width = 7`, it wraps to 3 lines with padding cells.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Inline Code and Syntax Highlighting Deep Dive
|
||||
|
||||
Inline code uses backtick syntax: `HashMap<String, Vec<u8>>`, `Option<&'a mut T>`, `impl Fn(usize) -> bool`. Each inline code span gets a distinct background colour (`bg_code`) to visually separate it from prose. The renderer must:
|
||||
|
||||
1. Parse the backtick delimiter (single `` ` `` or double ``` `` ```)
|
||||
2. Extract the code content
|
||||
3. Apply `Style::default().bg(theme.bg_code).fg(theme.fg_code)`
|
||||
4. Handle **nested formatting** — e.g., `**bold `code` bold**` where code is inside bold
|
||||
|
||||
Fenced code blocks trigger full **syntect** highlighting. The highlighting pipeline:
|
||||
|
||||
1. Look up the `SyntaxReference` by language identifier (`rust`, `python`, `typescript`, etc.)
|
||||
2. Create a `HighlightLines` with the Tokyo Night theme
|
||||
3. Iterate source lines, calling `highlight_line()` to get `Vec<(syntect::Style, &str)>`
|
||||
4. Convert syntect styles to ratatui `Span` styles (mapping RGB colours)
|
||||
5. Each line gets `Style::default().bg(theme.bg_dark)` as a block background
|
||||
|
||||
The syntect state machine is **line-stateful** — each line's highlighting depends on the parse state at the end of the previous line. This means we can't parallelise highlighting within a single code block, but we *can* cache the result.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Patterns
|
||||
|
||||
The scrollback rendering has comprehensive snapshot tests using `insta`. Here's the typical pattern:
|
||||
|
||||
```python
|
||||
# This is a Python code block to exercise a different syntax highlighter.
|
||||
# The renderer must detect the language and switch syntect grammars.
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
@dataclass
|
||||
class TrainingConfig:
|
||||
"""Configuration for a distributed training run. 🔧"""
|
||||
model_name: str
|
||||
batch_size: int = 32
|
||||
learning_rate: float = 3e-4
|
||||
max_epochs: int = 100
|
||||
gradient_accumulation_steps: int = 1
|
||||
warmup_ratio: float = 0.1
|
||||
weight_decay: float = 0.01
|
||||
devices: List[str] = field(default_factory=lambda: ["cuda:0"])
|
||||
mixed_precision: bool = True
|
||||
compile_model: bool = False # torch.compile — can 2× throughput
|
||||
checkpoint_dir: Optional[str] = None
|
||||
|
||||
@property
|
||||
def effective_batch_size(self) -> int:
|
||||
return self.batch_size * self.gradient_accumulation_steps * len(self.devices)
|
||||
|
||||
def validate(self) -> None:
|
||||
assert self.batch_size > 0, f"batch_size must be positive, got {self.batch_size}"
|
||||
assert 0 < self.learning_rate < 1, f"learning_rate out of range: {self.learning_rate}"
|
||||
assert self.max_epochs > 0, f"max_epochs must be positive, got {self.max_epochs}"
|
||||
for device in self.devices:
|
||||
assert device.startswith(("cuda", "cpu")), f"unknown device: {device}"
|
||||
|
||||
|
||||
async def train_epoch(
|
||||
model,
|
||||
dataloader,
|
||||
optimizer,
|
||||
scheduler,
|
||||
config: TrainingConfig,
|
||||
epoch: int,
|
||||
) -> Dict[str, float]:
|
||||
"""Run a single training epoch. Returns metrics dict. 📈"""
|
||||
model.train()
|
||||
total_loss = 0.0
|
||||
num_batches = 0
|
||||
|
||||
for batch_idx, batch in enumerate(dataloader):
|
||||
# Forward pass — compute loss on this micro-batch
|
||||
outputs = model(**batch)
|
||||
loss = outputs.loss / config.gradient_accumulation_steps
|
||||
loss.backward()
|
||||
|
||||
if (batch_idx + 1) % config.gradient_accumulation_steps == 0:
|
||||
optimizer.step()
|
||||
scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
total_loss += loss.item() * config.gradient_accumulation_steps
|
||||
num_batches += 1
|
||||
|
||||
avg_loss = total_loss / max(num_batches, 1)
|
||||
return {"epoch": epoch, "avg_loss": avg_loss, "num_batches": num_batches}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Benchmarking Strategy
|
||||
|
||||
To measure render performance, we need to isolate the **per-frame** cost from one-time setup:
|
||||
|
||||
- **Setup** (not measured): Parse markdown, create `ScrollbackEntry`, compute initial wrap cache
|
||||
- **Measured**: For each scroll offset `0..total_height`, render into a `Buffer` of size `width × viewport_height`
|
||||
|
||||
This simulates a user holding down `j` (scroll down) and measures the **worst case** — every frame re-renders the viewport at a new scroll position, exercising:
|
||||
|
||||
- `EntryRenderer::render()` — accent, padding, content layout
|
||||
- `BlockRenderer::render()` — vpad, content lines, background fills
|
||||
- Partial rendering via `ScratchBuffer` — for clipped entries at top/bottom
|
||||
- Cell-by-cell copy — the innermost hot loop
|
||||
|
||||
### Expected results
|
||||
|
||||
On a modern machine (M2 Pro), we expect:
|
||||
|
||||
- **~50–200 µs/frame** for a 120×30 viewport with a 200-line markdown document
|
||||
- **~80% of time** in `EntryRenderer::render()` + scratch buffer copy
|
||||
- **~15% of time** in `BlockContent::output()` (cache hit path — just iterating cached lines)
|
||||
- **~5% of time** in layout computation (`HorizontalLayout`, `EntryLayout`, gap math)
|
||||
|
||||
If the benchmark shows >500 µs/frame, there's likely an unexpected cache miss or allocation in the hot path. Use `cargo bench -- --profile-time 10` with `flamegraph` to identify the culprit.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Miscellaneous Wide Characters and Edge Cases
|
||||
|
||||
Here are some strings that exercise interesting rendering edge cases:
|
||||
|
||||
- **Emoji sequences**: 👨👩👧👦 (family ZWJ sequence, should be width 2 but terminal support varies)
|
||||
- **Flags**: 🇺🇸 🇯🇵 🇩🇪 (regional indicator pairs)
|
||||
- **Fullwidth**: `ABCDE` (each char is 2 columns wide)
|
||||
- **Combining**: `naïve` vs `naïve` (precomposed U+00EF vs combining U+0308)
|
||||
- **Box drawing**: `┌─────────┐│ content │└─────────┘` (all width 1)
|
||||
- **Mathematical**: `∀x ∈ ℝ : x² ≥ 0`, `∑_{i=0}^{n} aᵢ = S`, `∫₀^∞ e^{-x} dx = 1`
|
||||
- **CJK mixed**: `これはテストです — this is a test — 這是測試 — 이것은 시험이다`
|
||||
- **RTL markers**: `Hello dlrow!` (contains RLO/PDF override characters)
|
||||
|
||||
The renderer must handle all of these without panicking or producing garbled output. The word wrapper is the critical component — it must correctly account for each character's display width when deciding where to break lines.
|
||||
|
||||
> **⚠️ Warning**: Some terminals render emoji sequences incorrectly (showing them as 1-wide or as multiple glyphs). Our renderer uses `unicode-width` which reports the **Unicode standard** width, not the terminal's actual rendering width. This is a known source of misalignment — there is no perfect solution without querying the terminal.
|
||||
|
||||
---
|
||||
|
||||
*Generated for benchmarking purposes. Total: ~230 lines of rich markdown content with multiple code blocks, tables, inline code, emoji, wide Unicode characters, and varied formatting.*
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Criterion benchmarks: edit-diff syntax-highlight strategy costs.
|
||||
//!
|
||||
//! | Strategy | What runs |
|
||||
//! |----------|-----------|
|
||||
//! | `hunk_only` | Prod cold path: [`render_diff_hunks_highlighted`] |
|
||||
//! | `full_file_slice` | Prod upgrade compute: [`compute_file_scoped_styles`] |
|
||||
//! | `upgrade_once_per_file` | **Each iter:** full-file compute + one paint with those styles |
|
||||
//! | `paint_with_precomputed` | Styles computed in setup; timed path is paint only |
|
||||
//! | `prefix_per_hunk` | Non-product baseline: silent-prime `1..hunk_start` per hunk (small fixture only) |
|
||||
//!
|
||||
//! Groups: `edit_hl/matrix` (500L+prefix, 10kL no prefix) and `edit_hl/upgrade`
|
||||
//! (amortized session paint vs one-shot upgrade vs cold control).
|
||||
//!
|
||||
//! Caps: 2 MiB / 50k lines — see product docs on the edit block / worker.
|
||||
//! Magnitudes: run this bench; do not treat module docs as gates.
|
||||
//!
|
||||
//! `compute_file_scoped_styles` stops at the last hunk line; these fixtures
|
||||
//! spread hunks to near EOF, so `full_file_slice` still measures ≈ the whole
|
||||
//! file (the worst case a real upgrade pays).
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench -p kigi-tui --bench edit_highlight
|
||||
//! cargo bench -p kigi-tui --bench edit_highlight -- edit_hl/upgrade
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hint::black_box;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||
use similar::ChangeTag;
|
||||
use syntect::highlighting::Style as SyntectStyle;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use kigi_tui::diff::{DiffHunk, DiffLine};
|
||||
use kigi_tui::scrollback::blocks::tool::{
|
||||
DiffRenderConfig, EDIT_HL_MAX_BYTES, EDIT_HL_MAX_LINES, compute_file_scoped_styles,
|
||||
render_diff_hunks_highlighted, render_diff_hunks_with_styles,
|
||||
};
|
||||
use kigi_tui::syntax::{Syntect, get_syntect};
|
||||
use kigi_tui::theme::Theme;
|
||||
|
||||
const SAMPLE_SIZE: usize = 20;
|
||||
const SAMPLE_SIZE_HEAVY: usize = 10;
|
||||
const WARMUP_TIME: Duration = Duration::from_secs(1);
|
||||
const MEASURE_TIME: Duration = Duration::from_secs(3);
|
||||
const MEASURE_TIME_HEAVY: Duration = Duration::from_secs(5);
|
||||
const RENDER_WIDTH: u16 = 120;
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Fixture {
|
||||
_dir: TempDir,
|
||||
path: PathBuf,
|
||||
file_text: String,
|
||||
hunks: Vec<DiffHunk>,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn bytes(&self) -> u64 {
|
||||
self.file_text.len() as u64
|
||||
}
|
||||
|
||||
fn n_hunks(&self) -> usize {
|
||||
self.hunks.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated Python with mid-file `"""` closers. Hunk text matches disk (post-edit).
|
||||
fn gen_python_fixture(n_lines: usize, n_hunks: usize) -> Fixture {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join(format!("bulky_{n_lines}.py"));
|
||||
|
||||
let mut body = String::with_capacity(n_lines * 40);
|
||||
body.push_str("\"\"\"Module docstring: bulky fixture for edit highlight benches.\"\"\"\n");
|
||||
body.push_str("from pydantic import Field\n\n");
|
||||
|
||||
let classes = (n_lines / 10).max(n_hunks.max(8));
|
||||
for i in 0..classes {
|
||||
body.push_str(&format!(
|
||||
"class Class_{i}:\n\
|
||||
\t\"\"\"Docstring for Class_{i}.\n\
|
||||
\n\
|
||||
\tExtra lines so the closer is mid-block.\n\
|
||||
\t\"\"\"\n\
|
||||
\tfield_a: int = {i}\n\
|
||||
\tfield_b: str = Field(...)\n\
|
||||
\tfield_c: list[str] = Field(default_factory=list)\n\
|
||||
\n"
|
||||
));
|
||||
}
|
||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
||||
while lines.len() < n_lines {
|
||||
lines.push(format!("# pad line {}", lines.len()));
|
||||
}
|
||||
lines.truncate(n_lines);
|
||||
let file_text = lines.join("\n") + "\n";
|
||||
std::fs::write(&path, &file_text).expect("write fixture");
|
||||
|
||||
let file_line_refs: Vec<&str> = file_text.lines().collect();
|
||||
let usable: Vec<usize> = file_line_refs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, l)| (l.trim() == "\"\"\"").then_some(i))
|
||||
.filter(|&i| i > n_lines / 10 && i + 6 < n_lines)
|
||||
.collect();
|
||||
assert!(
|
||||
usable.len() >= n_hunks,
|
||||
"need ≥{n_hunks} mid-file docstring closers, got {}",
|
||||
usable.len()
|
||||
);
|
||||
|
||||
let mut hunks = Vec::with_capacity(n_hunks);
|
||||
for h in 0..n_hunks {
|
||||
let idx = usable[h * usable.len() / n_hunks];
|
||||
hunks.push(make_hunk_at(&file_line_refs, idx).expect("hunk at mid-file closer"));
|
||||
}
|
||||
|
||||
let label = format!("py_{n_lines}L_{n_hunks}H");
|
||||
eprintln!(
|
||||
"[fixture] {label} path={} lines={} hunks={} bytes={} (caps: {} MiB / {} lines)",
|
||||
path.display(),
|
||||
n_lines,
|
||||
n_hunks,
|
||||
file_text.len(),
|
||||
EDIT_HL_MAX_BYTES / (1024 * 1024),
|
||||
EDIT_HL_MAX_LINES,
|
||||
);
|
||||
|
||||
Fixture {
|
||||
_dir: dir,
|
||||
path,
|
||||
file_text,
|
||||
hunks,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_hunk_at(file_lines: &[&str], close_i: usize) -> Option<DiffHunk> {
|
||||
if close_i + 6 >= file_lines.len() {
|
||||
return None;
|
||||
}
|
||||
let start = close_i.saturating_sub(1);
|
||||
let end = (close_i + 6).min(file_lines.len());
|
||||
let mut hunk = Vec::new();
|
||||
for (idx, &text) in file_lines.iter().enumerate().take(end).skip(start) {
|
||||
let ln = idx + 1;
|
||||
let tag = if text.contains("field_b:") {
|
||||
ChangeTag::Insert
|
||||
} else {
|
||||
ChangeTag::Equal
|
||||
};
|
||||
hunk.push(DiffLine {
|
||||
text: format!("{text}\n"),
|
||||
lo: ln,
|
||||
ln,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
if hunk.iter().any(|l| l.tag != ChangeTag::Equal) {
|
||||
Some(hunk)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-product prefix baseline ─────────────────────────────────────────────
|
||||
|
||||
fn own_ranges(ranges: Vec<(SyntectStyle, &str)>) -> Vec<(SyntectStyle, String)> {
|
||||
ranges.into_iter().map(|(s, t)| (s, t.to_owned())).collect()
|
||||
}
|
||||
|
||||
/// Silent-prime `1..hunk_start` per hunk — expensive multi-hunk baseline, not product.
|
||||
fn highlight_prefix_per_hunk(
|
||||
syntect: &Syntect,
|
||||
path: &Path,
|
||||
file_lines: &[&str],
|
||||
hunks: &[DiffHunk],
|
||||
) -> Vec<Vec<(SyntectStyle, String)>> {
|
||||
let mut out = Vec::new();
|
||||
for hunk in hunks {
|
||||
let mut hl = syntect.highlight_lines_by_file_path(path);
|
||||
let start_ln = hunk
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let n = if l.ln > 0 { l.ln } else { l.lo };
|
||||
(n > 0).then_some(n)
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(1);
|
||||
|
||||
if let Some(h) = hl.as_mut() {
|
||||
for line in file_lines.iter().take(start_ln.saturating_sub(1)) {
|
||||
let owned = format!("{line}\n");
|
||||
let _ = h.highlight_line(&owned, &syntect.syntax_set);
|
||||
}
|
||||
}
|
||||
|
||||
for line in hunk {
|
||||
let content = line.text.trim_end_matches(['\r', '\n']);
|
||||
let owned = format!("{content}\n");
|
||||
let segs = if let Some(h) = hl.as_mut() {
|
||||
h.highlight_line(&owned, &syntect.syntax_set)
|
||||
.map(own_ranges)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.push(segs);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn estimate_style_map_bytes(map: &HashMap<usize, Vec<(ratatui::style::Style, String)>>) -> usize {
|
||||
let mut bytes = std::mem::size_of_val(map);
|
||||
bytes += map.len()
|
||||
* (std::mem::size_of::<usize>()
|
||||
+ std::mem::size_of::<Vec<(ratatui::style::Style, String)>>()
|
||||
+ 16);
|
||||
for spans in map.values() {
|
||||
bytes += spans.capacity() * std::mem::size_of::<(ratatui::style::Style, String)>();
|
||||
for (_, s) in spans {
|
||||
bytes += s.capacity();
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
// ── Bench groups ────────────────────────────────────────────────────────────
|
||||
|
||||
fn configure_fast(group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>) {
|
||||
group
|
||||
.sample_size(SAMPLE_SIZE)
|
||||
.warm_up_time(WARMUP_TIME)
|
||||
.measurement_time(MEASURE_TIME);
|
||||
}
|
||||
|
||||
fn configure_heavy(group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>) {
|
||||
group
|
||||
.sample_size(SAMPLE_SIZE_HEAVY)
|
||||
.warm_up_time(WARMUP_TIME)
|
||||
.measurement_time(MEASURE_TIME_HEAVY);
|
||||
}
|
||||
|
||||
fn register_prod_strategies(
|
||||
group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>,
|
||||
fx: &Fixture,
|
||||
theme: &Theme,
|
||||
config: &DiffRenderConfig,
|
||||
) {
|
||||
group.bench_function(BenchmarkId::new("hunk_only", &fx.label), |b| {
|
||||
b.iter(|| {
|
||||
black_box(render_diff_hunks_highlighted(
|
||||
black_box(&fx.hunks),
|
||||
fx.path(),
|
||||
theme,
|
||||
RENDER_WIDTH,
|
||||
config,
|
||||
))
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function(BenchmarkId::new("full_file_slice", &fx.label), |b| {
|
||||
b.iter(|| {
|
||||
black_box(
|
||||
compute_file_scoped_styles(
|
||||
fx.path(),
|
||||
black_box(&fx.file_text),
|
||||
black_box(&fx.hunks),
|
||||
)
|
||||
.expect("styles"),
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Strategy matrix: small (with prefix) + session-scale (prod paths only).
|
||||
fn bench_matrix(c: &mut Criterion) {
|
||||
let theme = Theme::current();
|
||||
let config = DiffRenderConfig::default();
|
||||
let syntect = get_syntect();
|
||||
|
||||
// ── 500L + prefix (own heavy config) ────────────────────────────────────
|
||||
{
|
||||
let fx = gen_python_fixture(500, 8);
|
||||
let file_lines: Vec<&str> = fx.file_text.lines().collect();
|
||||
let mut group = c.benchmark_group("edit_hl/matrix");
|
||||
configure_heavy(&mut group);
|
||||
group.throughput(Throughput::Bytes(fx.bytes()));
|
||||
register_prod_strategies(&mut group, &fx, &theme, &config);
|
||||
group.bench_function(BenchmarkId::new("prefix_per_hunk", &fx.label), |b| {
|
||||
b.iter(|| {
|
||||
black_box(highlight_prefix_per_hunk(
|
||||
syntect,
|
||||
fx.path(),
|
||||
black_box(&file_lines),
|
||||
black_box(&fx.hunks),
|
||||
))
|
||||
});
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ── 10kL session (no prefix) ────────────────────────────────────────────
|
||||
{
|
||||
let fx = gen_python_fixture(10_000, 40);
|
||||
let styles = compute_file_scoped_styles(fx.path(), &fx.file_text, &fx.hunks)
|
||||
.expect("file-scoped styles for 10k fixture");
|
||||
eprintln!(
|
||||
"[memory] {} style map ≈ {:.1} KiB ({} lines retained; file {:.1} KiB)",
|
||||
fx.label,
|
||||
estimate_style_map_bytes(&styles) as f64 / 1024.0,
|
||||
styles.len(),
|
||||
fx.bytes() as f64 / 1024.0,
|
||||
);
|
||||
|
||||
let mut group = c.benchmark_group("edit_hl/matrix");
|
||||
configure_heavy(&mut group);
|
||||
group.throughput(Throughput::Bytes(fx.bytes()));
|
||||
register_prod_strategies(&mut group, &fx, &theme, &config);
|
||||
group.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Amortized upgrade vs cold first paint on a session-scale fixture.
|
||||
fn bench_upgrade(c: &mut Criterion) {
|
||||
let fx = gen_python_fixture(10_000, 40);
|
||||
let theme = Theme::current();
|
||||
let config = DiffRenderConfig::default();
|
||||
let styles =
|
||||
compute_file_scoped_styles(fx.path(), &fx.file_text, &fx.hunks).expect("upgrade styles");
|
||||
|
||||
let mut group = c.benchmark_group("edit_hl/upgrade");
|
||||
configure_fast(&mut group);
|
||||
group.throughput(Throughput::Elements(fx.n_hunks() as u64));
|
||||
|
||||
// Steady-state after upgrade: the shared hunk walker (per-hunk syntect,
|
||||
// same as cold) plus the map overlay for Equal/Insert lines.
|
||||
group.bench_function(BenchmarkId::new("paint_with_precomputed", &fx.label), |b| {
|
||||
b.iter(|| {
|
||||
black_box(render_diff_hunks_with_styles(
|
||||
black_box(&fx.hunks),
|
||||
fx.path(),
|
||||
black_box(&styles),
|
||||
&theme,
|
||||
RENDER_WIDTH,
|
||||
&config,
|
||||
))
|
||||
});
|
||||
});
|
||||
|
||||
// One-shot job cost: compute + paint in the same timed iteration.
|
||||
group.bench_function(BenchmarkId::new("upgrade_once_per_file", &fx.label), |b| {
|
||||
b.iter(|| {
|
||||
let map = compute_file_scoped_styles(
|
||||
fx.path(),
|
||||
black_box(&fx.file_text),
|
||||
black_box(&fx.hunks),
|
||||
)
|
||||
.expect("styles");
|
||||
black_box(render_diff_hunks_with_styles(
|
||||
black_box(&fx.hunks),
|
||||
fx.path(),
|
||||
&map,
|
||||
&theme,
|
||||
RENDER_WIDTH,
|
||||
&config,
|
||||
))
|
||||
});
|
||||
});
|
||||
|
||||
// Cold control: first paint stays hunk-only.
|
||||
group.bench_function(BenchmarkId::new("hunk_only", &fx.label), |b| {
|
||||
b.iter(|| {
|
||||
black_box(render_diff_hunks_highlighted(
|
||||
black_box(&fx.hunks),
|
||||
fx.path(),
|
||||
&theme,
|
||||
RENDER_WIDTH,
|
||||
&config,
|
||||
))
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_matrix, bench_upgrade);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Criterion benchmarks for the kigi-tui rendering pipeline.
|
||||
//!
|
||||
//! Measures the per-frame cost of rendering a rich markdown document
|
||||
//! into a ratatui `Buffer`. This isolates the render hot path (entry
|
||||
//! rendering, scratch buffer copies, layout computation) from one-time
|
||||
//! setup (markdown parsing, syntax highlighting, word wrapping).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use kigi_tui::appearance::AppearanceConfig;
|
||||
use kigi_tui::render::Renderable;
|
||||
use kigi_tui::scrollback::entry::ScrollbackEntry;
|
||||
use kigi_tui::scrollback::render::render_scrolled_entries_with_scratch;
|
||||
use kigi_tui::scrollback::wrappers::EntryRenderer;
|
||||
use kigi_tui::scrollback::{
|
||||
EntryId, EntryLayoutInfo, HorizontalLayout, RenderBlock, ScrollbackState,
|
||||
};
|
||||
use kigi_tui::theme::Theme;
|
||||
|
||||
static BENCH_MD: &str = include_str!("bench.md");
|
||||
|
||||
/// Viewport dimensions for the benchmark.
|
||||
const VIEWPORT_WIDTH: u16 = 120;
|
||||
const VIEWPORT_HEIGHT: u16 = 50;
|
||||
|
||||
/// How many lines to advance per step in full_scroll.
|
||||
const SCROLL_STEP: u16 = 10;
|
||||
|
||||
/// Entry count for the reveal benchmarks. Approximates the ~3,200-entry
|
||||
/// scrollback of a long real session (~5 MB of searchable text).
|
||||
const REVEAL_ENTRIES: usize = 3000;
|
||||
|
||||
/// Build the entries used by every benchmark iteration.
|
||||
///
|
||||
/// This is called once in the setup closure so that markdown parsing,
|
||||
/// syntax highlighting, and word-wrap caching are **not** measured.
|
||||
fn build_entries() -> Vec<ScrollbackEntry> {
|
||||
vec![
|
||||
// A user prompt to exercise multi-entry layout
|
||||
ScrollbackEntry::new(RenderBlock::user_prompt(
|
||||
"Explain the rendering pipeline architecture in detail",
|
||||
)),
|
||||
// The main payload — a large markdown agent response
|
||||
ScrollbackEntry::new(RenderBlock::agent_message(BENCH_MD)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Pre-compute entry layout info (same as prepare_layout does in production).
|
||||
fn compute_layouts(
|
||||
entries: &[ScrollbackEntry],
|
||||
appearance: &AppearanceConfig,
|
||||
) -> Vec<EntryLayoutInfo> {
|
||||
let theme = Theme::current();
|
||||
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
let layout = HorizontalLayout::new(viewport, &appearance.scrollback.layout);
|
||||
let entry_width = layout.entry_content_area().width;
|
||||
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let height = EntryRenderer::new(e, &theme)
|
||||
.with_appearance(appearance.clone())
|
||||
.desired_height(entry_width);
|
||||
EntryLayoutInfo {
|
||||
height,
|
||||
gap_after: 1,
|
||||
group_header_count: 0,
|
||||
group_collapse_header: false,
|
||||
verb_group_header: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Benchmarks ────────────────────────────────────────────────────
|
||||
|
||||
/// Render a single frame at scroll offset 0 (top of document).
|
||||
///
|
||||
/// The per-frame baseline with no top clipping — only bottom-clipped.
|
||||
fn bench_single_frame(c: &mut Criterion) {
|
||||
let entries = build_entries();
|
||||
let entry_refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
let theme = Theme::current();
|
||||
let appearance = AppearanceConfig::default();
|
||||
let layouts = compute_layouts(&entries, &appearance);
|
||||
|
||||
// Prime the wrap cache
|
||||
{
|
||||
let mut buf = Buffer::empty(viewport);
|
||||
render_scrolled_entries_with_scratch(
|
||||
&mut buf,
|
||||
viewport,
|
||||
&entry_refs,
|
||||
0,
|
||||
None,
|
||||
&theme,
|
||||
&appearance,
|
||||
&layouts,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
c.bench_function("render/single_frame", |b| {
|
||||
let mut buf = Buffer::empty(viewport);
|
||||
b.iter(|| {
|
||||
buf.reset();
|
||||
render_scrolled_entries_with_scratch(
|
||||
&mut buf,
|
||||
viewport,
|
||||
&entry_refs,
|
||||
0,
|
||||
None,
|
||||
&theme,
|
||||
&appearance,
|
||||
&layouts,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Scroll through the entire document, stepping by SCROLL_STEP lines.
|
||||
///
|
||||
/// Simulates a user paging through the document, exercising the full
|
||||
/// render pipeline at many offsets including partial entry rendering.
|
||||
fn bench_full_scroll(c: &mut Criterion) {
|
||||
let entries = build_entries();
|
||||
let entry_refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
||||
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
let theme = Theme::current();
|
||||
let appearance = AppearanceConfig::default();
|
||||
let layouts = compute_layouts(&entries, &appearance);
|
||||
|
||||
// usize: scroll offset is usize in the render path.
|
||||
let total: usize = layouts
|
||||
.iter()
|
||||
.map(|l| l.height as usize + l.gap_after as usize)
|
||||
.sum(); // heights + gaps
|
||||
let max_scroll = total.saturating_sub(VIEWPORT_HEIGHT as usize);
|
||||
|
||||
// Prime the wrap cache
|
||||
{
|
||||
let mut buf = Buffer::empty(viewport);
|
||||
render_scrolled_entries_with_scratch(
|
||||
&mut buf,
|
||||
viewport,
|
||||
&entry_refs,
|
||||
0,
|
||||
None,
|
||||
&theme,
|
||||
&appearance,
|
||||
&layouts,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
c.bench_function("render/full_scroll", |b| {
|
||||
let mut buf = Buffer::empty(viewport);
|
||||
b.iter(|| {
|
||||
let mut offset = 0usize;
|
||||
while offset <= max_scroll {
|
||||
buf.reset();
|
||||
render_scrolled_entries_with_scratch(
|
||||
&mut buf,
|
||||
viewport,
|
||||
&entry_refs,
|
||||
offset,
|
||||
None,
|
||||
&theme,
|
||||
&appearance,
|
||||
&layouts,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
offset += SCROLL_STEP as usize;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Scroll through a large scrollback the way production does: per step,
|
||||
/// locate the paint window via `ScrollbackState::paint_window` (partition
|
||||
/// point over the cached virtual-y prefix sum) and render only that slice
|
||||
/// with `content_y0`/`entry_index_base` — mirroring
|
||||
/// `ScrollbackPane::render_content`. `full_scroll` above measures the
|
||||
/// renderer's full-list walk; this measures the shipped windowed path, so
|
||||
/// regressions in the window computation show up here.
|
||||
fn bench_windowed_scroll(c: &mut Criterion) {
|
||||
let (state, _think_id) = build_reveal_state();
|
||||
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
let theme = Theme::current();
|
||||
let n = state.len();
|
||||
let virtual_y = state.get_cached_virtual_y().expect("layout cache");
|
||||
let layouts = state.get_cached_entry_layouts().expect("layout cache");
|
||||
let total: usize =
|
||||
virtual_y[n - 1] + layouts[n - 1].height as usize + layouts[n - 1].gap_after as usize;
|
||||
let max_scroll = total.saturating_sub(VIEWPORT_HEIGHT as usize);
|
||||
|
||||
let mut g = c.benchmark_group("render");
|
||||
// Each iteration pages through the whole corpus; cap samples to keep the
|
||||
// run short (same treatment as reveal/navigate_rebuild).
|
||||
g.sample_size(10).warm_up_time(Duration::from_millis(500));
|
||||
g.bench_function("windowed_scroll", |b| {
|
||||
let mut buf = Buffer::empty(viewport);
|
||||
b.iter(|| {
|
||||
let mut offset = 0usize;
|
||||
while offset <= max_scroll {
|
||||
buf.reset();
|
||||
let (paint_range, content_y0) =
|
||||
state.paint_window(0..n, offset, VIEWPORT_HEIGHT as usize);
|
||||
let window = state.entries_in_range(paint_range.clone());
|
||||
render_scrolled_entries_with_scratch(
|
||||
&mut buf,
|
||||
viewport,
|
||||
&window,
|
||||
offset,
|
||||
None,
|
||||
&theme,
|
||||
state.appearance(),
|
||||
&layouts[paint_range.clone()],
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
content_y0,
|
||||
paint_range.start,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
offset += SCROLL_STEP as usize;
|
||||
}
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
// ─── Reveal (scrollback-search n/N navigation) ─────────────────────
|
||||
|
||||
/// One paragraph of lorem-style body per entry (~1.7 KB), so the
|
||||
/// `REVEAL_ENTRIES`-entry corpus is on the order of the motivating session's
|
||||
/// searchable text (a few MB; the exact size is logged).
|
||||
fn reveal_body(i: usize) -> String {
|
||||
let lorem = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do \
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua ut \
|
||||
enim ad minim veniam quis nostrud exercitation ullamco laboris ";
|
||||
format!(
|
||||
"Entry {i}: the quick brown fox jumps over the lazy dog. {} done.",
|
||||
lorem.repeat(9)
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a large scrollback approximating a long search session, returning it
|
||||
/// with the `EntryId` of a thinking block placed in the middle (the rebuild
|
||||
/// bench dirties that entry's height to force reveal's rebuild branch).
|
||||
fn build_reveal_state() -> (ScrollbackState, EntryId) {
|
||||
let mut state = ScrollbackState::new();
|
||||
let mut total_bytes = 0usize;
|
||||
let middle = REVEAL_ENTRIES / 2;
|
||||
let mut think_id = None;
|
||||
for i in 0..REVEAL_ENTRIES {
|
||||
if i == middle {
|
||||
let body = "reasoning about the matched entry and the nearby context";
|
||||
total_bytes += body.len();
|
||||
think_id = Some(state.push_block(RenderBlock::thinking(body)));
|
||||
} else {
|
||||
let body = reveal_body(i);
|
||||
total_bytes += body.len();
|
||||
state.push_block(RenderBlock::user_prompt(body));
|
||||
}
|
||||
}
|
||||
// ~1-2 KB prompts are foldable, so they default to Collapsed; expand them so
|
||||
// a reveal over an already-visible match doesn't change display state — the
|
||||
// steady n/N case the skip path optimizes. (Setup only; not measured.)
|
||||
state.expand_all();
|
||||
// Settle the layout cache and clear dirty heights so the measured reveals
|
||||
// start from a clean cache.
|
||||
state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
eprintln!(
|
||||
"reveal corpus: {REVEAL_ENTRIES} entries, ~{:.1} MB text",
|
||||
total_bytes as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
(state, think_id.expect("thinking block pushed"))
|
||||
}
|
||||
|
||||
/// New per-`n` cost: revealing an already-visible match reuses the settled
|
||||
/// cache and only refreshes the cheap total-height sum — no O(history) rebuild.
|
||||
fn bench_reveal_skip(c: &mut Criterion) {
|
||||
let (mut state, _think_id) = build_reveal_state();
|
||||
c.bench_function("reveal/navigate_skip", |b| {
|
||||
let mut i = 0usize;
|
||||
b.iter(|| {
|
||||
state.reveal_entry_line(i % REVEAL_ENTRIES, 0);
|
||||
i += 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Old per-`n` cost: the pre-fix reveal ran a full layout rebuild unconditionally.
|
||||
/// The O(1) `push_chunk_to_thinking` dirties one entry's height, negligible next
|
||||
/// to the O(N) `rebuild_layout` it forces reveal to take on every call.
|
||||
fn bench_reveal_rebuild(c: &mut Criterion) {
|
||||
let (mut state, think_id) = build_reveal_state();
|
||||
let mut g = c.benchmark_group("reveal");
|
||||
// Each iteration rebuilds the whole layout (O(N)); cap samples so the run
|
||||
// doesn't take minutes.
|
||||
g.sample_size(20).warm_up_time(Duration::from_millis(500));
|
||||
g.bench_function("navigate_rebuild", |b| {
|
||||
let mut i = 0usize;
|
||||
b.iter(|| {
|
||||
state.push_chunk_to_thinking(think_id, "x");
|
||||
state.reveal_entry_line(i % REVEAL_ENTRIES, 0);
|
||||
i += 1;
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_single_frame,
|
||||
bench_full_scroll,
|
||||
bench_windowed_scroll,
|
||||
bench_reveal_skip,
|
||||
bench_reveal_rebuild
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Criterion benchmarks for scrollback search.
|
||||
//!
|
||||
//! - `scan` measures the raw regex scan over a large corpus — the work that ran
|
||||
//! synchronously on the input thread on every keystroke before the background
|
||||
//! daemon, and now runs off-thread.
|
||||
//! - `query_steady` / `query_cold` measure the UI-thread cost of `update_query`
|
||||
//! after the daemon change: a steady keystroke only compiles the matcher and
|
||||
//! enqueues the query (the scan is off-thread), while the cold path also
|
||||
//! rebuilds and ships the corpus on a content change.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
|
||||
|
||||
use kigi_tui::scrollback::{
|
||||
RenderBlock, ScrollbackSearchIndex, ScrollbackSearchState, ScrollbackState,
|
||||
};
|
||||
use kigi_tui::search::{QueryKind, TextMatcher};
|
||||
|
||||
/// Roughly the entry count of a long working session.
|
||||
const CORPUS_ENTRIES: usize = 30_000;
|
||||
|
||||
/// Each measured iteration scans (or rebuilds) the whole corpus, so cap the
|
||||
/// sample count — criterion's default 100 would run for minutes.
|
||||
const SAMPLE_SIZE: usize = 10;
|
||||
|
||||
/// One paragraph of body text per entry, so the whole corpus is on the order of
|
||||
/// a long session's searchable text (tens of MB; the exact size is logged).
|
||||
fn entry_body(i: usize) -> String {
|
||||
let lorem = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do \
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua ut \
|
||||
enim ad minim veniam quis nostrud exercitation ullamco laboris ";
|
||||
format!(
|
||||
"Entry {i}: the quick brown fox jumps over the lazy dog. {lorem}{lorem}{lorem} \
|
||||
function foo_{i} calls bar_{i} and returns baz_{i} after work."
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a large scrollback approximating a long session.
|
||||
fn build_large_scrollback(entries: usize) -> ScrollbackState {
|
||||
let mut state = ScrollbackState::new();
|
||||
let mut total_bytes = 0usize;
|
||||
for i in 0..entries {
|
||||
let body = entry_body(i);
|
||||
total_bytes += body.len();
|
||||
state.push_block(RenderBlock::user_prompt(body));
|
||||
}
|
||||
eprintln!(
|
||||
"scrollback search corpus: {entries} entries, ~{:.1} MB searchable text",
|
||||
total_bytes as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The regex scan itself — the work the daemon now runs off the input thread
|
||||
/// (previously this ran synchronously per keystroke). `fox` appears in every
|
||||
/// entry, the worst case for match collection.
|
||||
fn bench_scan(c: &mut Criterion) {
|
||||
let state = build_large_scrollback(CORPUS_ENTRIES);
|
||||
let mut index = ScrollbackSearchIndex::new();
|
||||
index.sync(&state);
|
||||
let matcher = TextMatcher::new("fox", QueryKind::Regex);
|
||||
|
||||
let mut g = c.benchmark_group("search");
|
||||
g.sample_size(SAMPLE_SIZE)
|
||||
.warm_up_time(Duration::from_secs(1));
|
||||
g.bench_function("scan", |b| {
|
||||
b.iter(|| black_box(index.find(black_box(&matcher))));
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
/// Steady keystroke after the daemon: the corpus is already shipped, so
|
||||
/// `update_query` just compiles the matcher and enqueues the query, and `poll`
|
||||
/// picks up the async result — no scan on the UI thread.
|
||||
fn bench_query_steady(c: &mut Criterion) {
|
||||
let state = build_large_scrollback(CORPUS_ENTRIES);
|
||||
let mut search = ScrollbackSearchState::open();
|
||||
// Ship the corpus once so the measured calls only enqueue (content unchanged).
|
||||
search.update_query("warmup", &state);
|
||||
|
||||
let queries = ["fox", "baz", "lorem", "function"];
|
||||
let mut g = c.benchmark_group("search");
|
||||
g.sample_size(SAMPLE_SIZE)
|
||||
.warm_up_time(Duration::from_secs(1));
|
||||
g.bench_function("query_steady", |b| {
|
||||
let mut n = 0usize;
|
||||
b.iter(|| {
|
||||
let q = queries[n % queries.len()];
|
||||
n += 1;
|
||||
search.update_query(q, &state);
|
||||
search.poll();
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
/// Cold path: a fresh session, so `update_query` also rebuilds and ships the
|
||||
/// corpus (the per-entry searchable-text cache) before enqueueing the query.
|
||||
fn bench_query_cold(c: &mut Criterion) {
|
||||
let state = build_large_scrollback(CORPUS_ENTRIES);
|
||||
let mut g = c.benchmark_group("search");
|
||||
g.sample_size(SAMPLE_SIZE)
|
||||
.warm_up_time(Duration::from_secs(1));
|
||||
g.bench_function("query_cold", |b| {
|
||||
b.iter_batched(
|
||||
ScrollbackSearchState::open,
|
||||
|mut search| {
|
||||
search.update_query("fox", &state);
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_scan, bench_query_steady, bench_query_cold);
|
||||
criterion_main!(benches);
|
||||
Reference in New Issue
Block a user