mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-19 20:18:07 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user