Skip to content

Use cibuildwheel to build pyodide wheels#382

Merged
oscarbenjamin merged 18 commits intoflintlib:mainfrom
oscarbenjamin:pr_pyodide_cleanup
Mar 25, 2026
Merged

Use cibuildwheel to build pyodide wheels#382
oscarbenjamin merged 18 commits intoflintlib:mainfrom
oscarbenjamin:pr_pyodide_cleanup

Conversation

@oscarbenjamin
Copy link
Collaborator

CC @agriyakhetarpal

I'm not sure if there was a reason why the pyodide build didn't use cibuildwheel in the first place but it would be cleaner if we could combine all platforms into one cibuildwheel call.

@agriyakhetarpal
Copy link
Contributor

agriyakhetarpal commented Mar 23, 2026

Thanks, @oscarbenjamin! I'll take a closer look when I log in to work later today, but FYI you can also remove the setup-node, setup-python, and setup-emscripten steps. The cibuildwheel action takes care of setting up Python, and the Pyodide code in cibuildwheel takes care of setting up Node.js and Emscripten (and is faster at doing that too).

@oscarbenjamin
Copy link
Collaborator Author

Thanks. I removed some things at first but then the second commit here (6d15e42) added back mymindstorm/setup-emsdk and that seemed to fix the basic problem from the first commit that failed immediately with emconfigure not found:

Running before_all...
  
  + bin/cibw_before_all_pyodide.sh
  Cloning into 'gmp_mirror'...
  /home/runner/work/python-flint/python-flint/bin/pyodide_build_dependencies.sh: line 69: emconfigure: command not found

@oscarbenjamin
Copy link
Collaborator Author

Is there a reason you chose to build GMP, MPFR and FLINT as static libraries?

I imagine that is a typical thing to do for pyodide. What I'm not sure about are the license implications because GMP, MPFR and FLINT are all LGPL and the usual thing to do with LGPL is dynamic linking so that someone can replace the shared libraries with their own versions. That makes the LGPL not contagious in the way that GPL is so you can mix together differently licensed things with LGPL as long as the LGPL parts are replaceable (which they clearly are if they are shared libraries).

@oscarbenjamin
Copy link
Collaborator Author

This error reared its head after the license files commit:

test/test_docstrings.py::test_docstrings[flint.types._gr.gr_gr_poly_ctx.new] Pyodide has suffered a fatal error. Please report this to the Pyodide maintainers.
  The cause of the fatal error was:
  RuntimeError: memory access out of bounds
      at wasm://wasm/020fb45e:wasm-function[9780]:0x48a8e4
      at wasm://wasm/02f8fc6a:wasm-function[15398]:0x92a4a9
      at wasm://wasm/02f8fc6a:wasm-function[15382]:0x929f1e
      at wasm://wasm/02f8fc6a:wasm-function[14936]:0x8fe8de
      at wasm://wasm/031545ee:wasm-function[424]:0xa806d
      at wasm://wasm/031545ee:wasm-function[664]:0xd48e6
      at wasm://wasm/020fb45e:wasm-function[2314]:0x1ba307
      at wasm://wasm/031545ee:wasm-function[687]:0xd6d1c
      at wasm://wasm/020fb45e:wasm-function[2314]:0x1ba307
      at wasm://wasm/020fb45e:wasm-function[1970]:0x1a24f4 {
    pyodide_fatal_error: true
  }

@oscarbenjamin
Copy link
Collaborator Author

Then the last commit had

============================= test session starts ==============================
platform linux -- Python 3.13.12, pytest-9.0.2, pluggy-1.6.0
rootdir: /home/runner/work/python-flint/python-flint
configfile: pyproject.toml
plugins: cov-7.1.0
collected 2 items

Fatal Python error: Illegal instruction

