9.3 perf — gate StyleSpans recompute on cursor-only ticks

Root cause of "slow only when the GUI is attached": the daemon's
single-threaded dispatcher loop runs the semantic frontend's
render_frame every tick, and `scoped_style_spans` runs the tree-sitter
highlights query over the *whole declared viewport* — which the GPU
frontend sets to the entire buffer — plus clones the theme, on EVERY
tick. Since render_frame recomputes the projection to diff it, every
TUI keystroke forced a full-file tree-sitter query in the daemon
before TUI input could be serviced. Smooth without the GUI; the
attached semantic frontend is what loads the loop.

Fix: a recompute gate. Style spans for a grammar-backed buffer are a
pure function of (parse bundle, CRDT generation, viewport) — never the
cursor — so a cursor-only tick can skip the query and the diff
entirely. `StyleGate` holds the current parse bundle `Arc` (kept alive
so its address is stable; compared via `Arc::ptr_eq`, immune to the
ABA a raw-pointer compare would hit) plus generation + viewport.
`render_frame` skips `emit_style_spans` when the gate matches the
last one and a baseline was already sent.

Correctness:
- Edit → generation bumps → gate differs → recompute → full=true
  resync preserved (M11.7).
- Async reparse lands → bundle Arc changes → gate differs → recompute
  → incremental emit. The fresh parse is never missed.
- Cursor move → bundle, generation, viewport all unchanged → skip.
- LSP-token path (no grammar, e.g. C/C++) has no cheap bundle handle,
  so `grammar_style_key` returns None and that path recomputes every
  tick exactly as before — no behavior change, no new staleness.

`emit_style_spans` is the former inline StyleSpans block extracted
verbatim so the gate can wrap it.

Combined with the earlier scoped_decorations single-materialization
fix, the per-tick daemon cost for an idle (cursor-only) semantic
frontend drops from "full-file tree-sitter query + theme clone + 2
rope copies" to "one rope copy for the current-line/diagnostic
decoration set."

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings
- cargo clippy --all-targets --workspace --features crdt -- -D warnings
- pmacs lib 1329 + pmacs-protocol 11; semantic_render unit 33
- m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-29 09:39:57 -04:00
parent 8e29e8b90b
commit 965cdd9560
1 changed files with 150 additions and 55 deletions

View File

