I’ve been at the DevEx squad for about six weeks. In that time I’ve been heads-down on Bazel migration and Docker image optimization — my own little corner of the monorepo. But I share a squad channel with Zhimin Xiang, and every Thursday he posts an update about migrating GPU tests from Condor to Falcon.
I started reading those updates the way you read a teammate’s standup — quick scan, thumbs up, move on. Somewhere around week three I started actually studying them. Because what Zhimin was doing wasn’t just infrastructure work. It was a pattern. A repeatable, phase-by-phase approach to large-scale migration that I want to steal for my own projects.
So here’s my attempt to reverse-engineer the playbook.
The migration
Quick context. We run GPU tests across multiple clusters. Condor is the old one. Falcon is the new one — better hardware, different orchestration, different failure modes. Migrating GPU tests from Condor to Falcon means every team that runs GPU CI needs to be onboarded, their tests validated, their workflows updated, and the old path eventually deprecated.
This isn’t a “swap the config and pray” migration. GPU tests have container dependencies, runner environments, image build pipelines, cold-start latency, preemption behavior. Everything is load-bearing. You can’t just flip a switch.
Zhimin did it in six weeks. Here’s how it played out.
Week by week
Week 1 — Lay the foundation
The first thing Zhimin did was not migrate anything.
He optimized the build_image workflow so GPU tests only rebuild the pretrain image when Dockerfiles actually change — saving 8-9 minutes per PR for most runs. He set up logging for CI runners in Falcon so failures would be queryable. He validated the GPU runner environment with Chengji to confirm results matched Condor. He built a manual trigger pipeline so a developer could run Falcon GPU tests through CI on demand.
And he wrote a one-pager: “Setting up CI in a new cluster.”
Key PRs this week: #16421 (image hash-based rebuild), #16457 (image push to Falcon ACR), #16657 (manual Falcon GPU trigger).
No tests migrated. Zero teams onboarded. Just infrastructure, validation, and a doc. If you’re impatient, this looks like a slow start. If you’ve ever tried to migrate something and hit cascading failures on day three because you skipped the validation step — it looks like wisdom.
Week 2 — Prove it works
The build_image optimization landed real numbers: GPU test P90 dropped from ~55 minutes to 44 minutes. That’s not Falcon-specific — that’s an improvement to the whole pipeline. But it builds trust. “The person doing this migration is also making things faster” is a good reputation to have when you’re about to ask teams to change their CI.
Rocket integration tests landed in Falcon (#17832) — first real workload on the new cluster. And immediately, bugs. Runner environment incidents (both self-hosted and GitHub runner pools), uv sync not working on Falcon CPU runners.
This is the phase where you find out what’s actually broken. Zhimin found it by putting real work on the system, not by theorizing.
Week 3 — Document and scale
Here’s where I started paying closer attention. Zhimin wrote two docs this week:
- GPU unit test onboarding to Falcon — developer-facing instructions
- CI runner reliability plan — root-cause analysis plus improvement roadmap
He also root-caused GPU runner preemption issues and bumped GitHub larger-runner concurrency from 500 to 1500.
The docs matter more than they look. Without the onboarding doc, every team that migrates requires hand-holding. With it, migration becomes self-service. The reliability plan means failures get fixed systematically instead of one-off. This is the week where the work stopped being “Zhimin’s migration” and started being “a migration anyone could participate in.”
Also this week: he tried to deprecate st_tests (#18411, simpletransformer was retired) and had to revert (#18617) because of remaining code deps. And an upgrade to the GH controller broke wait-for-label. Key PRs: #18669 (nightly Falcon GPU tests), #18872 (integrate Falcon into PR and main), #18422 (refactor run_gpu_tests to merge Falcon logic).
Week 4 — Respond to feedback
Developers asked for their Falcon GPU tests to be decoupled from Condor GPU tests — independent execution with annotations for which mode to run. Zhimin implemented it (#20220, #20248) and updated the docs.
This is a detail that’s easy to miss but it’s important. The migration wasn’t a take-it-or-leave-it transition. When users said “we need this to work differently,” the response was “here’s the implementation and updated docs.” Not “that’s not in scope” or “file a ticket.” The migration absorbed the feedback loop.
Also this week: Azure quota approval was slow (it always is), GPU preemption got resolved, and a root-cause on image tag update failures that broke Rocket. Zhimin was sick one day. The work didn’t stop.
Week 5 — Productionize
Three things happened this week that signal the transition from “it works” to “it’s production”:
- CPU DinD runners stood up in Falcon — new runner types, not just GPU
- Integration test onboarding doc written — extending the playbook beyond unit tests
- Cold-start latency design doc — a design document about reducing container init time, shared with HPC and scheduling teams
The integration test doc is the second generation of documentation. Week 3’s doc covered unit tests. Week 5’s doc covers integration tests. The pattern scales by documenting each new capability as it lands.
Also: Rocket GPU test time dropped by ~20 minutes by avoiding redundant image builds (#21370). Performance wins that come from actually operating the system, not from upfront design. Key PRs: #20974 (Launch v2 daily tests on Falcon), #20927 (Rocket eval on CPU DinD runner).
Week 6 — Ship at scale
mai-kernels GPU tests onboarded (#22588 annotation, #23117 PR onboarding, #23556 main CI required) — roughly 10% of all GPU tests — moving through nightly, then PR review, then main CI required workflow. That’s the full ramp. Not “we turned it on and hoped.” Nightly first, then gated PR, then required. Three stages of confidence.
Rocket GPU test image build time: 19 min → 5 min on main CI. 21 min → 13 min on PR-required CI. wait-for-label optimized to skip for CPU-only PRs (#23239), saving unnecessary spin-up across ~1K PRs per week.
Six weeks in, the system is handling real load, multiple teams are onboarded, and there’s a documentation trail that means the next cluster migration won’t start from zero.
The pattern
Okay, so what did I actually learn from watching this? Here’s what I think the phases are.
Phase 1: Instrument before you migrate
Zhimin’s first week was logging, validation, and optimization. He didn’t touch a single team’s tests. He made the platform ready — queryable logs, confirmed runner parity, one manual trigger pipeline for testing.
The instinct is to start migrating immediately. “Let’s get one team over and see what happens.” That works for small migrations. For something touching GPU tests across multiple teams, the first broken test you can’t debug because you don’t have logging will cost you more time than a week of instrumentation would have saved.
The one-pager doc is also phase 1. Write down how to set up CI in a new cluster before you forget the steps. Future-you (or future-teammate) will need this.
Phase 2: Prove with one real workload
Week 2 — Rocket integration tests. One real team, one real workload. Every bug this surfaced (runner incidents, uv sync failures) was a bug that would have hit every subsequent team. Finding them with one team is cheap. Finding them with ten teams is chaos.
This phase is validation through use, not validation through testing. You can write all the test suites you want — they won’t catch “this runner type doesn’t have the right glibc” or “uv sync fails because of a CPU runner image mismatch.” Only real workloads surface real problems.
Phase 3: Document for self-service
Week 3’s onboarding doc is the leverage point. Before the doc, migrating a team requires Zhimin. After the doc, migrating a team requires the team reading instructions.
This is the phase most infra engineers skip. It’s the least fun part. But it’s the difference between a migration that scales linearly with one person’s time and a migration that scales with the number of teams who can read a doc.
The reliability plan is the internal version of this — it’s documentation for yourself, making failure modes legible so you fix them systematically instead of playing whack-a-mole.
Phase 4: Absorb feedback, extend the surface
Week 4’s decoupling feature request and week 5’s integration test onboarding are both extensions. The migration isn’t just “move existing tests.” It’s “make the new platform handle the things people actually need.”
The feedback loop is critical. If your migration plan is rigid — “we’re moving tests from A to B, period” — you’ll lose adoption when teams say “B doesn’t support X.” The successful pattern is to treat feedback as scope, not as resistance.
Phase 5: Ramp through confidence stages
Week 6’s mai-kernels onboarding went nightly → PR review → main CI required. Three stages, each building confidence before the next.
This is not unique to Zhimin — it’s a standard progressive rollout. But it matters that he did it for each team, not just once for the whole migration. Every team’s tests have different failure modes. Nightly catches the environmental issues. PR-gated catches the performance issues. Required catches the reliability issues. Skipping stages saves days and costs weeks.
What was hard
The lowlights across six weeks paint a consistent picture:
- Azure quota process is slow — you wait days for machine types you need today
- GitHub communication is frustrating — slow responses to incidents, runner pool issues
- Oncall interruptions are constant — controller upgrades break things, bad
uvreleases break things, flaky tests break things - Deprecation is harder than creation — trying to remove
st_testsfailed because of lingering deps - Runner preemption is real — k8s can preempt your GPU runners mid-test
None of these were blockers. All of them were friction that required parallel effort. Zhimin was simultaneously building the migration, running oncall, fixing incidents, and responding to developer requests. That’s not a sign of poor planning — it’s a sign that infrastructure work at this scale always has concurrent interrupt-driven work. The playbook has to account for that.
The docs trail
Five documents in six weeks:
- “Setting up CI in a new cluster” (Week 1) — Before anything migrates
- “GPU unit test onboarding to Falcon” (Week 3) — After validation, before scale
- “CI runner reliability plan” (Week 3) — After first production incidents
- “Integration test onboarding” (Week 5) — When scope extends beyond unit tests
- “Cold-start latency design doc” (Week 5) — When the next bottleneck becomes visible
There’s a rhythm here. Each doc lands at the moment it becomes necessary — not before (speculative docs rot), not after (missing docs become tribal knowledge). The docs aren’t an afterthought. They’re load-bearing infrastructure.
If Zhimin left the squad tomorrow, someone could pick up the migration by reading these five documents. That’s the test.
The playbook
Here’s what I’m taking away. Next time I need to migrate something at this scale — whether it’s Bazel targets, CI pipelines, or anything else — this is the checklist:
Week 1-2: Foundation
- Set up observability (logging, metrics) on the target system before migrating anything
- Validate environment parity — confirm the new system produces the same results as the old one
- Write the “how to set up X” doc while the steps are fresh
- Optimize the critical path (build time, test time) early — it buys goodwill and reveals the system’s shape
Week 2-3: Single-team validation
- Onboard one team with real workloads — not test workloads
- Track every failure. These are your bug backlog for the platform, not the team’s problem.
- Write the reliability plan after you’ve seen real failures, not before
Week 3-4: Self-service documentation
- Write the onboarding doc — the one that means teams can migrate without you
- Respond to feature requests by implementing and updating the doc
- Decouple old and new paths so teams can run both during transition
Week 4-6: Scale and ramp
- Onboard teams through progressive confidence stages (nightly → gated → required)
- Extend documentation for each new capability (integration tests, new runner types, etc.)
- Continuously optimize — every week should have a performance improvement
- Write the design doc for the next bottleneck when it becomes visible
Throughout:
- Expect oncall interruptions to consume 20-30% of your time — budget for it
- Track metrics (build times, test times, P90s) — they’re your evidence that the migration is worth doing
- Deprecation is a separate project from migration — don’t assume the old path disappears automatically
Wait — I was part of this
Writing this post made me remember something. Around the same time Zhimin was hitting image build bottlenecks in Week 2 — “no easy way to build our image locally and building the image in CI is very slow” — I was hitting the exact same wall from the other side.
Early February. Masatoshi and I were experimenting with faster Docker image builds on Falcon. The problem was DinD mode: cold Docker cache every run, ~17 minute image pulls, no layer reuse between jobs. Every CI run started from scratch. If you’ve ever watched a progress bar crawl through docker pull for the fourth time that day, you know the feeling.
Here’s what we did:
Feb 2-3: Masa created the cicd node pool in the Falcon cluster — dedicated E32ds_v5 nodes with local NVMe storage, separate from the general services pool (mango #8933, #8945, #9028).
Feb 4: I built two runner configurations on top of it:
- mango #9017 — DinD runner with
hostPathcache. The idea: Docker’s layer cache persists on the node’s NVMe disk across pod restarts, so repeated builds skip layers instead of pulling everything fresh. - mango #9073 — Simple CPU runner (Phase 1 — validate the hardware works before adding DinD complexity).
Feb 1: I also tested switching GPU tests from DinD to K8s runner mode (yolo #16791) — K8s mode uses kubelet image caching instead of DinD, which could eliminate the ~17 min image pull overhead entirely.
The result? With the hostPath cache, pretraining image build time dropped from ~52 minutes to ~9 minutes. That was the proof-of-concept number we needed.
All three of my PRs were closed. Not merged. Closed.
At the time I was confused — did the work not matter? Looking back now, I can see what happened. My PRs were the experiment phase. Masa took the learnings and iterated: E32ds_v5 → L-series VMs with better local NVMe (mango #9987), then a dedicated cicd-builder node pool (mango #10077). Zhimin built his runner configurations and GPU test migration on top of that infrastructure.
The chain looks like this:
My DinD + hostPath experiment (52→9 min proof-of-concept)
└─→ Masa's L-series node pool (productionized infra)
└─→ Zhimin's CPU DinD runners on Falcon (Week 5)
└─→ Zhimin's image build optimization (19→5 min, Week 6)
When Zhimin reports “Rocket GPU test image build time: 19 min → 5 min” — the reason that number was even possible is because the node pool underneath has fast local NVMe storage and persistent Docker cache. That’s the infrastructure Masa and I proved was worth building.
I didn’t know this at the time. I opened two PRs, they got closed, and I moved on to Bazel work. It took writing this blog post — literally tracing Zhimin’s timeline and then going “wait, I was doing something related back then” — for me to see how the pieces fit.
This is the thing about infrastructure work on a team. Your experiment becomes someone else’s foundation. The closed PR isn’t wasted work — it’s the archaeological first layer. But you’ll never know that unless someone traces the timeline. Which, I suppose, is another argument for leaving a trail.
The meta-lesson
I spent my first six weeks on this squad doing deep technical work — Bazel migration, Docker optimization, system torch toolchains. All worthwhile. But watching Zhimin work taught me something about the shape of infrastructure projects that I couldn’t learn from my own work.
The code is the easy part. The hard part is the sequencing — knowing that instrumentation comes before migration, that one-team validation comes before documentation, that documentation comes before scale. Getting the order wrong doesn’t mean you fail. It means you succeed slower, with more pain, and the next person to do it learns nothing from your work because there’s no trail to follow.
Zhimin left a trail. I’m going to leave one too.
Appendix: The PR trail
Zhimin merged 77 PRs in the yolo repo during this period. Here are the milestone ones, grouped by phase:
Week 1 — Foundation:
- #15715 — Workflow to build runner image and nvidia runtime image for Falcon runners
- #16421 — Tag image with hash of Dockerfiles, reuse during GPU tests
- #16457 — Build image and push to Falcon ACR for Falcon GPU tests
- #16657 — Allow manually triggering Falcon GPU test workflow
Week 2 — Prove it works:
- #17832 — Initial version of Rocket Integration test on Falcon
- #17680 — Build custom GitHub runner image with amd64 architecture
Week 3 — Document and scale:
- #18422 — Refactor run_gpu_tests.yml to merge Falcon logic into existing logic
- #18669 — Nightly job for Falcon GPU tests with annotation
- #18805 — Allow skipping Falcon GPU tests with annotation
- #18872 — Integrate gpu-tests-falcon into PR and main
- #18411 → #18617 — st_tests deprecation attempt → revert
Week 4 — Respond to feedback:
- #20220 — Allow running Falcon GPU tests only with annotation
- #20248 — Support annotation-based exec mode for Falcon tests
Week 5 — Productionize:
- #20974 — Switch to use cpu-dind-falcon for Daily LauncherV2 Tests
- #20927 — Switch Rocket evaluate test to CPU DinD runner
- #21370 — Do not always build rocket image for Rocket GPU test
- #21812 — Add Slack alerts for nightly Falcon GPU tests
Week 6 — Ship at scale:
- #22588 — Annotate mai-kernels tests to run in Falcon nightly
- #23117 — Onboard mai-kernels test to Falcon in PR
- #23556 — Run Falcon GPU tests in main required CI
- #23239 — Skip wait-for-label completely when not needed
- #23122 — Reduce shard config for Falcon GPU tests
The underground layer (my + Masa’s infra experiment):
- mango #8933 — Allow up to 300 nodes in falcon-phx-ca services pool (Masa)
- mango #8945 — Create cicd node pool in falcon-phx-ca (Masa)
- mango #9028 — Fix cicd node pool config (Masa)
- mango #9017 — DinD runner with hostPath cache on cicd node pool (me, closed)
- mango #9073 — Simple CPU runner on cicd node pool (me, closed)
- yolo #16791 — Switch GPU tests from DinD to K8s runner mode (me, closed)
- mango #9987 — Use L-series VMs for cicd node pool (Masa)
- mango #10077 — Create cicd-builder node pool in falcon-phx-ca (Masa)
Where this played out: sq-supercomputing-devex (DevEx squad channel) and support-ci-cd (CI/CD support channel).