Skip to content

Instantly share code, notes, and snippets.

@OrangeTide
Created March 16, 2026 04:24
Show Gist options
  • Select an option

  • Save OrangeTide/58ea635ec766171b4be565cf74528ee6 to your computer and use it in GitHub Desktop.

Select an option

Save OrangeTide/58ea635ec766171b4be565cf74528ee6 to your computer and use it in GitHub Desktop.
Comparison of build systems: Make, Plan 9 mk, Perforce Jam, Tup, and Ninja

Build Systems Comparison

A comparison of four build systems researched during the design of a new build tool. Each system represents a distinct philosophy and set of tradeoffs.

Summaries

Make (1976)

The original build tool by Stuart Feldman. Make reads a Makefile containing rules that map targets to prerequisites and shell recipes. It uses file modification timestamps to determine what needs rebuilding and supports pattern rules (%.o: %.c) for generic transformations. Make's key weakness is its lack of a real programming language — it has evolved ad-hoc features (conditionals, functions, pattern substitution) that are awkward and error-prone. Recursive make (invoking make in subdirectories) is the standard approach for multi-directory projects, but it fragments the dependency graph, leading to both correctness and performance problems at scale. Despite its limitations, make remains the most widely used build tool due to its ubiquity and simplicity for small projects.

Architecture: Rules map targets to prerequisites with associated shell recipes. The dependency graph is built incrementally — make evaluates rules on-demand as it encounters targets, rather than constructing the full graph first. Recipes are interpreted line-by-line, with each line executed as a separate shell invocation. Built-in implicit rules and suffix rules provide default behavior for common transformations (.c.o). Variables are expanded using $(VAR) syntax with deferred or immediate evaluation depending on assignment operator (:= vs =).

Pros:

  • Ubiquitous — available on every Unix system, deeply embedded in toolchain culture
  • Simple mental model for small projects — targets, prerequisites, and recipes are intuitive
  • Extensive ecosystem of knowledge, tutorials, and existing Makefiles
  • Built-in rules mean trivial C projects need almost no configuration
  • -j N parallel execution works well for projects with correct dependencies

Cons:

  • Timestamp-only staleness — susceptible to clock skew, touch false positives, and misses when content changes without mtime change
  • No command string tracking — changing CFLAGS doesn't trigger rebuilds
  • Recursive make fragments the dependency graph, making cross-directory dependencies fragile and slow
  • At scale (100k+ files), scanning the entire filesystem to find changes takes minutes
  • Line-by-line recipe interpretation creates quoting and escaping headaches
  • Pattern rule priority is implicit and silent — ambiguities are resolved by heuristic, not flagged as errors
  • Ad-hoc language features ($(call ...), $(eval ...), .SECONDEXPANSION) are powerful but cryptic

Plan 9 mk (1984)

Mk was created by Andrew Hume and Bob Flandrena for the Plan 9 operating system as a cleaner successor to make. It shares make's basic model (targets, prerequisites, recipes) but improves on it in several ways: the full dependency graph is constructed before any recipes execute; recipes are passed verbatim to the shell rather than interpreted line-by-line; and an attribute system (V, Q, N, E, D, P, R) replaces make's magic special targets. Mk has no built-in rules — all defaults are imported via include files, making the tool generic and the conventions explicit. Its metarule system supports % (match anything) and & (match non-/ non-.) patterns with proper ambiguity detection. The P attribute allows fully programmable staleness checks. Mk is deliberately minimal: it delegates all logic to the shell (rc) and focuses on doing dependency-driven execution well.

Architecture: Three-part rule headers (targets:attributes:prerequisites) with recipe bodies passed verbatim to rc(1). The full dependency graph is constructed before execution by computing a transitive closure over metarules to derive all reachable targets, then pruning irrelevant subgraphs. Variables in rule headers resolve at parse time; variables in recipes resolve at execution time (by the shell). The attribute system modifies per-rule behavior: V (virtual/phony), Q (quiet), N (no error if missing), E (continue on error), D (delete target on failure), P (programmable staleness), R (regex metarules). Prototype mkfiles imported via < include replace built-in rules, parameterized by $objtype for Plan 9's heterogeneous architecture support.