@ -114,6 +114,46 @@ pub struct SemanticRenderState {
/// already has and emits nothing. First emission happens on the
/// first frame for a buffer; further emissions only after edits.
last_summary: HashMap<BufferId, u64>,
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
/// the tree-sitter highlights query over the *whole declared
/// viewport* (which the GPU frontend sets to the entire buffer)
/// and clones the theme — too expensive to repeat on every tick.
/// The styling depends only on the parse bundle, the CRDT
/// generation, and the viewport — never the cursor — so a gate
/// built from those lets cursor-only ticks skip the query entirely.
/// Only the grammar (tree-sitter) path is gated; the LSP-token path
/// has no comparably cheap handle and recomputes as before.
last_style_gate: HashMap<BufferId, StyleGate>,
}
/// Recompute gate for [`scoped_style_spans`] on a grammar-backed
/// buffer. Holds the current parse bundle `Arc` so its address stays
/// stable while cached — comparing by `Arc::ptr_eq` then can't be
/// fooled by a freed bundle's address being reused (ABA). Equal gates
/// ⇒ identical spans ⇒ the tree-sitter query can be skipped.
/// `generation` is included so a CRDT edit still forces the M11.7
/// full-resync even when the parse bundle hasn't re-landed yet.
#[derive(Clone)]
struct StyleGate {
/// Current parse bundle, or `None` when none has landed yet.
bundle: Option<std::sync::Arc<crate::syntax::ParseTreeBundle>>,
/// CRDT generation of the buffer.
generation: u64,
/// Declared viewport.
visible: ByteRange,
}
impl StyleGate {
/// True when both gates would produce identical style spans.
fn matches(&self, other: &Self) -> bool {
self.generation == other.generation
&& self.visible == other.visible
&& match (&self.bundle, &other.bundle) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
}
}
impl SemanticRenderState {
@ -128,6 +168,7 @@ impl SemanticRenderState {
last_decorations: HashMap::new(),
last_adornments: HashMap::new(),
last_summary: HashMap::new(),
last_style_gate: HashMap::new(),
}
}
@ -172,63 +213,31 @@ impl SemanticRenderState {
let mut out = Vec::new();
// --- StyleSpans (T M11.2 producer, T M11.4 diff) ---
let spans = scoped_style_spans(state, &vp);
let prev = self.last_sent.get(&vp.buffer_id);
// Resync when there is no baseline, the declared viewport
// region moved (scoping window changed), OR the CRDT
// generation advanced (T M11.7: text edits shift byte
// positions, so prior spans are stale and incremental
// updates can't restore the full viewport — see
// `LastFrame`'s doc comment).
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
if full {
// The first frame for this buffer/viewport. One segment
// covering the declared viewport carries the whole scoped
// set (possibly empty → frontend clears the viewport).
self.last_sent.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: spans.clone(),
generation,
},
);
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
full: true,
segments: vec![StyleSegment {
range: vp.visible,
spans,
}],
});
// Perf gate: `scoped_style_spans` runs the tree-sitter query
// over the whole viewport + clones the theme. For a grammar-
// backed buffer it's a pure function of (bundle revision,
// generation, viewport), so a cursor-only tick — same key,
// already-sent baseline — can skip the whole block. The LSP-
// token path returns `None` (no cheap revision) and recomputes
// every tick as before.
let style_gate = grammar_style_key(state, &vp, generation);
let style_unchanged = match (&style_gate, self.last_style_gate.get(&vp.buffer_id)) {
(Some(g), Some(prev)) => g.matches(prev) && self.last_sent.contains_key(&vp.buffer_id),
_ => false,
};
if style_unchanged {
// Styling cannot have changed since the last computation;
// emit nothing and skip the query.
} else {
let prev = prev.expect("checked is_none_or above");
let intervals = changed_intervals(&prev.items, &spans, |s| s.range);
if !intervals.is_empty() {
let segments = intervals
.into_iter()
.map(|range| StyleSegment {
range,
spans: clip_style_spans(range, &spans),
})
.collect();
self.last_sent.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: spans,
generation,
},
);
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
full: false,
segments,
});
match style_gate {
Some(g) => {
self.last_style_gate.insert(vp.buffer_id, g);
}
None => {
self.last_style_gate.remove(&vp.buffer_id);
}
}
// No dirty interval → styling unchanged → emit nothing.
self.emit_style_spans(state, &vp, generation, &mut out);
}
// --- Decorations (T M11.3 producer, T M11.4 diff) ---
@ -368,6 +377,73 @@ impl SemanticRenderState {
/// frontend already owns (it has `CursorByte`) — emitting it would
/// couple a visual-motion concern to the instance, against the
/// contract boundary.
/// Compute the scoped style spans and push a `StyleSpans` message
/// (full resync or M11.4 incremental) when they differ from the
/// last sent baseline. Extracted from `render_frame` so the perf
/// gate there can skip it wholesale on unchanged ticks.
fn emit_style_spans(
&mut self,
state: &EditorState,
vp: &DeclaredViewport,
generation: u64,
out: &mut Vec<InstanceMessage>,
) {
let spans = scoped_style_spans(state, vp);
let prev = self.last_sent.get(&vp.buffer_id);
// Resync when there is no baseline, the declared viewport
// region moved (scoping window changed), OR the CRDT
// generation advanced (T M11.7: text edits shift byte
// positions, so prior spans are stale and incremental
// updates can't restore the full viewport).
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
if full {
self.last_sent.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: spans.clone(),
generation,
},
);
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
full: true,
segments: vec![StyleSegment {
range: vp.visible,
spans,
}],
});
} else {
let prev = prev.expect("checked is_none_or above");
let intervals = changed_intervals(&prev.items, &spans, |s| s.range);
if !intervals.is_empty() {
let segments = intervals
.into_iter()
.map(|range| StyleSegment {
range,
spans: clip_style_spans(range, &spans),
})
.collect();
self.last_sent.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: spans,
generation,
},
);
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
full: false,
segments,
});
}
// No dirty interval → styling unchanged → emit nothing.
}
}
fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec<Decoration> {
let core = state.core.borrow();
let registry = core.registry.clone();
@ -735,6 +811,25 @@ fn line_col_to_byte(line_starts: &[u64], source_len: u64, line: u32, col: u32) -
(line_start + u64::from(col)).min(line_end).min(source_len)
}
/// Cheap recompute-gate key for [`scoped_style_spans`] on a grammar-
/// backed buffer. Returns `None` for buffers with no tree-sitter view
/// (the LSP-token path), which has no comparably cheap revision handle
/// and therefore is never gated. `bundle.source_revision` is read via
/// the same `current()` accessor `scoped_style_spans` uses, so the key
/// flips exactly when the spans it would produce can change.
fn grammar_style_key(
state: &EditorState,
vp: &DeclaredViewport,
generation: u64,
) -> Option<StyleGate> {
let handle = state.syntax_registry.view(vp.buffer_id)?;
Some(StyleGate {
bundle: handle.current(),
generation,
visible: vp.visible,
})
}
/// Compute the styled byte runs intersecting the declared viewport,
/// mapped through the active theme. Spans are clipped to the viewport
/// and to the parsed source length; runs that resolve to the default