Skip to content

Commit d1cbdd2

Browse files
committed
perf: use single plot prefetch worker
Replace per-batch plot prefetch thread spawning with a persistent background worker that drains superseded queued navigation jobs before rendering. Keep prefetch results cache-only, preserve foreground ownership of terminal writes, and add a regression test for cancelling stale pending prefetch keys.
1 parent 74231f3 commit d1cbdd2

5 files changed

Lines changed: 212 additions & 65 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ matching `## [X.Y.Z]` section before the release tag is pushed.
88

99
## [Unreleased]
1010

11+
### Changed
12+
13+
- Replace per-batch plot prefetch threads with a single background prefetch
14+
worker that keeps the newest queued navigation job and cancels superseded
15+
pending prefetch keys before rendering them.
16+
1117
## [0.2.5] - 2026-06-18
1218

1319
### Added

docs/architecture.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,16 @@ Interactive plot viewing keeps terminal input ahead of expensive protocol
111111
payload work. The event loop drains pending key and resize events before drawing
112112
so burst input renders the latest state instead of every intermediate state. It
113113
keeps a bounded cache of encoded frames by protocol, plot kind, viewport, and
114-
terminal size. After user navigation, a small background prefetcher warms likely
115-
next frames without blocking the foreground draw. Prefetch results are cached
116-
as future candidates only; they must not replace the current visible frame just
117-
because the background worker finished late. For repeated pan actions on large
118-
scenes, the prefetcher can render a transparent marks atlas once and crop
119-
future same-zoom pan frames, then composite those marks over the current
120-
grid/frame layer so axis labels and grid lines stay correct.
114+
terminal size. After user navigation, a single background prefetch worker warms
115+
likely next frames without blocking the foreground draw. Before rendering a
116+
prefetch job, the worker waits briefly and keeps only the newest queued
117+
navigation job, cancelling older pending keys that have been superseded by
118+
later input. Prefetch results are cached as future candidates only; they must
119+
not replace the current visible frame just because the background worker
120+
finished late. For repeated pan actions on large scenes, the prefetcher can
121+
render a transparent marks atlas once and crop future same-zoom pan frames, then
122+
composite those marks over the current grid/frame layer so axis labels and grid
123+
lines stay correct.
121124

122125
Mouse input in the plot viewer is read-only. Hover events remember the latest
123126
terminal cell, snap to the nearest visible plot point, and render a separate
@@ -146,7 +149,8 @@ share the same stable z-index, avoiding broad z-index or full-screen deletes
146149
that can blank the plot during fast navigation. The prefetch list stays
147150
intentionally small: more candidates increase background raster work and hidden
148151
terminal bytes, so newer directional batches suppress stale,
149-
not-yet-transmitted candidates.
152+
not-yet-transmitted candidates. The worker caches results and pretransmit
153+
payloads only; terminal writes remain owned by the foreground viewer loop.
150154

151155
Kitty frames request the full terminal cell area while rendering normal terminal
152156
windows at the full terminal pixel estimate. Very large windows use a bounded

docs/testing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ navigation hit should show `payload_bytes_delta` near zero because the
8383
foreground update is an image placement command for an idle-transmitted image,
8484
not a new image transfer.
8585

86+
The plot prefetch path uses one background worker. Tests should keep covering
87+
two invariants: late worker results must not replace the current visible frame,
88+
and a newer navigation job must cancel older pending prefetch keys before they
89+
become cache entries. The worker may prepare cached frames and Kitty transmit
90+
payloads, but terminal writes must remain in the foreground viewer loop.
91+
8692
## Selector Tests
8793

8894
Selector tests live in `src/render/terminal.rs`.

src/viewer/plot/cache.rs

Lines changed: 127 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
use std::{
22
borrow::Cow,
33
collections::VecDeque,
4-
sync::{
5-
Arc,
6-
atomic::{AtomicUsize, Ordering},
7-
mpsc::{self, Receiver, Sender},
8-
},
4+
sync::mpsc::{self, Receiver, Sender},
95
thread,
106
time::Duration,
117
};
@@ -33,7 +29,6 @@ use super::{
3329
};
3430

