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

feat: free-threaded Python (3.13.0+) support #165

Merged
merged 24 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f335468
Rebase to master
jmao-denver Sep 23, 2024
a1fb456
More thread-safety change/refactoring
jmao-denver Sep 23, 2024
e93774a
Fix a race between PO cleanup thread and Python
jmao-denver Sep 27, 2024
e65f8ff
Add free-threaded build in GH action
jmao-denver Oct 21, 2024
6a0f8fe
Fix dll names for FT/debug builds
jmao-denver Oct 24, 2024
2cb3314
Cleanup and more comments
jmao-denver Oct 24, 2024
fe97888
Rollback temp change and add clarifying comments
jmao-denver Oct 24, 2024
ab38dcb
Complete release action for FT builds
jmao-denver Oct 24, 2024
16ba89a
Merge ft builds into build.yml
jmao-denver Oct 24, 2024
40ef816
Use GITHUB_PATH
jmao-denver Oct 29, 2024
a5718c1
Squash windows-t build, add Java ver in matrix
jmao-denver Oct 29, 2024
9aa8250
Fix for invalid syntax for powershell
jmao-denver Oct 29, 2024
752a0a7
Add upload action for JVM core/log
jmao-denver Oct 29, 2024
e1f841b
debug build.yml
jmao-denver Oct 29, 2024
0165921
Apply suggestions from code review
jmao-denver Oct 29, 2024
31b9010
Restore a block of code deleted by mistake
jmao-denver Oct 29, 2024
ccc3253
debug Windows FT build
jmao-denver Oct 29, 2024
d972d96
Add comments, use version directives
jmao-denver Oct 30, 2024
4058638
Code cleanup
jmao-denver Oct 31, 2024
173592d
Fix a bug in convert() for java array type
jmao-denver Nov 2, 2024
eea87e7
Remove duplicate comment
jmao-denver Nov 4, 2024
c9d7bfa
Add clarifying comment
jmao-denver Nov 4, 2024
1c51917
Respond to review comments
jmao-denver Nov 6, 2024
99f130a
Naming change to be more applicable in FT mode
jmao-denver Nov 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 26 additions & 38 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ jobs:
path: dist/*.whl
retention-days: 1

bdist-wheels-linux-arm64:
bdist-wheel-linux-arm64:
runs-on: 'ubuntu-22.04'
steps:
- uses: actions/checkout@v4
Expand All @@ -156,58 +156,46 @@ jobs:

- uses: actions/upload-artifact@v4
with:
name: bdist-wheels-linux-arm64
name: bdist-wheel-linux-arm64
path: /tmp/dist/*.whl
retention-days: 1

bdist-wheels-t:
bdist-wheel-t:
runs-on: ${{ matrix.info.machine }}
strategy:
fail-fast: false
matrix:
info:
- { machine: 'ubuntu-20.04', python: '3.13t', arch: 'amd64', cmd: '.github/env/Linux/bdist-wheel.sh' }
- { machine: 'macos-13', python: '3.13t', arch: 'amd64', cmd: '.github/env/macOS/bdist-wheel.sh' }
- { machine: 'macos-latest', python: '3.13t', arch: 'arm64', cmd: '.github/env/macOS/bdist-wheel.sh' }
- { machine: 'ubuntu-20.04', python: '3.13t', java: '8', arch: 'amd64', cmd: '.github/env/Linux/bdist-wheel.sh' }
- { machine: 'windows-2022', python: '3.13t', java: '8', arch: 'amd64', cmd: '.\.github\env\Windows\bdist-wheel.ps1' }
- { machine: 'macos-13', python: '3.13t', java: '8', arch: 'amd64', cmd: '.github/env/macOS/bdist-wheel.sh' }
- { machine: 'macos-latest', python: '3.13t', java: '11', arch: 'arm64', cmd: '.github/env/macOS/bdist-wheel.sh' }
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v3
- run: |
uv python install ${{ matrix.info.python }}
uv venv --python ${{ matrix.info.python }}
source .venv/bin/activate
uv pip install pip
echo $JAVA_HOME
echo PATH=$PATH >> $GITHUB_ENV

- run: ${{ matrix.info.cmd }}

- uses: actions/upload-artifact@v4
- uses: actions/setup-java@v4
id: setup-java
with:
name: build-${{ matrix.info.python }}-${{ matrix.info.machine }}-${{ matrix.info.arch }}
path: dist/*.whl
retention-days: 1

bdist-wheels-windows-t:
runs-on: ${{ matrix.info.machine }}
strategy:
fail-fast: false
matrix:
info:
- { machine: 'windows-2022', python: '3.13t', arch: 'amd64', cmd: '.\.github\env\Windows\bdist-wheel.ps1' }

steps:
- uses: actions/checkout@v4
distribution: 'temurin'
java-version: ${{ matrix.info.java }}

- uses: astral-sh/setup-uv@v3
- run: |
- if : ${{ startsWith(matrix.info.machine, 'windows')}}
run: |
uv python install ${{ matrix.info.python }}
uv venv --python ${{ matrix.info.python }}
.venv\Scripts\Activate.ps1
uv pip install pip
echo PATH=%PATH% >> $GITHUB_ENV
echo PATH=$PATH >> $GITHUB_PATH
${{ matrix.info.cmd }}
- if : ${{ ! startsWith(matrix.info.machine, 'windows')}}
run: |
uv python install ${{ matrix.info.python }}
uv venv --python ${{ matrix.info.python }}
source .venv/bin/activate
uv pip install pip
echo PATH=$PATH >> $GITHUB_PATH
${{ matrix.info.cmd }}
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved

- uses: actions/upload-artifact@v4
Expand All @@ -216,13 +204,13 @@ jobs:
path: dist/*.whl
retention-days: 1

bdist-wheels-linux-arm64-t:
bdist-wheel-linux-arm64-t:
runs-on: ${{ matrix.info.machine }}
strategy:
fail-fast: false
matrix:
info:
- { machine: 'ubuntu-20.04', python: '3.13t', arch: 'aarch64', cmd: '.github/env/Linux/bdist-wheel.sh' }
- { machine: 'ubuntu-20.04', python: '3.13t', java: '11', arch: 'aarch64', cmd: '.github/env/Linux/bdist-wheel.sh' }

steps:
- uses: actions/checkout@v4
Expand All @@ -238,7 +226,7 @@ jobs:
CIBW_BUILD: "cp313t-*"
CIBW_SKIP: "cp313t-musllinux_aarch64"
CIBW_BEFORE_ALL_LINUX: >
yum install -y java-11-openjdk-devel &&
yum install -y java-${{ matrix.info.java }}-openjdk-devel &&
yum install -y wget &&
wget https://www.apache.org/dist/maven/maven-3/3.8.8/binaries/apache-maven-3.8.8-bin.tar.gz -P /tmp &&
tar xf /tmp/apache-maven-3.8.8-bin.tar.gz -C /opt &&
Expand All @@ -258,7 +246,7 @@ jobs:

collect-artifacts:
runs-on: ubuntu-22.04
needs: ['sdist', 'bdist-wheel', 'bdist-wheel-universal2-hack', 'bdist-wheels-linux-arm64', 'bdist-wheels-t', 'bdist-wheels-windows-t', 'bdist-wheels-linux-arm64-t']
needs: ['sdist', 'bdist-wheel', 'bdist-wheel-universal2-hack', 'bdist-wheel-linux-arm64', 'bdist-wheel-t', 'bdist-wheel-linux-arm64-t']
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ jobs:
- name: Run Test
run: python setup.py test

- name: Upload JVM Error Logs
uses: actions/upload-artifact@v4
if: failure()
with:
name: check-ci-jvm-err
path: |
**/*_pid*.log
**/core.*
if-no-files-found: ignore

