Unity Builds

The Carbonite build system includes support for opting projects in to using unity builds. This support is provided through a premake extension that adds a few APIs that can be used to enable unity builds for the project and control how it is used. The premake extension is intended to be installed as a host dependency package in the repo and then enabled through a single require() call in the repo’s main premake script.

What is a Unity Build?

Unity builds provide a way to speed up the compilation of some projects, especially larger ones. This is done by grouping multiple source files (ie: .cpp or .c) in the project into a single or a small number of compilation units instead of building each source file separately. The idea behind this is that it can save the compiler a huge amount of time not needing to reprocess large header files that are commonly included in multiple source files. The benefits of unity builds can obviously differ greatly from one project to the next however - for example, if a project’s source files don’t include a large number of common headers, it could actually slow down compilation when using unity builds. However, with some slight changes in how development is done, many projects can be easily made to be more unity build friendly and still benefit from these types of builds.

The difference in build times between a unity build and a parallel build can vary quite drastically from one project to another and even between different machines. There are many variables involved including the total number and size of multiple-included headers in the project, the total number of source files in the project, and the number of CPU cores available on the building machine. When opting into unity builds, some care should be taken to profile builds to ensure it will be a net win in build times for the project. In general, there is a tradeoff bewtween the amount of time saved in reprocessing the same large header files multiple times in the unity build versus the potential gains of building multiple files in parallel with the headers being reprocessed.

Note however that there are some potential drawbacks to using unity builds:

  • Projects that have a small number of source files often don’t benefit. There are definitely exceptions to this rule, but in general projects that have fewer than five source files will typically build faster as individual parallel built source than with a unity build.

  • Projects with source files that do not include a set of common headers can increase build times. Since each included header is only included once or a few times in the project, the benefits of a unity build are outweighed by any gains that a parallel build would see. This can be avoided by having larger common header files or even with including several system header files to get the necessary functionality (where available).

  • Iterative builds no longer work as expected. While a project is configured as a unity build, a change to any source code file will effectively trigger a full rebuild of the project. This can increase the overall build times when iterating on changes locally. By default a unity build will not be used even for projects that have opted into unity builds. Unity builds will only be enabled for opted-in projects when the –unity-build command line option is also used during the build. This works best when there’s only a need to do a clean build (ie: in CI).

  • The project’s code must be clean enough to be able to effectively have all source files be part of the same translation unit. This may require either some code cleanup, or in very rare cases conditional compilation of some code based on whether OMNI_USING_UNITY_BUILD is defined. This symbol is defined any time a piece of code is being built as part of a unity build. Some common issues that are encountered when trying to enable a unity build are:

    • Local or static helper functions could have been copy/pasted between source files in the project. Since all source files in the project are effectively built as a single translation unit, these common local helper functions will conflict with each other giving a “this function already has a body” error. This can be fixed by removing duplicate helper functions and moving them to a common shared location.

    • Shared global symbols. If multiple source files define the same global variables or macros, they can conflict with each other in a unity build. All common symbols, even global ones, should be declared in a common location and defined only in a single location in the project.

    • Headers missing include guards. If a commonly included header is missing an include guard, this can lead to multiple definition or conflicting declaration errors during a unity build. As per the Omniverse coding standard, all headers that are only intended to be included once must have an include guard in the form of a #pragma once line at the top of the file.

    • Repeated definitions of classes. Some projects can end up with multiple internal implementations of a given common class (with the same name). This can lead to multiple definition errors in a unity build. This is technically undefined behavior in C++ (ie: the linker could choose which of the class’ methods are linked) and should not occur.

Some additional unity build pitfalls:

  • Code can get sloppy at including the correct headers or defining common symbols in shared locations if unity builds are always used. Unity builds should not be used during development for this reason so that it can be verified that the non-unity build path will not be broken.

This Implementation

This implementation is provided through an extension to the premake tool. This extension provides a handful of new APIs that can be used at the project or configuration scope in a premake script. The following new APIs are available:

  • unitybuildenabled(true): Enables unity builds for a project. By default a project will not use unity builds. Once enabled for a project, a unity build will only be configured if the --unity-build command line option is also used.

  • unitybuildcount(<count>): Controls the number of unity build files to use for the project. If this is not specified, this aims to have at least 5 .cpp/.c files included in each unity build file. This will be capped to a maximum of 8 unity build files for large projects and clamped to a minimum of 1 unity build file for small projects. In testing, 5 source files seems to be the average tipping point between the savings of processing header files multiple times in a single unity build file versus reprocessing all of them in a parallel build. If a count is specified with this API, the minimum between that given number and the total number of eligible source files in the project will be used. This setting can be used to tune how the unity build for the project is configured. The <count> value may be any positive number. Though note that if it is too large, the benefits of a unity build may disappear.

  • unitybuilddir(<path>): Allows the storage directory for the unity build files to be specified explicitly. By default, this will be the project’s intermediates directory. The <path> value may be any relative or absolute path. Tokens will be evaluated for this path.

  • unitybuilddefines {<table>}: Provides a set of custom macros to define at the top of each unity build file for the project. This can be used to add macros to help get a unity build working properly without needing to make source code changes. One example could be to add the NOMINMAX macro (set to 1) to work around issues with Windows’ broken min() and max() macros. Note that this can also be done at the project or config level with defines{}, but this allows it to be done only in the context of a unity build. The <table> value is expected to be a set of key/value pairs indicating each macro’s name and value. An empty string may be set for the value if no value is needed. It is the caller’s responsibility to ensure that any macros added will compile successfully.

With these three new APIs, a project can simply opt into using unity builds with a small handful of new lines (one new line at a minimum). Only C/C++ files will be affected by this change to a project. By default, these files can only end in ‘.c’ or ‘.cpp’.

How to Integrate Unity Builds in a Repo

In order to add unity build support in a repo, a few steps are required. These involve adding the package as a host dependency, adding the command line options to repo.toml, and loading the premake extension. These changes are necessary:

  • Add an entry to your repo’s deps/host-deps.packman.xml file (or another deps file if that one does not exist). This package needs to be pulled down before the premake scripts can be processed for the repo. This can be done with a block such as this:

  <dependency name="premake-unitybuild" linkPath="../_build/host-deps/unitybuild">
    <package name="premake-unitybuild" version="1.0.1-0"/>
  </dependency>
  • Add the command line options for the unity build extension to your repo’s repo.toml file. This is needed for the repo build tool to be able to know how to pass arguments along to premake. This can be done by adding this to the repo build tool section of the repo.toml file:

[[repo_build.argument]]
name = "--unity-build"
help = """
    When enabled, projects that have opted-in to unity builds will enable their unity build setup.  When disabled,
    the normal behavior of building each .cpp file independently is used.  Projects can opt-in to unity builds
    by using the `add_files()` when adding files to the project and setting the fourth argument to `true`.  Only
    .cpp/.cc files will be included in the unity build listing."""
kwargs.required = false
kwargs.nargs = 0
extra_premake_args = ["--unity-build"]
platforms = ["*"] # All platforms.

[[repo_build.argument]]
name = "--unity-log"
help = """
    When enabled, extra logging information will be output for all projects that have opted into unity builds.
    These will be silenced if this option is not used.  This logging will greatly increase the amount of output
    and should only be used for debugging purposes when trying to enable a unity build for a new project."""
kwargs.required = false
kwargs.nargs = 0
extra_premake_args = ["--unity-log"]
platforms = ["*"] # All platforms.

[[repo_build.argument]]
name = "--unity-log-filter"
help = """
    When enabled, extra logging information will be output for all projects that have opted into unity builds.
    These will be silenced if this option is not used.  This logging will greatly increase the amount of output
    and should only be used for debugging purposes when trying to enable a unity build for a new project."""
kwargs.required = false
kwargs.nargs = 1
extra_premake_args = ["--unity-log-filter={}"]
platforms = ["*"] # All platforms.
  • Load the unity build extension in the repo’s main premake script. This can be done with the line:

require("_build/host-deps/unitybuild")

How to Opt in to Unity Builds for a Project

Once the unity build extension has been integrated into a repo’s build toolchain, projects can start opting into unity builds. The simplest way to do this for a project is to add the following line to the project’s definition:

        unitybuildenabled(true)

The unity build behavior for the project can be further tweaked using APIs such as unitybuildcount or unitybuilddefines.

  • The unitybuildcount API allows the number of unity build files used for the project to be explicitly specified. This is useful for large projects to get the benefits of both unity builds and parallel builds. By default the number of unity build files for the project will be calculated based on the total number of C/C++ files in it. For most projects this is sufficient. If there are specific needs of the project or there are a large number of projects in the workspace that already make full use of a multi-core platform, a project owner may want to force the unity build file count for a specific project to a value such as 1 or 2 despite the number of source files in it.

  • The unitybuilddefines API allows additional macros to be defined before the source files are included in each unity build file. This can be used to help massage the code into working around certain build issues that only appear in unity builds or to provide some extra functionality or reporting from those builds. This API accepts a table of key/value pairs that can be used to define new macros for unity builds, but not for normal builds.

Command Line Options

The unity build extension adds a few new command line options that are valid in the premake invocation. These allow the unity builds to be enabled and enable some extra debug logging as needed. The following new command line options are available:

  • --unity-build: This enables unity builds for all projects that have opted into it. Since a unity build can negatively affect development workflows, this feature defaults to being disabled. When this option is not used, the project will be configured and built as if no projects had opted into unity builds. When this option is used, the projects that have opted into unity builds will be configured so that only the unity build files are included in the build. All other projects that have not opted into unity builds will still be configured and build as normal.

  • --unity-log: This enables extra debug logging output from premake when it runs during the build. This will outline which projects have opted into unity builds and how the files in each of those projects are being included in their unity build files. When enabled, this log can be very verbose and will result in log messages being generated even for projects that have not opted into unity builds. This can be filtered using the --unity-log-filter option below.

  • --unity-log-filter=<filter>: This allows the extra debugging messages from the --unity-log option to be filtered down to only a subset of projects as needed. This option has no effect if --unity-log was not also specified. The <filter> value can either be set to enabled to only output log messages for projects that have opted into unity builds, or it can be the name of one or more projects to display the unity build log messages for. If multiple project names are given, they should be separated by commas. This can be used to focus on the messages for just a single (or a few) project of interest when debugging unity build issues.