macos x64: crossplatform native build - #6448
Conversation
Alternative to the Rosetta approach: build the Intel target with a NATIVE arm64 toolchain that cross-targets x86_64 (-arch x86_64), linking the x86_64 Homebrew at /usr/local. Compiles run on native arm64 clang (fast); only the output is x86_64. - ConfigureHomebrew.cmake: honor an explicit `-D HOMEBREW_PREFIX=<prefix>` override (falls back to `brew --prefix`), so a native arm64 cmake can be pointed at the /usr/local x86_64 bottles. - build_source.sh: honor a caller-set NPROC to cap build parallelism. - macos/crossplatform-builds/README.md: documents the recipe, the critical gotchas (must force CMAKE_MAKE_PROGRAM to the arm64 ninja, else CMake's find_program grabs the x86_64 ninja from /usr/local and silently compiles under Rosetta; CMAKE_PREFIX_PATH=/usr/local for find_package; x86_64 python shim), and a native-vs-Rosetta comparison. Validated on an Apple M4: native arm64 clang (Code Type: ARM64) cross-built MeshLib core to x86_64; all binaries x86_64, linked against /usr/local x86_64 dylibs; 294/294 unit tests pass under Rosetta. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Idempotent provisioning for the NATIVE (non-Rosetta) x86_64 cross build:
1. native arm64 cmake + ninja at /opt/homebrew (the build driver; installs
only if missing, no force-upgrade)
2. Rosetta 2 (only to RUN the x86_64 output + CMake configure probes; the
compilation itself is native)
3. x86_64 Homebrew at /usr/local (source of x86_64 bottles); --prewarm to
install the formulae now, else CI installs them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fedr
left a comment
There was a problem hiding this comment.
Reviewed the diff, the workflow logic, and the actual CI run for f55500f (run 30714896464).
The headline result is real
x64-cross Release finished in 13m19s vs 51m04s for the GitHub-hosted macos-15-intel x64 job in the same run — a full 729-target build in ~2m08s, all tests passing (unit, C, python sanity, 197 regression). Strong result, worth landing. One caveat on framing: this measures the self-hosted M4 against GitHub's Intel VM, not "native vs Rosetta" as the README frames it.
Blocking
1. skip-bindings: false makes every new guard dead code
matrix.skip-bindings is false on the one entry that sets it and unset (null) everywhere else. !false and !null are both true, so !matrix.skip-bindings is unconditionally true across the whole matrix. All ~13 added && !matrix.skip-bindings clauses, both new if: guards, the two (matrix.skip-bindings && 'OFF') || … ternaries, and the MRTest${{ … ' --no-python-tests' … }} suffix change nothing.
The run log confirms it: MRBind deps installed, MRBind built, C bindings downloaded, Run ./build/Release/bin/MRTest with no --no-python-tests, python sanity + regression tests all ran on the cross entry.
So either delete the machinery (the cross build does bindings, successfully), or flip the flag to true. As-is it's a large, load-bearing-looking construct that is inert — a trap for whoever touches this file next.
2. If the flag does get flipped to true, Wait for C bindings was missed
It still reads if: ${{ inputs.mrbind_c }} while Download C bindings right below it gained the guard — the job would block on generate-c-bindings for an artifact it then skips.
3. The cross build's .pkg gets published to the GitHub release
Traced through the upload-distributions log for this run:
- Distributives_macos-x64-cross (ID: 8823183007, Size: 80930018 …)
for PKG_FILE in … meshlib*.pkg …; do mv "$PKG_FILE" "${PKG_FILE/meshlib/meshlib_v1.0.0.5733}"
gh release upload v1.0.0.5733 $PKG_FILES --clobber
meshlib_v1.0.0.5733_x64-cross.pkg landed on release v1.0.0.5733. On a real release run that puts a second, duplicate Intel package on the public release page. Gate Create Pkg / Upload Macos Distribution off for this entry, or name the artifact outside the meshlib*.pkg glob. (test-distribution doesn't pick it up — its *x64.pkg glob doesn't match — so it's an untested extra asset.)
4. No enable/disable gate for the new entry
The reusable workflow is gated only by build_enable_macos; the new matrix entry has none. It runs on every macOS-enabled run and hard-requires a self-hosted runner carrying crossplatform-build. If that host is down or busy, every PR's macOS CI sits until the 100-minute job timeout. Every other platform has a build_enable_* switch in config.yml.
Related: nothing is removed, so this is +13 min of scarce self-hosted Mac time per run on top of the 51-minute hosted Intel job. The PR body is empty — if the plan is to eventually replace macos-15-intel, that should be written down.
5. MR_PLATFORM is APPLE_arm64 in an x86_64 build
The build log shows platform: APPLE_arm64. The README calls this cosmetic, but it's compiled into the binary and returned by the public getOSNoSpaces() in source/MRMesh/MRSystem.cpp — nothing in MeshLib calls it, so it exists for downstream consumers, which would get a wrong platform string from shipped Intel binaries. Blocking only if this job is meant to replace the hosted x64 job. One-line fix: pass -D MR_PLATFORM=APPLE_x86_64 (already a cache STRING option).
Should fix
6. Nothing verifies the output is actually x86_64
The README's own thesis is that the failure mode is silent (wrong ninja → Rosetta), and separately a dangling shim symlink or a lost env var would yield arm64 binaries packaged as meshlib_x64*.pkg. Add a cheap assertion after Build:
lipo -archs build/Release/bin/libMRMesh.dylib | grep -qx x86_647. The submodule-script swap is an unrelated change affecting all four macOS entries
clone_submodules_linux.sh --skip-prebuilt-thirdparty → clone_submodules_macos.sh adds 10 submodules to every macOS job's checkout. It looks like a genuine latent-bug fix — build_thirdparty.sh needs clip/glad/laz-perf/libE57Format/OpenCTM-git/tinygltf/fastmcpp, which --skip-prebuilt-thirdparty excludes, so macOS would break on any thirdparty cache miss — but it arrived in a commit titled "force thirdparties to rebuild", which reads as temporary debugging. Worth splitting into its own PR so it can land immediately on its own merits.
8. /opt/homebrew is hardcoded in the shim
This run's own PATH shows the native brew at /Users/runner/.homebrew/bin, and install-macos-thirdparty carries a brew-hash cache-key input specifically because "self-hosted ARM fleet members … have homebrew at different paths". ln -sf cheerfully creates dangling symlinks. Derive the native prefix, or at minimum assert the three targets exist.
9. The build_thirdparty.sh changes were never exercised
The thirdparty cache hit in this run, so Build thirdparty libs was skipped. Also, the thirdparty cache key doesn't include the target arch — it's discriminated only indirectly via cache-instance and the brew prefix, which works today but is a sharp edge.
Nits
-
The
-D CMAKE_OSX_ARCHITECTURES=forwarding is redundant. CMake readsCMAKE_OSX_ARCHITECTURESfrom the environment — which is precisely why the un-instrumentedBuild C++ examplesstep (plaincmake, no-D) produced a linkable x86_64 build in this run. Harmless to keep, but the comment claiming it's necessary is wrong. -
Comment blocks run 8 and 7 lines in
build_source.shandbuild_thirdparty.sh— house style is ≤3, with the rationale in the commit/PR. The README already holds the long version. -
ConfigureHomebrew.cmakeloses validation on the override path. TheEXISTScheck now only guards the auto-detected value;-D HOMEBREW_PREFIX=/typosilently yields bogusinclude_directories/link_directories. Move the check outside theIF(NOT HOMEBREW_PREFIX). -
arch: x64-crossflows intocollect-runner-stats'starget_arch, introducing a new arch string that forks macOS x64 history in the stats DB. -
PKG_CONFIG_PATHis set to exactly one directory, replacing whatever the runner had. Also the Python version is hardcoded three times (python3.10×2,python@3.10) whileclang_version_macos.txtexists for the llvm pin. -
provision-runner.sh:- Header says the runner "must also carry the labels
[self-hosted, macos, arm64, build]" — the workflow usescrossplatform-build. softwareupdate --install-rosetta --agree-to-licenseneeds root; underset -ethe script dies on a Rosetta-less runner unless invoked with sudo.- It always wraps
/usr/local/bin/brewinarch -x86_64; the CI shim execs it natively. Worth making consistent, or documenting why the native invocation is safe.
- Header says the runner "must also carry the labels
-
README reads as an experiment writeup rather than maintenance docs. Now that CI has made the choice, the "Native vs Rosetta — which to use / Pick per priority" table doesn't belong. Also:
- "What changed (vs upstream)" lists 2 files; the PR touches 6.
- "their x86_64 output is bit-for-bit equivalent regardless of whether they were built natively or under Rosetta" is unsupported and almost certainly false in general — suggest dropping it.
- It references a
…-rosettabranch that doesn't exist in the repo. NPROC=5and/tmp/mlnative_binare machine-specific leftovers.
…platform-native # Conflicts: # .github/workflows/build-test-macos.yml
Fedr
left a comment
There was a problem hiding this comment.
Re-reviewed at dec8b02. The 8 new commits address nearly everything, and CI is fully green.
Resolved
| # | Finding | How |
|---|---|---|
| 1 | Dead skip-bindings guards |
Cross config moved into its own job macos-build-test-crossplatform; all skip-bindings machinery deleted |
| 2 | Ungated Wait for C bindings |
Moot — bindings always on |
| 4 | No enable/disable gate | New build_macos_crossplatform workflow input + build_enable_macos_crossplatform in config.yml, folded under disable-macos too |
| 5 | MR_PLATFORM=APPLE_arm64 |
-DMR_PLATFORM=APPLE_x86_64 |
| 6 | No arch verification | lipo -archs … | grep -qx x86_64 — plus a real distro test |
| 8 | /opt/homebrew hardcoded |
Discovery loop over /opt/homebrew, ~/.homebrew keyed on an arm64 cmake, with fail-loud existence checks on all five shim targets |
| 9 | Thirdparty cache not arch-keyed | osx-arch= folded into thirdparty-hash |
| 11 | Oversized comment blocks | Trimmed to 2 lines + README pointer |
| 12 | ConfigureHomebrew skipped validation on override |
EXISTS check now covers both paths |
| 14 | PKG_CONFIG_PATH clobbered; python version repeated |
Appends via ${PKG_CONFIG_PATH:+:…}; PYVER=3.10 used throughout |
| 15 | provision-runner.sh |
Label corrected to crossplatform-build, sudo softwareupdate, brew-arch equivalence documented |
| 16 | README | Rewritten as maintenance docs with an explicit "Intent" note |
Finding 3 (pkg publishing) was answered rather than removed: the cross .pkg is now deliberately published and installed/smoke-tested on a real Intel Mac (test-distribution / macos-test (x64-cross, macos-15-intel) — 3m46s, pass). That's stronger validation than the lipo check.
Timings on dec8b02: x64-cross 14m13s vs x64 44m44s. Also worth noting the cache-key change is a no-op for existing jobs — pip-build.yml never sets CMAKE_OSX_ARCHITECTURES, so the shared thirdparty cache isn't invalidated.
Remaining
N1. The original job still carries three cross-only constructs that can never fire
No entry in macos-build-test's matrix sets cross-osx-arch, so build-test-macos.yml lines 96–134 (the 33-line "Configure native x86_64 cross-build environment" step), line 213 (the -DMR_PLATFORM=APPLE_x86_64 ternary) and lines 215–217 (Verify x86_64 output) are all dead. Same class as the old skip-bindings guards, just relocated. Suggest deleting them from macos-build-test.
N2. The gating label doesn't exist yet
gh label list shows only disable-build-{windows,ubuntu-x64,ubuntu-arm64,macos,emscripten,linux-vcpkg}. Until disable-build-macos-crossplatform is created, the gate can never be turned off — which is the entire reason the job was split out. Both spellings survive the ${label/disable-build-/disable-} rewrite in config.yml, but the disable-build- prefix matches the existing family, whereas the README documents disable-macos-crossplatform; worth picking one.
N3. test-distribution's new x64-cross entry isn't gated
macos-test is gated only by inputs.test_macos (= build_enable_macos). Skip the cross build via the new label and there's no *x64-cross.pkg on the release, so gh release download --pattern '*x64-cross.pkg' fails — and it's wrapped in retry.sh, so it burns the retry budget first. Needs a test_macos_crossplatform input plumbed through build-test-distribute.yml. This is exactly the failure the gate was added to prevent, moved one job downstream.
N4. The 300-line duplicated job
Honestly labelled ("Keep the steps in sync"), and GHA makes this genuinely awkward, but every future macOS CI change now has to be applied twice with nothing catching a miss. Worth deciding before merge rather than after: computing strategy.matrix.include from fromJSON() keeps one copy of the steps and preserves the skip-when-runner-down behaviour. If the duplicate stays, a lint that diffs the two step lists would earn its keep.
Nits
provision-runner.shstill hardcodes/opt/homebrewwhile the CI step now also accepts~/.homebrew; a host CI would build on fine gets rejected by the provisioner.- Two Intel
.pkgs on every public release during the transition. Now deliberate and documented — just worth confirming downstream consumers of the release assets tolerate the extra file. - The
clone_submodules_macos.shswap still rides along and affects all macOS jobs. Fine to keep, but it's an independent latent-bug fix (thirdparty submodules missing on a cache miss) worth naming explicitly. - PR body is still empty.
No description provided.