test-free-threaded:
Copy link
Member

Choose a reason for hiding this comment

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

I see windows-specific and mac-specific code, but I don't see CI checks for the platforms. There do appear to be windows runners. https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners
It isn't the highest priority, but I am noting it.

runs-on: ubuntu-22.04
strategy:
Expand Down Expand Up @@ -58,3 +68,12 @@ jobs:
- name: Run Free-threaded Test
run: python setup.py test

- name: Upload JVM Error Logs
uses: actions/upload-artifact@v4
if: failure()
with:
name: check-ci-jvm-err
path: |
**/*_pid*.log
**/core.*
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions jpyutil.py
devinrsmith marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ def _find_python_dll_file(fail=False):
if extra_dir and extra_dir not in search_dirs and os.path.exists(extra_dir):
search_dirs.append(extra_dir)

if platform.system() == 'Windows':
extra_search_dirs = _get_existing_subdirs(search_dirs, "DLLs")
search_dirs = extra_search_dirs + search_dirs

multi_arch_sub_dir = sysconfig.get_config_var('multiarchsubdir')
if multi_arch_sub_dir:
while multi_arch_sub_dir.startswith('/'):
Expand Down
18 changes: 4 additions & 14 deletions src/main/c/jni/org_jpy_PyLib.c
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ void dumpDict(const char* dictName, PyObject* dict)
size = PyDict_Size(dict);
printf(">> dumpDict: %s.size = %ld\n", dictName, size);
#ifdef Py_GIL_DISABLED
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
// PyDict_Next is not thread-safe, so we need to protect it with a critical section
// https://docs.python.org/3/howto/free-threading-extensions.html#pydict-next
Py_BEGIN_CRITICAL_SECTION(dict);
#endif
while (PyDict_Next(dict, &pos, &key, &value)) {
Expand Down Expand Up @@ -563,7 +565,7 @@ JNIEXPORT jobject JNICALL Java_org_jpy_PyLib_getCurrentGlobals

JPy_BEGIN_GIL_STATE

#if PY_VERSION_HEX < 0x030D0000
#if PY_VERSION_HEX < 0x030D0000 // < 3.13
globals = PyEval_GetGlobals(); // borrowed ref
JPy_XINCREF(globals);
#else
Expand Down Expand Up @@ -594,7 +596,7 @@ JNIEXPORT jobject JNICALL Java_org_jpy_PyLib_getCurrentLocals

JPy_BEGIN_GIL_STATE

#if PY_VERSION_HEX < 0x030D0000
#if PY_VERSION_HEX < 0x030D0000 // < 3.13
locals = PyEval_GetLocals(); // borrowed ref
JPy_XINCREF(locals);
#else
Expand Down Expand Up @@ -1130,11 +1132,7 @@ JNIEXPORT void JNICALL Java_org_jpy_PyLib_incRef
if (Py_IsInitialized()) {
JPy_BEGIN_GIL_STATE

#if PY_VERSION_HEX < 0x030D0000
refCount = pyObject->ob_refcnt;
#else
refCount = Py_REFCNT(pyObject);
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
#endif
JPy_DIAG_PRINT(JPy_DIAG_F_MEM, "Java_org_jpy_PyLib_incRef: pyObject=%p, refCount=%d, type='%s'\n", pyObject, refCount, Py_TYPE(pyObject)->tp_name);
JPy_INCREF(pyObject);

Expand All @@ -1160,11 +1158,7 @@ JNIEXPORT void JNICALL Java_org_jpy_PyLib_decRef
if (Py_IsInitialized()) {
JPy_BEGIN_GIL_STATE

#if PY_VERSION_HEX < 0x030D0000
refCount = pyObject->ob_refcnt;
#else
refCount = Py_REFCNT(pyObject);
#endif
if (refCount <= 0) {
JPy_DIAG_PRINT(JPy_DIAG_F_ALL, "Java_org_jpy_PyLib_decRef: error: refCount <= 0: pyObject=%p, refCount=%d\n", pyObject, refCount);
} else {
Expand Down Expand Up @@ -1197,11 +1191,7 @@ JNIEXPORT void JNICALL Java_org_jpy_PyLib_decRefs
buf = (*jenv)->GetLongArrayElements(jenv, objIds, &isCopy);
for (i = 0; i < len; i++) {
pyObject = (PyObject*) buf[i];
#if PY_VERSION_HEX < 0x030D0000
refCount = pyObject->ob_refcnt;
#else
refCount = Py_REFCNT(pyObject);
#endif
if (refCount <= 0) {
JPy_DIAG_PRINT(JPy_DIAG_F_ALL, "Java_org_jpy_PyLib_decRefs: error: refCount <= 0: pyObject=%p, refCount=%d\n", pyObject, refCount);
} else {
Expand Down
13 changes: 8 additions & 5 deletions src/main/c/jpy_jobj.c
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,23 @@ PyObject* JObj_FromType(JNIEnv* jenv, JPy_JType* type, jobject objectRef)

// we check the type translations dictionary for a callable for this java type name,
// and apply the returned callable to the wrapped object
#ifdef Py_GIL_DISABLED
// if return is 1, callable is new reference
#if PY_VERSION_HEX < 0x030D0000 // < 3.13
// borrowed ref
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
callable = PyDict_GetItemString(JPy_Type_Translations, type->javaName); // borrowed reference
JPy_XINCREF(callable);
#else
// https://docs.python.org/3/howto/free-threading-extensions.html#borrowed-references
// PyDict_GetItemStringRef() is a thread safe version of PyDict_GetItemString() and returns a new reference
if (PyDict_GetItemStringRef(JPy_Type_Translations, type->javaName, &callable) != 1) {
callable = NULL;
}
#else
callable = PyDict_GetItemString(JPy_Type_Translations, type->javaName); // borrowed reference
JPy_XINCREF(callable);
#endif

if (callable != NULL) {
if (PyCallable_Check(callable)) {
callableResult = PyObject_CallFunction(callable, "OO", type, obj);
JPy_XDECREF(callable);
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
JPy_XDECREF(obj);
if (callableResult == NULL) {
Py_RETURN_NONE;
} else {
Expand Down
43 changes: 25 additions & 18 deletions src/main/c/jpy_jtype.c
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,41 @@
#include "jpy_compat.h"

#ifdef Py_GIL_DISABLED
// Reentrant lock for the recursive JType_GetType() and JType_ResolveType() in free-threaded environments
// Note that in order to avoid a fatal circular reference issues, JType_InitSuperType() no longer tries to resolve
// the super types. This means it is impossible for a thread to hold a get_type lock while trying to acquire the
// resolve_type lock but it can still hold a resolve_type lock while trying to acquire the get_type lock. This allows
// maximum concurrency but also avoids deadlocks at the same time.
typedef struct {
PyMutex lock;
PyThreadState* owner;
int recursion_level;
} ReentrantLock;

static void acquire_lock(ReentrantLock* self) {
static void acquire_lock(ReentrantLock* rlock) {
PyThreadState* current_thread = PyThreadState_Get();

if (self->owner == current_thread) {
self->recursion_level++;
if (rlock->owner == current_thread) {
rlock->recursion_level++;
return;
}

PyMutex_Lock(&(self->lock));
PyMutex_Lock(&(rlock->lock));

self->owner = current_thread;
self->recursion_level = 1;
rlock->owner = current_thread;
rlock->recursion_level = 1;
}

static void release_lock(ReentrantLock* self) {
if (self->owner != PyThreadState_Get()) {
static void release_lock(ReentrantLock* rlock) {
if (rlock->owner != PyThreadState_Get()) {
PyErr_SetString(PyExc_RuntimeError, "Lock not owned by current thread");
return;
}

self->recursion_level--;
if (self->recursion_level == 0) {
self->owner = NULL;
PyMutex_Unlock(&(self->lock));
rlock->recursion_level--;
if (rlock->recursion_level == 0) {
rlock->owner = NULL;
PyMutex_Unlock(&(rlock->lock));
}
}

Expand Down Expand Up @@ -202,7 +207,7 @@ JPy_JType* JType_GetType(JNIEnv* jenv, jclass classRef, jboolean resolve)
}

ACQUIRE_GET_TYPE_LOCK();
// borrowed ref, no need to replace with PyDict_GetItemRef because it protected by the lock
// borrowed ref, no need to replace with PyDict_GetItemRef because it is protected by the lock
typeValue = PyDict_GetItem(JPy_Types, typeKey);
if (typeValue == NULL) {

Expand Down Expand Up @@ -1082,14 +1087,16 @@ jboolean JType_AcceptMethod(JPy_JType* declaringClass, JPy_JMethod* method)
PyObject* callableResult;

//printf("JType_AcceptMethod: javaName='%s'\n", overloadedMethod->declaringClass->javaName);
#ifdef Py_GIL_DISABLED
// if return is 1, callable is new reference
#if PY_VERSION_HEX < 0x030D0000 // < 3.13
// borrowed ref
callable = PyDict_GetItemString(JPy_Type_Callbacks, declaringClass->javaName);
JPy_XINCREF(callable);
#else
// https://docs.python.org/3/howto/free-threading-extensions.html#borrowed-references
// PyDict_GetItemStringRef() is a thread safe version of PyDict_GetItemString() and returns a new reference
if (PyDict_GetItemStringRef(JPy_Type_Callbacks, declaringClass->javaName, &callable) != 1) {
cpwright marked this conversation as resolved.
Show resolved Hide resolved
callable = NULL;
}
#else
callable = PyDict_GetItemString(JPy_Type_Callbacks, declaringClass->javaName); // borrowed reference
JPy_XINCREF(callable);
#endif

if (callable != NULL) {
Expand Down
19 changes: 2 additions & 17 deletions src/main/c/jpy_module.c
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ PyObject* JPy_convert_internal(JNIEnv* jenv, PyObject* self, PyObject* args)
if (targetTypeParsed == NULL) {
return NULL;
}
JPy_DECREF(targetTypeParsed);
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
} else if (JType_Check(targetTypeArg)) {
targetTypeParsed = (JPy_JType*) targetTypeArg;
} else {
Expand All @@ -649,23 +650,7 @@ PyObject* JPy_convert_internal(JNIEnv* jenv, PyObject* self, PyObject* args)
return NULL;
}

// Create a global reference for the objectRef (so it is valid after we exit this frame)
objectRef = (*jenv)->NewGlobalRef(jenv, objectRef);
if (objectRef == NULL) {
PyErr_NoMemory();
return NULL;
}

// Create a PyObject (JObj) to hold the result
resultObj = (JPy_JObj*) PyObject_New(JPy_JObj, JTYPE_AS_PYTYPE(targetTypeParsed));
if (resultObj == NULL) {
(*jenv)->DeleteGlobalRef(jenv, objectRef);
return NULL;
}
// Store the reference to the converted object in the result JObj
((JPy_JObj*) resultObj)->objectRef = objectRef;

return (PyObject*) resultObj;
return JObj_FromType(jenv, targetTypeParsed, objectRef);
jmao-denver marked this conversation as resolved.
Show resolved Hide resolved
}


Expand Down
2 changes: 1 addition & 1 deletion src/main/java/org/jpy/PyObject.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public static int cleanup() {
PyObject(long pointer, boolean fromJNI) {
state = new PyObjectState(pointer);
if (fromJNI) {
if (CLEANUP_ON_INIT) {
if (CLEANUP_ON_INIT && PyLib.hasGil()) {
REFERENCES.cleanupOnlyUseFromGIL(); // only performs *one* cleanup
Copy link
Contributor

Choose a reason for hiding this comment

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

If we are no longer using the GIL, then we should rename this function and instead have comments on why it is correct. If we are on 3.12 mode why are we still correct?

Copy link
Contributor Author

@jmao-denver jmao-denver Oct 31, 2024

Choose a reason for hiding this comment

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

This change is made to fix a bug based on the following two observations:

  1. Because we now release the GIL whenever we cross into Java from Python (by @niloc132), the PyLib.hasGIL() test will always fail in Python GIL enabled mode, which results in that the cleanup method will never get called anymore. (BTW, in the GIL disabled mode, PyLib.hasGIL() returns true).
  2. As part of the same enhancement change by @niloc132, he made sure that the cleanup is done under GIL.
    private int cleanupOnlyUseFromGIL(long[] buffer) {
        return PyLib.ensureGil(() -> {
            int index = 0;
            while (index < buffer.length) {
                final Reference<? extends PyObject> reference = referenceQueue.poll();
                if (reference == null) {
   ...

Copy link
Contributor

Choose a reason for hiding this comment

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

@jmao-denver is right, there are several layers of questionable here, and the latest round here is my fault.

  • latest stable release: hasGil is always false here, so this block never executes
  • prior to the "release gil while in Java" change: hasGil is always true, so this always runs.

Why did it even test to begin with? Why does it clean up all other object any time any PyObject is about to be passed in to Java (instead of just once either just before or just after calling Java)? Couldn't say. I think removing the check is correct (either just before the ft patch, or as part of it), and we should reevaluate what is even happening here separately.

Agreed on renaming cleanupOnlyUseFromGIL.

Copy link
Member

Choose a reason for hiding this comment

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

This was an oversight on my part during the "drop GIL" review.

I made the argument during the review that we might prefer to have some way to keep the GIL during "quick" java calls, or in other cases where we explicitly want to keep the GIL. I might argue that our call from JType_CreateJavaPyObject into the constructor of PyObject should keep the GIL.

The reason we need this to only be called into the context of the GIL (historically) is because we need the GIL when calling PyLib.decRef; I'm assuming in the case of free-threaded (/GIL-less) builds, Py_DECREF is thread safe. And it's not only that Py_DECREF that needs to be thread-safe, the arbitrary python deallocation code that can be executed as part of a decRef to 0 needs to be thread-safe.

In the context of free-threaded / GIL-less builds, maybe the functions need to be renamed to be something like PyLib.isThreadSafe / PyLib.ensureThreadSafe, or something like that.

}
if (CLEANUP_ON_THREAD) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/org/jpy/PyObjectReferences.java
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ def cleanup(references):
}
}
catch (RuntimeException e) {
if (!e.getMessage().contains("PyLib not initialized")) {
String msg;
if ((msg = e.getMessage()) != null && !msg.contains("PyLib not initialized")) {
throw e;
}
}
Expand Down
Loading
Loading