Move large per-thread canvas scratch out of static TLS (#117)

* Move large per-thread canvas scratch out of static TLS

- Add canvas.lazy_tls.LazyTls: per-thread scratch behind one TLS pointer, heap-allocated and default-initialized on a thread's first use
- Convert the planner/diff/cache scratch giants (advance cache, span wrap cache, frame planner arrays, image decode buffer, probe tables) to lazy per-thread state; only threads that actually plan frames pay for them
- Windows cloned the full static TLS template per thread (~6.5 MiB x every window-host/COM/accessibility/worker thread); the template now carries pointers instead

Co-authored-by: SunkenInTime <76637177+SunkenInTime@users.noreply.github.com>

* Add changelog fragment for the static-TLS working-set fix

- Working-set drop, .tls shrink, and smaller executables, told from the user's side

---------

Co-authored-by: SunkenInTime <76637177+SunkenInTime@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-07-12 19:38:07 -05:00
committed by GitHub
parent 87d859fd4f
commit 2d623ac4f6
17 changed files with 418 additions and 227 deletions
+1
View File
@@ -0,0 +1 @@
fix: **Per-thread memory no longer scales with the canvas scratch**: the render planner's fixed scratch buffers lived in static thread-local storage, so on Windows every thread the process spawned (window host, COM, accessibility, workers) privately committed a full ~6.5 MB copy — most of a small app's working set. The scratch now allocates lazily on the one thread that actually plans frames: a scaffolded counter app's private working set drops ~4x, its `.tls` section shrinks from ~6.5 MB to under 200 bytes, and the executable itself is ~6.5 MB smaller. Linux and macOS binaries shed the same per-thread TLS block.
+14 -7
View File
@@ -191,8 +191,14 @@ pub const DisplayList = struct {
/// half-full bound; small or oversized lists keep the linear scans.
const diff_id_index_slots = 4096;
const DiffIdIndex = plan_key_index.HashSlots(diff_id_index_slots);
threadlocal var diff_previous_id_index: DiffIdIndex = .{};
threadlocal var diff_next_id_index: DiffIdIndex = .{};
// Lazily heap-allocated per thread (32 KiB of probe tables): reset per
// diff, so first-use init on the diffing thread is the only contract —
// threads that never diff never allocate it.
const DiffIdScratch = struct {
previous: DiffIdIndex = .{},
next: DiffIdIndex = .{},
};
const diff_id_scratch = @import("lazy_tls.zig").LazyTls(DiffIdScratch);
/// Fill `table` with the keyed commands' id->index mapping, erroring on
/// the duplicate ids `validateUniqueObjectIds` rejects — one pass does
@@ -229,9 +235,10 @@ fn diffDisplayLists(previous: DisplayList, next: DisplayList, output: []DiffChan
next.commands.len >= plan_key_index.min_entries_for_index) and
plan_key_index.fitsHashSlots(diff_id_index_slots, previous.commands.len) and
plan_key_index.fitsHashSlots(diff_id_index_slots, next.commands.len);
if (use_index) {
try buildDiffIdIndex(previous, &diff_previous_id_index);
try buildDiffIdIndex(next, &diff_next_id_index);
const id_scratch: ?*DiffIdScratch = if (use_index) diff_id_scratch.get() else null;
if (id_scratch) |scratch| {
try buildDiffIdIndex(previous, &scratch.previous);
try buildDiffIdIndex(next, &scratch.next);
} else {
try validateUniqueObjectIds(previous);
try validateUniqueObjectIds(next);
@@ -260,7 +267,7 @@ fn diffDisplayLists(previous: DisplayList, next: DisplayList, output: []DiffChan
for (previous.commands, 0..) |previous_command, previous_index| {
const id = previous_command.objectId() orelse continue;
const next_lookup = if (use_index) findCommandByIdIndexed(next, &diff_next_id_index, id) else next.findCommandById(id);
const next_lookup = if (id_scratch) |scratch| findCommandByIdIndexed(next, &scratch.next, id) else next.findCommandById(id);
const next_ref = next_lookup orelse {
try appendDiffChange(output, &len, .{
.kind = .removed,
@@ -284,7 +291,7 @@ fn diffDisplayLists(previous: DisplayList, next: DisplayList, output: []DiffChan
for (next.commands, 0..) |next_command, next_index| {
const id = next_command.objectId() orelse continue;
const previous_lookup = if (use_index) findCommandByIdIndexed(previous, &diff_previous_id_index, id) else previous.findCommandById(id);
const previous_lookup = if (id_scratch) |scratch| findCommandByIdIndexed(previous, &scratch.previous, id) else previous.findCommandById(id);
if (previous_lookup == null) {
try appendDiffChange(output, &len, .{
.kind = .added,
+73
View File
@@ -0,0 +1,73 @@
//! Lazily heap-allocated per-thread scratch.
//!
//! Large `threadlocal` arrays land in the executable's static TLS
//! template, and the OS loader materializes that whole template for
//! EVERY thread of the process — window host, COM, accessibility, and
//! worker threads all pay for the full multi-megabyte canvas planner
//! scratch even though only a runtime loop thread ever touches it
//! (measured on Windows as ~6.5 MiB of heap-backed private working set
//! per thread). `LazyTls` keeps only one pointer in static TLS: the
//! backing storage is heap-allocated the first time a thread actually
//! asks for it, so threads that never plan a frame pay eight bytes
//! instead of megabytes.
//!
//! Semantics match the `threadlocal var scratch: T = .{}` it replaces:
//! each thread gets its own instance, initialized to the struct's field
//! defaults on that thread's first access. Fields declared WITHOUT a
//! default stay uninitialized, matching the `= undefined` statics they
//! replace. The instance lives until process exit — one long-lived
//! runtime loop thread per process is the designed shape, and a static
//! TLS block was process-lifetime address space per thread too.
//!
//! Allocation failure panics: this is the render path's fixed scratch,
//! sized at compile time, and a process that cannot commit it cannot
//! render at all — the old static-TLS commit would have failed thread
//! creation under the same pressure.
const std = @import("std");
pub fn LazyTls(comptime T: type) type {
return struct {
threadlocal var instance: ?*T = null;
/// This thread's instance, allocated and default-initialized on
/// first use. The pointer is stable for the thread's lifetime,
/// so hot loops may hoist it once per operation.
pub fn get() *T {
return instance orelse create();
}
/// This thread's instance only if something already used it —
/// for stats accessors that must observe without allocating.
pub fn peek() ?*T {
return instance;
}
fn create() *T {
const ptr = std.heap.page_allocator.create(T) catch
@panic("out of memory allocating per-thread canvas scratch");
inline for (@typeInfo(T).@"struct".fields) |field| {
if (comptime field.defaultValue()) |value| @field(ptr, field.name) = value;
}
instance = ptr;
return ptr;
}
};
}
test "lazy tls initializes defaults once per access pattern" {
const Scratch = struct {
counter: u64 = 7,
buffer: [32]u8, // no default: stays uninitialized, like `= undefined`
};
const tls = LazyTls(Scratch);
try std.testing.expectEqual(@as(?*Scratch, null), tls.peek());
const first = tls.get();
try std.testing.expectEqual(@as(u64, 7), first.counter);
first.counter += 1;
first.buffer[0] = 42;
const second = tls.get();
try std.testing.expectEqual(first, second);
try std.testing.expectEqual(@as(u64, 8), second.counter);
try std.testing.expectEqual(@as(?*Scratch, first), tls.peek());
}
@@ -231,30 +231,31 @@ pub const RenderResourceCachePlanner = struct {
previous.len >= plan_key_index.min_entries_for_index) and
plan_key_index.fitsHashSlots(resource_cache_index_slots, previous.len) and
plan_key_index.fitsHashSlots(resource_cache_index_slots, resource_plan.resources.len);
if (use_index) {
resource_cache_previous_index.reset();
const index_scratch: ?*ResourceCacheIndexScratch = if (use_index) resource_cache_index_scratch.get() else null;
if (index_scratch) |scratch| {
scratch.previous.reset();
for (previous, 0..) |entry, index| {
var p = ResourceCacheIndex.probe(renderResourceKeyHash(entry.key));
while (resource_cache_previous_index.next(&p)) |_| {}
resource_cache_previous_index.insert(p, @intCast(index));
while (scratch.previous.next(&p)) |_| {}
scratch.previous.insert(p, @intCast(index));
}
resource_cache_entry_index.reset();
scratch.entry.reset();
}
for (resource_plan.resources, 0..) |resource, resource_index| {
const key = renderResourceKey(resource);
const key_hash = if (use_index) renderResourceKeyHash(key) else 0;
if (use_index) {
if (index_scratch) |scratch| {
var p = ResourceCacheIndex.probe(key_hash);
var duplicate = false;
while (resource_cache_entry_index.next(&p)) |candidate| {
while (scratch.entry.next(&p)) |candidate| {
if (renderResourceKeysEqual(self.entries[candidate].key, key)) {
duplicate = true;
break;
}
}
if (duplicate) continue;
const previous_index = findRenderResourceCacheEntryIndexed(previous, key, key_hash);
const previous_index = findRenderResourceCacheEntryIndexed(&scratch.previous, previous, key, key_hash);
try self.appendAction(.{
.kind = if (previous_index == null) .upload else .retain,
.key = key,
@@ -265,7 +266,7 @@ pub const RenderResourceCachePlanner = struct {
.key = key,
.last_used_frame = frame_index,
});
resource_cache_entry_index.insert(p, @intCast(self.entry_len - 1));
scratch.entry.insert(p, @intCast(self.entry_len - 1));
continue;
}
if (findRenderResourceCacheEntry(self.entries[0..self.entry_len], key) != null) continue;
@@ -284,10 +285,10 @@ pub const RenderResourceCachePlanner = struct {
}
for (previous, 0..) |entry, cache_index| {
if (use_index) {
if (index_scratch) |scratch| {
var p = ResourceCacheIndex.probe(renderResourceKeyHash(entry.key));
var kept = false;
while (resource_cache_entry_index.next(&p)) |candidate| {
while (scratch.entry.next(&p)) |candidate| {
if (renderResourceKeysEqual(self.entries[candidate].key, entry.key)) {
kept = true;
break;
@@ -346,14 +347,20 @@ fn findRenderResourceCacheEntry(entries: []const RenderResourceCacheEntry, key:
/// half-full bound; bigger inputs fall back to the linear scans.
const resource_cache_index_slots = 4096;
const ResourceCacheIndex = plan_key_index.HashSlots(resource_cache_index_slots);
threadlocal var resource_cache_previous_index: ResourceCacheIndex = .{};
threadlocal var resource_cache_entry_index: ResourceCacheIndex = .{};
// Lazily heap-allocated per thread (32 KiB of probe tables): reset per
// build, so first-use init on the planning thread is the only contract —
// threads that never plan resources never allocate it.
const ResourceCacheIndexScratch = struct {
previous: ResourceCacheIndex = .{},
entry: ResourceCacheIndex = .{},
};
const resource_cache_index_scratch = @import("lazy_tls.zig").LazyTls(ResourceCacheIndexScratch);
/// The chain's first equal candidate is the lowest-index equal entry —
/// the exact value the linear scan returned.
fn findRenderResourceCacheEntryIndexed(previous: []const RenderResourceCacheEntry, key: RenderResourceKey, key_hash: u64) ?usize {
fn findRenderResourceCacheEntryIndexed(previous_index: *const ResourceCacheIndex, previous: []const RenderResourceCacheEntry, key: RenderResourceKey, key_hash: u64) ?usize {
var p = ResourceCacheIndex.probe(key_hash);
while (resource_cache_previous_index.next(&p)) |candidate| {
while (previous_index.next(&p)) |candidate| {
if (renderResourceKeysEqual(previous[candidate].key, key)) return candidate;
}
return null;
+5
View File
@@ -469,6 +469,11 @@ pub const markdown = @import("markdown.zig");
// the runtime's keyed diffs (see plan_key_index.zig).
pub const plan_key_index = @import("plan_key_index.zig");
// Lazily heap-allocated per-thread scratch: keeps the large planner
// buffers out of the static TLS template every OS thread must clone
// (see lazy_tls.zig for the working-set numbers).
pub const lazy_tls = @import("lazy_tls.zig");
// Experimental markup front-end lives in `ui_markup.zig` / `ui_markup_view.zig`
// (runtime parse + interpret: the dev/hot-reload engine) and
// `ui_markup_compiled.zig` (comptime parse: the release engine, no parser in
+1
View File
@@ -27,4 +27,5 @@ test {
_ = @import("markdown_hostile_tests.zig");
_ = @import("layout_audit_tests.zig");
_ = @import("a11y_audit_tests.zig");
_ = @import("lazy_tls.zig");
}
+40 -32
View File
@@ -79,17 +79,18 @@ pub const GlyphAtlasPlanner = struct {
}
const use_index = estimated_glyphs >= plan_key_index.min_entries_for_index and
plan_key_index.fitsHashSlots(glyph_atlas_index_slots, self.entries.len);
if (use_index) glyph_atlas_plan_index.reset();
const plan_index: ?*GlyphAtlasIndex = if (use_index) &glyph_atlas_index_scratch.get().plan else null;
if (plan_index) |index| index.reset();
for (display_list.commands, 0..) |command, command_index| {
switch (command) {
.draw_text => |value| try self.consumeText(value, command_index, use_index),
.draw_text => |value| try self.consumeText(value, command_index, plan_index),
else => {},
}
}
return .{ .entries = self.entries[0..self.len] };
}
fn consumeText(self: *GlyphAtlasPlanner, text: anytype, command_index: usize, use_index: bool) Error!void {
fn consumeText(self: *GlyphAtlasPlanner, text: anytype, command_index: usize, plan_index: ?*GlyphAtlasIndex) Error!void {
if (text.glyphs.len > 0) {
for (text.glyphs, 0..) |glyph, glyph_index| {
const key = GlyphAtlasKey{
@@ -99,7 +100,7 @@ pub const GlyphAtlasPlanner = struct {
.subpixel_x = subpixelBucket(text.origin.x + glyph.x),
.subpixel_y = subpixelBucket(text.origin.y + glyph.y),
};
try self.appendUnique(key, command_index, glyph_index, use_index);
try self.appendUnique(key, command_index, glyph_index, plan_index);
}
return;
}
@@ -121,15 +122,15 @@ pub const GlyphAtlasPlanner = struct {
.subpixel_x = subpixelBucket(text.origin.x + @as(f32, @floatFromInt(scalar_index)) * text.size * 0.5),
.subpixel_y = subpixelBucket(text.origin.y),
};
try self.appendUnique(key, command_index, scalar_index, use_index);
try self.appendUnique(key, command_index, scalar_index, plan_index);
}
}
fn appendUnique(self: *GlyphAtlasPlanner, key: GlyphAtlasKey, command_index: usize, glyph_index: usize, use_index: bool) Error!void {
fn appendUnique(self: *GlyphAtlasPlanner, key: GlyphAtlasKey, command_index: usize, glyph_index: usize, plan_index: ?*GlyphAtlasIndex) Error!void {
var probe: GlyphAtlasIndex.Probe = undefined;
if (use_index) {
if (plan_index) |index| {
probe = GlyphAtlasIndex.probe(glyphAtlasKeyHash(key));
while (glyph_atlas_plan_index.next(&probe)) |candidate| {
while (index.next(&probe)) |candidate| {
if (glyphAtlasKeysEqual(self.entries[candidate].key, key)) return;
}
} else {
@@ -144,7 +145,7 @@ pub const GlyphAtlasPlanner = struct {
.glyph_index = glyph_index,
};
self.len += 1;
if (use_index) glyph_atlas_plan_index.insert(probe, @intCast(self.len - 1));
if (plan_index) |index| index.insert(probe, @intCast(self.len - 1));
}
};
@@ -225,22 +226,23 @@ pub const GlyphAtlasCachePlanner = struct {
previous.len >= plan_key_index.min_entries_for_index) and
plan_key_index.fitsHashSlots(glyph_atlas_cache_index_slots, previous.len) and
plan_key_index.fitsHashSlots(glyph_atlas_cache_index_slots, plan.entries.len + previous.len);
if (use_index) {
glyph_atlas_cache_previous_index.reset();
const index_scratch: ?*GlyphAtlasIndexScratch = if (use_index) glyph_atlas_index_scratch.get() else null;
if (index_scratch) |scratch| {
scratch.cache_previous.reset();
for (previous, 0..) |entry, index| {
var p = GlyphAtlasCacheIndex.probe(glyphAtlasKeyHash(entry.key));
while (glyph_atlas_cache_previous_index.next(&p)) |_| {}
glyph_atlas_cache_previous_index.insert(p, @intCast(index));
while (scratch.cache_previous.next(&p)) |_| {}
scratch.cache_previous.insert(p, @intCast(index));
}
glyph_atlas_cache_entry_index.reset();
scratch.cache_entry.reset();
}
for (plan.entries, 0..) |entry, atlas_index| {
const previous_index = blk: {
if (use_index) {
if (index_scratch) |scratch| {
const key_hash = glyphAtlasKeyHash(entry.key);
if (self.entryIndexProbe(entry.key, key_hash)) |_| continue;
break :blk findGlyphAtlasCacheEntryIndexed(previous, entry.key, key_hash);
if (self.entryIndexProbe(&scratch.cache_entry, entry.key, key_hash)) |_| continue;
break :blk findGlyphAtlasCacheEntryIndexed(&scratch.cache_previous, previous, entry.key, key_hash);
}
if (findGlyphAtlasCacheEntry(self.entries[0..self.entry_len], entry.key) != null) continue;
break :blk findGlyphAtlasCacheEntry(previous, entry.key);
@@ -248,7 +250,7 @@ pub const GlyphAtlasCachePlanner = struct {
try self.appendEntryMaybeIndexed(.{
.key = entry.key,
.last_used_frame = frame_index,
}, use_index);
}, if (index_scratch) |scratch| &scratch.cache_entry else null);
try self.appendAction(.{
.kind = if (previous_index == null) .upload else .retain,
.key = entry.key,
@@ -258,13 +260,13 @@ pub const GlyphAtlasCachePlanner = struct {
}
for (previous, 0..) |entry, previous_index| {
if (use_index) {
if (self.entryIndexProbe(entry.key, glyphAtlasKeyHash(entry.key))) |_| continue;
if (index_scratch) |scratch| {
if (self.entryIndexProbe(&scratch.cache_entry, entry.key, glyphAtlasKeyHash(entry.key))) |_| continue;
} else if (findGlyphAtlasCacheEntry(self.entries[0..self.entry_len], entry.key) != null) {
continue;
}
if (shouldRetainUnusedCacheEntry(frame_index, entry.last_used_frame, retention_frames) and self.hasEntryCapacity()) {
try self.appendEntryMaybeIndexed(entry, use_index);
try self.appendEntryMaybeIndexed(entry, if (index_scratch) |scratch| &scratch.cache_entry else null);
try self.appendAction(.{
.kind = .retain,
.key = entry.key,
@@ -288,20 +290,20 @@ pub const GlyphAtlasCachePlanner = struct {
/// First appended entry equal to `key`, walking the entry index's
/// probe chain — the indexed equivalent of scanning
/// `self.entries[0..self.entry_len]`.
fn entryIndexProbe(self: *GlyphAtlasCachePlanner, key: GlyphAtlasKey, key_hash: u64) ?usize {
fn entryIndexProbe(self: *GlyphAtlasCachePlanner, entry_index: *const GlyphAtlasCacheIndex, key: GlyphAtlasKey, key_hash: u64) ?usize {
var p = GlyphAtlasCacheIndex.probe(key_hash);
while (glyph_atlas_cache_entry_index.next(&p)) |candidate| {
while (entry_index.next(&p)) |candidate| {
if (glyphAtlasKeysEqual(self.entries[candidate].key, key)) return candidate;
}
return null;
}
fn appendEntryMaybeIndexed(self: *GlyphAtlasCachePlanner, entry: GlyphAtlasCacheEntry, use_index: bool) Error!void {
fn appendEntryMaybeIndexed(self: *GlyphAtlasCachePlanner, entry: GlyphAtlasCacheEntry, entry_index: ?*GlyphAtlasCacheIndex) Error!void {
try self.appendEntry(entry);
if (use_index) {
if (entry_index) |index| {
var p = GlyphAtlasCacheIndex.probe(glyphAtlasKeyHash(entry.key));
while (glyph_atlas_cache_entry_index.next(&p)) |_| {}
glyph_atlas_cache_entry_index.insert(p, @intCast(self.entry_len - 1));
while (index.next(&p)) |_| {}
index.insert(p, @intCast(self.entry_len - 1));
}
}
@@ -366,13 +368,19 @@ const glyph_atlas_index_slots = 16384;
const glyph_atlas_cache_index_slots = 32768;
const GlyphAtlasIndex = plan_key_index.HashSlots(glyph_atlas_index_slots);
const GlyphAtlasCacheIndex = plan_key_index.HashSlots(glyph_atlas_cache_index_slots);
threadlocal var glyph_atlas_plan_index: GlyphAtlasIndex = .{};
threadlocal var glyph_atlas_cache_previous_index: GlyphAtlasCacheIndex = .{};
threadlocal var glyph_atlas_cache_entry_index: GlyphAtlasCacheIndex = .{};
// Lazily heap-allocated per thread (320 KiB of probe tables): reset per
// build, so first-use init on the planning thread is the only contract —
// threads that never plan glyphs never allocate it.
const GlyphAtlasIndexScratch = struct {
plan: GlyphAtlasIndex = .{},
cache_previous: GlyphAtlasCacheIndex = .{},
cache_entry: GlyphAtlasCacheIndex = .{},
};
const glyph_atlas_index_scratch = @import("lazy_tls.zig").LazyTls(GlyphAtlasIndexScratch);
fn findGlyphAtlasCacheEntryIndexed(previous: []const GlyphAtlasCacheEntry, key: GlyphAtlasKey, key_hash: u64) ?usize {
fn findGlyphAtlasCacheEntryIndexed(previous_index: *const GlyphAtlasCacheIndex, previous: []const GlyphAtlasCacheEntry, key: GlyphAtlasKey, key_hash: u64) ?usize {
var p = GlyphAtlasCacheIndex.probe(key_hash);
while (glyph_atlas_cache_previous_index.next(&p)) |candidate| {
while (previous_index.next(&p)) |candidate| {
if (glyphAtlasKeysEqual(previous[candidate].key, key)) return candidate;
}
return null;
+29 -22
View File
@@ -136,22 +136,23 @@ pub const TextLayoutCachePlanner = struct {
previous.len >= plan_key_index.min_entries_for_index) and
plan_key_index.fitsHashSlots(text_layout_cache_index_slots, previous.len) and
plan_key_index.fitsHashSlots(text_layout_cache_index_slots, plans.len + previous.len);
if (use_index) {
text_layout_cache_previous_index.reset();
const index_scratch: ?*TextLayoutCacheIndexScratch = if (use_index) text_layout_cache_index_scratch.get() else null;
if (index_scratch) |scratch| {
scratch.previous.reset();
for (previous, 0..) |entry, index| {
var p = TextLayoutCacheIndex.probe(textLayoutKeyHash(entry.key));
while (text_layout_cache_previous_index.next(&p)) |_| {}
text_layout_cache_previous_index.insert(p, @intCast(index));
while (scratch.previous.next(&p)) |_| {}
scratch.previous.insert(p, @intCast(index));
}
text_layout_cache_entry_index.reset();
scratch.entry.reset();
}
for (plans, 0..) |plan, layout_index| {
const previous_index = blk: {
if (use_index) {
if (index_scratch) |scratch| {
const key_hash = textLayoutKeyHash(plan.key);
if (self.entryIndexProbe(plan.key, key_hash)) |_| continue;
break :blk findTextLayoutCacheEntryIndexed(previous, plan.key, key_hash);
if (self.entryIndexProbe(&scratch.entry, plan.key, key_hash)) |_| continue;
break :blk findTextLayoutCacheEntryIndexed(&scratch.previous, previous, plan.key, key_hash);
}
if (findTextLayoutCacheEntry(self.entries[0..self.entry_len], plan.key) != null) continue;
break :blk findTextLayoutCacheEntry(previous, plan.key);
@@ -162,7 +163,7 @@ pub const TextLayoutCachePlanner = struct {
.line_count = plan.lineCount(),
.bounds = plan.layout.bounds,
.last_used_frame = frame_index,
}, use_index);
}, if (index_scratch) |scratch| &scratch.entry else null);
try self.appendAction(.{
.kind = if (previous_index == null) .upload else .retain,
.key = plan.key,
@@ -172,13 +173,13 @@ pub const TextLayoutCachePlanner = struct {
}
for (previous, 0..) |entry, index| {
if (use_index) {
if (self.entryIndexProbe(entry.key, textLayoutKeyHash(entry.key))) |_| continue;
if (index_scratch) |scratch| {
if (self.entryIndexProbe(&scratch.entry, entry.key, textLayoutKeyHash(entry.key))) |_| continue;
} else if (findTextLayoutCacheEntry(self.entries[0..self.entry_len], entry.key) != null) {
continue;
}
if (shouldRetainUnusedCacheEntry(frame_index, entry.last_used_frame, retention_frames) and self.hasEntryCapacity()) {
try self.appendEntryMaybeIndexed(entry, use_index);
try self.appendEntryMaybeIndexed(entry, if (index_scratch) |scratch| &scratch.entry else null);
try self.appendAction(.{
.kind = .retain,
.key = entry.key,
@@ -202,20 +203,20 @@ pub const TextLayoutCachePlanner = struct {
/// First appended entry equal to `key`, walking the entry index's
/// probe chain — the indexed equivalent of scanning
/// `self.entries[0..self.entry_len]`.
fn entryIndexProbe(self: *TextLayoutCachePlanner, key: TextLayoutKey, key_hash: u64) ?usize {
fn entryIndexProbe(self: *TextLayoutCachePlanner, entry_index: *const TextLayoutCacheIndex, key: TextLayoutKey, key_hash: u64) ?usize {
var p = TextLayoutCacheIndex.probe(key_hash);
while (text_layout_cache_entry_index.next(&p)) |candidate| {
while (entry_index.next(&p)) |candidate| {
if (textLayoutKeysEqual(self.entries[candidate].key, key)) return candidate;
}
return null;
}
fn appendEntryMaybeIndexed(self: *TextLayoutCachePlanner, entry: TextLayoutCacheEntry, use_index: bool) Error!void {
fn appendEntryMaybeIndexed(self: *TextLayoutCachePlanner, entry: TextLayoutCacheEntry, entry_index: ?*TextLayoutCacheIndex) Error!void {
try self.appendEntry(entry);
if (use_index) {
if (entry_index) |index| {
var p = TextLayoutCacheIndex.probe(textLayoutKeyHash(entry.key));
while (text_layout_cache_entry_index.next(&p)) |_| {}
text_layout_cache_entry_index.insert(p, @intCast(self.entry_len - 1));
while (index.next(&p)) |_| {}
index.insert(p, @intCast(self.entry_len - 1));
}
}
@@ -249,12 +250,18 @@ fn findTextLayoutCacheEntry(entries: []const TextLayoutCacheEntry, key: TextLayo
/// bound; bigger inputs fall back to the linear scans.
const text_layout_cache_index_slots = 8192;
const TextLayoutCacheIndex = plan_key_index.HashSlots(text_layout_cache_index_slots);
threadlocal var text_layout_cache_previous_index: TextLayoutCacheIndex = .{};
threadlocal var text_layout_cache_entry_index: TextLayoutCacheIndex = .{};
// Lazily heap-allocated per thread (64 KiB of probe tables): reset per
// build, so first-use init on the planning thread is the only contract —
// threads that never plan text layouts never allocate it.
const TextLayoutCacheIndexScratch = struct {
previous: TextLayoutCacheIndex = .{},
entry: TextLayoutCacheIndex = .{},
};
const text_layout_cache_index_scratch = @import("lazy_tls.zig").LazyTls(TextLayoutCacheIndexScratch);
fn findTextLayoutCacheEntryIndexed(previous: []const TextLayoutCacheEntry, key: TextLayoutKey, key_hash: u64) ?usize {
fn findTextLayoutCacheEntryIndexed(previous_index: *const TextLayoutCacheIndex, previous: []const TextLayoutCacheEntry, key: TextLayoutKey, key_hash: u64) ?usize {
var p = TextLayoutCacheIndex.probe(key_hash);
while (text_layout_cache_previous_index.next(&p)) |candidate| {
while (previous_index.next(&p)) |candidate| {
if (textLayoutKeysEqual(previous[candidate].key, key)) return candidate;
}
return null;
+56 -41
View File
@@ -123,24 +123,36 @@ const AdvanceEntry = struct {
last_used: u64 = 0,
};
threadlocal var advance_entries: [advance_cache_capacity]AdvanceEntry = @splat(.{});
threadlocal var advance_storage: [advance_cache_capacity][max_cached_advance_run_bytes]f32 = undefined;
threadlocal var oversize_key: AdvanceKey = .{};
threadlocal var oversize_storage: [max_batched_advance_run_bytes]f32 = undefined;
/// Monotonic use tick driving least-recently-used eviction.
threadlocal var advance_use_tick: u64 = 0;
/// Fetch statistics for tests and the render benchmark: how many
/// batched provider calls actually happened vs how many lookups were
/// answered from retained entries.
threadlocal var advance_fetch_count: u64 = 0;
threadlocal var advance_hit_count: u64 = 0;
/// The whole per-thread cache behind one lazily heap-allocated pointer:
/// at ~2.25 MiB this was the single largest block in the static TLS
/// template every OS thread's loader had to clone. Init happens on the
/// first `textRunAdvances`/`cachedTextRunAdvances` call of a thread —
/// the measuring thread by construction — so threads that never measure
/// text never allocate it. The storage arrays carry no default and stay
/// uninitialized, exactly like the `= undefined` statics they replace.
const AdvanceCache = struct {
entries: [advance_cache_capacity]AdvanceEntry = @splat(.{}),
storage: [advance_cache_capacity][max_cached_advance_run_bytes]f32,
oversize_key: AdvanceKey = .{},
oversize_storage: [max_batched_advance_run_bytes]f32,
/// Monotonic use tick driving least-recently-used eviction.
use_tick: u64 = 0,
/// Fetch statistics for tests and the render benchmark: how many
/// batched provider calls actually happened vs how many lookups were
/// answered from retained entries.
fetch_count: u64 = 0,
hit_count: u64 = 0,
};
const advance_cache = @import("lazy_tls.zig").LazyTls(AdvanceCache);
pub fn textAdvanceFetchCount() u64 {
return advance_fetch_count;
const cache = advance_cache.peek() orelse return 0;
return cache.fetch_count;
}
pub fn textAdvanceHitCount() u64 {
return advance_hit_count;
const cache = advance_cache.peek() orelse return 0;
return cache.hit_count;
}
fn advanceKeyFor(provider: *const TextMeasureProvider, font_id: FontId, size: f32, text: []const u8) AdvanceKey {
@@ -192,32 +204,33 @@ pub fn textRunAdvances(provider: *const TextMeasureProvider, font_id: FontId, si
if (text.len == 0) return &.{};
if (text.len > max_batched_advance_run_bytes) return null;
const cache = advance_cache.get();
const key = advanceKeyFor(provider, font_id, size, text);
advance_use_tick += 1;
cache.use_tick += 1;
if (text.len > max_cached_advance_run_bytes) {
// Oversize runs: one uncached scratch slot, memoized against
// itself so repeated fetches of the same long run (the line
// breaker, then elision, then bounds) still pay one call.
if (advanceKeysEqual(oversize_key, key)) {
advance_hit_count += 1;
return oversize_storage[0..text.len];
if (advanceKeysEqual(cache.oversize_key, key)) {
cache.hit_count += 1;
return cache.oversize_storage[0..text.len];
}
oversize_key = .{};
advance_fetch_count += 1;
if (!provider.measureAdvances(font_id, size, text, oversize_storage[0..text.len])) return null;
if (!advancesValid(oversize_storage[0..text.len])) return null;
oversize_key = key;
return oversize_storage[0..text.len];
cache.oversize_key = .{};
cache.fetch_count += 1;
if (!provider.measureAdvances(font_id, size, text, cache.oversize_storage[0..text.len])) return null;
if (!advancesValid(cache.oversize_storage[0..text.len])) return null;
cache.oversize_key = key;
return cache.oversize_storage[0..text.len];
}
var victim: usize = 0;
var victim_tick: u64 = std.math.maxInt(u64);
for (&advance_entries, 0..) |*entry, index| {
for (&cache.entries, 0..) |*entry, index| {
if (advanceKeysEqual(entry.key, key)) {
entry.last_used = advance_use_tick;
advance_hit_count += 1;
return advance_storage[index][0..text.len];
entry.last_used = cache.use_tick;
cache.hit_count += 1;
return cache.storage[index][0..text.len];
}
// Unused slots evict first (tick 0), then the least recently
// used entry — honest bounded retention, no clock heuristics.
@@ -228,12 +241,12 @@ pub fn textRunAdvances(provider: *const TextMeasureProvider, font_id: FontId, si
}
}
advance_entries[victim].key = .{};
advance_fetch_count += 1;
if (!provider.measureAdvances(font_id, size, text, advance_storage[victim][0..text.len])) return null;
if (!advancesValid(advance_storage[victim][0..text.len])) return null;
advance_entries[victim] = .{ .key = key, .last_used = advance_use_tick };
return advance_storage[victim][0..text.len];
cache.entries[victim].key = .{};
cache.fetch_count += 1;
if (!provider.measureAdvances(font_id, size, text, cache.storage[victim][0..text.len])) return null;
if (!advancesValid(cache.storage[victim][0..text.len])) return null;
cache.entries[victim] = .{ .key = key, .last_used = cache.use_tick };
return cache.storage[victim][0..text.len];
}
/// Peek: the run's advances IF already retained (or sitting in the
@@ -248,20 +261,22 @@ pub fn cachedTextRunAdvances(provider: *const TextMeasureProvider, font_id: Font
if (provider.measure_advances_fn == null) return null;
if (text.len == 0) return &.{};
if (text.len > max_batched_advance_run_bytes) return null;
// Peek must not allocate a cache that nothing has fetched into yet.
const cache = advance_cache.peek() orelse return null;
const key = advanceKeyFor(provider, font_id, size, text);
if (text.len > max_cached_advance_run_bytes) {
if (advanceKeysEqual(oversize_key, key)) {
advance_hit_count += 1;
return oversize_storage[0..text.len];
if (advanceKeysEqual(cache.oversize_key, key)) {
cache.hit_count += 1;
return cache.oversize_storage[0..text.len];
}
return null;
}
for (&advance_entries, 0..) |*entry, index| {
for (&cache.entries, 0..) |*entry, index| {
if (advanceKeysEqual(entry.key, key)) {
advance_use_tick += 1;
entry.last_used = advance_use_tick;
advance_hit_count += 1;
return advance_storage[index][0..text.len];
cache.use_tick += 1;
entry.last_used = cache.use_tick;
cache.hit_count += 1;
return cache.storage[index][0..text.len];
}
}
return null;
+39 -26
View File
@@ -354,12 +354,13 @@ const LayoutState = struct {
/// to the pre-cache behavior (goldens, signatures, reference renders).
pub fn layoutTextSpans(spans: []const TextSpan, options: TextSpanLayoutOptions, runs_storage: []TextSpanRun) TextSpanLayout {
if (options.measure != null and runs_storage.len >= max_text_span_runs_per_paragraph) {
const cache = span_wrap_cache.get();
const key = spanWrapKey(spans, options);
if (findSpanWrapEntry(key)) |entry_index| {
if (rebaseSpanWrapEntry(entry_index, spans, options, runs_storage)) |layout| return layout;
if (findSpanWrapEntry(cache, key)) |entry_index| {
if (rebaseSpanWrapEntry(cache, entry_index, spans, options, runs_storage)) |layout| return layout;
}
const layout = layoutTextSpansUncached(spans, options, runs_storage);
storeSpanWrapEntry(key, spans, layout);
storeSpanWrapEntry(cache, key, spans, layout);
return layout;
}
return layoutTextSpansUncached(spans, options, runs_storage);
@@ -502,19 +503,31 @@ const SpanWrapEntry = struct {
last_used: u64 = 0,
};
threadlocal var span_wrap_entries: [span_wrap_cache_capacity]SpanWrapEntry = @splat(.{});
threadlocal var span_wrap_runs: [span_wrap_cache_capacity][max_text_span_runs_per_paragraph]SpanWrapRun = undefined;
threadlocal var span_wrap_use_tick: u64 = 0;
threadlocal var span_wrap_hit_count: u64 = 0;
threadlocal var span_wrap_miss_count: u64 = 0;
/// The whole per-thread wrap cache behind one lazily heap-allocated
/// pointer (~1 MiB that would otherwise sit in the static TLS template
/// every OS thread's loader clones). Init happens on the first cached
/// `layoutTextSpans` call of a thread — the layout thread by
/// construction — so threads that never wrap spans never allocate it.
/// `runs` carries no default and stays uninitialized, exactly like the
/// `= undefined` static it replaces.
const SpanWrapCache = struct {
entries: [span_wrap_cache_capacity]SpanWrapEntry = @splat(.{}),
runs: [span_wrap_cache_capacity][max_text_span_runs_per_paragraph]SpanWrapRun,
use_tick: u64 = 0,
hit_count: u64 = 0,
miss_count: u64 = 0,
};
const span_wrap_cache = @import("lazy_tls.zig").LazyTls(SpanWrapCache);
/// Cache observability for tests and benchmarks.
pub fn textSpanWrapCacheHitCount() u64 {
return span_wrap_hit_count;
const cache = span_wrap_cache.peek() orelse return 0;
return cache.hit_count;
}
pub fn textSpanWrapCacheMissCount() u64 {
return span_wrap_miss_count;
const cache = span_wrap_cache.peek() orelse return 0;
return cache.miss_count;
}
fn spanWrapKey(spans: []const TextSpan, options: TextSpanLayoutOptions) SpanWrapKey {
@@ -567,20 +580,20 @@ fn spanWrapKeysEqual(a: SpanWrapKey, b: SpanWrapKey) bool {
a.generation == b.generation;
}
fn findSpanWrapEntry(key: SpanWrapKey) ?usize {
for (&span_wrap_entries, 0..) |*entry, index| {
fn findSpanWrapEntry(cache: *SpanWrapCache, key: SpanWrapKey) ?usize {
for (&cache.entries, 0..) |*entry, index| {
if (spanWrapKeysEqual(entry.key, key)) {
span_wrap_use_tick += 1;
entry.last_used = span_wrap_use_tick;
span_wrap_hit_count += 1;
cache.use_tick += 1;
entry.last_used = cache.use_tick;
cache.hit_count += 1;
return index;
}
}
span_wrap_miss_count += 1;
cache.miss_count += 1;
return null;
}
fn storeSpanWrapEntry(key: SpanWrapKey, spans: []const TextSpan, layout: TextSpanLayout) void {
fn storeSpanWrapEntry(cache: *SpanWrapCache, key: SpanWrapKey, spans: []const TextSpan, layout: TextSpanLayout) void {
if (layout.runs.len > max_text_span_runs_per_paragraph) return;
// Offsets require every run to alias its span's bytes; the breaker
// only ever emits subslices of span.text, so a failure here would be
@@ -591,17 +604,17 @@ fn storeSpanWrapEntry(key: SpanWrapKey, spans: []const TextSpan, layout: TextSpa
var victim: usize = 0;
var victim_tick: u64 = std.math.maxInt(u64);
for (&span_wrap_entries, 0..) |*entry, index| {
for (&cache.entries, 0..) |*entry, index| {
const tick = if (entry.key.used) entry.last_used else 0;
if (tick < victim_tick) {
victim_tick = tick;
victim = index;
}
}
span_wrap_use_tick += 1;
cache.use_tick += 1;
for (layout.runs, 0..) |run, run_index| {
const offset = spanRunOffset(spans, run).?;
span_wrap_runs[victim][run_index] = .{
cache.runs[victim][run_index] = .{
.span_index = @intCast(run.span_index),
.text_start = @intCast(offset),
.text_len = @intCast(run.text.len),
@@ -611,14 +624,14 @@ fn storeSpanWrapEntry(key: SpanWrapKey, spans: []const TextSpan, layout: TextSpa
.baseline = run.baseline,
};
}
span_wrap_entries[victim] = .{
cache.entries[victim] = .{
.key = key,
.run_len = layout.runs.len,
.line_count = layout.line_count,
.line_height = layout.line_height,
.size = layout.size,
.truncated = layout.truncated,
.last_used = span_wrap_use_tick,
.last_used = cache.use_tick,
};
}
@@ -639,13 +652,13 @@ fn spanRunOffset(spans: []const TextSpan, run: TextSpanRun) ?usize {
/// offset does not fit the current spans — only reachable through a
/// content-hash collision, and answered by re-laying-out instead of
/// serving mismatched geometry.
fn rebaseSpanWrapEntry(entry_index: usize, spans: []const TextSpan, options: TextSpanLayoutOptions, runs_storage: []TextSpanRun) ?TextSpanLayout {
const entry = &span_wrap_entries[entry_index];
for (span_wrap_runs[entry_index][0..entry.run_len]) |cached| {
fn rebaseSpanWrapEntry(cache: *SpanWrapCache, entry_index: usize, spans: []const TextSpan, options: TextSpanLayoutOptions, runs_storage: []TextSpanRun) ?TextSpanLayout {
const entry = &cache.entries[entry_index];
for (cache.runs[entry_index][0..entry.run_len]) |cached| {
if (cached.span_index >= spans.len) return null;
if (cached.text_start + cached.text_len > spans[cached.span_index].text.len) return null;
}
for (span_wrap_runs[entry_index][0..entry.run_len], 0..) |cached, run_index| {
for (cache.runs[entry_index][0..entry.run_len], 0..) |cached, run_index| {
const span = spans[cached.span_index];
runs_storage[run_index] = .{
.span_index = cached.span_index,
+14 -7
View File
@@ -94,9 +94,10 @@ pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTok
next.nodes.len >= plan_key_index.min_entries_for_index) and
plan_key_index.fitsHashSlots(diff_widget_id_index_slots, previous.nodes.len) and
plan_key_index.fitsHashSlots(diff_widget_id_index_slots, next.nodes.len);
if (use_index) {
try buildDiffWidgetIdIndex(previous, &diff_previous_widget_id_index);
try buildDiffWidgetIdIndex(next, &diff_next_widget_id_index);
const id_scratch: ?*DiffWidgetIdScratch = if (use_index) diff_widget_id_scratch.get() else null;
if (id_scratch) |scratch| {
try buildDiffWidgetIdIndex(previous, &scratch.previous);
try buildDiffWidgetIdIndex(next, &scratch.next);
} else {
try validateUniqueWidgetIds(previous);
try validateUniqueWidgetIds(next);
@@ -106,7 +107,7 @@ pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTok
for (previous.nodes, 0..) |previous_node, previous_index| {
const id = previous_node.widget.id;
if (id == 0) continue;
const next_lookup = if (use_index) findWidgetNodeByIdIndexed(next, &diff_next_widget_id_index, id) else findWidgetNodeById(next, id);
const next_lookup = if (id_scratch) |scratch| findWidgetNodeByIdIndexed(next, &scratch.next, id) else findWidgetNodeById(next, id);
const next_ref = next_lookup orelse {
try appendWidgetInvalidation(output, &len, .{
.kind = .removed,
@@ -157,7 +158,7 @@ pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTok
for (next.nodes, 0..) |next_node, next_index| {
const id = next_node.widget.id;
if (id == 0) continue;
const previous_lookup = if (use_index) findWidgetNodeByIdIndexed(previous, &diff_previous_widget_id_index, id) else findWidgetNodeById(previous, id);
const previous_lookup = if (id_scratch) |scratch| findWidgetNodeByIdIndexed(previous, &scratch.previous, id) else findWidgetNodeById(previous, id);
if (previous_lookup == null) {
try appendWidgetInvalidation(output, &len, .{
.kind = .added,
@@ -201,8 +202,14 @@ fn findWidgetNodeById(layout: anytype, id: ObjectId) ?WidgetNodeRef {
/// bound; small or oversized trees keep the linear scans.
const diff_widget_id_index_slots = 2048;
const DiffWidgetIdIndex = plan_key_index.HashSlots(diff_widget_id_index_slots);
threadlocal var diff_previous_widget_id_index: DiffWidgetIdIndex = .{};
threadlocal var diff_next_widget_id_index: DiffWidgetIdIndex = .{};
// Lazily heap-allocated per thread (16 KiB of probe tables): reset per
// diff, so first-use init on the diffing thread is the only contract —
// threads that never diff widget trees never allocate it.
const DiffWidgetIdScratch = struct {
previous: DiffWidgetIdIndex = .{},
next: DiffWidgetIdIndex = .{},
};
const diff_widget_id_scratch = @import("lazy_tls.zig").LazyTls(DiffWidgetIdScratch);
/// Fill `table` with the keyed nodes' id->index mapping, erroring on the
/// duplicate ids `validateUniqueWidgetIds` rejects — one pass does both
+25 -15
View File
@@ -126,21 +126,31 @@ const max_widget_depth: usize = 32;
/// per-view storage within the same emit call stack, so one threadlocal
/// buffer per frame is sound — reset at each emit entry point. Overflow
/// fails loudly by budget name.
threadlocal var frame_label_bytes: [chart_model.max_chart_label_bytes_per_frame]u8 = undefined;
threadlocal var frame_label_len: usize = 0;
// Lazily heap-allocated per thread (the label pool plus the chart
// polyline scratch below): init on first emit/chart use of a thread —
// the emitting thread by construction — so threads that never emit
// widgets never allocate it. The arrays carry no default and stay
// uninitialized, exactly like the `= undefined` statics they replace.
const WidgetRenderScratch = struct {
frame_label_bytes: [chart_model.max_chart_label_bytes_per_frame]u8,
frame_label_len: usize = 0,
chart_polyline_points: [chart_model.max_chart_points_per_series]geometry.PointF,
};
const widget_render_scratch = @import("lazy_tls.zig").LazyTls(WidgetRenderScratch);
fn resetFrameLabelScratch() void {
frame_label_len = 0;
widget_render_scratch.get().frame_label_len = 0;
}
/// Persist a formatted label into the frame scratch so the emitted
/// command outlives the local formatting buffer.
fn allocFrameLabelBytes(text: []const u8) Error![]const u8 {
if (frame_label_len + text.len > frame_label_bytes.len) return error.ChartLabelBytesFull;
const start = frame_label_len;
frame_label_len += text.len;
@memcpy(frame_label_bytes[start..frame_label_len], text);
return frame_label_bytes[start..frame_label_len];
const scratch = widget_render_scratch.get();
if (scratch.frame_label_len + text.len > scratch.frame_label_bytes.len) return error.ChartLabelBytesFull;
const start = scratch.frame_label_len;
scratch.frame_label_len += text.len;
@memcpy(scratch.frame_label_bytes[start..scratch.frame_label_len], text);
return scratch.frame_label_bytes[start..scratch.frame_label_len];
}
/// Frame-lifetime scratch (same single-threaded emit contract as the
@@ -2062,27 +2072,27 @@ fn emitChartBand(
}
/// Map a series into plot-space points, skipping non-finite values.
/// Returned points live in a threadlocal scratch valid until the next
/// series maps (each emitter consumes them before returning).
threadlocal var chart_polyline_points: [chart_model.max_chart_points_per_series]geometry.PointF = undefined;
/// Returned points live in per-thread scratch (`WidgetRenderScratch`)
/// valid until the next series maps (each emitter consumes them before
/// returning).
fn chartPolylinePoints(
values: []const f32,
domain: chart_model.ChartDomain,
plot: geometry.RectF,
inset: f32,
) Error![]const geometry.PointF {
const count = @min(values.len, chart_polyline_points.len);
const points = &widget_render_scratch.get().chart_polyline_points;
const count = @min(values.len, points.len);
var len: usize = 0;
for (values[0..count], 0..) |value, index| {
if (!std.math.isFinite(value)) continue;
chart_polyline_points[len] = geometry.PointF.init(
points[len] = geometry.PointF.init(
chartMapX(index, count, plot, inset),
chartMapY(value, domain, plot, inset),
);
len += 1;
}
return chart_polyline_points[0..len];
return points[0..len];
}
const chart_grid_seed: u64 = 0x5eed_c4a8_0000_0001;
+57 -36
View File
@@ -43,10 +43,29 @@ const max_canvas_render_animations_per_view = canvas_limits.max_canvas_render_an
const max_canvas_text_layouts_per_view = canvas_limits.max_canvas_text_layouts_per_view;
const max_canvas_text_layout_lines_per_view = canvas_limits.max_canvas_text_layout_lines_per_view;
const max_canvas_retained_packet_commands_per_view = canvas_limits.max_canvas_retained_packet_commands_per_view;
threadlocal var canvas_frame_text_layout_plans_scratch: [max_canvas_text_layouts_per_view]canvas.TextLayoutPlan = undefined;
threadlocal var canvas_frame_text_layout_lines_scratch: [max_canvas_text_layout_lines_per_view]canvas.TextLine = undefined;
threadlocal var canvas_frame_text_layout_cache_entries_scratch: [max_canvas_text_layouts_per_view]canvas.TextLayoutCacheEntry = undefined;
threadlocal var canvas_frame_text_layout_cache_actions_scratch: [max_canvas_text_layouts_per_view * 2]canvas.TextLayoutCacheAction = undefined;
// The frame planner's per-thread scratch behind one lazily
// heap-allocated pointer (~1.8 MiB that would otherwise sit in the
// static TLS template every OS thread's loader clones): the text-layout
// planning arrays plus the packet patch-derivation arrays declared with
// their contract below. Init happens on a thread's first frame plan —
// the runtime loop thread by construction — so window-host, COM,
// accessibility, and worker threads never allocate it. Every field is
// per-use scratch (no cross-frame state), so first-use init cannot
// change planner output; the arrays carry no default and stay
// uninitialized, exactly like the `= undefined` statics they replace.
const CanvasFrameScratch = struct {
text_layout_plans: [max_canvas_text_layouts_per_view]canvas.TextLayoutPlan,
text_layout_lines: [max_canvas_text_layout_lines_per_view]canvas.TextLine,
text_layout_cache_entries: [max_canvas_text_layouts_per_view]canvas.TextLayoutCacheEntry,
text_layout_cache_actions: [max_canvas_text_layouts_per_view * 2]canvas.TextLayoutCacheAction,
packet_current: [max_canvas_retained_packet_commands_per_view]CanvasPacketCurrentCommand,
packet_current_sort: [max_canvas_retained_packet_commands_per_view]u32,
packet_baseline_sort: [max_canvas_retained_packet_commands_per_view]u32,
packet_baseline_matched: [max_canvas_retained_packet_commands_per_view]bool,
packet_baseline_stable: [max_canvas_retained_packet_commands_per_view]bool,
packet_upsert: [max_canvas_retained_packet_commands_per_view]bool,
};
const canvas_frame_scratch = canvas.lazy_tls.LazyTls(CanvasFrameScratch);
/// One entry of the frame's CURRENT keyed command list — the full draw
/// order the retained packet protocol works on (never the scissor
@@ -62,17 +81,14 @@ const CanvasPacketCurrentCommand = struct {
bounds: geometry.RectF,
};
// Patch-derivation scratch (threadlocal, same pattern as the text-layout
// scratch above): the current keyed list, a key-sorted index over it for
// duplicate detection, a key-sorted index over the view's retained
// baseline for O(log n) lookups, per-baseline matched flags (unmatched =
// evict), and per-current upsert flags. ~64 KiB per thread total.
threadlocal var canvas_packet_current_scratch: [max_canvas_retained_packet_commands_per_view]CanvasPacketCurrentCommand = undefined;
threadlocal var canvas_packet_current_sort_scratch: [max_canvas_retained_packet_commands_per_view]u32 = undefined;
threadlocal var canvas_packet_baseline_sort_scratch: [max_canvas_retained_packet_commands_per_view]u32 = undefined;
threadlocal var canvas_packet_baseline_matched_scratch: [max_canvas_retained_packet_commands_per_view]bool = undefined;
threadlocal var canvas_packet_baseline_stable_scratch: [max_canvas_retained_packet_commands_per_view]bool = undefined;
threadlocal var canvas_packet_upsert_scratch: [max_canvas_retained_packet_commands_per_view]bool = undefined;
// Patch-derivation scratch (the `packet_*` fields of
// `CanvasFrameScratch` above): the current keyed list, a key-sorted
// index over it for duplicate detection, a key-sorted index over the
// view's retained baseline for O(log n) lookups, per-baseline matched
// flags (unmatched = evict), and per-current upsert flags. ~104 KiB per
// planning thread total. The matched/upsert flags persist between
// `computeCanvasPacketPatchStats` and `writeCanvasPacketPatchBinary` on
// the same thread — the same contract the statics carried.
const validateViewLabel = validation.validateViewLabel;
const canvasRenderAnimationStartNsForView = runtime_view.canvasRenderAnimationStartNsForView;
@@ -1125,6 +1141,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type {
}
pub fn canvasFrameScratchStorage(self: *Runtime) canvas.CanvasFrameStorage {
const scratch = canvas_frame_scratch.get();
return .{
.render_commands = &self.canvas_frame_render_commands,
.render_batches = &self.canvas_frame_render_batches,
@@ -1148,10 +1165,10 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type {
.glyph_atlas_entries = &self.canvas_frame_glyph_atlas_entries,
.glyph_atlas_cache_entries = &self.canvas_frame_glyph_atlas_cache_entries,
.glyph_atlas_cache_actions = &self.canvas_frame_glyph_atlas_cache_actions,
.text_layout_plans = &canvas_frame_text_layout_plans_scratch,
.text_layout_lines = &canvas_frame_text_layout_lines_scratch,
.text_layout_cache_entries = &canvas_frame_text_layout_cache_entries_scratch,
.text_layout_cache_actions = &canvas_frame_text_layout_cache_actions_scratch,
.text_layout_plans = &scratch.text_layout_plans,
.text_layout_lines = &scratch.text_layout_lines,
.text_layout_cache_entries = &scratch.text_layout_cache_entries,
.text_layout_cache_actions = &scratch.text_layout_cache_actions,
.changes = &self.canvas_frame_changes,
};
}
@@ -1261,14 +1278,15 @@ fn gatherCanvasPacketCurrentCommands(canvas_frame: canvas.CanvasFrame) ?[]const
/// byte-identical output.
fn gatherCanvasPacketCurrentCommandsFromPlan(render_commands: []const canvas.RenderCommand, surface_size: geometry.SizeF, render_bounds: ?geometry.RectF) ?[]const CanvasPacketCurrentCommand {
const full_bounds = canvasFullRepaintBounds(surface_size, render_bounds) orelse return null;
const scratch = canvas_frame_scratch.get();
var count: usize = 0;
for (render_commands, 0..) |command, index| {
if (!canvas.renderCommandIntersectsDirtyBounds(command, full_bounds)) continue;
const gpu_command = canvas.canvasGpuCommandFromRenderCommand(command, index);
if (!gpu_command.supported()) return null;
if (count >= canvas_packet_current_scratch.len) return null;
if (count >= scratch.packet_current.len) return null;
const fingerprint = canvas.canvasGpuCommandFingerprint(gpu_command);
canvas_packet_current_scratch[count] = .{
scratch.packet_current[count] = .{
.key = canvas.canvasGpuPacketCommandKey(gpu_command, fingerprint),
.fingerprint = fingerprint,
.render_index = @intCast(index),
@@ -1276,14 +1294,14 @@ fn gatherCanvasPacketCurrentCommandsFromPlan(render_commands: []const canvas.Ren
};
count += 1;
}
const sorted = canvas_packet_current_sort_scratch[0..count];
const sorted = scratch.packet_current_sort[0..count];
for (sorted, 0..) |*slot, index| slot.* = @intCast(index);
std.sort.pdq(u32, sorted, @as([]const CanvasPacketCurrentCommand, canvas_packet_current_scratch[0..count]), canvasPacketCurrentKeyLessThan);
std.sort.pdq(u32, sorted, @as([]const CanvasPacketCurrentCommand, scratch.packet_current[0..count]), canvasPacketCurrentKeyLessThan);
var index: usize = 1;
while (index < count) : (index += 1) {
if (canvas_packet_current_scratch[sorted[index - 1]].key == canvas_packet_current_scratch[sorted[index]].key) return null;
if (scratch.packet_current[sorted[index - 1]].key == scratch.packet_current[sorted[index]].key) return null;
}
return canvas_packet_current_scratch[0..count];
return scratch.packet_current[0..count];
}
fn canvasPacketCurrentKeyLessThan(current: []const CanvasPacketCurrentCommand, a: u32, b: u32) bool {
@@ -1317,12 +1335,13 @@ fn computeCanvasPacketPatchStats(view: anytype, current: []const CanvasPacketCur
const baseline_keys = view.canvas_packet_baseline_keys[0..baseline_count];
const baseline_fingerprints = view.canvas_packet_baseline_fingerprints[0..baseline_count];
const baseline_sorted = canvas_packet_baseline_sort_scratch[0..baseline_count];
const scratch = canvas_frame_scratch.get();
const baseline_sorted = scratch.packet_baseline_sort[0..baseline_count];
for (baseline_sorted, 0..) |*slot, index| slot.* = @intCast(index);
std.sort.pdq(u32, baseline_sorted, @as([]const u64, baseline_keys), canvasPacketBaselineKeyLessThan);
const matched = canvas_packet_baseline_matched_scratch[0..baseline_count];
const matched = scratch.packet_baseline_matched[0..baseline_count];
@memset(matched, false);
const upserts = canvas_packet_upsert_scratch[0..current.len];
const upserts = scratch.packet_upsert[0..current.len];
var stats = CanvasPacketPatchStats{};
for (current, 0..) |entry, index| {
@@ -1395,22 +1414,23 @@ fn canvasPacketPatchDirtyBounds(view: anytype, current: []const CanvasPacketCurr
const baseline_fingerprints = view.canvas_packet_baseline_fingerprints[0..baseline_count];
const baseline_bounds = view.canvas_packet_baseline_bounds[0..baseline_count];
const baseline_sorted = canvas_packet_baseline_sort_scratch[0..baseline_count];
const scratch = canvas_frame_scratch.get();
const baseline_sorted = scratch.packet_baseline_sort[0..baseline_count];
for (baseline_sorted, 0..) |*slot, index| slot.* = @intCast(index);
std.sort.pdq(u32, baseline_sorted, @as([]const u64, baseline_keys), canvasPacketBaselineKeyLessThan);
const matched = canvas_packet_baseline_matched_scratch[0..baseline_count];
const stable = canvas_packet_baseline_stable_scratch[0..baseline_count];
const matched = scratch.packet_baseline_matched[0..baseline_count];
const stable = scratch.packet_baseline_stable[0..baseline_count];
@memset(matched, false);
@memset(stable, false);
var dirty = CanvasPacketPatchDirty{};
for (current, 0..) |entry, index| {
canvas_packet_upsert_scratch[index] = true;
scratch.packet_upsert[index] = true;
if (findCanvasPacketBaselineIndex(baseline_keys, baseline_sorted, entry.key)) |baseline_index| {
matched[baseline_index] = true;
if (baseline_fingerprints[baseline_index] == entry.fingerprint) {
stable[baseline_index] = true;
canvas_packet_upsert_scratch[index] = false;
scratch.packet_upsert[index] = false;
continue;
}
dirty.add(baseline_bounds[baseline_index]);
@@ -1426,7 +1446,7 @@ fn canvasPacketPatchDirtyBounds(view: anytype, current: []const CanvasPacketCurr
// outside the union may change.
var baseline_walk: usize = 0;
for (current, 0..) |entry, index| {
if (canvas_packet_upsert_scratch[index]) continue;
if (scratch.packet_upsert[index]) continue;
while (baseline_walk < baseline_count and !stable[baseline_walk]) baseline_walk += 1;
if (baseline_walk >= baseline_count or baseline_keys[baseline_walk] != entry.key) return null;
baseline_walk += 1;
@@ -1453,8 +1473,9 @@ fn writeCanvasPacketPatchBinary(
) !void {
const baseline_count = view.canvas_packet_baseline_count;
const baseline_keys = view.canvas_packet_baseline_keys[0..baseline_count];
const matched = canvas_packet_baseline_matched_scratch[0..baseline_count];
const upserts = canvas_packet_upsert_scratch[0..current.len];
const scratch = canvas_frame_scratch.get();
const matched = scratch.packet_baseline_matched[0..baseline_count];
const upserts = scratch.packet_upsert[0..current.len];
try canvas.writeCanvasGpuPacketBinaryHeader(
canvas.binary_packet_load_action_patch,
+9 -3
View File
@@ -52,8 +52,14 @@ pub const RegisteredCanvasImage = struct {
/// bound because decoders may need in-buffer scratch beyond the tight
/// pixel bytes (the null platform's strict PNG parser keeps one filter
/// byte per row: raw stream <= pixels + pixels/4 since a row is at least
/// 4 pixel bytes). Loop-thread only, like the frame scratch.
threadlocal var canvas_image_decode_scratch: [max_registered_canvas_image_pixel_bytes + max_registered_canvas_image_pixel_bytes / 4]u8 = undefined;
/// 4 pixel bytes). Loop-thread only, like the frame scratch — and
/// lazily heap-allocated per thread (1.25 MiB) on the first decode, so
/// threads that never register image bytes never carry it in their
/// static TLS block.
const CanvasImageDecodeScratch = struct {
bytes: [max_registered_canvas_image_pixel_bytes + max_registered_canvas_image_pixel_bytes / 4]u8,
};
const canvas_image_decode_scratch = canvas.lazy_tls.LazyTls(CanvasImageDecodeScratch);
pub fn RuntimeCanvasImages(comptime Runtime: type) type {
return struct {
@@ -112,7 +118,7 @@ pub fn RuntimeCanvasImages(comptime Runtime: type) type {
/// `error.ImageTooLarge` (decoded pixels over the slot bound).
pub fn registerCanvasImageBytes(self: *Runtime, id: canvas.ImageId, bytes: []const u8) anyerror!RegisteredCanvasImage {
if (id == 0) return error.InvalidImageId;
const decoded = try self.options.platform.services.decodeImage(bytes, &canvas_image_decode_scratch);
const decoded = try self.options.platform.services.decodeImage(bytes, &canvas_image_decode_scratch.get().bytes);
if (decoded.rgba8.len > max_registered_canvas_image_pixel_bytes) return error.ImageTooLarge;
try registerCanvasImage(self, id, decoded.width, decoded.height, decoded.rgba8);
return .{ .width = decoded.width, .height = decoded.height };
+7 -4
View File
@@ -114,9 +114,12 @@ pub const CanvasWidgetSemanticsIndex = CanvasWidgetIdIndex(canvas.WidgetSemantic
/// Shared per-pass index scratch (~8 KiB of slots per table). The two
/// reconcile passes per rebuild (the staged reconcile, then the retained
/// copy) run back-to-back on the single-threaded event loop and each
/// rebuilds every table it uses, so one threadlocal set serves both —
/// the same pattern as the planners' probe-table scratch.
pub threadlocal var canvas_widget_reconcile_index_scratch: CanvasWidgetReconcileIndexScratch = .{};
/// rebuilds every table it uses, so one per-thread set serves both —
/// the same pattern as the planners' probe-table scratch. Lazily
/// heap-allocated (~32 KiB) on a thread's first reconcile, so threads
/// that never reconcile widgets never carry it in their static TLS
/// block.
pub const canvas_widget_reconcile_index_scratch = canvas.lazy_tls.LazyTls(CanvasWidgetReconcileIndexScratch);
pub const CanvasWidgetReconcileIndexScratch = struct {
controls: CanvasWidgetControlEntryIndex = .{},
@@ -825,7 +828,7 @@ pub fn canvasWidgetLayoutTreeWithRuntimeReconcileState(
// mid-rubber-band must not clamp an offset the OS scroller owns.
restoreCanvasWidgetLayoutScrollOffsets(staged_nodes, previous_runtime_offsets, previous_source_scroll_entries);
const index_scratch = &canvas_widget_reconcile_index_scratch;
const index_scratch = canvas_widget_reconcile_index_scratch.get();
index_scratch.controls.build(previous_control_states);
index_scratch.source_controls.build(previous_source_control_entries);
index_scratch.texts.build(previous_text_states);
+24 -17
View File
@@ -209,8 +209,14 @@ fn addCanvasCount(value: *usize, amount: usize, max_value: usize, comptime failu
/// the half-full bound; small lists keep the linear scans.
const summary_id_index_slots = 4096;
const SummaryIdIndex = canvas.plan_key_index.HashSlots(summary_id_index_slots);
threadlocal var summary_current_id_index: SummaryIdIndex = .{};
threadlocal var summary_presented_id_index: SummaryIdIndex = .{};
// Lazily heap-allocated per thread (32 KiB of probe tables): reset per
// diff, so first-use init on the diffing thread is the only contract —
// threads that never diff summaries never allocate it.
const SummaryIdScratch = struct {
current: SummaryIdIndex = .{},
presented: SummaryIdIndex = .{},
};
const summary_id_scratch = canvas.lazy_tls.LazyTls(SummaryIdScratch);
pub const PresentedCanvasCommand = struct {
id: ?canvas.ObjectId = null,
@@ -647,28 +653,29 @@ pub fn RuntimeViewCanvasFrame(comptime RuntimeView: type) type {
presented.len >= canvas.plan_key_index.min_entries_for_index) and
canvas.plan_key_index.fitsHashSlots(summary_id_index_slots, current_commands.len) and
canvas.plan_key_index.fitsHashSlots(summary_id_index_slots, presented.len);
if (use_index) {
summary_current_id_index.reset();
const id_scratch: ?*SummaryIdScratch = if (use_index) summary_id_scratch.get() else null;
if (id_scratch) |scratch| {
scratch.current.reset();
for (current_commands, 0..) |command, index| {
const id = command.objectId() orelse continue;
var p = SummaryIdIndex.probe(canvas.plan_key_index.mixHash(id));
while (summary_current_id_index.next(&p)) |_| {}
summary_current_id_index.insert(p, @intCast(index));
while (scratch.current.next(&p)) |_| {}
scratch.current.insert(p, @intCast(index));
}
summary_presented_id_index.reset();
scratch.presented.reset();
for (presented, 0..) |command, index| {
const id = command.id orelse continue;
var p = SummaryIdIndex.probe(canvas.plan_key_index.mixHash(id));
while (summary_presented_id_index.next(&p)) |_| {}
summary_presented_id_index.insert(p, @intCast(index));
while (scratch.presented.next(&p)) |_| {}
scratch.presented.insert(p, @intCast(index));
}
}
var len: usize = 0;
for (presented) |previous| {
const id = previous.id orelse continue;
const current_ref = if (use_index)
currentCanvasCommandByIdIndexed(current_commands, id)
const current_ref = if (id_scratch) |scratch|
currentCanvasCommandByIdIndexed(&scratch.current, current_commands, id)
else
self.currentCanvasCommandById(id);
if (current_ref == null) {
@@ -683,8 +690,8 @@ pub fn RuntimeViewCanvasFrame(comptime RuntimeView: type) type {
for (current_commands, 0..) |command, index| {
const id = command.objectId() orelse continue;
const bounds = command.bounds();
const previous_ref = if (use_index)
presentedCanvasCommandByIdIndexed(presented, id)
const previous_ref = if (id_scratch) |scratch|
presentedCanvasCommandByIdIndexed(&scratch.presented, presented, id)
else
self.presentedCanvasCommandById(id);
if (previous_ref) |previous| {
@@ -708,17 +715,17 @@ pub fn RuntimeViewCanvasFrame(comptime RuntimeView: type) type {
return output[0..len];
}
fn currentCanvasCommandByIdIndexed(commands: []const canvas.CanvasCommand, id: canvas.ObjectId) ?canvas.CommandRef {
fn currentCanvasCommandByIdIndexed(current_index: *const SummaryIdIndex, commands: []const canvas.CanvasCommand, id: canvas.ObjectId) ?canvas.CommandRef {
var p = SummaryIdIndex.probe(canvas.plan_key_index.mixHash(id));
while (summary_current_id_index.next(&p)) |candidate| {
while (current_index.next(&p)) |candidate| {
if (commands[candidate].objectId() == id) return .{ .index = candidate, .command = commands[candidate] };
}
return null;
}
fn presentedCanvasCommandByIdIndexed(presented: []const PresentedCanvasCommand, id: canvas.ObjectId) ?PresentedCanvasCommandRef {
fn presentedCanvasCommandByIdIndexed(presented_index: *const SummaryIdIndex, presented: []const PresentedCanvasCommand, id: canvas.ObjectId) ?PresentedCanvasCommandRef {
var p = SummaryIdIndex.probe(canvas.plan_key_index.mixHash(id));
while (summary_presented_id_index.next(&p)) |candidate| {
while (presented_index.next(&p)) |candidate| {
if (presented[candidate].id == id) return .{ .index = candidate, .command = presented[candidate] };
}
return null;
+2 -2
View File
@@ -226,10 +226,10 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
);
// Per-pass probe-table indices over the collected entry lists
// (shared threadlocal scratch; see the reconcile-id-index note
// (shared per-thread scratch; see the reconcile-id-index note
// in canvas_widget_runtime.zig). Lookups return exactly what
// the linear scans returned; only the search cost changes.
const index_scratch = &canvas_widget_runtime.canvas_widget_reconcile_index_scratch;
const index_scratch = canvas_widget_runtime.canvas_widget_reconcile_index_scratch.get();
index_scratch.controls.build(previous_control_states);
index_scratch.source_controls.build(self.widgetSourceControlEntries());
index_scratch.texts.build(previous_text_states);