Skip to content

Thoughts on improving omicron-nexus build times #8015

Open
@sunshowers

Description

@sunshowers

Last week I did a bunch of investigation into build times for the omicron-nexus crate. Here are some notes about that.

Why this is important

Nexus build times and iteration cycles are really slow in the experience of several of us. Nexus is nowhere close to done, so things are likely to get much worse over the next 6-12 months if we do nothing about build times.

This is not an immediate priority above all the other things we need to get done (the folks best-positioned to work on this are pretty oversubscribed), but systematic solutions are going to take a while to build. Making improvements here is rather impactful, too -- speeding up things like a4x2 iteration cycles helps all of us move faster.

The problem

There are two major issues with Nexus build times, one at the Cargo level and one at the rustc level.

Cargo issue: crates above nexus-db-model are linear

With omicron-nexus, there's a chain of dependencies that looks roughly like:

  • nexus-db-model
  • nexus-auth
  • nexus-db-queries
  • nexus-reconfigurator-execution
  • omicron-nexus

Each item in the list depends on the one before it. This means that the last few parts of the omicron-nexus build are completely linear. On my workstations, for most of the time in this part, I see a single core pegged to 100% and the rest of the system completely idle.

Now, Cargo can do a bit of pipelining -- specifically, there are three steps involved within the build of a single crate:

  1. Type and borrow checking
  2. Metadata generation
  3. rlib generation (LLVM code generation)

Cargo can kick off dependent builds after step 2. There's some benefit from this for us, but I've seen steps 1 and 2 take up around 70% of the time to build a single crate so it's not a huge help. (This pipelining is also the status quo today, so build times are slow despite pipelining).

rustc issue: omicron-nexus is quite large

The other big issue I've seen is that omicron-nexus itself takes a while to build. In my profile of it (stored on atrium under /staff/rain/nexus-self-profile-20250410 -- check out chrome_profiler.json), I noticed that:

  • type + borrow checking took around 30% of the time
  • monomorphization resolution around 40%
  • LLVM codegen around 30%

(The times are a bit bloated due to the overhead of profiling itself, but the ratios are likely reasonably accurate.)

There's nothing particularly obvious or quadratic that stands out. It's just that:

  • omicron-nexus is quite large and has many functions
  • a lot of rustc's execution is single-threaded
  • for larger crates, things like coherence checking are inherently quite slow because the orphan rules don't apply within a single crate

There is also the matter of how much time it takes to link everything, especially on illumos, which I'll talk about separately.

Ideas for solutions

Based on discussions we've had over the last week, the easiest way to think about solutions here is to talk in terms of three worlds:

  • World 1: The status quo -- the crate graph as exists today
  • World 2: Find ways to break up Nexus and its dependents into possibly-parallelizable crates, but still maintain the general linear dependency
  • World 3: Introduce looser coupling between Nexus and other subsystems like the database code, truly build these subsystems in parallel

Each subsequent world would provide us with some benefits, though world 3 would likely be required to truly exploit parallelism in Nexus.

World 1 to world 2

The thrust of this is to basically make nexus-db-queries and omicron-nexus smaller by splitting them up into multiple crates, and finding independent subsets that can be compiled in parallel.

To pick a random example:

  • nexus-db-queries contains both silo code and physical disk code
  • you could imagine two new crates, nexus-db-queries-silo and nexus-db-queries-physical-disk that contain the corresponding code respectively, and can be compiled in parallel with each other.
  • the interface to other parts of Nexus is still nexus-db-queries, which eventually becomes a glue crate that puts together all of these individual crates

The big challenge here is finding these independent subsets. I've spotted a couple of ad-hoc cases (e.g. LookupPath now lives in a new nexus-db-lookup crate), but doing so systematically would probably require a careful call graph analysis.

The upper bound of what we can achieve with this code is also somewhat limited. It's hard to give a general estimate without doing the work and building a prototype, but the basic issue remains that the Nexus application code depends on the database implementation and that there is an inherent linearity there.

World 2 to world 3

