1. 59
    1. 16

      The author of Shake and Buck2 has a series of blog posts that traces their path from Shake to Buck2:

      1. Shake: A Better Make
      2. Reflecting on the Shake Build System
      3. Small Project Build Systems
      4. Huge Project Build Systems
      5. Working on build systems full-time at Meta
    2. 10

      It’s wonderful to see this finally release! Rules being fully defined in Starlark is great for integrating new languages, and seeing Shake’s influence here after so many years makes me happy.

      1. 2

        seeing Shake’s influence here

        How so?

        1. 16

          tl;dr FB hired the author of Shake to productionise his research into build systems.

          From the blog post: (emphasis added)

          The goal of Buck2 was to keep what we liked about Buck1 (the core concepts and workflows), draw inspiration from innovations after Buck1 (including Bazel, Adapton, and Shake), and focus on speed and enabling new experiences.

          Also within the blog post, the single incremental dependency graph link goes to Implementing Applicative Build Systems Monadically. In that paper, it describes Buck as a “monadic” build system and notes:

          As future work, we see value in investigating the feasibility to extend Buck in this direction, given the benefits offered by monadic build systems such as Shake.

          The blog post also notes that Buck2 “[follows] the model in the paper, ‘Build Systems a la Carte’”. That paper was co-authored by the creator of Shake and one significant section within is entitled “Experience from SHAKE.”

          Finally, as @puffnfresh noted, Neil Mitchell (author of Shake) is a co-author of Buck2.

        2. 2

          Neil Mitchell is the author of Shake.

    3. 6

      I’ve done some polyglot programming which involved transpiling a library into multiple target languages. It was impossible to get anything done without reverting to MAKE and shell scripts, making Unix a hard dependency. I toyed with Bazel for a few hours before just accepting the concensus that the benefits wouldn’t manifest unless I had a huge codebase.

      But reinventing package management and build system infrastructure from scratch for each language is a huge waste of resources! And each one is its own fractal of bad engineering choices … none of them get security or isolated (let alone deterministic) build environments right.

      Would a hypothetical language with a build system designed from the start around Bazel/Buck have a better UX? Or is there just a fundamental trade-off between the features required by Bazel/Buck and the UX of a build system?

      1. 7

        I think the core issue is that Bazel (and I assume Buck) works on a monorepo with a single global version number.

        That doesn’t totally gibe with the way open source works, where you usually have separate version numbers for each package, and then a dependency solver for version constraints.

        In fact I remember Jane St. (who also employed one of the authors) said that they sponsored an open source OCaml package manager with a dependency solver, but they don’t actually use it. Instead they use a monorepo, because it simplifies life if you can get away with it.

        It would basically be like if you had not just all Debian packages in a monorepo, but also all Python packages, all R packages, Java packages, etc. It’s possible, but it’s a lot of work.

        I guess Nix does this by hard-coding versions in dependencies? I’m a little fuzzy on that, and am interested in how it works. I think it’s closer to a monorepo, and it’s open source with a very large number of packages.


        Also, in some ways you have to “pick your poison” with regard to portability: do you want to portable across languages, or across OSes? Most language package managers work on Windows to some extent. But a lot of the global build systems work across languages, but are worse on Windows.


        There is a polyglot vs. monoglot tension here too… Most language package managers optimize for the happy path of everything in that language.

        e.g. a Python package manager fundamentally has no idea what versions of C/C++ libraries are installed on your system, and this is the ultimate source of a lot of build failures and lack of reproducibility. (The Conda package manager was written separately to address this problem)

        Also I believe node.js and Rust have problems with C/C++ dependencies too. It’s an inherently messy problem. So some hypothetically nicely-buildable language would basically have to outlaw those – sort of like Java and Go heavily discourage foreign native code, but they still have them anyway because that’s life :-)

        1. 5

          Also I believe node.js and Rust have problems with C/C++ dependencies too. It’s an inherently messy problem.

          Yes, that’s the fundamental issue: you either need to first fix the C/C++ build system/package manager mess or not depend on any C/C++ code (which is unlikely to be realistic). But once you’ve solved this properly for C/C++, chances are you could reuse the same build system/package manager for other languages that build on top.

        2. 4

          I guess Nix does this by hard-coding versions in dependencies? I’m a little fuzzy on that, and am interested in how it works. I think it’s closer to a monorepo, and it’s open source with a very large number of packages.

          My mental model of nixpkgs is that it’s a monorepo that takes vendoring[1] to the extreme and does not deal with version numbers at all. Each package has a “hard” link to it’s dependencies. If multiple versions are needed, then they are “namespaced” seperately, and depended on explicitly & seperately.

          Example: code-server explicitly depends on Node 16 https://sourcegraph.com/github.com/NixOS/nixpkgs/-/blob/pkgs/servers/code-server/default.nix?L9

          Then the standard monorepo upgrade techniques apply: any and all breakages are fixed in the commit where the change is introduced[2]. If for some reason you want to split this work across multiple commits, then you need to introduce a new namespace that dependees can incrementally move over to.


          [1] NB: I don’t mean literal vendoring of source. Source is usually pulled down from github

          [2] In practice, I think this is only approximately true. There are definitely some broken packages in nixpkgs

          1. 3

            an idea that I need to flesh out a bit better: I have recently started to think that nixpkgs is the truly interesting & innovative part of nix, and that the rest of nix is only interesting in that it enables nixpkgs to exist.

            1. 4

              It will be interesting to see that hot take. IMO, Nix is interesting because of the Nix store, and the language is sort of the minimum language to get you into that store, and then you have a platform upon which all the other stuff can be built. But there is definitely a synergistic thing going on with all the elements of the platform, although you can pick and choose and still get a lot of benefit.

          2. 2

            Yes thanks, that matches my model … So nixpkgs is a big monorepo, and the global version number is basically the git repo number.

            But the individual package version numbers are essentially opaque names? (which makes sense if everything is a hash) So node-16 is conceptually an entirely different package than node-15, and node-14, and you can depend on specific versions.

            This is in contrast to the Bazel/Blaze monorepo (google3), where it’s impossible to depend on old versions of packages. Every dependency is essentially from a package at $VERSION_X to another package at $VERSION_X.

            And it’s in contrast to say Debian, because there the dependency edges have constraints, and the versions are ordered and obey a a “less than” (<) relation.

            1. 2

              google3 doesn’t have multiple concurrent versions of any dep? woah. that must take an unbelievable amount of work

              I assume they must do temporarily during migrations, but even then, huge amount of work

              1. 4

                Monorepo means monotonically and atomically updating everything at once.

                Blaze handles testing everything that depends on the library being updated. Failing tests block submit.

              2. 4

                Yes, it definitely leads to the situation where you have a lot of old packages that nobody wants to upgrade, because it’s a huge amount of work to make sure the consumers don’t break. That burden is put on the person who wants to upgrade. It requires a good CI.

                But if you allow depending on specific versions, now you have the diamond dependency problem. I googled for “nix diamond dependency” and got this 2021 issue:

                Flakes: Make avoiding dependency diamonds easier somehow

                I’d be interested in hearing about how often that problem comes up in Nix, and how it’s solved


                Personally I think there is a third option, which is to avoid transitive dependencies as much as possible by using dependency inversion. But that requires changing your code, and you don’t control all the code.

                Purely dynamic dependencies with no schemas should also be considered, as in shell scripts. They’re necessary at the largest scales.

                1. 4

                  We’re getting very close to the edge of my understanding now, but I believe the “diamond dependency” problem doesn’t occur in Nix due to different versions being treated as completely seperate packages. Rather than looking up libs at {run,link}time by name, libs will be “looked up” via an absolute path to the nix store.

                  Compare the two versions of node I have on my system:

                  # otool -L /nix/store/62jd5yypibchcpqk0a5g9vm9vmhf4yvc-nodejs-14.19.3/bin/node | rg libuv
                          /nix/store/2imiy4acyajf2mi8s4r9sc3l0smjg6s5-libuv-1.44.1/lib/libuv.1.dylib (compatibility version 2.0.0, current version 2.0.0)
                  # otool -L /nix/store/laiax5p45fd08z1bz5hbknmfz2fbvjpl-nodejs-16.17.0/bin/node | rg libuv
                          /nix/store/ai3qc2rbjjj9mfq3kypvw7hhpjyxfsiq-libuv-1.44.2/lib/libuv.1.dylib (compatibility version 2.0.0, current version 2.0.0)
                  

                  I wish I had a better example where the deps were incompatible, but hopefully that’s illustrative enough.

                  I think the issue you linked is specific to nix flakes, which are a new addition to nix that I don’t understand at all.

                  1. 3

                    Interpreters don’t suffer from this so much as libraries.

                    In C, if libA depends on version 1.0 of libfoo and libB depends on version 2.0 of libfoo, then you usually can’t write a single program that links in both libA and libB. C kinda has only one namespace for symbols.

        3. 3

          I guess Nix does this by hard-coding versions in dependencies? I’m a little fuzzy on that, and am interested in how it works. I think it’s closer to a monorepo, and it’s open source with a very large number of packages.

          In Nix, dependencies are not names, they are actual packages. So if package B depends on package A, the definition of package B will have a variable that refers to package A. It comes through as a file path, basically. Package A will absolutely have a hard reference to a certain version of the source, with a checksum, so that is hard coded. However, in the dependencies it is not hard-coded, but the references are not “by name” as they are in other package managers, they are overt files. So you really cannot bump the version of package A in isolation, package B will have to be rebuilt—but the definition file probably doesn’t need to change. Although Nix distinguishes a build-time dependency like gcc from a runtime dependency like some library.

          NixPkgs is a monorepo. But with Nix Flakes, you can get an even stronger notion of repeatability from distributed packages that are not centrally maintained.

          1. 1

            Hm when I look at the contributed Oil Nix file I don’t really see many versions:

            https://github.com/oilshell/oil/blob/master/shell.nix

            There is the global nixos-19.09 at the top… So I guess that means that all the edges in that file somehow get that version, but you can override them?

            i.e. I guess the indirection between the Nix expression and the derivation assigns all the versions.

            But it seems like there is some notion of converging on a global distro version, maybe for base packages?


            Without that you would probably have more diamond dependencies. What is the procedure for that problem? e.g. I found this link

            https://github.com/NixOS/nix/issues/5570

            It depends on the language, but in C/C++ you can’t have 2 versions of the same code in the same binary.


            Does flakes have some kind of hashed reference from one repo to the other? I would imagine you still want to maintain some properties of a monorepo.

            1. 4

              Say you want to use the nodejs package from that shell.nix file. Since it references nixpkgs branch nixos-19.09, it pulls in the derivations from that branch. The derivation files for Node.js happen to sit at pkgs/development/web/nodejs in nixpkgs. Clicking through those files, you might be able to see how the versions are pinned. When it pulls in packages, those are pulled in from .nix files elsewhere in nixpkgs (through the top-level recursive system). The best practice nowadays is to pin your shell.nix (or actually, Flakes are the new big thing, so flake.nix) to a specific nixpkgs commit, so the versions of every single package right down to the bootstrap blob are pinned.

              Flakes accomplish this by defining all their external inputs in the flake.nix file and having the nix command lock those to a commit using a lock file. When you use those inputs in the flake, it’s just pulling in and executing the Nix code contained in the dependency.

        4. 2

          I guess Nix does this by hard-coding versions in dependencies? I’m a little fuzzy on that, and am interested in how it works. I think it’s closer to a monorepo, and it’s open source with a very large number of packages.

          One of the big things the nix community do is write tools like python2nix, cabal2nix, cargo2nix, that try to mostly automate the process of converting language specific package manager metadata into nix derivations.

    4. 5

      Oh. Another Facebook product.

    5. 3

      The linked paper is pretty interesting, and short at 4 pages.

      In plainer language, I’d say the observation/motivation is that not only do compiling and linking benefit from incrementality/caching/parallelism, but so does the build system itself. That is, the parsing of the build config, and the transformation of the high level target graph to the low level action graph.

      So you can implement the build system itself on top of an incremental computation engine.

      Also the way I think about the additional dependencies for monadic build systems is basically #include scanning. It’s common to complain that Bazel forces you to duplicate dependency info in BUILD files. This info is already present (in some possibly sloppy form) in header files.

      So maybe they can allow execution of the preprocessor to feed back into the shape of the target graph or action graph. Although I wonder effect that has on performance.


      The point about Java vs. Rust is interesting too – Java doesn’t have async/await, or coroutines.

      I would have thought you give up some control over when things run with with async/await, but maybe not… I’d like to see how they schedule the tasks.

      Implementing Applicative Build Systems Monadically

      https://ndmitchell.com/downloads/paper-implementing_applicative_build_systems_monadically-01_jan_2022.pdf

      (copy of HN comment)

      1. 3

        In plainer language, I’d say the observation/motivation is that not only do compiling and linking benefit from incrementality/caching/parallelism, but so does the build system itself. That is, the parsing of the build config, and the transformation of the high level target graph to the low level action graph.

        IMO, a parallel build system (i.e., one that performs its own housekeeping in parallel, not only runs external tools in parallel like, say, make) is a no brainer in this day and age. build2 was a multi-threaded build system from the early days and parsing things (like buildfiles, -M output, etc) is only half of the story. You also often need to store intermediate build state (like partially preprocessed C/C++ translation units) which can be quite large and need to be compressed (e.g., with something cheap like lz4). Being able to compress in parallel helps a lot, naturally. But the biggest benefit in having a multi-threaded build system, IMO, is that you can now implement certain rules in-process. For example, in build2 we have a rule for processing the venerable .in files and it is implemented essentially as a function which the build system calls whenever it needs to update an .in file.

        So maybe they can allow execution of the preprocessor to feed back into the shape of the target graph or action graph. Although I wonder effect that has on performance.

        The trick to good performance here is to not throw away the result of preprocessing. In fact, it can even be faster compared to the traditional model of extracting header dependencies as byproduct of compilation proper. Details are here: https://build2.org/article/preprocess-compile-performance.xhtml

    6. 1

      Buck2 is written in Rust - Buck1 was written in Java. One of the advantages of using Rust is the absence of GC pauses

      Isn’t that an exaggeration? I mean, I understand GC pauses to be a problem in gaming, when there’s a need to provide stable 60fps, but are GC pauses in a build system really that significant in order to use them as a #1 reason for rewrite?

      I mean, why aren’t GC pauses significant in web servers, where Java is mostly used for, but it’s somehow a problem for a build system?

      1. 6

        Build systems like Buck and Bazel are huge graphs of tiny objects, and that really stresses the GC more than most programs.

        In the paper linked in the blog post you’ll see that the main things the program does are parsing, deserializing, and then lowering the target graph to the action graph. They are considering millions of files in a monorepo at once, which will easily lead to tens of millions of objects.

        Although I would agree that you’re not dropping frames, and garbage collection is very useful for graph-based workloads. If the GC is efficient, and the JVM’s is, then it should be a win?

        However another problem is that the JVM lacks value types on the stack, and so it creates more garbage. Go for example has value types.


        I googled “Bazel Garbage Collection”, thinking it would be more of a problem, and ended up with this link instead! It does seem like the JVM isn’t ideal, but not because of its GC!

        https://github.com/bazelbuild/bazel/issues/6514#issuecomment-675157826

        There was a widespread desire for a more integrated implementation, so various interested people had a meeting some time in early 2006 to discuss what the next system should look like. There was no question that it had to be a typed language, which at that point meant C++ or Java. (Rob Pike was in the room. Sadly Go wasn’t invented for four more years, as it would have been the ideal tool for the job. Google uses very little Rust, Scala, and Haskell.) If memory serves, Java won primarily because it had a garbage collector—and half the job of a build tool is concurrent operations on often-cyclic graphs. And I’m sure that at least in part it was because Java was the language Johannes Henkel and I, who started the project, were using at the time.

        The other half of a job of a build tool is interacting with the operating system: reading files and directories, writing files, communicating over a network, and controlling other processes. In hindsight, the JVM was a poor choice for this work, and many of Blaze’s problems stem from it. Most system calls are inaccessible. Strings use UTF-16, requiring twice as much space and expensive conversions at I/O boundaries. Its objects afford the user little control over memory layout, making it hard to design efficient core data structures, and no means of escape for performance-critical code. Also, compiling for the JVM is slow—surprisingly, slower than C++ or Go even though the compiler does less—yet the resulting code is also slow to start and slow to warm up, pushing CPU costs that should be borne by the developer onto the user. (Google runs Blaze’s JVM on an 18-bit number of cores.) The JVM is opaque, noisy, and unpredictable, making it hard to get accurate CPU profiles, which are crucial to finding and optimizing the slow parts. The only thing I really like about the JVM is that it can run in a mode in which Object pointers occupy 32 bits but can address 32GB, which is a significant space saving in a pointer-heavy program.

        This is after doing a project that I’m guessing has 100K to 500K lines of Java code written from scratch, maybe more. FWIW Alan and Johannes were my team-mates at the time! I worked on Blaze a tiny bit, but it wasn’t my main project.


        FWIW I wrote a comment on “Rust is the future of JS infrastructure” here, explaining what I learned about similar workloads:

        https://news.ycombinator.com/item?id=35045520

        However this is more about static typing than garbage collection. Java is better than say node.js or Python for sure.

        1. 1

          Thanks for that informative answer. However, the GitHub post you’ve linked is strange, because it mentions a lot of problems that are either solved, or have good walkarounds: Java Native Interface, UTF-8 by default in JDK18 (plan to move to UTF-8 was known since 2017), also slower compilation of Java code is a mystery to me, since C++ has one of the most slow compilation times from the languages I’ve encountered (Rust may be even slower). Fortunately the author mentions in one of the subsequent posts that slow compilation was somewhat related to the build tool itself, not with JDK. The amount of introspection of JVM is pretty high (profilers, agents), and value types support is en route in Valhalla.

          But well, it’s not like I’ve ever developed Blaze/Bazel/BuckN (I’ve never actually used it as a user), so I’ll stop argumenting here. I’m just not really buying their arguments.

          1. 3

            It makes perfect sense to me, given that Blaze was written in 2006 :) JDK 18 doesn’t exactly help there

            I think Java was the best choice at the time, but these days I think Go and Rust make more sense.

            A key issue is that high performance and predictable performance is greatly aided by control. For example, Java has escape analysis which avoids garbage, but it’s opaque to the user. I’d rather have explicit types on the stack like Go, Rust, C++.

            AOT is just much simpler and predictable than JIT. There’s no reason to throw away that info if you can encode it directly in the language (which is what we’re doing in Oil’s translation to C++).


            Also to repeat: my memory is that Alan wrote over 100K lines of Java from scratch AND optimized the hell out of it (there were also lots of other people on the team of course, but he drove a lot of the initial coding and architecture)

            So his criticisms are from hard-won experience

            Blaze is a very performance-sensitive and highly optimized program

      2. 2

        GC pauses get significant when you need either consistently low latency, or prolonged periods of high performance with a bunch of memory operations. For web servers, there’s a bunch of empty time between requests, where the GC can run “for free”, and the request usually doesn’t take a lot of memory allocations or time. But for build systems, when you run it, it’s gonna be a couple seconds of high intensity planning work, with a lot of memory allocations (creating the dependency/task graphs, etc.), which will trigger GC several times during the work (no empty time to fit the pause in unlike in web servers) and that will directly impact the performance the user feels for interactive compilation.

        1. 2

          The thing is that “normal” method of allocating memory pays a similar price as GC. Normal malloc() takes longer than Java alloc, while Java alloc is faster during alloc request, but we pay the full cost later with a GC pause. So it’s not about who is faster, because in the end the speed is similar, but about who does random pauses of the world at some random points in time.

          Games specifically can’t accept GC pauses, because each frame needs to have predictable time. So games accept slower but more predictable memory allocations; because for them it’s better to have slower alloc time than unpredictable GC times. But a build system? I’m not sure I understand why it must have predictable allocation times. Also I’m not sure what’s the problem with a GC pause during building the dependency graph. The build system is very similar to a web service; you just run the build, and GC can run between builds “for free”.

          But anyway, Java also allows to manually manage memory by using off-heap buffers and arena allocators, although this needs specific patterns during programming, not something Java developers do by default.

          1. 1

            Also I’m not sure what’s the problem with a GC pause during building the dependency graph.

            My only guess would be that it’s a pretty un-useful time for the GC to run since it’s just allocated a big pile of memory and it’s all still being used.

            But yeah, I wouldn’t expect the pauses to matter as much as the GC overhead and Java’s more profligate approach to heap memory in general. Would be interesting to see memory profiles for both Buck1 and Buck2!

      3. 2

        TBH, that whole paragraph looks rather incoherent. I think the goal there was “say something, fit it into two lines, don’t start Java vs Rust vs Go vs C++ flamewar”, rather than “summarize the actual reasoning”. GC pauses are certainly irrelevant for a build system.

        Here’s what I’d say instead (I do believe that today Rust is by far the best choice for build systems):

        • Rust would use significantly less memory, as its object graph has far less indirection. Given that the build system runs compilers who tend to devour RAM, setting less ran aside for build system is important.
        • Rust would run somewhat faster. Less memory usage and less pointer chairing improves cache efficiency. Runtime speed is a bottleneck for no-op build, and there are some rather cpu-heavy operations (dependency resolution & hashing)
        • Rust starts up significantly faster. No-op build doesn’t have time to warm the JIT up.
        • Build systems are nastily concurrent, and Rust is great at that, as it tracks thread-safety in the type system, and is somewhat-adequate for evented programming.
        • Build system can build anything except itself, so it needs to solve “bootstrap problem” — getting build system itself to the user’s machine. That’s much easier with a statically linked binary.
        • Build systems are IO-heavy and sometimes do systemsy-schenanigans, so having direct access to OS facilities is a plus.
        1. 2

          Here’s what I’d say instead (I do believe that today Rust is by far the best choice for build systems):

          You only list advantages compared to Java so it should probably be “the better choice”. In particular, none of the items you list make Rust better than C++ (and I can think of a few items that make C++ better).

          1. 2

            What about this point:

            Build systems are nastily concurrent, and Rust is great at that, as it tracks thread-safety in the type system, and is somewhat-adequate for evented programming.

            C++ doesn’t track thread-safety in the type system. That automatically makes Rust a better fit, since you probably don’t want non-deterministic data races plaguing your build system.

            1. 1

              In my experience (which is of writing a build system that is capable of building real-world projects like Qt), they are so “nastily concurrent” that the standard mechanisms (mutexes, etc) don’t cut it and one has to build custom ones out of atomics. And, as I understand, atomics in Rust are unsafe-only. This would appear to put C++ and Rust on equal footing (both unsafe) but I believe C++ has an edge, at least for now: the thread sanitizer. It did help detect and fix a few race conditions in our code.

              1. 2

                And, as I understand, atomics in Rust are unsafe-only.

                I don’t doubt that they’re lower-level and trickier to use than Mutex and RwLock, but I don’t see unsafe in the examples in https://doc.rust-lang.org/std/sync/atomic/.

                I believe C++ has an edge, at least for now: the thread sanitizer.

                While I’ve never tried using this and I imagine Rust’s integration with it is less mature than Clang’s, Rust can use the LLVM thread sanitizer, although it sounds like it entails building the Rust standard library from source, which sounds obnoxious but at least streamlined with tooling.

                Another thing I haven’t used is Loom.

                1. 2

                  I swear I read somewhere that atomics are unsafe-only in Rust and the reasons made sense but I can’t remember where and what exactly those reasons were. But looks like you are right and I must have been dreaming.