Skip to content

Commit

Permalink
3.13t free-threading compatibility
Browse files Browse the repository at this point in the history
  • Loading branch information
ijl committed Oct 29, 2024
1 parent ac82ace commit 28e33b4
Show file tree
Hide file tree
Showing 29 changed files with 511 additions and 265 deletions.
20 changes: 14 additions & 6 deletions .github/workflows/artifact.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:

- run: python3 -m pip install --user -r test/requirements.txt -r integration/requirements.txt mypy

- run: pytest -s -rxX -v -n 4 test
- run: pytest -v test
env:
PYTHONMALLOC: "debug"

Expand Down Expand Up @@ -76,6 +76,7 @@ jobs:
},
]
python: [
{ interpreter: 'python3.13t', package: 'python3.13-freethreading', compatibility: "manylinux_2_34" },
{ interpreter: 'python3.13', package: 'python3.13', compatibility: "manylinux_2_17" },
{ interpreter: 'python3.12', package: 'python3.12', compatibility: "manylinux_2_17" },
{ interpreter: 'python3.11', package: 'python3.11', compatibility: "manylinux_2_17" },
Expand All @@ -85,6 +86,7 @@ jobs:
env:
PYTHON: "${{ matrix.python.interpreter }}"
PYTHON_PACKAGE: "${{ matrix.python.package }}"
PYTHON_GIL: "0"
TARGET: "${{ matrix.arch.target }}"
CC: "${{ matrix.arch.cc }}"
VENV: ".venv"
Expand Down Expand Up @@ -127,6 +129,8 @@ jobs:
source "${VENV}/bin/activate"
export ORJSON_ENABLE_FREETHREADING="$(script/is_freethreading)"
maturin build --release --strip \
--features="${FEATURES}" \
--compatibility="${COMPATIBILITY}" \
Expand All @@ -135,7 +139,7 @@ jobs:
uv pip install ${CARGO_TARGET_DIR}/wheels/orjson*.whl
pytest -s -rxX -v -n 4 test
pytest -v test
./integration/run thread
./integration/run http
./integration/run init
Expand Down Expand Up @@ -170,11 +174,13 @@ jobs:
},
]
python: [
{ interpreter: 'python3.13t', package: 'python3.13-freethreading', compatibility: "manylinux_2_34" },
{ interpreter: 'python3.13', package: 'python3.13', compatibility: "manylinux_2_17" },
]
env:
PYTHON: "${{ matrix.python.interpreter }}"
PYTHON_PACKAGE: "${{ matrix.python.package }}"
PYTHON_GIL: "0"
TARGET: "${{ matrix.arch.target }}"
CC: "${{ matrix.arch.cc }}"
VENV: ".venv"
Expand Down Expand Up @@ -217,6 +223,8 @@ jobs:
source "${HOME}/.cargo/env"
source "${VENV}/bin/activate"
export ORJSON_ENABLE_FREETHREADING="$(script/is_freethreading)"
maturin build --release --strip \
--features="${FEATURES}" \
--compatibility="${COMPATIBILITY}" \
Expand All @@ -225,7 +233,7 @@ jobs:
uv pip install ${CARGO_TARGET_DIR}/wheels/orjson*.whl
pytest -s -rxX -v -n 2 test
pytest -v test
cp ${CARGO_TARGET_DIR}/wheels/orjson*.whl dist
Expand Down Expand Up @@ -302,7 +310,7 @@ jobs:
venv/bin/pip install -U pip wheel
venv/bin/pip install -r test/requirements.txt
venv/bin/pip install orjson --no-index --find-links dist/ --force-reinstall
venv/bin/python -m pytest -s -rxX -v -n 2 test
venv/bin/python -m pytest -v test
- name: Store wheels
if: "startsWith(github.ref, 'refs/tags/')"
Expand Down Expand Up @@ -441,7 +449,7 @@ jobs:
--target=universal2-apple-darwin
uv pip install target/wheels/orjson*.whl
- run: pytest -s -rxX -v -n 3 test
- run: pytest -v test
env:
PYTHONMALLOC: "debug"

Expand Down Expand Up @@ -510,7 +518,7 @@ jobs:
--target=universal2-apple-darwin
uv pip install target/wheels/orjson*.whl
- run: pytest -s -rxX -v -n 3 test
- run: pytest -v test
env:
PYTHONMALLOC: "debug"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/debug.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
env:
PYTHONMALLOC: "debug"

- run: ./integration/run thread
- run: ./integration/run concurrent
timeout-minutes: 2

- run: ./integration/run http
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ unwind = ["unwinding"]
yyjson = []

# Features detected by build.rs. Do not specify.
freethreading = []
inline_int = []
intrinsics = []
optimize = []
Expand Down
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ and i686/x86 wheels for Windows.
orjson does not and will not support PyPy, embedded Python builds for
Android/iOS, or PEP 554 subinterpreters.

orjson has experimental support for PEP 703 free-threading CPython.

