Bazel Migration: The ROI Math of Knowing Your Blockers

2026/03/13

BUILDbazeldependenciesmigrationmonorepo

I was staring at our migration tracker and feeling the thing you feel when a spreadsheet has 66 rows of “blocked” and no obvious place to start. Sixty-six packages. All waiting on something. The tracker says what each package is blocked by, but when everything is blocked by everything else, you’re not looking at a to-do list — you’re looking at a hairball.

So I asked a different question: what are the actual root causes?

Not “which packages are blocked” — that’s 66 and it’s overwhelming. But: which packages are causing the blocking, and aren’t themselves blocked by something else? The first-class blockers. The diseases, not the symptoms.

Turns out there are 8.

The setup

106-package Python monorepo. Here’s where we stand:

Status Count %
Already on Bazel 28 26%
Ready to migrate 3 3%
Blocked by dependencies 66 62%
Blocked by non-Python code 4 4%
Manually excluded 4 4%
Skipped (monorepo root) 1 1%

26% coverage. Not terrible — we started at 24% and the torch fix got us some headroom. But 66 packages sitting behind dependency walls means we can’t just “migrate more packages.” The low-hanging fruit is gone. Everything remaining is blocked by something.

The method

The data comes from MIGRATION_STATUS.md, which is machine-generated by find_bazel_ready_packages.py. For each blocked package, it lists exactly which dependencies are blocking it. No guessing, no “I think package X needs Y.” The script crawls real pyproject.toml files and resolves the graph.

Step one: count how often each package appears as a blocker. Simple frequency analysis.

Step two: for each frequent blocker, check — is it blocked by something else? If yes, it’s a symptom, not a root cause. Keep tracing backward until you hit something that isn’t itself blocked.

Here’s the top 15 by direct block count:

conversation           → blocks 39 packages  (also blocked)
mai_preprocessors      → blocks 26 packages  (also blocked)
mai_config             → blocks 24 packages  (ROOT — now solved)
mai_cluster            → blocks 24 packages  (also blocked)
common_utils           → blocks 23 packages  (also blocked)
lib/oai_api            → blocks 21 packages  (also blocked)
mai_distributed        → blocks 14 packages  (also blocked)
mai_logger             → blocks 13 packages  (ROOT — now solved)
judges                 → blocks 11 packages  (also blocked)
mai_dataplatform       → blocks 11 packages  (also blocked)
mai_layers             → blocks 11 packages  (also blocked)
mai_trainer            → blocks 10 packages  (also blocked)
caas                   → blocks  9 packages  (also blocked)
st_server              → blocks  9 packages  (also blocked)
mai_kernels            → blocks  9 packages  (ROOT)

Look at conversation. Blocks 39 packages — the single most-cited blocker in the whole monorepo. Your instinct says “fix conversation first.” But conversation is itself blocked. It can’t migrate until its own dependencies are resolved. Fixing it isn’t a thing you can just do. You have to fix what’s blocking it.

This is the trap. The most-cited blockers aren’t the most important ones — they’re the most downstream ones. They’re popular because lots of packages depend on them, but they’re symptoms of something deeper.

The 8 root cause blockers

A root cause blocker is a package that:

  1. Appears as a blocker for other packages
  2. Is not itself blocked by any other unresolved dependency

It’s either non-Python code that Bazel can’t compile, a package that’s manually excluded, or a package that just needs migration work but nobody’s done it yet. These are the places where effort actually has leverage.

There are exactly 8.

Tier 1: High ROI — just needs migration work

Blocker Direct blocks Transitive blocks Status
mai_config 24 ~40+ Solved — Ryan merged #23994
mai_logger 13 ~30+ Solved — same PR
mai_preprocessors 26 ~50+ Not started

mai_config and mai_logger are done. Ryan shipped those. That leaves mai_preprocessors as the single highest-ROI migration target in the entire monorepo.

Here’s why. conversation — the package that blocks 39 others — is blocked by two things: mai_config (solved) and mai_preprocessors (not solved). Fix mai_preprocessors, and conversation unblocks. Once conversation unblocks, those 39 downstream packages start cascading.

One package. One migration. The gateway to 39.

Tier 2: Hard walls — non-Python code

Blocker Type Direct blocks Transitive blocks Difficulty
lib/bus Cython (4 .pyx files) 2 ~28 Medium-Hard
mai_kernels CUDA/C++ (scikit-build-core) 9 ~15 Hard
simplertransformer CUDA/C++ (CMake) 5 ~10 Very Hard

