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:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,4 +27,5 @@ test {
|
||||
_ = @import("markdown_hostile_tests.zig");
|
||||
_ = @import("layout_audit_tests.zig");
|
||||
_ = @import("a11y_audit_tests.zig");
|
||||
_ = @import("lazy_tls.zig");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user