These tools operate at a higher level of abstraction. The BUILD file abstraction is something like "I am declaring a C library with these sources and headers." The build system then turns that into a set of commands to run. This higher-level abstraction provides a bunch of compelling features:
- Reliability: it's easy to write Makefiles that are fragile, for example one that works when run on a single CPU but fails when run with -j, because of undeclared dependencies. When the tool is in charge, it has more control and can sandbox each build step so that undeclared dependencies simply won't be available in the sandbox.
- Reproducibility: similar to the previous point, it's easy to write a Makefile that produces incorrect results with an incremental rebuild. For example, unless you implement your own header scanning (or manually list your header dependencies) "make" won't know to rebuild a source file when a header changes. These tools can ensure that builds are reproducible and incremental rebuilds are always correct.
Besides that, working at a higher level generally means writing less code to accomplish the same task, and the code you do write is closer to expressing your actual intent compared with a Makefile.
> The BUILD file abstraction is something like "I am declaring a C library with these sources and headers."
This is wrong. Even ninja has generic rules. Here's an example of a minimal makefile:
OBJ = src/a.o src/b.o src/c.o
libfoo.so: $(OBJ)
$(CC) -shared -o libfoo.so $(OBJ)
> When the tool is in charge, it has more control and can sandbox each build step so that undeclared dependencies simply won't be available in the sandbox.There's no reason this shouldn't be possible with make; it just hasn't been implemented so. Do bazel/buck/please actually do this? As far as I know tup is the only tool that actually verifies inputs/outputs of rules, and it needs FUSE to do so.
> For example, unless you implement your own header scanning (or manually list your header dependencies) "make" won't know to rebuild a source file when a header changes.
At least with GNU make, it's very easy:
CFLAGS += -MMD
-include $(patsubst %.o,%.d,$(OBJ))
True, it's a bit of a footgun, but by no means difficult.Make has problems, but the ones you listed aren't they.
Your example does not contradict what I wrote. You manually specified the tool to be run ($CC) and all of the arguments to that tool.
It's true that there is a level of indirection through the $CC variable, but you're still operating at the level of specifying a tool's command-line.
> There's no reason this shouldn't be possible with make; it just hasn't been implemented so.
Make is 44 years old. If it were an easy extension to the existing paradigm, there has been ample time to implement such an extension.
> Do bazel/buck/please actually do this? As far as I know tup is the only tool that actually verifies inputs/outputs of rules, and it needs FUSE to do so.
Bazel certainly has some amount of sandboxing, though I don't think it's quite as complete as what is available internally at Google with Blaze. I haven't used Buck or Please, so I can't speak for them.
> True, it's a bit of a footgun, but by no means difficult.
Well footguns aren't great. :) As just one example, any header that is conditionally included (behind an #ifdef) could cause this cache to be invalidated when CFLAGS change, but Make has no idea of this.
Notably, I didn't specify the arguments to be used to compile the source files. But if you insist:
linklibrary = $(CC) -shared -o $1 $2
libfoo.so: $(OBJ)
$(call linklibrary,$@,$(OBJ))
Notably, linklibrary can be defined according the platform, or according to dynamically set variables, giving you the same level of flexibility as make.>> There's no reason [sandboxing] shouldn't be possible with make; it just hasn't been implemented so.
> Make is 44 years old. If it were an easy extension to the existing paradigm, there has been ample time to implement such an extension.
‘Nobody's done it yet, ergo it's not possible or easy’ is not a valid argument.
> just one example, any header that is conditionally included (behind an #ifdef) could cause this cache to be invalidated when CFLAGS change, but Make has no idea of this.
Then you specify that the source files depend on the makefile.
Then you gratuitously rebuild everything whenever the Makefile changes, even if you only changed a comment.
Also this scheme is incorrect if the Makefile was written to allow command-line overrides of variables like CFLAGS, as many Makefiles do.
But these are just details. The larger point is this. The language of Bazel is defined such that builds are automatically correct and reproducible.
While it's true that Make has some facilities like "call" that support some amount of abstraction, it is up to you to ensure the correctness. If you get it wrong, your builds are back to being non-reproducible.
It's like the difference between programming in a memory safe vs a memory unsafe language. Sure, every thing you can do in the memory safe language can be done in the unsafe language. But the unsafe language has far more footguns and requires more diligence from the programmer to get reasonable results.
Ultimately, make and co are text oriented, while bazel and co are object oriented. Hacking object oriented capabilities into a text-oriented language isn't particularly fruitful or ergonomic.
Bazel and make are both text-based languages for describing symbolic, abstract structures. Just like pretty much every other programming language.
Bazel and make both use the same abstract structure, just like pretty much every other build system: a directed graph of build directives and dependencies.
But let's look at how a rule is implemented in bazel[0]. Here's a rule "implementation" for a simple executable rule[1]:
def _impl(ctx):
# The list of arguments we pass to the script.
args = [ctx.outputs.out.path] + [f.path for f in ctx.files.chunks]
# Action to call the script.
# actions.run will call "executable" with
# "arguments", saving the result to "output"
# access to files not listed in "inputs" will
# cause errors.
ctx.actions.run(
inputs = ctx.files.chunks,
outputs = [ctx.outputs.out],
arguments = args,
progress_message = "Merging into %s" % ctx.outputs.out.short_path,
executable = ctx.executable.merge_tool,
)
concat = rule(
implementation = _impl,
attrs = {
"chunks": attr.label_list(allow_files = True),
"out": attr.output(mandatory = True),
"merge_tool": attr.label(
executable = True,
cfg = "exec",
allow_files = True,
default = Label("//actions_run:merge"),
),
},
)
This is, admittedly, not easy to follow at first glance. Concat defines a "rule" (just like cc_binary) that takes three arguments: "chunks", "out", and "merge_tool" (and "name", because every target needs a name).Targets of this form have metadata, they have input and output files that are known and can be queried as part of the dag. Other types of rules can be tagged as test or executable, so that `blaze test` and `blaze run` can autodiscover test and executable targets. This metadata can also be used by other rules[2], so that a lot of static analysis can be done as a part of the dag creation, without even building the binary. To give an example, a rule like
does_not_depend_on(
name = "check_deps",
src = ":opensource_thing",
forbidden_deps = [
"//super/secret:sauce",
]
)
can be built and implemented natively within bazel by analyzing the dependency graph, so this test could actually run and fail before any code is compiled (in practice there are lots of more useful, although less straightforward to explain, uses for this kind of feature).Potentially, one could create shadow rules that do all of these things, but you'd need to do very, very silly things like, off the top of my head, creating a shadow filesystem that keeps a file-per-make-target that can be used to query for dependency information (make suggests something similar for per-file dependencies[3], but bazel allows for much more complex querying). That's what I mean by "object-oriented". Targets in bazel and similar are more than just an executable statement with file dependencies. They're complex, user-defined structs.
This object-oriented nature is also what allows querying (blaze query/cquery/aquery), which are often quite useful for various sort of things like dead or unusued code detection or refactoring (you can reverse dependency query a library that defines an API, see all direct users and then be sure that they have all migrated to a new version). My personal favorite from some work I did over the past year or so was is `query --output=build record_rule_instantiation_callstack`, which provides a stacktrace of any intermediate startlark macros. Very useful when tracking down macros that conditionally set flags, but you don't know why, and a level of introspection, transparency, and debugability that just isn't feasible in make.
That's what I mean by object-oriented vs. text oriented. Bazel has structs with metadata and abstractions and functions that can be composed along and provide shared, well known interfaces. Make has text substitution and files. While a sufficiently motivated individual could probably come up with something in make that approximates many of the features bazel natively provides, I'm confident they couldn't provide all of them, and I'm confident it wouldn't be pretty or ergonomic.
[0]: https://docs.bazel.build/versions/master/skylark/rules.html
[1]: https://github.com/bazelbuild/examples/blob/master/rules/act...
[2]: https://docs.bazel.build/versions/master/skylark/aspects.htm...
[3]: https://www.gnu.org/software/make/manual/html_node/Automatic...
- Cannot handle multiple outputs for a single rule
- Does not rebuild when flags change
- Make rules may contain implicit dependencies
- Slow for large codebases
- Does not understand how to build for multiple platforms, sucks for cross-compiling
- Recursive make sucks (job control does not work across recursive invocation boundaries)
- You must create output directories yourself
- You must create your own rule to clean
This adds up to a fragile & slow build system, where you have to do a full rebuild to have a reasonable level of assurance that your build is correct—incremental builds aren’t safe.
There have been a few attempts to make a “better make” over the years like Jam, SCons, Ninja, Tup, etc. each with their own pros and cons.
The new generation of build tools—Bazel, Buck, Pants, and Please are all an attempt to redesign build systems so that the design is resistant to the flaws that plague Make build systems. You can use a shared cache (shared between different users) fairly easily, and you have reasonable assurance than incremental builds are identical to full builds (so your CI pipeline can move a lot faster, developers almost never have to "make clean" when they change things, etc).
Personally I’m working on a project right now that uses Bazel (which is similar to Please) and is for an embedded system. It’s been a great experience, and I can pass a flag to Bazel to tell it to build for the embedded target using the cross compiler or for the native host--that makes it easy to share code between tools and the target system, and I can do things like write tests that run on both the target & host. Anyone who does any cross-compiling is missing out if they are using Make—but, do note that setting up a cross-compiling toolchain in Bazel isn’t exactly a cakewalk.
https://docs.bazel.build/versions/master/tutorial/cc-toolcha...
No, it doesn’t. There’s NO syntax for multiple outputs. If you can show me what the syntax is and prove me wrong, I’d love to see it. At best, there are workarounds for the lack of multiple output support, with various tradeoffs.
> Make is a programming language with a built-in dependency mechanism, and as such you can use it to build whatever you want...
Make is NOT a programming language. End of story. You can… kind of… build things with it, given that variables in Make can be used like functions, but it’s goddamn horrible and you shouldn’t do it because nobody will want to use it and nobody will maintain it.
At most, you can build something on top of Make, but you’re still facing the limitations of Make and working around them. If you are interested in building something on top of a low-level build system, you want Ninja instead of Make, because Ninja is good at that. Make isn’t.
Make is, at best, something you would want to use for a small C program where all the files are in one directory. Once you grow past that use case, my rule is that you shouldn't be using Make any more, because there are just too many things that Make does wrong.
Make is successful because eh, it’s good enough for small things, you can suffer through the pain if you need to use it for big things, and it was the first tool to solve this problem. We have better tools now. We had better have better tools now! Make is in its fifth decade… if we didn’t improve on Make in that many years, that would be a damning indictment of the entire field.
(One point you don't mention in your list of reasons why Make is successful is that it's reliably available everywhere. For projects that ship as source for others to build, that matters, and it delays uptake of fancy new build systems in that segment.)
So do C, and Bourne shell. And SQL.
And yet, here we are. Are the benefits enough to overcome incumbency?
Everything else that is needed for a modern build system is lacking:
- expressive scripting language
- platform-related configuration
- feature-related configuration
- handling hierarchical projects
- properly handling rebuilds when the Makefile itself has been changed
- dealing with dynamically generated parts of the dependency graph
- handling third-party dependencies
- build reproducibility
- etc... the list is very long
This is why people these days mostly take the path of generating Makefiles, because make is only good at one thing: executing a statically defined dependency graph.At a very minor cost after all: your sanity.
1. Reproducible/Hermetic: your builds cannot, say, access the network during a step or produce changing binaries for the same input. This makes sure that once you've downloaded your deps locally everything builds correctly from then on.
2. Caching: Because everything is reproducible in bazel-likes everything is cachable. Dependencies, builds, and even tests! If you've worked with Makefiles you've likely run `make clean; make tests` or something similar. I've never needed to do `bazel clean` to make sure things were working right.
3. Visibility: you don't only control who can see what in your source code. Different dependencies can be marked as private/protected/public to control who can import them. This is a huge boon to large monorepos.
4. Everything is uniform: code generation, compilation, etc is all described in "rules" and "toolchains" and can "easily" expanded to other languages. The community manages a Rust and Golang rules set for bazel itself and they're better then the Google rule sets for Java (only "officially" supported rule set right now) in some areas.
So if you have a lot of code/internal libraries/code generation, what to write a LOT of unit tests and cache their results, and write code in multiple languages bazel is probably for you.
You can also use tools like BuildBuddy [0] to share a cache with your team and extract build performance metrics to identify areas you can improve performance in.
[0] - https://www.buildbuddy.io/ or https://github.com/buildbuddy-io/buildbuddy
all of which are even worse than make itself.
If the goal is continued maintenance, then encouraging new engineers to explore and read the codebase with tools they can comprehend is critical.
disclaimer: have not used these particular tools, but the domain is nice and polite
Here are some real reasons why Make isn't the end-all:
- Make doesn't allow platform selection in a nice way. - Make doesn't work on Windows natively (no, NMake doesn't count). - Recursive make doesn't work well at all. - Make doesn't track byproducts or deleted artifacts. - Make doesn't have auto-clean. - Make doesn't facilitate out-of-source builds. - Make itself isn't a scripting language (arguably good/bad). - Make doesn't facilitate compiler selection cross-platform, so things like MSVC are nearly impossible to integrate nicely without the use of the developer tools prompt. - Make can't (elegantly) handle a number of rule cases, such as single-input multiple-output rules (it can, but it's a hack).
Not sure why you think Make is unapproachable. That's like saying shell scripting is unapproachable. No developer worth a damn is going to avoid shell scripting, and as long as they understand "this file turns into this file using this command" then Make makes sense.
As with all tools, Make and Autotools and CMake and probably Plz will be misused by developers that think they're being clever. In actuality, they make things worse at best and unusable/unmaintainable at worst.
As a build systems designer, I've evaluated Plz personally and found it to be neat but not suitable for most of the problems I think build systems should solve.
I would strongly recommend watching this talk if you want to understand why systems like Bazel and Please were created:
Build Systems with Andrey Mokhov (Jane Street)
So at the end of the day you end up with an executable artifact, but hopefully you did a lot less work to get there.
Also curious to know, is there no work done in the realm of developer productivity inside the FPGA world?
I've only looked at modern tools briefly but everytime I come away realising we'd have to re-write everything. There is no out-of-the-box support for our tools/language and it would be a lot of work to learn someone else's DSL, re-implement everything and then potentially discover a load of ways it's broken for our workflow. Personally I think it'd be worth it in the long run but it's a really hard sell.