Throughput improvements when processing large blocks of text

Mostly comes from detecting the case of runs of ASCII printable
characters and using a special fast path for them. Improves benchmarks
as per benchmark.py by 15-30% for text heavy workloads
This commit is contained in:
Kovid Goyal 2026-08-16 17:47:56 +05:30
parent 69025901b3
commit e62ec6d540
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
5 changed files with 222 additions and 22 deletions

View file

@ -22,9 +22,11 @@ from kitty.utils import read_screen_size
BENCHMARK_WINDOW_ID = 1
ALL_BENCHMARKS = ('ascii', 'unicode', 'unique_unicode', 'csi', 'images', 'long_escape_codes')
def perf_output() -> str:
return os.path.join(tempfile.gettempdir(), 'kitty-benchmark.perf')
# Set by the re-exec wrapper so we don't recurse when --perf is in argv.
_UNDER_PERF_ENV = '_KITTY_BENCHMARK_UNDER_PERF'

View file

@ -186,6 +186,8 @@ Detailed list of changes
- Improve throughput when processing large amounts of text with a scrollback buffer much larger than the CPU cache by ~35% by prefetching scrollback memory before it is written to
- Improve throughput when processing large amounts of text by ~50% for ASCII and ~15% for Unicode by writing runs of plain ASCII chars to the screen in batches and skipping unnecessary bookkeeping in the scrolling and tab handling hot paths
- The :opt:`scrollbar` option now takes a new value ``scrolled-or-hovered`` to also show the scrollbar when the mouse moves over the scrollbar region (:pull:`10345`)
- A new :code:`kitten @ screenshot` remote control command to take a pixel perfect PNG screenshot of an OS Window, tab or window

View file

