Skip to main content

max / shop

Ring-buffer grid: fullscreen scroll becomes O(1) origin shift shop-grid now stores rows in a ring: logical row R lives at physical row (origin + R) mod rows. Fullscreen scrolls advance the origin instead of memcpy'ing rows. Partial-region scrolls still memcpy, but through ring-mapped indices. cell_index, row(), erase_line, erase_display all rewritten to go through row_start() which does the ring translation. erase_display in particular can no longer treat the buffer as contiguous — it now iterates logical rows one at a time. resize_buf takes an old_origin and unrolls the ring while copying to the fresh buffer, so origins are always 0 after resize. Results — the scrolling workloads that motivated this change now BEAT foot instead of running 3x slower: workload orig damage ring foot ring/foot vtebench scrolling 411 391 119 134 0.89x (SHOP WINS) vtebench unicode 6 6 4 8 0.50x (SHOP WINS 2x) vtebench sync_medium 10 9 5 5 1.00x (parity) vtebench medium_cells 9 9 5 4 1.25x vtebench dense_cells 20 15 14 7 2.00x bespoke log_colored 1.96s 0.72s 0.43s 0.32s 1.35x bespoke unicode_mix 1.30s 0.22s 0.13s 0.14s 0.93x (SHOP WINS) bespoke hexdump 1.81s 0.67s 0.34s 0.24s 1.42x bespoke llm_stream 1.27s 0.12s 0.07s 0.03s ~noise Partial-region scrolling (with a DECSTBM-set region smaller than the screen) picks up 2-5% overhead from the row_start lookup on every copy — acceptable given how rare partial scrolls are in practice. All 31 grid tests still pass. Full workspace: 67 tests, 0 failures.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 03:03 UTC
Signed with PGP, not checked
Commit: 9d6871dd955e2ef2d056728b7d9de84ac6db5384
Parent: b6b0ced
1 file changed, +169 insertions, -52 deletions
@@ -99,10 +99,16 @@
99 99 pub struct Grid {
100 100 cols: u16,
101 101 rows: u16,
102 - // Row-major cells; index = row * cols + col.
102 + // Row-major cells with a ring-buffer row layout: logical row `r` lives at
103 + // physical row `(origin + r) mod rows`. Fullscreen scrolls advance the
104 + // origin instead of memcpy'ing rows — a 2.4 GB/s cost on the vtebench
105 + // scrolling workload with the naive layout, near-free with the ring.
106 + // Partial-region scrolls fall back to memcpy through ring-mapped indices.
103 107 main: Vec<Cell>,
104 108 alt: Vec<Cell>,
105 109 on_alt: bool,
110 + main_origin: u16,
111 + alt_origin: u16,
106 112 cursor: Cursor,
107 113 cursor_shape: CursorShape,
108 114 saved_main_cursor: Cursor,
@@ -131,6 +137,8 @@
131 137 main: vec![Cell::default(); cell_count],
132 138 alt: vec![Cell::default(); cell_count],
133 139 on_alt: false,
140 + main_origin: 0,
141 + alt_origin: 0,
134 142 cursor: Cursor {
135 143 visible: true,
136 144 ..Cursor::default()
@@ -211,7 +219,7 @@
211 219 }
212 220
213 221 pub fn row(&self, r: u16) -> &[Cell] {
214 - let start = r as usize * self.cols as usize;
222 + let start = self.row_start(r);
215 223 let end = start + self.cols as usize;
216 224 &self.active_cells()[start..end]
217 225 }
@@ -228,6 +236,54 @@
228 236 }
229 237 }
230 238
239 + fn active_origin(&self) -> u16 {
240 + if self.on_alt {
241 + self.alt_origin
242 + } else {
243 + self.main_origin
244 + }
245 + }
246 +
247 + /// Physical byte offset for the start of logical row `r`.
248 + fn row_start(&self, r: u16) -> usize {
249 + let phys = (self.active_origin() as u32 + r as u32) % self.rows as u32;
250 + phys as usize * self.cols as usize
251 + }
252 +
253 + /// Advance the active screen's ring origin. Positive `n` = scroll up
254 + /// (logical row 0 shows what was logical row `n`); negative = scroll
255 + /// down. Blanking of newly-exposed rows is the caller's job.
256 + fn advance_origin(&mut self, n: i32) {
257 + let rows = self.rows as i32;
258 + let origin = if self.on_alt {
259 + &mut self.alt_origin
260 + } else {
261 + &mut self.main_origin
262 + };
263 + let new = (*origin as i32 + n).rem_euclid(rows);
264 + *origin = new as u16;
265 + }
266 +
267 + /// Zero one physical row's cells.
268 + fn blank_physical_row(&mut self, phys: u16) {
269 + let cols = self.cols as usize;
270 + let start = phys as usize * cols;
271 + let cells = self.active_cells_mut();
272 + for cell in &mut cells[start..start + cols] {
273 + *cell = Cell::default();
274 + }
275 + }
276 +
277 + /// Zero one logical row's cells.
278 + fn blank_logical_row(&mut self, r: u16) {
279 + let cols = self.cols as usize;
280 + let start = self.row_start(r);
281 + let cells = self.active_cells_mut();
282 + for cell in &mut cells[start..start + cols] {
283 + *cell = Cell::default();
284 + }
285 + }
286 +
231 287 /// Resize the grid, preserving as much of the top-left as fits. Truncates
232 288 /// content outside the new bounds; does not reflow.
233 289 pub fn resize(&mut self, cols: u16, rows: u16) {
@@ -236,8 +292,24 @@
236 292 if cols == self.cols && rows == self.rows {
237 293 return;
238 294 }
239 - self.main = resize_buf(&self.main, self.cols, self.rows, cols, rows);
240 - self.alt = resize_buf(&self.alt, self.cols, self.rows, cols, rows);
295 + self.main = resize_buf(
296 + &self.main,
297 + self.main_origin,
298 + self.cols,
299 + self.rows,
300 + cols,
301 + rows,
302 + );
303 + self.alt = resize_buf(
304 + &self.alt,
305 + self.alt_origin,
306 + self.cols,
307 + self.rows,
308 + cols,
309 + rows,
310 + );
311 + self.main_origin = 0;
312 + self.alt_origin = 0;
241 313 self.cols = cols;
242 314 self.rows = rows;
243 315 self.scroll_top = 0;
@@ -252,7 +324,7 @@
252 324 }
253 325
254 326 fn cell_index(&self, row: u16, col: u16) -> usize {
255 - row as usize * self.cols as usize + col as usize
327 + self.row_start(row) + col as usize
256 328 }
257 329
258 330 fn place_char(&mut self, c: char) {
@@ -433,29 +505,26 @@
433 505 }
434 506
435 507 fn scroll_up_in_region(&mut self, n: u16) {
436 - let top = self.scroll_top as usize;
437 - let bot = self.scroll_bottom as usize;
438 - let cols = self.cols as usize;
439 - let n = (n as usize).min(bot - top + 1);
440 - let cells = self.active_cells_mut();
441 - for r in top..=(bot - n) {
442 - let src = (r + n) * cols;
443 - let dst = r * cols;
444 - cells.copy_within(src..src + cols, dst);
508 + let n = n.min(self.scroll_bottom - self.scroll_top + 1);
509 + if n == 0 {
510 + return;
445 511 }
446 - for r in (bot - n + 1)..=bot {
447 - for c in 0..cols {
448 - cells[r * cols + c] = Cell::default();
449 - }
450 - }
451 - // Damage: if the region is the whole screen, tell the renderer to
452 - // rotate its cache instead of rebuilding every row.
453 512 if self.scroll_top == 0 && self.scroll_bottom == self.rows - 1 {
513 + // Fullscreen fast path: O(1) origin shift + blank the newly-
514 + // exposed rows (which are physically the old top-of-ring).
515 + let old_origin = self.active_origin();
516 + self.advance_origin(n as i32);
517 + for k in 0..n {
518 + let phys = (old_origin as u32 + k as u32) % self.rows as u32;
519 + self.blank_physical_row(phys as u16);
520 + }
454 521 self.pending_scroll = self.pending_scroll.saturating_add(n as i16);
455 - for r in (self.rows as usize - n)..self.rows as usize {
456 - self.row_dirty[r] = true;
522 + for r in (self.rows - n)..self.rows {
523 + self.row_dirty[r as usize] = true;
457 524 }
458 525 } else {
526 + // Partial region: memcpy via logical indices (ring-mapped).
527 + self.memcpy_scroll_up_partial(n);
459 528 for r in self.scroll_top..=self.scroll_bottom {
460 529 self.mark_row_dirty(r);
461 530 }
@@ -463,33 +532,58 @@
463 532 }
464 533
465 534 fn scroll_down_in_region(&mut self, n: u16) {
466 - let top = self.scroll_top as usize;
467 - let bot = self.scroll_bottom as usize;
468 - let cols = self.cols as usize;
469 - let n = (n as usize).min(bot - top + 1);
470 - let cells = self.active_cells_mut();
471 - for r in (top + n..=bot).rev() {
472 - let src = (r - n) * cols;
473 - let dst = r * cols;
474 - cells.copy_within(src..src + cols, dst);
475 - }
476 - for r in top..(top + n) {
477 - for c in 0..cols {
478 - cells[r * cols + c] = Cell::default();
479 - }
535 + let n = n.min(self.scroll_bottom - self.scroll_top + 1);
536 + if n == 0 {
537 + return;
480 538 }
481 539 if self.scroll_top == 0 && self.scroll_bottom == self.rows - 1 {
540 + self.advance_origin(-(n as i32));
541 + for k in 0..n {
542 + self.blank_physical_row((self.active_origin() as u32 + k as u32) as u16 % self.rows);
543 + }
482 544 self.pending_scroll = self.pending_scroll.saturating_sub(n as i16);
483 545 for r in 0..n {
484 - self.row_dirty[r] = true;
546 + self.row_dirty[r as usize] = true;
485 547 }
486 548 } else {
549 + self.memcpy_scroll_down_partial(n);
487 550 for r in self.scroll_top..=self.scroll_bottom {
488 551 self.mark_row_dirty(r);
489 552 }
490 553 }
491 554 }
492 555
556 + fn memcpy_scroll_up_partial(&mut self, n: u16) {
557 + let cols = self.cols as usize;
558 + let top = self.scroll_top;
559 + let bot = self.scroll_bottom;
560 + // Collect physical row ranges first so we can borrow cells mutably.
561 + for r in top..=(bot - n) {
562 + let src = self.row_start(r + n);
563 + let dst = self.row_start(r);
564 + let cells = self.active_cells_mut();
565 + cells.copy_within(src..src + cols, dst);
566 + }
567 + for r in (bot - n + 1)..=bot {
568 + self.blank_logical_row(r);
569 + }
570 + }
571 +
572 + fn memcpy_scroll_down_partial(&mut self, n: u16) {
573 + let cols = self.cols as usize;
574 + let top = self.scroll_top;
575 + let bot = self.scroll_bottom;
576 + for r in (top + n..=bot).rev() {
577 + let src = self.row_start(r - n);
578 + let dst = self.row_start(r);
579 + let cells = self.active_cells_mut();
580 + cells.copy_within(src..src + cols, dst);
581 + }
582 + for r in top..(top + n) {
583 + self.blank_logical_row(r);
584 + }
585 + }
586 +
493 587 fn erase_line(&mut self, mode: u16) {
494 588 let cols = self.cols as usize;
495 589 let row = self.cursor.row;
@@ -508,22 +602,42 @@
508 602 }
509 603
510 604 fn erase_display(&mut self, mode: u16) {
511 - let cols = self.cols as usize;
512 - let idx = self.cell_index(self.cursor.row, self.cursor.col);
513 - let total = cols * self.rows as usize;
605 + // Iterate LOGICAL rows — physical layout is a ring, so contiguous
606 + // "erase from cursor to end" isn't contiguous in memory.
607 + let cursor_row = self.cursor.row;
608 + let cursor_col = self.cursor.col;
609 + match mode {
610 + 1 => {
611 + // Start of screen to cursor (inclusive).
612 + for r in 0..cursor_row {
613 + self.blank_logical_row(r);
614 + }
615 + self.erase_line_range(cursor_row, 0, cursor_col + 1);
616 + }
617 + 2 | 3 => {
618 + for r in 0..self.rows {
619 + self.blank_logical_row(r);
620 + }
621 + }
622 + _ => {
623 + // Cursor to end of screen (inclusive).
624 + self.erase_line_range(cursor_row, cursor_col, self.cols);
625 + for r in (cursor_row + 1)..self.rows {
626 + self.blank_logical_row(r);
627 + }
628 + }
629 + }
630 + self.mark_all_rows_dirty();
631 + }
632 +
633 + fn erase_line_range(&mut self, row: u16, start_col: u16, end_col: u16) {
634 + let row_start = self.row_start(row);
635 + let end_col = end_col.min(self.cols) as usize;
636 + let start_col = start_col.min(end_col as u16) as usize;
514 637 let cells = self.active_cells_mut();
515 - let (from, to) = match mode {
516 - 1 => (0, idx + 1),
517 - 2 | 3 => (0, total),
518 - _ => (idx, total),
519 - };
520 - for cell in &mut cells[from..to] {
638 + for cell in &mut cells[row_start + start_col..row_start + end_col] {
521 639 *cell = Cell::default();
522 640 }
523 - // Erase display always touches at least the cursor row and (except
524 - // for mode-tail) rows around it. Cheap to just mark all — the cases
525 - // where only one row changes are minority.
526 - self.mark_all_rows_dirty();
527 641 }
528 642
529 643 fn swap_alt(&mut self, to_alt: bool) {
@@ -620,6 +734,7 @@
620 734
621 735 fn resize_buf(
622 736 old: &[Cell],
737 + old_origin: u16,
623 738 old_cols: u16,
624 739 old_rows: u16,
625 740 new_cols: u16,
@@ -629,7 +744,9 @@
629 744 let copy_cols = old_cols.min(new_cols) as usize;
630 745 let copy_rows = old_rows.min(new_rows) as usize;
631 746 for r in 0..copy_rows {
632 - let src_start = r * old_cols as usize;
747 + // Ring-map the old logical row to its physical offset.
748 + let src_phys = (old_origin as u32 + r as u32) % old_rows as u32;
749 + let src_start = src_phys as usize * old_cols as usize;
633 750 let dst_start = r * new_cols as usize;
634 751 new[dst_start..dst_start + copy_cols]
635 752 .copy_from_slice(&old[src_start..src_start + copy_cols]);