Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Api mode cffi compile #169

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open

Conversation

georgeharker
Copy link

Cffi code is much faster when compiled with a c compiler (API mode) rather than using libfffi (ABI mode) - however this requires a compiler to be installed .

This change allows an install to be made using

XCFFIB_API_MODE=1 pip3 install xcffib

Various other projects (cairocffi, pangocffi and pangocairocffi) can then benefit from similar changes.

During installation XCFFIB_API_MODE=0 or XCFFIB_API_MODE not set defaults to the previous ABI mode install which incurs lever overhead on load, runs the ffi_build as before and will dynamically translate arguments to C using the general libffi.

During installationXCFFIB_API_MODE=1 compiles a shared library / C extension which dynamically depends on xcd.

At runtime if the shared library / C extension is present it will be used (unless XCFFIB_API_MODE=0). The user does not have to arrange for XCFFIB_API_MODE=1 to be set. If the extension is not present, the old behavior is used.

To make this work I modified the CPU checks to be portable, and added the relevant FFI calls / setup.py calls to have the C extension be built.

The language-python requirement changes may not be needed - I was working around a happy bug / clash which I think you've noted in a report for haskell.

I don't suggest this be default, but API mode is a potential substantial performance improvement (especially for cairo, which needs xcffib to be build in API mode to work, C and py ffi can't be mixed with ffi.include())

Copy link
Owner

@tych0 tych0 left a comment

Choose a reason for hiding this comment

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

You'll be amused to know that just recently moved away from api mode (517169a) largely because it was too hard for people to install correctly.

I'm not opposed to supporting both modes, but we need to do it in a backwards compatible way, and probably have tests to make sure that when things are installed in either mode they still work.

xcffib.cabal Outdated Show resolved Hide resolved
portable_nproc.sh Outdated Show resolved Hide resolved
)

if __name__ == "__main__":
ffi.compile(verbose=True)
Copy link
Owner

Choose a reason for hiding this comment

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

This file rename looks like an API break, which will break downstream users, so we should figure out some way to avoid that.

Copy link
Author

Choose a reason for hiding this comment

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

due to the move? There was some oddity in that the ffi py name was shadowed by the ffi object in the module - which is why the rename - it seems like no one would have been able to access ffi.py without some module import path import lib magic. But I would definitely like it to be as compatible as possible. I believe in init.py I pull that back in as ffi in non api mode to make the change transparent externally

Copy link
Owner

Choose a reason for hiding this comment

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

Yeah, that sounds good to me, thanks.

module/__init__.py Outdated Show resolved Hide resolved
setup.py Outdated Show resolved Hide resolved
setup.py Outdated Show resolved Hide resolved
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools >= 64"]
build-backend = "setuptools.build_meta"
Copy link
Owner

Choose a reason for hiding this comment

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

Can you elaborate on why this is necessary?

Copy link
Author

Choose a reason for hiding this comment

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

pyproject.toml is the defacto standard now and all projects are supposed to have one to select which build system is used - without it editable installs complain and will soon be disallowed (because the editable part needs to know which build system to use to install editable).

Copy link
Owner

Choose a reason for hiding this comment

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

huh, I had never heard of "editable installs", TIL, thanks. Is there any chance we can completely get rid of setup.py? Last I had looked at pyproject.toml it wasn't fully feature parity with setup.py.

Copy link
Author

Choose a reason for hiding this comment

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

unfortunately in order to support stuff like api mode it's required, migration from .cfg to pyproject.toml is pretty doable, but feels like it might want to be separate to this PR.

setup.py Outdated Show resolved Hide resolved
@georgeharker
Copy link
Author

You'll be amused to know that just recently moved away from api mode (517169a) largely because it was too hard for people to install correctly.

I'm not opposed to supporting both modes, but we need to do it in a backwards compatible way, and probably have tests to make sure that when things are installed in either mode they still work.

I didn't;t know it used to be that way but can certainly understand that it's a bit of a pain - would love to provide the high performance option whilst preserving the easier route for folks by default.

Will respond to the rest inline.

@tych0
Copy link
Owner

tych0 commented Dec 4, 2024