Current thread 0x00007ff061655b80 (most recent call first):
  File "<frozen importlib._bootstrap>", line 488 in _call_with_frames_removed
  File "<frozen importlib._bootstrap_external>", line 1325 in exec_module
  File "<frozen importlib._bootstrap>", line 935 in _load_unlocked
  File "<frozen importlib._bootstrap>", line 1331 in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 1360 in _find_and_load
  File "<doctest _gr.rst[0]>", line 1 in <module>
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/doctest.py", line 1398 in __run
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/doctest.py", line 1569 in run
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/doctest.py", line 1964 in run
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/doctest.py", line 301 in runtest
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/runner.py", line 179 in pytest_runtest_call
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_callers.py", line 121 in _multicall
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_hooks.py", line 512 in __call__
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/runner.py", line 245 in <lambda>
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/runner.py", line 353 in from_call
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/runner.py", line 244 in call_and_report
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/runner.py", line 137 in runtestprotocol
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/runner.py", line 118 in pytest_runtest_protocol
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_callers.py", line 121 in _multicall
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_hooks.py", line 512 in __call__
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/main.py", line 396 in pytest_runtestloop
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_callers.py", line 121 in _multicall
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_hooks.py", line 512 in __call__
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/main.py", line 372 in _main
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/main.py", line 318 in wrap_session
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/main.py", line 365 in pytest_cmdline_main
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_callers.py", line 121 in _multicall
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/pluggy/_hooks.py", line 512 in __call__
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/config/__init__.py", line 199 in main
  File "/opt/hostedtoolcache/Python/3.13.12/x64/lib/python3.13/site-packages/_pytest/config/__init__.py", line 223 in console_main
  File "/opt/hostedtoolcache/Python/3.13.12/x64/bin/pytest", line 6 in <module>

Extension modules: flint.flint_base.flint_context, flint.types.fmpz, flint.flint_base.flint_base, flint.pyflint, flint.types.fmpq, flint.types.fmpq_poly, flint.types.arf, flint.types.arb, flint.types.dirichlet, flint.types.acb, flint.types.fmpz_poly, flint.types.fmpq_mat, flint.types.fmpz_mat, flint.types.acb_poly, flint.types.arb_poly, flint.types.acb_series, flint.types.arb_series, flint.types.fmpq_series, flint.types.fmpz_series, flint.types.fmpz_vec, flint.types.fmpq_vec, flint.types.nmod, flint.types.nmod_poly, flint.types.fmpz_mod, flint.types.nmod_mpoly, flint.types.nmod_mat, flint.types.nmod_series, flint.types.fmpz_mpoly, flint.types.fmpz_mod_poly, flint.types.fmpz_mod_mpoly, flint.types.fmpz_mod_mat, flint.types.fmpq_mpoly, flint.types.fq_default, flint.types.fq_default_poly, flint.types.acb_mat, flint.types.arb_mat, flint.functions.showgood, flint.types._gr (total: 38)
doc/source/_gr.rst 

@oscarbenjamin
Copy link
Collaborator Author

I wonder if both of those are intermittent failures, not related to the changes in the last two commits.

@oscarbenjamin
Copy link
Collaborator Author

In fact the second one is intermittent. It isn't to do with the pyodide build at all. I've been seeing that failure occasionally with the FLINT main branch CI job. Earlier I bumped the main FLINT version from 3.3.1 to 3.4.0 so it is possible that is why we first see this now.

meson.build Outdated
Comment on lines +24 to +30
dep_py = py.dependency()
if meson.is_cross_build() and host_machine.system() == 'emscripten'
# Avoid picking up the runner's host python.pc via pkg-config.
# For Pyodide, use the interpreter sysconfig data from pyodide-build instead.
dep_py = py.dependency(method: 'system')
else
dep_py = py.dependency()
endif
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was apparently necessary to avoid picking up the wrong Python headers at C compile time. Is there a better way?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure, actually. This should have worked, but I wonder if there is some sort of PATH mangling going around here. My first instinct was to of course check NumPy which has worked with Pyodide's build system since forever, and I found that it doesn't do this: https://github.com/numpy/numpy/blob/7baf0e7c71dfc2de9fa5022cf51cb4873efd4285/meson.build#L38-L39. I think it's probably okay for now, but should be good to get to the bottom of later.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are we using a Meson cross file? I remember it was there when I was looking at the PR last night, but I can't see why you removed it through the comments you posted as you worked through the PR. Could it be perhaps something to do with that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So, I think pyodide uses its own cross file. I added one but then removed it because this py.dependency thing was what actually made it work.

