From 075c87d52a4d90c0f00f8ba7ba849677771a9be3 Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 31 Jul 2026 18:08:45 +0200 Subject: vt: defer scrolls and batch framebuffer pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming output scrolled one framebuffer push per line (each a ~3-8 MB write-only copy). The vt now scrolls the logical grid instantly in RAM and defers the display shift: N pending scrolls are pushed together as one shift_up_n — one push regardless of batch size. - vt: PENDING_SCROLLS counter; scroll_up shifts CELLS only; flush renders nothing while pending (the diff would otherwise re-rasterise the whole screen per line); sync_scroll() does one gop::shift_up_n + RENDERED mirror + normal diff flush (which re-blits just the newly exposed rows and the cursor). - Sync triggers: batch of 16 rows, ~40 ms of stream pause (100 Hz TICKS), any CSI sequence (full-screen apps like the editor render immediately), clear_all/set_cursor_visible, interactive input (tty read_line/read_byte), and the shell before each prompt. - gop: shift_up → shift_up_n(n) (one shadow memcpy + one push). Verified in QEMU: mmap/hexdump streaming renders in coherent batches with the prompt syncing at the end; clear wipes clean; the editor scrolls and redraws immediately (45-line file, gutter + status bar intact, cursor correct); save/exit/cat round-trip unchanged. Release and dev builds pass. --- CONTEXT.md | 24 ++++++++++++------- rsnix/src/gop.rs | 31 ++++++++++++++----------- rsnix/src/tty.rs | 6 +++++ rsnix/src/vt.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- shell/src/lib.rs | 3 +++ 5 files changed, 108 insertions(+), 27 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 32a6eca..8fa4025 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -163,14 +163,22 @@ Single Rust crate (`x86_64-unknown-uefi` target). Module breakdown: ## Known issues / perf notes - **Scroll cost** — scrolling still shifts the whole framebuffer (a - ~3-8 MB pixel copy per row), but a RAM backbuffer (`gop::SHADOW`) - makes it fast on real hardware: every raster write goes to the shadow - too, and scrolling is a cached shadow memcpy + one write-only push to - the display. The display is never *read* — that was the killer, since - real-hardware GOP framebuffers are uncacheable/write-combining and - uncached reads cost a bus round-trip per cache line. Still on the - someday list: a pan-based ring so scrolling never touches the whole - framebuffer at all. + ~3-8 MB pixel copy per row), but two mechanisms make it fast on real + hardware: + 1. a RAM backbuffer (`gop::SHADOW`): every raster write goes to the + shadow too; scrolling is a cached shadow memcpy + one write-only + push to the display — the display is never *read* (uncached reads + were the killer, since real-hardware GOP framebuffers are + uncacheable/write-combining). + 2. deferred scroll batching (`vt::PENDING_SCROLLS`): the logical grid + scrolls instantly in RAM while the framebuffer shift+push is + deferred; N pending scrolls cost one push. Bursts of streaming + output (hexdump/cat) push every ~16 rows (or after ~40 ms of + pause), and the shell syncs before each prompt. Any CSI sequence + (full-screen apps like the editor) or interactive input pushes + immediately. + Still on the someday list: a pan-based ring so scrolling never touches + the whole framebuffer at all. - **Editor redraw** re-blits changed cells per keystroke (fine at 1280×800); the "optimize drawing someday" item. - **ramfs only**: edited files live in memory, lost on reboot until disk diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index fa5378e..fa14eae 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -149,14 +149,16 @@ impl Framebuffer { } } - /// Shift the whole framebuffer up one row (16 pixels) and clear the - /// exposed bottom row. Called by `vt::scroll_up`; the vt then re-blits - /// only the new bottom row. + /// Shift the framebuffer content up by `n` text rows (16 pixels each) + /// and clear the exposed bottom rows. Called by `vt` when deferred + /// scrolls are pushed to the display; `n` can batch many scrolls into + /// one write-only push. /// /// With the RAM backbuffer, this is a cached shadow memcpy + a /// write-only push to the display — the display itself is never read /// (reads are uncached on real hardware and dominate the old path). - pub(crate) fn shift_up(&self) { + pub(crate) fn shift_up_n(&self, n: usize) { + let n = n.max(1).min(self.height / 16); // sanity let row_bytes = self.pitch * 16; let total = self.pitch * self.height; let row_words = row_bytes / 4; @@ -165,17 +167,17 @@ impl Framebuffer { if SHADOW.is_null() { // No shadow (allocation failed): fall back to shifting the // display directly. - ptr::copy(self.base.add(row_bytes), self.base, total - row_bytes); - let last_row = self.base.add(total - row_bytes); - for i in 0..row_words { + ptr::copy(self.base.add(n * row_bytes), self.base, total - n * row_bytes); + let last_row = self.base.add(total - n * row_bytes); + for i in 0..(n * row_words) { ptr::write_volatile(last_row.add(i * 4) as *mut u32, self.bg); } return; } - // Shift the shadow up one row (cached RAM — fast). - ptr::copy(SHADOW.add(row_words), SHADOW, words - row_words); - // Clear the exposed bottom row in the shadow. - for i in (words - row_words)..words { + // Shift the shadow up n rows (cached RAM — fast). + ptr::copy(SHADOW.add(n * row_words), SHADOW, words - n * row_words); + // Clear the exposed bottom rows in the shadow. + for i in (words - n * row_words)..words { *SHADOW.add(i) = self.bg; } // Push the shadow to the display: write-only, no reads. @@ -372,10 +374,11 @@ pub(crate) fn blit_glyph(col: usize, row: usize, ch: u16, fg: (u8, u8, u8), bg: } } -/// Shift the framebuffer up one row and clear the bottom row. -pub(crate) fn shift_up() { +/// Shift the framebuffer content up `n` text rows and clear the exposed +/// bottom rows. `n` batches deferred scrolls into one push. +pub(crate) fn shift_up_n(n: usize) { if let Some(fb) = unsafe { FB.as_ref() } { - fb.shift_up(); + fb.shift_up_n(n); } } diff --git a/rsnix/src/tty.rs b/rsnix/src/tty.rs index 8d745b3..b6b6cc8 100644 --- a/rsnix/src/tty.rs +++ b/rsnix/src/tty.rs @@ -37,6 +37,9 @@ static mut RAW_LEN: usize = 0; /// Read one line in canonical mode: echo every key, ^C cancels the line, /// ^D on an empty line is EOF. Echo goes to the terminal (GOP) and serial. pub fn read_line() -> LineInput { + // Interactive input: make sure deferred scrolls are pushed first so + // the screen shows the current state (no-op when nothing is pending). + crate::vt::sync_scroll(); let mut buf = strbuf::String::::new(); loop { match ps2::poll_key() { @@ -112,6 +115,9 @@ pub fn interrupt_pressed() -> bool { /// Blocking read of the next raw-mode byte. Keys arrive translated to the /// terminal byte protocol; multi-byte sequences are delivered in order. pub fn read_byte() -> u8 { + // Interactive (raw) input: push deferred scrolls so the full-screen + // app draws over a current framebuffer. + crate::vt::sync_scroll(); loop { if let Some(b) = pop_raw() { return b; diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs index 4db08fc..88129f2 100644 --- a/rsnix/src/vt.rs +++ b/rsnix/src/vt.rs @@ -119,6 +119,7 @@ pub fn size() -> (usize, usize) { } pub fn set_cursor_visible(v: bool) { + sync_scroll(); unsafe { CURSOR_VISIBLE = v; } @@ -261,6 +262,12 @@ fn write_char(c: char) { } fn csi_final(b: u8) { + // A CSI sequence means a full-screen app is actively rendering — + // push any deferred scrolls so it draws over a current framebuffer + // (streaming shell output never emits CSI, so it stays batched). + if unsafe { PENDING_SCROLLS } > 0 { + sync_scroll(); + } match b { // Cursor position (1-based; 0 means 1) b'H' | b'f' => { @@ -400,18 +407,62 @@ fn newline() { } } -/// Shift the whole screen up one row: cell grids in memory, pixels on the -/// framebuffer. Only the exposed bottom row gets re-rasterised by `flush`. +/// Deferred scrolls: the logical grid scrolls instantly (RAM), but the +/// framebuffer shift+push is batched. N pending scrolls cost one push, +/// so a burst of streaming output no longer writes the whole framebuffer +/// once per line. `flush` renders nothing while scrolls are pending; +/// `sync_scroll` pushes the whole batch and lets the normal diff re-blit +/// only the newly-exposed rows. +const SCROLL_BATCH: usize = 16; +static mut PENDING_SCROLLS: usize = 0; +static mut LAST_SYNC_TICK: u16 = 0; + +/// Shift the logical grid up one row; defer the framebuffer shift. fn scroll_up() { let n = unsafe { ROWS * COLS }; let cols = unsafe { COLS }; cells().copy_within(cols..n, 0); - rendered_cells().copy_within(cols..n, 0); for c in 0..cols { cells()[n - cols + c] = BLANK; - rendered_cells()[n - cols + c] = BLANK; } - gop::shift_up(); + unsafe { + PENDING_SCROLLS += 1; + // Batch: sync when enough scrolls piled up, or when the stream + // pauses (~40 ms at the 100 Hz timer) so interactive output still + // appears promptly. + const TICK_LIMIT: u16 = 4; + let now = crate::interrupts::TICKS.load(core::sync::atomic::Ordering::Relaxed); + if PENDING_SCROLLS >= SCROLL_BATCH + || now.wrapping_sub(LAST_SYNC_TICK) > TICK_LIMIT + { + sync_scroll(); + } + } +} + +/// Push deferred scrolls to the display in one batch: shift the shadow +/// (and the rendered grid, which mirrors it) up by the pending count and +/// do a single write-only push, then let the normal flush diff re-blit +/// the newly-exposed rows and redraw the cursor. +pub fn sync_scroll() { + unsafe { + let n = PENDING_SCROLLS; + if n == 0 { + return; + } + let rows = ROWS; + let cols = COLS; + gop::shift_up_n(n); + // Mirror the pushed content in RENDERED (it describes the fb). + let total = rows * cols; + rendered_cells().copy_within(n * cols..total, 0); + for i in (total - n * cols)..total { + rendered_cells()[i] = BLANK; + } + PENDING_SCROLLS = 0; + LAST_SYNC_TICK = crate::interrupts::TICKS.load(core::sync::atomic::Ordering::Relaxed); + flush(); + } } // --------------------------------------------------------------------------- @@ -473,6 +524,7 @@ pub fn clear_all() { unsafe { SGR_FG = 7; SGR_BG = 0; + sync_scroll(); erase_all(); CURSOR_R = 0; CURSOR_C = 0; @@ -490,6 +542,15 @@ pub fn clear_all() { /// (scrolling) or full clears can never leave a stale inverse block that /// the diff can't see (blank-vs-blank). fn flush() { + unsafe { + // Deferred scrolls pending: the framebuffer lags the logical grid. + // Render nothing until `sync_scroll` pushes the batch — the diff + // would otherwise see every cell as changed and re-rasterise the + // whole screen once per line. + if PENDING_SCROLLS > 0 { + return; + } + } let rows = unsafe { ROWS }; let cols = unsafe { COLS }; let n = rows * cols; diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 4601b98..9644f01 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -20,6 +20,9 @@ pub fn run() -> ! { ); loop { + // Push any deferred scrolls so the prompt renders on a current + // framebuffer (e.g. after a streaming command's batch of output). + rsnix::vt::sync_scroll(); // Print prompt gop_print!("> "); serial_print!("> "); -- cgit v1.2.3