gtk/build: speed up repeated builds by letting the Zig build cache work (#14212)

Repeated builds were penalized for three reasons:

1. The gresource XML embedded the absolute cache paths of the compiled
.ui files. It now uses relative paths, resolved against a `--sourcedir`
passed to glib-compile-resources.

2. Blueprints were compiled through a small Zig wrapper, and a Run step
hashes the bytes of the executable it runs. A Zig binary does not relink
to the same bytes, so after a branch switch that touched the wrapper
every .ui moved, the gresource compiler re-ran and the whole app
recompiled for identical output. blueprint-compiler is now run directly
and the wrapper is reduced to a single version check whose output
nothing reads.

3. The gresource pipeline was built once per artifact. It is now
memoized on the *std.Build.

Cold builds and builds after real blueprint changes are unaffected. A
build after the wrapper relinks goes from ~43s to ~7s on my system.

AI disclosure: Claude Opus 5 was used to pinpoint the source of the
cache poisoning and to prepare a patch. Claude Fable 5.1 measured it,
traced the remaining churn to the wrapper's relinks, and replaced a
custom build step with stock ones. The commit message is human-written
based on the author's understanding of the changes.
This commit is contained in:
Mitchell Hashimoto
2026-09-14 12:06:29 -07:00
committed by GitHub
3 changed files with 203 additions and 169 deletions

View File

@@ -1,9 +1,14 @@
//! Compiles a blueprint file using `blueprint-compiler`. This performs
//! additional checks to ensure that various minimum versions are met.
//! Checks that `libadwaita` is at least the given version and that
//! `blueprint-compiler` is on the PATH and new enough. The blueprints
//! themselves are compiled by `blueprint-compiler` directly from the build
//! system; see `gtkNgDistResources` in `src/build/SharedDeps.zig`.
//!
//! Usage: blueprint.zig <major> <minor> <output> <input>
//! Usage: blueprint.zig <major> <minor> <stamp>
//!
//! Example: blueprint.zig 1 5 output.ui input.blp
//! Example: blueprint.zig 1 5 blueprint-check.stamp
//!
//! `<stamp>` is written when every check passes, so the build system has an
//! output to cache this step by.
const std = @import("std");
const adw_c = @import("adw_c");
@@ -45,8 +50,7 @@ pub fn main(init: std.process.Init) !void {
_ = it.next(); // Skip argv0
const arg_major = it.next() orelse return error.NoMajorVersion;
const arg_minor = it.next() orelse return error.NoMinorVersion;
const output = it.next() orelse return error.NoOutput;
const input = it.next() orelse return error.NoInput;
const stamp = it.next() orelse return error.NoStamp;
const required_adwaita_version = std.SemanticVersion{
.major = try std.fmt.parseUnsigned(u8, arg_major, 10),
@@ -58,7 +62,7 @@ pub fn main(init: std.process.Init) !void {
\\`libadwaita` is too old.
\\
\\Ghostty requires a version {f} or newer of `libadwaita` to
\\compile this blueprint. Please install it, ensure that it is
\\compile its blueprints. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
, .{required_adwaita_version});
std.process.exit(1);
@@ -104,43 +108,7 @@ pub fn main(init: std.process.Init) !void {
}
}
// Compilation
{
const blueprint_compiler = std.process.run(alloc, init.io, .{
.argv = &.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
}) catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
++ blueprint_compiler_help,
.{required_blueprint_version},
);
std.process.exit(1);
},
else => return err,
};
defer {
alloc.free(blueprint_compiler.stdout);
alloc.free(blueprint_compiler.stderr);
}
switch (blueprint_compiler.term) {
.exited => |rc| {
if (rc != 0) {
std.debug.print("{s}", .{blueprint_compiler.stderr});
std.process.exit(1);
}
},
else => {
std.debug.print("{s}", .{blueprint_compiler.stderr});
std.process.exit(1);
},
}
}
// Everything passed.
const file = try std.Io.Dir.cwd().createFile(init.io, stamp, .{});
file.close(init.io);
}

View File

