fc9f9849
Add performance benchmarking and incremental atlas upload specs and plans
a73x 2026-04-10 10:17
Commit message
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(®ion), | ||
| 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 | ||