There’s a story arc in our Bazel migration that I’ve been writing about in pieces but never connected. It spans three months, two competing PRs, five failed attempts at hermetic compilation, a CUDA package with 80+ C++ source files that needed exactly zero lines of CC toolchain config, and now a vendor fork of NCCL that compiles in a completely different CI pipeline and gets installed via apt.
The punchline is that we spent weeks debating how to give Bazel a C++ compiler, and the biggest native-code packages in our monorepo don’t use a C++ compiler through Bazel at all. The problems looked identical from the outside. They’re completely orthogonal.
If you’ve been following the series, this is the connective tissue. If you haven’t – the previous posts cover the technical depth. This one is the map.
The timeline
| Date | What happened |
|---|---|
| Feb 24 | System torch lands in Docker – Option C ships, 30 packages unblocked |
| Feb 27 | CC toolchain explainer – “why Bazel can’t compile your sdists” |
| Mar 2 | Hermetic toolchain post – PR #22578 with toolchains_llvm |
| Mar 3 | Ryan’s PR #21967 merges – use_default_shell_env=True + --action_env |
| Mar 3 | Correction post – “I was wrong about use_default_shell_env” |
| Mar 6 | mai_kernels migration – PR #23478, no CC toolchain needed |
| Mar 6 | Ryan opens DEVEX-510 – vendor fork of NCCL, compiled via GHA, installed as .deb |
| Mar 7 | Native build tooling guide – the reference post |
Four different problems wearing the same hat: “Bazel and C++ code.” Four completely different solutions.
Problem 1: fasttext needs a compiler at pip-install time
fasttext ships as an sdist – source code that needs gcc or clang to compile. When Bazel’s sdist_build rule tries to compile it in a hermetic sandbox, the compiler isn’t there. Build fails. This is the most straightforward version of “Bazel meets C++.”
Two people solved this at the same time, in different ways.
Ryan’s solution: the one that shipped
PR #21967. Merged March 3.
Three changes:
- Patch
aspect_rules_py’ssdist_buildrule to adduse_default_shell_env=True - Add
--action_env=PATH,--action_env=CC,--action_env=CXXto.bazelrc - Add
pip_annotations.tomldeclaring fasttext’s build-time deps - Remove
fasttextfrom the Bazel exclusion list
The host’s compiler leaks into the sandbox through environment variables. setup.py finds gcc. fasttext compiles. Done.
It works. It also has a known limitation – Ryan put a TODO on it himself:
TODO(DEVEX-418): replace with a Bazel-managed cc_toolchain for hermeticity.
The --action_env=PATH in .bazelrc is the part that stings. PATH values differ between machines. Different PATH means different cache keys, which means cache misses. On a monorepo where remote caching saves us hours per day, that’s not nothing.
Although – and this is the part I got wrong initially – --action_env=PATH doesn’t pollute cache keys for all actions. Only for actions that use use_default_shell_env=True. Which is just sdist_build. The blast radius is smaller than I claimed. More on that in a sec.
My solution: the one that didn’t ship
PR #22578. Still open.
The plan was simple: register a hermetic LLVM/Clang compiler via toolchains_llvm, wire it into sdist_build, remove the --action_env hacks entirely. No host compiler leak. Same compiler on every machine. The Bazel Way.
Five attempts. Five failures. Each one peeled back a layer of something I didn’t understand.
I wrote the full technical walkthrough already, but the short version:
- Hermetic toolchain –
find_cc_toolchain()returns a truthy object even whenall_filesisNone. Null check passes,.to_list()blows up. - Add sysroot – sysroot flag only gets injected into
CppCompileAction.sdist_buildrunspython -m buildas a subprocess. Flag never reaches the compiler. - Extract flags via cc_common API – Got the right flags, but they reference paths relative to Bazel’s execroot. And then…
- Simplified flags – Docker CI passed. Linux CI didn’t. Same root cause as #3.
- Gave up on full hermetic – fell back to
use_default_shell_env=True, same as Ryan’s core fix.
The root cause is architectural. build_helper.py in aspect_rules_py changes CWD to a temp directory before running python -m build. Everything in toolchains_llvm – the compiler wrapper, the include paths, the linker scripts – uses paths relative to Bazel’s execroot. Change the CWD and every relative path breaks.
There’s an 8-line fix that would bridge them:
EXECROOT = os.getcwd() # capture before CWD changes
for var in ("CC", "CXX"):
val = env.get(var, "")
if val and not os.path.isabs(val):
env[var] = os.path.join(EXECROOT, val)
But that means patching a second file in aspect_rules_py. I stopped at one patch.
Why is the CC toolchain hard?
I need to correct something I’ve been implying. The CC toolchain is not hard in general. Registering toolchains_llvm or rules_cc in a Bazel project that builds cc_library or cc_binary targets is… fine. Normal. Well-documented. You register the toolchain, you point it at a compiler, Bazel drives everything. Thousands of projects do this.
The CC toolchain is hard for our specific problem because of an impedance mismatch between four layers that each have incompatible assumptions about how compilation works.
Here’s the compilation flow for an sdist build:
Bazel's sdist_build rule
-> runs: python -m build (subprocess)
-> which runs: setup.py
-> which calls: gcc/clang (to compile C extensions)
Notice what’s happening. Bazel isn’t calling the compiler. Python is. Bazel starts a subprocess that starts a build tool that starts a compiler. Three levels of indirection. And each layer has its own ideas about where the compiler lives, how paths work, and what the current directory means.
Here’s the impedance mismatch table – the reason my PR has five attempts in it:
| Layer | Assumption |
|---|---|
| Bazel CC toolchain | Compiler paths are relative to execroot |
| toolchains_llvm wrapper | cc_wrapper.sh uses relative paths for sysroot, includes |
aspect_rules_py build_helper.py |
Changes CWD to temp dir before subprocess |
Python setup.py / python -m build |
Finds compiler via CC env var or PATH |
Every attempt broke at the boundary between two of these layers:
- Just register the toolchain –
setup.pycan’t see it. Hermetic sandbox, no PATH, no CC env var. The toolchain exists in Bazel’s world but Python doesn’t know about Bazel’s world. - Pass the compiler path as
CCenv var – the path is relative to execroot. Works as long as you’re standing in the execroot. - Make it absolute –
build_helper.pychanges CWD to a temp directory before running the subprocess. The compiler wrapper (cc_wrapper.sh) still uses relative paths internally for sysroot and includes. Breaks. - Pass only the sysroot flag – works in Docker (where the sysroot layout matches). Fails on regular Linux (different sysroot layout, different include paths).
- Give up on hermetic, use
use_default_shell_env=True– same as Ryan’s fix. The host compiler leaks in. It works.
The fix is those 8 lines: capture the execroot path before build_helper.py changes CWD, absolutize the compiler paths. But that’s a patch to a second file in aspect_rules_py, on top of the patch we already carry for use_default_shell_env. I stopped at one patch. There’s a limit to how many upstream files you want to fork-maintain.
For normal cc_library targets – where Bazel directly invokes the compiler – none of this matters. The impedance mismatch only exists because sdist_build runs Python-as-a-subprocess-that-runs-a-compiler. It’s a Rube Goldberg chain of build tools, and the CC toolchain breaks at every joint.
The honest comparison
Here’s the table I should have written from the start, before I made claims about Ryan’s approach being worse than mine:
| Aspect | Ryan’s #21967 (merged) | My #22578 (open) |
|---|---|---|
| Core mechanism | use_default_shell_env=True in patch |
use_default_shell_env=True in patch |
Extra .bazelrc flags |
--action_env=PATH/CC/CXX/SDKROOT |
None |
| CC toolchain registered | None | toolchains_llvm LLVM 19.1.6 (unused by sdist builds) |
| Smoke test | None | test_fasttext_smoke end-to-end |
| macOS | --action_env=SDKROOT |
target_compatible_with = linux |
| Cache impact | PATH in --action_env – but only affects use_default_shell_env=True actions |
No --action_env entries |
| Status | Shipped | Open |
The core fix is the same. One line: use_default_shell_env = True on the sdist_build action. Everything else is scaffolding.
I wrote a PR description arguing that Ryan’s --action_env=PATH “pollutes all cache keys.” Then I read the Bazel source code and discovered that --action_env only manifests in actions with use_default_shell_env=True. On Linux, PATH is already in SHELL_ACTION_ENV by default. Ryan’s --action_env=PATH is a no-op on Linux.
My PR adds a hermetic LLVM toolchain that sdist_build doesn’t even use (because of the CWD problem). Ryan’s PR adds --action_env flags that are mostly redundant on Linux. We were both carrying dead weight. His was smaller.
The only concrete difference: my PR adds a test_fasttext_smoke test that validates the build end-to-end. That’s genuinely useful. Everything else was me overengineering a solution to a problem Ryan had already pragmatically solved.
What I actually learned about being wrong
The correction post was uncomfortable to write. I made technical claims in a PR description – about Bazel internals, about cache key semantics, about the scoping of --action_env – that didn’t hold up when I read the source code. Not “slightly off.” Wrong.
Three things were wrong:
- "–action_env=PATH pollutes all actions" – only affects actions with
use_default_shell_env=True - “use_default_shell_env=True only leaks PATH” – also leaks
LD_LIBRARY_PATH,LC_CTYPE, and on macOS,SDKROOT - “Our approach scopes the leak, Ryan’s is global” – both PRs set
use_default_shell_env=Trueon the same action. The “leak” is identical.
The meta-lesson: don’t reason about implementation behavior from documentation. Read the implementation. Specifically, SpawnAction.java, ActionEnvironment.java, and BazelRuleClassProvider.java told me everything. The docs told me a story that was close enough to be dangerous.
Problem 2: mai_kernels has 80+ C++ files and needs zero CC toolchain
This is where the story gets interesting.
While the CC toolchain debate was happening, I looked at mai_kernels – the package everyone assumed was permanently stuck in the “Blocked by Non-Python Code” column. Nine pybind11 native modules. CMake/nvcc build pipeline. CUDA, CUTLASS, MathDx, MPI. The works.
The assumption was: “this package has C++ code, so we need a CC toolchain to migrate it to Bazel.”
The assumption was wrong.
mai_kernels doesn’t need a CC toolchain because Bazel doesn’t compile its C++ code. Nobody asked Bazel to. The pretrain_base Docker image already compiles all nine .so modules during uv sync via scikit-build-core -> CMake -> nvcc. Those binaries exist at runtime inside the container.
Bazel’s job is just managing the Python source layer. The BUILD file is surprisingly small:
py_library(
name = "mai_kernels",
srcs = glob(["src/mai_kernels/**/*.py"], exclude = ["src/mai_kernels/**/tests/**"]),
imports = ["src"],
deps = ["//lib/mai_nano/src/mai_nano/mocks", "@pypi//numpy"],
)
Two deps. numpy and the mock utilities. The nine native .so files, torch, triton, CUDA libraries – all come from system site-packages via include_system_site_packages=True. Same pattern as system torch.
And here’s the part that makes it clean: mai_kernels/__init__.py already uses magic_mock_packages(). When the native .so files aren’t available – like on CPU – the modules get mocked. The py_library builds and type-checks fine on CPU. Only the tests need GPU. The CPU/GPU boundary was already designed into the package.
I wrote the full migration story separately. The point for this post is that mai_kernels and fasttext look like the same problem from the outside – “Bazel and C++ code” – but they’re completely different problems with completely different solutions.
Problem 3: NCCL gets a vendor fork and Bazel still doesn’t care
Just as I was wrapping up the mai_kernels migration, Ryan opened DEVEX-510 – “setup forked build of nccl.” And it’s yet another native-code story where the answer to “do we need a CC toolchain?” is “no.”
NCCL – NVIDIA’s Collective Communications Library – is the glue that coordinates GPU-to-GPU communication in distributed training. It’s C and CUDA under the hood. The upstream team wants a fork so they can carry internal bug fixes and patches without waiting for NVIDIA releases.
Here’s the plan:
- Fork NCCL to
infinity-microsoft/mai-nccl(DEVEX-511) - Build it using NCCL’s own Makefiles (
make+nvcc) in a GitHub Actions workflow - Package the output as a
.deb - Upload via
gh release create+gh release upload - In the Docker image build:
gh release download+apt install
That’s it. The entire compilation lifecycle lives inside a GHA workflow that has nothing to do with Bazel. The resulting .deb gets installed into the Docker image like any other system package. By the time Bazel runs, NCCL is just another shared library sitting in /usr/lib. Invisible.
This is the third variation of the same pattern. mai_kernels compiles in the Docker image build itself (CMake/nvcc during uv sync). torch is pre-compiled upstream. NCCL will be compiled in a dedicated GHA workflow. Three different “when” and “where” answers – same Bazel outcome: don’t touch it, it’s already there.
What makes NCCL interesting is how far removed the compilation is from anything Bazel-adjacent. It’s not even in the same repository. A separate repo, a separate CI pipeline, a separate artifact format (.deb), installed by the system package manager. The distance between “this code gets compiled” and “Bazel sees the result” is about as wide as it can get.
The matrix
Here’s the distinction that took me three months to internalize. It’s not a two-by-two – it’s a “where does compilation happen?” question with three answers:
| Package | What’s compiled | Where it’s compiled | Who compiles it | Bazel’s job | CC toolchain? |
|---|---|---|---|---|---|
| fasttext | C extensions | Bazel sandbox (sdist_build) |
gcc via pip install |
Compile + manage | Yes |
| mai_kernels | CUDA/C++ (.so) | Docker image build | CMake/nvcc via uv sync |
Manage .py only | No |
| torch | C++/CUDA (huge) | Upstream pre-built | PyTorch CI | Nothing – it’s there | No |
| NCCL fork | C/CUDA (.deb) | GHA workflow | make + nvcc |
Nothing – it’s there | No |
One of these needs a CC toolchain. The other three are already compiled before Bazel even starts. Different compilation pipelines, different lifecycles, same “but it has C++ code” surface appearance.
fasttext = sdist that needs gcc at pip install time -> CC toolchain or use_default_shell_env -> Ryan’s PR / my PR
mai_kernels = CUDA code compiled in Docker via CMake/nvcc -> already done by image build -> py_library + system site-packages -> PR #23478
torch = pre-compiled in Docker image -> not even compiled by us -> system site-packages -> PR #21169
NCCL fork = compiled in a separate GHA workflow, packaged as .deb, installed into Docker via apt -> Bazel never sees it
If you try to solve a Docker-compiled problem with a CC toolchain, you’ll spend weeks rewriting 418 lines of CMakeLists.txt into rules_cuda for zero practical gain. If you try to solve an sdist problem with system site-packages, it won’t work because the compilation hasn’t happened yet. And if your native code is compiled in an entirely separate CI pipeline and installed as a system package? You don’t even need to think about it.
The solution depends on when and where the compilation happens. Not on whether C++ is involved.
Where we are now
| Package | Approach | Why this approach |
|---|---|---|
| fasttext | sdist_build in Bazel sandbox |
Already working (Ryan’s PR #21967) |
| mai_kernels | Docker builds .so, Bazel manages .py | CUDA + torch + CUTLASS + MPI = too exotic for any Bazel-based approach |
| torch | Pre-compiled upstream, system site-packages | Not even compiled by us |
| NCCL fork | GHA -> .deb -> Docker | Different repo, own release cycle. Could theoretically use rules_foreign_cc but no reason – it’s not in the monorepo |
| lib/bus | TBD – investigate first | Don’t know complexity yet |
The key lesson: we made the assumption mistake with mai_kernels. Looked at “CUDA/C++ code” and assumed it needed a CC toolchain before actually looking at what the code does. It didn’t. Don’t repeat that with lib/bus – investigate first, read the build files, then pick an approach. Could be a cc_library rewrite, a rules_foreign_cc wrap, or the Docker route. Depends entirely on what the CMakeLists.txt looks like and what it depends on.
Ryan’s TODO still stands: DEVEX-418, replace --action_env with a managed CC toolchain. My PR (#22578) is the half-built attempt. The CWD problem in build_helper.py is the real blocker – and it might be worth an upstream PR to aspect_rules_py rather than carrying a second local patch.
When do you actually need a CC toolchain?
After four packages and three months, here’s the decision tree I wish I’d had on day one:
Step 1: Does the package have native code (C, C++, CUDA, Fortran, Rust)?
No -> Normal py_library. You’re done.
Yes -> Continue.
Step 2: Is that native code already compiled before Bazel runs?
This is the only question that matters. Check these, in order:
- Pre-compiled upstream? (torch, numpy wheels, etc.) -> No CC toolchain. Use pre-built wheels or system site-packages.
- Compiled in the Docker image build? (mai_kernels via CMake/nvcc during
uv sync) -> No CC toolchain.py_library+include_system_site_packages=True. - Compiled in a separate CI pipeline? (NCCL fork via GHA workflow ->
.deb->apt install) -> No CC toolchain. It’s a system package by the time your container starts. - Compiled at
pip installtime from an sdist? (fasttext, pyyaml) -> Yes, you need a CC toolchain (oruse_default_shell_envto leak the host compiler).
That last case – sdist packages compiled inside Bazel’s sandbox – is the only case where a CC toolchain matters. It’s also the narrowest case. Most native code in a Python monorepo falls into one of the first three buckets.
The instinct is to see “C++ code” and reach for a CC toolchain. Resist it. Ask “where does this get compiled?” first. Four out of four times so far, the answer has been “somewhere else” for everything except sdists.
The CC toolchain beyond sdist
The CC toolchain currently serves exactly one consumer: fasttext’s sdist build. Zero cc_library targets in the monorepo. All C++ code is compiled somewhere else – Docker, upstream, GHA. Bazel has never called a C++ compiler for our code.
But that won’t last:
| Use case | When | Why CC toolchain matters |
|---|---|---|
| lib/bus migration | Next major blocker (~28 packages) | C++/Cython – if Bazel compiles it (TBD after investigation) |
| Cython targets | lib/bus, potentially others | cython_library generates C code that needs a compiler |
| cc_test for native code | If we want to test C++ directly | Unit testing C extensions without Python |
| pybind11 targets | If pybind11 builds move into Bazel | pybind11_extension needs a compiler |
| rules_foreign_cc | If we vendor a C library with simple deps | CMake/Make projects inside Bazel need CC toolchain |
The big one is lib/bus. But the approach is: investigate first. Read the build files. Check the complexity spectrum. If it’s a grocery-list CMakeLists.txt, cc_library rewrite or rules_foreign_cc wrap. If it’s got exotic deps, Docker route. We don’t know yet, and that’s fine – the mistake would be assuming before looking.
The honest framing: the CC toolchain is either infrastructure-for-the-future or over-engineering-for-the-present. Which one depends on what we find when we actually open lib/bus’s build files.
Why C++ is uniquely hard in Bazel
Here’s something I didn’t fully appreciate until deep into this migration: C++ is categorically different from every other language Bazel supports. Not harder. Different.
Go has go build. Python has pip. Rust has cargo. Java has javac. Each language has ONE canonical build tool. When Bazel adds support, there’s a standard to translate from. rules_go takes Go’s one build model and maps it to Bazel’s world. The translation is mechanical. Boring, even.
C++ was created in 1979. C in 1972. There has never been a standard build tool. Instead, the ecosystem produced dozens of them over fifty years: Make (1976), Autoconf (1991), CMake (2000), Ninja (2012), Meson (2013), Visual Studio, and… Bazel itself.
There is no cpp build command. Never was.
This means:
rules_goTRANSLATES Go’s one standard build model into Bazelrules_ccIS a build model for C++. One of many. It doesn’t translate anything – you write the build instructions from scratch in Bazel’s language.
rules_cc tells Bazel “compile these files, link these deps.” That’s it. The CC toolchain tells it WHICH compiler to use. Two separate concerns.
This distinction explains why wrapping C++ in Bazel is so much harder than wrapping Go or Rust. For those languages, there’s a canonical source of truth to import from. For C++, every project invented its own truth.
rules_cc vs rules_foreign_cc
“Doesn’t rules_cc teach Bazel how to build C++?” Yes. But only for code described in Bazel’s own language. If the project uses CMake, Make, Autoconf, or anything else – rules_cc doesn’t help. That’s what rules_foreign_cc is for.
rules_cc = Bazel is the C++ build system. You write cc_library, cc_binary, cc_test in BUILD.bazel. Bazel calls the compiler, manages paths, tracks deps, caches objects. Full control, full commitment. You’re rewriting the project’s build in Bazel’s language.
rules_foreign_cc = Bazel runs another C++ build system. You write cmake_external in BUILD.bazel. Bazel says “run CMake inside my sandbox and give me the output.” The CMakeLists.txt stays untouched. Bazel orchestrates the subprocess and captures artifacts.
rules_cc teaches Bazel to be a C++ build system. rules_foreign_cc teaches Bazel to run one.
The CMakeLists.txt complexity spectrum
Whether you can rewrite, wrap, or have to bail depends entirely on what the CMakeLists.txt actually does. It’s not one thing on the complexity spectrum. It IS the spectrum:
| Level | What it looks like | Effort to rewrite in Bazel |
|---|---|---|
Grocery list (3 lines: add_library, target_link) |
Simple lib | 5 minutes. Almost 1:1 with cc_library. |
Some logic (20 lines: find_package, if(LINUX)) |
Medium lib | Few hours. Doable. |
It’s a program (100+ lines: FetchContent, execute_process, loops) |
Complex lib | Painful. Consider rules_foreign_cc. |
| mai_kernels (418 lines: CUDA + torch + CUTLASS + MPI + arch detection) | Don’t even try. | Docker builds it. |
rules_foreign_cc shines when the CMake project is self-contained – “compile these C files with a C compiler.” It breaks down when:
- The project needs exotic deps in the sandbox (CUDA toolkit, torch headers, MPI)
- It does
FetchContent(network access inside Bazel’s sandbox = no) - It does hardware detection (
execute_process(COMMAND nvcc --version))
For mai_kernels, even rules_foreign_cc doesn’t save you – the dependency chain is too exotic for any sandbox. Docker is genuinely the only option.
The full spectrum
Simple C files ──→ Simple CMake ──→ Complex CMake ──→ CUDA/exotic CMake
| | | |
cc_library cc_library rules_foreign_cc Docker builds it
(rewrite) (rewrite) (IF deps are simple) (only real option)
or or
rules_foreign_cc Docker builds it
Where you land on this spectrum determines your approach. And you figure that out by reading the CMakeLists.txt, not by guessing.
The pattern
Every native-code package in a Python monorepo falls into one of four buckets:
-
Sdist with C extensions (fasttext, pyyaml) – needs a compiler at
pip installtime. Fix: CC toolchain oruse_default_shell_env. Small blast radius, tractable problem. This is the only bucket that needs a CC toolchain. -
CUDA/GPU code compiled in Docker (mai_kernels) – compiled during image build via CMake/nvcc/scikit-build-core. Fix:
py_library+include_system_site_packages. Bazel manages Python, Docker manages native code. -
Pre-compiled upstream (torch, triton, numpy) – someone else compiled it. Pre-built wheel or system package in Docker. Fix: system site-packages. Bazel doesn’t need to know.
-
Compiled in a separate CI pipeline (NCCL fork) – your org compiles it, but not in Bazel and not in the Docker image build. GHA workflow -> artifact -> system package install. Fix: nothing. It’s already there by the time anything runs.
Buckets 2, 3, and 4 all converge on the same answer: “Docker handles it.” The compilation happens somewhere upstream of Bazel – whether that’s in the Dockerfile, in PyTorch’s release pipeline, or in a dedicated GHA workflow. By the time Bazel starts, the native code is just shared libraries on disk. Invisible.
The first question isn’t “do we need a CC toolchain?” The first question is “when does this code get compiled, and where?” If the answer is anything other than “inside Bazel’s sandbox at pip-install time,” you don’t need Bazel to know about C++ at all.
Two teammates. Two PRs. One compiler debate. And three native-code packages that watched from the sidelines, already knowing the answer was “none of the above.”
Previous posts
The full arc, in reading order:
- Three Ways to Smuggle torch into Bazel – the original torch experiments
- Making Bazel See System Torch Inside Docker – Option C implementation
- Why Bazel Can’t Compile Your Python C Extensions – sdist + CC toolchain explainer
- Stocking the Kitchen – the hermetic toolchain attempt (before I discovered it was wrong)
- I Was Wrong About use_default_shell_env – the correction post
- Migrating a CUDA Package Without Bazel Knowing About CUDA – mai_kernels
- Everything Below import torch – reference guide for the whole native build stack