Ok, if you rebase this you shouldn't have the language-python based failures any more, I just wrote the code to drop our dependency on it. In no particular order, here are some things I'd like to see before landing this:

  1. clean up the git history to standalone patches
  2. do we need to add pyproject.toml to MANIFEST.in? I don't really know how that works.
  3. I would like the (pytest) test suite to test both API and ABI mode based installs, to ensure we keep both working
  4. have a module-level thing that indicates which mode you're running in, e.g. xcffib.cffi_mode == "API" or "ABI"

If we can do that, I think I'm happy to merge this.

One question is: should we have the env var at all? What about just figuring out if the user has a C compiler available and using it?

@georgeharker
Copy link
Author

Great. I'll try and take a look at this in the next few days.

I'll try and find out if we need to add pyproject.toml to the manifest. It's possible not if the generated portion remains in setup.cfg. They can both be used and maybe modifying that would be best as a separate change?

It's definitely possible to just determine if the compiler is available - however I was concerned with the way installs work with pip - by default they will pull all dependencies fresh and build against the non installed version. I guess that would still work either way, perhaps better as it could ensure builds of the dependencies were made in api mode. I can experiment.

Will rebase against your changes!

The install mode global is a great idea. I'll propagate that onwards to cairocffi pangocffi and pangocairocffi.

@georgeharker georgeharker force-pushed the api-mode branch 2 times, most recently from 386aa5a to 2d650b0 Compare December 6, 2024 23:30
@georgeharker
Copy link
Author

georgeharker commented Dec 6, 2024

Following up:

  1. clean up the git history to standalone patches

I now have a pair commits and it's rebased off yours.

  1. do we need to add pyproject.toml to MANIFEST.in? I don't really know how that works.

Checked on this and we should not. https://setuptools.pypa.io/en/latest/userguide/miscellaneous.html says it's included by default and would have to be explicitly excluded in MANIFEST.in. I have modified that to exclude the ABI mode product (see below)

  1. I would like the (pytest) test suite to test both API and ABI mode based installs, to ensure we keep both working

Agreed - I'm a little confused about how to go about this - and also found difficulties in controlling setup.py in a fallback way (see below). It's possible we could reintroduce an env var to force a particular mode for testing?

  1. have a module-level thing that indicates which mode you're running in, e.g. xcffib.cffi_mode == "API" or "ABI"
    One question is: should we have the env var at all? What about just figuring out if the user has a C compiler available and using it?

At first I considered trying to have setup.py/setup() detect errors and fall back if compilation fails. This is fraught with issues and errors don't make it to exception handlers around setup() but rather break inside setup tools when invoked from pip.

After taking a step back I realized that ABI mode can also be precompiled which saves on import time.

This works like so:

setup() takes cffi_modules=["xcffib/ffi_build.py:ffi_build"] which says call ffi_build() from ffi_build.py. This function is expected to return an ffi on which compile() will be invoked by setuptools - however doing so is a no-op if it's already done. Instead, we try an attempt to make C compilation work, and fall back to generating _xcffib.py instead (which still requires dlopen() but skips all the cdefs as they're already done in a python-compiled way.

We also define some constants __XCFFIB_CFFI_MODE along with _XCFFIB_CFFI_MODE_API _XCFFIB_CFFI_MODE_ABI and _XCFFIB_CFFI_MODE_ABI_PRECOMPILED which allow the loaded ffi.lib to determine which mode was used. This is translated to xcffib.cffi_mode during module load ( the constants must be raw numbers, not strings etc).

What remains is to figure out a multi-mode test strategy.

@georgeharker georgeharker force-pushed the api-mode branch 3 times, most recently from 6af8cda to 012b318 Compare December 7, 2024 00:57
@georgeharker
Copy link
Author

I've noticed some issues with this approach (not so much for xcffib but for libraries wanting to build onto like cairocffi.

I will take a look at resolving these in a way that allows those to be build api mode - it basically hangs on being able to grab the non compiled ffi from ffi_build but i don't want that to parse cdefs multiple times. Just a little fiddly, but i'm sure doable.

@georgeharker georgeharker force-pushed the api-mode branch 2 times, most recently from f177735 to 08e767e Compare December 7, 2024 05:27
@georgeharker
Copy link
Author

I had originally tried defining constants in the complex xcffi library. Which is doable but in so doing I had redefined the ffi for each mode which was inefficient. I also realized I could do away with that entirely and just determine which is which in init mode.

