From 4dfef443db9fe2a7abcef0274f774100781f6b0e Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Wed, 16 Sep 2026 19:49:45 -0400 Subject: [PATCH 1/6] macOS: take OpenMP from the compiler's own tree when it has one An LLVM that builds the openmp runtime -- Homebrew's llvm formula, and the conda-forge clang packages -- installs libomp.dylib and omp.h inside its own tree, and `-fopenmp` links THAT copy regardless of what else is on the link line. The Darwin block nonetheless always added a libomp of its own, from $CONDA_PREFIX or from $(brew --prefix)/opt/libomp, so building with such a compiler put two same-named runtimes into _core_cpu.so. The result is an abort at the first parallel region: OMP: Error #15: Initializing libomp.dylib, but found libomp.dylib already initialized. [ 8] libomp.dylib __kmpc_fork_call + 52 [ 9] _core_cpu.cpython-314-darwin.so sbd::GenerateExcitation... Resolve the compiler before choosing OpenMP rather than merely reporting it afterwards, and prefer a runtime shipped beside that compiler. Only when there is none -- Apple clang, which has no OpenMP at all -- fall through to conda and then Homebrew as before. BLAS stays a separate question: the compiler tree carries no OpenBLAS, so that continues to come from conda or Homebrew. This was not reachable from this repo's own CI, whose macOS cells build with Apple clang and so take the third path. It reproduces in Qiskit/qiskit-addon-sqd#367, which pins CC/CXX to Homebrew LLVM in order to build another extension, and it is not fixable from that side: the paths were hardcoded here and ignored CPPFLAGS/LDFLAGS entirely. Verified on Linux (tox -e py) that the non-Darwin path is untouched, and unit-tested the new detection against a synthetic LLVM keg, Apple clang, and a tree with a header but no library. Assisted-by: Claude Opus 5 --- setup.py | 112 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 86 insertions(+), 26 deletions(-) diff --git a/setup.py b/setup.py index 63bff45..9f0a889 100644 --- a/setup.py +++ b/setup.py @@ -473,6 +473,53 @@ def _homebrew_prefix(): return None +def _resolve_darwin_cxx(): + """(path, version_line) of the C++ compiler this build will actually use. + + distutils takes CC/CXX from the environment, else from sysconfig -- where + conda records a bare 'clang++' that is resolved through PATH, so a Homebrew + LLVM silently wins over both Apple clang and a conda toolchain. Knowing + which one it is decides where OpenMP comes from, so this is resolved before + the libomp search rather than merely reported afterwards. + """ + import shutil + cxx = (os.environ.get('CXX') or sysconfig.get_config_var('CXX') + or 'clang++').split()[0] + path = shutil.which(cxx) or cxx + try: + version = subprocess.check_output( + [path, '--version'], universal_newlines=True, + stderr=subprocess.STDOUT).splitlines()[0] + except Exception: + version = '(version unknown)' + return path, version + + +def _compiler_openmp_prefix(cxx_path): + """Prefix of an OpenMP runtime shipped alongside cxx_path, or None. + + An LLVM that builds the openmp runtime -- Homebrew's llvm formula, and the + conda-forge clang packages -- installs libomp.dylib and omp.h into its own + tree, and `-fopenmp` links THAT copy. Adding a second libomp from elsewhere + then puts two same-named runtimes in one process, which aborts at the first + parallel region with "OMP: Error #15". So when the compiler brings its own, + that is the one to build against. + + Looks one directory up from bin/, i.e. /lib/libomp.dylib next to + /include/omp.h. Returns the prefix only when BOTH are present, + since the header is needed to compile and the library to link. + """ + if not cxx_path or not os.path.isabs(cxx_path): + return None + prefix = os.path.dirname(os.path.dirname(cxx_path)) + if not prefix or prefix == os.sep: + return None + has_header = os.path.exists(os.path.join(prefix, 'include', 'omp.h')) + has_lib = any(os.path.exists(os.path.join(prefix, 'lib', name)) + for name in ('libomp.dylib', 'libomp.a')) + return prefix if (has_header and has_lib) else None + + def find_nvidia_hpc_sdk(): nvhpc_home = os.environ.get('NVHPC_HOME', None) if nvhpc_home: @@ -750,13 +797,43 @@ def detect_gpu_toolchain(): if build_cpu: print("\nConfiguring CPU backend (_core_cpu)") if platform.system() == 'Darwin': + # Which compiler is used decides where OpenMP may come from, so resolve + # it first. Printing it is also the difference between a reproducible + # build and a mystery, since a bare 'clang++' from sysconfig is resolved + # through PATH. + _cxx_path, _cxx_ver = _resolve_darwin_cxx() + print(f"Darwin C++ compiler: {_cxx_path}\n" + f" {_cxx_ver} (pin it with CC/CXX)") + # macOS has no system OpenMP, so libomp comes from a package manager. - # Prefer the conda env when it has one: those are the libraries actually - # LOADED at import time (resolved via the python executable's - # @loader_path/../lib), so building against Homebrew's copies instead - # means compiling against different libraries than the process runs on. + # + # Order matters, and it is about which libomp ends up in the PROCESS, + # not merely which one satisfies the compile: + # + # 1. The compiler's own runtime, when it has one. `-fopenmp` links that + # copy no matter what else is on the link line, so naming a second + # libomp here is how you get two same-named runtimes in one process + # and an "OMP: Error #15" abort at the first parallel region. + # 2. Otherwise the conda env, whose libraries are the ones actually + # LOADED at import time (resolved via the python executable's + # @loader_path/../lib). + # 3. Otherwise Homebrew's standalone libomp, which is what Apple clang + # needs, having no OpenMP of its own. conda_prefix = os.environ.get('CONDA_PREFIX') - if conda_prefix and os.path.exists( + compiler_omp = _compiler_openmp_prefix(_cxx_path) + if compiler_omp: + omp_inc = os.path.join(compiler_omp, 'include') + omp_lib = os.path.join(compiler_omp, 'lib') + # BLAS is a separate question from OpenMP: the compiler tree has no + # OpenBLAS, so keep taking that from conda or Homebrew. + if conda_prefix and os.path.isdir(os.path.join(conda_prefix, 'lib')): + openblas_lib = os.path.join(conda_prefix, 'lib') + else: + openblas_lib = os.path.join(_homebrew_prefix() or '/opt/homebrew', + 'opt', 'openblas', 'lib') + print(f"Darwin: libomp from the compiler's own tree {compiler_omp}\n" + f" BLAS from {openblas_lib}") + elif conda_prefix and os.path.exists( os.path.join(conda_prefix, 'include', 'omp.h')): omp_inc = os.path.join(conda_prefix, 'include') omp_lib = openblas_lib = os.path.join(conda_prefix, 'lib') @@ -775,7 +852,8 @@ def detect_gpu_toolchain(): # Fail here with the fix, rather than 100 lines later with # "'omp.h' file not found" from the middle of a compile. print("Error: no OpenMP runtime found on this macOS host.\n" - f" Looked in $CONDA_PREFIX/include and {omp_inc}.\n" + f" Looked beside {_cxx_path}, in $CONDA_PREFIX/include, " + f"and in {omp_inc}.\n" " Apple clang ships without OpenMP, so install one:\n" " conda install -c conda-forge llvm-openmp (preferred)\n" " brew install libomp") @@ -783,23 +861,6 @@ def detect_gpu_toolchain(): print(f"Darwin: libomp and BLAS from Homebrew at {brew_prefix} " "(no conda libomp found)") - # Say WHICH clang is compiling. distutils takes CC/CXX from the - # environment, else from sysconfig -- where conda records a bare - # 'clang++' that is resolved through PATH, so a Homebrew LLVM silently - # wins over both Apple clang and a conda toolchain. Printing it is the - # difference between a reproducible build and a mystery. - import shutil - _cxx = (os.environ.get('CXX') or sysconfig.get_config_var('CXX') - or 'clang++').split()[0] - _cxx_path = shutil.which(_cxx) or _cxx - try: - _cxx_ver = subprocess.check_output( - [_cxx_path, '--version'], universal_newlines=True, - stderr=subprocess.STDOUT).splitlines()[0] - except Exception: - _cxx_ver = '(version unknown)' - print(f"Darwin C++ compiler: {_cxx_path}\n" - f" {_cxx_ver} (pin it with CC/CXX)") cpu_compile_args = [ '-DSBD_TRADMODE', '-DOMPI_SKIP_MPICXX', @@ -813,9 +874,8 @@ def detect_gpu_toolchain(): # Not extra_link_args: that carries a bare `-fopenmp`, which Apple # clang rejects at link time the same way it does when compiling. # Re-derive the rpath entries over the libomp/BLAS directories chosen - # above -- whether they came from conda or Homebrew -- so those dylibs - # resolve at import time. Apple's linker wants `-rpath`, not GNU ld's - # `--rpath`. + # above, so those dylibs resolve at import time. Apple's linker wants + # `-rpath`, not GNU ld's `--rpath`. cpu_link_args = [f'-L{d}' for d in (omp_lib, openblas_lib)] cpu_link_args += [f'-Wl,-rpath,{d}' for d in cpu_lib_dirs] else: From 648b744733ca6236bcfc6f6f99586b5d21b56cb8 Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Thu, 17 Sep 2026 10:28:24 -0400 Subject: [PATCH 2/6] Fix the OpenMP detection: omp.h lives in the clang resource directory The previous commit looked for the compiler's omp.h at /include, so detection returned nothing for the very compiler it was written for and the build fell through to Homebrew's standalone libomp -- the two-runtime abort it was meant to prevent. Confirmed in Qiskit/qiskit-addon-sqd#367, where the diagnostic reported "libomp and BLAS from Homebrew" while CXX was /opt/homebrew/opt/llvm/bin/clang++. An LLVM installs omp.h into its clang resource directory (lib/clang//include), not /include; Homebrew's llvm formula compiles `#include ` in its own test with no -I at all. Only libomp.dylib lands in /lib. So ask the driver via `clang++ -print-resource-dir` rather than guessing, which also avoids hardcoding an LLVM version into the path. Return the include and library directories separately, since they are no longer under one prefix. Unit-tested against synthetic trees for the Homebrew-LLVM layout, Apple clang, a tree with libomp but no omp.h, and a compiler that does not understand -print-resource-dir. Linux (tox -e py) still passes. Assisted-by: Claude Opus 5 --- setup.py | 54 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/setup.py b/setup.py index 9f0a889..bfb04cd 100644 --- a/setup.py +++ b/setup.py @@ -495,29 +495,38 @@ def _resolve_darwin_cxx(): return path, version -def _compiler_openmp_prefix(cxx_path): - """Prefix of an OpenMP runtime shipped alongside cxx_path, or None. +def _compiler_openmp(cxx_path): + """(include_dir, lib_dir) of an OpenMP shipped with cxx_path, or None. An LLVM that builds the openmp runtime -- Homebrew's llvm formula, and the - conda-forge clang packages -- installs libomp.dylib and omp.h into its own - tree, and `-fopenmp` links THAT copy. Adding a second libomp from elsewhere - then puts two same-named runtimes in one process, which aborts at the first - parallel region with "OMP: Error #15". So when the compiler brings its own, - that is the one to build against. - - Looks one directory up from bin/, i.e. /lib/libomp.dylib next to - /include/omp.h. Returns the prefix only when BOTH are present, - since the header is needed to compile and the library to link. + conda-forge clang packages -- carries a libomp.dylib in its own tree, and + `-fopenmp` links THAT copy. Adding a second libomp from elsewhere then puts + two same-named runtimes in one process, which aborts at the first parallel + region with "OMP: Error #15". So when the compiler brings its own, that is + the one to build against. + + Do not guess the layout. omp.h is installed into the clang RESOURCE + directory (lib/clang//include), not /include -- Homebrew's own + formula test compiles `#include ` with no -I at all -- while + libomp.dylib does land in /lib. Ask the driver for the resource + directory rather than hardcoding a version number into the path. """ if not cxx_path or not os.path.isabs(cxx_path): return None - prefix = os.path.dirname(os.path.dirname(cxx_path)) - if not prefix or prefix == os.sep: + lib_dir = os.path.join(os.path.dirname(os.path.dirname(cxx_path)), 'lib') + if not any(os.path.exists(os.path.join(lib_dir, name)) + for name in ('libomp.dylib', 'libomp.a')): return None - has_header = os.path.exists(os.path.join(prefix, 'include', 'omp.h')) - has_lib = any(os.path.exists(os.path.join(prefix, 'lib', name)) - for name in ('libomp.dylib', 'libomp.a')) - return prefix if (has_header and has_lib) else None + try: + resource_dir = subprocess.check_output( + [cxx_path, '-print-resource-dir'], universal_newlines=True, + stderr=subprocess.DEVNULL).strip() + except Exception: + return None + inc_dir = os.path.join(resource_dir, 'include') + if not os.path.exists(os.path.join(inc_dir, 'omp.h')): + return None + return inc_dir, lib_dir def find_nvidia_hpc_sdk(): @@ -820,10 +829,9 @@ def detect_gpu_toolchain(): # 3. Otherwise Homebrew's standalone libomp, which is what Apple clang # needs, having no OpenMP of its own. conda_prefix = os.environ.get('CONDA_PREFIX') - compiler_omp = _compiler_openmp_prefix(_cxx_path) + compiler_omp = _compiler_openmp(_cxx_path) if compiler_omp: - omp_inc = os.path.join(compiler_omp, 'include') - omp_lib = os.path.join(compiler_omp, 'lib') + omp_inc, omp_lib = compiler_omp # BLAS is a separate question from OpenMP: the compiler tree has no # OpenBLAS, so keep taking that from conda or Homebrew. if conda_prefix and os.path.isdir(os.path.join(conda_prefix, 'lib')): @@ -831,8 +839,10 @@ def detect_gpu_toolchain(): else: openblas_lib = os.path.join(_homebrew_prefix() or '/opt/homebrew', 'opt', 'openblas', 'lib') - print(f"Darwin: libomp from the compiler's own tree {compiler_omp}\n" - f" BLAS from {openblas_lib}") + print(f"Darwin: libomp from the compiler's own tree\n" + f" headers {omp_inc}\n" + f" library {omp_lib}\n" + f" BLAS {openblas_lib}") elif conda_prefix and os.path.exists( os.path.join(conda_prefix, 'include', 'omp.h')): omp_inc = os.path.join(conda_prefix, 'include') From 5d419fc92638040ca98b12d83d0ccee660938238 Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Fri, 18 Sep 2026 10:46:58 -0400 Subject: [PATCH 3/6] Cover the compiler-owned-libomp path on macOS The preceding commits teach setup.py to take OpenMP from the compiler's own tree when it has one, but nothing here exercises that: every macOS cell uses Apple clang, which has no OpenMP of its own and so only ever takes the Homebrew-libomp branch. That gap is not hypothetical -- the first version of the detection looked for omp.h at /include, found nothing, silently fell back to Homebrew, and still built and still passed. It took a downstream repo pinning CC/CXX to Homebrew LLVM to notice. Add one macOS cell that pins Homebrew LLVM, whose clang carries a libomp of its own. `cc` joins the matrix identity rather than being an extra key on an existing cell, because an include entry matching an existing os/python-version pair merges into it -- which would have replaced the Apple clang coverage for 3.14 instead of adding to it. Verified by expanding the matrix: 11 cells, with macos/3.14 present under both cc: system and cc: llvm. A passing suite alone would not protect this path, since the fallback compiles and tests clean and only aborts once another OpenMP consumer shares the process. So also assert, on every macOS cell, that exactly one OpenMP runtime is mapped after the backend loads -- read from dyld, and after get_backend(), since backends load lazily on first use. Assisted-by: Claude Opus 5 --- .github/workflows/test_latest_versions.yml | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/.github/workflows/test_latest_versions.yml b/.github/workflows/test_latest_versions.yml index 1944108..6230432 100644 --- a/.github/workflows/test_latest_versions.yml +++ b/.github/workflows/test_latest_versions.yml @@ -25,6 +25,22 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + include: + # Every other macOS cell uses Apple clang, which has no OpenMP of its + # own and so exercises only the Homebrew-libomp path. This one pins + # Homebrew LLVM to cover the other branch -- a compiler that ships its + # own libomp -- which is the case setup.py has to detect and prefer. + # Without it that path has no coverage here at all. + # + # `cc` is part of the identity rather than an extra key on an existing + # cell: an include entry matching an existing os/python-version pair + # merges into it instead of adding a job, which would have replaced the + # Apple clang coverage for that version rather than adding to it. Every + # other cell therefore carries cc: system. + - os: macos-latest + python-version: "3.14" + cc: llvm + cc: [system] steps: - uses: actions/checkout@v7 with: @@ -48,6 +64,21 @@ jobs: # default library path. run: | brew install open-mpi libomp openblas + - name: Pin Homebrew LLVM as the compiler (macOS) + if: matrix.cc == 'llvm' + shell: bash + # Homebrew's llvm builds the openmp runtime, so its clang carries a + # libomp of its own. setup.py must notice and use that one: adding a + # second from the standalone formula would put two same-named runtimes in + # one process, which aborts at the first parallel region with + # "OMP: Error #15". + run: | + brew install llvm + LLVM_PREFIX="$(brew --prefix llvm)" + { + echo "CC=${LLVM_PREFIX}/bin/clang" + echo "CXX=${LLVM_PREFIX}/bin/clang++" + } >> "$GITHUB_ENV" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -62,3 +93,34 @@ jobs: # and the MPI tests want the runner's cores to themselves. run: | tox -e py,notebook,mpi + - name: Assert exactly one OpenMP runtime is loaded (macOS) + if: runner.os == 'macOS' + shell: bash + # A passing suite is not enough to protect the compiler-owned-libomp + # path: an earlier version of that detection silently found nothing and + # fell back to the standalone formula, which still built and still passed + # here -- it only aborted once another OpenMP consumer shared the process. + # Count the runtimes actually mapped after the backend loads instead. + run: | + .tox/py/bin/python - <<'PY' + import ctypes, sys + + libc = ctypes.CDLL(None) + libc._dyld_image_count.restype = ctypes.c_uint32 + libc._dyld_get_image_name.restype = ctypes.c_char_p + libc._dyld_get_image_name.argtypes = [ctypes.c_uint32] + + import sbd + sbd.get_backend() # backends load lazily, on first use + + omp = sorted({ + n for i in range(libc._dyld_image_count()) + if (n := libc._dyld_get_image_name(i).decode("utf-8", "replace")) + and any(k in n.lower() for k in ("libomp", "libiomp", "libgomp")) + }) + for name in omp: + print(f" {name}") + if len(omp) != 1: + sys.exit(f"expected exactly 1 OpenMP runtime, found {len(omp)}") + print("OK: one OpenMP runtime") + PY From 5f86be071f5b1642c270a446d87ffba9652c6c9e Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Fri, 18 Sep 2026 15:52:45 -0400 Subject: [PATCH 4/6] macOS: resolve the compiler path, and report what the OpenMP probe found The new cc: llvm CI cell aborted with "OMP: Error #15" on its first run: the build took OpenMP from /opt/homebrew/opt/libomp while -fopenmp linked /opt/homebrew/Cellar/llvm/23.1.0/lib/libomp.dylib. So _compiler_openmp() returned None for the very compiler it exists to detect, and the fallback then picked a second runtime. CC/CXX did reach the build -- tox's passenv covers the .pkg env, verified locally -- so the compiler was right and the probe was wrong. The paths differ in form: CC/CXX is Homebrew's opt/ alias (/opt/homebrew/opt/llvm/bin/clang++) while -print-resource-dir answers with the real Cellar path. Resolve the compiler with realpath before deriving lib_dir or invoking it, so both forms agree. Also print what the probe found -- compiler, lib_dir, resource_dir, and whether each of libomp and omp.h is present -- rather than returning a silent None. That silence is what made this expensive to chase: the fallback compiles cleanly and passes this repo's own suite, and only aborts once a second OpenMP consumer shares the process. Tested against synthetic kegs reached both through an opt/ symlink and by real path. Linux unaffected (tox -e py). Whether the symlink was the whole cause is not yet established -- the runner's probe output will say, since it now reports each check. Assisted-by: Claude Opus 5 --- setup.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index bfb04cd..68c9318 100644 --- a/setup.py +++ b/setup.py @@ -513,18 +513,30 @@ def _compiler_openmp(cxx_path): """ if not cxx_path or not os.path.isabs(cxx_path): return None - lib_dir = os.path.join(os.path.dirname(os.path.dirname(cxx_path)), 'lib') - if not any(os.path.exists(os.path.join(lib_dir, name)) - for name in ('libomp.dylib', 'libomp.a')): - return None + # Resolve symlinks first: CC/CXX is commonly Homebrew's opt/ alias + # (/opt/homebrew/opt/llvm/bin/clang++), while -print-resource-dir answers + # with the real Cellar path. Comparing or joining the two forms without + # normalising invites mismatches. + cxx_real = os.path.realpath(cxx_path) + lib_dir = os.path.join(os.path.dirname(os.path.dirname(cxx_real)), 'lib') + have_lib = any(os.path.exists(os.path.join(lib_dir, name)) + for name in ('libomp.dylib', 'libomp.a')) try: resource_dir = subprocess.check_output( - [cxx_path, '-print-resource-dir'], universal_newlines=True, + [cxx_real, '-print-resource-dir'], universal_newlines=True, stderr=subprocess.DEVNULL).strip() - except Exception: - return None - inc_dir = os.path.join(resource_dir, 'include') - if not os.path.exists(os.path.join(inc_dir, 'omp.h')): + except Exception as exc: + resource_dir = None + print(f"Notice: {cxx_real} -print-resource-dir failed: {exc!r}") + inc_dir = os.path.join(resource_dir, 'include') if resource_dir else None + have_header = bool(inc_dir) and os.path.exists(os.path.join(inc_dir, 'omp.h')) + # Say what was found either way: a silent None here sends the build to a + # different OpenMP, which still compiles and still passes its own tests, and + # only aborts once another OpenMP consumer shares the process. + print(f"Darwin OpenMP probe: compiler={cxx_real}\n" + f" lib_dir={lib_dir} libomp={have_lib}\n" + f" resource_dir={resource_dir} omp.h={have_header}") + if not (have_lib and have_header): return None return inc_dir, lib_dir From b38b44b73000eb4c1cb1c5628028477349d7730f Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Fri, 18 Sep 2026 16:06:32 -0400 Subject: [PATCH 5/6] Print the Darwin OpenMP decision to stderr so tox shows it The cc: llvm cell still aborts, and the probe added in the previous commit printed nothing at all in CI -- so it said nothing about why. tox hides the build backend's stdout at default verbosity; the wheel is built through pyproject_api in the .pkg env, and only stderr comes through. Route the probe and the other Darwin build-decision messages (resolved compiler, which OpenMP was chosen, the -print-resource-dir failure notice) to stderr. No behaviour change; this is about being able to see which check returns false on the runner instead of inferring it from the flags that end up on the compile line. Note for anyone reading the previous run's log: the -L/opt/homebrew/opt/libomp/lib and -I/opt/homebrew/opt/libomp/include lines in it are Homebrew's `brew install libomp` caveat text, not flags this build used. Linux unaffected (tox -e py). Assisted-by: Claude Opus 5 --- setup.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 68c9318..4987893 100644 --- a/setup.py +++ b/setup.py @@ -527,15 +527,20 @@ def _compiler_openmp(cxx_path): stderr=subprocess.DEVNULL).strip() except Exception as exc: resource_dir = None - print(f"Notice: {cxx_real} -print-resource-dir failed: {exc!r}") + print(f"Notice: {cxx_real} -print-resource-dir failed: {exc!r}", + file=sys.stderr) inc_dir = os.path.join(resource_dir, 'include') if resource_dir else None have_header = bool(inc_dir) and os.path.exists(os.path.join(inc_dir, 'omp.h')) # Say what was found either way: a silent None here sends the build to a # different OpenMP, which still compiles and still passes its own tests, and # only aborts once another OpenMP consumer shares the process. + # stderr, not stdout: tox hides the build backend's stdout at default + # verbosity, which is why an earlier version of this probe printed nothing + # in CI and left the fallback looking like a mystery again. print(f"Darwin OpenMP probe: compiler={cxx_real}\n" f" lib_dir={lib_dir} libomp={have_lib}\n" - f" resource_dir={resource_dir} omp.h={have_header}") + f" resource_dir={resource_dir} omp.h={have_header}", + file=sys.stderr) if not (have_lib and have_header): return None return inc_dir, lib_dir @@ -824,7 +829,8 @@ def detect_gpu_toolchain(): # through PATH. _cxx_path, _cxx_ver = _resolve_darwin_cxx() print(f"Darwin C++ compiler: {_cxx_path}\n" - f" {_cxx_ver} (pin it with CC/CXX)") + f" {_cxx_ver} (pin it with CC/CXX)", + file=sys.stderr) # macOS has no system OpenMP, so libomp comes from a package manager. # @@ -854,12 +860,13 @@ def detect_gpu_toolchain(): print(f"Darwin: libomp from the compiler's own tree\n" f" headers {omp_inc}\n" f" library {omp_lib}\n" - f" BLAS {openblas_lib}") + f" BLAS {openblas_lib}", file=sys.stderr) elif conda_prefix and os.path.exists( os.path.join(conda_prefix, 'include', 'omp.h')): omp_inc = os.path.join(conda_prefix, 'include') omp_lib = openblas_lib = os.path.join(conda_prefix, 'lib') - print(f"Darwin: libomp and BLAS from conda env {conda_prefix}") + print(f"Darwin: libomp and BLAS from conda env {conda_prefix}", + file=sys.stderr) else: # Ask brew for its prefix rather than assuming: it is /opt/homebrew # on Apple silicon and /usr/local on Intel, so either one hardcoded @@ -881,7 +888,7 @@ def detect_gpu_toolchain(): " brew install libomp") sys.exit(1) print(f"Darwin: libomp and BLAS from Homebrew at {brew_prefix} " - "(no conda libomp found)") + "(no conda libomp found)", file=sys.stderr) cpu_compile_args = [ '-DSBD_TRADMODE', From 9053793890e0929b92e2a5880c07f53fe95f377c Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Fri, 18 Sep 2026 16:25:55 -0400 Subject: [PATCH 6/6] Run tox with -vv on CI so the build's own output is visible The cc: llvm cell has failed three runs in a row while reporting nothing about why: not the OpenMP probe added for exactly this purpose, and not even the unconditional "Using MPI from", "RPATH will be set to" and "Configuring CPU backend" lines setup.py has always printed. tox runs the wheel build through pyproject_api and, at default verbosity, prints one "build_wheel>" line and discards the rest -- stdout and stderr alike, which is why routing the probe to stderr did not help either. Checked locally which level is needed: -v still yields nothing, -vv shows it. This is instrumentation, not a fix. The underlying question -- why _compiler_openmp() does not engage on that runner -- is still open, and the next run should finally say. Assisted-by: Claude Opus 5 --- .github/workflows/test_latest_versions.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_latest_versions.yml b/.github/workflows/test_latest_versions.yml index 6230432..90215aa 100644 --- a/.github/workflows/test_latest_versions.yml +++ b/.github/workflows/test_latest_versions.yml @@ -92,7 +92,12 @@ jobs: # Not --parallel: the environments would then race to install the same wheel, # and the MPI tests want the runner's cores to themselves. run: | - tox -e py,notebook,mpi + # -vv surfaces the build backend's output. At default verbosity, and + # at -v, tox prints a single "build_wheel>" line and swallows + # everything setup.py reports -- so a macOS build that silently picked + # the wrong OpenMP left no trace of which one it chose. Checked + # locally: -v yields nothing, -vv shows it. + tox -vv -e py,notebook,mpi - name: Assert exactly one OpenMP runtime is loaded (macOS) if: runner.os == 'macOS' shell: bash