From d73fb6708069b72ae553c6fd502a2f5b90a94984 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 26 May 2026 13:09:01 -0500 Subject: [PATCH] renderer/vulkan: scaffold build-time SPV precompile (foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `vulkan_spvgen` ExeEntrypoint + the host tool itself. The tool takes a (shader_name, stage) argv pair, looks up the matching `source.*` decl in `renderer/vulkan/shaders.zig`, runs `vulkanizeGlsl` + `glslang.vk.compileToSpv`, and writes the SPIR-V bytes to stdout. Goal: eliminate the residual ~10 MB Vulkan-vs-OpenGL leak delta. The 9 built-in shaders currently go through `Module.init` (glslang at runtime) on every first surface init, populating glslang's thread-local TPoolAllocator. The pool's high-water mark is set by these compiles and never released (Zig pthreads + C++ thread_local = no destructor hook). Pre- compile at build time, embed via @embedFile, call `Module.initFromSpirv` for built-ins → glslang never gets invoked for them → pool stays empty for users with no custom shader, and shrinks to just-custom-shader size for users with one. Remaining work (next session): - src/build/VulkanSpv.zig — host-target build helper that builds the gen tool, runs it 9 times, captures stdout into a generated `vulkan_spv.zig` module exposing @embedFile'd bytes per shader. Mirror HelpStrings.zig's pattern. - src/build/SharedDeps.zig — wire the generated module into libghostty when -Drenderer=vulkan. - src/renderer/vulkan/shaders.zig — replace the 9 `Module.init(alloc, dev, source.X, .stage)` calls with `Module.initFromSpirv(dev, vulkan_spv.X, .stage)`. Foundation alone doesn't change runtime behavior (entrypoint unused until VulkanSpv.zig wires it in). Both variants still build clean. Co-Authored-By: claude-flow --- src/build/Config.zig | 8 ++++ src/main.zig | 1 + src/vulkan_spvgen.zig | 88 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/vulkan_spvgen.zig diff --git a/src/build/Config.zig b/src/build/Config.zig index 0a9947317..e2f4c0074 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -688,6 +688,14 @@ pub const ExeEntrypoint = enum { webgen_config, webgen_actions, webgen_commands, + /// Build-time tool: compiles one of the renderer's built-in + /// GLSL shaders to SPIR-V and writes the bytes to stdout. + /// Invoked by `src/build/VulkanSpv.zig` once per (shader, stage) + /// pair so libghostty can `@embedFile` the resulting .spv + /// instead of running glslang at runtime — eliminates the + /// per-process TPoolAllocator high-water-mark leak (~10 MB) + /// that the Vulkan path otherwise pays on first surface init. + vulkan_spvgen, }; /// The release channel for the build. diff --git a/src/main.zig b/src/main.zig index b08e63dd2..c29a29158 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,6 +10,7 @@ const entrypoint = switch (build_config.exe_entrypoint) { .webgen_config => @import("build/webgen/main_config.zig"), .webgen_actions => @import("build/webgen/main_actions.zig"), .webgen_commands => @import("build/webgen/main_commands.zig"), + .vulkan_spvgen => @import("vulkan_spvgen.zig"), }; /// The main entrypoint for the program. diff --git a/src/vulkan_spvgen.zig b/src/vulkan_spvgen.zig new file mode 100644 index 000000000..dc110c48d --- /dev/null +++ b/src/vulkan_spvgen.zig @@ -0,0 +1,88 @@ +//! Build-time tool: compiles one of `src/renderer/vulkan/shaders.zig`'s +//! `source.*` constants to SPIR-V and writes the bytes to stdout. +//! +//! Invoked by `src/build/VulkanSpv.zig` once per (shader_name, stage) +//! pair so the renderer can `@embedFile` the resulting .spv blobs +//! and call `Module.initFromSpirv` for built-ins instead of going +//! through `glslang.vk.compileToSpv` at runtime. The runtime path +//! is what populates glslang's per-thread `TPoolAllocator`, which +//! never releases its high-water-mark pages (Zig pthreads don't +//! run C++ thread_local destructors) — heaptrack attributed ~10 MB +//! to that residual leak on the Vulkan variant, exactly the delta +//! over OpenGL (which never invokes glslang for its built-ins +//! because the GPU driver compiles GLSL natively). +//! +//! Usage: +//! vulkan_spvgen +//! +//! Where `shader_name` is one of the public decls of +//! `vulkan.shaders.source` (e.g. `bg_color_frag`, `cell_text_vert`) +//! and `stage` is `vertex` or `fragment`. +//! +//! On success: writes binary SPIR-V to stdout, exits 0. +//! On failure: writes a diagnostic to stderr, exits 1. + +const std = @import("std"); +const shaders = @import("renderer/vulkan/shaders.zig"); +const glslang = @import("glslang"); + +pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const args = try std.process.argsAlloc(alloc); + defer std.process.argsFree(alloc, args); + + if (args.len != 3) { + std.debug.print( + "usage: {s} \n", + .{args[0]}, + ); + std.process.exit(1); + } + const name = args[1]; + const stage = std.meta.stringToEnum(shaders.Stage, args[2]) orelse { + std.debug.print("invalid stage: {s}\n", .{args[2]}); + std.process.exit(1); + }; + + try glslang.init(); + defer glslang.finalize(); + + // Resolve the source by name. The runtime renderer accesses + // `shaders.source.bg_color_frag` etc. directly; we look up the + // matching decl by name at comptime so the build step can pass + // any of the 9 built-ins by string argv. + const src: [:0]const u8 = src: { + inline for (@typeInfo(shaders.source).@"struct".decls) |decl| { + if (std.mem.eql(u8, decl.name, name)) { + break :src @field(shaders.source, decl.name); + } + } + std.debug.print("unknown shader: {s}\n", .{name}); + std.process.exit(1); + }; + + // Vulkan-flavor rewrite (gl_VertexID → gl_VertexIndex, multi-set + // descriptor layout, etc.). Same path the runtime took before + // this precompile change. + const translated = try shaders.vulkanizeGlsl(alloc, src); + defer alloc.free(translated); + + const spv = try glslang.vk.compileToSpv( + alloc, + translated, + stage.vkBindingStage(), + ); + defer alloc.free(spv); + + // Write the raw SPIR-V words (u32 little-endian on every host + // we build for; Vulkan loaders accept the in-memory byte order + // of the platform). The build step captures stdout into a .spv + // file the renderer @embedFiles at compile time. + var buf: [4096]u8 = undefined; + var stdout = std.fs.File.stdout().writerStreaming(&buf); + try stdout.interface.writeAll(std.mem.sliceAsBytes(spv)); + try stdout.end(); +}