Releases follow semantic versioning and serializing a new object type
without an opt-in flag is considered a breaking change.

Expand Down Expand Up @@ -74,9 +76,10 @@ available in the repository.
1. [Latency](https://github.com/ijl/orjson?tab=readme-ov-file#latency)
2. [Memory](https://github.com/ijl/orjson?tab=readme-ov-file#memory)
3. [Reproducing](https://github.com/ijl/orjson?tab=readme-ov-file#reproducing)
5. [Questions](https://github.com/ijl/orjson?tab=readme-ov-file#questions)
6. [Packaging](https://github.com/ijl/orjson?tab=readme-ov-file#packaging)
7. [License](https://github.com/ijl/orjson?tab=readme-ov-file#license)
6. [Free-threading](https://github.com/ijl/orjson?tab=readme-ov-file#free-threading)
7. [Questions](https://github.com/ijl/orjson?tab=readme-ov-file#questions)
8. [Packaging](https://github.com/ijl/orjson?tab=readme-ov-file#packaging)
9. [License](https://github.com/ijl/orjson?tab=readme-ov-file#license)

## Usage

Expand Down Expand Up @@ -161,7 +164,8 @@ serializing subclasses, specify the option `orjson.OPT_PASSTHROUGH_SUBCLASS`.

The output is a `bytes` object containing UTF-8.

The global interpreter lock (GIL) is held for the duration of the call.
In non-free-threading Python, the global interpreter lock (GIL) is held for
the duration of the call.

It raises `JSONEncodeError` on an unsupported type. This exception message
describes the invalid object with the error message
Expand Down Expand Up @@ -630,7 +634,8 @@ orjson maintains a cache of map keys for the duration of the process. This
causes a net reduction in memory usage by avoiding duplicate strings. The
keys must be at most 64 bytes to be cached and 2048 entries are stored.

The global interpreter lock (GIL) is held for the duration of the call.
In non-free-threading Python, the global interpreter lock (GIL) is held for
the duration of the call.

It raises `JSONDecodeError` if given an invalid type or invalid
JSON. This includes if the input contains `NaN`, `Infinity`, or `-Infinity`,
Expand Down Expand Up @@ -1179,6 +1184,22 @@ orjson 3.10.6, ujson 5.10.0, python-rapidson 1.18, and simplejson 3.19.2.
The latency results can be reproduced using the `pybench` and `graph`
scripts. The memory results can be reproduced using the `pymem` script.

## Free-threading

orjson 3.11.0 introduces experimental support for PEP 703 free-threading CPython.

orjson supports an arbitrary number of Python threads concurrently calling the
library. There are no threads or queues internal to the library. There are
no Python critical sections.

PyPI wheels are provided for manylinux amd64 and aarch64.

To build a wheel with free-threading support, see
[packaging](https://github.com/ijl/orjson?tab=readme-ov-file#packaging).

The free-threading implementation does not respect semantic versioning and may
be removed entirely in the future.

## Questions

### Why can't I install it from PyPI?
Expand Down Expand Up @@ -1216,6 +1237,10 @@ It benefits from also having a C build environment to compile a faster
deserialization backend. See this project's `manylinux_2_28` builds for an
example using clang and LTO.

Building a wheel with freethreading support requires the environmental
variable `ORJSON_ENABLE_FREETHREADING` set during build or the
`--features=freethreading` argument passed to maturin.

The project's own CI tests against `nightly-2024-10-25` and stable 1.72. It
is prudent to pin the nightly version because that channel can introduce
breaking changes.
Expand Down
2 changes: 1 addition & 1 deletion bench/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
memory-profiler
memory-profiler; python_version<"3.13"
pandas; python_version<"3.13"
pytest-benchmark
pytest-random-order
Expand Down
8 changes: 8 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fn main() {
println!("cargo:rerun-if-env-changed=ORJSON_DISABLE_AVX512");
println!("cargo:rerun-if-env-changed=ORJSON_DISABLE_SIMD");
println!("cargo:rerun-if-env-changed=ORJSON_DISABLE_YYJSON");
println!("cargo:rerun-if-env-changed=ORJSON_ENABLE_FREETHREADING");
println!("cargo:rerun-if-env-changed=RUSTFLAGS");
println!("cargo:rustc-check-cfg=cfg(intrinsics)");
println!("cargo:rustc-check-cfg=cfg(optimize)");
Expand All @@ -31,6 +32,13 @@ fn main() {
println!("cargo:rustc-cfg=feature=\"setitem_knownhash\"");
}

let freethreading_env = env::var("ORJSON_ENABLE_FREETHREADING");
if (freethreading_env.is_ok() && freethreading_env.unwrap() == "1")
|| env::var("CARGO_FEATURE_FREETHREADING").is_ok()
{
println!("cargo:rustc-cfg=feature=\"freethreading\"");
}

if let Some(true) = version_check::supports_feature("core_intrinsics") {
println!("cargo:rustc-cfg=feature=\"intrinsics\"");
}
Expand Down
6 changes: 3 additions & 3 deletions ci/azure-win.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ steps:
UNSAFE_PYO3_SKIP_VERSION_CHECK: "1"
- script: python.exe -m pip install orjson --no-index --find-links=D:\a\1\s\target\wheels
displayName: install
- script: python.exe -m pytest -s -rxX -v test
- script: python.exe -m pytest -v test
env:
PYTHONMALLOC: "debug"
displayName: pytest
- script: python.exe integration\thread
displayName: thread
- script: python.exe integration\concurrent
displayName: concurrent
- script: python.exe integration\init
displayName: init
- bash: ./ci/deploy /d/a/1/s/target/wheels/*.whl
Expand Down
131 changes: 131 additions & 0 deletions integration/concurrent
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3

import asyncio
import multiprocessing
import random
import string
import sys
from concurrent import futures

import orjson

try:
IS_FREETHREADING = not sys._is_gil_enabled() # type: ignore
except Exception:
IS_FREETHREADING = False

CHARS = string.ascii_lowercase + string.ascii_uppercase

NUM_THREADS = min(multiprocessing.cpu_count(), 16)

MULTIPLIER = int(sys.argv[1]) if len(sys.argv) == 2 else 1


def random_string():
return "".join((random.choice(CHARS) for _ in range(0, 32)))


def per_thread_func(data):
serialized = orjson.dumps(data)
deserialized = orjson.loads(serialized)
assert deserialized == data


async def loads_test():
TEST_MESSAGE = f"concurrent serialization test running ..."

sys.stdout.write(TEST_MESSAGE)
sys.stdout.flush()

sys.stdout.write(f"\r{TEST_MESSAGE} creating tasks\n")

unique_items = 1000

keys_per_dictionary = 16

# must force key map cache eviction
assert keys_per_dictionary * unique_items > 5000

num = 250 * MULTIPLIER

data = [] * num
for _ in range(unique_items):
prefix = random_string()
data.append(
{
f"{prefix}_{i}": [True, False, None, "", "🐈", []]
for i in range(keys_per_dictionary)
}
)

tasks = []
for _ in range(num):
tasks.extend(
list(
(
asyncio.create_task(asyncio.to_thread(per_thread_func, each))
for each in data
)
)
)

sys.stdout.write(f"\r{TEST_MESSAGE} running {len(tasks):,} tasks\n")
await asyncio.gather(*tasks)

sys.stdout.write(f"\r{TEST_MESSAGE} ok\n")


async def list_mutation_test():
TEST_MESSAGE = f"concurrent list mutation test running ..."

sys.stdout.write(TEST_MESSAGE)
sys.stdout.flush()
num = 1000 * MULTIPLIER
fixture = [None] * num

tasks = []
for _ in range(num):
tasks.append(asyncio.create_task(asyncio.to_thread(orjson.dumps, fixture)))
tasks.append(asyncio.create_task(asyncio.to_thread(fixture.pop)))

await asyncio.gather(*tasks)

assert len(fixture) == 0

sys.stdout.write(f"\r{TEST_MESSAGE} ok\n")


async def dict_mutation_test():
TEST_MESSAGE = f"concurrent dict mutation test running ..."

sys.stdout.write(TEST_MESSAGE)
sys.stdout.flush()
num = 1000 * MULTIPLIER
fixture = {f"key_{i}": None for i in range(num)}

tasks = []
for i in reversed(range(num)):
tasks.append(asyncio.create_task(asyncio.to_thread(orjson.dumps, fixture)))
tasks.append(asyncio.create_task(asyncio.to_thread(fixture.pop, f"key_{i}")))

await asyncio.gather(*tasks)

assert len(fixture) == 0

sys.stdout.write(f"\r{TEST_MESSAGE} ok\n")


async def main():
asyncio.get_running_loop().set_default_executor(
futures.ThreadPoolExecutor(max_workers=NUM_THREADS)
)
sys.stdout.write(
f"concurrent tests running with free-threading {str(IS_FREETHREADING).lower()} on {NUM_THREADS} threads ...\n"
)

await list_mutation_test()
await dict_mutation_test()
await loads_test()


asyncio.run(main())
2 changes: 1 addition & 1 deletion integration/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
flask;sys_platform!="win"
gunicorn;sys_platform!="win"
httpx==0.24.1;sys_platform!="win"
httpx==0.27.2;sys_platform!="win"
6 changes: 3 additions & 3 deletions integration/run
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ set -eou pipefail

_dir="$(dirname "${BASH_SOURCE[0]}")"

to_run="${@:-thread http init}"
to_run="${@:-concurrent http init}"

export PYTHONMALLOC="debug"

if [[ $to_run == *"thread"* ]]; then
"${_dir}"/thread
if [[ $to_run == *"concurrent"* ]]; then
"${_dir}"/concurrent
fi

if [[ $to_run == *"http"* ]]; then
Expand Down
Loading

0 comments on commit 28e33b4

Please sign in to comment.