Now though (per your comment below) I just remove py_dep altogether and that seems to be fine.

I guess that py_dep was confusing the situation somehow when it isn't really needed...

@oscarbenjamin
Copy link
Collaborator Author

Is there a reason you chose to build GMP, MPFR and FLINT as static libraries?

The generated wheel is 150MB and expands to 450 MB when unzipped. I think this is because python-flint has over 30 extension modules that all link to these same libraries. Using static linking likely means that we have many copies of the same machine code across all those extension modules.

By comparison the manylinux wheels are about 10 MB and the bulk of that is just libflint.so which expands to about 13MB when unzipped.

@oscarbenjamin
Copy link
Collaborator Author

I've changed it to build shared libraries and bundle them and it seems to be working now. Now the wheel is 15MB expanding to 36MB uncompressed. The bulk of that is just libflint.so weighing in at 27MB and then GMP and MPFR add up to 3MB so there are 30MB of shared libraries and then 6MB of all of python-flint's actual Python code and extension modules.

I guess that statically linking was stripping some stuff to get the extension modules from 30MB of static library down to more like 10MB but in the new wheel those extension modules are more like 100KB so static linking is about 10MB of overhead per extension module and with over 30 extension modules that ends up much more than just shipping 30MB of separate shared library files.

@oscarbenjamin
Copy link
Collaborator Author

@agriyakhetarpal thanks for chiming in.

As far as I am concerned this is done now. Let me know if you are thinking of reviewing it or otherwise I would just merge at this point. We can of course follow up after merge anyway as well (although I am planning a release ASAP).

The changes here are:

  • Merge the pyodide CI stuff with the cibuildwheel matrix job that builds all other wheels.
  • Use shared libraries (side modules) in the pyodide wheel bringing it down from 150MB to 15MB.
  • Add a special case to use py.dependency(method: 'system') in meson.build because otherwise meson was finding the wrong Python headers in the cross build (maybe there is a better way).
  • Bundle the licenses for all bundled libraries into the pyodide wheel like for other wheels.
  • This will automatically upload the pyodide wheels to the nightly wheels index after each push to main.
  • Make an exception to prevent the pyodide wheels being uploaded to PyPI.

@agriyakhetarpal
Copy link
Contributor

I am taking a look now; thanks for the summary @oscarbenjamin!

@oscarbenjamin
Copy link
Collaborator Author

Great timing! I'm heading out to the pub and shop now but will be back in a couple of hours.

Copy link
Contributor

@agriyakhetarpal agriyakhetarpal left a comment

Choose a reason for hiding this comment

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

This is looking great, thanks a lot! I hope you are having fun outside! :) I took a look at the workflows and I have a few comments, nothing substantial at all or broken, so that's a good sign. Please feel free to take or leave the suggestion to bump the Pyodide version, lest it causes some new test failures (probably best to try out in another PR if it does).

Switching to a shared library should be alright. As a follow-up for whenever this PR makes it to a release I think we should also do that for the pyodide-recipes builds for libflint and co. The difference is that instead of being linked into the wheel and the python-flint shared objects, the libraries will start to be loaded at runtime, e.g., how OpenBLAS is separately loaded with SciPy. So in the browser console you'll start seeing something like Loading libflint, libgmp, libmpfr, python-flint and then Loaded libflint, libgmp, libmpfr, python-flint and so on.

P.S. I did previously notice you pinged me on a couple of other WASM-related items on SymPy and here sometime late last year. I missed responding to them because I was travelling, either on vacation, or the usual FOSS or scientific Python conference or so on, and those were merged/resolved in my absence. Hence, I never got to writing a comment/message on those; I apologise! However, please feel free to continue pinging me for things like this as you see fit :D

