diff --git a/benchmark.py b/benchmark.py index f8f536cca..546247102 100755 --- a/benchmark.py +++ b/benchmark.py @@ -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' diff --git a/docs/changelog.rst b/docs/changelog.rst index bccf71f56..c42918f8e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/kitty/screen.c b/kitty/screen.c index 86e078219..5267c4266 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -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) { diff --git a/kitty/screen.h b/kitty/screen.h index d51bda9a9..51c3482ad 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -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 { diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index 26fda8507..40f338d99 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -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()