feat(build)!: ✨ ship x86-64 wheels as a fat binary over five ISA tiers - #311
Draft
robertodr wants to merge 6 commits into
Draft
feat(build)!: ✨ ship x86-64 wheels as a fat binary over five ISA tiers#311robertodr wants to merge 6 commits into
robertodr wants to merge 6 commits into
Conversation
robertodr
requested review from
adamglos92,
diagonal-hamiltonian,
fpietra and
ludmilaasb
as code owners
August 29, 2026 19:22
|
Docs preview: https://pr-311.monoprop-docs.pages.dev |
robertodr
marked this pull request as draft
August 29, 2026 19:28
robertodr
commented
Aug 29, 2026
| 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" |
Member
Author
There was a problem hiding this comment.
I thinks we're fine with the compiler generating FMAs for us.
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. |
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
force-pushed
the
split/07-fat-binary
branch
from
August 29, 2026 19:41
bfa6b94 to
95ff002
Compare
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
force-pushed
the
split/07-fat-binary
branch
from
August 29, 2026 19:42
95ff002 to
960d6d5
Compare
|
9 tasks
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.



Summary
Enabling autovectorization has been done with the
-march=nativeflag (monoprop_ENABLE_ARCH_FLAGS=ON) in local builds, but to make portable wheels, we have been compiling to the baseline of themanylinuxcontainer. For the various uses ofpopcountin this codebase that means effectively not using any CPU primitives (neitherPOPCNTnorVPOPCNTDQ).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-64wheels carry the propagation engine five times over, compiled for four ISA levels with the top level shipped twice at two vector widths, andmonoproppicks one when it is imported. Off by default in source builds, where-march=nativebeats every tier.Stacked on #304, which turns
_monoprop_query_machine_flagsinto the generalARCH_FLAGS <flags...>form — exactly the generalization the tiers need, since a tier is a whole flag list and not a single-marchtoken, 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 onrefactor-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 of34218ac) are not extractable: their design is "tier exactly the one translation unit holdingbuild_layer's instantiation tree", and onmainthere is no such TU —MonomialPropagatoris 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.x86-64,-v2,-v3, and-v4+avx512vpopcntdq. Plainv4is not shipped: ablating the eight AVX-512 extensions one at a time,-mavx512vpopcntdqaccounts for the entirev4 → v4xgain and the other seven for exactly zero, which is what a codebase ofstd::popcountword loops and no intrinsics looks like.v1ships despite being slowest because it is the floor.-mprefer-vector-width=256and512. Identical-march, identical__builtin_cpu_supportsrequirements — the pair exists because left unset GCC takes the AVX-512 width from the-mtunetables, so it was being decided bymonoprop_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.znver4is 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.runnableis features only and gates amonoprop_VARIANTpin;preferredadds 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.-ffp-contract=offis a contract, not a tuning knob. Without it-march=x86-64-v3and up contracta*b+cinto 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.glibc-hwcaps, which would need no code at all. Its directory names are the four psABI levels, and the top tier isv4plusavx512vpopcntdq. Installing it asx86-64-v4hands it to Skylake-X and Cascade Lake, which arev4with no vector popcount, and they take SIGILL. The predicate has to be ours.target_clones. GCC will not inline across anarchmismatch, so atarget-attributed wrapper around the engine is ajmp,flattennotwithstanding; and#pragma GCC targetdoes not capture templates defined outside its region.flatten+target_clonesis 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
_isauses nanobind split mode rather thanNB_STATIC:wheel.py-api = "cp311"setsSKBUILD_SABI_VERSION, and nanobind 3 refuses any linked module belowcp312. It still links no engine object library, so it inherits no tier's arch flags.just diff-baseline-variantsis not included: it callstools/capture-baseline.py, which arrives with feat(tools): ✨ capture and diff a golden baseline #302, and its undefinedbaseline_dirmade the justfile unparseable for every recipe. The byte-wise cross-tier gate is therefore currently onlytest_variants.py's single probe. Worth restoring once feat(tools): ✨ capture and diff a golden baseline #302 lands —-vw256and-vw512are otherwise indistinguishable except by disassembly.flatten/target_clonesnumbers 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-v1which is the baselinex86-64-v2which addsPOPCNTx86-64-v4-vpopcntdq-vw256AVX512 +VPOPCNTDQwith 256 bit vector widthx86-64-v4-vpopcntdq-vw512AVX512 +VPOPCNTDQwith 512 bit vector widthaarch64
WIP
Changes
-march=native🤖 Details 🤖
Changes
cmake/compiler_flags/FatBinary.cmake: the only place a tier or a narrow-vector core is declared. It generates the loader's predicate table intoFatVariants.h, so a tier cannot be built without being selectable or selectable without being built.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.cppregistered withtarget_sources(monoprop-objs ...)reaches only the baseline tier.CMAKE_CXX_FLAGS, not per target: nanobind's glue is compiled with whatever-marchthe toolchain defaults to, which is not the psABI baseline (Ubuntu's GCC is--with-arch-64=x86-64-v3), and without the floor_isaitself would fault on the machines it exists to detect.src/monoprop/bindings/isa.cpp, a ~100 KB baseline-ISA probe answeringsupported_variants()/runnable_variants()/known_variants()off the generated table, andsrc/monoprop/_bootstrap.py, which binds one variant asmonoprop._corebefore anything else imports it.Variants.hper tier rather than once, somonoprop.__variant__and__compiler_flags__report what actually loaded. They previously reported a-march=nativequery whenevermonoprop_ENABLE_ARCH_FLAGSwas ON, regardless of what was compiled.monoprop_ARCH_MARCHas the variant id a single-ISA build reports as__variant__, so what is compiled and what is reported cannot disagree. The flags themselves areARCH_FLAG, fed to refactor(cmake): ♻️ query machine flags from ARCH_FLAG itself #304'sARCH_FLAGSquery.*-manylinux_x86_64wheels only, via acibuildwheeloverride: it is x86-64 only, and the other legs are aarch64.tests/test_variants.py(15 cases) andjust build-fat/just test-variants.docs/content/docs/fat-binary.mdx; updateAGENTS.md,README.mdanddocs/content/docs/building.mdx.Checklist
docs/,CONTRIBUTING.md) if neededCHANGELOG/ release notes updated if applicableAI/LLM disclosure
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.