terminal: handful of scroll region optimizations (minor) (#13231)

This optimizes scrolling inside a scroll region (DECSTBM). 

We're squeezing blood out of a rock here. There just isn't much
improvement to be had given our architecture for this case, but there
were a couple things found that result in modest gains.

## Vtebench

You can see the modest gains here. Still behind Alacritty (very very
close).

<img width="3558" height="2410" alt="CleanShot 2026-07-06 at 21 18
39@2x"
src="https://github.com/user-attachments/assets/3c800089-3510-4887-9872-e9926d686955"
/>


## The changes

1. **Stop creating scrollback for top-anchored regions on screens that
don't retain scrollback.** `index()` routed any full-width region with
`top == 0` through `cursorScrollAbove()`, which pushes the scrolled-out
row into scrollback. Every scroll paid `PageList.grow()` plus amortized
page pruning, which includes a 512 KB `memset` each time a page is
recycled. These now use the in-place region scroll instead. CSI S gets
the same routing fix. **Result: 1.05x-1.49x on the bottom-anchored
region workloads, 1.25x on alt-screen full-screen scrolling.**

2. **Add a specialized `Screen.cursorScrollRegionUp()` for the region
scroll hot path.** The previous fast path (`PageList.eraseRowBounded`)
paid per-scroll bookkeeping that exceeded the actual row work. The new
function is built around the invariant that the cursor sits on the
bottom row of a full-width region. **Result: 1.23x-1.24x on the
top-anchored region workloads.**

## Benchmarks

| workload | region (80 rows) | before | after | change |
 |---|---|---|---|---|
| scrolling (control) | primary screen, no region | 237 ms | 235 ms |
1.0x |
| scrolling_bottom_region | alt, rows 1-79 | 243 ms | 231 ms | 1.05x | 
| scrolling_bottom_small_region | alt, rows 1-40 | 311 ms | 208 ms |
1.49x |
 | scrolling_top_region | alt, rows 2-80 | 283 ms | 229 ms | 1.23x | 
| scrolling_top_small_region | alt, rows 40-80 | 258 ms | 208 ms | 1.24x
|
| alt screen full-screen scrolling | alt, no region | 288 ms | 230 ms |
1.25x |

## LLM Notes

Assisted by Fable 5: it diagnosed the vtebench gap, wrote the benchmark
harness payloads, profiled, and proposed hot paths. I manually wrote the
hot path replacements and had it judge my work.
This commit is contained in:
Mitchell Hashimoto
2026-07-07 06:36:07 -07:00
committed by GitHub
2 changed files with 496 additions and 38 deletions

View File

@@ -1055,6 +1055,155 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
self.cursor.page_cell = page_rac.cell;
}
/// Scroll a full-width scroll region that ends at the cursor row up by
/// one row. The cursor must be on the bottom row of the region. `limit`
/// is the number of rows in the region above the cursor (region height
/// minus one) and must be at least 1.
///
/// The top row of the region is discarded (NOT moved into scrollback).
/// All other rows in the region shift up by one and the cursor row
/// becomes a blank row, filled with the current background color (like
/// other scroll operations such as deleteLines).
///
/// The cursor stays at the same screen position (the new blank row).
/// Content outside of the region is unmodified.
///
/// This is a very hot path for scroll region usage (e.g. a program
/// on the alt screen using DECSTBM and scrolling via LF/IND) so this
/// is optimized for the common case where the full region is within
/// a single page.
pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void {
assert(limit >= 1);
assert(self.cursor.y >= limit);
defer self.assertIntegrity();
const pin: *Pin = self.cursor.page_pin;
// If the region crosses a page boundary we take a slower path. This
// is rare: it requires the active area to span multiple pages with
// the split point inside the scroll region.
if (pin.y < limit) return try self.cursorScrollRegionUpSlow(limit);
// Fast path: the entire region is in a single page. We can clear
// the top row and rotate it down to the cursor row, updating any
// tracked pins along the way.
const page: *Page = &pin.node.data;
const rows = page.rows.ptr(page.memory.ptr)[pin.y - limit ..][0 .. limit + 1];
// Clear the erased (top) row.
{
const row: *Row = &rows[0];
// Whether our blank cell is a plain zero cell. This is true
// unless the cursor has a background color set (see blankCell).
const blank_is_zero = self.cursor.style_id == style.default_id or
self.cursor.style.bg_color == .none;
if (!row.managedMemory() and blank_is_zero) {
// Hot path: the row has no managed memory (styles,
// graphemes, hyperlinks) and our blank is zero so this is
// a straight zero fill. This is the overwhelmingly common
// case for scroll region usage.
const cells = page.getCells(row);
@memset(@as([]u64, @ptrCast(cells)), 0);
} else {
// The generic clear handles managed memory and fills the
// row with our blank cell, preserving the background color.
self.clearCells(page, row, page.getCells(row));
}
}
// Rotate the region rows so the now-blank top row moves to the
// bottom (the cursor row) and everything else shifts up by one.
fastmem.rotateOnce(Row, rows);
// Mark the whole page as dirty.
//
// Technically we only need to mark the rotated rows but this is
// a hot function, so we want to minimize work.
page.dirty = true;
// If our viewport is a pin and it's within the rotated region
// then we need to shift its cached row offset up. See
// PageList.eraseRowBounded for details; this mirrors that logic.
if (self.pages.viewport == .pin) viewport: {
if (self.pages.viewport_pin_row_offset) |*v| {
const p = self.pages.viewport_pin;
if (p.node != pin.node or
p.y < pin.y - limit or
p.y > pin.y or
p.y == 0) break :viewport;
v.* -= 1;
}
}
// Update tracked pins within the region since their rows moved up
// by one. The cursor's own pin is skipped because the cursor stays
// at the region bottom (the new blank row).
const pin_keys = self.pages.tracked_pins.keys();
for (pin_keys) |p| {
if (p.node != pin.node or
p == pin or
p.y < pin.y - limit or
p.y > pin.y) continue;
if (p.y == 0) p.x = 0 else p.y -= 1;
}
// The cursor pin is unchanged, but the Row structure at the pin
// position now contains the blank row, so we need to refresh our
// cached row/cell pointers. We compute them directly from the row
// slice we already have rather than going through the pin since
// this is a hot path.
const cursor_row: *Row = &rows[limit];
self.cursor.page_row = cursor_row;
self.cursor.page_cell = &page.getCells(cursor_row)[self.cursor.x];
}
/// Slow path for cursorScrollRegionUp: the scroll region spans
/// multiple pages so we use the generic PageList erase machinery.
fn cursorScrollRegionUpSlow(self: *Screen, limit: usize) !void {
// The call to eraseRowBounded below will move our tracked cursor
// pin up by one row since it is inside the erased region, but we
// don't actually want that: the cursor stays put, on the new blank
// row at the region bottom. We keep the old pin and put it back
// after, exactly like cursorDownScroll does for the no-scrollback
// case.
//
// This matters beyond performance: when the cursor is on the first
// row of a page, eraseRowBounded moves the tracked pin to the
// previous page. Moving the cursor back down with cursorDown would
// then cross pages via cursorChangePin, which migrates the cursor
// style refcount from a page that never held it, corrupting the
// style accounting.
const old_pin = self.cursor.page_pin.*;
try self.pages.eraseRowBounded(
.{ .active = .{ .y = @intCast(self.cursor.y - limit) } },
limit,
);
// We don't use `cursorChangePin` here because we aren't actually
// changing the pin, we're keeping it the same. Since the page
// never changes, the cursor's style ref stays valid and no style
// accounting needs to be updated.
self.cursor.page_pin.* = old_pin;
// We do, however, need to refresh the cached page row and cell,
// because the row contents at our pin position changed (it now
// contains the blank row).
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
// eraseRowBounded clears the new row with zero cells so if our
// blank cell isn't zero (bg color is set) we need to fill it.
const blank = self.blankCell();
if (!blank.isZero()) {
const cells: [*]Cell = @ptrCast(self.cursor.page_cell);
@memset((cells - self.cursor.x)[0..self.pages.cols], blank);
}
}
/// Move the cursor down if we're not at the bottom of the screen. Otherwise
/// scroll. Currently only used for testing.
inline fn cursorDownOrScroll(self: *Screen) !void {
@@ -4589,6 +4738,207 @@ test "Screen: scrolling moves selection" {
}
}
test "Screen: cursorScrollRegionUp simple" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
// Scroll a region ending at row 2 (zero-indexed) up by one. This
// emulates a scroll region of rows 0-2 with the cursor at the
// region bottom.
s.cursorAbsolute(1, 2);
try s.cursorScrollRegionUp(2);
// The cursor stays in place, on the new blank row.
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
// Rows in the region scrolled, rows below are unchanged, and
// nothing was moved into scrollback.
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
}
}
test "Screen: cursorScrollRegionUp moves selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
// Select the second row.
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?,
false,
));
s.cursorAbsolute(0, 2);
try s.cursorScrollRegionUp(2);
// Our selection should've moved up with its row.
{
const sel = s.selection.?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = s.pages.cols - 1,
.y = 0,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
}
}
test "Screen: cursorScrollRegionUp region spans pages" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
defer s.deinit();
// We need to get the cursor to a new page
const first_page_size = s.pages.pages.first.?.data.capacity.rows;
s.pages.pages.first.?.data.pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1A00000000| | 0
// 4306 |2B00000000| | 1
// 4307 |3C00000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4D00000000| | 3
// :^ : : = PIN 0
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
// Move the cursor to the first row of the second page and give it
// a non-default style. This is important: it verifies that the
// cursor's style ref stays accounted on the correct page even
// though eraseRowBounded moves the cursor's tracked pin across
// the page boundary.
s.cursorAbsolute(0, 3);
try s.setAttribute(.{ .bold = {} });
try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
// Scroll a region of active rows 1-3 with the cursor at the region
// bottom. The region spans the page boundary so this exercises the
// slow path.
try s.cursorScrollRegionUp(2);
// The cursor stays in place, on the new blank row.
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
}
// Our cursor style must remain usable: write a styled cell and
// verify the style ref counting is intact on the cursor's page.
try s.testWriteString("X");
{
const page = &s.cursor.page_pin.node.data;
const styles = page.styles.count();
try testing.expectEqual(@as(usize, 1), styles);
}
}
test "Screen: cursorScrollRegionUp region spans pages with background SGR" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
defer s.deinit();
// We need to get the cursor to a new page. See the previous test
// for a diagram of the page layout.
const first_page_size = s.pages.pages.first.?.data.capacity.rows;
s.pages.pages.first.?.data.pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
s.cursorAbsolute(0, 3);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } });
try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
try s.cursorScrollRegionUp(2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
}
// The new blank row must be filled with our background color.
for (0..s.pages.cols) |x| {
const list_cell = s.pages.getCell(.{ .active = .{
.x = @intCast(x),
.y = 3,
} }).?;
try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 0xFF,
.g = 0,
.b = 0,
}, list_cell.cell.content.color_rgb);
}
}
test "Screen: cursorScrollRegionUp with styled erased row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer s.deinit();
// Write a styled row at the top so the erased row has managed
// memory that must be released.
try s.setAttribute(.{ .bold = {} });
try s.testWriteString("1ABCD");
try s.setAttribute(.{ .unset = {} });
try s.testWriteString("\n2EFGH\n3IJKL");
s.cursorAbsolute(0, 2);
try s.cursorScrollRegionUp(2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// The style should be gone from the page since the only user
// was the erased row.
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
test "Screen: scrolling moves viewport" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -1974,10 +1974,19 @@ pub fn index(self: *Terminal) !void {
screen.kitty_images.dirty = true;
}
// If our scrolling region is at the top, we create scrollback.
// If our scrolling region is at the top, we create scrollback,
// but only if our screen retains scrollback. If our screen
// doesn't retain scrollback (e.g. the alternate screen) then
// creating scrollback is pure overhead: the rows are never
// visible and are simply pruned later. In that case we use the
// in-place region scroll below, unless the region is a single
// row (a one row screen) which cursorScrollRegionUp can't
// handle (and cursorDownScroll special-cases).
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
self.scrolling_region.right == self.cols - 1)
self.scrolling_region.right == self.cols - 1 and
(!screen.no_scrollback or
self.scrolling_region.bottom == 0))
{
try screen.cursorScrollAbove();
return;
@@ -1985,49 +1994,18 @@ pub fn index(self: *Terminal) !void {
// Slow path for left and right scrolling region margins.
if (self.scrolling_region.left != 0 or
self.scrolling_region.right != self.cols - 1 or
// PERF(mitchellh): If we have an SGR background set then
// we need to preserve that background in our erased rows.
// scrollUp does that but eraseRowBounded below does not.
// However, scrollUp is WAY slower. We should optimize this
// case to work in the eraseRowBounded codepath and remove
// this check.
!screen.blankCell().isZero())
self.scrolling_region.right != self.cols - 1)
{
try self.scrollUp(1);
return;
}
// Otherwise use a fast path function from PageList to efficiently
// scroll the contents of the scrolling region.
// Preserve old cursor just for assertions
const old_cursor = screen.cursor;
try screen.pages.eraseRowBounded(
.{ .active = .{ .y = self.scrolling_region.top } },
// Otherwise use a fast path function to efficiently scroll
// the contents of the scrolling region.
try screen.cursorScrollRegionUp(
self.scrolling_region.bottom - self.scrolling_region.top,
);
// eraseRow and eraseRowBounded will end up moving the cursor pin
// up by 1, so we need to move it back down. A `cursorReload`
// would be better option but this is more efficient and this is
// a super hot path so we do this instead.
assert(screen.cursor.x == old_cursor.x);
assert(screen.cursor.y == old_cursor.y);
screen.cursor.y -= 1;
screen.cursorDown(1);
// The operations above can prune our cursor style so we need to
// update. This should never fail because the above can only FREE
// memory.
screen.manualStyleUpdate() catch |err| {
std.log.warn("deleteLines manualStyleUpdate err={}", .{err});
screen.cursor.style = .{};
screen.manualStyleUpdate() catch unreachable;
};
return;
}
@@ -2186,9 +2164,17 @@ pub fn scrollUp(self: *Terminal, count: usize) !void {
// If our scroll region is at the top and we have no left/right
// margins then we move the scrolled out text into the scrollback.
//
// If our screen doesn't retain scrollback (e.g. the alternate
// screen) then creating scrollback is pure overhead, so we use the
// deleteLines path below instead, unless the region is the full
// screen where cursorScrollAbove has a specialized fast path
// (cursorDownScroll) for scrolling without scrollback.
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
self.scrolling_region.right == self.cols - 1)
self.scrolling_region.right == self.cols - 1 and
(!self.screens.active.no_scrollback or
self.scrolling_region.bottom == self.rows - 1))
{
// Scrolling dirties the images because it updates their placements pins.
if (comptime build_options.kitty_graphics) {
@@ -8940,6 +8926,128 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" {
}
}
test "Terminal: index bottom of scroll region with top margin and background SGR" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
try t.printString("1\n2\n3\n4\n5");
t.setTopAndBottomMargin(2, 4);
t.setCursorPos(4, 1);
try t.setAttribute(.{ .direct_color_bg = .{
.r = 0xFF,
.g = 0,
.b = 0,
} });
try t.index();
// The region (rows 2-4) scrolled up, rows outside are unchanged.
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("1\n3\n4\n\n5", str);
}
// The cursor is on the new blank row.
try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.y);
// The new blank row must be filled with our background color.
for (0..t.cols) |x| {
const list_cell = t.screens.active.pages.getCell(.{ .active = .{
.x = @intCast(x),
.y = 3,
} }).?;
try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 0xFF,
.g = 0,
.b = 0,
}, list_cell.cell.content.color_rgb);
}
}
test "Terminal: index bottom of alt screen full region" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 3, .cols = 5 });
defer t.deinit(alloc);
try t.switchScreenMode(.@"1049", true);
try t.printString("A\nB\nC");
try t.index();
t.carriageReturn();
try t.print('D');
// Content scrolled up and the scrolled-out row is discarded, NOT
// moved into scrollback (the alt screen has none).
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("B\nC\nD", str);
}
{
const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("B\nC\nD", str);
}
// Primary screen is untouched.
try t.switchScreenMode(.@"1049", false);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("", str);
}
}
test "Terminal: index bottom of alt screen top region" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
try t.switchScreenMode(.@"1049", true);
try t.printString("1\n2\n3\n4\n5");
// Region at the top of the screen, excluding the last row. On the
// alt screen this must NOT create scrollback.
t.setTopAndBottomMargin(1, 4);
t.setCursorPos(4, 1);
try t.index();
try t.print('X');
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
}
{
const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
}
}
test "Terminal: scrollUp top region no scrollback" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("A\nB\nC\nD\nE");
t.setTopAndBottomMargin(1, 3);
try t.scrollUp(1);
// The region scrolled and the scrolled-out row is discarded.
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("B\nC\n\nD\nE", str);
}
{
const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
defer testing.allocator.free(str);
try testing.expectEqualStrings("B\nC\n\nD\nE", str);
}
}
test "Terminal: cursorUp basic" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });