Files
zed/crates/language_core/src/highlight_cache.rs
T
Kirill Bulatov 7265ff479a language: Cache buffer syntax highlighting per row chunk (#63145)
Closes https://github.com/zed-industries/zed/issues/63730
Related to #52340 and #43460
Based on https://github.com/zed-industries/zed/pull/63138 and needs it
to be merged first.

`BufferSnapshot::chunks` re-ran the tree-sitter highlight query over the
requested range on every call, so scrolling and cursor movement paid the
full query cost per frame.

Highlight runs are now computed once per 50-row chunk (the same
`RowChunks` that back bracket colorization), resolved against the
current `HighlightMap` at compute time, merged into `(Range<usize>,
HighlightId)` runs, and kept in a cost-budgeted per-buffer LRU (10MB).
Entries reuse the `ResolvedHighlights` representation from #63138: each
stores the grammars and maps it was resolved against and is served only
while every map is pointer-identical (`is_current`), so theme changes
and grammar reloads invalidate structurally, with no invalidation hooks
and no global state.
Edits and reparses drop the cache through the existing `TreeSitterData`
swap.
Chunks spanning more than 64KB bypass the cache and fall back to direct
querying, so files with giant lines behave as before.

## Benchmarks

`editor_render_highlighted` and `editor_render_highlighted_minimap`
benches (`cargo bench -p benchmarks --bench editor_render`) move the
cursor through a generated 10K-line Rust file with real tree-sitter
highlighting, with and without the minimap.
The benches are new in this PR, so the baseline is the same binary with
`ZED_DISABLE_HIGHLIGHT_CACHE=1`, which routes rendering through the
pre-existing direct-query path.

Run 1 — baseline, cache disabled:

```
editor_render_highlighted          time: [1.7164 ms 1.7377 ms 1.7604 ms]
editor_render_highlighted_minimap  time: [12.273 ms 12.407 ms 12.549 ms]
```

Run 2 — cache enabled:

```
editor_render_highlighted          time: [1.4572 ms 1.4597 ms 1.4627 ms]
                                   change: [-16.378% -14.944% -13.526%] (p = 0.00 < 0.05)
editor_render_highlighted_minimap  time: [9.2750 ms 9.3213 ms 9.3744 ms]
                                   change: [-25.800% -24.872% -23.930%] (p = 0.00 < 0.05)
```

Over the same runs, GPUI reports window draw mean 0.81 ms → 0.68 ms and
p99 1.06 ms → 0.76 ms without the minimap, and mean 5.14 ms → 4.12 ms,
p99 6.12 ms → 4.75 ms with it.
The relative deltas held across two independent sessions (-14.1…-14.9%
and -20.7…-24.9%), while absolute times drift a few percent with machine
state.

Release Notes:

- Improved editor rendering performance in syntax-highlighted files,
especially with the minimap enabled
2026-09-09 14:31:14 +00:00

340 lines
10 KiB
Rust

use crate::grammar::Grammar;
use crate::highlight_map::{HighlightId, HighlightMap};
use collections::FxHasher;
use lru::LruCache;
use parking_lot::Mutex;
use smallvec::SmallVec;
use std::{
fmt,
hash::{Hash, Hasher as _},
ops::Range,
sync::Arc,
};
pub const MAX_TEXT_HIGHLIGHT_ENTRY_BYTES: usize = MAX_TEXT_HIGHLIGHT_CACHE_BYTES / 8;
const MAX_TEXT_HIGHLIGHT_CACHE_BYTES: usize = 4 * 1024 * 1024;
const MAX_CHUNK_HIGHLIGHT_CACHE_BYTES: usize = 10 * 1024 * 1024;
const APPROXIMATE_LRU_NODE_BYTES: usize = 4 * size_of::<usize>();
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TextHighlightKey {
text_hash: u64,
text_len: usize,
}
impl TextHighlightKey {
pub fn new<'a>(text_chunks: impl Iterator<Item = &'a str>, text_len: usize) -> Self {
let mut hasher = FxHasher::default();
for chunk in text_chunks {
hasher.write(chunk.as_bytes());
}
Self {
text_hash: hasher.finish(),
text_len,
}
}
}
#[derive(Clone, Default)]
pub struct ResolvedHighlights {
pub sources: SmallVec<[(Arc<Grammar>, HighlightMap); 2]>,
pub runs: Arc<[(Range<usize>, HighlightId)]>,
}
impl ResolvedHighlights {
pub fn is_current(&self) -> bool {
self.sources
.iter()
.all(|(grammar, highlight_map)| grammar.highlight_map_matches(highlight_map))
}
pub fn cost_bytes(&self) -> usize {
self.sources.len() * size_of::<(Arc<Grammar>, HighlightMap)>()
+ self.runs.len() * size_of::<(Range<usize>, HighlightId)>()
}
}
impl fmt::Debug for ResolvedHighlights {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResolvedHighlights")
.field("runs", &self.runs)
.finish_non_exhaustive()
}
}
pub struct TextHighlightCache(Mutex<CostBudgetedLru<TextHighlightKey, TextHighlightEntry>>);
impl Default for TextHighlightCache {
fn default() -> Self {
Self(Mutex::new(CostBudgetedLru::new(
MAX_TEXT_HIGHLIGHT_CACHE_BYTES,
MAX_TEXT_HIGHLIGHT_ENTRY_BYTES,
)))
}
}
impl TextHighlightCache {
pub fn get<'a>(
&self,
key: &TextHighlightKey,
text_chunks: impl Iterator<Item = &'a str>,
) -> Option<ResolvedHighlights> {
let mut cache = self.0.lock();
let entry = cache.get(key)?;
if !entry.highlights.is_current() {
return None;
}
if !chunks_match_text(&entry.text, text_chunks) {
return None;
}
Some(entry.highlights.clone())
}
pub fn insert(
&self,
key: TextHighlightKey,
text: Arc<str>,
highlights: ResolvedHighlights,
) -> ResolvedHighlights {
let cost = text.len() + highlights.cost_bytes();
self.0.lock().insert(
key,
TextHighlightEntry {
text,
highlights: highlights.clone(),
},
cost,
);
highlights
}
}
pub struct ChunkHighlightCache(Mutex<CostBudgetedLru<usize, ResolvedHighlights>>);
impl Default for ChunkHighlightCache {
fn default() -> Self {
Self(Mutex::new(CostBudgetedLru::new(
MAX_CHUNK_HIGHLIGHT_CACHE_BYTES,
MAX_CHUNK_HIGHLIGHT_CACHE_BYTES,
)))
}
}
impl fmt::Debug for ChunkHighlightCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChunkHighlightCache")
.finish_non_exhaustive()
}
}
impl ChunkHighlightCache {
pub fn get(&self, chunk_id: usize) -> Option<ResolvedHighlights> {
let mut lru = self.0.lock();
let highlights = lru.get(&chunk_id)?;
if !highlights.is_current() {
return None;
}
Some(highlights.clone())
}
pub fn insert(&self, chunk_id: usize, highlights: ResolvedHighlights) {
let cost = highlights.cost_bytes();
self.0.lock().insert(chunk_id, highlights, cost);
}
pub fn clear(&mut self) {
self.0.get_mut().clear();
}
}
struct CostBudgetedLru<K: Hash + Eq, V> {
entries: LruCache<K, (V, usize)>,
total_cost: usize,
max_total_cost: usize,
max_entry_cost: usize,
}
impl<K: Hash + Eq, V> CostBudgetedLru<K, V> {
const ENTRY_OVERHEAD_BYTES: usize =
size_of::<K>() + size_of::<(V, usize)>() + APPROXIMATE_LRU_NODE_BYTES;
fn new(max_total_cost: usize, max_entry_cost: usize) -> Self {
Self {
entries: LruCache::unbounded(),
total_cost: 0,
max_total_cost,
max_entry_cost,
}
}
fn get(&mut self, key: &K) -> Option<&V> {
self.entries.get(key).map(|(value, _)| value)
}
fn insert(&mut self, key: K, value: V, cost: usize) {
if cost > self.max_entry_cost {
return;
}
let cost = cost + Self::ENTRY_OVERHEAD_BYTES;
if let Some((_, old_cost)) = self.entries.put(key, (value, cost)) {
self.total_cost -= old_cost;
}
self.total_cost += cost;
while self.total_cost > self.max_total_cost {
let Some((_, (_, evicted_cost))) = self.entries.pop_lru() else {
break;
};
self.total_cost -= evicted_cost;
}
}
fn clear(&mut self) {
self.entries.clear();
self.total_cost = 0;
}
}
struct TextHighlightEntry {
text: Arc<str>,
highlights: ResolvedHighlights,
}
fn chunks_match_text<'a>(text: &str, text_chunks: impl Iterator<Item = &'a str>) -> bool {
let mut remaining = text;
for chunk in text_chunks {
let Some(rest) = remaining.strip_prefix(chunk) else {
return false;
};
remaining = rest;
}
remaining.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_budget_evicts_least_recently_used() {
let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES;
let mut cache = CostBudgetedLru::<&str, u32>::new(2 * (40 + overhead), 100);
cache.insert("a", 1, 40);
cache.insert("b", 2, 40);
cache.get(&"a");
cache.insert("c", 3, 40);
assert_eq!(cache.get(&"a"), Some(&1));
assert_eq!(cache.get(&"b"), None);
assert_eq!(cache.get(&"c"), Some(&3));
assert_eq!(cache.total_cost, 2 * (40 + overhead));
}
#[test]
fn test_replacing_an_entry_updates_the_budget() {
let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES;
let mut cache = CostBudgetedLru::<&str, u32>::new(100 + 2 * overhead, 100);
cache.insert("a", 1, 60);
cache.insert("a", 2, 30);
assert_eq!(cache.total_cost, 30 + overhead);
assert_eq!(cache.get(&"a"), Some(&2));
cache.insert("b", 3, 70);
assert_eq!(cache.get(&"a"), Some(&2));
assert_eq!(cache.get(&"b"), Some(&3));
}
#[test]
fn test_oversized_entries_are_rejected_without_flushing() {
let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES;
let mut cache = CostBudgetedLru::<&str, u32>::new(100 + overhead, 50);
cache.insert("a", 1, 40);
cache.insert("b", 2, 60);
assert_eq!(cache.get(&"a"), Some(&1));
assert_eq!(cache.get(&"b"), None);
assert_eq!(cache.total_cost, 40 + overhead);
}
#[test]
fn test_clear_resets_the_budget() {
let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES;
let mut cache = CostBudgetedLru::<&str, u32>::new(90 + overhead, 100);
cache.insert("a", 1, 90);
cache.clear();
assert_eq!(cache.total_cost, 0);
cache.insert("b", 2, 90);
assert_eq!(cache.get(&"b"), Some(&2));
}
#[test]
fn test_zero_cost_entries_still_consume_budget() {
let overhead = CostBudgetedLru::<usize, u32>::ENTRY_OVERHEAD_BYTES;
let mut cache = CostBudgetedLru::<usize, u32>::new(3 * overhead, 100);
for key in 0..5_usize {
cache.insert(key, key as u32, 0);
}
assert_eq!(cache.get(&0), None);
assert_eq!(cache.get(&1), None);
assert_eq!(cache.get(&2), Some(&2));
assert_eq!(cache.get(&3), Some(&3));
assert_eq!(cache.get(&4), Some(&4));
assert_eq!(
cache.total_cost,
3 * overhead,
"Entries with zero caller-supplied cost must still be bounded by the budget"
);
}
#[test]
fn test_oversized_highlights_are_returned_but_not_cached() {
let text = Arc::<str>::from("fn main() {}");
let run_bytes = size_of::<(Range<usize>, HighlightId)>();
let max_entry_cost = text.len() + 2 * run_bytes;
let cache = TextHighlightCache(Mutex::new(CostBudgetedLru::new(
max_entry_cost * 10,
max_entry_cost,
)));
let resolved = |ranges: &[Range<usize>]| ResolvedHighlights {
sources: SmallVec::new(),
runs: ranges
.iter()
.map(|range| (range.clone(), HighlightId::new(0)))
.collect(),
};
let small_text = Arc::<str>::from("fn f() {}");
let small_key = TextHighlightKey::new([small_text.as_ref()].into_iter(), small_text.len());
let small_range = 0..2;
let small_highlights = cache.insert(
small_key.clone(),
Arc::clone(&small_text),
resolved(std::slice::from_ref(&small_range)),
);
assert_eq!(
small_highlights.runs.as_ref(),
resolved(std::slice::from_ref(&small_range)).runs.as_ref()
);
assert_eq!(
cache
.get(&small_key, [small_text.as_ref()].into_iter())
.map(|highlights| highlights.runs),
Some(small_highlights.runs.clone()),
"highlights within the entry budget must be cached as-is"
);
let big_key = TextHighlightKey::new([text.as_ref()].into_iter(), text.len());
let big_highlights_source = resolved(&[0..2, 3..7, 8..9]);
let big_highlights = cache.insert(
big_key.clone(),
Arc::clone(&text),
big_highlights_source.clone(),
);
assert!(
Arc::ptr_eq(&big_highlights.runs, &big_highlights_source.runs),
"highlights over the entry budget must be returned unchanged"
);
assert!(
cache.get(&big_key, [text.as_ref()].into_iter()).is_none(),
"highlights over the entry budget must not be cached"
);
}
}