3531
const MAX_CACHED_FRAMES: usize = 48;
36-
const MAX_IN_FLIGHT_PREFETCHES: usize = 3;
3732
const PREFETCH_FORWARD_STEPS: usize = 3;
3833
const PREFETCH_GRACE_PERIOD: Duration = Duration::from_millis(12);
3934
const PAN_ATLAS_MIN_POINTS: usize = 2_000;
@@ -50,9 +45,8 @@ pub(super) struct PlotFrameCacheKey {
5045
pub(super) struct PlotFrameCache {
5146
entries: VecDeque<CachedPlotFrame>,
5247
pub(super) last: Option<CachedPlotFrame>,
53-
prefetch_tx: Sender<PrefetchResult>,
54-
prefetch_rx: Receiver<PrefetchResult>,
55-
in_flight: Arc<AtomicUsize>,
48+
prefetch_tx: Sender<PrefetchJob>,
49+
prefetch_rx: Receiver<PrefetchEvent>,
5650
queued: Vec<PlotFrameCacheKey>,
5751
next_image_id: u32,
5852
next_transmit_batch: u64,
@@ -98,15 +92,41 @@ struct PrefetchResult {
9892
frame: Result<CachedPlotFrame, String>,
9993
}
10094

95+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96+
enum PrefetchMode {
97+
Batch,
98+
Pan,
99+
}
100+
101+
#[derive(Debug)]
102+
struct PrefetchJob {
103+
scene: PlotScene,
104+
requests: Vec<PrefetchRequest>,
105+
mode: PrefetchMode,
106+
}
107+
108+
impl PrefetchJob {
109+
fn keys(&self) -> Vec<PlotFrameCacheKey> {
110+
self.requests.iter().map(|request| request.key).collect()
111+
}
112+
}
113+
114+
#[derive(Debug)]
115+
enum PrefetchEvent {
116+
Cancelled(Vec<PlotFrameCacheKey>),
117+
Result(PrefetchResult),
118+
}
119+
101120
impl Default for PlotFrameCache {
102121
fn default() -> Self {
103-
let (prefetch_tx, prefetch_rx) = mpsc::channel();
122+
let (prefetch_tx, prefetch_job_rx) = mpsc::channel();
123+
let (prefetch_event_tx, prefetch_rx) = mpsc::channel();
124+
spawn_prefetch_worker(prefetch_job_rx, prefetch_event_tx);
104125
Self {
105126
entries: VecDeque::new(),
106127
last: None,
107128
prefetch_tx,
108129
prefetch_rx,
109-
in_flight: Arc::new(AtomicUsize::new(0)),
110130
queued: Vec::new(),
111131
next_image_id: 1,
112132
next_transmit_batch: 0,
@@ -161,9 +181,6 @@ impl PlotFrameCache {
161181
if protocol == Protocol::Blocks {
162182
return;
163183
}
164-
if self.in_flight.load(Ordering::Relaxed) >= MAX_IN_FLIGHT_PREFETCHES {
165-
return;
166-
}
167184

168185
let Some(action) = recent_action else {
169186
return;
@@ -208,13 +225,13 @@ impl PlotFrameCache {
208225
) {
209226
let keys = self.keys_with_image_ids(keys);
210227
if scene.total_points() >= PAN_ATLAS_MIN_POINTS {
211-
self.spawn_pan_prefetch(scene.clone(), keys);
228+
self.schedule_prefetch(scene.clone(), keys, PrefetchMode::Pan);
212229
} else {
213-
self.spawn_batch_prefetch(scene.clone(), keys);
230+
self.schedule_prefetch(scene.clone(), keys, PrefetchMode::Batch);
214231
}
215232
} else {
216233
let keys = self.keys_with_image_ids(keys);
217-
self.spawn_batch_prefetch(scene.clone(), keys);
234+
self.schedule_prefetch(scene.clone(), keys, PrefetchMode::Batch);
218235
}
219236
}
220237

@@ -223,6 +240,11 @@ impl PlotFrameCache {
223240
|| self.entries.iter().any(|cached| cached.key == key)
224241
}
225242

243+
#[cfg(test)]
244+
pub(super) fn has_cached_or_queued_key(&self, key: PlotFrameCacheKey) -> bool {
245+
self.contains_key(key) || self.queued.contains(&key)
246+
}
247+
226248
pub(super) fn drain_transmit_payloads(&mut self, max_count: usize) -> Vec<String> {
227249
self.collect_prefetches();
228250
let mut payloads = Vec::new();
@@ -291,11 +313,20 @@ impl PlotFrameCache {
291313
}
292314

293315
fn collect_prefetches(&mut self) {
294-
while let Ok(result) = self.prefetch_rx.try_recv() {
295-
self.queued.retain(|key| *key != result.key);
296-
if let Ok(mut frame) = result.frame {
297-
discard_stale_transmit_payload(&mut frame, self.min_transmit_priority);
298-
self.insert_prefetched(frame);
316+
while let Ok(event) = self.prefetch_rx.try_recv() {
317+
match event {
318+
PrefetchEvent::Cancelled(keys) => {
319+
for key in keys {
320+
self.queued.retain(|queued| *queued != key);
321+
}
322+
}
323+
PrefetchEvent::Result(result) => {
324+
self.queued.retain(|key| *key != result.key);
325+
if let Ok(mut frame) = result.frame {
326+
discard_stale_transmit_payload(&mut frame, self.min_transmit_priority);
327+
self.insert_prefetched(frame);
328+
}
329+
}
299330
}
300331
}
301332
}
@@ -320,42 +351,32 @@ impl PlotFrameCache {
320351
image_id
321352
}
322353

323-
fn spawn_batch_prefetch(&mut self, scene: PlotScene, keys: Vec<PrefetchRequest>) {
324-
self.discard_stale_transmit_payloads(&keys);
325-
self.queued.extend(keys.iter().map(|request| request.key));
326-
self.in_flight.fetch_add(1, Ordering::Relaxed);
327-
let tx = self.prefetch_tx.clone();
328-
let in_flight = Arc::clone(&self.in_flight);
329-
thread::spawn(move || {
330-
thread::sleep(PREFETCH_GRACE_PERIOD);
331-
for request in keys {
332-
let frame = render_plot_frame_for_key(
333-
&scene,
334-
request.key,
335-
Some(request.image_id),
336-
request.transmit_priority,
337-
)
338-
.map_err(|error| error.to_string());
339-
let key = request.key;
340-
let _ = tx.send(PrefetchResult { key, frame });
341-
}
342-
in_flight.fetch_sub(1, Ordering::Relaxed);
343-
});
344-
}
345-
346-
fn spawn_pan_prefetch(&mut self, scene: PlotScene, keys: Vec<PrefetchRequest>) {
347-
self.discard_stale_transmit_payloads(&keys);
348-
self.queued.extend(keys.iter().map(|request| request.key));
349-
self.in_flight.fetch_add(1, Ordering::Relaxed);
350-
let tx = self.prefetch_tx.clone();
351-
let in_flight = Arc::clone(&self.in_flight);
352-
thread::spawn(move || {
353-
thread::sleep(PREFETCH_GRACE_PERIOD);
354-
for (key, frame) in render_pan_prefetch_frames(&scene, &keys) {
355-
let _ = tx.send(PrefetchResult { key, frame });
354+
fn schedule_prefetch(
355+
&mut self,
356+
scene: PlotScene,
357+
requests: Vec<PrefetchRequest>,
358+
mode: PrefetchMode,
359+
) {
360+
self.discard_stale_transmit_payloads(&requests);
361+
self.queued
362+
.extend(requests.iter().map(|request| request.key));
363+
let queued_keys = requests
364+
.iter()
365+
.map(|request| request.key)
366+
.collect::<Vec<_>>();
367+
if self
368+
.prefetch_tx
369+
.send(PrefetchJob {
370+
scene,
371+
requests,
372+
mode,
373+
})
374+
.is_err()
375+
{
376+
for key in queued_keys {
377+
self.queued.retain(|queued| *queued != key);
356378
}
357-
in_flight.fetch_sub(1, Ordering::Relaxed);
358-
});
379+
}
359380
}
360381

361382
fn render_plot_frame(
@@ -405,6 +426,55 @@ fn discard_stale_transmit_payload(frame: &mut CachedPlotFrame, min_priority: u64
405426
}
406427
}
407428

429+
fn spawn_prefetch_worker(job_rx: Receiver<PrefetchJob>, event_tx: Sender<PrefetchEvent>) {
430+
thread::spawn(move || {
431+
while let Ok(job) = job_rx.recv() {
432+
let job = newest_prefetch_job_after_grace(job, &job_rx, &event_tx);
433+
render_prefetch_job(job, &event_tx);
434+
}
435+
});
436+
}
437+
438+
fn newest_prefetch_job_after_grace(
439+
mut job: PrefetchJob,
440+
job_rx: &Receiver<PrefetchJob>,
441+
event_tx: &Sender<PrefetchEvent>,
442+
) -> PrefetchJob {
443+
thread::sleep(PREFETCH_GRACE_PERIOD);
444+
let mut cancelled = Vec::new();
445+
while let Ok(next) = job_rx.try_recv() {
446+
cancelled.extend(job.keys());
447+
job = next;
448+
}
449+
if !cancelled.is_empty() {
450+
let _ = event_tx.send(PrefetchEvent::Cancelled(cancelled));
451+
}
452+
job
453+
}
454+
455+
fn render_prefetch_job(job: PrefetchJob, event_tx: &Sender<PrefetchEvent>) {
456+
match job.mode {
457+
PrefetchMode::Batch => {
458+
for request in job.requests {
459+
let frame = render_plot_frame_for_key(
460+
&job.scene,
461+
request.key,
462+
Some(request.image_id),
463+
request.transmit_priority,
464+
)
465+
.map_err(|error| error.to_string());
466+
let key = request.key;
467+
let _ = event_tx.send(PrefetchEvent::Result(PrefetchResult { key, frame }));
468+
}
469+
}
470+
PrefetchMode::Pan => {
471+
for (key, frame) in render_pan_prefetch_frames(&job.scene, &job.requests) {
472+
let _ = event_tx.send(PrefetchEvent::Result(PrefetchResult { key, frame }));
473+
}
474+
}
475+
}
476+
}
477+
408478
pub(super) fn render_plot_frame(
409479
scene: &PlotScene,
410480
kind: PlotKind,

src/viewer/plot/tests.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,67 @@ fn late_prefetch_results_do_not_replace_visible_plot_frame() {
474474
assert_eq!(cache.last.as_ref().unwrap().key, visible_key);
475475
}
476476

477+
#[test]
478+
fn prefetch_worker_cancels_superseded_job_before_rendering() {
479+
let scene = PlotScene {
480+
title: Some("latency".to_owned()),
481+
series: vec![PlotSeries {
482+
name: "api".to_owned(),
483+
points: (0..80)
484+
.map(|index| PlotPoint {
485+
x: index as f64,
486+
y: (index % 17) as f64,
487+
})
488+
.collect(),
489+
}],
490+
};
491+
let size = TerminalSize {
492+
width: 120,
493+
height: 32,
494+
};
495+
let mut state = PlotViewState::new(scene.bounds().unwrap().normalized());
496+
state.zoom_in();
497+
let mut cache = PlotFrameCache::default();
498+
499+
cache.prefetch_neighbors(
500+
&scene,
501+
PlotKind::Line,
502+
&state,
503+
Protocol::Kitty,
504+
size,
505+
Some(PlotNavAction::ZoomIn),
506+
);
507+
let mut stale_zoom = state;
508+
stale_zoom.zoom_in();
509+
let stale_key = cache::PlotFrameCacheKey {
510+
kind: PlotKind::Line,
511+
protocol: Protocol::Kitty,
512+
visible: stale_zoom.visible,
513+
size,
514+
hover_x: None,
515+
};
516+
517+
cache.prefetch_neighbors(
518+
&scene,
519+
PlotKind::Line,
520+
&state,
521+
Protocol::Kitty,
522+
size,
523+
Some(PlotNavAction::PanRight),
524+
);
525+
let transmit_payloads = wait_for_transmit_payloads(&mut cache, 4);
526+
527+
assert!(
528+
transmit_payloads
529+
.iter()
530+
.any(|payload| payload.contains("a=t"))
531+
);
532+
assert!(
533+
!cache.has_cached_or_queued_key(stale_key),
534+
"worker should drop an older pending prefetch job when a newer navigation job arrives"
535+
);
536+
}
537+
477538
#[test]
478539
fn stale_prefetched_frame_still_replaces_previous_visible_image() {
479540
let scene = PlotScene {

0 commit comments

Comments
 (0)