After a bit of restructuring I think I have a workable solution which allows packages which depend on xcffib to pull the api version of the ffi to use as an include on their ffi.

It seems to work well. I haven't added tests yet.

Current state is in the PR. I'll do further checks on cairocffi to make sure this works as needed downstream.

If you have suggestions on how to write tests for all modes I'd be interested.

This should be pretty transparent for users. It should even work without running setup from the unpacked source.

Copy link
Owner

@tych0 tych0 left a comment

Choose a reason for hiding this comment

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

Thanks, this is looking pretty nice, a few small comments.

In terms of testing, however we can make sure CI runs both targets is fine with me. Here is one (though perhaps not the best/only) way I can imagine it working:

  1. change the xcffib build target to also run xcffib/ffi_build.py
  2. change CI to install a C compiler, then have it do make check && apt-get autoremove <the C compiler> && make clean && make check

That way we can test both modes. Ideally make check would check both and we wouldn't have to uninstall the C compiler, but I'm not really sure how to mask it when it's still installed. maybe CC=/bin/false is enough? not sure.

ffi_api.compile(verbose=True)
return ffi_api
except Exception:
warn("Falling back to precompiled python mode")
Copy link
Owner

Choose a reason for hiding this comment

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

Does this need to be a warn()?

Copy link
Author

Choose a reason for hiding this comment

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

the warning seems useful from a debug perspective - it's actually hard to get things to show up from the build subprocesses and by default nothing will show for pip install unless pip install -v is used. Can still change if desired.

module/ffi_build.py Outdated Show resolved Hide resolved
module/ffi_build.py Show resolved Hide resolved
module/__init__.py Outdated Show resolved Hide resolved
@georgeharker
Copy link
Author

georgeharker commented Dec 9, 2024

Thanks, this is looking pretty nice, a few small comments.

In terms of testing, however we can make sure CI runs both targets is fine with me. Here is one (though perhaps not the best/only) way I can imagine it working:

  1. change the xcffib build target to also run xcffib/ffi_build.py
  2. change CI to install a C compiler, then have it do make check && apt-get autoremove <the C compiler> && make clean && make check

That way we can test both modes. Ideally make check would check both and we wouldn't have to uninstall the C compiler, but I'm not really sure how to mask it when it's still installed. maybe CC=/bin/false is enough? not sure.

It turns out distutils uses the env var CC, so as you suggest, CC=/bin/false would do the trick. I'll take a stab at modifying the .github cc - would you be able to look at that when done?

I'm not actually sure if we are running pip install on the package before testing - so I suspect we are already falling back to out of line abi mode. In which case the simplest may be to do a series of pytest with a build in-between of the ffi using CC=/bin/false python3 xcffib/ffi_build.py and then python3 xcffib/ffi_build.py

@georgeharker
Copy link
Author

I've modified the makefile to check all modes.

@tych0
Copy link
Owner

tych0 commented Dec 9, 2024

 # check abi precompiled mode
CC=/bin/false python3 xcffib/ffi_build.py
Traceback (most recent call last):
  File "/home/runner/work/xcffib/xcffib/xcffib/ffi_build.py", line 16, in <module>
    from distutils.errors import CCompilerError, DistutilsExecError, DistutilsPlatformError
ModuleNotFoundError: No module named 'distutils'

on newer pythons, which makes sense, since:

    from distutils.errors import CCompilerError, DistutilsExecError, DistutilsPlatformError

seems to happen on older pythons. I see:

Traceback (most recent call last):
  File "/home/tycho/packages/qtile/qtile_venv/lib/python3.13/site-packages/setuptools/_distutils/spawn.py", line 70, in spawn
    subprocess.check_call(cmd, env=_inject_macos_ver(env))
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/subprocess.py", line 419, in check_call
    raise CalledProcessError(retcode, cmd)