Pros:

  • Full graph before execution eliminates recursive make's correctness problems
  • Attribute system is cleaner and more composable than make's magic target names
  • P attribute enables fully programmable staleness — any command can determine if a target is out-of-date
  • Recipes passed verbatim to shell — no escaping issues, embedded awk/sed scripts work naturally
  • $newprereq gives recipes only out-of-date prerequisites, enabling efficient incremental operations
  • & pattern prevents false metarule matches across directory boundaries
  • D attribute auto-deletes partial targets on failure, preventing stale outputs
  • Missing intermediate optimization — won't rebuild .o if the final binary is current with sources
  • Ambiguity detection — errors out rather than silently picking one matching metarule
  • -w file flag for selective forced rebuilds

Cons:

  • Timestamp-only staleness (without P attribute), same weaknesses as make
  • No programming language — all logic delegated to the shell, limiting expressiveness within mkfiles
  • Transitive closure of metarules can cause rapid graph growth, though pruning keeps it tractable
  • Recipes cannot read stdin (mk uses it internally to pipe recipes to the shell)
  • Adding a prerequisite to a metarule header can create unintended ambiguities — subtle pitfall
  • Tied to rc shell and Plan 9 conventions — less portable than make outside the Plan 9 ecosystem
  • No built-in support for header dependency discovery

Perforce Jam (1993)

Jam, created by Christopher Seiwald, is the most language-rich of the systems studied. It has its own interpreted language with procedures (rule), flow control (if/for/while/switch), local variables, and return values — but its only data type is a list of strings. Jam separates rules (procedures that build the dependency graph) from actions (shell commands executed during the update phase), a distinction that no other system makes as cleanly. Its four-phase execution model (startup → parse → bind → update) provides clear boundaries between configuration, name resolution, and execution. Notable features include "grist" for disambiguating same-named targets across directories, SEARCH/LOCATE variables for explicit path resolution during binding, cartesian product variable expansion, piecemeal actions for handling command-line length limits, and INCLUDES for modeling sibling dependencies (header files). Jambase provides all default build rules as a loadable file, keeping the tool itself generic.

Architecture: Four strictly ordered phases — startup (load Jambase and Jamfiles), parsing (evaluate the Jam language to build the dependency graph), binding (resolve target names to filesystem paths using SEARCH/LOCATE), and updating (execute actions in dependency order). Rules are Jam-language procedures that manipulate the dependency graph; actions are separate shell command templates attached to rules. The language has one data type (list of strings) with cartesian product expansion (t$(X) where X="a b c" produces "ta tb tc"), variable modifiers for path decomposition (:D, :B, :S), and dynamic scoping where target-specific variables override globals during that target's processing. Header dependency discovery uses regex scanning (HDRSCAN/HDRRULE) — approximate but dynamic. The standard rule hierarchy follows a delegation pattern: Main/Library → Objects → Object → Cc/C++/Yacc/Lex, with UserObject as the extensibility hook.

Pros:

  • Clean separation of rules (graph-building logic) from actions (shell commands) — the key architectural insight
  • Four-phase model provides clear boundaries between parsing, name resolution, and execution
  • Full interpreted language with procedures, flow control, local variables, and return values
  • INCLUDES vs DEPENDS distinguishes direct dependencies from sibling dependencies (header files)
  • Binding phase explicitly resolves target names to filesystem paths — no implicit path assumptions
  • Grist systematically disambiguates same-named targets across directories
  • Piecemeal actions handle command-line length limits transparently
  • Cartesian product expansion eliminates boilerplate for common variable patterns
  • Jambase externalizes all build conventions — the tool itself is generic
  • Modifier rules (ALWAYS, LEAVES, NOCARE, NOTFILE, NOUPDATE, TEMPORARY) provide fine-grained control
  • SubDir/SubInclude enables full-graph multi-directory builds

