diff --git a/src/font/main.zig b/src/font/main.zig index e092de03f..72c3aa9cf 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -14,6 +14,7 @@ pub const Glyph = @import("Glyph.zig"); pub const Metrics = face.Metrics; pub const shape = @import("shape.zig"); pub const Shaper = shape.Shaper; +pub const ShaperCache = shape.Cache; pub const SharedGrid = @import("SharedGrid.zig"); pub const SharedGridSet = @import("SharedGridSet.zig"); pub const sprite = @import("sprite.zig"); diff --git a/src/font/shape.zig b/src/font/shape.zig index 469cf9aaf..1e5dd1a9f 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -4,6 +4,7 @@ pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); pub const web_canvas = @import("shaper/web_canvas.zig"); pub usingnamespace @import("shaper/run.zig"); +pub const Cache = @import("shaper/Cache.zig"); /// Shaper implementation for our compile options. pub const Shaper = switch (options.backend) { @@ -56,3 +57,8 @@ pub const Options = struct { /// support applying features globally. features: []const []const u8 = &.{}, }; + +test { + _ = Cache; + _ = Shaper; +} diff --git a/src/font/shaper/Cache.zig b/src/font/shaper/Cache.zig new file mode 100644 index 000000000..9da736621 --- /dev/null +++ b/src/font/shaper/Cache.zig @@ -0,0 +1,86 @@ +//! This structure caches the shaped cells for a given text run. +//! +//! At one point, shaping was the most expensive part of rendering text +//! (accounting for 96% of frame time on my machine). To speed it up, this +//! was introduced so that shaping results can be cached depending on the +//! run. +//! +//! The cache key is the text run. The text run builds its own hash value +//! based on the font, style, codepoint, etc. This just utilizes the hash that +//! the text run provides. +pub const Cache = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const font = @import("../main.zig"); +const lru = @import("../../lru.zig"); + +const log = std.log.scoped(.font_shaper_cache); + +/// Our LRU is the run hash to the shaped cells. +const LRU = lru.AutoHashMap(u64, []font.shape.Cell); + +/// The cache of shaped cells. +map: LRU, + +pub fn init() Cache { + // Note: this is very arbitrary. Increasing this number will increase + // the cache hit rate, but also increase the memory usage. We should do + // some more empirical testing to see what the best value is. + const capacity = 1024; + + return .{ .map = LRU.init(capacity) }; +} + +pub fn deinit(self: *Cache, alloc: Allocator) void { + var it = self.map.map.iterator(); + while (it.next()) |entry| alloc.free(entry.value_ptr.*.data.value); + self.map.deinit(alloc); +} + +/// Get the shaped cells for the given text run or null if they are not +/// in the cache. +pub fn get(self: *const Cache, run: font.shape.TextRun) ?[]const font.shape.Cell { + return self.map.get(run.hash); +} + +/// Insert the shaped cells for the given text run into the cache. The +/// cells will be duplicated. +pub fn put( + self: *Cache, + alloc: Allocator, + run: font.shape.TextRun, + cells: []const font.shape.Cell, +) Allocator.Error!void { + const copy = try alloc.dupe(font.shape.Cell, cells); + const gop = try self.map.getOrPut(alloc, run.hash); + if (gop.evicted) |evicted| { + log.debug("evicted shaped cells for text run hash={}", .{run.hash}); + alloc.free(evicted.value); + } + gop.value_ptr.* = copy; +} + +pub fn count(self: *const Cache) usize { + return self.map.map.count(); +} + +test Cache { + const testing = std.testing; + const alloc = testing.allocator; + + var c = Cache.init(); + defer c.deinit(alloc); + + var run: font.shape.TextRun = undefined; + run.hash = 1; + try testing.expect(c.get(run) == null); + try c.put(alloc, run, &.{ + .{ .x = 0, .glyph_index = 0 }, + .{ .x = 1, .glyph_index = 1 }, + }); + + const actual = c.get(run).?; + try testing.expect(actual.len == 2); +} diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 9441fae22..ef55ba981 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -4,11 +4,21 @@ const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const shape = @import("../shape.zig"); const terminal = @import("../../terminal/main.zig"); +const autoHash = std.hash.autoHash; +const Hasher = std.hash.Wyhash; /// A single text run. A text run is only valid for one Shaper instance and /// until the next run is created. A text run never goes across multiple /// rows in a terminal, so it is guaranteed to always be one line. pub const TextRun = struct { + /// A unique hash for this run. This can be used to cache the shaping + /// results. We don't provide a means to compare actual values if the + /// hash is the same, so we should continue to improve this hash to + /// lower the chance of hash collisions if they become a problem. If + /// there are hash collisions, it would result in rendering issues but + /// the core data would be correct. + hash: u64, + /// The offset in the row where this run started offset: u16, @@ -54,6 +64,9 @@ pub const RunIterator = struct { // Allow the hook to prepare try self.hooks.prepare(); + // Initialize our hash for this run. + var hasher = Hasher.init(0); + // Let's get our style that we'll expect for the run. const style = self.row.style(&cells[self.i]); @@ -211,12 +224,13 @@ pub const RunIterator = struct { // If we're a fallback character, add that and continue; we // don't want to add the entire grapheme. if (font_info.fallback) |cp| { - try self.hooks.addCodepoint(cp, @intCast(cluster)); + try self.addCodepoint(&hasher, cp, @intCast(cluster)); continue; } // Add all the codepoints for our grapheme - try self.hooks.addCodepoint( + try self.addCodepoint( + &hasher, if (cell.codepoint() == 0) ' ' else cell.codepoint(), @intCast(cluster), ); @@ -225,7 +239,7 @@ pub const RunIterator = struct { for (cps) |cp| { // Do not send presentation modifiers if (cp == 0xFE0E or cp == 0xFE0F) continue; - try self.hooks.addCodepoint(cp, @intCast(cluster)); + try self.addCodepoint(&hasher, cp, @intCast(cluster)); } } } @@ -233,10 +247,17 @@ pub const RunIterator = struct { // Finalize our buffer try self.hooks.finalize(); + // Add our length to the hash as an additional mechanism to avoid collisions + autoHash(&hasher, j - self.i); + + // Add our font index + autoHash(&hasher, current_font); + // Move our cursor. Must defer since we use self.i below. defer self.i = j; return TextRun{ + .hash = hasher.final(), .offset = @intCast(self.i), .cells = @intCast(j - self.i), .grid = self.grid, @@ -244,6 +265,12 @@ pub const RunIterator = struct { }; } + fn addCodepoint(self: *RunIterator, hasher: anytype, cp: u32, cluster: u32) !void { + autoHash(hasher, cp); + autoHash(hasher, cluster); + try self.hooks.addCodepoint(cp, cluster); + } + /// Find a font index that supports the grapheme for the given cell, /// or null if no such font exists. /// diff --git a/src/lru.zig b/src/lru.zig index a334a783e..bb7977f00 100644 --- a/src/lru.zig +++ b/src/lru.zig @@ -150,7 +150,7 @@ pub fn HashMap( } /// Get a value for a key. - pub fn get(self: *Self, key: K) ?V { + pub fn get(self: *const Self, key: K) ?V { if (@sizeOf(Context) != 0) { @compileError("getContext must be used."); } @@ -158,7 +158,7 @@ pub fn HashMap( } /// See get - pub fn getContext(self: *Self, key: K, ctx: Context) ?V { + pub fn getContext(self: *const Self, key: K, ctx: Context) ?V { const node = self.map.getContext(key, ctx) orelse return null; return node.data.value; } diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 251c700ba..e4528eeca 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -98,6 +98,7 @@ uniforms: mtl_shaders.Uniforms, /// The font structures. font_grid: *font.SharedGrid, font_shaper: font.Shaper, +font_shaper_cache: font.ShaperCache, /// The images that we may render. images: ImageMap = .{}, @@ -571,6 +572,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // Fonts .font_grid = options.font_grid, .font_shaper = font_shaper, + .font_shaper_cache = font.ShaperCache.init(), // Shaders .shaders = shaders, @@ -588,6 +590,7 @@ pub fn deinit(self: *Metal) void { self.cells.deinit(self.alloc); self.font_shaper.deinit(); + self.font_shaper_cache.deinit(self.alloc); self.config.deinit(); @@ -1666,6 +1669,12 @@ fn rebuildCells( cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, ) !void { + // const start = try std.time.Instant.now(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // std.log.warn("rebuildCells time={}us", .{end.since(start) / std.time.ns_per_us}); + // } + // Create an arena for all our temporary allocations while rebuilding var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); @@ -1728,7 +1737,24 @@ fn rebuildCells( if (shape_cursor) screen.cursor.x else null, ); while (try iter.next(self.alloc)) |run| { - for (try self.font_shaper.shape(run)) |shaper_cell| { + // Try to read the cells from the shaping cache if we can. + const shaper_cells = self.font_shaper_cache.get(run) orelse cache: { + const cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we continue + // because it is just a performance optimization, not a correctness + // issue. + self.font_shaper_cache.put(self.alloc, run, cells) catch |err| { + log.warn("error caching font shaping results err={}", .{err}); + }; + + // The cells we get from direct shaping are always owned by + // the shaper and valid until the next shaping call so we can + // just return them. + break :cache cells; + }; + + for (shaper_cells) |shaper_cell| { const coord: terminal.Coordinate = .{ .x = shaper_cell.x, .y = y, @@ -1829,6 +1855,11 @@ fn rebuildCells( x += if (cp.wide) 2 else 1; } } + + // Log some things + // log.debug("rebuildCells complete cached_runs={}", .{ + // self.font_shaper_cache.count(), + // }); } fn updateCell( diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 041947f32..2a845d602 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -74,6 +74,7 @@ gl_state: ?GLState = null, /// The font structures. font_grid: *font.SharedGrid, font_shaper: font.Shaper, +font_shaper_cache: font.ShaperCache, texture_greyscale_modified: usize = 0, texture_greyscale_resized: usize = 0, texture_color_modified: usize = 0, @@ -345,6 +346,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { .gl_state = gl_state, .font_grid = grid, .font_shaper = shaper, + .font_shaper_cache = font.ShaperCache.init(), .draw_background = options.config.background, .focused = true, .foreground_color = options.config.foreground, @@ -359,6 +361,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { pub fn deinit(self: *OpenGL) void { self.font_shaper.deinit(); + self.font_shaper_cache.deinit(self.alloc); { var it = self.images.iterator(); @@ -1013,7 +1016,24 @@ pub fn rebuildCells( if (shape_cursor) screen.cursor.x else null, ); while (try iter.next(self.alloc)) |run| { - for (try self.font_shaper.shape(run)) |shaper_cell| { + // Try to read the cells from the shaping cache if we can. + const shaper_cells = self.font_shaper_cache.get(run) orelse cache: { + const cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we continue + // because it is just a performance optimization, not a correctness + // issue. + self.font_shaper_cache.put(self.alloc, run, cells) catch |err| { + log.warn("error caching font shaping results err={}", .{err}); + }; + + // The cells we get from direct shaping are always owned by + // the shaper and valid until the next shaping call so we can + // just return them. + break :cache cells; + }; + + for (shaper_cells) |shaper_cell| { // If this cell falls within our preedit range then we skip it. // We do this so we don't have conflicting data on the same // cell.