subprocess.CalledProcessError: Command '['/usr/bin/false', '-fno-strict-overflow', '-Wsign-compare', '-DNDEBUG', '-g', '-O2', '-Wall', '-g', '-fstack-protector-strong', '-Wformat', '-Werror=format-security', '-g', '-fwrapv', '-O2', '-fPIC', '-I/home/tycho/packages/qtile/qtile_venv/include', '-I/usr/include/python3.13', '-c', '_xcffib.c', '-o', './_xcffib.o']' returned non-zero exit status 1.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/tycho/packages/qtile/qtile_venv/lib/python3.13/site-packages/setuptools/_distutils/unixccompiler.py", line 200, in _compile
    self.spawn(compiler_so + cc_args + [src, '-o', obj] + extra_postargs)
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/tycho/packages/qtile/qtile_venv/lib/python3.13/site-packages/setuptools/_distutils/ccompiler.py", line 1045, in spawn
    spawn(cmd, dry_run=self.dry_run, **kwargs)
    ~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/tycho/packages/qtile/qtile_venv/lib/python3.13/site-packages/setuptools/_distutils/spawn.py", line 76, in spawn
    raise DistutilsExecError(
        f"command {_debug(cmd)!r} failed with exit code {err.returncode}"
    ) from err
distutils.errors.DistutilsExecError: command '/usr/bin/false' failed with exit code 1

So I wonder if we can directly import it somehow? I played around a bit and couldn't figure it out.

@georgeharker
Copy link
Author

georgeharker commented Dec 9, 2024

Evidently the thing to do is to import setup tools which we already rely on and sub imports / shims those errors. Updated the PR.

@georgeharker
Copy link
Author

Evidently the thing to do is to import setup tools which we already rely on and sub imports / shims those errors. Updated the PR.

Ive fixed the derived imports which change name slightly. This appear to now work.

@georgeharker
Copy link
Author

Evidently the thing to do is to import setup tools which we already rely on and sub imports / shims those errors. Updated the PR.

Ive fixed the derived imports which change name slightly. This appear to now work.

CI checks are failing for reasons I don't understand - looks like missing xcb and cffi?

Makefile Outdated
python3 -c "import xcffib; assert xcffib.cffi_mode == 'abi_precompiled'"
pytest-3 -v --durations=3 -n auto
# check api mode
python3 xcffib/ffi_build.py
Copy link
Owner

Choose a reason for hiding this comment

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

This seems like it generates stuff in the root directory, shouldn't it really live in module/? If not, why not? Can we add it to the clean target if not?

Copy link
Author

Choose a reason for hiding this comment

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

It does. As does the setup which would install those libs). But it is doable to have them built in xcffib/ and to adjust adjust the import paths in __init__.py. I’ll do that and write back.

Copy link
Author

Choose a reason for hiding this comment

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

This now compiles in temp and copies in. The extensions will be under xcffib/

@tych0
Copy link
Owner

tych0 commented Dec 10, 2024

CI checks are failing for reasons I don't understand - looks like missing xcb and cffi?

Yeah, very odd. The pytest-3 call right above that works, which should run the very same import line, IIUC.

@georgeharker
Copy link
Author

CI checks are failing for reasons I don't understand - looks like missing xcb and cffi?

Yeah, very odd. The pytest-3 call right above that works, which should run the very same import line, IIUC.

I've modified the PR to include a more isolated environment and try to set the PYTHON var used for python in the workflow.

@tych0
Copy link
Owner

tych0 commented Dec 10, 2024

Yeah, I think this test failure is my fault, let me see if I can figure it out.

@tych0
Copy link
Owner

tych0 commented Dec 10, 2024

Bit of an embarrassing bug :). Can you see if #172 fixes it for you?

@georgeharker
Copy link
Author

Overall looks pretty good to me. Two minor comments, and then if you clean up the git history, I'm happy to merge + release.

Fantastic! Resolved the comments and have squashed the commits for a clean history.

@georgeharker georgeharker force-pushed the api-mode branch 2 times, most recently from 600f3f9 to 5689a2d Compare December 11, 2024 21:54
@tych0
Copy link
Owner

tych0 commented Dec 11, 2024

I think it'll fail on pushd & popd too, or at least it did for me locally:

pushd /tmp/tmp.PCgMxGyCa6 && \
	/tmp/tmp.PCgMxGyCa6/bin/python3 -c "import xcffib; assert xcffib.cffi_mode == 'api'" && \
	popd
/bin/sh: 1: pushd: not found
make: *** [Makefile:96: check-api] Error 127

I think it's fine to make this into a script that we call, vs. having line-continuation-make nonsense.

