Kiln

A CMake-compatible build system that can do what CMake can't

Martin Chang  ·  2026-03-06

Warning

Lots of AI code. Idea been around over 2 years. Many attempts. Not enough time. Initially written in Lua, now C++23.

Show of hands

Who has debugged a CMake error that gave you no indication of where it came from?

...right.

This is what we've accepted as normal


CMake Error at CMakeLists.txt:276 (find_package):
  By not providing "FindThreads.cmake" in CMAKE_MODULE_PATH this project has
  asked CMake to find a package configuration file provided by "Threads",
  but CMake did not find one.

  Could not find a package configuration file provided by "Threads"...
                        

Which line triggered this? Which file? What's the call stack?

You already know the answer.

CMake is a horrible language

...and we've accepted this too

Dynamic scoping


                    function(foo)
                        message(STATUS "x = ${x}")   # sees caller's x
                    endfunction()

                    set(x 42)
                    foo()   # prints 42 - not a closure, not lexical
                            # the call stack IS the scope chain
                        

No lexical scope. No closures. Variables bleed across call frames freely.

Condition parsing is a trap


                    # VAR_NOT_EXIST is not set
                    if(NOT ${VAR_NOT_EXIST}) # despite CMake elises into if(NOT) and not syntax erroring
                        message("this should not run")
                    endif()

                    # expands to: if(NOT ) which CMake treats as if(NOT)
                    # which somehow is not a syntax error
                        

Unset variables expand to empty string at parse time. Conditions are string-matched, not type-checked.

You meant if(NOT VAR_NOT_EXIST). These are not the same.

Dynamic variable names


                    set(varname "MY_VAR")
                    set(${varname} "hello")   # sets MY_VAR at runtime

                    # variable names are arbitrary strings resolved at runtime
                    # no static analysis, no interning, no slot optimisation
                    set("a variable with spaces" "valid")   # this works
                        

Variable names are runtime strings. The interpreter cannot know what variables exist at parse time.

This is why a fast CMake interpreter is hard. Every lookup is a hashtable hit.

We are not fixing any of this

Compatibility means compatibility.

Your CMakeLists.txt relies on these semantics whether you know it or not.

The goal is a better engine under the same contract - not a new language.

The bugs we fix are the ones CMake should have fixed itself.

Rust didn't beat C++ on performance

It beat C++ on experience

cargo build just works

A new C++ developer hitting CMake in 2026 is a recruitment problem for the entire ecosystem

What is Kiln?

It eats your existing CMakeLists.txt. No changes required.

It is the build system. Not a generator.

That second sentence matters. We'll come back to it.

CMake's architecture

Configure → Generator → Build system

Three separate processes. Information is lost at each boundary.

Kiln: one process, one graph.

That's the constraint everything else follows from.

Act 1: CMake but better

Error reporting · Bug detection · Speed

Same problem. Kiln's output:


[WARN] This macro can only be used with libraries
[STATUS] Trantor using SSL library: None
[STATUS] c-ares found!
warning: Could not find "Threads": no FindThreads.cmake under CMAKE_MODULE_PATH and no
ThreadsConfig.cmake found. Set Threads_DIR or CMAKE_PREFIX_PATH accordingly.
  --> /home/marty/Documents/trantor/CMakeLists.txt:276:1
       |
   276 | find_package(Threads)
       | ^^^^^^^^^^^^^^^^^^^^^
                        

File. Line. Column. First time, every time.

Yes I stole Rust's message format

Errors are legible:


[WARN] This macro can only be used with libraries
[STATUS] Trantor using SSL library: None
[STATUS] c-ares found!
[STATUS] Found module: /usr/share/cmake/Modules/FindThreads.cmake
error: FindThreads only works if either C or CXX language is enabled
  --> /usr/share/cmake/Modules/FindThreads.cmake:129:3
       |
   129 |   message(FATAL_ERROR "FindThreads only works if either C or CXX ...")
       |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Call Stack (most recent call first):
  /usr/share/cmake/Modules/FindThreads.cmake:122 (if)
  /home/marty/Documents/trantor/CMakeLists.txt:276 (find_package)
Error: Interpretation error
                        

Bugs CMake silently accepts


-- Checking Performing Test HAVE_VISIBILITY_HIDDEN - Success
warning: Unbalanced generator expression (missing closing '>')
  --> /home/marty/.../mariadb-server/cmake/install_macros.cmake:291:7
       |
   291 |   $<$<BOOL:${VCPKG_INSTALLED_DIR}>:${VCPKG_INSTALLED_DIR}/...
       |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = note: Accepting as-is - CMake treats generator expressions
           as strings at parse time.
                        

This is in MariaDB. It has been there. CMake never told anyone.

This is a real bug category, not a toy example.

Stale globs


                    # CMake's own documentation warns you:
                    
                      It is tempting to use this command to avoid writing the list of
                      source files for a library or executable target. While this seems
                      to work, there is no way for CMake to generate a build system that
                      knows when a new source file has been added.
                    
                    aux_source_directory(SOURCES src/)
                    add_executable(myapp ${SOURCES})
                        

Because CMake is a generator, glob results are baked in at configure time.
New file added? Manually rerun CMake. Or forget and debug a missing symbol.

Kiln owns the build loop

We cache and re-evaluate globs before every build.

New file in src/? It's in the next build. No manual reconfigure.

This is the same reason ExternalProject graph merging works.
We never handed control to another process.

You can disregard CMake's own advice here.

Speed · LLVM configure