meson.build Outdated
Comment on lines +24 to +30
dep_py = py.dependency()
if meson.is_cross_build() and host_machine.system() == 'emscripten'
# Avoid picking up the runner's host python.pc via pkg-config.
# For Pyodide, use the interpreter sysconfig data from pyodide-build instead.
dep_py = py.dependency(method: 'system')
else
dep_py = py.dependency()
endif
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure, actually. This should have worked, but I wonder if there is some sort of PATH mangling going around here. My first instinct was to of course check NumPy which has worked with Pyodide's build system since forever, and I found that it doesn't do this: https://github.com/numpy/numpy/blob/7baf0e7c71dfc2de9fa5022cf51cb4873efd4285/meson.build#L38-L39. I think it's probably okay for now, but should be good to get to the bottom of later.

Comment on lines +206 to +211
- run: |
pyodide venv .venv-pyodide
source .venv-pyodide/bin/activate
pip install wheelhouse/*.whl
pip install pytest hypothesis
python -m pytest -svra -p no:cacheprovider --pyargs flint
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be better to move this to cibw-test-command territory, as that would avoid writing another job just to test the Pyodide wheels. You can build and test them in one go, which also saves CI time. cibuildwheel also takes care of syncing the Pyodide version, the pyodide-build version, etc. automatically because the environment is locked. Is there a specific reason to have another job?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I set the test rig up this way a long time ago not for pyodide but for the other platforms when using cibuildwheel. The reason is that when the cibuildwheel step fails we don't end up with a downloadable artifact. If cibuildwheel succeeds then I want to be able to download the wheels even (especially!) if the wheels fail testing.

It is not often that I do that but it tends to be useful in exactly this situation when you are trying to get things working in the first place. I did in fact download the wheel earlier after e5dc593 where build succeeded but testing failed. It turned out that building the shared libraries with SIDE_MODULE=2 stripped out all the code so libflint.so was only about 300 bytes. The fix in 57f4aec was to set SIDE_MODULE=1 and then it worked.

Being able to download the broken wheel was very helpful in that situation.

As an aside here I did not find any clear documentation that explains precisely what emscripten's SIDE_MODULE=1 vs SIDE_MODULE=2 actually does although this is explained for MAIN_MODULE. The effect is clear if you download the wheels from the referenced commit though: in one case libflint.so is 300 bytes and in the other it is 27MB.

meson.build Outdated
Comment on lines +24 to +30
dep_py = py.dependency()
if meson.is_cross_build() and host_machine.system() == 'emscripten'
# Avoid picking up the runner's host python.pc via pkg-config.
# For Pyodide, use the interpreter sysconfig data from pyodide-build instead.
dep_py = py.dependency(method: 'system')
else
dep_py = py.dependency()
endif
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we using a Meson cross file? I remember it was there when I was looking at the PR last night, but I can't see why you removed it through the comments you posted as you worked through the PR. Could it be perhaps something to do with that?

@oscarbenjamin
Copy link
Collaborator Author

Thanks for the review.

I'm working through it now...

@oscarbenjamin
Copy link
Collaborator Author

I don't seem to be able to reply to this in the normal reivew/reply way so replying here:

Are we using a Meson cross file? I remember it was there when I was looking at the PR last night, but I can't see why you removed it through the comments you posted as you worked through the PR. Could it be perhaps something to do with that?

I did add a cross file and that was my first attempt at getting meson to find the correct Python headers for the cross build. However, that did not work and then I found that using py.dependency(method: 'system') did work and subsequently that removing the cross file didn't cause any problems.

It definitely seemed like a cross file would be the right thing to use there but it did not give any discernible improvement so it was added in 5794e26 and removed in 920ced0. The in-between commit that seemed to actually fix the problem was afe2b14.

Oh, also it didn't seem like the cross file was actually being used. I think there was something in the meson output that referenced a cross-file from pyodide as if pyodide-build was already forcing its own cross file and that was taking precedence.

Maybe I didn't set up the cross file correctly?

@agriyakhetarpal
Copy link
Contributor

Yes, we have the WASM cross file being applied and picked up automatically for Meson projects.

The difference for NumPy is that it uses its custom vendored Meson, in which case the cross file has to be added manually like this: https://github.com/numpy/numpy/blob/7baf0e7c71dfc2de9fa5022cf51cb4873efd4285/pyproject.toml#L232. I assume that is what is making it work without the py.dependency(method: 'system') conditional. So NumPy is probably not the best example to look at for us.

We also have a copy of this cross file in the pyodide-build source code, in the tools/ folder, provided as pyodide_build/tools/emscripten.meson.cross. With that in mind, I checked how scikit-learn is compiling to WASM. Since they don't have a cross file and don't use a vendored Meson, the one from pyodide-build is correctly getting picked up automatically for them; see here: https://github.com/scikit-learn/scikit-learn/actions/runs/23473094079/job/68300022567#step:3:533

So scikit-learn is closer to what we want. One difference I have been able to find is that they don't use py.dependency as we do here. Their way to find Python is this: https://github.com/scikit-learn/scikit-learn/blob/cb4ce6d5257378052fbe664b710084b2bb39fd56/meson.build#L47, just like ours, but they don't need dep_py. I see that we require it for pyflint. That made me think it's probably a Cython-based requirement, but then other Meson + Cython projects don't do this, so I'm a bit stumped. Perhaps this provides some food for thought to you? One thing I see is that even NumPy uses it only in the f2py code, npyrandom, and nowhere else: https://github.com/search?q=repo:numpy/numpy+%22py_dep%22&type=code

@oscarbenjamin
Copy link
Collaborator Author

but they don't need dep_py. I see that we require it for pyflint.

Oh, maybe we don't need it. I probably wrote that in the early days of learning meson...

@oscarbenjamin
Copy link
Collaborator Author

Okay, thanks. I have pushed all suggested changes apart from removing the separate test job because I prefer to keep it that way and it was useful to have it like that today even when debugging a problem with this wheel build.

Each change is a separate commit and a separate 40 minute CI run. It is still early in the runs and so far there are some unrelated random CI failures but otherwise all looks good for the relevant jobs.

@oscarbenjamin
Copy link
Collaborator Author

Okay, Ci is green. I made all changes including removing dep_py and everything seems to work!

Copy link
Contributor

@agriyakhetarpal agriyakhetarpal left a comment

Choose a reason for hiding this comment

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

I am glad we got to the bottom of the Meson cross file issue. I checked all files, and everything looks in order and good to merge. I will follow up with my cibuildwheel-Pyodide-patches change upstream soon – it's not a lot of code, fortunately, because the patches are all included in and handled through the xbuildenv. Thanks a lot for working through this!

@oscarbenjamin
Copy link
Collaborator Author

Great, thanks always for your help!

I'm going to merge this and then hopefully that will mean that we have nightly pyodide wheels...

I also opened a docs issue at autditwheel_emscripten:
pyodide/auditwheel-emscripten#53

@oscarbenjamin oscarbenjamin merged commit 54a1396 into flintlib:main Mar 25, 2026
89 checks passed
@oscarbenjamin oscarbenjamin deleted the pr_pyodide_cleanup branch March 25, 2026 00:48
@agriyakhetarpal
Copy link
Contributor

agriyakhetarpal commented Mar 25, 2026

As an aside here I did not find any clear documentation that explains precisely what emscripten's SIDE_MODULE=1 vs SIDE_MODULE=2 actually does although this is explained for MAIN_MODULE. The effect is clear if you download the wheels from the referenced commit though: in one case libflint.so is 300 bytes and in the other it is 27MB.

Yes, this is a bit under-explained. SIDE_MODULE=1 forces inclusion of all symbols from object files (--whole-archive), while SIDE_MODULE=2 only includes explicitly exported symbols (such as those you do using -sEXPORTED_FUNCTIONS). That is the explanation for the size difference. Some packages do need to use SIDE_MODULE=1 (GDAL, for example).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants