blob: 168cb0707df3990c6598f09c6a37a6850256ce77 [file] [log] [blame] [edit]
.. _docs-blog-02-bazel-feature-flags:
==============================================
Pigweed Blog #2: Feature flags in Bazel builds
==============================================
By Ted Pudlik
Published 2024-05-31
Let's say you're migrating your build system to Bazel. Your project heavily
relies on preprocessor defines to configure its code.
.. code-block::
-DBUILD_FEATURE_CPU_PROFILE
-DBUILD_FEATURE_HEAP_PROFILE
-DBUILD_FEATURE_HW_SHA256
In your source files, you use these preprocessor variables to conditionally
compile some sections, via ``#ifdef``. When building the same code for
different final product configurations, you want to set different defines.
How do you model this in Bazel?
This post discusses three possible approaches:
#. :ref:`docs-blog-02-config-with-copts`
#. :ref:`docs-blog-02-platform-based-skylib-flags`
#. :ref:`docs-blog-02-chromium-pattern`
Which one to choose? If you have the freedom to refactor your code to use the
Chromium pattern, give it a try! It is not difficult to maintain once
implemented, and can prevent real production issues by detecting typos.
.. _docs-blog-02-config-with-copts:
-------------------------------------------
Easy but limited: bazelrc config with copts
-------------------------------------------
Let's start with the simplest approach: you can put the compiler options into
your `bazelrc configuration file <https://bazel.build/run/bazelrc>`_.
.. code-block::
# .bazelrc
common:mydevice_evt1 --copts=-DBUILD_FEATURE_CPU_PROFILE
common:mydevice_evt1 --copts=-DBUILD_FEATURE_HEAP_PROFILE
common:mydevice_evt1 --copts=-DBUILD_FEATURE_HW_SHA256
# and so on
Then, when you build your application, the defines will all be applied:
.. code-block:: sh
bazel build --config=mydevice_evt1 //src:application
Configs are expanded recursively, allowing you to group options together and
reuse them:
.. code-block::
# .bazelrc
common:full_profile --copts=-DBUILD_FEATURE_CPU_PROFILE
common:full_profile --copts=-DBUILD_FEATURE_HEAP_PROFILE
# When building for mydevice_evt1, use full_profile.
common:mydevice_evt1 --config=full_profile
# When building for mydevice_evt2, additionally enable HW_SHA256.
common:mydevice_evt2 --config=full_profile
common:mydevice_evt1 --copts=-DBUILD_FEATURE_HW_SHA256
Downsides
=========
While it *is* simple, the config-with-copts approach has a few downsides.
.. _docs-blog-02-config-dangeous-typos:
Dangerous typos
---------------
If you misspell ``BUILDDD_FEATURE_CPU_PROFILE`` [sic!] in your ``.bazelrc``,
the actual ``BUILD_FEATURE_CPU_PROFILE`` variable will `take the default value
of 0 <https://stackoverflow.com/q/5085392/24291280>`__. So, although you
intended to enable this feature, it will just remain disabled!
This isn't just a Bazel problem, but a general issue with the simple
``BUILD_FEATURE`` macro pattern. If you misspell ``BUILD_FEATUER_CPU_PROFILE``
[sic!] in your C++ file, you'll get it to evaluate to 0 in any build system!
One way to avoid this issue is to use the :ref:`"Chromium-style" build flag
pattern <docs-blog-02-chromium-pattern>`, ``BUILDFLAG(CPU_PROFILE)``. If you
do, a misspelled or missing define becomes a compiler error. However, the
config-with-copts approach is a little *too* simple to express this pattern,
which requires code generation of the build flag headers.
No multi-platform build support
-------------------------------
Bazel allows you to perform multi-platform builds. For example, in a single
Bazel invocation you can build a Python flasher program (that will run on your
laptop) which embeds as a data dependency the microcontroller firmware to flash
(that will run on the microcontroller). `We do this in Pigweed's own
examples
<https://cs.opensource.google/pigweed/examples/+/main:examples/01_blinky/BUILD.bazel>`__.
Unfortunately, the config-with-copts pattern doesn't work nicely with
multi-platform build primitives. (Technically, the problem is that Bazel
`doesn't support transitioning on --config
<https://bazel.build/extending/config#unsupported-native-options>`__.) If you
want a multi-platform build, you need some more sophisticated idiom.
No build system variables
-------------------------
This approach doesn't introduce any variable that can be used within the build
system to e.g. conditionally select different source files for a library,
choose a different library as a dependency, or remove some targets from the
build altogether. We're really just setting preprocessor defines here.
Limited multirepo support
-------------------------
The ``.bazelrc`` files are not automatically inherited when another repo
depends on yours. They can be `imported
<https://bazel.build/run/bazelrc#imports>`__, but it's an all-or-nothing
affair.
.. _docs-blog-02-platform-based-skylib-flags:
------------------------------------------------------------
More power with no code changes: Platform-based Skylib flags
------------------------------------------------------------
Let's address some shortcomings of the approach above by representing the build
features as `Skylib flags
<https://github.com/bazelbuild/bazel-skylib/blob/main/docs/common_settings_doc.md>`__
and grouping them through `platform-based flags
<https://github.com/bazelbuild/proposals/blob/main/designs/2023-06-08-platform-based-flags.md>`__.
(Important note: this feature is `still under development
<https://github.com/bazelbuild/bazel/issues/19409>`__! See :ref:`the Appendix
<docs-blog-02-old-bazel>` for workarounds for older Bazel versions.)
The platform sets a bunch of flags:
.. code-block:: python
# //platform/BUILD.bazel
# The platform definition
platform(
name = "mydevice_evt1",
flags = [
"--//build/feature:cpu_profile=true",
"--//build/feature:heap_profile=true",
"--//build/feature:hw_sha256=true",
],
)
The flags have corresponding code-generated C++ libraries:
.. code-block:: python
# //build/feature/BUILD.bazel
load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
# I'll show one possible implementation of feature_cc_library later.
load("//:feature_cc_library.bzl", "feature_cc_library")
# This is a boolean flag, but there's support for int- and string-valued
# flags, too.
bool_flag(
name = "cpu_profile",
build_setting_default = False,
)
# This is a custom rule that generates a cc_library target that exposes
# a header "cpu_profile.h", the contents of which are either,
#
# BUILD_FEATURE_CPU_PROFILE=1
#
# or,
#
# BUILD_FEATURE_CPU_PROFILE=0
#
# depending on the value of the cpu_profile bool_flag. This "code
# generation" is so simple that it can actually be done in pure Starlark;
# see below.
feature_cc_library(
name = "cpu_profile_cc",
flag = ":cpu_profile",
)
# Analogous library that exposes the constant in Python.
feature_py_library(
name = "cpu_profile_py",
flag = ":cpu_profile",
)
# And in Rust, why not?
feature_rs_library(
name = "cpu_profile_rs",
flag = ":cpu_profile",
)
bool_flag(
name = "heap_profile",
build_setting_default = False,
)
feature_cc_library(
name = "heap_profile_cc",
flag = ":heap_profile",
)
bool_flag(
name = "hw_sha256",
build_setting_default = False,
)
feature_cc_library(
name = "hw_sha256_cc",
flag = ":hw_sha256",
)
C++ libraries that want to access the variable needs to depend on the
``cpu_profile_cc`` (or ``heap_profile_cc``, ``hw_sha256_cc``) library.
Here's one possible implementation of ``feature_cc_library``:
.. code-block:: python
# feature_cc_library.bzl
load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
def feature_cc_library(name, build_setting):
hdrs_name = name + ".hdr"
flag_header_file(
name = hdrs_name,
build_setting = build_setting,
)
native.cc_library(
name = name,
hdrs = [":" + hdrs_name],
)
def _impl(ctx):
out = ctx.actions.declare_file(ctx.attr.build_setting.label.name + ".h")
# Convert boolean flags to canonical integer values.
value = ctx.attr.build_setting[BuildSettingInfo].value
if type(value) == type(True):
if value:
value = 1
else:
value = 0
ctx.actions.write(
output = out,
content = r"""
#pragma once
#define {}={}
""".format(ctx.attr.build_setting.label.name.upper(), value),
)
return [DefaultInfo(files = depset([out]))]
flag_header_file = rule(
implementation = _impl,
attrs = {
"build_setting": attr.label(
doc = "Build setting (flag) to construct the header from.",
mandatory = True,
),
},
)
Advantages
==========
Composability of platforms
--------------------------
A neat feature of the simple config-based approach was that configs could be
composed through recursive expansion. Fortunately, platforms can be composed,
too! There are two mechanisms for doing so:
#. Use platforms' `support for inheritance
<https://bazel.build/reference/be/platforms-and-toolchains#platform_inheritance>`__.
This allows "subplatforms" to override entries from "superplatforms". But,
only single inheritance is supported (each platform has at most one parent).
#. The other approach is to compose lists of flags directly, through concatenation:
.. code-block:: python
FEATURES_CORTEX_M7 = [
"--//build/feature:some_feature",
]
FEATURES_MYDEVICE_EVT1 = FEATURES_CORTEX_M7 + [
"--//build/feature:some_other_feature",
]
platform(
name = "mydevice_evt1",
flags = FEATURES_MYDEVICE_EVT1,
)
Concatenation doesn't allow overriding entries, but frees you from the
single-parent limitation of inheritance.
.. tip::
This approach can also be used to define custom host platforms:
``HOST_CONSTRAINTS`` in ``@local_config_platform//:constraints.bzl``
contains the autodetected ``@platform//os`` and ``@platforms//cpu``
constraints set by Bazel's default host platform.
Multi-platform build support
----------------------------
How do you actually associate the platform with a binary you want to build? One
approach is to just specify the platform on the command-line when building a
``cc_binary``:
.. code-block:: sh
bazel build --platforms=//platform:mydevice_evt1 //src:main
But another approach is to leverage multi-platform build, through
`platform_data <https://github.com/bazelbuild/rules_platform/blob/main/platform_data/defs.bzl>`__:
.. code-block:: python
# //src/BUILD.bazel
load("@rules_platform//platform_data:defs.bzl", "platform_data")
cc_binary(name = "main")
platform_data(
name = "main_mydevice_evt1",
target = ":main",
platform = "//platform:mydevice_evt1",
)
Then you can keep your command-line simple:
.. code-block:: sh
bazel build //src:main_mydevice_evt1
Flags correspond to build variables
-----------------------------------
You can make various features of the build conditional on the value of the
flag. For example, you can select different dependencies:
.. code-block:: python
# //build/feature/BUILD.bazel
config_setting(
name = "hw_sha256=true",
flag_values = {
":hw_sha256": "true",
},
)
# //src/BUILD.bazel
cc_library(
name = "my_library",
deps = [
"//some/unconditional:dep",
] + select({
"//build/feature:hw_sha256=true": ["//extra/dep/for/hw_sha256:only"],
"//conditions:default": [],
})
Any Bazel rule attribute described as `"configurable"
<https://bazel.build/docs/configurable-attributes>`__ can take a value that
depends on the flag in this way. Library header lists and source lists are
common examples, but the vast majority of attributes in Bazel are configurable.
Downsides
=========
Typos remain dangerous
----------------------
If you used :ref:`"Chromium-style" build flags <docs-blog-02-chromium-pattern>`
you *would* be immune to dangerous typos when using this Bazel pattern. But
until then, you still have this problem, and actually it got worse!
If you forget to ``#include "build/features/hw_sha256.h"`` in the C++ file that
references the preprocessor variable, the build system or compiler will still
not yell at you. Instead, the ``BUILD_FEATURE_HA_SHA256`` variable will take
the default value of 0.
This is similar to the :ref:`typo problem with the config approach
<docs-blog-02-config-dangeous-typos>`, but worse, because it's easier to miss
an ``#include`` than to misspell a name, and you'll need to add these
``#include`` statements in many places.
One way to mitigate this problem is to make the individual
``feature_cc_library`` targets private, and gather them into one big library
that all targets will depend on:
.. code-block:: python
feature_cc_library(
name = "cpu_profile_cc",
flag = ":cpu_profile",
visibility = ["//visibility:private"],
)
feature_cc_library(
name = "heap_profile_cc",
flag = ":heap_profile",
visibility = ["//visibility:private"],
)
feature_cc_library(
name = "hw_sha256_cc",
flag = ":hw_sha256",
visibility = ["//visibility:private"],
)
# Code-generated cc_library that #includes all the individual
# feature_cc_library headers.
all_features_cc_library(
name = "all_features",
deps = [
":cpu_profile_cc",
":heap_profile_cc",
":hw_sha256_cc",
# ... and many more.
],
visibility = ["//visibility:public"],
)
However, a more satisfactory solution is to adopt :ref:`Chromium-style build
flags <docs-blog-02-chromium-pattern>`, which we discuss next.
Build settings have mandatory default values
--------------------------------------------
The Skylib ``bool_flag`` that represents the build flag within Bazel has a
``build_setting_default`` attribute. This attribute is mandatory.
This may be a disappointment if you were hoping to provide no default, and have
Bazel return errors if no value is explicitly set for a flag (either via a
platform, through ``.bazelrc``, or on the command line). The Skylib build flags
don't support this.
The danger here is that the default value may be unsafe, and you forget to
override it when adding a new platform (or for some existing platform, when
adding a new flag).
There is an alternative pattern that allows you to define default-less build
flags: instead of representing build flags as Skylib flags, you can represent
them as ``constraint_setting`` objects. I won't spell this pattern out in
this blog post, but it comes with its own drawbacks:
* The custom code-generation rules are more complex, and need to parse the
``constraint_value`` names to infer the build flag values.
* All supported flag values must be explicitly enumerated in the ``BUILD``
files, and the code-generation rules need explicit dependencies on them.
This leads to substantially more verbose ``BUILD`` files.
On the whole, I'd recommend sticking with the Skylib flags!
.. _docs-blog-02-chromium-pattern:
------------------------------------------------------------
Error-preventing approach: Chromium-style build flag pattern
------------------------------------------------------------
This pattern builds on :ref:`docs-blog-02-platform-based-skylib-flags` by
adding a macro helper for retrieving flag values that guards against typos. The
``BUILD.bazel`` files look exactly the same as in the :ref:`previous section
<docs-blog-02-platform-based-skylib-flags>`, but:
#. Users of flags access them in C++ files via ``BUILDFLAG(SOME_NAME)``.
#. The code generated by ``feature_cc_library`` is a little more elaborate than
a plain ``SOME_NAME=1`` or ``SOME_NAME=0``, and it includes a dependency on
the `Chromium build flag header
<https://chromium.googlesource.com/chromium/src/build/+/refs/heads/main/buildflag.h>`__.
Here's the ``feature_cc_library`` implementation:
.. code-block:: python
load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
def feature_cc_library(name, build_setting):
"""Generates a cc_library from a common build setting.
The generated cc_library exposes a header [build_setting.name].h that
defines a corresponding build flag.
Example:
feature_cc_library(
name = "evt1_cc",
build_setting = ":evt1",
)
* This target is a cc_library that exposes a header you can include via
#include "build/flags/evt1.h".
* That header defines a build flag you can access in your code through
BUILDFLAGS(EVT1).
* If you wish to use the build flag from a cc_library, add the target
evt1_cc to your cc_library's deps.
Args:
name: Name for the generated cc_library.
build_setting: One of the Skylib "common settings": bool_flag, int_flag,
string_flag, etc. See
https://github.com/bazelbuild/bazel-skylib/blob/main/docs/common_settings_doc.md
"""
hdrs_name = name + ".hdr"
flag_header_file(
name = hdrs_name,
build_setting = build_setting,
)
native.cc_library(
name = name,
hdrs = [":" + hdrs_name],
# //:buildflag is a cc_library containing the
# Chromium build flag header.
deps = ["//:buildflag"],
)
def _impl(ctx):
out = ctx.actions.declare_file(ctx.attr.build_setting.label.name + ".h")
# Convert boolean flags to canonical integer values.
value = ctx.attr.build_setting[BuildSettingInfo].value
if type(value) == type(True):
if value:
value = 1
else:
value = 0
ctx.actions.write(
output = out,
content = r"""
#pragma once
#include "buildflag.h"
#define BUILDFLAG_INTERNAL_{}() ({})
""".format(ctx.attr.build_setting.label.name.upper(), value),
)
return [DefaultInfo(files = depset([out]))]
flag_header_file = rule(
implementation = _impl,
attrs = {
"build_setting": attr.label(
doc = "Build setting (flag) to construct the header from.",
mandatory = True,
),
},
)
-----------
Bottom line
-----------
If you have the freedom to refactor your code to use the Chromium pattern,
Bazel provides safe and convenient idioms for expressing configuration through
build flags. Give it a try!
Otherwise, you can still use platform-based Skylib flags, but beware typos and
missing ``#include`` statements!
--------
Appendix
--------
A couple "deep in the weeds" questions came up while this blog post was being
reviewed. I thought they were interesting enough to discuss here, for the
interested reader!
Why isn't the reference code a library?
=======================================
If you made it this far you might be wondering, why is the code listing for
``feature_cc_library`` even here? Why isn't it just part of Pigweed, and used
in our own codebase?
The short answer is that Pigweed is middleware supporting multiple build
systems, so we don't want to rely on the build system to generate configuration
headers.
But the longer answer has to do with how this blog post came about. Some time
ago, I was migrating team A's build from CMake to Bazel. They used Chromium
build flags, but in CMake, so to do a build migration they needed Bazel support
for this pattern. So I put an implementation together. I wrote a design
document, but it had confidential details and was not widely shared.
Then team B comes along and says, "we tried migrating to Bazel but couldn't
figure out how to support build flags" (not the Chromium flags, but the "naive"
kind; i.e. their problem statement was exactly the one the blog opens with). So
I wrote a less-confidential but still internal doc for them saying "here's how
you could do it"; basically, :ref:`docs-blog-02-platform-based-skylib-flags`.
Then Pigweed's TL comes along and says "Ted, don't you feel like spending a day
fighting with RST [the markup we use for pigweed.dev]?" Sorry, actually they
said something more like, "Why is this doc internal, can't we share this more
widely"? Well we can. So that's the doc you're reading now!
But arguably the story shouldn't end here: Pigweed should probably provide a
ready-made implementation of Chromium build flags for downstream projects. See
:bug:`342454993` to check out how that's going!
Do you need to generate actual files?
=====================================
If you are a Bazel expert, you may ask: do we need to have Bazel write out the
actual header files, and wrap those in a ``cc_library``? If we're already
writing a custom rule for ``feature_cc_library``, can we just set ``-D``
defines by providing `CcInfo
<https://bazel.build/rules/lib/providers/CcInfo>`__? That is, do something like
this:
.. code-block:: python
define = "{}={}"..format(
ctx.attr.build_setting.label.name.upper(),
value)
return [CcInfo(
compilation_context=cc_common.create_compilation_context(
defines=depset([define])))]
The honest answer is that this didn't occur to me! But one reason to prefer
writing out the header files is that this approach generalizes in an obvious
way to other programming languages: if you want to generate Python or Golang
constants, you can use the same pattern, just change the contents of the file.
Generalizing the ``CcInfo`` approach is trickier!
.. _docs-blog-02-old-bazel:
What can I do on older Bazel versions?
======================================
This blog focused on describing approaches that rely on `platform-based
flags
<https://github.com/bazelbuild/proposals/blob/main/designs/2023-06-08-platform-based-flags.md>`__.
But this feature is very new: in fact, as of this writing, it is `still under
development <https://github.com/bazelbuild/bazel/issues/19409>`__, so it's not
available in *any* Bazel version! So what can you do?
One approach is to define custom wrapper rules for your ``cc_binary`` targets
that use a `transition
<https://bazel.build/extending/config#user-defined-transitions>`__ to set the
flags. You can see examples of such transitions in the `Pigweed examples
project
<https://cs.opensource.google/pigweed/examples/+/main:targets/transition.bzl>`__.