Repository navigation
ci: build once and run the Metal and CPU test passes in parallel - #39
Merged
Merged
Conversation
Split the fork-tests job into parallel jobs. build-wheel and build-cpp build in parallel. build-cpp also runs the C++ tests on the GPU and on the CPU. python-gpu (3 shards) and python-cpu (4 shards) run the Python tests from the uploaded wheel. metal-build-and-test merges the coverage profiles of all jobs, writes the same coverage report, and fails when another job failed. python_shards.py checks the shard map: each python/tests/test*.py file must be in each pass exactly one time. Split files must partition their test IDs. The last job fails when a test ran in two shards. fork-build-cache restores and saves ccache on pull requests and on main, and keeps a cache of the compiled Metal kernels through an xcrun wrapper keyed on the preprocessed source, the arguments and the toolchain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Split test_fast_sdpa and test_quantized into named tests and rest entries in the CPU shards, so that no CPU shard has an entry longer than test_gated_delta_sequential. Put the entries in longest-first order. Save the ccache files and the Metal kernel cache only when the build had misses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The full --version text of clang and metal names the install folder and the OS, and it changed between three macos-26 runner images, so every job started from an empty cache. Key ccache on the clang major.minor version and fall back to a cache of another clang version; ccache keeps compiler_check=content. Key the Metal kernel cache on the metal compiler version and the kernels hash, and fall back only to a cache of the same metal compiler. Each Metal entry key holds the first line of the metal --version text and the SDK build. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hosted macOS runners are a shared pool. Pack the GPU pass into 2 shards and the CPU pass into 3 shards, balanced by the durations that run 39902948153 measured. A run now uses 8 macOS jobs, and at most 6 run at the same time (before: 10 jobs, 8 at the same time). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The preprocessed source of the NAX kernels names the Metal toolchain mount folder, which has a random suffix on each runner. The 10 NAX kernels missed the Metal cache in every warm build (41 of 51 hits). Remove the suffix from the text that the key hashes, and keep the toolchain version. CPU shard 0 was the slowest shard in the cold and warm runs of b72420d (651 s and 470 s of tests). Move test_quantized:rest to CPU shard 1 (221 s). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Result
The Fork Tests workflow takes 13.3 min with warm caches and 21.1 min with cold caches. The old single job took 55 min. All tests run exactly one time in each pass (GPU pass and CPU pass), and the coverage report has the same rows and columns.
Base:
sync/upstream-2026-10-22at 36f043e (merged into this branch). Head: a8c2635. Measured run: 39921651047. Attempt 1 is cold, because all caches of this pull request were deleted before the push. Attempt 2 is warm.Design
build-wheelbuild-cpppython-gpupython-cpuDEVICE=cpu, three entries at a time.metal-build-and-testThe two build jobs start at the same time. The five test shards start when the wheel is ready. A run uses 8 macOS jobs, and at most 6 run at the same time. Before this change, a run used 1 macOS job. The workflow keeps
cancel-in-progressfor pull requests, so a new push cancels the old run and frees its runners. Run 39902085502 shows this: its Python jobs were cancelled before they got a runner.The steps of each job are the same commands as before:
build-macosfor the builds,test-macosfor the distributed test and the extension examples, and the CPU harness for the CPU pass. The GPU Python pass ranpython/tests/run.pyin one process. It now runs one child process for each shard entry, with the same environment (MLX_ENABLE_TF32=0,MLX_ENABLE_CACHE_THRASHING_CHECK=0, the Metal debug variables). Each entry writes its own log. A failed entry prints the end of its log at once. The CPU pass keepsCPU_EXCLUDE(2 tests, unchanged).Files:
.github/workflows/fork-tests.yml: the jobs above..github/actions/fork-build-cache/action.yml: restore and save of ccache and of the Metal kernel cache..github/actions/setup/action.yml: restore only. New inputsccache-fallbackandinstall-macos-deps. New outputccache-save-key..github/scripts/fork-tests/python-shards.json: the shard map..github/scripts/fork-tests/python_shards.py: the guard and the shard runner..github/scripts/fork-tests/metal-cache/xcrun: the Metal kernel cache.fork.yaml: description and globs for the new files.Shard map
An entry is a whole test file (
test_x), one test (test_x.Class.test_y), or the rest of a split file (test_x:rest). Four CPU files are split:test_fast_gated_delta,test_fast_sdpaandtest_quantized, because one of their tests or the whole file takes longer than the other entries. The map is balanced by the per-entry durations that run 39902948153 measured.test_quantized224 s / 240 stest_fast_sdpa90 s / 93 stest_gated_delta_simdgroup525 s / 529 stest_gated_delta_sequential346 s / 403 stest_gated_delta_nax349 s / 372 sThe CPU 0 shard has one test as its floor (
test_gated_delta_simdgroup). A smaller shard is not possible without a change to that test. The speed of a hosted runner changes by up to 2.2 times between runs. For example,test_gated_delta_simdgrouptook 278 s, 315 s, 470 s and 649 s in four runs.The guard
python_shards.pystops the run in three places:check(first step ofbuild-wheel, and again in each shard): everypython/tests/test*.pyfile must be in the map of each pass exactly one time. A file is either one whole-file entry, or named entries with at most one rest entry. An entry that names no file fails.run(each shard): the named tests of a split file must match tests, no test can be in two entries, and a split file without a rest entry must name all its tests. The shard count in the workflow must equal the shard count in the map.verify(inmetal-build-and-test): each shard writes the IDs of the tests that started. The job fails when a shard result is missing or when a test ID is in two shards.A broken copy of the map (a missing file, a duplicate file, a file that is whole and split, an unknown file) gave five
::error::lines and exit code 1.Tests started in each pass (from
verify):The base merge added one C++ scheduler test, so the C++ count is 314. No test was added to or removed from the Python suite. No skip was added.
Caches
The organization blocks
hendrikmuhs/ccache-action. The build jobs use Homebrew ccache andactions/cacheat commit caa2961 (v5.1.0).ccache-fork-tests-wheel-FLAGS-clang21.0-macOS-ARM64-metal-SHA-RUN-ATTEMPTccache-fork-tests-cpp-FLAGS-clang21.0-...metal-32023.883-KERNELS-KIND-RUN-ATTEMPTCFLAGS,CXXFLAGS, the deployment target and the build kind. KERNELS ishashFiles('mlx/backend/metal/kernels/**').compiler_check=content. An object from another compiler is a miss, never a wrong hit.PATHin the build jobs. It caches onlyxcrun -sdk macosx metal -c X.metal -o X.airand the.metalliblink. All other calls go to/usr/bin/xcrun. The key of an.airfile is a SHA-256 of the metal compiler version line, the SDK build, the arguments and the preprocessed source. The key of the.metallibis a SHA-256 of the same version data, the arguments and the hashes of the.airinputs. A cache from another metal version is never restored.--versiontext and the preprocessed line markers name the Metal toolchain mount folder, which has a random suffix on each runner (MetalToolchain-v17.6.109.0.oxYh3g). The keys use the version line only, and the script removes the suffix from the line markers. Before this fix, every runner image had a new key, and the 10 NAX kernels missed in every warm build.Hit rates (
ccache -sand the Metal cache log):Artifacts in one run: wheel 89 MB (uploaded with no compression, because a wheel is a zip file), C++ test binary 14 MB, 6 profile files 0.3 to 0.6 MB each, test ID lists under 6 KB each. Retention is 3 days.
Measured times
Before: run 39885643836 on 1f7e906, one job, 55 min. After: run 39921651047 on a8c2635.
build-cpp(23 s); 3 Python shards: 9.7, 7.8 and 6.6 min per jobOther cold runs of this branch: 17.3 min (27f2a2c, 7 shards), 14.0 min (2268806, 7 shards). Cold time depends on the runner: the cold wheel build step took from 312 s to 576 s.
Coverage (C++ lines, Total row): before 73.36% (on 1f7e906), cold 73.40%, warm 73.39%. The report has the same rows, columns and text, plus one line that says the passes run in parallel jobs. The small changes come from the new base code and from tests with threads.
Open items
test_quantizedfrom GPU 0 to GPU 1 can make the two GPU shards nearly equal (estimate: about 1 min less).if $(grep "\[WARN\]" stderr.log). This check does not fail on[WARN]lines (run 39885643836 printedtest_donation: command not found). The step is unchanged here.🤖 Generated with Claude Code