NAS hardware (Intel 245K, not a workstation)

CMake: 15.2s  ·  Kiln: 1.5s

10x. Reproduced with hyperfine.

Not including 7.0s CMake writes the actual build system

Speed · Interpreter (8-queens benchmark)


Benchmark 1: cmake -P benchmark/8queens.cmake
  Time (mean ± σ):   2.491 s ±  0.016 s

Benchmark 2: kiln -P benchmark/8queens.cmake
  Time (mean ± σ):   153.4 ms ±  3.6 ms

Summary: kiln ran 16.24x faster than cmake
                        

Pure interpreter speed. Matters for large projects with heavy CMake scripting and agressively cached.

Act 2: What CMake can't do

"So it's faster and has better errors."

"That's not why I built it."

The ExternalProject problem

CMake's ExternalProject_Add:

  • The supposed solution to dependency management
  • Runs as a separate build step, isolated from the main graph
  • No parallelism with your main build
  • If it doesn't produce what you expect, you find out at link time

This is a generator limitation. CMake hands off to Ninja/Make and loses control.

Kiln: graph merge


[  1/258]   Configuring jsoncpp
[  2/280]   Ready jsoncpp  <- injected into main graph
[  3/280]   Building src/main.cpp
[  4/280]   Building jsoncpp/src/lib_json/json_reader.cpp
...
                        

ExternalProject's build graph is merged into the main graph

Shared parallelism. Verified dependencies. Errors at merge time, not link time.

This is structurally impossible in a generator-based system.

What else becomes possible

  • Detect missing generated file dependencies before the build starts
  • Target introspection without parsing Makefiles/ninja files
  • An actual CMake debugger
  • Module support that works across generators
  • compile_commands.json by default, always correct

The CMake debugger


(kiln) /home/marty/.../c-ares/CMakeLists.txt:3  CMAKE_MINIMUM_REQUIRED(VERSION 3.5.0)
(kiln) > break find_package
(kiln) > continue
(kiln) > backtrace
(kiln) > print CMAKE_THREAD_LIBS_INIT
                        

break, step, next, watch, backtrace - full gdb-style debugger for CMake scripts

Is this real?

Differential fuzzing · Compatibility matrix · Real projects

Differential fuzzing against CMake 4.0

The interpreter is fuzz-tested against CMake 4.0 output

Not bug-for-bug identical - but handles edge cases well enough

FindXXX.cmake? We just use CMake's own modules. Best compliance test possible.

Projects that work today

  • LLVM
  • Qt6 Core
  • DuckDB
  • folly
  • poco
  • drogon / trantor
  • CMake
  • nlohmann/json
  • libwebp
  • ~30 total

Qt6 is harder than LLVM. Sprawling codebase, unusual genex usage. It works.

Continuous compatibility tracker

  • On aarch64
  • Clean build on new commit
  • Auto pulls HEAD or pin

LLVM and Qt6 not tested there. Takes way too long.

Real bugs discovered

  • MariaDB - Broken GenEx
  • JsonCpp - Broken Test under parallel run

The bigger picture

C++ needs its Cargo moment

CMake won the build system wars. That was genuinely hard. Credit where it's due.

But it won in 2010. The design reflects 2010 constraints.

The goal isn't to replace CMake overnight.
It's to show what the next 15 years could look like.

Win condition

Kiln becomes the thing

or

Kiln forces CMake to do better

Either way, C++ wins

One engineer. Spare time. If this is possible, imagine what a team could do.

Current state

What works

  • Linux + GCC
  • ~30 real-world projects
  • ExternalProject graph merging
  • Ninja-style parallelism, no colored output mangling
  • CMake script mode (-P)
  • Continuous compatibility tracker
  • Agressive internal caching

What's not there yet

  • Windows / MSVC / MacOS / BSD
  • Cross-compilation
  • Policy support
  • Unity build
  • Limited resources, not limited architecturally

It is an interperper, it is not that hard

Open questions

Things I don't have answers to yet

How much language compatibility is enough?

The long tail of CMake behaviour is very long.

Differential fuzzing catches the obvious cases.
Real projects catch the rest.

Is 95% compatibility useful? 99%? Where does the cost curve go vertical?

Probably project-dependent. Which means the tracker is the only real answer.

How far can you push a CMake interpreter?

Dynamic variable names make static analysis hard.

Dynamic scoping makes optimisation harder.

16x faster today. Is there a ceiling? Where is it?

Parallel interpretation of independent subtrees might be possible.
Nobody has tried.

Do we actually need policy?

CMake policies exist to paper over breaking changes
while maintaining backwards compatibility.

Kiln is a clean break at the engine level.
Do we inherit that debt?

Probably yes for real-world projects.
Probably not for the semantics we actually care about.

Found only a handful of project that needs it .
That might just mean I haven't found dark daemon.

What does C++ dependency management actually need?

FetchContent is better than ExternalProject.
Cargo is better than FetchContent.

Kiln can own the full build graph including dependencies.
How far should it go? Meson support?

Package identity. Version resolution. Hermetic builds.
Each one is a rabbit hole.

vcpkg and Conan exist. Are they the right answer,
or what we settled for? Or we go full Cargo and have kiln.toml

Can one person maintain CMake compatibility?

CMake ships new versions. Kiln has to keep up.

The tracker tells me when I break. It doesn't fix it.

This is an open question about the project's sustainability,
not just its architecture.

Which is why this talk exists.

Questions

Source coming soon · Apache 2.0


Martin Chang