lib/bus is the sneaky one. The migration scanner tagged it as “Contains: C++” because of language="c++" in its setup.py. I went to look at the actual C++ files.

There aren’t any. 420 Python files. 4 Cython files. Zero C++. Zero CUDA. Zero headers.

The language="c++" flag is a Cython codegen directive — it tells Cython to generate C++ instead of C as the intermediate compiled output. The scanner saw “c++” and categorized lib/bus alongside real CUDA libraries. The scariest-looking blocker in the entire migration was four .pyx files cosplaying as a C++ library.

lib/bus blocks only 2 packages directly (lib/oai_api), but lib/oai_api feeds into common_utils, and common_utils feeds into everything. The transitive chain: lib/buslib/oai_apicommon_utils → ~28 packages. It’s the choke point for the entire bottom half of the dependency graph.

mai_kernels is real CUDA — scikit-build-core, CMake, the whole build-system-within-a-build-system situation. There’s a PR #23478 with a precompiled .so approach. simplertransformer is the same flavor but harder — more CUDA, more CMake, more pain.

Tier 3: Manually excluded — fixable but niche

Blocker Why excluded Direct blocks Difficulty
dataprocessing/audio_ops Gazelle saw optional torch import 3 Easy — torch is solved
dataprocessing/resumable_ray_pipeline Same 3 Easy
lib/excel_evaluation Complex native deps 1 Hard

These were excluded back when torch was unsolvable. Now that torch works in Bazel, the first two just need a re-scan. lib/excel_evaluation is genuinely gnarly (native Excel parsing libraries) but only blocks 1 package, so it’s low priority.

The priority stack

Sort by ROI — impact divided by difficulty:

Priority 1:  mai_preprocessors → conversation → 39+ packages cascade
             ROI: MASSIVE. Single biggest unlock remaining.

Priority 2:  lib/bus → lib/oai_api → common_utils → ~28 packages
             ROI: MASSIVE. Clears the ceiling.

Priority 3:  mai_kernels (PR exists)
             ROI: MEDIUM. 9 direct, overlaps with lib/bus chain.

Priority 4:  audio_ops + resumable_ray_pipeline (re-run scanner)
             ROI: LOW. 6 packages total.

Priority 5:  simplertransformer
             ROI: LOW. Overlaps with lib/bus chain.

The math

Here’s the punchline.

If you fix… Packages unblocked Total on Bazel Coverage
Nothing (today) 28 / 106 26%
mai_preprocessors + conversation cascade ~30-39 ~58-67 / 106 55-63%
+ lib/bus chain ~28 more ~86-95 / 106 81-90%
+ mai_kernels ~5-9 more ~91-100 / 106 86-94%
+ everything all remaining 106 / 106 100%

Two fixes take you from 26% to 85-90%. mai_preprocessors (a standard Python migration — torch is already solved) and lib/bus (4 Cython files, not real C++). Everything after that is diminishing returns.

Two out of eight root blockers account for roughly 80% of the blocked packages. Pareto, right on schedule.

The meta-insight

Most large migrations have this structure. A small number of root causes propagate transitively through the dependency graph, creating the illusion of a massive, evenly-distributed problem. You look at 66 blocked packages and think “this will take months.” But when you trace backward to root causes, you find a handful of packages doing all the damage.

The mistake is treating the migration as a flat list. “66 packages to go, let’s chip away.” That framing makes it feel insurmountable. The right framing is: “8 root blockers, and 2 of them account for 80% of the problem.”

This is the same method from the cascade analysis we ran before committing to the torch fix. That analysis showed Option C would unblock 30 packages while Path A would unblock 1 — a 30x difference in ROI. It saved weeks of wasted engineering on the wrong approach.

This time the same method, applied one level up:

  1. Parse the dependency graph for blocker relationships
  2. Identify root causes — blockers that aren’t themselves blocked
  3. Count transitive impact of each root cause
  4. Rank by ROI (impact / difficulty)
  5. Fix in order

The dep graph isn’t just useful for bazel query. It’s the analytical substrate that makes this kind of reasoning possible. Without a machine-readable graph, you’re guessing. With it, you can do the math — and the math says focus everything on mai_preprocessors and lib/bus.

Two fixes. 26% to 85-90%. That’s the number that matters.