@ -439,18 +439,18 @@ index_selection(const Screen *self, Selections *selections, bool up, index_type
}
#define INDEX_DOWN \
linebuf_reverse_index(self->linebuf, top, bottom); \
linebuf_clear_line(self->linebuf, top, true); \
if (self->linebuf == self->main_linebuf && self->last_visited_prompt.is_set) { \
if (self->last_visited_prompt.scrolled_by > 0) self->last_visited_prompt.scrolled_by--; \
else if (self->last_visited_prompt.y < self->lines - 1) self->last_visited_prompt.y++; \
else self->last_visited_prompt.is_set = false; \
} \
INDEX_GRAPHICS(1) \
self->is_dirty = true; \
index_selection(self, &self->selections, false, top, bottom); \
clear_selection(&self->url_ranges);
#define INDEX_DOWN \
linebuf_reverse_index(self->linebuf, top, bottom); \
linebuf_clear_line(self->linebuf, top, true); \
if (self->linebuf == self->main_linebuf && self->last_visited_prompt.is_set) { \
if (self->last_visited_prompt.scrolled_by > 0) self->last_visited_prompt.scrolled_by--; \
else if (self->last_visited_prompt.y < self->lines - 1) self->last_visited_prompt.y++; \
else self->last_visited_prompt.is_set = false; \
} \
INDEX_GRAPHICS(1) \
self->is_dirty = true; \
if (UNLIKELY(self->selections.count)) index_selection(self, &self->selections, false, top, bottom); \
if (UNLIKELY(self->url_ranges.count || self->url_ranges.in_progress)) clear_selection(&self->url_ranges);
static void
@ -844,8 +844,8 @@ selection_has_screen_line(const Selections *selections, const int y) {
static void
clear_intersecting_selections(Screen *self, index_type y) {
if (selection_has_screen_line(&self->selections, y)) clear_selection(&self->selections);
if (selection_has_screen_line(&self->url_ranges, y)) clear_selection(&self->url_ranges);
if (UNLIKELY(self->selections.count) && selection_has_screen_line(&self->selections, y)) clear_selection(&self->selections);
if (UNLIKELY(self->url_ranges.count) && selection_has_screen_line(&self->url_ranges, y)) clear_selection(&self->url_ranges);
}
static void
@ -867,8 +867,13 @@ init_segmentation_state(Screen *self, text_loop_state *s) {
init_prev_cell(self, s);
grapheme_segmentation_reset(&s->seg);
if (s->prev.cc) {
text_in_cell(s->prev.cc, self->text_cache, self->lc);
for (index_type i = 0; i < self->lc->count; i++) s->seg = grapheme_segmentation_step(s->seg, char_props_for(self->lc->chars[i]));
if (LIKELY(!s->prev.cc->ch_is_idx)) {
// single codepoint in cell, no need for the ListOfChars machinery
s->seg = grapheme_segmentation_step(s->seg, char_props_for(s->prev.cc->ch_or_idx));
} else {
text_in_cell(s->prev.cc, self->text_cache, self->lc);
for (index_type i = 0; i < self->lc->count; i++) s->seg = grapheme_segmentation_step(s->seg, char_props_for(self->lc->chars[i]));
}
}
}
@ -1064,6 +1069,8 @@ screen_garbage_collect_text_cache(Screen *self) {
if (self->overlay_line.original_line.cpu_cells)
text_cache_gc_process_cells(self->text_cache, gc, self->overlay_line.original_line.cpu_cells, self->overlay_line.xnum);
tc_gc_end(self->text_cache, gc);
// the GC remaps all cache indices so the memoized tab indices are stale
memset(&self->tab_cache, 0, sizeof(self->tab_cache));
}
static bool
@ -1304,6 +1311,35 @@ draw_text_loop(Screen *self, const uint32_t *chars, size_t num_chars, text_loop_
draw_control_char(self, s, ch);
continue;
}
if (LIKELY(!self->charset.current && !self->modes.mIRM && self->cursor->x < self->columns)) {
// Batched fast path: write a run of printable ASCII chars into the
// current line in one go, keeping all state in locals so it is not
// reloaded for every char. Stops at the first char needing the
// scalar path: non-ASCII, control char or an existing multicell cell.
const size_t limit = MIN(num_chars - i, (size_t)(self->columns - self->cursor->x));
CPUCell *cp = s->cp + self->cursor->x;
size_t n = 0;
while (n < limit && (chars[i + n] - 32u) < 95u && !cp[n].is_multicell) n++;
if (n) {
GPUCell *gp = s->gp + self->cursor->x;
const CPUCell cc = s->cc;
const GPUCell g = s->g;
for (size_t j = 0; j < n; j++) {
cp[j] = cc;
cell_set_char(cp + j, chars[i + j]);
}
for (size_t j = 0; j < n; j++) gp[j] = g;
self->last_graphic_char = chars[i + n - 1];
s->prev.y = self->cursor->y;
s->prev.x = self->cursor->x + n - 1;
s->prev.cc = cp + n - 1;
self->cursor->x += n;
s->seg = (GraphemeSegmentationResult){.grapheme_break = GBP_None};
i += n - 1;
continue;
}
// n == 0: the cell under the cursor is a multicell, let the scalar path handle it
}
char_width = 1;
s->seg = (GraphemeSegmentationResult){.grapheme_break = GBP_None};
} else {
@ -2262,10 +2298,17 @@ screen_tab(Screen *self) {
CPUCell *c = cpu_cell + i;
cell_set_char(c, ' ');
}
self->lc->count = 2;
self->lc->chars[0] = '\t';
self->lc->chars[1] = diff;
cell_set_chars(cpu_cell, self->text_cache, self->lc);
char_type idx;
if (diff < arraysz(self->tab_cache.idx_plus_1) && self->tab_cache.idx_plus_1[diff]) idx = self->tab_cache.idx_plus_1[diff] - 1;
else {
self->lc->count = 2;
self->lc->chars[0] = '\t';
self->lc->chars[1] = diff;
idx = tc_get_or_insert_chars(self->text_cache, self->lc);
if (diff < arraysz(self->tab_cache.idx_plus_1)) self->tab_cache.idx_plus_1[diff] = idx + 1;
}
cpu_cell->ch_or_idx = idx;
cpu_cell->ch_is_idx = true;
}
}
self->cursor->x = found;
@ -2397,8 +2440,8 @@ screen_cursor_to_column(Screen *self, unsigned int column) {
} \
linebuf_clear_line(self->linebuf, bottom, true); \
self->is_dirty = true; \
index_selection(self, &self->selections, true, top, bottom); \
clear_selection(&self->url_ranges);
if (UNLIKELY(self->selections.count)) index_selection(self, &self->selections, true, top, bottom); \
if (UNLIKELY(self->url_ranges.count || self->url_ranges.in_progress)) clear_selection(&self->url_ranges);
void
screen_index(Screen *self) {

View file

@ -214,6 +214,12 @@ typedef struct {
} paused_rendering;
CharsetState charset;
ListOfChars *lc;
struct {
// Memoized text cache indices for the ('\t', diff) text written by
// screen_tab(), keyed by diff. Entries are idx + 1 with 0 meaning
// unset. Must be cleared when the text cache is garbage collected.
char_type idx_plus_1[16];
} tab_cache;
monotonic_t parsing_at;
ExtraCursors extra_cursors;
struct {

View file

@ -48,6 +48,153 @@ class TestScreen(BaseTest):
self.ae(str(s.line(4)), 'ab123')
self.ae((s.cursor.x, s.cursor.y), (2, 4))
def test_draw_batched_ascii_run(self):
# The batched fast path for runs of printable ASCII in draw_text_loop
# must behave identically to drawing char by char
# a run crossing multiple line wraps
s = self.create_screen()
s.draw('abcdefghijkl')
self.ae(str(s.line(0)), 'abcde')
self.ae(str(s.line(1)), 'fghij')
self.ae(str(s.line(2)), 'kl')
self.assertTrue(s.linebuf.is_continued(1))
self.assertTrue(s.linebuf.is_continued(2))
self.ae((s.cursor.x, s.cursor.y), (2, 2))
# without line-wrap the run must stop at the right margin and
# overwrite the last column
s = self.create_screen()
s.reset_mode(DECAWM)
s.draw('abcdefgh')
self.ae(str(s.line(0)), 'abcdh')
self.ae((s.cursor.x, s.cursor.y), (5, 0))
# insert mode must bypass the batch and insert per char
s = self.create_screen()
s.draw('12345')
s.cursor_position(1, 1)
s.set_mode(IRM)
s.draw('abc')
self.ae(str(s.line(0)), 'abc12')
self.ae((s.cursor.x, s.cursor.y), (3, 0))
# charset translation must still be applied to every char of a run
s = self.create_screen()
parse_bytes(s, b'\x1b(0qqq\x1b(B')
self.ae(str(s.line(0)), '─' * 3)
# overwriting the first cell of a wide char nukes the whole wide char
s = self.create_screen()
s.draw('ニチ')
s.cursor_position(1, 1)
s.draw('ab')
self.ae(str(s.line(0)), 'abチ')
# overwriting the second cell of a wide char replaces it with spaces
s = self.create_screen()
s.draw('ニチ')
s.cursor_position(1, 2)
s.draw('a')
self.ae(str(s.line(0)), ' aチ')
# a run drawn over a multiline multicell char must be handled by the
# scalar path
s = self.create_screen()
draw_multicell(s, 'X', scale=2)
before_line1 = str(s.line(1))
s.cursor_position(1, 1)
s.draw('abc')
self.ae(str(s.line(0)), 'abc')
self.ae(str(s.line(1)), before_line1)
# drawing on a lower row of a multiline char moves the cursor past it
s = self.create_screen()
draw_multicell(s, 'X', scale=2)
s.cursor_position(2, 1)
s.draw('ab')
self.ae(str(s.line(0)), 'X')
self.ae((s.cursor.x, s.cursor.y), (4, 1))
# REP must repeat the last char of a batched run
s = self.create_screen()
s.draw('abc')
parse_bytes(s, b'\x1b[2b')
self.ae(str(s.line(0)), 'abccc')
# a combining char following a run attaches to the run's last char,
# also when the run ends exactly at the right margin
s = self.create_screen()
s.draw('ab́cd')
self.ae(str(s.line(0)), 'ab́cd')
self.ae((s.cursor.x, s.cursor.y), (4, 0))
s = self.create_screen()
s.draw('abcdé')
self.ae(str(s.line(0)), 'abcdé')
self.ae((s.cursor.x, s.cursor.y), (5, 0))
# a wide ZWJ emoji following a run
s = self.create_screen()
s.draw('ab\U0001f468‍\U0001f466')
self.ae(str(s.line(0)), 'ab\U0001f468‍\U0001f466')
self.ae((s.cursor.x, s.cursor.y), (4, 0))
# the active hyperlink must be applied to all cells of a run
s = self.create_screen()
parse_bytes(s, '\x1b]8;;http://x.com\x1b\\'.encode('utf-8'))
s.draw('abc')
parse_bytes(s, '\x1b]8;;\x1b\\'.encode('utf-8'))
s.draw('d')
self.ae(s.line(0).hyperlink_ids(), (1, 1, 1, 0, 0))
# SGR attributes must be applied to all cells of a run
s = self.create_screen()
parse_bytes(s, b'\x1b[31mabc\x1b[39md')
self.ae(s.line(0).as_ansi(), '\x1b[31mabc\x1b[39md')
# drawing over a selection must clear it
s = self.create_screen()
s.draw('hello')
s.start_selection(0, 0)
s.update_selection(4, 0)
self.ae(''.join(s.text_for_selection()), 'hello')
s.cursor_position(1, 1)
s.draw('bye')
self.ae(s.text_for_selection(), ())
# also when the run wraps onto the selected line
s = self.create_screen()
s.draw('hello')
s.carriage_return(), s.linefeed()
s.draw('world')
s.start_selection(0, 1)
s.update_selection(4, 1)
self.ae(''.join(s.text_for_selection()), 'world')
s.cursor_position(1, 1)
s.draw('a' * (s.columns + 1))
self.ae(s.text_for_selection(), ())
# scrolling must keep an existing selection anchored to its content
s = self.create_screen()
for i in range(s.lines):
if i:
s.carriage_return(), s.linefeed()
s.draw(str(i) * s.columns)
s.start_selection(0, 0)
s.update_selection(4, 0)
self.ae(''.join(s.text_for_selection()), '00000')
s.linefeed()
self.ae(''.join(s.text_for_selection()), '00000')
# tabs at various offsets, exercising the memoized tab text lookup
s = self.create_screen(cols=32)
s.draw('a\tbc\td\te')
self.ae((s.cursor.x, s.cursor.y), (25, 0))
self.ae(s.line(0).as_ansi(), 'a\tbc\td\te')
# a tab spanning more columns than the memo covers
s = self.create_screen(cols=40)
parse_bytes(s, b'\x1b[3g') # clear all tab stops
s.draw('a\tb')
self.ae((s.cursor.x, s.cursor.y), (40, 0))
self.ae(s.line(0).as_ansi(), 'a\tb')
def test_draw_char(self):
# Test in line-wrap, non-insert mode
s = self.create_screen()