@@ -5,7 +5,6 @@
//! Litmus test: `src/apprt/gtk` should exist relative to the pwd.
const std = @import("std");
const Allocator = std.mem.Allocator;
const build_info = @import("info.zig");
@@ -123,25 +122,6 @@ pub fn blueprint(comptime bp: Blueprint) [:0]const u8 {
}
pub fn main(init: std.process.Init) !void {
const alloc = init.arena.allocator();
// Collect the UI files that are passed in as arguments.
var ui_files: std.ArrayList([]const u8) = .empty;
defer {
for (ui_files.items) |item| alloc.free(item);
ui_files.deinit(alloc);
}
var it = try init.minimal.args.iterateAllocator(alloc);
defer it.deinit();
while (it.next()) |arg| {
if (!std.mem.endsWith(u8, arg, ".ui")) continue;
try ui_files.append(
alloc,
try alloc.dupe(u8, arg),
);
}
var buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writer(init.io, &buf);
const writer = &stdout.interface;
@@ -153,7 +133,7 @@ pub fn main(init: std.process.Init) !void {
try genRoot(init.io, writer);
try genIcons(init.io, writer);
try genUi(alloc, writer, &ui_files);
try genUi(init.io, writer);
try writer.writeAll(
\\</gresources>
@@ -235,38 +215,44 @@ fn genRoot(io: std.Io, writer: *std.Io.Writer) !void {
);
}
/// Generate all the UI resources. This works by looking up all the
/// blueprint files in `${ui_path}/{major}.{minor}/{name}.blp` and
/// assuming these will be
fn genUi(
alloc: Allocator,
writer: *std.Io.Writer,
files: *const std.ArrayList([]const u8),
) !void {
/// Generate all the UI resources.
///
/// Each file is written as a *relative* path, `{major}.{minor}/{name}.ui`,
/// which `glib-compile-resources` resolves against a `--sourcedir`. An
/// absolute path into the Zig cache would change whenever the step that
/// produced the `.ui` did, and this XML with it.
///
/// The compiled `.ui` files are not inputs here for the same reason. We
/// check that the blueprint *sources* exist instead, as `genRoot` and
/// `genIcons` do for the CSS and icons.
fn genUi(io: std.Io, writer: *std.Io.Writer) !void {
// Two `comptimePrint` calls per blueprint exceed the default quota.
@setEvalBranchQuota(100_000);
try writer.print(
\\ <gresource prefix="{s}/ui">
\\
, .{build_info.resource_path});
for (files.items) |ui_file| {
for (blueprints) |bp| {
const expected = try std.fmt.allocPrint(
alloc,
"/{d}.{d}/{s}.ui",
.{ bp.major, bp.minor, bp.name },
);
defer alloc.free(expected);
if (!std.mem.endsWith(u8, ui_file, expected)) continue;
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{d}.{d}/{s}.ui\">{s}</file>\n",
.{ bp.major, bp.minor, bp.name, ui_file },
);
break;
} else {
// The for loop never broke which means it didn't find
// a matching blueprint for this input.
return error.BlueprintNotFound;
}
const cwd: std.Io.Dir = .cwd();
inline for (blueprints) |bp| {
const source = std.fmt.comptimePrint("{s}/{d}.{d}/{s}.blp", .{
ui_path,
bp.major,
bp.minor,
bp.name,
});
try cwd.access(io, source, .{});
const alias = std.fmt.comptimePrint("{d}.{d}/{s}.ui", .{
bp.major,
bp.minor,
bp.name,
});
try writer.print(
\\ <file compressed="true" preprocess="xml-stripblanks" alias="{s}">{s}</file>
\\
, .{ alias, alias });
}
try writer.writeAll(

View File

@@ -983,14 +983,121 @@ pub fn addSimd(
}
}
/// Creates the resources that can be prebuilt for our dist build.
pub fn gtkNgDistResources(
b: *std.Build,
) struct {
pub const GtkNgResources = struct {
resources_c: DistResource,
resources_h: DistResource,
} {
};
/// Memoized result of `gtkNgDistResources`, keyed on the `*std.Build`.
/// The configure pass is single-threaded, so a file-scope map is enough.
var gtk_ng_resources: std.AutoHashMapUnmanaged(*std.Build, GtkNgResources) = .empty;
/// Creates the resources that can be prebuilt for our dist build.
///
/// Memoized because `add` calls this once per artifact that links GTK and
/// `GhosttyDist` calls it too. Each call used to build its own copy of the
/// whole pipeline, and since Zig's cache hashes input *paths* as well as
/// contents, the copies did not share results downstream.
pub fn gtkNgDistResources(b: *std.Build) GtkNgResources {
if (gtk_ng_resources.get(b)) |cached| return cached;
const resources = gtkNgDistResourcesUncached(b);
gtk_ng_resources.put(b.allocator, b, resources) catch @panic("OOM");
return resources;
}
fn gtkNgDistResourcesUncached(b: *std.Build) GtkNgResources {
const gresource = @import("../apprt/gtk/build/gresource.zig");
const gresource_file_inputs = gresource.file_inputs;
// Compile every blueprint into one directory laid out as
// `{major}.{minor}/{name}.ui`, so that `glib-compile-resources` gets a
// single `--sourcedir` and the gresource XML needs no absolute paths.
//
// `blueprint-compiler` is run directly, not through a compiled wrapper.
// A run step hashes the bytes of the executable it runs, and a Zig
// binary does not relink to the same bytes (anonymous declaration
// numbering depends on compilation history), so a branch switch that
// touched the wrapper would move every `.ui`, re-run the gresource
// compiler and recompile the whole app for identical output. Run
// directly, a `.ui` depends only on its `.blp`.
const ui_dir = ui_dir: {
// The version checks, done once. This links libadwaita for the
// version macros and so relinks as described above, which is
// harmless: nothing reads its output, the compile steps only
// depend on it having succeeded.
const check_exe = b.addExecutable(.{
.name = "gtk_blueprint_check",
.root_module = b.createModule(.{
.root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"),
.target = b.graph.host,
.link_libc = true,
}),
});
// Adwaita headers
translate_c.addImportToModule(b, "adw_c", check_exe.root_module, .{
.source = .{ .includes = .{ .files = &.{.{ .path = "adwaita.h" }} } },
.target = b.graph.host,
.optimize = .Debug,
.link_system_libs = &.{"libadwaita-1"},
}) catch unreachable;
// The headers have to satisfy the newest blueprint.
var required: struct { major: u16, minor: u16 } = .{ .major = 0, .minor = 0 };
for (gresource.blueprints) |bp| {
if (bp.major > required.major or
(bp.major == required.major and bp.minor > required.minor))
{
required = .{ .major = bp.major, .minor = bp.minor };
}
}
const check_run = b.addRunArtifact(check_exe);
check_run.addArgs(&.{
b.fmt("{d}", .{required.major}),
b.fmt("{d}", .{required.minor}),
});
// An output, so the check is cached instead of run every build.
_ = check_run.addOutputFileArg("blueprint-check.stamp");
// `WriteFile` hashes source paths as well as bytes, but these paths
// only move when a `.blp` changes, which reaches the gresource
// compiler regardless since the `.blp` files are its inputs too.
const ui_files = b.addWriteFiles();
for (gresource.blueprints) |bp| {
const sub_path = b.fmt("{d}.{d}/{s}.ui", .{
bp.major,
bp.minor,
bp.name,
});
const compile = b.addSystemCommand(&.{
"blueprint-compiler",
"compile",
"--output",
});
const ui_file = compile.addOutputFileArg(sub_path);
compile.addFileArg(b.path(b.fmt(
"{s}/{d}.{d}/{s}.blp",
.{
gresource.ui_path,
bp.major,
bp.minor,
bp.name,
},
)));
compile.step.dependOn(&check_run.step);
_ = ui_files.addCopyFile(ui_file, sub_path);
}
break :ui_dir ui_files.getDirectory();
};
// The gresource XML. Its only inputs are source tree files, so its path
// and contents are stable. The compiled `.ui` files are deliberately
// not inputs: it names them relative to the `--sourcedir` below, so no
// cache path ever appears in it.
const gresource_xml = gresource_xml: {
const xml_exe = b.addExecutable(.{
.name = "generate_gresource_xml",
@@ -1001,88 +1108,61 @@ pub fn gtkNgDistResources(
});
const xml_run = b.addRunArtifact(xml_exe);
// Run our blueprint compiler across all of our blueprint files.
const blueprint_exe = b.addExecutable(.{
.name = "gtk_blueprint_compiler",
.root_module = b.createModule(.{
.root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"),
.target = b.graph.host,
.link_libc = true,
}),
});
// Adwaita headers
translate_c.addImportToModule(b, "adw_c", blueprint_exe.root_module, .{
.source = .{ .includes = .{ .files = &.{.{ .path = "adwaita.h" }} } },
.target = b.graph.host,
.optimize = .Debug,
.link_system_libs = &.{"libadwaita-1"},
}) catch unreachable;
for (gresource.blueprints) |bp| {
const blueprint_run = b.addRunArtifact(blueprint_exe);
blueprint_run.addArgs(&.{
b.fmt("{d}", .{bp.major}),
b.fmt("{d}", .{bp.minor}),
});
const ui_file = blueprint_run.addOutputFileArg(b.fmt(
"{d}.{d}/{s}.ui",
.{
bp.major,
bp.minor,
bp.name,
},
));
blueprint_run.addFileArg(b.path(b.fmt(
"{s}/{d}.{d}/{s}.blp",
.{
gresource.ui_path,
bp.major,
bp.minor,
bp.name,
},
)));
xml_run.addFileArg(ui_file);
}
// Named in the XML by relative path; the program only `access`es them.
for (gresource.file_inputs) |path| xml_run.addFileInput(b.path(path));
break :gresource_xml xml_run.captureStdOut(.{});
};
const generate_c = b.addSystemCommand(&.{
"glib-compile-resources",
"--c-name",
"ghostty",
"--generate-source",
"--target",
});
const resources_c = generate_c.addOutputFileArg("ghostty_resources.c");
generate_c.addFileArg(gresource_xml);
for (gresource.file_inputs) |path| {
generate_c.addFileInput(b.path(path));
}
const generate = struct {
fn step(
bb: *std.Build,
dir: std.Build.LazyPath,
xml: std.Build.LazyPath,
mode: []const u8,
name: []const u8,
) std.Build.LazyPath {
const run = bb.addSystemCommand(&.{"glib-compile-resources"});
const generate_h = b.addSystemCommand(&.{
"glib-compile-resources",
"--c-name",
"ghostty",
"--generate-header",
"--target",
});
const resources_h = generate_h.addOutputFileArg("ghostty_resources.h");
generate_h.addFileArg(gresource_xml);
for (gresource.file_inputs) |path| {
generate_h.addFileInput(b.path(path));
}
// The build root for the icons and CSS, the collected directory
// for the compiled blueprints. Any `--sourcedir` replaces the
// default of the working directory, so the root must be named.
run.addArgs(&.{ "--sourcedir", "." });
run.addArg("--sourcedir");
run.addDirectoryArg(dir);
run.addArgs(&.{ "--c-name", "ghostty", mode, "--target" });
const out = run.addOutputFileArg(name);
run.addFileArg(xml);
// `glib-compile-resources` reads these itself, so they are
// inputs here as well as of the XML step.
for (gresource_file_inputs) |path| run.addFileInput(bb.path(path));
return out;
}
}.step;
return .{
.resources_c = .{
.dist = "src/apprt/gtk/ghostty_resources.c",
.generated = resources_c,
.generated = generate(
b,
ui_dir,
gresource_xml,
"--generate-source",
"ghostty_resources.c",
),
},
.resources_h = .{
.dist = "src/apprt/gtk/ghostty_resources.h",
.generated = resources_h,
.generated = generate(
b,
ui_dir,
gresource_xml,
"--generate-header",
"ghostty_resources.h",
),
},
};
}