@georgeharker
Copy link
Author

I think it'll fail on pushd & popd too, or at least it did for me locally:

pushd /tmp/tmp.PCgMxGyCa6 && \
	/tmp/tmp.PCgMxGyCa6/bin/python3 -c "import xcffib; assert xcffib.cffi_mode == 'api'" && \
	popd
/bin/sh: 1: pushd: not found
make: *** [Makefile:96: check-api] Error 127

I think it's fine to make this into a script that we call, vs. having line-continuation-make nonsense.

I have an easy fix and will upload

@georgeharker
Copy link
Author

latest fails may be prior to my change to have the outputs copied individually? I don't see the copy lines in the logs. Wondering if I'm missing something.

@tych0
Copy link
Owner

tych0 commented Dec 11, 2024

I think it's because both check-api and check-abi really have a dependency on xcffib, but it's not declared in the current makefile? or at least, I hope that's it :)

@georgeharker
Copy link
Author

good catch - thanks, have fixed

@georgeharker
Copy link
Author

There's still a race in the Makefile which I'm tracking down, it's the root of the failures. Have recreated here.

@georgeharker
Copy link
Author

Gah! How frustrating. This works locally. I'm fairly convinced this is a race. But I'm not quite sure why. I'm going to run this under Debian and try and figure it out.

@tych0
Copy link
Owner

tych0 commented Dec 12, 2024

Classic computers. I'm afk for the next few days, but I can take a look early next week too.

attempt to compile xcffib by default.  This makes a pre-parsed ffi we can import without parsing cdefs at import time.

If c compilation API mode fails, fall back to ABI mode but still precompile.

Finally if the compiled library is missing for some reason fall back to import time ABI mode.

xcffib.cffi_mode reflects which option was used.
@georgeharker
Copy link
Author

Thanks for your efforts on this!

@tych0
Copy link
Owner

tych0 commented Jan 8, 2025

Yeah, sorry it's taken me a while to get to it. I hope I'm pretty close... let me know what you think of that patch and I can fold it in and clean it up as appropriate once I get it working.

@georgeharker
Copy link
Author

It looks great to me - and I think using sh will be a lot cleaner and avoid the race I had!

@tych0 tych0 force-pushed the api-mode branch 2 times, most recently from b529d72 to c2ce3b7 Compare January 8, 2025 21:11
tych0 added 2 commits January 8, 2025 14:20
...this way we can actually compile for api mode again.

Signed-off-by: Tycho Andersen <[email protected]>
@tych0
Copy link
Owner

tych0 commented Jan 8, 2025

Ok, looks like we still have one race, but I don't really want to fight it that hard. If I can't figure it out, I'll just rip out parallel test execution, it's not that bad to have them go serially.

One question I have, that maybe we talked about before but I have since forgotten is: under what circumstances does abi mode get used? abi_precompiled is when the user didn't have a C compiler during installation (which... isn't _precompiled a misnomer there? shouldn't that really just be abi?), but when does abi get used?

@tych0
Copy link
Owner

tych0 commented Jan 8, 2025

In particular, I would like to test it, but I don't understand how.

@georgeharker
Copy link
Author

I'm so confused as to why it clashes like that. Thanks for looking into it.

abi_precompiled corresponds to this doc https://cffi.readthedocs.io/en/stable/overview.html#out-of-line-abi-level a It works by making a python lib that can be imported and uses ffilib which is slower than c. It's precompiled in the sense that the Cdef parsing has been done and made into opaque structures in the python lib which gets imported.

abi mode is this one https://cffi.readthedocs.io/en/stable/overview.html#simple-example-abi-level-in-line should make it possible to download the library fresh and import it without install - in which case there would be no share lib made (no setup tools install has run). I think a fresh checkout (with python gen run) would do the trick and an import on that library. It will parse cdefs at import time.

@tych0
Copy link
Owner

tych0 commented Jan 9, 2025

should make it possible to download the library fresh and import it without install

I am curious: how would one do that? When we ship the thing via pypi, the setuptools cffi bits will (presumably) always run, and so this mode is really only for development, IIUC? If so, since we're not really using it for local testing any more, I wonder if we should fail here and declare it as explicitly unsupported since we can't test it with the new install-to-test scheme.

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