For a more substantial benefit, I think introducing looser coupling is necessary. The most natural way to do that is:

  • There is a trait which describes part or all of the interface between nexus-db-queries and omicron-nexus
  • The omicron-nexus application code (let's call this nexus-app) is written against this trait
  • nexus-db-queries implements this trait
  • at the very end, omicron-nexus glues together nexus-db-queries and nexus-app

I built out a small prototype of this approach with a single saga, and it appears to be possible to do, at least in principle.

Now as things stand today, there are some major downsides to this approach:

  • Boilerplate: Each function exposed via this trait needs to be defined at least three times:
    • the trait itself
    • a wrapper struct which forwards to the trait, required for a few things (exposing the trait as a &dyn Trait results in some missing functionality)
    • the implementation of the trait which forwards to the corresponding inherent method on DataStore
      This is a fair bit of extra boilerplate compared to what needs to be written today.
  • rust-analyzer: control-clicking (go to reference) on a Nexus -> datastore function will not immediately forward to the datastore, but rather to the wrapper struct. What is a single control-click today would become at least three in the future: to the wrapper struct, then to the trait, then from the trait to the datastore method.

Luckily, this does not need to be a flag day -- it's quite possible to make this incremental. For example, sagas can be converted one or a few at a time.

Ideas to reduce boilerplate

An idea I had to reduce boilerplate is to see if one or more of the instances described above can be autogenerated.

For example:

  • each datastore method exposed to nexus-app is annotated somehow
  • something walks over the nexus-db-queries source tree, either in a build script or in an xtask, and extracts all of the annotated functions
  • and then generates the trait and wrapper struct with this information, either in the build script OUT_DIR or in

This is pretty tricky for a moderately long list of reasons, but I believe it is possible to do. It is a serious project though, effectively building a compiler of a sort. Good error messages are a lot of work.

A brief survey of crates.io did not suggest that anything of this sort exists. There are a few DI frameworks like shaku, but IMHO they are a little too magical with things like inject annotations.

Other benefits

Having indirection via a trait also provides a natural spot for fault injection in tests. For example, one could simulate Cockroach misbehaving by having a second implementation of the trait which wraps the datastore and inserts timeouts or other failures along the way.

Other approaches

A different approach to achieve the same goal of loose coupling is to pass in all data as a concrete struct. nexus-reconfigurator-planning follows this pattern. This is a fantastic approach if data can be loaded in memory upfront, but it doesn't work if data needs to be queried dynamically along the way.

Related work

@steveklabnik mentioned that this approach is called hexagonal architecture. To be honest I'm not well-versed in architecture terminology so I just think of it as a kind of loose coupling between components.

I guess terms like "dependency injection" and "inversion of control" can also be used to describe this kind of thing, but importantly we are not proposing any kind of XML file or component registry -- just some Rust traits and implementations.

This isn't dissimilar from Dropshot API traits, though I think there was a clearer case to be made in terms of impact -- API traits genuinely make some things possible to do that are not possible without them, while this is more of a build speed optimization.

Doing this kind of source-code analysis to extract interfaces from Java code was an important part of the original Buck build system:

Buck generates source-only ABI JARs using only the text of the source code for a rule, without first compiling (most of) the rule's dependencies. Some details of an ABI JAR cannot be known for certain from just the source, so Buck uses heuristics to infer those details.

I believe buck2 does this as well.


As @hawkw has said in the past, one of the fundamental distinctions in software is between loose and tight coupling. Whether to go down this path is a major philosophical decision that would be worth serious thinking.


Improving linking time

The improvements discussed above will improve build times within rustc. But another part of the build is link times, which happen after all code generation is complete and are the last step before binaries are generated.

Using mold

Especially on illumos, the biggest benefit to linking would be to use a third-party linker like mold. Folks have gotten this working in the past, but third-party linkers are explicitly unsupported on illumos. We could potentially use them for dev builds, but using them for release builds (like in a4x2) is risky and worth careful consideration.

Dynamic libraries

Another set of benefits would be to build parts of Nexus as a dynamic library. For example, if omicron-nexus became a dynamic library, then the Nexus integration tests wouldn't have to pay the full linking cost again.

  • One practical issue here is that whether something is built as a dynamic library is a crate-wide decision, not a per-profile decision. It would be nice to, for example, use dynamic libraries for dev builds but not for release builds. I couldn't find a way to make that happen other than editing Cargo.toml.
  • Dynamic libraries are (inherently) incompatible with monomorphization, so to the extent that code has type generics it won't be part of the dynamic library. I don't think this is a huge problem with omicron-nexus or nexus-db-queries -- they don't expose generic interfaces to my knowledge.
  • I believe there are some issues with panic = abort and std. I'm not entirely versed in them, but I believe that if we want to mix panic = abort with dynamic libraries we'll need to use the nightly-only build-std Cargo feature, either by using nightly Rust or via RUSTC_BOOTSTRAP. Note that we build dev builds with panic = unwind, so if we restrict somehow dynamic libs to dev builds this will hopefully not be an issue.

I briefly tried and made both nexus-db-queries and omicron-nexus dylibs, and the test suite did pass. I ran out of time before doing a full investigation into build time benefits here, but it definitely merits a further look.

It's also worth reading this post regarding runtime performance and other considerations.

Some things that probably won't work

Speaking of buck2, it's unlikely that we'll get huge benefits out of switching to it. The biggest benefit of buck2 would be in reusing build artifacts with unchanged code.

  • People who aren't editing Nexus could benefit from it, but buck2 cannot help with large individual crates, or with the dependency graph linearity we're observing here.
  • Switching to buck2 will of course be a lot of both one-time and ongoing work as well.
  • Buck2 would meaningfully restrict what we can do, for example we would not be able to have per-workspace-crate feature flags.

sccache has this issue too, and this is further compounded by the fact that it doesn't work for any Rust code that transitively depends on C or C++ code.

How I investigated

@smklein prepared an excellent document with instructions on how to investigate build times.

For rustc profiling, I followed these instructions including generating the chrome_profiler.json with a minimum resolution of 1ms. This file can be loaded up into Chrome's devtools for a really nice visualization of what's happening.

Credits

Thanks to the above folks, as well as @jmpesp, @iximeow, @andrewjstone, and likely others I'm missing, for valuable discussions and feedback. Any errors here are my own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions