Skip to content

feat(build)!: ✨ ship x86-64 wheels as a fat binary over five ISA tiers - #311

Draft
robertodr wants to merge 6 commits into
split/03-arch-flagsfrom
split/07-fat-binary
Draft

feat(build)!: ✨ ship x86-64 wheels as a fat binary over five ISA tiers#311
robertodr wants to merge 6 commits into
split/03-arch-flagsfrom
split/07-fat-binary

Conversation

@robertodr

@robertodr robertodr commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

Enabling autovectorization has been done with the -march=native flag (monoprop_ENABLE_ARCH_FLAGS=ON) in local builds, but to make portable wheels, we have been compiling to the baseline of the manylinux container. For the various uses of popcount in this codebase that means effectively not using any CPU primitives (neither POPCNT nor VPOPCNTDQ).

The approach here creates a so-called fat binary where a single wheel ships multiple DSO, each compiled for a specific microarchitecture, and then there's a dispatcher wrapping and routing the calls to the best match for the machine once runs on.

Fixes #13.

🤖 Details 🤖

Summary

Published x86-64 wheels carry the propagation engine five times over, compiled for four ISA levels with the top level shipped twice at two vector widths, and monoprop picks one when it is imported. Off by default in source builds, where -march=native beats every tier.

Stacked on #304, which turns _monoprop_query_machine_flags into the general ARCH_FLAGS <flags...> form — exactly the generalization the tiers need, since a tier is a whole flag list and not a single -march token, so this branch adopts that vocabulary rather than carrying its own.

This is the fat-binary half of #276, extracted onto main. #276 is based on refactor-drop-nttp, so it carries the whole NTTP stack (#302#309) underneath it; the four whole-module fat-binary commits do not depend on any of that and are what this PR is. The narrow-seam commits that follow them (e646bbc, 68ad0a5, and two thirds of 34218ac) are not extractable: their design is "tier exactly the one translation unit holding build_layer's instantiation tree", and on main there is no such TU — MonomialPropagator is header-resident and instantiated per mode width in the generated binder TUs, so the module boundary is the narrowest seam that exists here. That narrowing is a payoff of #308 and should land on top of it.

  1. Four ISA levels, from a compile-time study of what each buys this codebase. x86-64, -v2, -v3, and -v4 + avx512vpopcntdq. Plain v4 is not shipped: ablating the eight AVX-512 extensions one at a time, -mavx512vpopcntdq accounts for the entire v4 → v4x gain and the other seven for exactly zero, which is what a codebase of std::popcount word loops and no intrinsics looks like. v1 ships despite being slowest because it is the floor.
  2. The top level ships twice, at -mprefer-vector-width=256 and 512. Identical -march, identical __builtin_cpu_supports requirements — the pair exists because left unset GCC takes the AVX-512 width from the -mtune tables, so it was being decided by monoprop_FAT_MTUNE, a core chosen for its schedule. No feature bit answers the question, so the discriminator is a core-name table read through __builtin_cpu_is. znver4 is on it because it is measured: 1.1% to the narrow tier on the 127-qubit kicked-Ising model, three-sample ranges disjoint, against GCC's own znver4 tuning.
  3. Two predicates per tier, and the distinction is load-bearing. runnable is features only and gates a monoprop_VARIANT pin; preferred adds the core table and is what the automatic selection reads. They differ for exactly one tier. Conflating them makes the 512-bit tier unpinnable on precisely the machines worth comparing it on.
  4. -ffp-contract=off is a contract, not a tuning knob. Without it -march=x86-64-v3 and up contract a*b+c into an FMA and the energy moves 1–2 ULP — the same wheel answering differently per host CPU. It is set project-wide so a source build, a wheel and every tier stay bit-comparable.
  5. Why not glibc-hwcaps, which would need no code at all. Its directory names are the four psABI levels, and the top tier is v4 plus avx512vpopcntdq. Installing it as x86-64-v4 hands it to Skylake-X and Cascade Lake, which are v4 with no vector popcount, and they take SIGILL. The predicate has to be ours.
  6. Why not target_clones. GCC will not inline across an arch mismatch, so a target-attributed wrapper around the engine is a jmp, flatten notwithstanding; and #pragma GCC target does not capture templates defined outside its region. flatten + target_clones is unaffordable regardless — measured on the runtime-width engine, four flattened clones took one TU from 16.7 s to killed at 21 minutes and ~100 GB of compiler memory.

Deviations from #276

  • _isa uses nanobind split mode rather than NB_STATIC: wheel.py-api = "cp311" sets SKBUILD_SABI_VERSION, and nanobind 3 refuses any linked module below cp312. It still links no engine object library, so it inherits no tier's arch flags.
  • just diff-baseline-variants is not included: it calls tools/capture-baseline.py, which arrives with feat(tools): ✨ capture and diff a golden baseline #302, and its undefined baseline_dir made the justfile unparseable for every recipe. The byte-wise cross-tier gate is therefore currently only test_variants.py's single probe. Worth restoring once feat(tools): ✨ capture and diff a golden baseline #302 lands — -vw256 and -vw512 are otherwise indistinguishable except by disassembly.
  • The flatten / target_clones numbers are attributed to the runtime-width engine they were taken on, and the note about narrowing the seam says what it needs.

x86-64

The fat binary includes the following microarchitectures:[^1]

  • x86-64-v1 which is the baseline
  • x86-64-v2 which adds POPCNT
  • `x86-64-v3
  • x86-64-v4-vpopcntdq-vw256 AVX512 + VPOPCNTDQ with 256 bit vector width
  • x86-64-v4-vpopcntdq-vw512 AVX512 + VPOPCNTDQ with 512 bit vector width

aarch64

WIP

Changes

  • Retire entirely the use of -march=native
  • Fat-binary build is default on Linux x86-64
  • Fat-binary build is default on Linux aarch64
🤖 Details 🤖

Changes

  • Add cmake/compiler_flags/FatBinary.cmake: the only place a tier or a narrow-vector core is declared. It generates the loader's predicate table into FatVariants.h, so a tier cannot be built without being selectable or selectable without being built.
  • Compile the engine once per tier behind monoprop_engine_sources(...) and configure every tier through one _monoprop_configure_engine_objs, so the tiers cannot drift apart in anything but arch flags. A new .cpp registered with target_sources(monoprop-objs ...) reaches only the baseline tier.
  • Apply the baseline ISA as a global floor in CMAKE_CXX_FLAGS, not per target: nanobind's glue is compiled with whatever -march the toolchain defaults to, which is not the psABI baseline (Ubuntu's GCC is --with-arch-64=x86-64-v3), and without the floor _isa itself would fault on the machines it exists to detect.
  • Add src/monoprop/bindings/isa.cpp, a ~100 KB baseline-ISA probe answering supported_variants() / runnable_variants() / known_variants() off the generated table, and src/monoprop/_bootstrap.py, which binds one variant as monoprop._core before anything else imports it.
  • Configure Variants.h per tier rather than once, so monoprop.__variant__ and __compiler_flags__ report what actually loaded. They previously reported a -march=native query whenever monoprop_ENABLE_ARCH_FLAGS was ON, regardless of what was compiled.
  • Add monoprop_ARCH_MARCH as the variant id a single-ISA build reports as __variant__, so what is compiled and what is reported cannot disagree. The flags themselves are ARCH_FLAG, fed to refactor(cmake): ♻️ query machine flags from ARCH_FLAG itself #304's ARCH_FLAGS query.
  • Enable the fat binary for *-manylinux_x86_64 wheels only, via a cibuildwheel override: it is x86-64 only, and the other legs are aarch64.
  • Add tests/test_variants.py (15 cases) and just build-fat / just test-variants.
  • Add docs/content/docs/fat-binary.mdx; update AGENTS.md, README.md and docs/content/docs/building.mdx.

Checklist

  • Tests added or updated to cover the changes
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed
  • CHANGELOG / release notes updated if applicable

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description: Claude Code
  • I used the following tool to generate or modify code: Claude Code

Important

By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.

Warning

If you're contributing on behalf of your employer, contact cla@algorithmiq.fi to arrange a Corporate CLA.

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file python cpp ci labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown

Docs preview: https://pr-311.monoprop-docs.pages.dev

@robertodr
robertodr marked this pull request as draft August 29, 2026 19:28
set(
monoprop_CXX_FLAGS
"-Wall -Wno-padded -Wno-unknown-pragmas -Woverloaded-virtual -Wwrite-strings -fcolor-diagnostics -Wno-c++98-compat -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"
"-Wall -Wno-padded -Wno-unknown-pragmas -Woverloaded-virtual -Wwrite-strings -fcolor-diagnostics -Wno-c++98-compat -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -ffp-contract=off"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thinks we're fine with the compiler generating FMAs for us.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 43.85965% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.86%. Comparing base (aa38399) to head (960d6d5).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/monoprop/_bootstrap.py 42.85% 29 Missing and 3 partials ⚠️
Additional details and impacted files
@@                   Coverage Diff                   @@
##           split/03-arch-flags     #311      +/-   ##
=======================================================
- Coverage                97.70%   93.86%   -3.85%     
=======================================================
  Files                       14       15       +1     
  Lines                      742      799      +57     
  Branches                    98      105       +7     
=======================================================
+ Hits                       725      750      +25     
- Misses                      12       41      +29     
- Partials                     5        8       +3     
Flag Coverage Δ
cpp 93.86% <43.85%> (-3.85%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Ubuntu and others added 5 commits August 29, 2026 19:31
Published x86-64 wheels compiled with no architecture flags at all, because the
only alternative in the tree was `-march=native`, which cannot be shipped. A
source build therefore got a fully vectorized library and a PyPI install got one
targeting the 2003 baseline -- where `std::popcount` has no instruction and
lowers to `call __popcountdi2@PLT`, in a library whose inner loops are
population counts.

Compile the engine once per ISA tier instead, and select one at import:

  x86-64, x86-64-v2, x86-64-v3, x86-64-v4 + avx512vpopcntdq

all with `-mtune=skylake`. The tiers come out of a compile-time sweep of GCC's
`-fopt-info-vec-loop-all` reports across the psABI levels; the flag-level
evidence, the `-mtune` sweep and the ablation behind each choice are written up
in `docs/content/docs/fat-binary.mdx`.

Three of those choices are load-bearing:

* Whole libraries, not `target_clones`. The vectorization lands in headers that
  are inlined into their callers and instantiated a dozen times over across the
  basis, row-backend and word-width seams, so a per-function dispatch boundary
  would suppress the inlining it exists to enable.
* Not glibc-hwcaps either, which would need no code: its directory names are the
  four psABI levels, and `x86-64-v4` does not imply `avx512vpopcntdq`. Skylake-X
  and Cascade Lake are v4 with no vector popcount and would fault. The predicate
  has to be ours, so the dispatch does too.
* The baseline ISA is a global floor, not just the baseline tier's flags. A wheel
  contains objects from targets nobody tiered -- nanobind's static library --
  compiled at whatever the toolchain defaults to, and that default is not the
  psABI baseline (recent Ubuntu GCC is built `--with-arch-64=x86-64-v3`).
  Without the floor the v1 and v2 variants carried AVX2 in their glue and the CPU
  probe itself faulted on the machines it exists to detect.

Also fixes a provenance bug in the way: `Variants.h` was configured once from a
query of `-march=native` whenever `monoprop_ENABLE_ARCH_FLAGS` was ON, so
`__variant__`, `__compiler_flags__` and every benchmark artifact's machine-flags
entry reported the host's ISA regardless of what had been compiled. It is now
generated per variant, which is what makes it possible to tell which tier loaded.
The same one-decision rule closes the older disagreement where a Debug build
compiled portable code, advertised the native ISA and took the native-tuned
sparse-row crossover.

BREAKING CHANGE: `-ffp-contract=off` is now set project-wide. Without it,
`-march=x86-64-v3` and up fuse `a*b+c` into an FMA and the energy moves by 1-2
ULP (all evolved terms stay bit-identical), which in a fat binary would mean one
wheel answering differently depending on the host CPU. Existing `-march=native`
builds change in the last bits once, and a stored golden baseline needs
re-seeding. In exchange a source build, a wheel and all four tiers are
byte-comparable, and `just diff-baseline-variants` is the gate on it.

BREAKING CHANGE: `monoprop_VARIANT` and `monoprop_VARIANT_FLAGS` are gone from
the public `monoprop/Variants.h`. They were function-multiversioning scaffolding,
never used, and superseded by whole-library tiering. `variant()` now returns the
tier id (or `native`/`default`) rather than always `default`.

Assisted-by: ClaudeCode:claude-opus-5
The floor is appended to CMAKE_CXX_FLAGS, which is also where CXXFLAGS lands, so
the configure summary was attributing our flags to the user's environment.

Assisted-by: ClaudeCode:claude-opus-5
The reason on record -- that a per-function dispatch seam would suppress the
inlining the tiers depend on -- is wrong: `__attribute__((flatten))` answers it,
and clones built that way do vectorize at their own ISA (measured: ymm at
arch=x86-64-v3, zmm at arch=x86-64-v4, header templates inlined). The location is
favourable too, with 91% of the project's vectorized loops in one TU behind a
single non-template caller.

Replace it with the two reasons that hold. `avx512vpopcntdq` is not a valid ISA
name in a `target` attribute and `arch=` takes one name from a closed list, while
an `arch=<named core>` clone resolves on CPU identity rather than features, so it
would skip every non-Intel part with a vector popcount -- and that feature is 100%
of the top tier's measured value. And flattening build_layer's
with_algebra x with_store x with_kernel_width fan-out four times took one TU from
16.7 s to over 20 minutes at ~100 GB of compiler memory, against 16 GB runners.

Assisted-by: ClaudeCode:claude-opus-5
Two errors in the previous note. avx512vpopcntdq IS expressible -- a comma
separates options in a plain `target` attribute, and only separates clones in
`target_clones`, which is where that error came from. And the real obstacle is
narrower and more general than a missing flag name: GCC will not inline across an
`arch` mismatch, so a targeted wrapper around the engine compiles to a jmp with
`flatten` having no effect, and `#pragma GCC target` does not capture templates
defined outside its region. Header-resident code is widened by its TU's command
line or not at all.

Also record that collapsing the width axis does not rescue flatten (OOM at 24.5 GB
against 740 MB for the same code compiled normally), and that the duplication is
reducible a different way: 91% of the vectorized loops and 51% of the engine's
.text are in one TU.

Assisted-by: ClaudeCode:claude-opus-5
Reconciles the four fat-binary commits with what this base actually has.

Build: the `_isa` probe cannot be a linked (NB_STATIC) nanobind module here.
`wheel.py-api = "cp311"` sets SKBUILD_SABI_VERSION, and nanobind 3 refuses any
non-split module below cp312, so the probe goes through the shared backend.
It still links no engine object library, so it inherits no tier's arch flags,
and the baseline floor in CMAKE_CXX_FLAGS keeps the backend itself at x86-64.

Prose: the `flatten` + `target_clones` measurement and the note on narrowing
the seam were written against an engine whose scan lives in one out-of-line
translation unit. Here it is header-resident and instantiated per mode width
in the generated binder TUs, so the module boundary is the narrowest seam
available; the measurement is attributed to where it was taken.

Drops `just diff-baseline-variants`: it calls tools/capture-baseline.py, which
this base does not carry, and an undefined `baseline_dir` made the justfile
unparseable for every recipe.

Assisted-by: ClaudeCode:claude-opus-5
@robertodr
robertodr force-pushed the split/07-fat-binary branch from bfa6b94 to 95ff002 Compare August 29, 2026 19:41
The top tier ships twice, at -mprefer-vector-width=256 and 512. Same -march,
same __builtin_cpu_supports requirements, same everything a feature bit can
express; the run time picks between them.

Left unset, GCC takes the AVX-512 vector width from the -mtune tables, so it
was being decided by monoprop_FAT_MTUNE -- a core chosen for its schedule, on
the reasoning that -mtune never changes which instructions come out. For
AVX-512 widths it does, and differently per core.

No feature bit answers the question, because it is not a capability question:
it is how wide the datapath behind the registers really is and what the core
charges in clock for lighting all of it up. So the discriminator is a table of
core names, monoprop_FAT_NARROW_VECTOR_CORES, read through __builtin_cpu_is
and probed at configure time for names this compiler knows. znver4 is on it
because it is measured -- 1.1% to the narrow tier on the 127-qubit
kicked-Ising model with disjoint three-sample ranges, against GCC's own znver4
tuning.

Each tier therefore carries two predicates. `runnable` is features only and is
what gates a monoprop_VARIANT pin; `preferred` adds the core table and is what
the automatic selection and supported_variants() read. They differ for exactly
one tier, and conflating them would make the wide one unpinnable on precisely
the machines worth comparing it on -- so _bootstrap.py checks a pin against
runnable_variants() and selects out of supported_variants().

Nothing but a disassembly tells the pair apart, so
test_reported_machine_flags_widen_with_the_tier now asserts that the width
setting differs while the feature set does not.

Assisted-by: ClaudeCode:claude-opus-5
@robertodr
robertodr force-pushed the split/07-fat-binary branch from 95ff002 to 960d6d5 Compare August 29, 2026 19:42
@robertodr
robertodr changed the base branch from main to split/03-arch-flags August 29, 2026 19:42
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci cpp dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document usage of architecture dependent compiler flags

1 participant