a73x

fc9f9849

Add performance benchmarking and incremental atlas upload specs and plans

a73x   2026-04-10 10:17

Commit message
Add performance benchmarking and incremental atlas upload specs and plans

Design specs and implementation plans for:
- Per-section frame timing with ring buffer, SIGUSR1 stats dump,
  WAYSTTY_BENCH workload, and perf/flamegraph Makefile targets
- Incremental atlas upload with ASCII precompute, dirty-region
  tracking, persistent staging buffer, and fence-based sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

docs/superpowers/plans/2026-04-10-incremental-atlas-upload-implementation.md
Old New
@@ -0,0 +1,483 @@
1 # Incremental Atlas Upload Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Reduce atlas upload cost from ~1.7ms to near-zero by precomputing ASCII glyphs at startup and uploading only dirty atlas rows incrementally.
6
7 **Architecture:** Add `last_uploaded_y` and `needs_full_upload` tracking fields to the Atlas struct in `font.zig`. Add `uploadAtlasRegion` to `renderer.zig` with a persistent staging buffer, content-preserving layout transitions, and a dedicated transfer fence. Wire the precompute loop and incremental upload into `main.zig`.
8
9 **Tech Stack:** Zig 0.15, Vulkan host-visible staging buffers, image layout transitions, fence synchronization.
10
11 ---
12
13 ## File Structure
14
15 - Modify: `src/font.zig`
16 - Add `last_uploaded_y: u32` and `needs_full_upload: bool` to `Atlas`
17 - Update `init()` and `reset()` to set these fields
18 - Modify: `src/renderer.zig`
19 - Add persistent staging buffer + dedicated transfer command buffer + transfer fence to `Context`
20 - Add `uploadAtlasRegion(pixels, y_start, y_end, full)` method
21 - Keep existing `uploadAtlas` as full-upload convenience wrapper
22 - Modify: `src/main.zig`
23 - Add ASCII precompute loop at startup
24 - Replace render-loop atlas upload with incremental path
25
26 ### Task 1: Add dirty-region tracking fields to Atlas with tests
27
28 **Files:**
29 - Modify: `src/font.zig`
30 - Test: `src/font.zig`
31
32 - [ ] **Step 1: Write the failing tests**
33
34 Add at the bottom of `src/font.zig`, after the existing test blocks:
35
36 ```zig
37 test "Atlas dirty tracking fields initialized correctly" {
38 var atlas = try Atlas.init(std.testing.allocator, 256, 256);
39 defer atlas.deinit();
40
41 try std.testing.expectEqual(@as(u32, 0), atlas.last_uploaded_y);
42 try std.testing.expect(atlas.needs_full_upload);
43 }
44
45 test "Atlas dirty region covers new glyphs" {
46 var atlas = try Atlas.init(std.testing.allocator, 256, 256);
47 defer atlas.deinit();
48
49 // After init, cursor_y=0, row_height=1 (for the white pixel)
50 const y_start = atlas.last_uploaded_y;
51 const y_end = atlas.cursor_y + atlas.row_height;
52 try std.testing.expectEqual(@as(u32, 0), y_start);
53 try std.testing.expect(y_end > 0);
54 }
55
56 test "Atlas reset restores dirty tracking fields" {
57 var atlas = try Atlas.init(std.testing.allocator, 256, 256);
58 defer atlas.deinit();
59
60 // Simulate having uploaded some region
61 atlas.last_uploaded_y = 50;
62 atlas.needs_full_upload = false;
63
64 atlas.reset();
65
66 try std.testing.expectEqual(@as(u32, 0), atlas.last_uploaded_y);
67 try std.testing.expect(atlas.needs_full_upload);
68 }
69 ```
70
71 - [ ] **Step 2: Run test to verify it fails**
72
73 Run: `zig build test 2>&1 | head -20`
74 Expected: FAIL — `last_uploaded_y` field does not exist.
75
76 - [ ] **Step 3: Add the fields to Atlas**
77
78 In `src/font.zig`, add to the `Atlas` struct fields (after `dirty: bool`):
79
80 ```zig
81 last_uploaded_y: u32,
82 needs_full_upload: bool,
83 ```
84
85 In `Atlas.init` (the return struct literal), add:
86
87 ```zig
88 .last_uploaded_y = 0,
89 .needs_full_upload = true,
90 ```
91
92 In `Atlas.reset`, add at the end (after `self.dirty = true;`):
93
94 ```zig
95 self.last_uploaded_y = 0;
96 self.needs_full_upload = true;
97 ```
98
99 - [ ] **Step 4: Run test to verify it passes**
100
101 Run: `zig build test 2>&1 | tail -5`
102 Expected: PASS
103
104 - [ ] **Step 5: Commit**
105
106 ```bash
107 git add src/font.zig
108 git commit -m "Add dirty-region tracking fields to Atlas"
109 ```
110
111 ### Task 2: Add persistent staging buffer and transfer fence to renderer
112
113 **Files:**
114 - Modify: `src/renderer.zig`
115
116 - [ ] **Step 1: Add fields to Context struct**
117
118 In `src/renderer.zig`, add three new fields to the `Context` struct after `atlas_height: u32`:
119
120 ```zig
121 // Persistent atlas staging buffer (reused across frames)
122 atlas_staging_buffer: vk.Buffer,
123 atlas_staging_memory: vk.DeviceMemory,
124 // Dedicated transfer command buffer + fence
125 atlas_transfer_cb: vk.CommandBuffer,
126 atlas_transfer_fence: vk.Fence,
127 ```
128
129 - [ ] **Step 2: Allocate resources in Context.init**
130
131 In `Context.init`, after the atlas sampler creation and before the descriptor set update (around line 910), add:
132
133 ```zig
134 // --- Atlas staging buffer (persistent, reused across frames) ---
135 const atlas_staging_size: vk.DeviceSize = @as(vk.DeviceSize, atlas_width) * atlas_height;
136 const atlas_staging = try createHostVisibleBuffer(vki, pd_info.physical, vkd, device, atlas_staging_size, .{ .transfer_src_bit = true });
137 errdefer {
138 vkd.destroyBuffer(device, atlas_staging.buffer, null);
139 vkd.freeMemory(device, atlas_staging.memory, null);
140 }
141
142 // --- Dedicated atlas transfer command buffer ---
143 var atlas_transfer_cb: vk.CommandBuffer = undefined;
144 try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{
145 .command_pool = command_pool,
146 .level = .primary,
147 .command_buffer_count = 1,
148 }, @ptrCast(&atlas_transfer_cb));
149
150 // --- Atlas transfer fence (starts signaled so first wait is a no-op) ---
151 const atlas_transfer_fence = try vkd.createFence(device, &vk.FenceCreateInfo{
152 .flags = .{ .signaled_bit = true },
153 }, null);
154 errdefer vkd.destroyFence(device, atlas_transfer_fence, null);
155 ```
156
157 - [ ] **Step 3: Add new fields to the return struct**
158
159 In the return struct literal in `Context.init`, add after `.atlas_height = atlas_height`:
160
161 ```zig
162 .atlas_staging_buffer = atlas_staging.buffer,
163 .atlas_staging_memory = atlas_staging.memory,
164 .atlas_transfer_cb = atlas_transfer_cb,
165 .atlas_transfer_fence = atlas_transfer_fence,
166 ```
167
168 - [ ] **Step 4: Free resources in Context.deinit**
169
170 In `Context.deinit`, add after the atlas memory free (after `self.vkd.freeMemory(self.device, self.atlas_memory, null);`):
171
172 ```zig
173 self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null);
174 self.vkd.freeMemory(self.device, self.atlas_staging_memory, null);
175 self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null);
176 ```
177
178 - [ ] **Step 5: Verify it compiles**
179
180 Run: `zig build 2>&1 | tail -5`
181 Expected: BUILD SUCCESS
182
183 - [ ] **Step 6: Run tests**
184
185 Run: `zig build test 2>&1 | tail -5`
186 Expected: PASS
187
188 - [ ] **Step 7: Commit**
189
190 ```bash
191 git add src/renderer.zig
192 git commit -m "Add persistent staging buffer and transfer fence to renderer"
193 ```
194
195 ### Task 3: Implement uploadAtlasRegion
196
197 **Files:**
198 - Modify: `src/renderer.zig`
199
200 - [ ] **Step 1: Add the uploadAtlasRegion method**
201
202 Add after the existing `uploadAtlas` method in `Context`:
203
204 ```zig
205 /// Upload a horizontal band of the atlas (y_start..y_end) to the GPU.
206 /// Uses the persistent staging buffer and dedicated transfer command buffer.
207 /// If `full` is true, transitions from UNDEFINED (for initial/reset uploads).
208 /// Otherwise transitions from SHADER_READ_ONLY (preserves existing data).
209 pub fn uploadAtlasRegion(
210 self: *Context,
211 pixels: []const u8,
212 y_start: u32,
213 y_end: u32,
214 full: bool,
215 ) !void {
216 if (y_start >= y_end) return;
217
218 const byte_offset: usize = @as(usize, y_start) * self.atlas_width;
219 const byte_len: usize = @as(usize, y_end - y_start) * self.atlas_width;
220
221 // Wait for any prior atlas transfer to finish before reusing staging buffer
222 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence), .true, std.math.maxInt(u64));
223 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence));
224
225 // Copy dirty band into staging buffer
226 const mapped = try self.vkd.mapMemory(self.device, self.atlas_staging_memory, 0, @intCast(byte_len), .{});
227 @memcpy(@as([*]u8, @ptrCast(mapped))[0..byte_len], pixels[byte_offset .. byte_offset + byte_len]);
228 self.vkd.unmapMemory(self.device, self.atlas_staging_memory);
229
230 // Record transfer command
231 try self.vkd.resetCommandBuffer(self.atlas_transfer_cb, .{});
232 try self.vkd.beginCommandBuffer(self.atlas_transfer_cb, &vk.CommandBufferBeginInfo{
233 .flags = .{ .one_time_submit_bit = true },
234 });
235
236 // Barrier: old_layout -> TRANSFER_DST
237 const old_layout: vk.ImageLayout = if (full) .undefined else .shader_read_only_optimal;
238 const barrier_to_transfer = vk.ImageMemoryBarrier{
239 .src_access_mask = if (full) @as(vk.AccessFlags, .{}) else .{ .shader_read_bit = true },
240 .dst_access_mask = .{ .transfer_write_bit = true },
241 .old_layout = old_layout,
242 .new_layout = .transfer_dst_optimal,
243 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
244 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
245 .image = self.atlas_image,
246 .subresource_range = .{
247 .aspect_mask = .{ .color_bit = true },
248 .base_mip_level = 0,
249 .level_count = 1,
250 .base_array_layer = 0,
251 .layer_count = 1,
252 },
253 };
254 const src_stage: vk.PipelineStageFlags = if (full) .{ .top_of_pipe_bit = true } else .{ .fragment_shader_bit = true };
255 self.vkd.cmdPipelineBarrier(
256 self.atlas_transfer_cb,
257 src_stage,
258 .{ .transfer_bit = true },
259 .{},
260 0, null,
261 0, null,
262 1, @ptrCast(&barrier_to_transfer),
263 );
264
265 // Copy staging buffer -> image (dirty band only)
266 const region = vk.BufferImageCopy{
267 .buffer_offset = 0,
268 .buffer_row_length = 0,
269 .buffer_image_height = 0,
270 .image_subresource = .{
271 .aspect_mask = .{ .color_bit = true },
272 .mip_level = 0,
273 .base_array_layer = 0,
274 .layer_count = 1,
275 },
276 .image_offset = .{ .x = 0, .y = @intCast(y_start), .z = 0 },
277 .image_extent = .{ .width = self.atlas_width, .height = y_end - y_start, .depth = 1 },
278 };
279 self.vkd.cmdCopyBufferToImage(
280 self.atlas_transfer_cb,
281 self.atlas_staging_buffer,
282 self.atlas_image,
283 .transfer_dst_optimal,
284 1,
285 @ptrCast(&region),
286 );
287
288 // Barrier: TRANSFER_DST -> SHADER_READ_ONLY
289 const barrier_to_shader = vk.ImageMemoryBarrier{
290 .src_access_mask = .{ .transfer_write_bit = true },
291 .dst_access_mask = .{ .shader_read_bit = true },
292 .old_layout = .transfer_dst_optimal,
293 .new_layout = .shader_read_only_optimal,
294 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
295 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
296 .image = self.atlas_image,
297 .subresource_range = .{
298 .aspect_mask = .{ .color_bit = true },
299 .base_mip_level = 0,
300 .level_count = 1,
301 .base_array_layer = 0,
302 .layer_count = 1,
303 },
304 };
305 self.vkd.cmdPipelineBarrier(
306 self.atlas_transfer_cb,
307 .{ .transfer_bit = true },
308 .{ .fragment_shader_bit = true },
309 .{},
310 0, null,
311 0, null,
312 1, @ptrCast(&barrier_to_shader),
313 );
314
315 try self.vkd.endCommandBuffer(self.atlas_transfer_cb);
316
317 // Submit with dedicated fence (no queueWaitIdle)
318 try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
319 .command_buffer_count = 1,
320 .p_command_buffers = @ptrCast(&self.atlas_transfer_cb),
321 }), self.atlas_transfer_fence);
322 }
323 ```
324
325 - [ ] **Step 2: Verify it compiles**
326
327 Run: `zig build 2>&1 | tail -5`
328 Expected: BUILD SUCCESS
329
330 - [ ] **Step 3: Run tests**
331
332 Run: `zig build test 2>&1 | tail -5`
333 Expected: PASS
334
335 - [ ] **Step 4: Commit**
336
337 ```bash
338 git add src/renderer.zig
339 git commit -m "Implement uploadAtlasRegion with incremental uploads"
340 ```
341
342 ### Task 4: Add ASCII precompute and wire incremental upload into main.zig
343
344 **Files:**
345 - Modify: `src/main.zig`
346
347 - [ ] **Step 1: Add ASCII precompute at startup**
348
349 In `src/main.zig`, replace the block at lines 171-172:
350
351 ```zig
352 // Upload empty atlas first (so descriptor set is valid)
353 try ctx.uploadAtlas(atlas.pixels);
354 ```
355
356 With:
357
358 ```zig
359 // Precompute printable ASCII glyphs (32-126) into atlas
360 for (32..127) |cp| {
361 _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
362 error.AtlasFull => break,
363 else => return err,
364 };
365 }
366 // Upload warm atlas (full upload — descriptor set needs valid data)
367 try ctx.uploadAtlas(atlas.pixels);
368 atlas.last_uploaded_y = atlas.cursor_y;
369 atlas.needs_full_upload = false;
370 atlas.dirty = false;
371 ```
372
373 - [ ] **Step 2: Replace the render-loop atlas upload**
374
375 In `src/main.zig`, replace the atlas upload block (lines 477-482):
376
377 ```zig
378 // Re-upload atlas if new glyphs were added
379 if (atlas.dirty) {
380 try ctx.uploadAtlas(atlas.pixels);
381 atlas.dirty = false;
382 render_cache.layout_dirty = true;
383 }
384 ```
385
386 With:
387
388 ```zig
389 // Re-upload atlas if new glyphs were added (incremental)
390 if (atlas.dirty) {
391 const y_start = atlas.last_uploaded_y;
392 const y_end = atlas.cursor_y + atlas.row_height;
393 if (y_start < y_end) {
394 try ctx.uploadAtlasRegion(
395 atlas.pixels,
396 y_start,
397 y_end,
398 atlas.needs_full_upload,
399 );
400 atlas.last_uploaded_y = atlas.cursor_y;
401 atlas.needs_full_upload = false;
402 render_cache.layout_dirty = true;
403 }
404 atlas.dirty = false;
405 }
406 ```
407
408 - [ ] **Step 3: Verify it compiles**
409
410 Run: `zig build 2>&1 | tail -5`
411 Expected: BUILD SUCCESS
412
413 - [ ] **Step 4: Run tests**
414
415 Run: `zig build test 2>&1 | tail -5`
416 Expected: PASS
417
418 - [ ] **Step 5: Commit**
419
420 ```bash
421 git add src/main.zig
422 git commit -m "Wire ASCII precompute and incremental atlas upload"
423 ```
424
425 ### Task 5: Full verification
426
427 **Files:**
428 - Test: `src/font.zig`, `src/renderer.zig`, `src/main.zig`
429
430 - [ ] **Step 1: Run the full test suite**
431
432 Run: `zig build test`
433 Expected: PASS
434
435 - [ ] **Step 2: Manual smoke test — normal run**
436
437 Run: `zig build run`
438 Expected:
439 - Terminal opens and shows text correctly (precomputed ASCII atlas).
440 - Typing normal text works. Cursor renders.
441 - Exit dumps frame timing stats — atlas_upload should be 0 for most frames.
442
443 - [ ] **Step 3: Manual smoke test — Unicode character**
444
445 Run inside terminal: `echo "★ ← → ★"`
446 Expected: Characters render correctly (incremental upload fires for the first time these codepoints appear).
447
448 - [ ] **Step 4: Manual smoke test — bench comparison**
449
450 Run: `make bench`
451 Expected:
452 - atlas_upload avg should drop significantly from the baseline ~1700us.
453 - Steady-state frames should show atlas_upload near 0.
454
455 - [ ] **Step 5: Commit if any fixups were needed**
456
457 ```bash
458 git add src/font.zig src/renderer.zig src/main.zig
459 git commit -m "Fix verification issues for incremental atlas upload"
460 ```
461
462 ## Self-Review
463
464 - **Spec coverage:**
465 - `last_uploaded_y` + `needs_full_upload` fields: Task 1
466 - `reset()` sets both fields: Task 1
467 - Persistent staging buffer: Task 2
468 - Transfer fence (starts signaled): Task 2
469 - `uploadAtlasRegion` with partial copy: Task 3
470 - Layout transition: `UNDEFINED` vs `SHADER_READ_ONLY` based on `full` flag: Task 3
471 - Post-copy barrier back to `SHADER_READ_ONLY`: Task 3
472 - Fence wait before reusing staging buffer: Task 3
473 - No `queueWaitIdle`: Task 3
474 - ASCII precompute (32-126): Task 4
475 - Render-loop incremental wiring with `y_start < y_end` guard: Task 4
476 - `last_uploaded_y = cursor_y` (not `cursor_y + row_height`): Task 4
477 - Bench comparison: Task 5
478 - **Placeholder scan:** No TBD/TODO markers. All code blocks are complete.
479 - **Type consistency:**
480 - `Atlas.last_uploaded_y` and `Atlas.needs_full_upload` defined in Task 1, used in Task 4
481 - `Context.atlas_staging_buffer`, `atlas_staging_memory`, `atlas_transfer_cb`, `atlas_transfer_fence` defined in Task 2, used in Task 3
482 - `uploadAtlasRegion(pixels, y_start, y_end, full)` defined in Task 3, called in Task 4
483 - Existing `uploadAtlas` kept unchanged — used for initial full upload in Task 4
docs/superpowers/plans/2026-04-10-performance-benchmarking-implementation.md
Old New
@@ -0,0 +1,711 @@
1 # Performance Benchmarking Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Add per-section frame timing instrumentation, a reproducible bench workload, and a perf/flamegraph target so we can measure responsiveness before and after fixing known bottlenecks.
6
7 **Architecture:** A 256-entry ring buffer of `FrameTiming` structs records microsecond timings for five render-loop sections. Stats are dumped to stderr on SIGUSR1 and clean exit. A `WAYSTTY_BENCH=1` env var swaps the user's shell for a fixed workload script. A `Makefile` provides `bench` and `profile` targets.
8
9 **Tech Stack:** Zig 0.15, `std.time.Timer`, `std.posix.sigaction`, `perf record`, `flamegraph.pl`/`stackcollapse-perf.pl`
10
11 ---
12
13 ## File Structure
14
15 - Modify: `src/main.zig`
16 - `FrameTiming` struct, `FrameTimingRing` ring buffer, `computeStats` helper, `formatStats` printer
17 - SIGUSR1 signal handler that sets an atomic flag
18 - Section timers wrapping each render-loop phase
19 - `WAYSTTY_BENCH` env var check in the shell-selection block
20 - Stats dump on clean exit
21 - Create: `Makefile`
22 - `bench` target: build + run with `WAYSTTY_BENCH=1`, extract stats from stderr
23 - `profile` target: build ReleaseSafe + `perf record` + flamegraph generation
24
25 ### Task 1: Add FrameTiming struct and ring buffer with tests
26
27 **Files:**
28 - Modify: `src/main.zig`
29 - Test: `src/main.zig`
30
31 - [ ] **Step 1: Write the failing tests**
32
33 Add at the bottom of `src/main.zig`, after the existing test blocks:
34
35 ```zig
36 test "FrameTiming.total sums all sections" {
37 const ft: FrameTiming = .{
38 .snapshot_us = 10,
39 .row_rebuild_us = 20,
40 .atlas_upload_us = 30,
41 .instance_upload_us = 40,
42 .gpu_submit_us = 50,
43 };
44 try std.testing.expectEqual(@as(u32, 150), ft.total());
45 }
46
47 test "FrameTimingRing records and wraps correctly" {
48 var ring = FrameTimingRing{};
49 try std.testing.expectEqual(@as(usize, 0), ring.count);
50
51 ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
52 try std.testing.expectEqual(@as(usize, 1), ring.count);
53 try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
54
55 // Fill to capacity
56 for (1..FrameTimingRing.capacity) |i| {
57 ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
58 }
59 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
60
61 // One more wraps around — overwrites entries[0], head advances to 1
62 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
63 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
64 // Newest entry is at (head + capacity - 1) % capacity = 0
65 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
66 // head has advanced past the overwritten slot
67 try std.testing.expectEqual(@as(usize, 1), ring.head);
68 }
69
70 test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
71 var ring = FrameTimingRing{};
72 // Push capacity + 3 entries so the ring wraps
73 for (0..FrameTimingRing.capacity + 3) |i| {
74 ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
75 }
76 var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
77 const ordered = ring.orderedSlice(&buf);
78 try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
79 // First entry should be the 4th pushed (index 3), last should be capacity+2
80 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
81 try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
82 }
83 ```
84
85 - [ ] **Step 2: Run test to verify it fails**
86
87 Run: `zig build test 2>&1 | head -20`
88 Expected: FAIL with `FrameTiming` not found.
89
90 - [ ] **Step 3: Implement FrameTiming and FrameTimingRing**
91
92 Add above the test blocks in `src/main.zig`:
93
94 ```zig
95 const FrameTiming = struct {
96 snapshot_us: u32 = 0,
97 row_rebuild_us: u32 = 0,
98 atlas_upload_us: u32 = 0,
99 instance_upload_us: u32 = 0,
100 gpu_submit_us: u32 = 0,
101
102 fn total(self: FrameTiming) u32 {
103 return self.snapshot_us +
104 self.row_rebuild_us +
105 self.atlas_upload_us +
106 self.instance_upload_us +
107 self.gpu_submit_us;
108 }
109 };
110
111 const FrameTimingRing = struct {
112 const capacity = 256;
113
114 entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
115 head: usize = 0,
116 count: usize = 0,
117
118 fn push(self: *FrameTimingRing, timing: FrameTiming) void {
119 const idx = if (self.count < capacity) self.count else self.head;
120 self.entries[idx] = timing;
121 if (self.count < capacity) {
122 self.count += 1;
123 } else {
124 self.head = (self.head + 1) % capacity;
125 }
126 }
127
128 /// Return a slice of valid entries in insertion order.
129 /// Caller must provide a scratch buffer of `capacity` entries.
130 fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
131 if (self.count < capacity) {
132 return self.entries[0..self.count];
133 }
134 // Ring has wrapped — copy from head..end then 0..head
135 const tail_len = capacity - self.head;
136 @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
137 @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
138 return buf[0..capacity];
139 }
140 };
141 ```
142
143 - [ ] **Step 4: Run test to verify it passes**
144
145 Run: `zig build test 2>&1 | tail -5`
146 Expected: PASS
147
148 - [ ] **Step 5: Commit**
149
150 ```bash
151 git add src/main.zig
152 git commit -m "Add FrameTiming struct and ring buffer"
153 ```
154
155 ### Task 2: Add stats computation and formatting with tests
156
157 **Files:**
158 - Modify: `src/main.zig`
159 - Test: `src/main.zig`
160
161 - [ ] **Step 1: Write the failing tests**
162
163 Add after the Task 1 tests in `src/main.zig`:
164
165 ```zig
166 test "FrameTimingStats computes min/avg/p99/max correctly" {
167 var ring = FrameTimingRing{};
168 // Push 100 frames with snapshot_us = 1..100
169 for (0..100) |i| {
170 ring.push(.{
171 .snapshot_us = @intCast(i + 1),
172 .row_rebuild_us = 0,
173 .atlas_upload_us = 0,
174 .instance_upload_us = 0,
175 .gpu_submit_us = 0,
176 });
177 }
178 const stats = computeFrameStats(&ring);
179 try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
180 try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
181 try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
182 // p99 of 1..100 = value at index 98 (0-based) = 99
183 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
184 try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
185 }
186
187 test "FrameTimingStats handles empty ring" {
188 var ring = FrameTimingRing{};
189 const stats = computeFrameStats(&ring);
190 try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
191 try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
192 }
193 ```
194
195 - [ ] **Step 2: Run test to verify it fails**
196
197 Run: `zig build test 2>&1 | head -20`
198 Expected: FAIL with `computeFrameStats` not found.
199
200 - [ ] **Step 3: Implement stats computation and formatting**
201
202 Add after the `FrameTimingRing` definition:
203
204 ```zig
205 const SectionStats = struct {
206 min: u32 = 0,
207 avg: u32 = 0,
208 p99: u32 = 0,
209 max: u32 = 0,
210 };
211
212 const FrameTimingStats = struct {
213 snapshot: SectionStats = .{},
214 row_rebuild: SectionStats = .{},
215 atlas_upload: SectionStats = .{},
216 instance_upload: SectionStats = .{},
217 gpu_submit: SectionStats = .{},
218 total: SectionStats = .{},
219 frame_count: usize = 0,
220 };
221
222 fn computeSectionStats(values: []u32) SectionStats {
223 if (values.len == 0) return .{};
224 std.mem.sort(u32, values, {}, std.sort.asc(u32));
225 var sum: u64 = 0;
226 for (values) |v| sum += v;
227 const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
228 return .{
229 .min = values[0],
230 .avg = @intCast(sum / values.len),
231 .p99 = values[p99_idx],
232 .max = values[values.len - 1],
233 };
234 }
235
236 fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
237 if (ring.count == 0) return .{};
238
239 var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
240 const entries = ring.orderedSlice(&ordered_buf);
241 const n = entries.len;
242
243 var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
244 var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
245 var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
246 var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
247 var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
248 var total_vals: [FrameTimingRing.capacity]u32 = undefined;
249
250 for (entries, 0..) |e, i| {
251 snapshot_vals[i] = e.snapshot_us;
252 row_rebuild_vals[i] = e.row_rebuild_us;
253 atlas_upload_vals[i] = e.atlas_upload_us;
254 instance_upload_vals[i] = e.instance_upload_us;
255 gpu_submit_vals[i] = e.gpu_submit_us;
256 total_vals[i] = e.total();
257 }
258
259 return .{
260 .snapshot = computeSectionStats(snapshot_vals[0..n]),
261 .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
262 .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
263 .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
264 .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
265 .total = computeSectionStats(total_vals[0..n]),
266 .frame_count = n,
267 };
268 }
269
270 fn printFrameStats(stats: FrameTimingStats) void {
271 const stderr = std.io.getStdErr().writer();
272 stderr.print(
273 \\
274 \\=== waystty frame timing ({d} frames) ===
275 \\{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)
276 \\{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}
277 \\{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}
278 \\{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}
279 \\{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}
280 \\{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}
281 \\----------------------------------------------------
282 \\{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}
283 \\
284 , .{
285 stats.frame_count,
286 "section", "min", "avg", "p99", "max",
287 "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max,
288 "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max,
289 "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max,
290 "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max,
291 "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max,
292 "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max,
293 }) catch |err| {
294 std.log.debug("failed to print frame stats: {}", .{err});
295 };
296 }
297 ```
298
299 - [ ] **Step 4: Run test to verify it passes**
300
301 Run: `zig build test 2>&1 | tail -5`
302 Expected: PASS
303
304 - [ ] **Step 5: Commit**
305
306 ```bash
307 git add src/main.zig
308 git commit -m "Add frame timing stats computation and formatting"
309 ```
310
311 ### Task 3: Add SIGUSR1 signal handler
312
313 **Files:**
314 - Modify: `src/main.zig`
315
316 - [ ] **Step 1: Add the signal flag and handler**
317
318 Add below the `FrameTimingRing` and stats code in `src/main.zig`:
319
320 ```zig
321 var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
322
323 fn sigusr1Handler(_: c_int) callconv(.c) void {
324 sigusr1_received.store(true, .release);
325 }
326
327 fn installSigusr1Handler() void {
328 const act = std.posix.Sigaction{
329 .handler = .{ .handler = sigusr1Handler },
330 .mask = std.posix.sigemptyset(),
331 .flags = .{ .RESTART = true },
332 };
333 std.posix.sigaction(std.posix.SIG.USR1, &act, null);
334 }
335 ```
336
337 - [ ] **Step 2: Wire into runTerminal**
338
339 In `runTerminal`, right before the `// === main loop ===` comment (line 205), add:
340
341 ```zig
342 // === frame timing ===
343 var frame_ring = FrameTimingRing{};
344 installSigusr1Handler();
345 ```
346
347 Inside the main loop, right after `clearConsumedDirtyFlags` (line 534), add:
348
349 ```zig
350 // Check for SIGUSR1 stats dump request
351 if (sigusr1_received.swap(false, .acq_rel)) {
352 printFrameStats(computeFrameStats(&frame_ring));
353 }
354 ```
355
356 Right after the main loop (after the `while` block ends, before `_ = try ctx.vkd.deviceWaitIdle`), add:
357
358 ```zig
359 // Dump timing stats on exit
360 printFrameStats(computeFrameStats(&frame_ring));
361 ```
362
363 - [ ] **Step 3: Verify it compiles**
364
365 Run: `zig build 2>&1 | tail -5`
366 Expected: BUILD SUCCESS (no test run needed — signal handling is not unit-testable)
367
368 - [ ] **Step 4: Commit**
369
370 ```bash
371 git add src/main.zig
372 git commit -m "Add SIGUSR1 handler for frame timing stats dump"
373 ```
374
375 ### Task 4: Wire section timers into the render loop
376
377 **Files:**
378 - Modify: `src/main.zig`
379
380 This task wraps each render-loop section with `std.time.Timer` and pushes a `FrameTiming` entry after each rendered frame.
381
382 - [ ] **Step 1: Add timer helper**
383
384 Add near the other helper functions in `src/main.zig`:
385
386 ```zig
387 fn usFromTimer(timer: std.time.Timer) u32 {
388 const ns = timer.read();
389 const us = ns / std.time.ns_per_us;
390 return std.math.cast(u32, us) orelse std.math.maxInt(u32);
391 }
392 ```
393
394 - [ ] **Step 2: Instrument the render loop**
395
396 In `runTerminal`, replace the render section. The existing code between `// === render ===` (line 357) and `clearConsumedDirtyFlags` (line 534) gets wrapped with timers. Add a `var frame_timing: FrameTiming = .{};` before `// === render ===` and instrument each section:
397
398 **snapshot section** — wrap `try term.snapshot();` (line 359):
399
400 ```zig
401 var frame_timing: FrameTiming = .{};
402
403 // === render ===
404 const previous_cursor = term.render_state.cursor;
405 var section_timer = std.time.Timer.start() catch unreachable;
406 try term.snapshot();
407 frame_timing.snapshot_us = usFromTimer(section_timer);
408 ```
409
410 **row_rebuild section** — wrap the dirty-row rebuild loop (the `var rows_rebuilt` through cursor rebuild blocks):
411
412 ```zig
413 section_timer = std.time.Timer.start() catch unreachable;
414 ```
415
416 Right before `// Re-upload atlas if new glyphs were added` (line 452):
417
418 ```zig
419 frame_timing.row_rebuild_us = usFromTimer(section_timer);
420 ```
421
422 **atlas_upload section** — wrap the atlas upload block:
423
424 ```zig
425 section_timer = std.time.Timer.start() catch unreachable;
426 // Re-upload atlas if new glyphs were added
427 if (atlas.dirty) {
428 try ctx.uploadAtlas(atlas.pixels);
429 atlas.dirty = false;
430 render_cache.layout_dirty = true;
431 }
432 frame_timing.atlas_upload_us = usFromTimer(section_timer);
433 ```
434
435 **instance_upload section** — wrap the upload plan + upload blocks:
436
437 ```zig
438 section_timer = std.time.Timer.start() catch unreachable;
439 ```
440
441 Right before `const baseline_coverage = renderer.coverageVariantParams(.baseline);` (line 517):
442
443 ```zig
444 frame_timing.instance_upload_us = usFromTimer(section_timer);
445 ```
446
447 **gpu_submit section** — wrap `ctx.drawCells(...)`:
448
449 ```zig
450 section_timer = std.time.Timer.start() catch unreachable;
451 const baseline_coverage = renderer.coverageVariantParams(.baseline);
452 ctx.drawCells(
453 render_cache.total_instance_count,
454 .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
455 default_bg,
456 baseline_coverage,
457 ) catch |err| switch (err) {
458 error.OutOfDateKHR => {
459 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
460 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
461 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
462 try ctx.recreateSwapchain(buf_w, buf_h);
463 render_pending = true;
464 continue;
465 },
466 else => return err,
467 };
468 frame_timing.gpu_submit_us = usFromTimer(section_timer);
469 ```
470
471 **Push timing entry** — right after the gpu_submit timer read, before `clearConsumedDirtyFlags`:
472
473 ```zig
474 frame_ring.push(frame_timing);
475 ```
476
477 - [ ] **Step 3: Verify it compiles**
478
479 Run: `zig build 2>&1 | tail -5`
480 Expected: BUILD SUCCESS
481
482 - [ ] **Step 4: Run tests to verify nothing broke**
483
484 Run: `zig build test 2>&1 | tail -5`
485 Expected: PASS
486
487 - [ ] **Step 5: Commit**
488
489 ```bash
490 git add src/main.zig
491 git commit -m "Instrument render loop with per-section frame timers"
492 ```
493
494 ### Task 5: Add WAYSTTY_BENCH shell override
495
496 **Files:**
497 - Modify: `src/main.zig`
498
499 - [ ] **Step 1: Replace the shell selection block**
500
501 In `runTerminal`, the current shell selection code (lines 190-194) is:
502
503 ```zig
504 const shell: [:0]const u8 = blk: {
505 const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh";
506 break :blk try alloc.dupeZ(u8, shell_env);
507 };
508 defer alloc.free(shell);
509 ```
510
511 Replace it with:
512
513 ```zig
514 const shell: [:0]const u8 = blk: {
515 if (std.posix.getenv("WAYSTTY_BENCH") != null) {
516 break :blk try alloc.dupeZ(u8, "/bin/sh");
517 }
518 const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh";
519 break :blk try alloc.dupeZ(u8, shell_env);
520 };
521 defer alloc.free(shell);
522
523 const bench_script: ?[:0]const u8 = if (std.posix.getenv("WAYSTTY_BENCH") != null)
524 "echo warmup; sleep 0.2; seq 1 50000; find /usr/lib -name '*.so' 2>/dev/null | head -500; yes 'hello world' | head -2000; exit 0"
525 else
526 null;
527 ```
528
529 - [ ] **Step 2: Pass bench script as shell arg when set**
530
531 Replace the `pty.Pty.spawn` call (line 196) with:
532
533 ```zig
534 var p = try pty.Pty.spawn(.{
535 .cols = cols,
536 .rows = rows,
537 .shell = shell,
538 .shell_args = if (bench_script) |script| &.{ "-c", script } else null,
539 });
540 ```
541
542 - [ ] **Step 3: Update pty.zig to accept shell_args**
543
544 In `src/pty.zig`, modify the `SpawnOptions` struct (line 18) to add `shell_args`:
545
546 ```zig
547 pub const SpawnOptions = struct {
548 cols: u16,
549 rows: u16,
550 shell: [:0]const u8,
551 shell_args: ?[]const [:0]const u8 = null,
552 };
553 ```
554
555 In the `spawn` function, replace the `argv` construction (line 40) with:
556
557 ```zig
558 if (opts.shell_args) |args| {
559 std.debug.assert(args.len < 15); // argv[0] = shell, must fit in 16-slot buffer
560 var argv_buf: [16:null]?[*:0]const u8 = .{null} ** 16;
561 argv_buf[0] = opts.shell.ptr;
562 for (args, 1..) |arg, i| {
563 argv_buf[i] = arg.ptr;
564 }
565 std.posix.execveZ(opts.shell.ptr, &argv_buf, std.c.environ) catch {};
566 } else {
567 var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null };
568 std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {};
569 }
570 ```
571
572 - [ ] **Step 4: Verify it compiles**
573
574 Run: `zig build 2>&1 | tail -5`
575 Expected: BUILD SUCCESS
576
577 - [ ] **Step 5: Run tests**
578
579 Run: `zig build test 2>&1 | tail -5`
580 Expected: PASS
581
582 - [ ] **Step 6: Commit**
583
584 ```bash
585 git add src/main.zig src/pty.zig
586 git commit -m "Add WAYSTTY_BENCH env var for bench workload"
587 ```
588
589 ### Task 6: Create Makefile with bench and profile targets
590
591 **Files:**
592 - Create: `Makefile`
593
594 - [ ] **Step 1: Create the Makefile**
595
596 Create `Makefile` in the project root:
597
598 ```makefile
599 ZIG ?= zig
600 FLAMEGRAPH ?= flamegraph.pl
601 STACKCOLLAPSE ?= stackcollapse-perf.pl
602
603 .PHONY: build run test bench profile clean
604
605 build:
606 $(ZIG) build
607
608 run: build
609 $(ZIG) build run
610
611 test:
612 $(ZIG) build test
613
614 zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard shaders/*)
615 $(ZIG) build
616
617 bench: zig-out/bin/waystty
618 WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log || true
619 @echo "--- frame timing ---"
620 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
621
622 profile:
623 $(ZIG) build -Doptimize=ReleaseSafe
624 perf record -g -F 999 --no-inherit -o perf.data -- \
625 sh -c 'WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log'
626 perf script -i perf.data \
627 | $(STACKCOLLAPSE) \
628 | $(FLAMEGRAPH) > flamegraph.svg
629 @echo "--- frame timing ---"
630 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
631 xdg-open flamegraph.svg
632
633 clean:
634 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg
635 ```
636
637 - [ ] **Step 2: Verify bench target syntax**
638
639 Run: `make -n bench`
640 Expected: prints the commands that would run (dry run), no syntax errors.
641
642 - [ ] **Step 3: Verify profile target syntax**
643
644 Run: `make -n profile`
645 Expected: prints the commands that would run (dry run), no syntax errors.
646
647 - [ ] **Step 4: Commit**
648
649 ```bash
650 git add Makefile
651 git commit -m "Add Makefile with bench and profile targets"
652 ```
653
654 ### Task 7: Full verification
655
656 **Files:**
657 - Test: `src/main.zig`, `src/pty.zig`
658
659 - [ ] **Step 1: Run the full test suite**
660
661 Run: `zig build test`
662 Expected: PASS
663
664 - [ ] **Step 2: Manual smoke test — normal run**
665
666 Run: `zig build run`
667 Expected:
668 - Terminal opens and works normally.
669 - On Ctrl+D / exit, frame timing stats print to stderr.
670
671 - [ ] **Step 3: Manual smoke test — SIGUSR1**
672
673 In one terminal: `zig build run`
674 In another terminal: `kill -USR1 $(pgrep waystty)`
675 Expected: frame timing stats print to stderr of the running waystty.
676
677 - [ ] **Step 4: Manual smoke test — bench**
678
679 Run: `make bench`
680 Expected:
681 - waystty opens, runs the bench workloads, exits.
682 - `bench.log` contains frame timing stats.
683 - Stats are printed to the console.
684
685 - [ ] **Step 5: Commit if any fixups were needed**
686
687 ```bash
688 git add src/main.zig src/pty.zig Makefile
689 git commit -m "Fix verification issues for performance benchmarking"
690 ```
691
692 ## Self-Review
693
694 - **Spec coverage:**
695 - Ring buffer: Task 1
696 - Stats computation (min/avg/p99/max): Task 2
697 - SIGUSR1 trigger: Task 3
698 - Section timers: Task 4
699 - WAYSTTY_BENCH shell override: Task 5
700 - Makefile bench target: Task 6
701 - Makefile profile target: Task 6
702 - Clean exit stats dump: Task 3
703 - **Placeholder scan:** No TBD/TODO markers. All code blocks are complete.
704 - **Type consistency:**
705 - `FrameTiming` defined in Task 1, used in Tasks 2-4
706 - `FrameTimingRing` defined in Task 1, used in Tasks 2-4
707 - `computeFrameStats` defined in Task 2, called in Task 3
708 - `printFrameStats` defined in Task 2, called in Task 3
709 - `usFromTimer` defined in Task 4, used in Task 4
710 - `SpawnOptions.shell_args` added in Task 5, used in Task 5
711 - `sigusr1_received` and `installSigusr1Handler` defined in Task 3, used in Tasks 3-4
docs/superpowers/specs/2026-04-10-incremental-atlas-upload-design.md
Old New
@@ -0,0 +1,145 @@
1 # Incremental Atlas Upload Design
2
3 ## Goal
4
5 Reduce atlas upload cost from full-texture re-upload (~1.7ms avg, 3.6ms peak) to near-zero for steady-state frames by uploading only new glyph rows and precomputing the common ASCII set at startup.
6
7 ## Current Problem
8
9 Every time a new glyph is rasterized into the atlas, `uploadAtlas` re-uploads the entire atlas texture (1024x1024 = 1MB at 1x, 2048x2048 = 4MB at 2x) through a freshly allocated staging buffer, transitions the image layout from `UNDEFINED` (discarding GPU cache), and calls `queueWaitIdle` (CPU stall). Bench data shows this is 61% of average frame time.
10
11 ## Two Complementary Changes
12
13 ### 1. Atlas precomputation
14
15 Rasterize printable ASCII (codepoints 32–126, 95 characters) into the atlas at startup, before the first frame renders. Do a single full upload of the warm atlas. This eliminates the cold-start spike entirely — most terminal content uses only these characters.
16
17 ### 2. Incremental upload
18
19 For glyphs added after startup (Unicode, CJK, symbols), upload only the new rows instead of the entire texture.
20
21 ## Dirty-Region Tracking
22
23 Add two fields to the `Atlas` struct:
24 - `last_uploaded_y: u32` — initialized to 0. Tracks how far up the GPU atlas is known-good.
25 - `needs_full_upload: bool` — initialized to `true`. Set to `true` by `init()` and `reset()`. Cleared after a full upload completes.
26
27 The dirty region is always a horizontal band spanning the full atlas width:
28 - `y_start` = `last_uploaded_y`
29 - `y_end` = `cursor_y + row_height`
30
31 After a successful upload, set `last_uploaded_y = cursor_y` (NOT `cursor_y + row_height`). This ensures the in-progress row is always re-uploaded on the next frame if new glyphs are added to it at new X positions. The cost of re-uploading one row (~20KB for a 20px row in a 1024-wide atlas) is negligible.
32
33 Once the packing cursor wraps to a new row, `cursor_y` advances past the previously uploaded row, and those rows are never re-uploaded again.
34
35 On `reset()` (DPI/scale change), set `last_uploaded_y = 0` and `needs_full_upload = true`.
36
37 If `y_start == y_end`, skip the upload and clear `atlas.dirty` — no pixels actually changed.
38
39 ## Renderer Changes
40
41 Replace `uploadAtlas(pixels)` with `uploadAtlasRegion(pixels, y_start, y_end, full)`:
42
43 ### Persistent staging buffer
44
45 Allocate once at `Context.init`, sized to hold the full atlas (1024x1024 = 1MB, fixed regardless of DPI). Reuse across frames. Free at `Context.deinit`. No per-frame alloc/free.
46
47 ### Partial staging copy
48
49 Only copy the dirty band of pixels into the staging buffer. Byte range: `y_start * atlas_width` to `y_end * atlas_width`.
50
51 ### Layout transition preserves contents
52
53 - Incremental upload: `SHADER_READ_ONLY_OPTIMAL → TRANSFER_DST_OPTIMAL` (preserves existing GPU data)
54 - Full upload (after reset or initial): `UNDEFINED → TRANSFER_DST_OPTIMAL` (discards, no preservation needed)
55
56 The `needs_full_upload` flag controls which transition is used.
57
58 ### Post-copy barrier
59
60 After the `BufferImageCopy`, transition back: `TRANSFER_DST_OPTIMAL → SHADER_READ_ONLY_OPTIMAL`. This is required for both full and incremental uploads (same as the existing code).
61
62 ### Partial image copy
63
64 The `BufferImageCopy` region targets only the dirty rows:
65 - `image_offset = { .x = 0, .y = y_start, .z = 0 }`
66 - `image_extent = { .width = atlas_width, .height = y_end - y_start, .depth = 1 }`
67
68 ### Remove queueWaitIdle
69
70 Replace with a dedicated transfer fence. At the start of `uploadAtlasRegion`, if a prior transfer fence is unsignaled, wait on it before writing to the staging buffer or re-recording the command buffer. This prevents corruption if two uploads happen in consecutive frames. After submitting the transfer command, signal the fence.
71
72 This is still a win over `queueWaitIdle` because the fence only waits on the single transfer command, not the entire graphics queue.
73
74 ## Caller-Side Wiring (main.zig)
75
76 ### Startup precompute
77
78 After `Atlas.init` and before the main loop, rasterize codepoints 32–126 into the atlas, then do a single full upload via the existing `uploadAtlas` path.
79
80 ### Render loop
81
82 Replace:
83 ```zig
84 if (atlas.dirty) {
85 try ctx.uploadAtlas(atlas.pixels);
86 atlas.dirty = false;
87 render_cache.layout_dirty = true;
88 }
89 ```
90
91 With:
92 ```zig
93 if (atlas.dirty) {
94 const y_start = atlas.last_uploaded_y;
95 const y_end = atlas.cursor_y + atlas.row_height;
96 if (y_start < y_end) {
97 try ctx.uploadAtlasRegion(
98 atlas.pixels,
99 y_start,
100 y_end,
101 atlas.needs_full_upload,
102 );
103 atlas.last_uploaded_y = atlas.cursor_y;
104 atlas.needs_full_upload = false;
105 render_cache.layout_dirty = true;
106 }
107 atlas.dirty = false;
108 }
109 ```
110
111 ## Files Changed
112
113 - `src/font.zig` — add `last_uploaded_y` and `needs_full_upload` fields to `Atlas`, reset them in `reset()`
114 - `src/renderer.zig` — add persistent staging buffer, `uploadAtlasRegion` method, dedicated transfer fence and command buffer
115 - `src/main.zig` — startup precompute loop, render-loop wiring change
116
117 ## Testing
118
119 ### Unit tests (font.zig)
120
121 - `last_uploaded_y` starts at 0 and `needs_full_upload` starts `true` after `init()`
122 - After inserting a glyph, dirty region is `0..cursor_y + row_height`
123 - After `reset()`, `last_uploaded_y` resets to 0 and `needs_full_upload` is `true`
124
125 ### Unit tests (renderer.zig)
126
127 - `uploadAtlasRegion` byte offset/length calculation: `y_start * width` to `y_end * width`
128 - Full-upload flag selects `UNDEFINED` vs `SHADER_READ_ONLY` as the old layout
129
130 ### Manual smoke tests
131
132 - Startup shows text correctly (precomputed atlas works)
133 - Typing a rare Unicode character (`echo "★"`) renders correctly (incremental upload works)
134 - DPI change still works (full re-upload after reset)
135 - `make bench` shows atlas_upload dropping from ~1700us to near-zero steady state
136
137 ## Future Consideration
138
139 Precomputing box-drawing (U+2500–U+257F) and block element (U+2580–U+259F) characters would improve first-render for TUI apps like tmux, htop, and tree. Not needed for this phase — the incremental upload handles them correctly on first appearance.
140
141 ## Non-Goals
142
143 - Atlas resizing (atlas is fixed at 1024x1024 regardless of DPI, returns `AtlasFull` error if exhausted)
144 - Double-buffered atlas images (overkill for a terminal)
145 - Async transfer queue (single queue is sufficient)
docs/superpowers/specs/2026-04-10-performance-benchmarking-design.md
Old New
@@ -0,0 +1,140 @@
1 # Performance Benchmarking Design
2
3 ## Goal
4
5 Establish a reproducible performance baseline for waystty before tackling known bottlenecks. The primary metric is responsiveness under real workloads — not synthetic throughput scores.
6
7 ## Non-goals
8
9 - vtebench integration (rewards batching, doesn't measure latency)
10 - tracy GPU profiling (GPU draw cost is negligible for a terminal; CPU-side bottlenecks dominate)
11 - Input-to-display latency measurement (out of scope for this phase)
12
13 ## Known bottlenecks (to be measured, then fixed)
14
15 1. Atlas full re-upload on any new glyph — entire atlas through staging buffer + `queueWaitIdle` stall
16 2. Instance buffer map/unmap on every frame — host-visible memory can stay persistently mapped
17 3. Atlas staging buffer allocated/freed on every upload — should be persistent
18 4. Atlas image layout transitions from `UNDEFINED` — should go `SHADER_READ_ONLY → TRANSFER_DST → SHADER_READ_ONLY` for incremental updates
19
20 ## Module 1: Frame timing ring buffer
21
22 ### Instrumented sections
23
24 Five sections timed with `std.time.Timer` on every rendered frame:
25
26 | Section | What it covers |
27 |---|---|
28 | `snapshot` | `term.snapshot()` |
29 | `row_rebuild` | refresh planning + dirty-row rebuild + cursor rebuild |
30 | `atlas_upload` | `ctx.uploadAtlas(...)` — zero when atlas is not dirty |
31 | `instance_upload` | `uploadInstances` / `uploadInstanceRange` |
32 | `gpu_submit` | fence wait + image acquire + command record + submit + present. Note: the fence wait blocks on the *previous* frame's GPU work, so this section includes GPU execution time of frame N-1. This is correct for latency measurement (actual wall-clock cost of this phase). |
33
34 Idle frames (no render) are not recorded.
35
36 ### Data structure
37
38 256-entry ring buffer of `FrameTiming` structs in `src/main.zig`. All fields are `u32` microseconds. ~6KB total. Always compiled in — timer reads are negligible overhead.
39
40 ```zig
41 const FrameTiming = struct {
42 snapshot_us: u32 = 0,
43 row_rebuild_us: u32 = 0,
44 atlas_upload_us: u32 = 0,
45 instance_upload_us: u32 = 0,
46 gpu_submit_us: u32 = 0,
47 };
48 ```
49
50 ### Stats output
51
52 Triggered on SIGUSR1 and on clean exit. Prints to stderr:
53
54 ```
55 === waystty frame timing (243 frames) ===
56 section min avg p99 max (µs)
57 snapshot 2 4 15 89
58 row_rebuild 1 12 124 890
59 atlas_upload 0 180 5200 8100
60 instance_upload 1 6 24 71
61 gpu_submit 3 8 35 210
62 ─────────────────────────────────────────
63 total 9 210 5400 8800
64 ```
65
66 p99 computed by sorting a copy of the 256 values per section.
67
68 ## Module 2: Bench workload
69
70 ### Mechanism
71
72 When `WAYSTTY_BENCH=1` env var is set at startup, spawn `sh -c '<bench script>'` instead of `$SHELL`. Stats are dumped to stderr on exit (clean shell exit triggers the normal exit path).
73
74 ### Workloads
75
76 ```sh
77 echo warmup; sleep 0.2;
78 seq 1 50000;
79 find /usr/lib -name '*.so' 2>/dev/null | head -500;
80 yes 'hello world' | head -2000;
81 exit 0
82 ```
83
84 - `echo warmup; sleep 0.2` — lets the atlas rasterize common ASCII before timing real workloads
85 - `seq` — burst of short sequential lines, tests frame batching and row rebuild
86 - `find` — irregular line lengths, mixed output cadence
87 - `yes` — high-frequency identical lines, tests the low-change-rate path
88
89 ### Makefile target
90
91 ```makefile
92 .PHONY: bench
93 bench: zig-out/bin/waystty
94 WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log
95 @echo "--- frame timing ---"
96 @grep -A 12 "waystty frame timing" bench.log
97 ```
98
99 ## Module 3: perf + flamegraph
100
101 ### Build mode
102
103 `ReleaseSafe` — keeps debug symbols and frame pointers. `ReleaseFast` may omit frame pointers, producing useless perf stacks.
104
105 ### Makefile target
106
107 ```makefile
108 FLAMEGRAPH ?= flamegraph.pl
109 STACKCOLLAPSE ?= stackcollapse-perf.pl
110
111 .PHONY: profile
112 profile:
113 zig build -Doptimize=ReleaseSafe
114 perf record -g -F 999 --no-inherit -o perf.data -- \
115 sh -c 'WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log'
116 perf script -i perf.data \
117 | $(STACKCOLLAPSE) \
118 | $(FLAMEGRAPH) > flamegraph.svg
119 @echo "--- frame timing ---"
120 @grep -A 12 "waystty frame timing" bench.log
121 xdg-open flamegraph.svg
122 ```
123
124 `FLAMEGRAPH` and `STACKCOLLAPSE` default to scripts in `PATH` (available via `flamegraph` package on Arch), overridable: `make profile FLAMEGRAPH=~/FlameGraph/flamegraph.pl`.
125
126 ### Prerequisites
127
128 - `flamegraph` package (provides `flamegraph.pl` and `stackcollapse-perf.pl`)
129 - `perf` with `CAP_PERFMON` or `/proc/sys/kernel/perf_event_paranoid <= 1`
130
131 ## Files changed
132
133 - `src/main.zig` — ring buffer, section timers, SIGUSR1 handler, `WAYSTTY_BENCH` env check
134 - `Makefile` — `bench` and `profile` targets
135
136 ## Testing
137
138 - Run `make bench`, verify stats appear in bench.log
139 - Send SIGUSR1 to a running waystty, verify stats print to stderr
140 - Run `make profile`, verify flamegraph.svg opens and shows waystty frames