Skip to content

Commit 2d623ac

Browse files
ctateSunkenInTime
andauthored
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>
1 parent 87d859f commit 2d623ac

17 files changed

Lines changed: 418 additions & 227 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/primitives/canvas/commands.zig

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,14 @@ pub const DisplayList = struct {
191191
/// half-full bound; small or oversized lists keep the linear scans.
192192
const diff_id_index_slots = 4096;
193193
const DiffIdIndex = plan_key_index.HashSlots(diff_id_index_slots);
194-
threadlocal var diff_previous_id_index: DiffIdIndex = .{};
195-
threadlocal var diff_next_id_index: DiffIdIndex = .{};
194+
// Lazily heap-allocated per thread (32 KiB of probe tables): reset per
195+
// diff, so first-use init on the diffing thread is the only contract —
196+
// threads that never diff never allocate it.
197+
const DiffIdScratch = struct {
198+
previous: DiffIdIndex = .{},
199+
next: DiffIdIndex = .{},
200+
};
201+
const diff_id_scratch = @import("lazy_tls.zig").LazyTls(DiffIdScratch);
196202

197203
/// Fill `table` with the keyed commands' id->index mapping, erroring on
198204
/// the duplicate ids `validateUniqueObjectIds` rejects — one pass does
@@ -229,9 +235,10 @@ fn diffDisplayLists(previous: DisplayList, next: DisplayList, output: []DiffChan
229235
next.commands.len >= plan_key_index.min_entries_for_index) and
230236
plan_key_index.fitsHashSlots(diff_id_index_slots, previous.commands.len) and
231237
plan_key_index.fitsHashSlots(diff_id_index_slots, next.commands.len);
232-
if (use_index) {
233-
try buildDiffIdIndex(previous, &diff_previous_id_index);
234-
try buildDiffIdIndex(next, &diff_next_id_index);
238+
const id_scratch: ?*DiffIdScratch = if (use_index) diff_id_scratch.get() else null;
239+
if (id_scratch) |scratch| {
240+
try buildDiffIdIndex(previous, &scratch.previous);
241+
try buildDiffIdIndex(next, &scratch.next);
235242
} else {
236243
try validateUniqueObjectIds(previous);
237244
try validateUniqueObjectIds(next);
@@ -260,7 +267,7 @@ fn diffDisplayLists(previous: DisplayList, next: DisplayList, output: []DiffChan
260267

261268
for (previous.commands, 0..) |previous_command, previous_index| {
262269
const id = previous_command.objectId() orelse continue;
263-
const next_lookup = if (use_index) findCommandByIdIndexed(next, &diff_next_id_index, id) else next.findCommandById(id);
270+
const next_lookup = if (id_scratch) |scratch| findCommandByIdIndexed(next, &scratch.next, id) else next.findCommandById(id);
264271
const next_ref = next_lookup orelse {
265272
try appendDiffChange(output, &len, .{
266273
.kind = .removed,
@@ -284,7 +291,7 @@ fn diffDisplayLists(previous: DisplayList, next: DisplayList, output: []DiffChan
284291

285292
for (next.commands, 0..) |next_command, next_index| {
286293
const id = next_command.objectId() orelse continue;
287-
const previous_lookup = if (use_index) findCommandByIdIndexed(previous, &diff_previous_id_index, id) else previous.findCommandById(id);
294+
const previous_lookup = if (id_scratch) |scratch| findCommandByIdIndexed(previous, &scratch.previous, id) else previous.findCommandById(id);
288295
if (previous_lookup == null) {
289296
try appendDiffChange(output, &len, .{
290297
.kind = .added,

src/primitives/canvas/lazy_tls.zig

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! Lazily heap-allocated per-thread scratch.
2+
//!
3+
//! Large `threadlocal` arrays land in the executable's static TLS
4+
//! template, and the OS loader materializes that whole template for
5+
//! EVERY thread of the process — window host, COM, accessibility, and
6+
//! worker threads all pay for the full multi-megabyte canvas planner
7+
//! scratch even though only a runtime loop thread ever touches it
8+
//! (measured on Windows as ~6.5 MiB of heap-backed private working set
9+
//! per thread). `LazyTls` keeps only one pointer in static TLS: the
10+
//! backing storage is heap-allocated the first time a thread actually
11+
//! asks for it, so threads that never plan a frame pay eight bytes
12+
//! instead of megabytes.
13+
//!
14+
//! Semantics match the `threadlocal var scratch: T = .{}` it replaces:
15+
//! each thread gets its own instance, initialized to the struct's field
16+
//! defaults on that thread's first access. Fields declared WITHOUT a
17+
//! default stay uninitialized, matching the `= undefined` statics they
18+
//! replace. The instance lives until process exit — one long-lived
19+
//! runtime loop thread per process is the designed shape, and a static
20+
//! TLS block was process-lifetime address space per thread too.
21+
//!
22+
//! Allocation failure panics: this is the render path's fixed scratch,
23+
//! sized at compile time, and a process that cannot commit it cannot
24+
//! render at all — the old static-TLS commit would have failed thread
25+
//! creation under the same pressure.
26+
27+
const std = @import("std");
28+
29+
pub fn LazyTls(comptime T: type) type {
30+
return struct {
31+
threadlocal var instance: ?*T = null;
32+
33+
/// This thread's instance, allocated and default-initialized on
34+
/// first use. The pointer is stable for the thread's lifetime,
35+
/// so hot loops may hoist it once per operation.
36+
pub fn get() *T {
37+
return instance orelse create();
38+
}
39+
40+
/// This thread's instance only if something already used it —
41+
/// for stats accessors that must observe without allocating.
42+
pub fn peek() ?*T {
43+
return instance;
44+
}
45+
46+
fn create() *T {
47+
const ptr = std.heap.page_allocator.create(T) catch
48+
@panic("out of memory allocating per-thread canvas scratch");
49+
inline for (@typeInfo(T).@"struct".fields) |field| {
50+
if (comptime field.defaultValue()) |value| @field(ptr, field.name) = value;
51+
}
52+
instance = ptr;
53+
return ptr;
54+
}
55+
};
56+
}
57+
58+
test "lazy tls initializes defaults once per access pattern" {
59+
const Scratch = struct {
60+
counter: u64 = 7,
61+
buffer: [32]u8, // no default: stays uninitialized, like `= undefined`
62+
};
63+
const tls = LazyTls(Scratch);
64+
try std.testing.expectEqual(@as(?*Scratch, null), tls.peek());
65+
const first = tls.get();
66+
try std.testing.expectEqual(@as(u64, 7), first.counter);
67+
first.counter += 1;
68+
first.buffer[0] = 42;
69+
const second = tls.get();
70+
try std.testing.expectEqual(first, second);
71+
try std.testing.expectEqual(@as(u64, 8), second.counter);
72+
try std.testing.expectEqual(@as(?*Scratch, first), tls.peek());
73+
}

src/primitives/canvas/render_generic_resources.zig

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -231,30 +231,31 @@ pub const RenderResourceCachePlanner = struct {
231231
previous.len >= plan_key_index.min_entries_for_index) and
232232
plan_key_index.fitsHashSlots(resource_cache_index_slots, previous.len) and
233233
plan_key_index.fitsHashSlots(resource_cache_index_slots, resource_plan.resources.len);
234-
if (use_index) {
235-
resource_cache_previous_index.reset();
234+
const index_scratch: ?*ResourceCacheIndexScratch = if (use_index) resource_cache_index_scratch.get() else null;
235+
if (index_scratch) |scratch| {
236+
scratch.previous.reset();
236237
for (previous, 0..) |entry, index| {
237238
var p = ResourceCacheIndex.probe(renderResourceKeyHash(entry.key));
238-
while (resource_cache_previous_index.next(&p)) |_| {}
239-
resource_cache_previous_index.insert(p, @intCast(index));
239+
while (scratch.previous.next(&p)) |_| {}
240+
scratch.previous.insert(p, @intCast(index));
240241
}
241-
resource_cache_entry_index.reset();
242+
scratch.entry.reset();
242243
}
243244

244245
for (resource_plan.resources, 0..) |resource, resource_index| {
245246
const key = renderResourceKey(resource);
246247
const key_hash = if (use_index) renderResourceKeyHash(key) else 0;
247-
if (use_index) {
248+
if (index_scratch) |scratch| {
248249
var p = ResourceCacheIndex.probe(key_hash);
249250
var duplicate = false;
250-
while (resource_cache_entry_index.next(&p)) |candidate| {
251+
while (scratch.entry.next(&p)) |candidate| {
251252
if (renderResourceKeysEqual(self.entries[candidate].key, key)) {
252253
duplicate = true;
253254
break;
254255
}
255256
}
256257
if (duplicate) continue;
257-
const previous_index = findRenderResourceCacheEntryIndexed(previous, key, key_hash);
258+
const previous_index = findRenderResourceCacheEntryIndexed(&scratch.previous, previous, key, key_hash);
258259
try self.appendAction(.{
259260
.kind = if (previous_index == null) .upload else .retain,
260261
.key = key,
@@ -265,7 +266,7 @@ pub const RenderResourceCachePlanner = struct {
265266
.key = key,
266267
.last_used_frame = frame_index,
267268
});
268-
resource_cache_entry_index.insert(p, @intCast(self.entry_len - 1));
269+
scratch.entry.insert(p, @intCast(self.entry_len - 1));
269270
continue;
270271
}
271272
if (findRenderResourceCacheEntry(self.entries[0..self.entry_len], key) != null) continue;
@@ -284,10 +285,10 @@ pub const RenderResourceCachePlanner = struct {
284285
}
285286

286287
for (previous, 0..) |entry, cache_index| {
287-
if (use_index) {
288+
if (index_scratch) |scratch| {
288289
var p = ResourceCacheIndex.probe(renderResourceKeyHash(entry.key));
289290
var kept = false;
290-
while (resource_cache_entry_index.next(&p)) |candidate| {
291+
while (scratch.entry.next(&p)) |candidate| {
291292
if (renderResourceKeysEqual(self.entries[candidate].key, entry.key)) {
292293
kept = true;
293294
break;
@@ -346,14 +347,20 @@ fn findRenderResourceCacheEntry(entries: []const RenderResourceCacheEntry, key:
346347
/// half-full bound; bigger inputs fall back to the linear scans.
347348
const resource_cache_index_slots = 4096;
348349
const ResourceCacheIndex = plan_key_index.HashSlots(resource_cache_index_slots);
349-
threadlocal var resource_cache_previous_index: ResourceCacheIndex = .{};
350-
threadlocal var resource_cache_entry_index: ResourceCacheIndex = .{};
350+
// Lazily heap-allocated per thread (32 KiB of probe tables): reset per
351+
// build, so first-use init on the planning thread is the only contract —
352+
// threads that never plan resources never allocate it.
353+
const ResourceCacheIndexScratch = struct {
354+
previous: ResourceCacheIndex = .{},
355+
entry: ResourceCacheIndex = .{},
356+
};
357+
const resource_cache_index_scratch = @import("lazy_tls.zig").LazyTls(ResourceCacheIndexScratch);
351358

352359
/// The chain's first equal candidate is the lowest-index equal entry —
353360
/// the exact value the linear scan returned.
354-
fn findRenderResourceCacheEntryIndexed(previous: []const RenderResourceCacheEntry, key: RenderResourceKey, key_hash: u64) ?usize {
361+
fn findRenderResourceCacheEntryIndexed(previous_index: *const ResourceCacheIndex, previous: []const RenderResourceCacheEntry, key: RenderResourceKey, key_hash: u64) ?usize {
355362
var p = ResourceCacheIndex.probe(key_hash);
356-
while (resource_cache_previous_index.next(&p)) |candidate| {
363+
while (previous_index.next(&p)) |candidate| {
357364
if (renderResourceKeysEqual(previous[candidate].key, key)) return candidate;
358365
}
359366
return null;

src/primitives/canvas/root.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,11 @@ pub const markdown = @import("markdown.zig");
469469
// the runtime's keyed diffs (see plan_key_index.zig).
470470
pub const plan_key_index = @import("plan_key_index.zig");
471471

472+
// Lazily heap-allocated per-thread scratch: keeps the large planner
473+
// buffers out of the static TLS template every OS thread must clone
474+
// (see lazy_tls.zig for the working-set numbers).
475+
pub const lazy_tls = @import("lazy_tls.zig");
476+
472477
// Experimental markup front-end lives in `ui_markup.zig` / `ui_markup_view.zig`
473478
// (runtime parse + interpret: the dev/hot-reload engine) and
474479
// `ui_markup_compiled.zig` (comptime parse: the release engine, no parser in

src/primitives/canvas/tests.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ test {
2727
_ = @import("markdown_hostile_tests.zig");
2828
_ = @import("layout_audit_tests.zig");
2929
_ = @import("a11y_audit_tests.zig");
30+
_ = @import("lazy_tls.zig");
3031
}

0 commit comments

Comments
 (0)