Cons:

  • Custom one-off language that only Jam users learn — steep learning curve with no transferable skills
  • Timestamp-only staleness — no command string tracking or content hashing
  • No command string tracking — changing flags doesn't trigger rebuilds
  • Header scanning via regex is approximate — misses conditional includes, computed includes, generated headers
  • Dynamic scoping can be confusing — target-specific variable overrides are implicit
  • Whitespace sensitivity (missing space between token and : causes silent bugs)
  • Action modifiers are limited compared to mk's attribute system
  • The language, while richer than make, lacks features expected of a real programming language (no maps, no integers, limited string manipulation)

Tup (2009)

Tup, created by Mike Shal, prioritizes build correctness above all else. Its defining feature is a persistent dependency DAG stored in a database, which enables O(changed) incremental builds instead of O(total) filesystem scanning. On a project with 100,000 files, tup can determine what to rebuild in 0.1 seconds where make takes 30 minutes. Tup uses runtime file-access monitoring (via FUSE or ptrace) to intercept actual file reads and writes during command execution, catching any undeclared dependencies — this is enforced, not advisory. The system guarantees that N iterations of incremental updates always produce the same result as a clean build. Other notable features include content-aware skipping (don't rebuild dependents if the output didn't actually change), transient outputs (auto-delete intermediate files), and an optional inotify-based monitor for sub-millisecond no-op builds. The tradeoffs are a 5–30% slower initial build, Linux-centric features (FUSE, inotify), and the complexity of maintaining a persistent database.

Architecture: The DAG contains two node types — file nodes and command nodes — with edges representing dependencies. The graph is persisted in a database (SQLite) across builds, enabling change detection proportional to what actually changed rather than the total project size. The update algorithm scans the filesystem for modifications (or receives inotify notifications), marks affected nodes in the DAG, and executes independent commands in parallel respecting dependency ordering. During execution, tup intercepts file system calls to verify that every file read was declared as an input and every file written was declared as an output — violations are errors. Only one command may write to a given output file (enforced). Variants are supported through separate directories each with their own tup.config, with @-variables reading configuration values and establishing dependencies on them.

Pros:

  • Persistent DAG enables O(changed) incremental builds — near-instant when nothing changed
  • Runtime file-access monitoring guarantees dependency completeness — no hidden dependencies possible
  • Correctness guarantee: N iterations of updates always produce the same result as a clean build
  • No clean target needed — the database tracks all generated files and auto-deletes stale ones
  • Content-aware skipping (^o) avoids rebuilding dependents when output is unchanged
  • Transient outputs (^t) automatically delete intermediate files after dependents complete
  • inotify monitor mode eliminates filesystem scanning entirely — sub-millisecond no-op updates
  • File + command node DAG naturally models multi-output commands
  • Variant builds via separate config directories, all updated in parallel
  • One command per output file — enforced, preventing conflicting rules

Cons:

  • 5–30% slower on initial/clean builds (database overhead)
  • Runtime file-access interception requires FUSE or ptrace — Linux-centric and complex
  • FUSE requires elevated privileges or user namespaces on some systems
  • inotify monitor is Linux-only
  • No programming language in Tupfiles — limited to a DSL with variables and macros
  • Persistent database adds complexity (corruption recovery, migration between versions)
  • Cannot easily integrate with build systems that don't go through tup (external build steps)
  • The run command for dynamic rule generation is powerful but opaque

Ninja (2012)

Ninja, created by Evan Martin for the Chromium project, is built around a single insight: "build systems get slow when they need to make decisions." Ninja is deliberately not designed to be written by hand — it's a "build assembler" meant to be generated by higher-level tools (CMake, Meson, GN). By eliminating all policy decisions from the build execution phase, Ninja achieves sub-second incremental builds on projects with 30,000+ files. Commands implicitly depend on their own command string, so changing compiler flags automatically triggers rebuilds with no special mechanism. Ninja distinguishes three dependency types (explicit, implicit, order-only) and integrates directly with compiler-generated depfiles for header tracking. It runs parallel by default, buffers output per-job to prevent interleaving, and uses pools to throttle resource-heavy operations. Other practical features include response files for Windows command-line limits, self-regeneration (rebuilds its own build file if stale), and a console pool for interactive tools. Ninja's deliberate minimalism makes it fast and reliable but requires a generator frontend for usability.

Architecture: rule declarations define named command templates with variable placeholders; build statements declare specific file transformations using those rules. Variables are immutable bindings that can only be shadowed in narrower scopes — build-level shadows rule-level shadows file-level. Scoping lookup order: built-ins ($in, $out) → build statement → rule (expanded late) → file-level → parent scope. Three dependency types: explicit (listed in $in, missing is fatal), implicit (| dep — triggers rebuild but not in $in, missing is non-fatal), and order-only (|| dep — must exist before running but changes don't trigger rebuild). Header dependencies are stored compactly in .ninja_deps (binary log) after being read from compiler-generated depfiles. Dynamic dependencies (dyndep, v1.10) allow the build graph to be augmented mid-build from generated files. The pool mechanism limits concurrency for specific rules, with a special console pool (depth 1) providing direct terminal access. Self-regeneration: if build.ninja is itself a build output and is stale, ninja rebuilds and reloads it before proceeding.

Pros:

  • Extremely fast incremental builds — sub-second on 30,000+ file projects
  • Command string as implicit dependency — flag changes trigger rebuilds with zero extra mechanism
  • Three dependency types precisely model real-world relationships (explicit, implicit, order-only)
  • Compiler depfile integration is exact and avoids fragile scanning heuristics
  • Dynamic dependencies (dyndep) enable correct first builds when deps are only discoverable at build time
  • Parallel by default — underspecified deps surface as failures instead of hiding behind serial execution
  • Buffered per-job output prevents interleaving; on failure, full command + output shown together
  • Pools with depth limits for throttling resource-heavy operations (linking, license-limited tools)
  • Console pool grants stdin/stdout access for interactive tools
  • Response files handle Windows command-line length limits
  • Self-regeneration elegantly handles build file changes
  • GNU jobserver integration for nested build systems
  • Immutable variables prevent accidental mutation bugs

Cons:

  • Deliberately not designed for humans — requires a generator frontend (CMake, Meson, GN)
  • No programming language, no conditionals, no loops — all logic must be in the generator
  • No built-in understanding of any toolchain — everything must be spelled out
  • Phony rule semantics can be surprising (inputs "passed through" to dependents)
  • subninja scoping rules add complexity for multi-file builds
  • .ninja_deps binary log is an opaque format that can't be inspected or edited
  • No content-based skipping by default — restat must be opted into per-rule
  • No built-in clean mechanism — requires the generator to produce clean rules

Systems Overview

Make (baseline) Plan 9 mk Perforce Jam Tup Ninja
Year 1976 1984 1993 2009 2012
Language Makefile DSL Mkfile DSL + rc shell Custom interpreted language Tupfile DSL Ninja DSL
Philosophy General-purpose Minimal, shell-centric Programmable build logic Correctness above all Build assembler (generated, not hand-written)
Built-in rules Yes No (imported via includes) No (provided by Jambase) No No

Dependency Graph

Make mk Jam Tup Ninja
Graph construction Incremental (on-demand) Full graph before execution Full graph before execution (4-phase) Persistent DAG in database Full graph before execution
Node types File targets File targets File targets File nodes + command nodes File targets
Cycle handling Allowed Prohibited Prohibited Prohibited Prohibited
Pattern/metarules %.o: %.c % and & metarules with transitive closure Suffix-based dispatch via rules None (explicit rules only) None (generator handles it)
Ambiguity Silent priority-based choice Error on ambiguity Error on ambiguity N/A N/A

Staleness Detection

Make mk Jam Tup Ninja
Primary method Timestamps Timestamps Timestamps Content-based (database) Timestamps + command string hash
Command string tracking No No No Yes Yes (implicit dep on command)
Custom staleness No P attribute (programmable) No No No
Content awareness No No No Yes (skip if output unchanged) restat mode

Header / Implicit Dependency Discovery

Make mk Jam Tup Ninja
Method External (makedepend) or -M flags Manual or external Regex scanning (HDRSCAN/HDRRULE) Runtime file-access interception Compiler depfiles (-M flags)
Accuracy Depends on method Manual Approximate (over-includes) Exact Exact
Portability Good Good Good Linux-centric (FUSE/ptrace) Good

Parallel Execution

Make mk Jam Tup Ninja
Default Serial Parallel ($NPROC) Serial (-j) Parallel (from DAG) Parallel (CPU count)
Output handling Interleaved Interleaved Interleaved Buffered Buffered per-job
Throttling -j N $NPROC -j N N/A Pools with depth limits
Process slot ID No $nproc per-job ! in JAMSHELL No No

Rule / Recipe Model

Make mk Jam Tup Ninja
Recipe handling Line-by-line interpreted Passed verbatim to shell (rc) Passed to shell via JAMSHELL Monitored shell execution Passed to shell
Recipe stdin Available Not available (mk uses it) Available Available Available (console pool)
Multi-output rules Awkward (grouped targets) Aliases Actions attached to rules Natural (command node → multiple outputs) Implicit outputs (| out)
Action modifiers None Attributes (V, Q, N, E, D, P, R) quietly, piecemeal, existing, bind Flags (^b, ^o, ^t, ^s, etc.) generator, restat, depfile

Variable / Language Model

Make mk Jam Tup Ninja
Data types Strings Strings Lists of strings Strings Strings
Variable expansion $(VAR) $VAR, ${var:A%B=C%D} transforms $(VAR) with cartesian product, :D/:B/:S modifiers $(VAR), @(CONFIG) $var, ${var}
Flow control $(if ...), $(foreach ...) None (delegated to shell) if/for/while/switch None None
Scoping Global + target-specific Headers at parse-time, recipes at exec-time Global + target-specific (dynamic scoping) File-scoped File-scoped, shadowing only
Procedures None None (shell functions) rule definitions None rule templates
Mutability Mutable Mutable Mutable Mutable Immutable (bindings, shadow only)

Multi-Directory Builds

Make mk Jam Tup Ninja
Approach Recursive make (SUBDIRS) Include (<) SubDir/SubInclude with grist Auto-discovery of Tupfiles per directory subninja/include
Graph visibility Partial (per-invocation) Full (single process) Full (single process) Full (persistent database) Full (single process)
Target disambiguation Path-based Path-based Grist (<dir!subdir>file) Path-based Path-based
Cross-directory deps Fragile Robust Robust (SEARCH/LOCATE) Robust Robust

Unique Strengths

Plan 9 mk

  • Attribute system replaces make's magic targets with explicit per-rule flags (V, Q, N, E, D, P, R)
  • P attribute enables fully programmable staleness detection — any external command can determine if a target is out-of-date
  • $newprereq provides only out-of-date prerequisites to recipes, enabling efficient incremental operations (e.g. archive updates)
  • & pattern (matches non-/ non-.) prevents false metarule matches across directory boundaries
  • Missing intermediate optimization — won't rebuild .o files if the final binary is current with original sources
  • D attribute auto-deletes partial targets on recipe failure, preventing stale outputs from persisting

Perforce Jam

  • Four-phase execution (startup → parse → bind → update) cleanly separates concerns
  • INCLUDES vs DEPENDS — distinguishes direct dependencies from sibling dependencies (header inclusion)
  • Binding phase with SEARCH/LOCATE explicitly resolves target names to filesystem paths
  • Cartesian product variable expansion (t$(X)ta tb tc) eliminates explicit loops for common patterns
  • Piecemeal actions automatically chunk large file lists to avoid command-line length limits
  • Grist provides a systematic solution to same-name target disambiguation across directories
  • Full interpreted language with procedures, flow control, local variables, and return values

Tup

  • Persistent DAG in a database enables O(changed) incremental updates instead of O(total) scanning
  • Runtime file-access monitoring catches undeclared dependencies — one command per output enforced, not just by convention
  • No clean target needed — the database tracks all generated files; N updates always equals a clean build
  • Content-aware skipping (^o flag) avoids rebuilding dependents when output is unchanged
  • Transient outputs (^t flag) automatically delete intermediate files after dependents complete
  • inotify monitor mode (Linux) eliminates filesystem scanning entirely — sub-millisecond no-op updates

Ninja

  • "Build systems get slow when they need to make decisions" — eliminating decisions at build time is the core insight
  • Command string as implicit dependency — changing flags automatically triggers rebuilds with no special tracking
  • Three dependency types (explicit, implicit, order-only) precisely model different dependency relationships
  • Compiler depfile integration stores header dependencies compactly in .ninja_deps, avoiding reparsing
  • Dynamic dependencies (dyndep) enable correct first builds when dependencies are only discoverable by analyzing sources
  • Self-regeneration — rebuilds and reloads its own build file if stale
  • Response files transparently handle Windows command-line length limits
  • Console pool provides direct terminal access for interactive or status-printing tools

Key Tradeoffs

Tradeoff Conservative choice Aggressive choice
Correctness vs speed mk/make: timestamps only, simple Tup: content hashing + runtime interception, always correct
Simplicity vs features Ninja: deliberately minimal, generated not written Jam: full language, rich feature set
Portability vs power Ninja/Jam: pure userspace, works everywhere Tup: OS-specific (FUSE, inotify) for maximum correctness
Convenience vs explicitness Make: built-in rules, implicit behavior mk/Ninja: no built-in rules, everything explicit
Initial build vs incremental Make: fast initial, slow incremental at scale Tup: 5–30% slower initial, near-instant incremental
Hand-written vs generated mk/Jam: designed for humans Ninja: designed for machines

Lessons Learned

  1. Separate the language from the engine. Jam's split between rules (logic) and actions (shell commands) is the right model. Make's conflation of the two causes most of its problems.

  2. Build the full graph before executing anything. Every system except make does this. Partial graph visibility (recursive make) is both slow and incorrect.

  3. No built-in build knowledge. mk, Jam, and Ninja all externalize their default rules. The build tool should be a generic engine; build conventions belong in loadable configuration.

  4. Command strings are dependencies. Ninja's insight that changing flags should trigger rebuilds — with no special mechanism beyond hashing the command — is elegant and should be standard.

  5. Timestamps alone are insufficient. Every modern system supplements or replaces timestamps. Content hashing (cached behind stat fingerprints) provides correctness without excessive cost.

  6. Let the compiler track headers. Depfiles (-M flags) are exact and portable. Regex scanning (Jam) is fragile. Runtime interception (tup) is correct but not portable. Depfiles are the pragmatic choice.

  7. Parallel by default. Ninja's approach of defaulting to parallel execution and surfacing broken dependencies as failures is better than make's serial-by-default which hides dependency bugs.

  8. Pattern rules are a workaround. Make and mk need patterns because they lack a programming language. With a real language for build descriptions, iteration and dispatch are just code.

@OrangeTide
Copy link
Copy Markdown
Author

Claude Code wrote this. I thought it was a neat experiment to have it research something.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment