Merge pull request #1725 from mitchellh/shaper-cache

Cache font shaping results
This commit is contained in:
Mitchell Hashimoto
2024-05-01 19:55:03 -07:00
committed by GitHub
7 changed files with 178 additions and 7 deletions

View File

@ -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");

View File

@ -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;
}

86
src/font/shaper/Cache.zig Normal file
View File

@ -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);
}

View File

@ -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.
///

View File

@ -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;
}

View File

@ -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(

View File

@ -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.