Optimizing Incremental Compilation in a Hexagonal Architecture Project with Rust

I wrote a big (but not so complex) project using hexagonal architecture (similar to Clean and Scalable Architecture for Web Applications in Rust).

So my project is like:

- src/crates/

    - invoicing

        - models

            - very
            - long
            - list
            - of
            - files
            - here

        - services

            - very
            - long
            - list
            - of
            - files
            - here

        - adapters

            - very
            - long
            - list
            - of
            - files
            - here

        - ports

    - payments

    - uploads

Everything works great.

But I'm not happy because each time I edit one single file in services or adapters it recompiles all the invoicing crates and everything that depends on it (but not payments nor uploads).

The times for incremental compilation (the time I need to wait after each "save" command on a file during a cargo watch run ) are very slow.

QUESTIONS:

  1. Is there another code structure to avoid the long incremental build times after each "save" command?

  2. Is there a way to avoid compilation at all using something like an interpreter for Rust code?

    I mean, the code architecture is already there, I'm only editing business logic in very few files or adding new of them. I don't care about --release build times: I just want to try the changes on my local PC before deploying them.

    Is there for example a WASM interpreter for Rust code? Such as to avoid compilation at all?

Do “all the invoicing crates” in fact depend on services and adapters, or are you saying you see an unexpected recompilation?

Of course, if you remove dependencies you can reduce how much is rebuilt. But I assume you already know that.

There’s an unobvious way that dependencies can contribute to dependents taking a long time to build themselves: generics. Much of the work of compiling generic code happens, not when its crate is compiled, but when the crate that instantiates it — that makes a concrete type from a generic type by specifying the generic parameters — is compiled. (When the compiler processes the code for such a newly-concrete type, this is called monomorphization.) So, for example, if you have

fn foo(v: Vec<String>) { ... }

then the cost of compiling (part of) Vec's code is paid when the crate containing foo is compiled, not when std is compiled, since it has to be adjusted for processing Strings in particular.

Therefore, if you have generic types that aren’t assembled into concrete types until a crate that is relatively late in your dependency graph, you may be able to improve build performance by:

  • Arranging so that the code that uses the type concretely is earlier in the dependency graph, so it is more likely to not need to be recompiled.
  • Writing functions that accept dyn Trait types instead of generic types. These functions don’t need to be monomorphized, so they are fully compiled as part of the crate that defines them rather than the crate that uses them, which is just what you want if the crate that defines them isn’t the one you’re editing.
    • You can also write generic functions that do as little work as possible (e.g. calling an .into()) before calling private non-generic functions. This can avoid tradeoffs between performance and API ergonomics. The standard library makes lots of use of this technique. It does clutter your code, though.

Technically, there is an interpreter, Miri, but it is so slow to execute — even slower than debug / opt-level=0 builds — that it is not worth using for performance reasons.


A couple more general tips for faster builds:

  • If you don't regularly use a debugger and don’t care about the quality of backtraces, disable debug info. This will give the compiler and linker less work to do.

    [profile.dev]
    debug = 0
    
  • Review your dependencies on other libraries, and remove features that you don’t need, by setting default-features = false and enabling only the ones you actually need. This will only make an actual difference to incremental builds if the features getting disabled are ones which add unnecessary code to functions you are actually using, but it’ll also make clean builds faster, so it’s not worthless.

  • Use cargo build --timings to see which crates are taking particularly long to build, or cause the most waiting in your dependency graph, and where you might have a bottleneck that’s not taking advantage of all your CPU cores.

This advice is originally from https://matklad.github.io/2021/09/04/fast-rust-builds.html.

2 Likes