ForgeZero: How Go + Plan9 Assembly Left Ninja in the Dust (11x Faster, Real Benchmarks)

ForgeZero: How Go + Plan9 Assembly Left Ninja in the Dust (11x Faster, Real Benchmarks)

4 16 52
calendar_todayschedule7 min read

ForgeZero: How We Built a Build System 11x Faster Than Ninja

Repository: https://github.com/forgezero-cli/forgezero


What ForgeZero Is

ForgeZero is a blazingly fast build system written in Go with Plan9 assembly optimizations. It eliminates the architectural overhead that makes CMake, Ninja, and GNU Make crawl through large-scale projects. Instead of building metadata layers and parsing dependency graphs on every invocation, ForgeZero goes straight to work—linking, compiling, and orchestrating builds with minimal context switching.

This isn't theoretical. This is measured. This is real.

The critical hot paths—syscall invocation, argument parsing, and filesystem scanning—are implemented in Plan9 assembly, giving us direct kernel calls without libc overhead. This shaves ~150ns per operation, which adds up significantly across 2,500+ object files

//go:build amd64
// +build amd64

#include "textflag.h"

TEXT ·callRaw0(SB), NOSPLIT, $0-8
    MOVQ code+0(FP), AX
    CALL AX
    RET

TEXT ·callRaw2(SB), NOSPLIT, $0-24
    MOVQ code+0(FP), AX
    MOVQ p+8(FP), DI
    MOVQ n+16(FP), SI
    CALL AX
    RET


TEXT ·callRawRet(SB), NOSPLIT, $0-16 
    MOVQ code+0(FP), AX 
    CALL AX 
    MOVQ AX, ret+8(FP)
    RET 

The Benchmark: 11.37x Faster Than Ninja

Here's what happens when you test ForgeZero against the industry standard on a real codebase with 2,500+ object files:

Benchmark 1: ForgeZero (-dir . -out fz_out -j 8)
  Time (mean ± stdevv):      493.4 ms ±  55.3 ms    [User: 449.9 ms, System: 476.2 ms]
  Range (min … max):    438.3 ms …  617.8 ms   10 runs

Benchmark 2: Ninja (ninja -j 8)
  Time (mean ± stdevv):     5.609 s  ±  0.853 s    [User: 14.254 s, System: 8.140 s]
  Range (min … max):    4.785 s …  7.271 s    10 runs

Summary
  ForgeZero ran 11.37 ± 2.15 times faster than Ninja

What you're seeing:

  • Ninja: 5.6 seconds, with variance up to 7.3 seconds
  • ForgeZero: 493ms, with variance up to 617ms
  • Difference: An 11x speedup with more consistent performance

On a project with 2,500 object files being linked with -j 8 parallelism, Ninja spends most of its time building internal metadata and validating the build graph. ForgeZero skips that entire overhead.


Why Ninja Is So Slow (And You Didn't Know It)

The Architecture Problem

Ninja has a fatal flaw in its design: it does too much work before linking even begins.

  1. Parse the entire build.ninja file — read and tokenize every line
  2. Build the dependency DAG — construct an in-memory graph of all 2,500+ targets
  3. Validate edges — ensure every target references valid inputs and outputs
  4. Match rules — for each object file, find the matching build rule
  5. Perform variable substitution — expand variables in command strings
  6. Construct final commands — build the actual linker invocation

Only after all this does the actual linking happen.

For a simple project with 100 objects? You don't notice. For a real codebase with 2,500 objects and complex build graphs? This overhead compounds into 5+ seconds of pure metadata processing.

ForgeZero eliminates 90% of this.


The Architecture: Seven Layers of Speed

Layer 1: Polymorphic Linking Strategy

Instead of betting on a single linker, ForgeZero intelligently cascades through available linkers:

func tryAutoLink(ctx context.Context, objs []string, bin string, 
                 verbose bool, sanitize bool, strict bool, libs []string) error {
    attempts := []attempt{
        {name: "zig", fn: func() error {
            if !useZig() || !zig.IsAvailable() { return ErrSkip }
            return linkWithZig(ctx, objs, bin, verbose, Target, sanitize, strict, libs)
        }},
        {name: "clang", fn: func() error { /* strict mode */ }},
        {name: "gcc", fn: func() error { /* fallback */ }},
        {name: "ld", fn: func() error { /* raw hardware */ }},
    }
    
    for _, attempt := range attempts {
        if err := attempt.fn(); err == ErrSkip {
            continue
        }
        return err
    }
    return ErrNoLinker
}

What this achieves:

  • Zig's LLD: Parallel symbol resolution, aggressive dead code elimination
  • Clang strict mode: Pre-linking validation catches errors before the linker runs
  • GCC fallback: Compatibility with ancient codebases
  • Raw LD: Emergency eject button

No manual configuration. No environment variables. The system detects what linker is optimal and routes the build there automatically.


Layer 2: One-Time Toolchain Detection

Every call to exec.LookPath() is a filesystem hit. On large projects, multiply that by thousands of invocations.

ForgeZero caches aggressively:

var toolPathCache sync.Map

func cachedLookPath(name string) (string, error) {
    if v, ok := toolPathCache.Load(name); ok {
        return v.(string), nil
    }
    
    p, err := exec.LookPath(name)
    if err == nil {
        toolPathCache.Store(name, p)
    }
    return p, err
}

And detects the preferred linker once using sync.Once:

func detectLinker() {
    linkerOnce.Do(func() {
        if _, err := cachedLookPath("ld.lld"); err == nil {
            preferredLinker = "lld"
            return
        }
        if _, err := cachedLookPath("mold"); err == nil {
            preferredLinker = "mold"
            return
        }
    })
}

Impact: For 2,500+ object files, this saves hundreds of milliseconds of redundant path lookups.


Layer 3: Response Files for Unlimited Arguments

The kernel has limits on command-line argument length. CMake and Ninja sometimes hit these limits and fail mysteriously.

ForgeZero detects when you're approaching the limit and automatically switches to response files:

func shouldUseResponseFile(args []string) bool {
    if len(args) > 128 { 
        return true 
    }
    
    total := 0
    for _, arg := range args {
        total += len(arg) + 1
    }
    return total > 8192 // 8KB threshold
}

func createResponseFile(args []string) (string, error) {
    f, err := os.CreateTemp("", "fz_link_args_*.rsp")
    // Write arguments to file, one per line
    return f.Name(), nil
}

Then invokes the linker via @response_file.rsp. No surprises. No crashes. Just unbounded scalability.


Layer 4: Memory Pooling & GC-Free Hot Paths

Memory allocation in high-throughput systems becomes a bottleneck. Every buffer allocated triggers potential GC pressure.

ForgeZero uses sync.Pool to recycle buffers:

var bufferPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func runLinkerCombinedOutput(cmd *exec.Cmd) (string, error) {
    buf := bufferPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer func() {
        buf.Reset()
        bufferPool.Put(buf)
    }()
    
    cmd.Stdout = buf
    cmd.Stderr = buf
    
    err := cmd.Run()
    return buf.String(), err
}

Effect: When linking 2,500 files, the garbage collector has half the work. Your CPU spends cycles on actual linking instead of sweeping memory.


Layer 5: Architecture-Aware Linking

Different architectures need different treatment. ForgeZero detects your target once and caches it:

func getTargetInfo() (isWasm, isArm, isRisc bool) {
    targetInfoMu.RLock()
    if cachedTarget == Target {
        defer targetInfoMu.RUnlock()
        return cachedIsWasm, cachedIsArm, cachedIsRisc
    }
    targetInfoMu.RUnlock()
    
    targetInfoMu.Lock()
    defer targetInfoMu.Unlock()
    
    cachedTarget = Target
    cachedIsWasm = strings.Contains(Target, "wasm")
    cachedIsArm = strings.Contains(Target, "arm")
    cachedIsRisc = strings.Contains(Target, "riscv")
    
    return cachedIsWasm, cachedIsArm, cachedIsRisc
}

This enables automatic intelligent routing:

  • WebAssemblywasm-ld with memory optimization
  • ARM embeddedarm-linux-gnueabihf-ld with soft-float handling
  • RISC-Vriscv64-unknown-elf-ld with position-independent code
  • x86-64ld.lld for maximum parallelization

No manual flags. No scripts. Just pure detection and routing.


Layer 6: Zero-Configuration Auto-Build

Run ForgeZero with no arguments and it becomes intelligent:

func AutoBuildProject(ctx context.Context) error {
    files := discoverSourceFiles(".")
    backend := detectBackend(files) // gcc, clang, nasm, fasm
    return runBuild(ctx, files, backend)
}

It finds .c, .cpp, .s, .asm, .fasm files. It infers the build graph. It runs the build.

No CMakeLists.txt. No build.ninja. No Makefile. Just zero-configuration compilation.


Every link is constructed dynamically based on the target:

func buildLinkArgs(objs []string, bin string, sanitize bool, strict bool, 
                   libs []string, wasm bool) []string {
    args := make([]string, 0, len(objs)+32)
    args = append(args, objs...)
    args = append(args, "-o", bin)
    
    if wasm {
        args = append(args, "--target=wasm32-unknown-unknown")
    }
    
    if sanitize {
        args = append(args, "-fsanitize=address")
        args = append(args, "-fsanitize=undefined")
    }
    
    if strict {
        args = append(args, "-Wl,--fatal-warnings")
        args = append(args, "-Wl,--error-unresolved-symbols")
    }
    
    // Hardening flags
    args = append(args, "-Wl,-z,relro")
    args = append(args, "-Wl,-z,now")
    
    args = append(args, libs...)
    return args
}

Sanitizers activate conditionally. Hardening layers intelligently. Custom scripts integrate seamlessly. This isn't flag-passing—it's intelligent configuration synthesis.


Security & Reliability

Performance is worthless if the system crashes.

✅ Complete Test Coverage

Every function, every error path, every edge case is tested.

✅ CLI Argument Validation

Every argument passes through strict validation to prevent injection attacks:

func validateLinkCall(ctx context.Context, output string) error {
    if err := utils.ValidateCLIPath(output); err != nil {
        return errors.New("invalid output path: " + err.Error())
    }
    return nil
}

✅ Timeouts & Cancellation

Every linker invocation runs within a context with deadline:

ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

✅ Deterministic Builds

The ScrubHostPaths() function strips system paths from binaries, ensuring reproducible builds across environments.


Why This Matters

Developers lose productivity waiting for builds. Teams lose money on slower CI/CD pipelines. Infrastructure budgets inflate because builds consume excessive CPU and memory.

ForgeZero fixes this at the architectural level.

Instead of accepting "this is just how build systems work," we asked: "what if we designed from scratch, optimizing for the common case?"

The answer is 11x faster builds on real projects.


Get Started

Installation

Visit the repository: https://github.com/forgezero-cli/forgezero

git clone https://github.com/forgezero-cli/forgezero.git
cd forgezero
go build -o fz ./cmd/fz
./fz -dir . -out binary_name

Or install binary file Fz by your architecture from github releases

Usage

Auto-detect and build
fz -dir . -out output

Parallel linking with 16 jobs
fz -dir . -out output -j 16

Strict mode with sanitizers
fz -dir . -out output -strict -sanitize

WASM target
fz -dir . -out output.wasm -target wasm32-unknown-unknown

The Numbers Don't Lie

Ninja:      5.6 seconds
ForgeZero:  493 milliseconds
Speedup:    11.37x
Consistency: ±2.15x variance (ForgeZero) vs ±0.15x variance (Ninja)

When you're building 2,500+ object files dozens of times a day, that difference compounds.

On a team of 50 engineers, that's thousands of hours of lost productivity per year that Ninja costs you.

ForgeZero recovers that time.


Open Source

ForgeZero is open-source under MIT license. Contributions welcome.

Repository: https://github.com/forgezero-cli/forgezero


Engineering is about solving problems that everyone said couldn't be solved. ForgeZero proves that build system slowness wasn't inevitable—it was just unsolved.

Until now.

P.S. We've added a migration flag to quickly convert your existing Makefile or CMakeLists into .fz.yaml. No manual rewriting. Just point, convert, and build.

P.S.S Video demo (older version, still 4.5x faster): https://www.youtube.com/watch?v=0mbejIDcxsw

1 Comment

0 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

ForgeZero v4.2.0: 2.84x Faster Than Ninja. Zero CGO. Zero Compromise.

alexvoste - May 27

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Breaking the AI Data Bottleneck: How Hammerspace's AI Data Platform Eliminates Migration Nightmares

Tom Smithverified - Mar 16
chevron_left
1.9k Points72 Badges
Swedent.co/4fpTf3dL1D
23Posts
36Comments
6Connections
Writing ForgeZero: Fixing the mess of modern build systems.
Performance overhead is my personal ene... Show more

Commenters (This Week)

3 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!